<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sang_yoon54.log</title>
        <link>https://velog.io/</link>
        <description>.</description>
        <lastBuildDate>Sun, 19 Apr 2026 15:23:56 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sang_yoon54.log</title>
            <url>https://velog.velcdn.com/images/sang_yoon54/profile/85f6bef4-601e-4688-8ada-c356158023de/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sang_yoon54.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sang_yoon54" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[1-1장] 쿠버네티스 시스템 아키텍처와 컴포넌트 상호작용]]></title>
            <link>https://velog.io/@sang_yoon54/1-1%EC%9E%A5-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9</link>
            <guid>https://velog.io/@sang_yoon54/1-1%EC%9E%A5-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9</guid>
            <pubDate>Sun, 19 Apr 2026 15:23:56 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가며">들어가며</h1>
<p>클러스터에 접속해 kubectl apply -f deployment.yaml을 실행하는 순간, 무슨일이 벌어질까? </p>
<p>먼저 현재 경로에 있는 YAML 파일이 클러스터로 전달되고, 잠시 후 Pod가 올라온다.
그 사이에 클러스터에서는 많은 일들이 벌어진다. 예를 들면, API 서버가 요청을 검증하고, 스케쥴러가 Pod를 할당할 노드를 고르고, kubelet이 실제로 노드를 띄우는 과정 등이 있다. 이를 여기서 다 말하지는 않을 것이고, 전체적으로 흐름을 잡고 이후에 하나하나 뜯어 보겠다.</p>
<h2 id="1-쿠버네티스의-설계-철학">1. 쿠버네티스의 설계 철학</h2>
<p>쿠버네티스에서 가장 중요한 개념은 <strong>선언적 시스템</strong>이라는 개념이다.</p>
<p>명령적 시스템은 &quot;컨테이너를 실행하라&quot;같은 명령으로 <strong>행위</strong>를 <strong>지시</strong>한다. 
반면, 쿠버네티스는 &quot;n개의 Pod가 항상 실행 중인 상태여야 한다&quot;라는 <strong>원하는 상태</strong>를 <strong>선언</strong>하면 시스템이 스스로 현재 상태를 보고 맞춰나간다.</p>
<p>여기서 현재 상태와 원하는 상태의 차이점을 좁히는 Loop가 목차중에 있었던 <strong>Reconciliation Loop</strong>이다. 쿠버네티스의 모든 컨트롤러는 이 Loop위에서 동작하며, 쿠버네티스는 이 설계 덕분에 일시적인 장애에 자연스럽게 대응할 수 있으며, 명시적으로 복구해라 라고 명령하지 않아도 컨트롤러가 지속적으로 상태를 감시하며 스스로 수렴한다.</p>
<h2 id="2-control-plain과-worker-node">2. Control Plain과 Worker Node</h2>
<p>쿠버네티스 클러스터는 크게 두 영역으로 나뉜다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/8e70e1ad-44cf-4949-b826-d4831b7e67c9/image.png" alt="">
출처: <a href="https://kubernetes.io/ko/docs/concepts/overview/components/">쿠버네티스 공식 document</a></p>
<p><strong>Control Plain</strong>은 클러스터의 두뇌이다. 워크로드를 어디에 배치할지 결정하고, 현재 상태를 추적하며, 원하는 상태로 수렴하도록 지시한다. 실제 컨테이너는 여기서 실행되지 않는다.</p>
<p><strong>Worker Node</strong>는 실제로 컨테이너가 돌아가는 실행 횐경이다. Control Plain의 지시를 받아 Pod를 생성하고, 상태를 계속 보고한다.</p>
<p>이는 단순한 역할 분리가 아니며, Control Plain장애가 곧바로 워크로드 중단으로 이어지지 않는다는 의미이기도 하다. 또한, Control Plain이 잠시 내려가도 새로운 스케쥴링이나 자가 복구가 실행되지는 않지만 이미 실행중인 Pod들은 계속 동작한다.</p>
<h2 id="3-control-plain의-컴포넌트">3. Control Plain의 컴포넌트</h2>
<p>위 사진에서 본 대로, Control Plain에는 여러개의 컴포넌트가 존재한다.
이 중 c-c-m은 Cloud Controller Manager의 약자로, 클라우드 제공자(CSP)의 시스템을 이용할때만 사용되므로 넘어가겠다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/faba389c-1ac0-4cf8-b029-8658ae70d98d/image.png" alt=""></p>
<ol>
<li><p>kube-apiserver(사진의 api)
Control Plain의 유일한 진입점이며, 모든 컴포넌트는 서로 통산하지 않고 반드시 api서버를 통해 상태를 읽고 쓴다. kubectl 명령어도, 스케쥴러도, 컨트롤러도 예외없이 반드시 api서버를 통해 대화한다.
요청이 들어오면 Authentication -&gt; Authorization -&gt; Admission Control 순서로 파이프라인을 통과하며 이 파이프라인의 내부 동작은 1-2장에서 자세히 다루겠다.</p>
</li>
<li><p>etcd
클러스터의 모든 상태가 저장되는 분산 Key-Value 구조를 가진 저장소다. API서버를 제외한 어떤 컴포넌트도 etcd에 직접 접근하지 않으며, &quot;지금 클러스터에 Pod가 몇 개 있는가&quot;와 &quot;이 Service의 ClusterIP는 무엇인가&quot;같이 클러스의 터에 대한 정보는 전부 etcd에 존재한다.
Raft Consensus Algorithm을 통해 다중 etcd 노드간 데이터 일관성을 보장하며, 이는 1-3장에서 자세히 다루겠다.</p>
</li>
<li><p>kube-scheduler(사진의 sched)
새로 생성된 Pod가 어느 노드로 배치될지 선택한다. 직접 Pod를 실행하지는 않으며, 어느 노드로 배치할지 결정하고 API서버로 이 노드로 배치하겠다고 기록하는 것이 스케쥴러의 역할이다.
어떤 노드로 배치할지 결정하는 일고리즘은 2-1장에서 자세히 다루겠다.</p>
</li>
<li><p>kube-controller-manager(사진의 c-m)
다양한 컨트롤러들을 하나의 프로세스로 묶어 실행하는 관리자이며, Deployment 컨트롤러, ReplicaSet 컨트롤러, Node 컨트롤러 등이 모두 여기에 속한다. 각 컨트롤러는 자신이 담당하는 리소스의 상태를 지속적으로 감시하며 Reconciliation Loop를 돌리며 이는 2-2장에서 자세히 다루겠다.</p>
</li>
</ol>
<h2 id="4-worker-node의-컴포넌트">4. Worker Node의 컴포넌트</h2>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/3eadf4a8-2614-46b1-a5ae-7e810f0c838b/image.png" alt=""></p>
<ol>
<li><p>kubelet
노드 위의 에이전트이다. API서버로부터 &quot;이 노드 위에 이 Pod를 실행하라&quot;는 명령을 받아, 실제로 컨테이너를 생성하고 상태를 보고한다. 컨테이너 런타임과 CRI gRPC 규격으로 통신하는데 이 부분은 3-1장에서 자세히 다루겠다.</p>
</li>
<li><p>kube-proxy
각 노드에서 Service 추상화를 구현하는 네트워크 프록시이며, Service IP(Cluster IP)로 들어오는 트래픽을 실제 Pod IP로 라우팅하는 규칙을 관리한다. iptables 또는 IPVS모드로 동작하며, 이 차이는 3-2장에서 자세히 다루겠다.</p>
</li>
<li><p>Container Runtime
사진에는 없지만, 실제로 컨테이너 이미지를 받아 컨테이너를 실행하는 소프트웨어이다. containerd가 현재 사실상 표준으로 사용되고 있으며, kubelet과 CRI 규격으로 대화한다.</p>
</li>
</ol>
<h2 id="5-kubectl-apply-동작-과정">5. kubectl apply 동작 과정</h2>
<p>그럼 이제 각 컴포넌트에 대해 간단하게 알아봤으니, kubectl apply 명령어가 실행되었을 때 어떤 흐름이 이뤄지는지 간단하게 알아보자.</p>
<ol>
<li><p>요청 진입
kubectl apply가 실행되면 YAML이 HTTP요청으로 변환되어 API서버에 전달된다. API서버는 Authentication -&gt; Authorization -&gt; Admission Control 파이프라인을 거쳐 요청을 검증하고, 통과하면 Deployment 오브젝트를 etcd에 저장한다.</p>
</li>
<li><p>컨트롤러 감지
Deployment 컨트롤러가 새 Deployment를 감지한다. 원하는 상태와 현재 상태의 차이를 인지하고, ReplicaSet을 생성한다. ReplicaSet 컨트롤러는 다시 이를 감지해 Pod 오브젝트를 생성한다. 이 시점에서의 Pod는 아직 Pending 상태로, 어떤 노드에도 배치되지 않은 상태이다.</p>
</li>
<li><p>스케쥴링
스케쥴러가 nodeName이 비어있는 Pod를 감지한다. 각 노드의 리소스 상황, taint/Toleration/afinity 규칙 등을 고려해 최적의 노드를 선택하고, API서버를 통해 Pod의 nodeName 필드를 업데이트한다.</p>
</li>
<li><p>실행
해당 노드의 kubelet이 자신에게 배정된 Pod를 감지한다. 컨테이너 런타임에 CRI 규격으로 컨테이너 생성을 요청하고, 이미지를 pull받아 컨테이너를 실행하면 Pod가 정상적으로 동작한다. 이후, kubelet은 주기적으로 Pod 상태를 API서버에 보고한다.</p>
</li>
</ol>
<p>간략하게는 이러한 흐름을 따라 명령이 실행된다.</p>
<h2 id="핵심-설계-원칙">핵심 설계 원칙</h2>
<p>이때까지 읽었다면, 이 아키텍처에서 반복적으로 등장하는 원칙을 눈치챘을 수 있다.</p>
<ol>
<li>모든 상태는 하나의 저장소에서 관리한다.
모든 상태는 etcd에서 관리한다. 컴포넌트들은 직접 서로의 상태를 묻지 않는다. API서버를 통해 etcd를 읽고 쓰며, 이 구조 덕분에 어떤 컴포넌트가 재시작되어도 etcd에서 상태를 복원할 수 있다.</li>
<li>느슨한 결합도
모든 컴포넌트들은 서로를 직접 호출하지 않으며 API서버를 통해 소통한다. 이러한 간접적인 통신 방식이 각 컴포넌트를 독립적으로 확장하고 교체할 수 있게 한다.</li>
<li>레벨 트리거 방식
쿠버네티스 컨트롤러는 이벤트 하나하나를 처리하는 엣지 트리거 방식이 아니라, 현재 상태 자체를 지속적으로 방식으로 동작한다. 이벤트를 놓쳐도 다음 Reconciliation 루프에서 올바른 상태로 수렴하게 되며, 이는 재시작이나 네트워크 단절에 자연스럽게 내성을 갖는 이유다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠버네티스 내부 구조에 대해 알아보자!]]></title>
            <link>https://velog.io/@sang_yoon54/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@sang_yoon54/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sun, 19 Apr 2026 03:47:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/98d77f5b-d39d-439e-aa02-2f9175cbc26d/image.png" alt=""></p>
<p>이번 시리즈에서는 쿠버네티스의 내부 구조를 자세하게 알아보겠다.</p>
<h2 id="목차">목차</h2>
<ol>
<li>쿠버네티스 시스템 아키텍쳐와 제어 평면
1-1. <a href="https://velog.io/@sang_yoon54/1-1%EC%9E%A5-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9">쿠버네티스의 시스템 아키텍처와 컴포넌트들의 상호작용</a>
1-2. kube-apiserver의 요청 처리 파이프라인
1-3. etcd와 데이터 일관성 모델</li>
<li>오케스트레이션과 리소스 격리
2-1. kube-scheduler의 선정 알고리즘
2-2. Controller Manager과 Reconciliation Loop
2-3. 리눅스 커널을 기반으로 한 리소스 격리 (Cgroup &amp; Namespace)</li>
<li>노드 실행 환경과 네트워킹 (The Node &amp; Connectivity)
3-1. kubelet과 Pod 생명주기
3-2. kube-proxy와 서비스 추상화
3-3. CNI 원리와 데이터 평면 가속화</li>
<li>운영 안정성과 데이터
4-1. 클러스터의 Obsercability와 Storage
4-2. CSI(Container Storage Interface)와 볼륨 프로비저닝</li>
<li>보안과 확장성
5-1. 보안 아키텍처와 입장 제어
5-2. Operator 패턴과 Custom Controller 구현</li>
</ol>
<p>이제 본격적으로 들어가기 전에, 이 시리즈를 집필하는 이유에 대해 언급하고 가겠다.</p>
<h3 id="왜-내부-구조인가">왜 내부 구조인가?</h3>
<p>얼마 전, bare-metal 클러스터에 Cilium을 올리다가 worker 노드의 cilium-agent가 기동 직후 계속 죽는 상황을 겪었다. 로그엔 SIGTERM이 찍혀 있었고, kubelet이 외부에서 프로세스를 종료하고 있다는 걸 알기까지 꽤 시간이 걸렸다. kubectl describe만 봐서는 알 수 없었고, 결국 /var/log/pods 아래를 직접 파서 kubelet의 동작 방식을 이해하고 나서야 원인을 추적할 수 있었다.</p>
<p>나는 쿠버네티스라는 툴을 사용할 줄만 알지 내부 구조를 제대로 모른다는 사실을 깨달았고, 나중에 운영을 할 때도 최적화를 할 수 없을것이라는 결론을 내렸다.</p>
<p>이런 이유로 내부 구조에 대한 시리즈를 집필하게 되었다.</p>
<p>쿠버네티스는 사실상 현대 인프라의 표준이 되었지만, 나를 포함한 일부의 개발자들은 kubectl로 리소스를 던지면 알아서 돌아가는 툴 정도로 알고 있는 사람들이 있을 것이다. 물론 추상화라는게 내부 구조를 몰라도 사용할 수 있게 하기 위해서 존재한다지만, 장애 상황에서는 그러한 추상화가 오히려 독이 되기도 한다.</p>
<ul>
<li>왜 이 Pod가 스케쥴링되지 않지?</li>
<li>왜 API 서버 응답이 이 타이밍에 느려지는거지?</li>
<li>네트워크 패킷이 어떤 경로를 거쳐 목적지에 닿지?</li>
</ul>
<p>이러한 질문들에 답하려면 쿠버네티스의 내부 원리를 알고 있어야 하며, 그저 작성한 YAML만 알고 있어서는 안된다.</p>
<h3 id="이-시리즈의-방향성">이 시리즈의 방향성</h3>
<p>Control Plane 컴포넌트들이 서로 어떻게 통신하는지, kubelet이 컨테이너를 실제로 어떻게 만들어내는지, 리눅스 커널이 프로세스를 어떻게 격리하는지, 그리고 네트워크 패킷이 Pod에서 Pod으로 이동하는 실제 경로를. 가장 거시적인 아키텍처에서 시작해서, 회차를 거듭할수록 커널 수준까지 내려갈 것이다.</p>
<p>서론은 여기까지 마치고, 이제 여정을 시작해 보자. 끝을 볼 수 있으면 좋겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[윈도우 멀티부팅 설정 및 RHEL기반 쿠버 구축기 - (1)]]></title>
            <link>https://velog.io/@sang_yoon54/%EC%9C%88%EB%8F%84%EC%9A%B0-%EB%A9%80%ED%8B%B0%EB%B6%80%ED%8C%85-%EC%84%A4%EC%A0%95-%EB%B0%8F-RHEL%EA%B8%B0%EB%B0%98-%EC%BF%A0%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-1</link>
            <guid>https://velog.io/@sang_yoon54/%EC%9C%88%EB%8F%84%EC%9A%B0-%EB%A9%80%ED%8B%B0%EB%B6%80%ED%8C%85-%EC%84%A4%EC%A0%95-%EB%B0%8F-RHEL%EA%B8%B0%EB%B0%98-%EC%BF%A0%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-1</guid>
            <pubDate>Thu, 16 Apr 2026 20:02:05 GMT</pubDate>
            <description><![CDATA[<p>윈도우로 쓰고있던 데스크탑과 노트북을 활용해서 서버를 구축해볼텐데, 들어가기 전에 스펙을 먼저 언급하고 들어가겠다.</p>
<ol>
<li><p>Control Plane &amp; Worker 1 (Desktop)
Spec: 4 Core CPU / 16GB RAM
Storage: 400GB HDD (RHEL 할당 영역)</p>
</li>
<li><p>Worker 2 (Laptop)
Spec: 4 Core CPU / 12GB RAM
Storage: 150GB HDD (RHEL 할당 영역)</p>
</li>
</ol>
<p>참고로 멀티부트란 하나의 컴퓨터 하드웨어에 두 개 이상의 운영체제를 설치하고, 부팅 시점에 사용자가 원하는 운영체제를 선택하여 실행하는 구성 방식을 의미한다.</p>
<p>VM을 사용하지 않고 굳이 이 방식을 사용하는 이유는 PC들의 사양이 충분하지 않아 최대한 이용하기 위해서이다.</p>
<p>그럼 이제 그럼 이제 멀티부트 설정법을 알아보자.
먼저, 윈도우에서 RHEL에 할당할 디스크 볼륨을 나눠야 한다.</p>
<p>Win + X를 눌러 디스크 관리 창으로 들어가자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/46962b5f-d471-4ef0-8139-1aa692865132/image.png" alt=""></p>
<p>디스크 구성은 개인마다 다를것이며, 나는 여유가 많은 D드라이브를 선택하였다. 처음에는 전부 주 파티션(파란색)으로 채워져 있을 것이다.</p>
<p>선택한 디스크 위에서 우클릭후 볼륨 축소를 누르면 입력창이 생성되며, 여기에 다른 OS에 할당할 디스크 크기를 입력한다.</p>
<p>기준은 MB이므로, 409600을 입력해주었다.</p>
<p>그 후 축소를 누르면 진행되며, 이는 사용하는 디스크의 종류에 따라 시간 차이가 많이 생길 것이다. SSD는 보통 수 분 이내에 완료 되나, HDD의 경우는 물리적인 헤드가 움직이면서 손수 흩어져 있는 데이터를 긁어모아 공간을 확보해야 하므로 시간이 더욱 걸린다. 내 경우에는 20분 넘게 걸린것 같다.
혹시나 응답 없음같은 문구가 출력되더라도, 시간이 너무 오래 걸리는 것 같아도 참고 기다리자. 강제종료를 하는 순간 디스크가 깨질수도 있다.</p>
<p>이제 RHEL 설치를 위해 전용 USB를 만들어야 한다.
빈 USB를 하나 준비 후, OS의 iso파일을 USB에 구워야 한다.</p>
<p>이를 위해서, <a href="https://rufus.ie/ko/">Rufus</a>라는 프로그램을 사용할 것이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/85a41ffb-97e6-4618-b133-286669d44646/image.png" alt="">
상관 없는 이야기이긴 하다만, 멋있다..</p>
<p>사이트 접속 후 본인의 운영체제에 맞는 프로그램을 받아주자.</p>
<p>실행 후 다음 창이 뜬다. 각 항목에 대해 알아보자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/b1172c6a-7067-4395-9961-857bda8173df/image.png" alt=""></p>
<p>장치 칸에는 USB명, 부팅 선택 칸에는 iso이미지가 들어간다. 우측의 선택을 누르면 파일 시스템에서 선택이 가능하다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/c464ffca-9356-4a79-9bfc-40629db239e2/image.png" alt=""></p>
<p>다음은 영구 파티션이다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/0c39e100-c44e-45c5-8eb7-a50b34e9613a/image.png" alt="">
이는 USB를 OS설치용이 아닌 컴퓨터 자체를 들고다니고 싶을 때 설정하는 용도로, 이를 설정해놓으면 설치 후 그 컴퓨터에서 작업한 내용이 USB의 특정 구역에 저장된다.
나는 설치용으로만 사용할 예정이므로 0으로 설정하겠다.</p>
<p>파티션 구성과 대상 시스템이다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/0cf60b68-b6ce-4478-b1f3-132b5886023b/image.png" alt="">
파티션 구성은 MBR과 GPT가 있으며, 각 특징은 다음과 같다.</p>
<ul>
<li><p>MBR (Master Boot Record)
1980년대부터 쓰인 오래된 방식이며, 주 파티션을 최대 4개까지만 만들 수 있고, 2TB 이상의 디스크를 인식하지 못하고, 아주 오래된 컴퓨터(BIOS 방식)에서 사용한다.</p>
</li>
<li><p>GPT (GUID Partition Table)
현재 표준으로 쓰이는 최신 방식이며, 파티션을 거의 무제한(기본 128개)으로 나눌 수 있고, 2TB가 넘는 대용량 디스크도 지원한다. 또한, 지도가 디스크 여러 곳에 저장되어 있어 하나가 깨져도 복구가 가능하다는 특징이 있다.</p>
</li>
</ul>
<p>다음 대상 시스템은 BIOS 또는 UEFI 하나만 존재한다. 공용인듯 하다.</p>
<ul>
<li><p>BIOS (또는 CSM)
전통적인 텍스트 기반의 부팅 제어 방식이다.</p>
</li>
<li><p>UEFI (Unified Extensible Firmware Interface)
마우스 사용이 가능하고 보안 부팅을 지원하는 현대적인 부팅 방식이다.</p>
</li>
</ul>
<p>GPT + BIOS 또는 UEFI로 하고 넘어가자.</p>
<p>고급 속성들은 다룰 필요 없으므로 넘어가겠다.</p>
<p>다음은 포멧 옵션이다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/0d90c255-7733-4c91-ad16-a3bb7f5d4fff/image.png" alt=""></p>
<p>먼저 볼륨 레이블은 USB 드라이브의 논리적 명칭이며, 파일 시스템의 메타데이터 영역(정확히는 루트 디렉토리의 Volume ID 필드)에 저장되는 문자열이다.</p>
<p>다음 파일 시스템은 FAT32로 설정되어 있고, 이는 Rufus가 자동으로 추천해 준 시스템이다. 파일 시스템까지 여기서 설명하긴 너무 기니, 넘어가겠다.</p>
<p>클러스터 크기는 디스크에서 데이터를 저장하는 최소 크기를 의미하며, 블록 크기와 개념이 비슷하다. </p>
<p>이제 대충 다 설명했으니 시작 버튼을 누르자.
다음 ISO 모드와 DD모드가 있는데 ISO 모드를 선택하면 된다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/84070cd6-1358-43a7-a406-1c507b919919/image.png" alt="">
다음과 같이 작업중인것을 알 수 있다.</p>
<p>그 후 UEFI 모드로 들어가서 설정을 해 줘야하는데, Secure Boot 모드를 Disable로 설정하고, OS Type 또는 Boot Mode Selection라고 있을텐데 그걸 기타 OS로, 부팅 순서에서 리눅스 부트로더(GRUB)를 위로 올려주었다.</p>
<p>이제 OS를 설치하러 가자.</p>
<p>설치 과정에서 많은 문제가 발생하였다.. </p>
<p>내 경우는 RHEL 디스크 지정 후 설치 과정에서 에러가 발생하였는데, 그러면 처음부터 다시 실행해야 한다. 그렇게 되면 이미 디스크가 이렇게 할당이 되어있는데, 이들을 전부 삭제해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/cd8916fc-7ff8-473f-bf38-dd8fd84cee58/image.png" alt=""></p>
<p>똑같이 우클릭 후 볼륨 삭제를 눌러준다. 끝나면 다음과 같이 검정색으로 할당되지 않음으로 묶일 것이고, 다시 설치할 수 있게 된다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/eac1354c-1b34-4500-8c16-bbdc0b893ad2/image.png" alt="">
다시 하러가자..</p>
<p><strong>An error occured during the transaction: Error in POSTTRANS scriptletin rpm package grub2-common</strong>
라는 에러가 발생하였다. rpm 패키지 설치 중 발생한 오류같은데 검색을 해봐도 9.2버전에서 UFEI 시간 설정 오류로 해당 에러가 발생하였다고 한다. </p>
<p>그래서 체크섬 검사도 해 봤는데, 일치하였다.</p>
<p>일단 디스크 초기화 후, UEFI 시간을 확인하고 다시 설치해보자.</p>
<p>그런데!! 여전히 같은 에러가 발생했다.</p>
<p>Ctrl + Alt + F2를 누르면 쉘 창으로 나갈 수 있는데, 거기서 로그 파일을 확인해 보았다.</p>
<pre><code>error level 3

ERROR: dnf : no group base-grachical from environment grapical-server-environment

DEBUG: anaconda.modules.payload.dnf.validation: Resolving has been conpleted: ValidationReport(error_messages=[]. warning_messages=[])

warning:%posttrans(grub2-common-1:2.12-29.el10_1.noarch)scriptlet failed, exit status 1

ERROR: dnf.rpm: Error in POSTTRANS scriptlet in rpm package grub2-common

INFO: anaconda.core.threads: Thread Failed: AnaTaskThread-InstallPackagesTask-1 (140241612650176)

ERROR: anaconda.modules.common.task.task: Thread Failed: AnaTaskThread-InstallPackagesTask-1 has failed: TraceBack (most recent call last):

raise PayloadInstallationError(&quot;An Error occurred during the transaction: &quot; + msg)

pyanaconda.modules.common.errors.installation.PayloadInstallationError: An error accurred during the transaction: Error in POSTTRANS script in rpm package grub2-common

WARNING: dasbus.server.handler: The call org.fedoraproject.Anaconda.Task.Finish has failed with an exception:

thread_manager,raise_if_error(self._thread_name)

File &quot;usr/lib64/python3.12/site-packages/pyanaconda/core/threads.py&quot;, line 172, in raise_if_error

raise PayloadInstallationError(&quot;An error occurred during the transaction: &quot; msg)</code></pre><p>이런 에러 때문에 위 문제가 발생하였는데, 대충 에러 내용을 설명하자면 dnf(패키지 관리자)가 설치 소스에서 그래픽 서버 환경에 필요한 패키지 묶음을 찾지 못했고, 패키지 설치의 마지막 단계(POSTTRANS)에서 GRUB2 부트로더 설정을 하게 된다.</p>
<p>이 과정에서 grub2-mkconfig 명령이 실행되며, 내부적으로 os-prober 유틸리티를 호출하는데 이 유틸리티가 하는 일이 문제가 된 것이다.</p>
<p>os-prober는 시스템의 모든 블록 장치를 탐색하여 멀티 부팅 메뉴 구성을 위한 메타데이터를 수집하는데, 여기 아까 디스크 구성에서 보았던 &quot;새 볼륨 (D:)&quot; 파티션 라벨이 문제였다.</p>
<p>이에 대한 로그는 /tmp/packaging.log에 적혀 있었는데, </p>
<pre><code>INFO:dnfrpm:GRUB~깨져서 안보임

/usr/share/grub/grub-mkconfig_lib:(깨짐) 343(깨짐) : printf: &#39;$&#39; (깨짐)

warning: %posttrans(grub2-common-1:2.12-29.eli0_1.noarch) scriptlet failes, exit status 1</code></pre><p>대충 이런 로그였다.</p>
<p>제미나이의 설명을 들어보니, grub-mkconfig가 디스크 스캔 중 변수를 출력하다 생긴 오류같다고 해서, 윈도우로 다시 접속해 볼륨 명을 D:로 바꾸니 설치가 정상적으로 되었다. </p>
<p>문제 해결 과정에서 하드웨어 문제인가 싶어 efibootmgr 명령어로 NVRAM에 부팅 항목이 정상적으로 등록되는지 테스트도 해보고, dvd 이미지인데도 설마 패키지를 서버에서 가져오나 해서 레드햇 계정 등록도 해보고, 이미지를 굽는 방식(dd)도 바꿔보고, 이미지 체크섬 검사도 해보고, 이미지 버전도 boot 버전으로 바꿔 보는 등 여러 시도를 해봤었는데, 정작 문제는 문자열 파싱 오류였다니 조금 허무했다.</p>
<p>앞으로는 표면적인 로그만 보고 추측하기 보다는 결정적인 로그를 가장 먼저 찾아야겠다.</p>
<p>다음 포스팅에서는 RHEL에서 쿠버네티스 서버를 설치하는 과정을 다루겠다. SELinux 때문에 꽤나 골치아플 예정일 것 같지만, 할건 해야지 ..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NCP - Clova Speech, Object Storage 설정 + OpenAI 연동]]></title>
            <link>https://velog.io/@sang_yoon54/NCP-Clova-Speech-Object-Storage-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@sang_yoon54/NCP-Clova-Speech-Object-Storage-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 07 Apr 2026 22:24:22 GMT</pubDate>
            <description><![CDATA[<p>진행했던 프로젝트가 있었는데, 사용하던 서버 대여기간이 끝나서 종료되었었다. 이 프로젝트를 다시 살려보고자 한다.</p>
<p>팀원이 NCP 연동을 해 뒀었기 때문에, 이를 다시 내 계정으로 연동하면서 과정을 기록하려 한다.</p>
<p>사용할 서비스는 위 두개의 서비스이며, 가장 먼저 NCP에서 무료로 제공해주는 크레딧을 사용하려 한다.</p>
<p>NCP에 회원가입을 한 후, <a href="https://www.ncloud.com/v2/main/creditEvent?from=creditevent">이 경로</a>에서 신청을 하면 바로 크레딧이 지급된다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/fd37b6e6-4730-488b-8d3d-ccb6f6b747b1/image.png" alt=""></p>
<p>유의사항은 다음과 같으므로 잘 읽어보고 사용하도록 하자. </p>
<p>이제 계정의 api키를 발급해 프로젝트에 연결해야한다.</p>
<p>콘솔에 진입 후, 우상단에 있는 이름을 클릭해 계정 및 보안관리 -&gt; 보안 관리 -&gt; 접근 관리 탭으로 이동하자. 그럼 아래 화면과 같이 api 키 생성 탭이 보일 것이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/7f4ea123-f304-4caa-8cb2-a6c9724a02e9/image.png" alt="">
그러면 Access Key ID, Secret Key, 생성일자와 상태 등이 보일텐데 Secret Key는 유출되지 않도록 주의하자. AWS처럼 처음 한번만 보여주는건 아닌듯 하니, 따로 다른데 적어놓을 필요는 없을 듯 하다.</p>
<p>이 키를 만들었다면, 이제 사용할 Storage를 만들러 가자.</p>
<p>좌상단 메뉴 -&gt; Storage -&gt; Object Storage로 이동한다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/41decac5-08c7-4f6c-a1a5-e65bcdd36456/image.png" alt="">
이용 신청을 눌러주자.</p>
<p>그 후, 좌상단 메뉴 밑에 탭이 있는데 거기서 Bucket Management로 이동 후 버킷을 생성한다. 이름은 ttt-bucket으로 하겠다. 그러면 설정 관리와 권한 관리를 설정하는데, 암호화 관리만 켜주고 기본 값을 따른다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/450ca81d-b25b-407f-92c7-1d6bab642fd9/image.png" alt="">
그러면 이렇게 버킷이 생성된걸 볼 수 있고, 아직 뭐가 올라가있지는 않다.</p>
<p>이제 마지막으로 speech 서비스를 신청하러 가자.
메뉴 -&gt; AI Service -&gt; Clava Speech 이동후 신청한다.
신청을 완료했으면 도메인을 생성해야 하는데, 틱택톡은 대화 내용을 바탕으로 서비스를 제공하므로 장문 인식 도메인을 선택한다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/01882ab5-1cf3-45bf-94aa-eac1d25aa5f1/image.png" alt=""></p>
<p>그러면 다음 설정창이 나오고, 이름과 코드를 입력하자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/d6a1392b-fd6c-4fb2-b3fd-61deb0987473/image.png" alt="">
서비스 플랜은 Free로 했을 시 계정당 1개만 사용 가능하며, 20분의 사용량 제한에 걸린다. Basic은 사용량에 따라 과금이 발생하니 사용에 주의하자.</p>
<p>추가 기능에서 이벤트 탐지는 박수, 음악 등 음성인식 내에서 발생하는 이벤트를 탐지하는 기능이다.
그리고 인식 대상과 결과 파일은 아까 생성한 버킷으로 하겠다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/0dc6f013-b8e5-4ab1-b73d-e2dbb11baa3b/image.png" alt="">
그러면 다음과 같이 도메인이 생성되었을 텐데, 이제 사용할 키와 url을 받기 위해 빌더 실행을 눌러 들어가주자.</p>
<p>좌측 탭에 설정을 누르면 시크릿 키와 Invoke URL, gRPC URL을 받을 수 있다.</p>
<p>이제 OpenAI Key도 한번 만들어보자.
먼저 <a href="https://platform.openai.com/">OpenAI Platform</a>에 접속한다.
로그인 하고, 바로 앞의 Create API Keys를 누른 후 Create Secret Key로 만들 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/a1b52307-ac2b-4055-a146-1864f45cf035/image.png" alt="">
<img src="https://velog.velcdn.com/images/sang_yoon54/post/d2218986-9872-49fe-92a8-d3b6ce7088cd/image.png" alt=""></p>
<p>이렇게 만든 OpenAI Secret Key는 다시 볼 수 없으니 따로 보관해야한다.</p>
<p>이제 프로젝트에 연동해서 사용해보자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium이란?]]></title>
            <link>https://velog.io/@sang_yoon54/Cilium%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@sang_yoon54/Cilium%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Fri, 03 Apr 2026 10:32:56 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/a977944c-d97e-42aa-8c07-caa0272a6931/image.png" alt=""></p>
<p>Cilium은 eBPF 기술을 활용하여 쿠버네티스 같은 클라우드 네이티브 환경에서 고성능 네트워킹, 강력한 보안 정책, 그리고 정밀한 가시성을 통합적으로 제공하는 오프소스 솔루션이며, kube-proxy를 대신하여 서비스 메시로 많이 사용된다. 가장 먼저 들어가기 전에, 왜 kube-proxy가 한계를 가지는지 알아보자.</p>
<h3 id="kubernetes-서비스-가용성">Kubernetes 서비스 가용성</h3>
<p>Kubernetes 환경에서 Service는 동적으로 변하는 Pod들의 IP를 추상화하여 안정적인 엔드포인트를 제공하는 핵심 객체이다. 하지만 클러스터의 규모가 커지고, 마이크로서비스의 개수가 수백, 수천개로 늘어나면 우리가 당연하게 여겼던 서비스 통신은 무시할 수 없는 오버헤드를 가져오게 된다.</p>
<p>이는 오랜 기간 쿠버네티스의 기본 패킷 전달 모델로 사용되어 온 kube-proxy와 iptables의 특성 때문이다.</p>
<h3 id="그럼-무슨-특성을-가지고-있길래">그럼 무슨 특성을 가지고 있길래?</h3>
<p>kube-proxy의 iptables모드는 리눅스 커널의 방화벽 Netfilter를 활용한다. 특정 서비스로 향하는 트래픽을 실제 파드의 IP로 변환(NAT)하기 위해, kube-proxy는 커널 내 iptables 규칙 테이블을 지속적으로 업데이트한다.</p>
<p>여기서 핵심은 iptables가 패킷을 처리하는 방식인데, iptables는 규칙(Rule)들을 하나의 긴 연결 리스트 형태로 관리하기 때문이다. 이 형태는 패킷이 노드에 도착하면 커널은 해당 패킷의 목적지에 맞는 규칙을 찾을 때까지 리스트의 최상단부터 최하단까지 순차적으로 대조 작업을 수행할 수 밖에 없다.</p>
<p>이러한 순차적 평가 방식은 알고리즘 관점에서 $O(n)$의 시간 복잡도를 가지며, 여기서 $n$은 클러스터 내의 서비스 및 엔드포인트의 총 개수를 의미한다. 다음은 순차 대조 방식이 가져오는 문제점들이다.</p>
<ol>
<li><p>지연 시간: 서비스 개수가 적을 때는 대조 과정이 매우 빠르게 수행되어 차이를 느끼기 어렵다. 하지만 서비스가 늘어날수록 패킷 하나마다 대조해야 하는 Rule이 많아지며, 특히 리스트 하단에 위치한 서비스 규칙에 매칭되어야 하는 패킷은 더 큰 지연 시간을 겪게 된다.</p>
</li>
<li><p>CPU 부하: 모든 패킷에 대해 이 선형 탐색을 반복해야 하므로, 네트워크 트래픽이 높은 상황에서 서비스 개수까지 많아지면 커널의 CPU 사용량이 급격히 상승하고, 이는 곧 애플리케이션이 사용할 자원을 네트워크 오버헤드가 점유하게 된다.</p>
</li>
<li><p>업데이트 성능 저하: 서비스가 추가되거나 파드가 재시작될 때마다 수천 개의 iptables 규칙 전체를 다시 계산하고 커널에 적용해야 한다. iptables 방식은 증분 업데이트가 아니라 전체 교체 방식이기 때문이며, 이 또한 클러스터 규모가 커질수록 지연이 길어진다.</p>
</li>
</ol>
<h3 id="그럼-ebpf는-뭐고-어떤게-좋냐">그럼 eBPF는 뭐고 어떤게 좋냐?</h3>
<p>eBPF(extended Berkeley Packet Filter)는 리눅스 커널의 소스 코드를 수정하거나 별도의 커널 모듈을 로드하지 않고도 커널 내에서 안전하게 사용자 정의 프로그램을 실행할 수 있게 하는 기술이다. 커널 이벤트에 훅(Hook)을 걸어 실시간으로 패킷을 처리하거나 데이터를 수집할 수 있다.</p>
<p>eBPF는 이름만 봤을 때 패킷 필터의 역할을 할 것 같지만 세월이 흐르면서 역할이 많이 다양해졌고, 이제 이름은 거의 의미가 없다. eBPF에 대해 더 알고싶다면 <a href="https://ebpf.io/ko-kr/what-is-ebpf/#%EC%99%9C-ebpf%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%82%98%EC%9A%94">이 페이지</a>를 참고하자.</p>
<p>Cilium은 eBPF를 활용하여 쿠버네티스의 네트워킹, 보안, 로드밸런싱을 재정의한다. 기존의 iptables가 Netfilter라는 고정된 프레임워크 안에서만 동작했다면, Cilium은 eBPF를 통해 커널 네트워크 스택의 가장 깊은 곳에서 직접 패킷 경로를 제어한다.</p>
<p>Cilium은 Hash Table 기반으로 $O(1)$의 시간복잡도를 달성하는데, 그 원리를 알아보자.</p>
<p>Cilium이 iptables의 선형 탐색 비효율을 극복하는 핵심은 BPF Maps를 이용한 해시 테이블 룩업 방식이다. 여기서 BPF Maps는 커널 공간에서 실행되는 eBPF 프로그램이 데이터를 저장 또는 공유하기 위해 사용하는 커널 내 키-값 저장소이다.</p>
<p>이러한 방식은 다음 장점을 가진다.</p>
<ol>
<li><p>데이터 구조: 리스트 형태의 규칙 대신, Cilium은 서비스의 가상 IP를 키로 하고 목적지 파드의 정보를 값으로 하는 해시 맵을 커널 메모리에 유지한다. 이 형태를 가짐으로써 해시연산 한번에 바로 목적지를 찾을 수 있게 된다.</p>
</li>
<li><p>컨텍스트 스위칭 최소화: eBPF는 커널 공간 내에서 실행되므로, 패킷 처리를 위해 사용자 공간과 커널 공간을 오가는 오버헤드를 최소화하며 데이터 플레인 레벨에서 즉각적인 포워딩을 수행한다.</p>
</li>
<li><p>eBPF-based Load Balancing: 서비스로 들어오는 트래픽에 대해 커널 내부에서 eBPF 프로그램이 직접 로드밸런싱을 수행한다. 이는 Netfilter의 복잡한 체인을 거치지 않고 패킷의 헤더를 수정하여 백엔드 파드로 직접 전달하도록 한다.</p>
</li>
</ol>
<h2 id="hubble이란">Hubble이란?</h2>
<p>그럼 이제 Cilium 프로젝트의 일부로 개발된 네트워크 가시성 및 보안 분석 도구 Hubble에 대해 알아보자.</p>
<p>마이크로서비스 아키텍처가 복잡해질수록 서비스 간 통신 흐름을 파악하는 것은 매우 어려워지며, 기존에 네트워크 문제를 진단하기 위해 주로 사용되던 방식들은 다음과 같은 단점이 있다.</p>
<ol>
<li><p>성능 부하
tcpdump와 같은 도구는 패킷을 캡처하기 위해 커널 공간에서 사용자 공간으로 대량의 데이터를 복사해야 하며, 트래픽이 몰리는 운영 환경에서 이를 실행하는 것은 시스템 전체의 성능 저하를 유발할 수 있다.</p>
</li>
<li><p>데이터의 파편화
여러 노드에 흩어져 있는 로그를 수집하여 하나의 흐름으로 재구성하는 과정이 복잡하며, 특정 서비스에서 발생한 패킷 드랍이 네트워크 계층의 문제인지 애플리케이션의 설정 문제인지 즉각적으로 판별하기 어렵다.</p>
</li>
</ol>
<p>하지만 Hubble은 Cilium 위에서 동작하며, eBPF를 활용해 커널 수준에서 네트워크 트래픽과 보안 정책의 흐름을 관찰하기 때문에 다음 장점을 가진다.</p>
<ol>
<li><p>데이터를 커널 내에서 직접 수집한다.
패킷이 커널 네트워크 스택을 통과할 때 eBPF 프로그램을 통해 필요한 메타데이터만 추출하며, 데이터를 사용자 공간으로 복사하는 과정을 최소화하므로 매우 낮은 부하로 실시간 모니터링이 가능해진다.</p>
</li>
<li><p>쿠버네티스 컨텍스트 통합
단순한 IP 정보가 아닌, 파드 라벨(Labels), 서비스 이름, 네임스페이스 등 쿠버네티스의 객체 정보를 기반으로 트래픽을 식별하며, 이는 트러블 슈팅시 데이터 가독성을 높여준다.</p>
</li>
</ol>
<h3 id="hubble을-통해-볼-수-있는-가시성-기능">Hubble을 통해 볼 수 있는 가시성 기능</h3>
<ol>
<li><p>서비스 간 종속성 맵
서비스들이 서로 어떻게 통신하고 있는지 그래픽으로 시각화해주며 어떤 서비스가 병목의 원인인지, 혹은 예상치 못한 외부 도메인과 통신하고 있지는 않은지 한눈에 확인할 수 있다.</p>
</li>
<li><p>실시간 패킷 드랍 모니터링
커널 레벨에서 패킷이 왜 드랍되었는지에 대한 구체적인 이유와 함께 실시간 흐름을 제공해주며, 이는 NetworkPolicy 설정 오류를 잡아내는 데 활용 가능하다.</p>
</li>
<li><p>L4/L7 계층 분석
TCP 재전송, 커넥션 타임아웃과 같은 L4 지표뿐만 아니라, HTTP 상태 코드나 gRPC 메서드 호출과 같은 L7 계층까지 수집하여 애플리케이션 성능 관리까지 할수있다.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kubernetes 홈 서버 구축기 - (2)]]></title>
            <link>https://velog.io/@sang_yoon54/Kubernetes-%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-2</link>
            <guid>https://velog.io/@sang_yoon54/Kubernetes-%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-2</guid>
            <pubDate>Fri, 27 Mar 2026 17:21:37 GMT</pubDate>
            <description><![CDATA[<p>저번 포스팅에서 VM 설치와 세팅, 그리고 Kubernetes 관련 패키지를 설치하였다.
이번 포스팅에서는 노드 설정과 함께 CNI를 설치해보자.</p>
<p>먼저 VM의 IP 포워딩 기능을 켜 줘야한다.
다음 명령어로 리눅스 설정 파일을 추가하자.</p>
<pre><code>cat &lt;&lt;EOF | sudo tee /etc/sysctl.d/k8s.conf
net.ipv4.ip_forward = 1
EOF

sudo sysctl --system</code></pre><p>그러면 이제 마스터 노드가 될 VM을 초기화 해야한다. </p>
<pre><code>sudo kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --apiserver-advertise-address=&lt;MASTER_IP&gt;</code></pre><p>먼저 kubeadm init 명령어는 많은 동작을 하지만, 중요한 몇 가지만 정리하고 넘어가자. 
실제 전체 동작은 다음 <a href="https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-init/">공식 Document</a>에서 확인할 수 있다.</p>
<ol>
<li><p>사전 점검 및 환경 준비
실제 설치에 들어가기 전, 시스템이 쿠버네티스를 실행할 수 있는 상태인지 확인하고 보안 통신을 위한 기반을 만든다. 여기엔 VM의 스펙 체크(CPU 2 core 이상, 메모리 2GB이상, 포트 점유 여부 등), 인증서 생성 등이 포함된다.</p>
</li>
<li><p>핵심 컴포넌트 구동
쿠버네티스의 핵심 서비스들을 정적 포드 형태로 실행하기 위한 YAML파일을 /etc/kubernetes/manifests에 작성하며, 여기는 kube-apiserver, kube-controller-manager,  kube-scheduler, etcd 등이 있다.
이 경로에 파일이 생성되면, 해당 노드의 kubelet이 이를 감지하고 containerd를 통해 컨테이너로 실행시킨다.</p>
</li>
<li><p>노드 설정 및 토큰 발행
현재 노드에 마스터임을 알리는 라벨과 Taint를 설정하여, 일반 사용자 포드가 마스터 노드에 배치되지 않도록 격리한다. 또한, 다른 워커 노드들이 클러스터에 join할 때 신원을 증명하기 위한 보안 토큰을 생성한다.</p>
</li>
<li><p>필수 애드온 설치
클러스터 운영에 필요한 기본 서비스를 배포하며, 이는 CoreDNS와 kube-proxy 등이 있지만 필자는 CNI로 eBPF를 사용하는 cilium을 설치할 것이므로 생략한다. 이 이유는 밑에 따로 정리하겠다.</p>
</li>
</ol>
<p>이제 init 명령어의 옵션으로 설정한 명령어들을 알아보자.</p>
<ol>
<li><p>--pod-network-cidr=10.244.0.0/16
클러스터 내부에서 Pod들이 서로 통신하기 위해 가상의 IP 주소 대역을 미리 예약한다.</p>
</li>
<li><p>--apiserver-advertise-address
다른 노드들이 접속할 마스터 노드의 대표 IP 주소를 명시한다.</p>
</li>
</ol>
<p>명령어를 실행하면 다음과 같이 초기화가 잘 실행된 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/c6115891-b694-4d8e-aab3-28a6c55ec330/image.png" alt="">
이 밑에는 join 명령어와 토큰값이 적혀 있을텐데, 이는 Worker Node에서 사용할 예정이다.
나와있는대로 3줄의 명령어를 입력하면 kubectl 명령어를 사용할 수 있다!</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/6ae15996-503b-483c-9b19-35056fa74f2a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/0845ca82-0537-446c-8c5d-1482aa535241/image.png" alt=""></p>
<p>이젠 CNI를 설치해야 한다. 필자의 경우는 Cilium을 설치하지만, kube-proxy를 사용해도 되고, Cilico를 사용해도 되고, 다른걸 사용해도 된다.</p>
<pre><code>cilium install --version 1.19.2 \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=&lt;MASTER-IP&gt; \
  --set k8sServicePort=6443 \
  --set operator.replicas=1</code></pre><p>옵션은 굳이 설명 안해도 읽어보면 쉽게 알 수 있기에 넘어가겠다.</p>
<p>그럼 이제 cilium을 설치 했으니 kube-proxy를 제거한다. kube-proxy의 역할을 cilium이 할 수 있고, cilium이 서비스가 커진 상황에서도 효과적으로 동작하기 때문이다.
이는 kube-proxy가 IPtables를 사용하여 네트워크 트래픽을 제어하기 때문인데, IPtables는 기본적으로 선형 검색을 사용해 패킷이 들어오면 등록되어 있는 Rule을 전부 대조하여 패킷을 보낸다. 또한, Rule 업데이트 시에도 증분 업데이트가 불가능하기 때문에 Lock을 걸고 전체 Rule 리스트를 메모리로 가져와서 수정 후 커널에 덮어씌워야 하기 때문이다. 이러한 방식은 서비스 규모가 커질수록 서버에 부하가 커진다.
<del>사실 학습용이라 크게 영향을 미치지는 않는다.</del></p>
<pre><code>kubectl delete ds kube-proxy -n kube-system
kubectl delete cm kube-proxy -n kube-system</code></pre><p>참고로 ds는 demonset, cm은 configmap을 의미한다.</p>
<p>이제 Worker VM으로 들어가서 아까 init 시에 나왔던 join 명령어를 입력해주면.. 다음과 같이 성공 문구가 뜬다! Warning은 kupe-proxy가 없어서 그런거니 넘어가자. 의도된 사항이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/ce51341e-b3a4-439b-bacf-4aec18f77894/image.png" alt=""></p>
<p>그리고 Master로 가서 kubectl get nodes를 입력하면 다음과 같이 노드가 정상적으로 표기되는 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/17aaf055-299e-4a90-a31e-1bd799873114/image.png" alt=""></p>
<p>이제 다음 포스팅에서는 프로젝트 세팅을 하거나, 대충 설명하고 넘어갔던 네트워크 지식을 다룰 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kubernetes 홈 서버 구축기 - (1)]]></title>
            <link>https://velog.io/@sang_yoon54/Kubernetes-%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-1</link>
            <guid>https://velog.io/@sang_yoon54/Kubernetes-%ED%99%88-%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%EA%B8%B0-1</guid>
            <pubDate>Mon, 23 Mar 2026 10:05:24 GMT</pubDate>
            <description><![CDATA[<p>기존 학교 서버 대여 기간이 끝나서, K8s 홈 서버 구축기를 다룰 예정이다. </p>
<p>학습용 간단한 MSA 서비스를 배포할 예정이고, 집 PC의 스펙이 여유롭지 않아서 Master 노드 1개와 Worker 노드 2개로 구성할 예정이다.</p>
<p>노드들의 스펙은 다음과 같이 정하였다.</p>
<p>Master Node</p>
<ul>
<li>Cpu 2 Core, RAM 3GB</li>
</ul>
<p>Worker Node</p>
<ul>
<li>Cpu 1 Core, RAM 2GB</li>
</ul>
<p>그리고 Ubuntu Live Server 22.04 LTS버전을 사용했으며, VM의 네트워크 설정에서 NAT대신 어댑터에 브리지 설정으로 대체하였다. 이는 각 VM이 공유기로부터 직접 IP를 할당받아 서로 다른 PC처럼 동작하도록 하기 위해서이며, 껐다 킬시 ip가 바뀌기 때문에 나중에 고정해 주어야 한다.</p>
<p>기본 설정은 Master나 Worker이나 똑같기 때문에 VM 1개를 apt upgrede까지 끝내면, 이제 본격적인 구축을 시작할 수 있다.</p>
<p>설치 과정은 다음 <a href="https://kubernetes.io/ko/docs/tasks/tools/install-kubectl-linux/#install-using-native-package-management">공식 Document</a>를 따른다.</p>
<p>그럼 이제 필수 패키지들을 설치하자.</p>
<pre><code>sudo apt-get install -y apt-transport-https ca-certificates curl gpg</code></pre><p>먼저 apt-transport-https는 우분투의 패키지 관리자인 apt가 일반적인 HTTP가 아닌, 암호화된 보안 통신망인 HTTPS 주소를 통해 패키지를 내려받을 수 있도록 도와주는 도구이다. 최근 우분투 버전에는 이 기능이 이미 포함되어 있는 경우가 많지만, 어떤 환경에서도 문제없이 작동하도록 관례적으로 가장 먼저 설치해 준다.</p>
<p>이렇게 보안 통신망을 준비했더라도, 접속하려는 사이트가 정말 믿을 만한 곳인지 확인하는 과정이 필요한데, 이때 ca-certificates가 그 역할을 한다. 이 패키지에는 전 세계적으로 신뢰받는 기관들이 발급한 디지털 인증서 목록이 들어있어, 컴퓨터가 HTTPS 사이트에 접속할 때 해당 사이트가 안전한지 실시간으로 판단할 수 있게 해준다.</p>
<p>마지막으로 파일이 도중에 변조되지는 않았는지, 정말 구글이나 쿠버네티스 팀에서 보낸 것이 맞는지를 확인해야 하는데, 이때 gpg가 그 역할을 수행하며, gpg는 내려받은 패키지에 찍힌 암호화 서명을 검증하여, 해당 파일이 제작자의 원본과 1%도 다르지 않다는 것을 최종적으로 보증해 준다.</p>
<p>이제 구글 클라우드 공개 샤이닝 키를 다운로드하자.</p>
<pre><code>sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg</code></pre><p>그 다음으로는, 기본 apt 패키지에 kube 시리즈가 존재하지 않으므로 수동으로 추가해주자.</p>
<pre><code>echo &#39;deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /&#39; | sudo tee /etc/apt/sources.list.d/kubernetes.list</code></pre><p>이제 패키지를 추가했으니, 다운로드 하고 버전을 고정시키자.</p>
<pre><code>sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl</code></pre><p>이때 각 도구들에 대해 간단히 알아보자.</p>
<ol>
<li>kubeadm</li>
</ol>
<p>쿠버네티스 클러스터를 구성하는 표준 설치 및 관리 도구이다.</p>
<p>주요 기능은 다음과 같다.</p>
<ul>
<li>인증서 및 토큰 관리
각 컴포넌트 간 통신을 위한 TLS 인증서를 생성하고 노드 합류를 위한 보안 토큰을 발행한다.</li>
<li>정적 포드 생성
kube-apiserver, kube-controller-manager, kube-scheduler를 구동하기 위한 YAML 매니페스트를 생성한다.</li>
<li>클러스터 생명주기 관리
버전 업그레이드, 노드 초기화 등의 작업을 수행한다.</li>
</ul>
<p>일회성 명령어로 실행되며, 설정이 완료된 후에는 상주하며 동작하지 않는다.</p>
<ol start="2">
<li>kubelet</li>
</ol>
<p>클러스터의 모든 노드에서 실행되는 핵심 에이전트이다.</p>
<p>주요 기능은 다음과 같다.</p>
<ul>
<li><p>컨테이너 실행 및 상태 감시
API 서버로부터 전달받은 Pod의 명세를 확인하고, 컨테이너 런타임에 요청하여 컨테이너를 실행한다.</p>
</li>
<li><p>상태 보고
해당 노드의 자원 상태와 컨테이너의 헬스 체크 결과를 마스터 노드에 보고한다.</p>
</li>
<li><p>정적 포드 감시
특정 디렉터리를 모니터링하여 파일이 존재하면 API 서버 없이도 컨테이너를 직접 구동합니다.</p>
</li>
</ul>
<p>이는 OS의 Systemd 서비스로 등록되어 백그라운드에서 항상 실행되며, 컨테이너 형태로 돌아가지 않는 유일한 핵심 컴포넌트이다.</p>
<ol start="3">
<li>kubectl
사용자가 쿠버네티스 API 서버와 통신하기 위해 사용하는 CLI이며, 가장 많이 사용하게 될 도구이다.</li>
</ol>
<p>주요 기능은 다음과 같다.</p>
<ul>
<li><p>API 호출
사용자의 명령을 REST API 요청으로 변환하여 마스터 노드의 kube-apiserver에 전달한다.</p>
</li>
<li><p>리소스 조작
Pod, Service, Deployment 등 쿠버네티스 객체의 생성, 조회, 수정, 삭제(CRUD)를 수행한다.</p>
</li>
<li><p>인증 및 컨텍스트 관리
kubeconfig 파일을 참조하여 접속할 클러스터 정보와 사용자 권한을 확인한다.</p>
</li>
</ul>
<p>그럼 우리가 내리는 명령이 어떻게 클러스터에 적용되는지 한번 알아보자.</p>
<ol>
<li>가장 먼저, 사용자가 kubectl apply -f pod.yaml 명령을 내린다.</li>
<li>kubectl은 사용자의 인증 정보를 확인하고, YAML 명세서를 마스터 노드의 kube-apiserver에 REST API 요청으로 전달한다.</li>
<li>kube-apiserver는 요청을 받아 etcd에 상태를 저장하고, Scheduler가 어느 노드에 배포할 지 결정하면 해당 노드의 kubelet에 작업 지시를 내린다.</li>
<li>그럼 kubelet은 전달받은 pod의 명세를 확인하고, Container Runtime Interface(CRI)에 맞춰 Container Runtime에 컨테이너를 생성하라고 요청한다.</li>
<li>Container Runtime은 이미지가 있는지 확인하고, 하위 레벨 런타임을 호출하여 실제 컨테이너를 만들고 구동한다.</li>
</ol>
<p>여기서 일반적으로 컨테이너 런타임은 containerd를 많이 사용하고, 하위 레벨 런타임은 runc를 많이 사용한다.</p>
<p>그럼 이제 마지막으로, 컨테이너 런타임으로 사용할 containerd를 설치해주자. containerd를 설치하면 의존성에 의해 runc가 같이 설치된다.</p>
<pre><code>sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i &#39;s/SystemdCgroup = false/SystemdCgroup = true/g&#39; /etc/containerd/config.toml</code></pre><p>쿠버네티스 환경에서 containerd를 사용하려면, 컨테이너 런타임과 kubelet의 Cgroup 드라이버를 OS의 프로세스 관리 시스템(systemd)과 일치시켜야 한다. 이 이유는 나중에 정리하겠다.</p>
<p>다음 명령어를 수행해주자.</p>
<pre><code>sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i &#39;s/SystemdCgroup = false/SystemdCgroup = true/g&#39; /etc/containerd/config.toml
sudo systemctl restart containerd</code></pre><p>진짜 진짜 마지막으로, 쿠버네티스는 노드 간 패킷 라우팅과 포드 네트워크 통신에 리눅스 커널의 IP Forwarding 기능을 사용하기 때문에 이를 켜 주어야 한다.</p>
<pre><code>sudo modprobe overlay
sudo modprobe br_netfilter

cat &lt;&lt;EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

sudo sysctl --system</code></pre><p>그러면 이제 kube관련 패키지 설치는 끝났고, 이제 VM 복사 후 노드 관련 설정을 해주자.</p>
<p>VM을 복사 할때는 다 어댑터에 브리지 형식으로 하고, IP주소가 다 다르게 해야한다. 그러고 접속을 하면 사용자 이름을 바꿔주어야 한다.</p>
<p>VM 종료 후 네트워크 설정 페이지에서 MAC Address를 새로고침 해주자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/6ba0d453-7b9d-4054-8a12-7dcfbec92a5e/image.png" alt=""></p>
<p>그리고 접속 후 호스트이름을 변경해주어야 하는데, 다음 명령어로 hostname과 hosts파일의 내용을 변경해주자.</p>
<pre><code>echo &quot;Worker01&quot; | sudo tee /etc/hostname
sudo tee /etc/hosts &lt;&lt;EOF
127.0.0.1 localhost
127.0.1.1 Worker01
EOF
sudo hostnamectl set-hostname Worker01</code></pre><p>설정 후 재접속하면 다음과 같이 변경된 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/5b650c94-bf7a-42f6-9ed2-6d22be5e14c2/image.png" alt=""></p>
<p>여기까지 VM 세팅과 패키지 설치를 마쳤고, 다음 포스팅에서는 노드 설정과 CNI설치 과정을 알아보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MVCC(Multi-Version Concurrency Control)와 MySQL, PostgreSQL의 구현 방식]]></title>
            <link>https://velog.io/@sang_yoon54/MVCCMulti-Version-Concurrency-Control%EC%99%80-MySQL-PostgreSQL%EC%9D%98-%EA%B5%AC%ED%98%84-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@sang_yoon54/MVCCMulti-Version-Concurrency-Control%EC%99%80-MySQL-PostgreSQL%EC%9D%98-%EA%B5%AC%ED%98%84-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Fri, 20 Mar 2026 00:54:44 GMT</pubDate>
            <description><![CDATA[<p>먼저 들어가기 전에, 이 글은 <a href="https://mangkyu.tistory.com/53">망나니 개발자님의 블로그</a>를 보고 정리한 글임을 알립니다.</p>
<p>그럼 가장 먼저, 동시성 제어(Concurrency-Controll)에 대해 알아보겠다.</p>
<h3 id="동시성-제어란">동시성 제어란?</h3>
<p>DBMS가 다수의 사용자 사이에서 동시에 작용하는 다중 트랜젝션의 상호간섭 작용에서 Database를 보호하는 것을 의미한다.
일반적으로 동시성을 높이면 일관성이 낮아지게 되며, 이들은 반비례 관계이다.</p>
<p>다수 사용자의 동시 접속을 위해 DBMS는 동시성 제어를 할 수 있도록 Lock기능과 SET TRANSACTION 명령어를 사용해 트랜젝션의 격리성 수준을 조정할 수 있는 기능도 제공한다. 격리성 수준에 관해서는 좀 있다 살펴보고, 먼저 동시성을 제어하는 방법을 알아보자. 여기엔 크게 두 가지 방법이 존재한다.</p>
<h3 id="낙관적-동시성-제어">낙관적 동시성 제어</h3>
<ul>
<li>사용자들이 같은 데이터를 동시에 수정하지 않을 것이라고 가정하며, 데이터를 읽는 시점에 Lock을 걸지 않는 대신 수정 시점에 값이 변경됐는지를 반드시 검사한다.</li>
</ul>
<h3 id="비관적-동시성-제어">비관적 동시성 제어</h3>
<ul>
<li>사용자들이 같은 데이터를 동시에 수정할 것이라고 가정하며, 데이터를 읽는 시점에 Lock을 걸고 트랜젝션이 완료될 때까지 이를 유지한다.</li>
<li>SELECT 시점에 Lock을 거는 비관적 동시성 제어는 시스템의 동시성을 심각하게 떨어뜨릴 수 있어서 wait 또는 nowait옵션과 함께 사용해야 한다.</li>
<li>기다릴 시간을 설정할 수 있는 wait이나 안 기다리는 nowait 명령어를 사용하지 않으면 데드락에 걸려 스레드가 무한 대기를 할 수 있으며, 이는 커넥션을 소모하여 다른 DB에 연결이 필요한 스레드들이 DB에 연결을 못하게 되는 결과를 야기할 수 있다.</li>
</ul>
<p>동시성 제어의 목표는 동시에 실행되는 트랜젝션 수를 최대화 하면서 입력, 수정, 삭제, 검색 시 데이터의 무결성을 유지하는데 있다. 따라서, 동시 업데이트가 거의 없는 경우라면 낙관적 잠금을 사용하면 되지만, 그렇지 않다면 비관적 제어를 사용해야 한다.</p>
<p>하지만 우리가 사용하는 OLTP는 대부분 동시 업데이트가 있으며, 따라서 비관적 제어를 많이 사용하게 된다. 그러면 이제 비관적 동시성 제어를 위한 대표적인 방법인 공유락과 배타락에 대해 알아보자.</p>
<p>간단하게 설명하면, 공유락은 읽기 잠금, 배타락은 쓰기 잠금이다.</p>
<p>만약 동일한 레코드에 대해 각각 공유락과 배타락을 가져간 경우, 다음과 같이 동작한다.</p>
<ul>
<li><p>1번 트랜젝션이 공유락을 가져간 경우</p>
<ul>
<li>2번 트랜젝션이 데이터를 읽는 경우는 데이터가 일관되므로, 2번 트랜젝션이 또 다른 공유락을 가져가면서 동시에 처리할 수 있게 된다.</li>
<li>하지만 만약 2번 트랜젝션이 데이터를 쓴다면 이는 데이터가 달라질 수 있으므로, 1번 트랜젝션이 종료될 때까지 기다려야 한다.</li>
</ul>
</li>
<li><p>1번 트랜젝션이 배타락을 가져간 경우</p>
<ul>
<li>2번 트랜젝션이 데이터를 읽거나 쓰거나 1번 트랜젝션이 데이터를 변경할 수 있으므로 기다려야 한다.</li>
</ul>
</li>
</ul>
<p>참고로, 획득한 락을 해제하는 방법은 결국 커밋과 롤백밖에 없다.</p>
<p>이러한 방식의 일반적인 Locking 매커니즘은 구현이 간단한 반면, 아래와 같은 문제점을 가지고 있다.</p>
<ol>
<li>읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 동시성 문제가 발생한다.</li>
<li>비관적 제어는 데이터 정합성 유지를 위해 Lock을 오래 유지하거나 테이블 수준까지 잠궈야 하는 경우가 생기는데, 이로 인해 다른 트랜잭션들이 차단되면서 시스템의 처리 성능(동시성)이 떨어진다는 단점이 있다.</li>
</ol>
<p>이러한 문제점들을 해결하기 위해 MVCC(Multi-Version Concurrency Control)이 나오게 되었다.</p>
<h2 id="mvcc란">MVCC란?</h2>
<p>동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사용하는 방법 중 하나이다.
원본의 데이터와 변경중인 데이터를 동시에 유지하는 방식으로, 원본 데이터에 대한 Snapshot을 백업하여 보관한다. 만약 두 가지 버전의 데이터가 존재하는 상황에서 새로운 사용자가 데이터에 접근하면 데이터베이스의 Snapshot을 읽는다. 그러다가 변경이 취소되면 원본 Snapshot을 바탕으로 데이터를 복구하고, 만약 변경이 완료되면 최종적으로 디스크에 반영하는 방식으로 동작한다.
결국 MVCC는 스냅샷을 이용하는 방식으로, 기존의 데이터를 덮어 씌우는게 아니라 기존의 데이터를 바탕으로 이전 버전의 데이터와 비교해서 변경된 내용을 기록한다. 이렇게 해서 하나의 데이터에 대해 여러 버전의 데이터가 존재하기 되고, 사용자는 마지막 버전의 데이터를 읽게 된다. 이러한 구조를 지닌 MVCC의 특징을 정리하면 아래와 같다.</p>
<ul>
<li>일반적인 RDBMS보다 매우 빠르게 작동</li>
<li>사용하지 않는 데이터가 계속 쌓이게 되므로 데이터를 정리하는 시스템이 필요</li>
<li>데이터 버전이 충돌하면 애플리케이션 영역에서 이러한 문제를 해결해야 함</li>
</ul>
<p>MVCC의 접근 방식은 잠금을 필요로 하지 않기 때문에 일반적인 RDBMS보다 매우 빠르게 작동한다. 또한 데이터를 읽기 시작할 때, 다른 사람이 그 데이터를 삭제하거나 수정하더라도 영향을 받지 않고 데이터를 사용할 수 있다. 대신 사용하지 않는 데이터가 계속 쌓이게 되므로 데이터를 정리하는 시스템이 필요하다. MVCC모델은 하나의 데이터에 대한 여러 버전의 데이터를 허용하기 때문에 데이터 버전이 충돌될 수 있으므로 어플리케이션 영역에서 이를 해결해야 한다. 또한 UNDO 블록 I/O, CR Copy 생성 등의 부가적인 오버헤드 또한 발생한다. 하지만 이러한 구조의 MVCC는 문장수준과 트랜젝션 수준의 읽기 일관성이 존재한다.</p>
<h3 id="mysql에서의-mvcc">MySQL에서의 MVCC</h3>
<p>MySQL의 InnoDB에서는 Undo Log를 활용해 MVCC 기능을 구현한다. 이해를 위해 실제 쿼리문 예시를 가지고 살펴보도록 하자. 만약 아래와 같은 CREATE 쿼리문이 실행되었다고 하자.</p>
<pre><code>CREATE TABLE member (
    id INT NOT NULL,
    name VARCHAR(20) NOT NULL,
    area VARCHAR(100) NOT NULL,
    PRIMARY KEY(id),
    INDEX idx_area(area)
)

INSERT INTO member(id, name, area) VALUES (1, &quot;Sang&quot;, &quot;Daegu&quot;);</code></pre><p>그러면 데이터는 다음과 같은 상태로 저장이 된다. 메모리와 디스크에 모드 해당 데이터가 동일하게 저장되는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/008860ff-998c-4ec4-a546-574ececdc673/image.png" alt=""></p>
<p>그리고 다음과 같이 UPDATE문을 실행시켰다고 하자.</p>
<pre><code>UPDATE member SET area = &quot;Busan&quot; WHERE id = 1;</code></pre><p>UPDATE 문이 실행된 결과를 그림으로 표현하면 다음과 같다. 먼저 COMMIT 실행 여부와 무관하게 InnoDB 버퍼 풀은 새로운 값으로 갱신된다. 그리고 Undo 로그에는 변경 전의 값들만 복사된다. 그리고 InnoDB 버퍼 풀의 내용은 백그라운드 쓰레드를 통해 디스크에 기록되는데, 디스크에도 반영되었는지 여부는 시점에 따라 다를 수 있다고 한다. 이는 InnoDB가 성능을 위해 버퍼 풀의 변경 내용을 즉시 데이터 파일에 반영하지 않고, 먼저 변경 이력은 Redo Log에 저장 후 나중에 여유가 있을 때 업데이트하는 방식이기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/d4637d29-51da-4047-b5ff-934e834c0e92/image.png" alt=""></p>
<p>그럼 여기서 COMMIT이나 ROLLBACK이 호출되지 않은 상대에서 다른 사용자가 아래와 같은 쿼리로 데이터를 조회하면 어떤 결과가 반환될까?</p>
<pre><code>SELECT * FROM member WHERE id = 1;</code></pre><p>그 결과는 트랜젝션의 격리 수준에 따라 다르다. 만약 커밋되지 않은 내용도 조회하도록 해주는 READ_UNCOMMITTED라면 버퍼 풀의 데이터를 읽어서 반환하며, 이는 커밋 여부와 무관하게 변경된 데이터를 읽어 반환하는 것이다.
만약 READ_COMMITED 이나 그 이상의 격리 수준(REPEATABLE_READ, SERIALIZABLE)이라면 변경되기 이전의 Undo 로그 영역의 데이터를 반환하게 된다. 이것이 가능한 이유는 하나의 데이터에 대해 여러 버전을 관리하는 MVCC 덕분이다.
여기서 Undo Log 영역의 데이터는 커밋 혹은 롤백을 호출하여 InnoDB 버퍼풀도 이전의 데이터로 복구되고, 더 이상 언두 영역을 필요로 하는 트랜잭션이 더는 없을 때 비로소 삭제된다.</p>
<h3 id="postgresql에서의-mvcc">PostgreSQL에서의 MVCC</h3>
<p>PostgreSQL은 다중 버전 행 저장 방식(Append-only)을 사용한다. 이를 이해하기 위해 UPDATE가 발생했을 때 내부 시스템 컬럼인 xmin(생성 트랜잭션 ID)과 xmax(삭제/업데이트 트랜잭션 ID)가 어떻게 변하는지 살펴보자.</p>
<p>PostgreSQL에서도 다음 INSERT문이 실행되었다고 하자.</p>
<pre><code>INSERT INTO member(id, name, area) VALUES (1, &#39;Sang&#39;, &#39;Daegu&#39;); -- 트랜잭션 ID: 100</code></pre><p>그럼 테이블(Heap)에는 다음과 같은 컬럼이 생성된다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/70b85900-b9b4-4e3a-b4cd-5a74265cda0e/image.png" alt=""></p>
<p>여기서 xmin은 이 행을 생성한 트랜잭션 번호이고, xmax는 이 행을 삭제하거나 업데이트한 트랜잭션 번호이며 0은 현재 유효한 데이터임을 의미한다.</p>
<p>그럼 여기서 다음 UPDATE 컬럼이 들어온다면?</p>
<pre><code>UPDATE member SET area = &#39;Busan&#39; WHERE id = 1; -- 트랜잭션 ID: 101</code></pre><p>PostgreSQL은 기존 데이터를 덮어쓰지 않고 새로운 행을 추가한다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/45771122-9c9d-4d31-9ce2-4217424b45ba/image.png" alt="">
그럼 이러한 상태가 되고, 이때 커밋되지 않은 시점에서 다른 사용자가 조회하면 어떻게 될까?</p>
<p>PostgreSQL은 SELECT를 할 때 현재 커밋된 트랜잭션 목록이 담긴 스냅샷을 찍는다. 이를 통해 각 행의 xmin, xmax와 대조하고, 어떤 행을 보여줄지 결정한다.</p>
<p>만약 트랜젝션 101번이 진행 중이었다면, 다른 사용자의 스냅샷에겐 101번은 작업중이라고 표시되기 때문에 Daegu 값이 조회된다.</p>
<p>이러한 방식은 간단해 보이지만, 쓸모없는 레코드가 많아지므로, 이를 정리할 작업이 필요하며 이는 PostgreSQL에서 VACUUM이라는 작업으로 이뤄지고 있다.</p>
<p>VACUUM이 실행되면 엔진은 테이블의 각 페이지를 밑바닥부터 훑으며 다음 작업을 수행한다.</p>
<ol>
<li><p>Dead Tuple 찾기
xmax가 이미 커밋된 트랜잭션 번호이고, 현재 이 데이터를 보고 있는 다른 활성 트랜잭션이 아무도 없다면 이를 Dead Tuple로 간주한다.</p>
</li>
<li><p>FSM(Free Space Map) 업데이트
가장 핵심이 되는 작업이며, Daegu가 있던 자리를 물리적으로 파일에서 삭제하는 게 아니라, 대신 FSM이라는 별도의 파일에 사용 가능한 공간이라고 적는다. 이후 insert작업이 들어오면 FSM을 참고해 저장한다.</p>
</li>
<li><p>Index 정리
테이블만 치운다고 끝이 아니며, 인덱스 또한 죽은 데이터를 가리키고 있기 때문에, 인덱스 페이지에서도 해당 항목을 정리하여 인덱스 효율을 높인다.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[부하 테스트 에러: java.net.BindException]]></title>
            <link>https://velog.io/@sang_yoon54/%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%97%90%EB%9F%AC-java.net.BindException</link>
            <guid>https://velog.io/@sang_yoon54/%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%97%90%EB%9F%AC-java.net.BindException</guid>
            <pubDate>Thu, 19 Mar 2026 00:37:58 GMT</pubDate>
            <description><![CDATA[<p>Java 21의 ZGC와 G1GC 성능을 비교하기 위해 로컬 환경(macOS)에서 부하 테스트를 진행하던 중, 기이한 현상을 발견했다. 서버는 문제가 없는데 JMeter에서는 수십만 건의 에러가 발생한 것이다.</p>
<p>테스트 시나리오는 다음과 같이 설정하였고, 로그인을 진행하는 간단한 api였다.</p>
<p>Thread 100, Duration 300s, Ramp-up 30s</p>
<p>테스트 종료 후 86%에 달하는 에러가 발생해 JMeter 결과 파일을 살펴보니, 다음 로그가 많이 찍혀있었다.</p>
<pre><code>1771728085116,53,POST /api/user/login,200,,Login Thread Group 1-9,text,true,,1121,237,33,33,http://localhost:8080/api/user/login,53,0,0
1771728085165,4,POST /api/user/login,Non HTTP response code: java.net.BindException,Non HTTP response message: Can&#39;t assign requested address,Login Thread Group 1-31,text,false,,2298,0,33,33,http://localhost:8080/api/user/login,0,0,4
1771728085161,9,POST /api/user/login,Non HTTP response code: java.net.BindException,Non HTTP response message: Can&#39;t assign requested address,Login Thread Group 1-12,text,false,,2298,0,33,33,http://localhost:8080/api/user/login,0,0,9
1771728085167,3,POST /api/user/login,Non HTTP response code: java.net.BindException,Non HTTP response message: Can&#39;t assign requested address,Login Thread Group 1-17,text,false,,2298,0,33,33,http://localhost:8080/api/user/login,0,0,3
1771728085165,5,POST /api/user/login,Non HTTP response code: java.net.BindException,Non HTTP response message: Can&#39;t assign requested address,Login Thread Group 1-7,text,false,,2298,0,33,33,http://localhost:8080/api/user/login,0,0,5
1771728085168,2,POST /api/user/login,Non HTTP response code: java.net.BindException,Non HTTP response message: Can&#39;t assign requested address,Login Thread Group 1-21,text,false,,2298,0,33,33,http://localhost:8080/api/user/login,0,0,2
</code></pre><p>초반에는 맨 위 로그와 같이 200이 계속 찍혔었지만, 어느 구간부터는 BindException이 계속 찍히면서, 에러를 발생시키고 있었던 것이다.</p>
<p>인터넷에 다음 에러를 검색 해 보니 이미 같은 문제를 겪은 사람들이 많았고, 이 에러는 TCP 요청에 대한 포트를 할당하지 못해 생긴 문제이다.</p>
<p>이제 이 문제가 생긴 근본적인 이유를 알아보자.</p>
<p>HTTP 요청 중 TCP 통신을 할 때, 클라이언트는 서버에 접속하기 위해 OS로부터 임시 포트를 할당받는다.</p>
<p>테스트 중 JMeter에서 100개의 쓰레드가 300초 동안 끊임없이 HTTP 요청을 보내게 되는데, 이때 각 요청이 완료되면 해당 소켓을 닫으려 한다. 하지만 TCP 프로토콜 설계상 소켓은 즉시 해제되지 않고 TIME_WAIT 상태에 머무르게 되어 소켓을 계속 소유하게된다.</p>
<p>macOS의 기본 설정은 사용할 수 있는 임시 포트 범위가 약 16,000개 정도로 좁고, TIME_WAIT 지속 시간은 길기 때문에 시작하고 얼마 지나지 않아 가용 포트가 고갈된다. 이로 인해 BindException이 발생하게 되는 것이다.</p>
<p>이러한 문제의 해결책은 크게 두 가지가 있다.</p>
<ol>
<li><p>TCP 세그먼트가 네트워크상에 존재할 수 있는 시간 자체를 줄인다.
msl(Maximum Segment Lifetime)은 TCP 세그먼트가 네트워크상에 존재할 수 있는 최대 시간을 의미하며, 보통 TIME_WAIT 상태는 $2 \times MSL$ 동안 유지된다. 
하지만 너무 극단적으로 낮추면, 지연되었던 이전 연결의 패킷이 뒤늦게 도착했을 때 새로운 연결의 패킷으로 오인받아 데이터 무결성 문제가 생길 수 있어 사용에 주의하여야 한다.
또한, 이와 함께 사용할 수 있는 포트의 번호 자체를 늘려 포트 풀을 늘여도 효과가 있을 것이다.</p>
</li>
<li><p>JMeter의 Connection Reuse를 활성화한다.
이 설정은 HTTP 요청마다 새로운 소켓을 열지 않고, 기존 연결을 재사용하도록 설정하는 것이며, 포트 사용량을 획기적으로 줄일 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/deebf459-b863-46d5-83a3-b708ee8d8af2/image.png" alt=""></p>
</li>
</ol>
<p>필자는 첫 번째 방법을 사용했고, 결과로 다음과 같이 잘 실행된 것을 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[B-Tree, B+Tree, B*Tree]]></title>
            <link>https://velog.io/@sang_yoon54/B-Tree-BTree-BTree</link>
            <guid>https://velog.io/@sang_yoon54/B-Tree-BTree-BTree</guid>
            <pubDate>Sat, 07 Mar 2026 12:31:47 GMT</pubDate>
            <description><![CDATA[<h2 id="b-tree">B-Tree</h2>
<blockquote>
<p>트리의 일종으로, 이진트리를 확장해 하나의 노드가 가질 수 있는 자식 노드의 최대 숫자가 2보다 큰 트리 구조이다.</p>
</blockquote>
<p>B-Tree의 특징은 다음과 같다.</p>
<ol>
<li>각 노드에는 2개 이상의 데이터가 들어갈 수 있으며, 항상 정렬된 상태로 저장된다.</li>
<li>노드의 자료 수가 N개이면 자식 수는 N + 1개여야 한다.</li>
<li>루트 노드는 적어도 2개의 자식을 가져야 한다.</li>
<li>루트 노드를 제외한 모든 노드는 적어도 M/2개의 자료를 가지고 있어야 한다.
여기서 M은 노드가 가질 수 있는 최대 자식 노드의 개수이다.</li>
<li>리프 노드로 가는 경로의 길이는 모두 같다.</li>
<li>입력 자료는 중복될 수 없다.</li>
</ol>
<h3 id="b-tree의-핵심-원리">B-Tree의 핵심 원리</h3>
<p>B-Tree의 가장 큰 장점은 항상 균형을 유지한다는 점이다. 모든 리프 노드가 같은 레벨에 있기 때문에, 어떤 데이터를 찾더라도 탐색 시간이 로그 n으로 일정하게 유지된다.</p>
<h2 id="btree">B+Tree</h2>
<p>동작 방식은 B-Tree와 비슷하지만, B+Tree의 다른점은 리프 노드들이 연결 리스트로 되어있어 선형 검색이 가능해 굉장히 작은 시간복잡도에 검색 작업을 수행할 수 있다는 점이다.</p>
<h3 id="b-tree와-btree의-차이점">B-Tree와 B+Tree의 차이점</h3>
<ol>
<li>모든 Key, data가 리프노드에 모여있다. B-Tree는 리프노드가 아닌 각자 Key마다 data를 가진다면, B+Tree는 리프 노드에만 data가 존재한다.</li>
<li>모든 리프노드가 연결리스트로 이루어져 있다. B-Tree는 옆에있는 리프노드를 검사할 때, 다시 루트노드부터 검사해야 한다면, B+Tree는 리프노드에서 선형검사를 수행할 수 있어 시간복잡도가 굉장히 줄어든다.</li>
<li>리프노드의 부모 Key는 리프노드의 첫 번째 Key보다 작거나 같다. 또한, Key값이 부모 노드와 리프 노드에 공존할 수 있다.</li>
<li>B+Tree의 삽입, 삭제 연산은 leaf에서만 이루어진다.</li>
</ol>
<h2 id="btree-1">B*Tree</h2>
<p>B*Tree는 B-Tree의 단점 중 하나인 노드 분할이 너무 자주 일어나서 트리가 깊어지는 현상을 해결하기 위해 등장한 개선된 버전이며, 가장 핵심적인 차이는 노드가 꽉 찼을 때 바로 쪼개지 않고, 옆 형제 노드를 살펴보고 여유가 있다면 재정렬하여 빈 공간에 채워넣는다는 점이다.</p>
<p>이러한 방식은 B-Tree보다 효율적으로 저장공간을 활용할 수 있다는 장점이 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[가비지 컬렉터 - JVM 밑바닥까지 파헤치기]]></title>
            <link>https://velog.io/@sang_yoon54/%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0-JVM-%EB%B0%91%EB%B0%94%EB%8B%A5%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@sang_yoon54/%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0-JVM-%EB%B0%91%EB%B0%94%EB%8B%A5%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Mon, 02 Mar 2026 11:21:54 GMT</pubDate>
            <description><![CDATA[<p>책 이름이 밑바닥까지 파헤치기라는걸 1회독을 끝내고 알아버렸다.. </p>
<p>이번 포스팅에서는 가비지 컬렉터에 대해 요점을 정리해 보려 한다.</p>
<h2 id="가비지-컬렉션은-어떤-메모리를-회수해야-하나">가비지 컬렉션은 어떤 메모리를 회수해야 하나?</h2>
<p>우선, 객체가 죽었는지 판단 해야한다. 자바에서는 이를 위해 도달 가능성 분석 알고리즘을 사용하고 있다.</p>
<h3 id="도달-가능성-분석-알고리즘">도달 가능성 분석 알고리즘</h3>
<p>이 알고리즘은 메모리 관리의 시작점을 GC 루트라고 불리는 특별한 객체들의 집합으로 설정하는 것에서 시작한다. 가비지 컬렉터는 GC 루트를 기점으로 연결된 객체들을 따라가며 참조 체인을 만듭니다. 이 체인에 묶여 있으면 도달 가능한 객체로 보아 메모리에 유지하고, 연결이 끊겨 도달할 수 없는 객체는 회수 대상으로 판단해 회수한다.</p>
<h3 id="참조-4개">참조 4개</h3>
<p>원래는 참조 타입 데이터의 값이 다른 메모리 조각의 시작 주소를 뜻한다면 참조 한다고 하였지만 JDK 1.2부터는 이 정의가 확장되어 4단계로 나뉘었다.</p>
<ol>
<li>강한 참조
코드 수준에서 new 연산자처럼 참조를 할당하는 걸 말한다. 강한 참조가 남아있는 객체는 가비지 컬렉터가 회수하지 않는다.</li>
<li>부드러운 참조
유용하지만 필수는 아닌 객체를 표현하며, 부드러운 참조만 남아있는 객체는 메모리가 넉넉하다면 살아있지만 OOM이 나기 전에 두 번째 회수 목록에 추가된다.</li>
<li>약한 참조
부드러운 참조와 비슷하지만 연결 강도가 더 약하다. 약한 참조뿐인 객체는 메모리가 넉넉하더라도 다음 GC때 회수된다.</li>
<li>유령 참조
객체 수명에 아무런 영향을 주지 않으며, 이를 통해 객체 인스턴스를 가져오는 것도 불가능하다. 이를 참조하는 유일한 목적은 대상 객체가 회수될 때 알림을 받기 위해서이다.</li>
</ol>
<h2 id="가비지-컬렉션-알고리즘">가비지 컬렉션 알고리즘</h2>
<h3 id="약한-세대-가설-강한-세대-가설-세대-간-참조-가설">약한 세대 가설, 강한 세대 가설, 세대 간 참조 가설</h3>
<ul>
<li>약한 세대 가설
대다수의 객체는 일찍 죽는다.</li>
<li>강한 세대 가설
GC에서 살아남은 횟수가 늘어날수록 객체는 앞으로도 살아남을 확률이 커진다.</li>
<li>세대 간 참조 가설
세대 간 참조의 개수는 동일 세대 안에서의 참조 개수보다 적다. 세대 간 참조의 수는 적으므로, 이를 관리하기 위해 신세대에 기억 집합이하는 맵을 하나 두어 세대 간 참조가 존재하는 구세대 조각의 객체들만 GC 루트에 추가한다.<h3 id="마크-스윕-마크-카피-마크-컴팩트">마크-스윕, 마크-카피, 마크-컴팩트</h3>
</li>
<li>마크-스윕
이름처럼 이 알고리즘은 먼저 회수할 객체들에 전부 표시를 한 다음, 표시된 객체들을 쓸어 담는 식이다. 간단하지만, 단점이 두 가지 존재한다.
첫째로, 실행효율이 일정하지 않다. 객체가 많아질수록 표시와 회수 작업량이 늘어나기 때문이다.
두 번째로는 단편화가 심해진다는 점이다. 가비지 컬렉터가 쓸고 간 자리에는 불연속적인 메모리 파편이 만들어지기 때문이다.</li>
<li>마크-카피
이는 회수할 객체가 많아질수록 효율이 떨어지는 마크-스윕의 단점을 해결하기 위해, 우선 힙을 반으로 나눠 한 번에 한 블록만 사용한다. 이때 한쪽 블록이 꽉 차면 살아남은 객체들을 다른 블록으로 복사하고 기존 블록을 한 번에 청소한다.
이 방식은 메모리 파편화 문제를 해결하고 구현이 쉬우며 실행 효율도 좋지만 가용 메모리 공간이 절반밖에 되지 않는다는 명백한 단점을 가지고 있다.</li>
<li>마크-컴팩트
이 방식은 마크-스윕 방식과 마크 과정은 똑같다. 하지만 다음 컴팩트 단계에서 생존한 모든 객체를 메모리 한쪽 끝으로 모은 후 나머지 공간을 한꺼번에 비운다.
이러한 객체 이동은 기존 참조들을 모두 변경해야 하고, 사용자 애플리케이션을 모두 멈춘 이후에 진행해야 하므로 부담이 크다.</li>
</ul>
<h2 id="가비지-컬렉터">가비지 컬렉터</h2>
<h3 id="시리얼--시리얼-올드">시리얼 &amp; 시리얼 올드</h3>
<p>가비지 컬렉션을 단 하나의 GC스레드로 처리하며, 신세대에서는 마크-카피 알고리즘을 사용하고 구세대에서는 마크-컴팩트 알고리즘을 사용한다.
가비지 컬렉션동안 다른 사용자 스레드가 모두 정지해야하는 단점이 존재한다. 하지만 시리얼 컬렉터는 다른 컬렉터의 단일 스레드 알고리즘보다 간단하고 효율적이라서 프로세서 또는 코어 수가 적은 환경이라면 시리얼 컬렉터는 스레드 상호 작용에 의한 오버헤드가 없다는 장점이 있다.</p>
<h3 id="g1gc">G1GC</h3>
<p>G1은 힙 메모리의 어느 곳이든 회수 대상에 포함할 수 있다. 이를 회수 집합(CSet)이라고 한다.</p>
<p>G1GC는 어느 세대에 속하느냐가 아닌 어느 영역에 쓰레기가 가장 많으냐를 회수 기준으로 하며, 이를 G1의 혼합 모드라고 한다.</p>
<p>G1은 연속된 자바 힙을 동일 크기의 여러 독립 리전으로 나눠서 각 리전을 고정된 역할이 아닌 필요에 따라 에덴, 생존자, 구세대 등으로 사용한다. 그래서 모든 리전은 새로 생성된 객체를 담을수도, 큰 객체를 담을수도, 오래 살아남은 객체를 담을수도 있다. 또한 이러한 구조는 G1의 정지 시간 예측 모델을 가능하게 한다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/f1678c75-815f-411d-a834-6acbf0ba2a5f/image.png" alt=""></p>
<p>처리 방식을 좀 더 구체적으로 살펴보면, G1은 각 리전의 쓰레기 누적값을 추적하며 이 값이란 가비지 컬렉션으로 회수할 수 있는 공간의 크기와 회수에 드는 시간의 경험값이다.</p>
<p>G1의 정지 시간 예측 모델의 이론적 기초는 감소 평균이며, 이는 GC동안 리전별 회수 시간, 리전별 기억 집합,에서 더럽혀진 카드 개수 등 측정할 수 있는 각 단계의 소요 시간을 기록해 이 정보로부터 평균, 표준 편차, 신뢰도 같은 통계를 분석한다. 감소 평균은 최근의 데이터에 가중치를 더 줘서 계산하는 방식이다.</p>
<p>G1의 가비지 컬렉션 과정은 대략 다음 4단계와 같다.</p>
<ol>
<li>최초 표시
GC 루트가 직접 참조하는 객체들을 표시하고 TAMS 포인터의 값을 수정한다(시작 단계 스냅숏을 수정한다). 사용자 스레드를 정지해야 하지만 시간이 매우 짧고, 마이너 GC가 실행되는 시간을 틈타 동시에 끝나므로 추가 STW는 없다.</li>
<li>동시 표시
GC 루트로부터 시작하여 객체들의 조작 도달 가능성을 분석하고, 전체 힘의 객체 그래프를 재귀적으로 스캔하며 회수할 객체를 찾는다.이는 사용자 스레드와 동시에 수행된다. 스캔이 끝나면, 시작 지점 스냅숏과 비교하여 변경된 객체를 찾는다.</li>
<li>재표시
또 한번 사용자 스레드를 멈춘다. 시작 단계 스냅숏 이후 변경된 소수의 객체만 처리하므로 빠르다.</li>
<li>복사 및 청소
통계 데이터를 기초로 리전들을 회수 순서대로 줄세우고, 목표한 정지 시간에 맞춰 계획을 세운다. 목표 리전에서 회수 후 살아남은 객체들을 새 리전으로 이주시킨다. 사용자 스레드가 멈추지만, 병렬로 처리한다.</li>
</ol>
<p>동시 표시단계를 제외한 모든 과정에서 사용자 스레드를 멈춰야 하지만, G1의 목표였던 지연시간을 제어하는 동시에 높은 처리량을 달성한다. 를 만족하였다. 여기서 정지 시간을 사용자가 설정할 수 있지만, 너무 터무니없이 적게 설정하면 쓰레기가 쌓여 전체 GC가 일어나 오히려 성능이 나빠질 수 있으니 주의하자.</p>
<h3 id="zgc">ZGC</h3>
<p>ZGC는 오라클이 개발한 저지연 가비지 컬렉터이다.</p>
<p>ZGC의 목표는 STW시간을 최대한 억제하면서 힙 크기에 상관없이 가비지 컬렉션으로 인한 정지 시간을 10 밀리초 안쪽으로 줄이고자 했다.</p>
<blockquote>
<p>ZGC는 세대 구분 없이 리전 기반 메모리 레이아웃을 사용한다. 낮은 지연 시간을 최우선 목표로 하며, 동시 마크-컴팩트 알고리즘을 구현하기 위해 읽기 장벽, 컬러 포인터, 매모리 다중 매핑 기술을 이용하는 가비지 컬렉터이다.</p>
</blockquote>
<p>동작 방식은 다음과 같다.</p>
<ol>
<li>동시 표시
G1과 비슷하게 객체 그래프를 탐색하며 도달 가능성을 분석하지만, 다른 점은 객체가 아니라 포인터에서 이뤄진다는 점이다. Marked0과 Marked1플래그가 이 표시 단계에서 갱신된다.</li>
<li>동시 재배치 준비
청소해야 할 리전들을 선정하여 재배치 집합을 만든다. G1과는 다르게, ZGC는 가비지 컬렉션 때마다 모든 리전을 스캔하기 때문에 ZGC의 재배치 집합에서는 살아남은 객체 이주 후 리전 자체를 회수할지 여부만 결정한다. 그리고 앞 단계의 표시 대상이 힙 전체이므로 재배치 집합에 포함되지 않은 리전들도 회수될 수 있다.</li>
<li>동시 재배치
이 단계에서 재배치 집합 안의 생존 객체들을 새로운 리전으로 이주시키고, 재배치 집합에 속한 각 리전의 포워드 테이블에 옛 객체와 새 객체의 이주 관계를 기록한다. 만약 사용자 스레드가 재배치 집합에 포함된 객체에 접근하려고 하면, 미리 설정해 둔 메모리 장벽이 끼어들어 새로운 객체로 포워드시키고 해당 참조의 값도 새로운 객체를 직접 가리키도록 갱신한다.</li>
<li>동시 재매핑
재매핑이란 힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신하는 작업이다.</li>
</ol>
<h3 id="generational-zgc">Generational ZGC</h3>
<p>JDK 21부터 세대 구분 ZGC가 추가되었고, 다중 매핑 메모리 제거, 다양한 장벽 최적화, 이중 버퍼를 이용한 기억 집합 관리, 밀집도 기반 리전 처리, 거대 객체 처리, ZPage 할당 방식 등 여러가지가 바뀌었다. 이들에 관해서는 따로 정리 해 두었으니 궁금하다면 한번 들어가서 확인해보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바 메모리 영역 - JVM 끝까지 파헤치기]]></title>
            <link>https://velog.io/@sang_yoon54/%EC%9E%90%EB%B0%94-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@sang_yoon54/%EC%9E%90%EB%B0%94-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Feb 2026 17:47:21 GMT</pubDate>
            <description><![CDATA[<h1 id="런타임-데이터-영역">런타임 데이터 영역</h1>
<p>책을 다 읽었지만, 2장~3장의 내용이 특히 중요한 것 같아 한번 글을 써 보고자 한다.</p>
<p>자바 개발자는 가상 머신이 제공하는 자동 메모리 관리 메커니즘 덕에 메모리 할당과 해제를 짝지어 코딩하지 않아도 메모리 누수나 오버플로 문제를 거의 겪지 않는다. 메모리 문제를 가상 머신이 해 주기 때문이다. 하지만 통제권이 개발자에게 없기 때문에 문제가 한번 터지면 가상 머신의 메모리 관리 방식을 이해하지 못하는 한 해결하기 어렵다는 문제가 있다. 이러한 문제를 방지하기 위해 메모리 영역에 관해 한번 알아보자.</p>
<p>가장 먼저, 런타임 데이터 영역의 전체 구조를 한번 보고, 그 후에 각 영역들에 관해 알아보겠다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/fd867da4-d74b-4274-8ce1-6e89791f28ce/image.png" alt=""></p>
<h2 id="pc-register">PC Register</h2>
<p> 프로그램 카운터 레지스터는 작은 메모리 영역으로, 현재 실행중인 바이트코드 명령의 주소값이다. 
 자바 가상 머신의 개념 모형에서 바이트코드 인터프리터는 이 카운터의 값을 바꿔 다음에 실행할 바이트코드 명령어를 선택하는 식으로 동작한다.</p>
<p> 자바 가상 머신에서의 멀티스레딩은 CPU 코어를 여러 스레드가 교대로 사용하는 방식으로 구현되기 때문에 특정 시각에 각 코어는 한 스레드의 명령어만 실행하게 된다. 따라서 스레드 전환 후, 이전에 실행하다 멈춘 부분부터 다시 실행해야 하므로 멈춘 부분을 기록해둔 데이터가 필요하기 때문에 각각의 스레드에 프로그램 카운터(이하 PC) 값을 저장한다.
각 PC는 서로 영향을 주면 안 되기 때문에, 서로 영향을 주지 않는 독립된 영역에 저장되고 이 영역을 스레드 프라이빗 메모리라고 한다. 이 영역은 자바 가상 머신 명세에서 OutOfMemoryMemoryError 조건이 명시되지 않은 유일한 영역이기도 하다.</p>
<h2 id="자바-가상-머신-스택">자바 가상 머신 스택</h2>
<p>프로그램 카운터처럼 자바 가상 머신 스택도 스레드 프라이밋하며, 연결된 스레드와 운명을 같이 한다. 자바 가상 머신 스택은 각 메서드가 호출될 때 마다 스택 프레임을 만들어 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환값 등의 정보를 저장하며, 이는 push, pop 동작으로 이루어진다.</p>
<p>지역 변수 테이블에는 자바 가상 머신이 컴파일타임에 알 수 있는 다양한 기본 데이터타입, 객체 참조, 반환 주소 타입을 저장한다. 이들을 저장하는 공간을 지역 변수 슬롯이라 하며, 일반적으로 슬롯 하나의 크기는 32비트다.</p>
<p>지역 변수 테이블을 구성하는 데 필요한 데이터 공간은 컴파일 과정에서 할당된다. 자바 메서드는 스택 프레임에서 지역 변수용으로 할당받아야 할 공간의 크기(슬롯의 개수)가 이미 결정되어 있어, 메서드 실행 중에는 절대 변하지 않는다.</p>
<p>자바 가상 머신 명세 에서는 스택 메모리 영역에서 두 가지 오류가 발생할 수 있도록 하였다.
첫 번째는 스레드가 요청한 스택 깊이가 가상 머신이 허용하는 깊이보다 클 때 StackOverFlowError 에러를 리턴하고, 두 번째는 스택 용량을 동적으로 확장할 수 있는 가상 머신에서 스택을 확장하려는 시점에 여유 메모리가 부족하다면 OutOfMemoryError를 던지는 것이다.</p>
<h2 id="네이티브-메서드-스택">네이티브 메서드 스택</h2>
<p>네이티브 메서드 스택은 가상 머신 스택과 매우 비슷한 역할을 한다. 차이점이라면 가상 머신 스택은 자바 메서드를 실행할 때 사용하고, 네이티브 메서드 스택은 네이티브 메서드를 실행할 때 사용하는 것이다.</p>
<h2 id="자바-힙">자바 힙</h2>
<ul>
<li>자바 애플리케이션이 사용할 수 있는 가장 큰 메모리</li>
</ul>
<p>모든 스레드가 공유하며, 가상 머신이 구동될 때 만들어진다. 객체 인스턴스를 저장한다.
힙은 사용하는 GC에 따라 보통 여러 단계로 나누어져 있는데, 이 단계들은 다음 포스팅에서 GC별 정리를 할 때 정리해 보겠다.</p>
<p>자바 힙은 모든 스레드가 공유하기 때문에 스레드 로컬 할당 버퍼 힙 여러개로 나뉘며, 이는 메모리 할당과 회수를 더 빠르게 해 준다.</p>
<p>자바 가상 머신 명세에 따르면 자바 힙은 물리적으로 떨어진 메모리에 위치해 있어도 상관없지만, 큰 객체 할당시엔 물리적으로도 연속된 메모리 공간을 사용하도록 구현하며 이는 저장 효율을 높이고 구현 로직을 단순하게 유지하기 위함이다.</p>
<h2 id="메서드-영역">메서드 영역</h2>
<blockquote>
<p>가상 머신이 읽어들인 타입 정보, 상수, 정적 변수 그리고 JIT 컴파일러가 컴파일한 코드 캐시 등을 저장한다.</p>
</blockquote>
<p>모든 스레드가 공유하며, 힙의 한 부분이지만 자바 힙과 구분하기 위해 논힙이라고 부르기도 한다.
이전에는 영구 세대가 메서드 영역에 구현되어 있었지만, JDK 8 이후로는 메타스페이스라는 개념으로 네이티브 영역으로 옮겨졌다.</p>
<p>메서드 영역에 저장되는 데이터는 대부분 상수 풀과 타입이라서 회수해도 효과가 미미하고, 특히 타입은 회수하기 매우 까다롭다. 그래서 가비지 컬렉션이 실행될 일이 별로 없다.
메서드 영역이 꽉 차서 더 이상 메모리를 할당할 수 없다면 OutOfMemoryError를 발생시킨다.</p>
<h2 id="런타임-상수-풀">런타임 상수 풀</h2>
<blockquote>
<p>메서드 영역의 일부이며, 클래스 버전, 필드, 메서드, 인터페이스 등 클래스 파일에 포함된 설명 정보에 더해 컴파일타임에 생성한 다양한 정보를 메서드 영역의 런타임 상수 풀에 저장한다.</p>
</blockquote>
<p>클래스 파일에 대해서는 정리해 놓은 포스팅이 있으니 궁금하다면 보고 오자.</p>
<p>이 영역은 동적으로 변경되며, 상수 풀의 모든 내용이 클래스 파일에 미리 완벽하게 기술되어 있지 않기 때문이다. 메서드 영역에 포함되어 있기 때문에 이를 초과하면 OutOfMemoryError를 발생시킨다.</p>
<h2 id="다이렉트-메모리">다이렉트 메모리</h2>
<blockquote>
</blockquote>
<h1 id="핫스팟-가상-머신에서의-객체-들여다보기">핫스팟 가상 머신에서의 객체 들여다보기</h1>
<p>다음으로는 메모리 모델을 알아보겠다. 가상 머신 메모리에 들어갈 내용(만들어지는 시기, 저장되는 구조, 접근 방식 등)을 알아 볼 텐데, 가장 대중적인 가상 머신인 핫스팟과 자바 힙으로 정리 해 보겠다.</p>
<h2 id="객체-생성">객체 생성</h2>
<p>언어 수준에서는 그냥 new 키워드를 쓰면 객체가 생성되지만, 가상 머신 수준에서는 이야기가 달라진다. 가상 머신이 new에 해당하는 바이트코드를 만나면, 다음 절차를 거친다.</p>
<ol>
<li>명령의 매개 변수가 상수 풀 안의 클래스를 가리키는 심벌 참조인지 확인한다.</li>
<li>심벌 참조라면, 이 심벌 참조가 뜻하는 클래스가 로딩, 해석, 초기화 되었는지 확인한다.
 ㄴ 아니라면, 클래스를 로딩한다.</li>
<li>새 객체를 담을 메모리를 할당하며, 크기는 메모리 로딩 이후에 알 수 있다.</li>
<li>GC에 맞는 방법으로 힙의 적절한 위치에 있는 메모리를 객체에 할당하며, 포인터를 이동한다. 
여기서 포인터는 힙 내의 사용 가능한 메모리를 알려주는 역할을 한다.</li>
</ol>
<p>하지만 이때, 여러 스레드가 동시에 객체를 할당하려고 하면 어떻게 될까?
포인터의 위치가 꼬여 이상한 위치에 객체를 할당 해 버리게 될 것이다.
이에 대한 해법은 크게 두 가지가 있다.</p>
<ol>
<li>메모리 할당을 동기화한다.
ㄴ CAS연산 또는 실패시 재활용 방법을 사용하여 메모리 할당 과정을 원자화 한다.</li>
<li>스레드마다 다른 메모리 공간을 할당한다.
ㄴ 전부 다른 공간을 사용하면 겹칠 일이 없다. 이러한 방식을 TLAB(Thread Local Allocation Buffer)라고 한다.</li>
</ol>
<p>메모리 할당이 끝났으면 가상 머신은 할당받은 공간을 0으로 초기화 하고, 각 객체에 필요한 설정들을 찾아 객체 헤더에 저장해 준다. 이 설정들의 예는 다음과 같다.</p>
<ul>
<li>어느 클래스의 인스턴스인지</li>
<li>클래스의 메타 정보는 어떻게 찾는지</li>
<li>이 객체의 해시 코드는 무엇인지</li>
<li>GC 세대 나이는 얼마인지 등</li>
</ul>
<p>이상의 과정이 끝났다면 가상 머신 관점에서는 새로운 객체가 다 만들어졌다. 하지만 자바 프로그램 관점에서는 아직 완료되지 않았다. 생성자가 실행되어야 하고, 필드가 아직 기본값이 0이기 때문이다. 일반적으로 new 명령어에 이어서 init 메서드까지 실행되어야 비로소 사용가능한 객체가 되는 것이다.</p>
<h2 id="객체-메모리-레이아웃">객체 메모리 레이아웃</h2>
<p>핫스팟 가상 머신은 객체를 세 부분으로 나눠서 힙에 저장하는데, 바로 객체 헤더, 인스턴스 데이터, 길이 맞추기용 정렬 헤더이다. 이제 이 부분에 대해 알아보자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/55b3769d-697c-4130-b1a3-a75e1271cef4/image.png" alt=""></p>
<h3 id="객체-헤더">객체 헤더</h3>
<blockquote>
<p>핫스팟 가상 머신은 여기 두 유형의 데이터를 담는다.</p>
</blockquote>
<ul>
<li><p>객체 자체의 런타임 데이터</p>
</li>
<li><p>객체 자체의 클래스 관련 메타데이터를 가리키는 클래스 포인터</p>
</li>
<li><p>객체 자체의 런타임 데이터
마크 워드라고 하며, 여기엔 런타임에 필요한 아주 많은 데이터가 들어가야 하므로 데이터 구조는 동적으로 의미가 달라진다.이는 대표적으로 락 플래그에 따라 달라지는데, 이 차이점도 다른 포스팅에 정리 해 두었으니 궁금하면 한번 보고 오자.</p>
</li>
<li><p>객체 자체의 클래스 관련 메타데이터를 가리키는 클래스 포인터
클래스 워드라고 하며, 자바 가상 머신은 이 데이터를 보고 이 객체가 어느 클래스의 객체인지 알 수 있다. 하지만 모든 가상 머신이 클래스 포인터를 객체 헤더에 저장하지는 않으며, 이는 객체의 메타데이터 정보를 반드시 객체 자체에서 찾을 필요는 없다는 말이다.</p>
</li>
</ul>
<h3 id="인스턴스-데이터">인스턴스 데이터</h3>
<blockquote>
<p>객체가 실제로 담고 있는 정보이다.</p>
</blockquote>
<p>프로그램 코드에서 정의한 다양한 타입의 필드 관련 내용, 부모 클래스 유무, 부모 클래스에서 정의한 모든 필드가 이 부분에 기록된다. 정보의 저장 순서는 옵션 설정(-XX:FieldsAllocationStyle)과 자바 소스 코드에서 필드를 정의한 순서에 따라 달라진다.</p>
<p>+XX:CompactFields매개 변수를 true로 설정하면(기본값임) 하위 클래스의 필드 중 길이가 짧은 것들은 상위 클래스의 변수 사이사이에 끼워 넣어져서 공간이 조금이나마 절약된다.</p>
<h3 id="정렬-패딩">정렬 패딩</h3>
<blockquote>
<p>모든 객체의 크기가 8바이트의 정수배가 될 수 있도록 맞춰주는 역할을 한다.</p>
</blockquote>
<p>객체 헤더는 8바이트의 정수배가 되도록 잘 설계되어 있어, 인스턴스 데이터만 채워주면 된다.</p>
<h2 id="객체에-접근하기">객체에 접근하기</h2>
<p>대다수 객체는 다른 객체 여러 개를 조합해 만들어진다. 그리고 자바 프로그램은 스택에 있는 참조 데이터를 통해 힙에 들어 있는 객체들에 접근해 이를 조작한다.
하지만 자바 가상 머신 명세에는 이 방법에 대해 규정하지 않았기 때문에, 이는 가상 머신이 구현하기 나름이며, 주로 핸들이나 다이렉트 포인터를 사용해 구현된다.</p>
<ul>
<li><p>핸들
핸들 방식에서는 자바 힙에 핸들 저장용 풀이 객체로 존재할 것이며, 참조에는 객체의 핸들 주소가 저장되고 핸들에는 다시 해당 객체의 인스턴스 데이터, 타입 데이터, 구조 등의 정확한 주소 정보가 담길것이다. 아래 그림으로 보자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/7e6694bb-e1ed-4e94-82d7-cffaf996cf4c/image.png" alt="">
이 방식의 장점은 참조에 안정적인 핸들의 주소가 저장된다는 점이다.</p>
</li>
<li><p>다이렉트 포인터
이 방식은 자바 힙에 위치한 객체에서 인스턴스 데이터뿐 아니라 타입 데이터에 접근하는 길도 제공해야 하며, 스택의 참조에는 객체의 실제 주소가 바로 저장되어 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/37edaeb6-e921-488d-93d2-1cd30bf5393b/image.png" alt=""></p>
</li>
</ul>
<p>이 방법은 속도가 빠르다는 장점이 있다.</p>
<p>이제 다음 포스팅에서는 GC에 대해 간단히 살펴보고, G1GC와 ZGC를 비교해 보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[락 최적화 - JVM 끝까지 파헤치기]]></title>
            <link>https://velog.io/@sang_yoon54/%EB%9D%BD-%EC%B5%9C%EC%A0%81%ED%99%94-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@sang_yoon54/%EB%9D%BD-%EC%B5%9C%EC%A0%81%ED%99%94-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Sat, 21 Feb 2026 23:29:14 GMT</pubDate>
            <description><![CDATA[<p>JDK 6에서는 동시성 효율이 크게 증가하였다. 
적응형 스핀, 락 제거, 락 범위 확장, 경량 락, 편향 락 등 다양한 락 최적화 기술을 구현하는 데 많은 자원을 투자했다.</p>
<h2 id="스핀-락과-적응형-스핀">스핀 락과 적응형 스핀</h2>
<p>이전 포스팅에서 상호 배제 동기화가 성능에 악영향을 주는 주된 원인은 블로킹이라고 언급했었다. 스레드를 일시 정지시키고 재개하려면 커널 모드로 진입해야 하기 때문에 동시성 성능에 부담을 주기 때문이다.
하지만, 분석 결과 수많은 애플리케이션이 공유 데이터를 아주 잠깐만 잠궜다가 곧바로 해제한다는 사실을 알게 되었다. 이 찰나의 시간에 스레드를 멈췄다 재개하는건 의미가 없다. 오늘날의 컴퓨터는 거의 다 멀티코어 시스템이며, 스레드를 둘 이상 병렬로 실행할 수 있어 대기 상태로 들어가지 않고도 원하는 락이 해제되는지 옆에서 지켜볼 수 있기 때문이다.</p>
<p>하지만 스핀 락은 블로킹 방식을 완전히 대체하지는 못한다. 스레드 전환 부하는 없애지만 락 시간이 길다면 프로세서 시간을 소비하는 부작용이 따르기 때문이다. 그래서 스핀 락에는 스핀하는 횟수를 정해 놓는다. 이는 옛날에는 사용자가 정할 수 있었지만 JDK 6부터는 적응형 스핀 락을 도입해 스핀의 성공 여부에 따라 횟수를 조정한다.</p>
<h2 id="락-제거">락 제거</h2>
<p>락 제거는 특정 코드 조각에서 런타임에 데이터 경합이 일어나지 않는다고 판단되면 가상 머신의 JIT 컴파일러가 해당 락을 제거하는 최적화 기법이다.</p>
<p>락을 제거할지에 대한 여부는 주로 탈출 분석에서 얻으며, 코드 조각에서 모든 데이터가 탈출하지 않고 다른 스레드에서 접근하지 않는다고 판단디면 마치 스택에 있는 데이터처럼 취급할 수 있다. 스택의 데이터는 오직 한 스레드만이 접근하므로 동기화 할 필요가 없다.</p>
<h2 id="락-범위-확장">락 범위 확장</h2>
<p>원칙적으로 코드를 작성할 때는 동기화 블록의 범위를 가능한 한 좁게 줄이는 게 좋다. 이러면 동기화된 상태에서 수행해야 할 연산의 수가 최소화되며, 이 덕분에 경합이 생기더라도 스레드들이 최대한 락을 짧게 쓰고 건네주어 전체적인 대기 시간이 줄어든다. 이 원칙은 대부분의 상황에서는 옳지만 연이은 다수의 작업이나 순환문에서 똑같은 락 객체를 잠그는 일련의 단편적인 작업들을 발견하면 락의 유효 범위를 해당 작업 전체로 늘릴 수 있다.</p>
<h2 id="경량-락">경량 락</h2>
<p>경량 락도 JDK 6때 추가되었으며, 이는 운영 체제의 뮤텍스를 이용해 구현한 기존 락보다 가볍다는 뜻이다.
경량 락은 중량 락(기존의 락)을 대체하기 위해 나온 건 아니고, 스레드 경합을 없애 뮤텍스를 사용하는 기존 중량 락의 성능 저하를 줄이는 목적으로 나왔다.</p>
<p>경량 락과 편향 락을 이해하기 위해서는 핫스팟 가상 머신에서 객체가 메모리에서 어떻게 표현되는지 알아야 한다. 핫스팟 가상 머신의 객체 헤더는 두 부분으로 나뉜다.</p>
<p>첫 번째 부분은 해시 코드와 GC 세대 나이 등 객체 자신의 런타임 데이터를 저장한다.
이 부분을 마크 워드라고 하며 편향, 경량 락 구현의 핵심이다.</p>
<p>두 번째 부분은 메서드 영역의 데이터 타입 데이터를 가리키는 포인터를 저장한다. 배열 객체인 경우 배열 길이를 저장하는 항목이 추가된다.
객체 헤더의 마크 워드는 다음과 같은 형식으로 되어있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/a61c7c06-3b8e-445e-b488-22de52e77d34/image.png" alt=""></p>
<p>객체의 락 상태는 다음과 같이 5가지가 있다. 이제 경량 락이 어떻게 동작하는지 알아보자.
만약 코드가 동기화 블록에 진입하려할 때, 락 객체가 잠겨있지 않다면(락 플래그가 01) 가상 머신은 가장 먼저 현재 스레드의 스택 프레임에 락 레코드를 생성한다. 
락 레코드는 현재 마크 워드의 복사본으로, 소유한 락 객체를 저장하는 용도의 공간이다.
아래 그림은 이때의 스레드 스택과 객체 헤더의 모습이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/c9ca2f97-d400-4ef7-b5e1-21d4412e4877/image.png" alt="">
이제 메서드가 동기화 블록에 진입하면, 가상 머신은 CAS연산으로 락 객체의 마크 워드를 락 레코드를 가리키는 포인터로 바꾼다. 변경에 성공하면 락 획득이 성공한 것이고, 마크 워드의 락 플래그를 00으로 바꿔 객체가 경량 락 상태에 있음을 표시한다. 이때의 스레드 스택과 객체 헤더는 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/cf8d67e0-2252-4a26-b5b9-7df319761b7d/image.png" alt=""></p>
<p>만약 변경에 실패한다면 같은 객체를 놓고 경쟁하는 스레드가 최소 하나는 더 있다는 뜻이고, 이때 가상 머신은 먼저 락 객체의 마크 워드가 현재 스레드의 스택 프레임을 가리키는지 확인해 이미 객체의 락을 얻었는지 확인한다. 그렇지 않다면, 다른 스레드가 있다는 뜻이므로 경량 락은 더 이상 유효하지 않다. 
객체의 마크 워드의 락 플래그를 10으로 변경해 중량 락으로 변경 후 블록된다.</p>
<p>락 해제도 동일하게 CAS 연산을 이용하여 실행된다. 객체의 마크 워드가 여전히 스레드의 락 레코드를 가리키고 락 플래그가 00이라면, CAS 연산으로 객체의 현재 마크 워드와 옮겨진 마크 워드를 교체한다. 만약 실패한다면 이는 다른 스레드가 락을 얻으려 했었고, 블록된 스레드가 다시 깨어나야 한다는 의미이다.</p>
<p>경량 락은 &quot;대부분의 락은 실제로는경합을 겪지 않는다&quot; 라는 경험 법칙에 의해 프로그램의 동기화 성능을 개선할 수 있다. 하지만 경합이 많다면 뮤텍스 부하에 더해 CAS연산까지 더해 전통적인 중량 락보다 오히려 느려진다.</p>
<h2 id="편향-락">편향 락</h2>
<p>편향 락은 경합이 없을 때 데이터의 동기화 장치들을 제거하여 프로그램 실행 성능을 높이는 최적화 기법이며 CAS 연산마저 쓰지 않게 하여 전체 동기화를 없앤다.</p>
<p>편향 락에서 &quot;편향&quot;은 락을 마지막으로 썼던 스레드가 락을 찜해둔다는 의미이다. 다음번 실행 시까지 다른 스레드가 락을 가져가지 않으면 직전에 사용한 스레드는 다시 동기화 할 필요가 없다.
편향 락이 활성화된 가상 머신에서는 어떤 스레드가 락 객체를 처음 획득하면 가상 머신이 객체 헤더의 락 플래그를 01로, 편향 모드를 1로 설정한다. 그리고 락을 얻은 스레드의 아이디를 마크 워드에 기록한다. 이때 CAS 연산을 사용한다. CAS 연산이 성공하면 편향 락을 소유한 스레드는 아무런 동기화 작업 없이 해당 동기화 블록에 몇 번이고 진입할 수 있다.</p>
<p>그러다가 다른 스레드가 락을 얻으려 시도하는 즉시 편향 모드가 종료된다. 락 객체가 현재 잠긴 상태인지에 따라 편향을 해제할지 여부를 결정한다.</p>
<p>편향 락은 JDK 15 이후부터 삭제되었다. 많은 단점들이 존재하기 때문이다.</p>
<h1 id="마치며">마치며</h1>
<p>여기까지 락에 대해 알아보았고 JDK 끝까지 알아보기도 전부 읽었다. 아직은 실무도 가보지 않았기 때문에 모든 내용이 가깝게 느껴지진 않았지만, 2<del>4장, 12</del>13장의 내용들은 공부하면서 참 도움이 많이 된 것 같다. 아직 읽지 않은 사람들은 읽어보면 큰 도움이 될 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스레드 안전성 - JVM 끝까지 파헤치기]]></title>
            <link>https://velog.io/@sang_yoon54/%EC%8A%A4%EB%A0%88%EB%93%9C-%EC%95%88%EC%A0%84%EC%84%B1-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@sang_yoon54/%EC%8A%A4%EB%A0%88%EB%93%9C-%EC%95%88%EC%A0%84%EC%84%B1-JVM-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Fri, 20 Feb 2026 20:44:30 GMT</pubDate>
            <description><![CDATA[<p>스레드 안정성이라는 용어를 많이 접해봤을 것이다. 이 용어를 브라이어 게츠는 </p>
<blockquote>
<p>여러 스레드가 한 객체에 동시에 접근할 때, 어떤 런타임 환경에서든 다음 두 조건을 모두 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있다면, 그 객체는 스레드 안전하다.</p>
</blockquote>
<ul>
<li>특별한 스레드 스케쥴링이나 대체 실행 수단을 고려할 필요 없다.</li>
<li>추가적인 동기화 수단이나 호출자 측에서 조율이 필요 없다.</li>
</ul>
<h1 id="자바-언어의-스레드-안정성">자바 언어의 스레드 안정성</h1>
<p>자바 언어에서 공유된 자원의 스레드 안정성은 다음 5단계로 나눌 수 있다. 이제 각 단계에 대해 알아보자.</p>
<ul>
<li>불변</li>
<li>절대적 스레드 안전</li>
<li>조건부 스레드 안전</li>
<li>스레드 호환</li>
<li>스레드 적대적</li>
</ul>
<h2 id="불변">불변</h2>
<p>불변이란 문자 그대로 변하지 않는다는 뜻이며, 특히 JDK 5 이후에서의 자바 언어에서 불변 객체는 객체 자체의 메서드 구현과 호출자 모두에서 아무런 안전장치가 없어도 스레드 안전하다. 불변 객체가 올바르게 만들어진다면, 이 불변성이 선사하는 안정성은 가장 직접적이고 완전무결하다.</p>
<p>자바 언어에서 기본 데이터 타입은 final로 정의되기만 하면 불변성이 보장되며, 자바 언어는 값 타입을 지원하지 않기 때문에 공유 데이터가 객체라면 객체의 메서드가 자신의 필드를 수정하지 않도록 해야한다.</p>
<p>자바 클래스 라이브러리에서 대표적인 불변 타입으로는 열거 타입이 있는데, Long, Double, BigInteger등 java.lang.Number의 하위 클래스들 역시 대부분 불변이다. 하지만 여기서 AtomicInteger과 AtomicLong은 불변이 아닌데, 이 두 클래스의 코드를 직접 읽고 수정 가능하게 한 이유를 생각해보자.</p>
<p>이들은 만들어진 목적 자체가 여러 스레드들과 값을 공유하며 이를 계속 변화시키는 것에 있기 때문이다.
그래서 값을 변경할 때 마다 새로운 객체를 생성할 필요도 없고 여러 스레드가 변수로 활용할 수 있다는 장점이 있다.</p>
<h2 id="절대적-스레드-안전">절대적 스레드 안전</h2>
<p>절대적 스레드 안전은 브라이어 게츠가 말한 안전성 정의를 완벽하게 만족한다. 하지만 그 정의는 사실 매우 엄격해, 자바 API에서 스레드 안전하다고 표시된 클래스 대부분이 절대적 스레드 안전을 의미하지는 않는다.</p>
<p>그럼 이들 중 하나인 java.util.Vector에 대해 알아보자. Vector는 add(), get() 등 모든 메서드가 synchronized 메서드이므로 스레드 안전하다고 할수 있다. 하지만 이가 호출자가 추가로 동기화 할 필요가 절대로 없다는 뜻은 아니다. 다음 코드를 살펴보자.</p>
<pre><code>// 호출자 측의 코드
int lastIndex = vector.size() - 1; // 1단계: 사이즈 확인
vector.remove(lastIndex);         // 2단계: 해당 인덱스 삭제</code></pre><p>만약 이런 코드가 존재할 때, 스레드 A가 vector의 size 10을 얻었다고 하자. 그 다음 으로는 remove()를 실행할텐데, 실행하기 직전 스레드 B가 끼어들어 remove()작업을 수행해버렸다.
그럼 A가 remove()를 실행하면 당연하게도 ArrayIndexOutOfBoundsException이 발생하게 되고, 이런 문제를 방지하기 위해서는 호출자가 Lock을 걸어 vector 객체를 보호해야 하므로 위 브라이어의 정의에 부합하지 않는다고 할 수 있다.</p>
<h2 id="조건부-스레드-안전">조건부 스레드 안전</h2>
<p>조건부 스레드 안전은 우리가 일반적으로 스레드 안전하다라고 말할 때 그 안전 수준을 말하며, 위의 Vector도 여기 속한다.
조건부 스레드 안전한 객체는 단일한 작업(메서드)을 별도 보호조치 없이 스레드로부터 안전하게 처리한다. 하지만 측정 순서로 연달아 호출하는 상황에서도 정확성을 보장하려면 호출자에서 추가로 동기화해야 할 수 있다.</p>
<h2 id="스레드-호환">스레드 호환</h2>
<p>스레드 호환이란 객체 자체는 스레드로부터 안전하지 않지만 호출자가 적절히 조치하면 멀티스레드 환경에서도 안전하게 사용할 수 있다는 뜻이다. 이런 클래스는 일반적으로 스레드 안전하지 않다라고 말하며, 자바의 클래스 대다수가 이 분류에 속한다.</p>
<h2 id="스레드-적대적">스레드 적대적</h2>
<p>스레드 적대적이란 호출자가 동기화 조치를 취하더라도 멀티스레드 환경에서 안전하게 사용할 수 없다는 뜻이다. 자바 언어는 처음부터 스레드를 지원한 덕분에 스레드 적대적 코드는 드물며, 이는 대체로 해로우므로 사용하지 말도록 하자.</p>
<h1 id="스레드-안전성-구현">스레드 안전성 구현</h1>
<p>스레드 안전성에 대해 살펴봤으니 이제 스레드 안전성을 수현하는 방법도 알아보자.</p>
<h2 id="상호-배제-동기화">상호 배제 동기화</h2>
<p>상호 배제 동기화는 가장 일반적이면서 가장 중요한 동시성 보장 수단이다. 동기화란 공유 데이터에 여러 스레드가 접근하려는 상황에서 그 어떤 시점에든 단 하나의 스레드(세마포어를 사용하면 n개의 스레드)만 데이터를 사용할 수 있다는 뜻이다.
뮤텍스가 대표적인 동기화 수단이며, 임계 영역과 세마포어도 상호 배제 구현에 자주 쓰인다. 따라서 상호 배제 동기화 라는 말에서 상호 배제가 원인 또는 수단이고, 동기화가 결과 또는 목적이다.</p>
<p>자바에서 상호 배제 동기화의 가장 기본적인 수단은 synchronized 키워드다. javac가 이 키워드를 컴파일하면 monitorenter과 monitorexit이라는 두 가지 바이트코드 명령어가 생성되며 각각 동기화 블록 전후에 실행된다. 자바 가상 머신 명세에 따르면 monitorenter 명령어를 실행할 때는 먼저 객체의 락을 얻으려 시도하고 객체가 잠겨있지 않거나 현재 스레드가 락을 소유하고 있으면 락 카운터 값을 1씩 증가시키고, monitorexit 명령어를 실행할 때 1씩 감소시키며 카운터가 0이되면 락을 해제한다. 락을 얻지 못한 스레드는 현재 락을 소유한 스레드가 일을 마치고 락을 해제할 때까지 블록된다.</p>
<p>또한, 명세를 더 읽어보면 다음 두 결론을 내릴 수 있다.</p>
<ul>
<li>같은 스레드라면 synchronized로 동기화된 블록에 다시 진입할 수 있다. 즉, 락을 이미 소유한 스레드는 동기화된 블록에 여러번 진입해도 블록되지 않는다.</li>
<li>synchronized로 동기화된 블록은 락을 소유한 스레드가 작업을 마치고 락을 해제할 때까지 다른 스레드의 진입을 무조건 차단한다. 락을 소유한 스레드가 락을 해제하도록 강제할 방법이 없다는 뜻이기도 하다. 또한 락을 기다리는 다른 스레드를 인터럽트해 깨울 방법도 없다.</li>
</ul>
<p>따라서 synchronized 명령어는 주의해서 사용해야 한다.</p>
<p>다음으로 락을 소유한다는 건 실행 비용 측면에서 상당히 무거운 작업인데, 이는 스레드를 재우고 깨우는데 운영 체제의 도움을 얻을 수 밖에 없고 이에 따라 사용자 모드와 커널 모드 사이의 전환이 불가피하기 때문이다.
하지만 자바 가상 머신은 나름대로의 최적화를 수행한다. 예컨대 스레드를 블록하라고 운영체제에 알리기 전에 바쁜 대기(spinning 또는 busy waiting) 코드를 추가하여 모드 전환이 자주 일어나지 않게끔 하기도 한다.</p>
<p>동기화의 수단이 synchronized밖에 없는것은 아니다. JDK 5부터는 java.util.concurrent.locks.lock 인터페이스가 새로운 상호 배제 동기화를 제공한다.
Reentrantlock이 Lock 인터페이스를 구현한 대표적인 예이며, synchroinzed와 똑같이 재진입이 가능한 락이며 코드는 다르지만 사용법은 synchronized와 매우 비슷하다. 하지만 대기 중 인터럽트, 페어 락, 둘 이상의 조건 지정 등 몇 가지 진보된 기능을 제공한다.</p>
<ul>
<li>대기 중 인터럽트
락을 소유한 스레드가 오랜시간 락을 해제하지 않을 때 같은 락을 얻기 위해 대기중인 스레드들은 락을 포기하고 다른 일을 할 수 있어 실행시간이 매우 긴 동기화 블록을 다루는 데 유용하다.</li>
<li>페어 락
같은 락을 얻기위해 대기하는 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 얻는 방식이다. 기본은 언페어 락이지만 페어 락을 사용하면 Reentrantlock의 성능이 급격히 감소할 수 있으므로 주의하자.</li>
<li>둘 이상의 조건 지정
Reentrantlock은 동시에 여러 개의 Condition 객체와 연결지을 수 있다. synchronized도 연결 지을 수 있지만 조건을 둘 이상 주고 싶다면 또 다른 락을 추가해야한다. Reentrantlock에서는 newCondition() 메서드를 여러 번 호출하기만 하면 된다.</li>
</ul>
<p>이 둘의 성능은 크게 차이나지 않으며 Reentrantlock이 synchronized의 기능을 모두 포괄한다. 그럼 Reentrantlock을 사용하는게 맞아 보이지만 이 책의 저자는 다음과 같은 이유로 synchronized를 추천한다.</p>
<ul>
<li>synchronized는 자바 구문 수준의 동기화 수단이며 매우 명확하고 간결하다. 모든 자바 개발자가 synchronized에 익숙하므로 고급 기능이 필요하지 않다면 그냥 사용하자.</li>
<li>Lock은 finally 블록에서 해제해야 한다. 그렇지 않으면 동기화로 보호한 코드 블록에서 예외 발생 시 소유중인 락이 해지되지 않을 가능성이 있다. 락 해제를 개발자가 직접 보장해야 한다. 반면 synchronized는 예외 발생 시 락 해제까지 가상 머신이 보장한다.</li>
<li>동기화 최적화는 자바 가상 머신에 맡기는 게 유리하며, synchronized를 사용하면 자바 가상 머신이 스레드 및 락과 관련된 다양한 내부 정보를 활용할 수 있지만 Lock을 이용하면 자바 가상 머신이 어느 스레드가 어느 락을 소유하고 있는지 알기 어렵기 때문이다.</li>
</ul>
<h2 id="논블로킹-동기화">논블로킹 동기화</h2>
<p>상호 배제 동기화의 가장 큰 문제는 스레드 일시 정지와 깨우기가 초래하는 성능 저하이며, 이런 동기화를 블로킹 동기화라고 한다.
문제 해결 방법이라는 관점에서 상호 배제 동기화는 비관적 동시성 전략에 속한다. 락과 같은 동기화 장치가 없다면 반드시 문제가 생길거라 가정하여, 경합이 실제로 벌어지는지와 상관없이 락을 건다(실제로는 가상 머신이 필요 없는 락의 상당수를 없애 준다). 이렇게 하면, 사용자 모드에서 커널 모드로 전환되고, 락 카운터를 계산하고, 블록된 스레드를 깨워야 하는지 확인하는 작업이 뒤따른다. 
하지만 하드웨어 명령어 집합이 발전하면서 또 다른 선택지가 생겨났다. 충돌 감지를 기반으로 하는 낙관적 동시성 전략이 그 주인공이다. 이 전략에서는 잠재적으로 위험할 수 있더라도 일단 작업을 진행한다. 공유 데이터를 놓고 경합하는 다른 스레드가 없다면 성공이며, 충돌이 발생하면 보완 조치를 취하는데 가장 흔한것은 경합하는 공유 데이터가 없을 때까지 계속 재시도하는 것이다. 
이렇게 하면 스레드를 블록할 일이 없으므로 논블로킹 동기화라고 하며, 이 방식을 따르는 프로그래밍 기법을 락프리 프로그래밍이라고 한다.</p>
<p>이 전략에 하드웨어 명령어 집합의 발전이 필요했던 이유는 작업 진행과 충돌 감지라는 두 단계를 한 명령어처럼 원자적으로 수행할 수 있어야 했기 때문이다. 
대표적인 예들 중, 자바에서 이용할 수 있는 CAS(Compare-and-Swap)에 대해 알아보자.</p>
<p>CAS 명령어는 피연산자를 세 개 요구하며, 메모리 위치(V), 예상하는 이전 값(A), 새로 설정할 값(B)이다.
CAS 명령어를 실행하는 프로세서는 V의 값이 A와 같으면 B로 교체하고, 그렇지 않으면 아무 작업도 하지 않는다. 그리고 V의 값과 관계없이 A를 반환한다. 이 작업들은 원자적으로 수행되고, 중간에 다른 스레드가 끼어들 수 없다.</p>
<p>이 연산은 핫스팟 내부 머신이 특별하게 처리하며, 그 방식은 JIT컴파일하여 메서드 호출은 없애고 밑단의 프로세서에 맞는 CAS 명령어로 대체하는 식이다. 이는 무조건 인라인하다고 생각해도 된다.</p>
<p>CAS는 완벽하다고 보일 수 있지만, 실제로는 ABA문제라는 허점이 하나 존재한다.</p>
<p>예를 들어 변수 V를 처음 읽었을 때 A이고 할당할 준비가 되었을 때도 A이라고 하자. 하지만 그 사이에 값이 B로 변경됐다 다시 A로 돌아왔다면 CAS연산은 한 번도 변경되지 않았다고 오해할 것이다. 정답은 모른다 이며, 이 문제를 ABA문제라고 한다.
이 문제를 해결하고자 java.util.concurrnent.atomic 패키지는 변숫값을 버전 관리하여 정확성을 보장한다. 하지만 이 문제는 대부분 프로그램 동시성의 정확성에 영향을 주지 않기 때문에, 해당 패키지의 입지는 견고하지 않다. 따라서, ABA문제를 해결해야 한다면 기존의 상호 배제 동기화를 이용하는게 효율적이다.</p>
<h2 id="동기화가-필요-없는-매커니즘">동기화가 필요 없는 매커니즘</h2>
<p>태생부터 스레드에 안전한 코드 중 두 가지를 알아보자.</p>
<h3 id="재진입-코드">재진입 코드</h3>
<p>순수 코드라고도 하며, 실행 중간에 아무 때나 끼어들어 다른 코드를 수행하고 와도 상관없는 코드를 말한다.제어가 돌아오면 마치 아무일도 없던 것처럼 오류도 없고 결과에도 영향을 끼치지 않는다. 특히 멀티스레딩 맥락에서는(세마포어 같은 요소가 없다면) 재진입 코드도 스레드 안전한 코드로 간주할 수 있다. 재진입성은 스레드 안전성보다 근본적인 특성이라서 그 자체로 스레드 안전함을 보장한다. 하지만 스레드 안전한 코드라고 해서 모두가 재진입 가능한건 아니다.</p>
<p>재진입 코드에는 몇 가지 특징이 있다. 예를 들어 전역 변수, 힙에 저장된 데이터, 공유 시스템 자원을 전혀 사용하지 않고 그 대신 필요한 모든 정보를 매개 변수로 받는다. 재진입이 불가능한 다른 메서드를 호출하지도 않는다.
재진입이 가능한 코드인지 판단하는 원칙은 다음과 같다. 메서드의 반환값을 예측할 수 있고 똑같은 입력에는 항상 똑같은 출력이 반환된다면, 그 메서드는 재진입 가능하고 스레드 안전하다.</p>
<h3 id="스레드-로컬-저장소">스레드 로컬 저장소</h3>
<p>코드 조각에서 사용하는 데이터를 다른 코드와 공유해야 한다면, 데이터를 공유하는 다른 코드도 같은 스레드에서 수행된다고 보장되는지 확인하자. 이 점만 보장된다면 공유 데이터의 가시 범위를 동일한 스레드로 제한할 수 있다.
이 방식을 광범위하게 적용하면 많은 웹 서버에서 스레드 로컬 저장소를 이용해 스레드 안전성 문제를 해결할 수 있다.</p>
<p>자바 언어에서는 여러 스레드가 같은 변수에 접근해야 한다면 volatile 키워드를 사용해서 휘발성 변수로 선언할 수 있다. 또한, java.lang.Threadlocal 클래스를 이용해서 스레드별 저장소를 만들 수 있다.
Thread 객체는 ThreadLocalMap 객체를 하나씩 가지고 있으며, 이는 키-값 쌍을 저장하는 객체로 키는 ThreadLocal.threadLocalHashCode이고 값은 로컬 스레드 변수다. ThreadLocal 객체 각각에는 고유한 ThreadLocalHashCode값이 담겨 있어서 대응하는 로컬 스레드 변수를 ThreadLocalMap에 넣고 뺄 수 있다.</p>
<p>이번 포스팅은 여기까지 하고, 다음으로는 락 최적화에 대해 알아보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인덱스로 API 응답속도 개선하기]]></title>
            <link>https://velog.io/@sang_yoon54/%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A1%9C-API-%EC%9D%91%EB%8B%B5%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sang_yoon54/%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A1%9C-API-%EC%9D%91%EB%8B%B5%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 13 Feb 2026 17:00:16 GMT</pubDate>
            <description><![CDATA[<p>먼저, 채팅 메시지는 시간이 지날수록 계속 쌓이는 구조이다.
현재 채팅 메시지 테이블의 구조는 다음과 같고, 조회 시에도 JPA를 이용하여 Sequence Scan을 하고있다.
ChatMessageEntity                                                    </p>
<pre><code>  id (PK)                                                             
  room_id (FK → chat_rooms)
  sender_id
  sender_name
  content
  sent_at
  message_type</code></pre><p>하지만 만약 메시지의 개수가 매우 많아진다면 이런 방식의 조회는 분명 응답 시간에 영향을 줄 것이고, 이번에 테스트로 10만개의 메시지를 저장해 직접 개선 전과 후의 응답시간 차이를 비교해 보겠다.</p>
<p>먼저, 테스트용 채팅방 100개와 메시지 10만개를 db에 집어넣은 다음 Jmeter에서 부하 테스트를 실행해보자.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/dee314d1-d123-4102-9bd3-446f96751c16/image.png" alt=""></p>
<p>현재 api에서 사용하는 메시지 조회 방식은 이렇다.</p>
<pre><code>jamjam_database=#  EXPLAIN ANALYZE                                                                                                                                                                                                                  
  SELECT * FROM chat_message_entity
  WHERE room_id = 9000
  ORDER BY sent_at DESC
  LIMIT 20;
                                                             QUERY PLAN                                                             
------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=2709.14..2709.19 rows=20 width=219) (actual time=66.150..66.180 rows=20 loops=1)
   -&gt;  Sort  (cost=2709.14..2711.58 rows=975 width=219) (actual time=66.148..66.158 rows=20 loops=1)
         Sort Key: sent_at DESC
         Sort Method: top-N heapsort  Memory: 30kB
         -&gt;  Seq Scan on chat_message_entity  (cost=0.00..2683.20 rows=975 width=219) (actual time=1.700..65.278 rows=1000 loops=1)
               Filter: (room_id = 9000)
               Rows Removed by Filter: 99177
 Planning Time: 0.333 ms
 Execution Time: 66.223 ms
(9 rows)</code></pre><p>Plan을 살펴보면 Seq Sean으로 10만전에 달하는 전체 데이터를 읽은 후, 99177건의 쓸모 없는 데이터를 버리고 정렬하였다. </p>
<p>이런 비효율적인 쿼리를 한번 개선해보자. 현재 쿼리는 room_id가 9000인 메시지를 보낸 순서로 정렬하는 작업을 수행한다.</p>
<p>먼저 인덱스의 첫 번째 컬럼으로 room_id를 지정하면, DB 엔진은 9000번 방의 데이터가 시작되는 지점으로 즉시 점프한다. 이 덕분에 9만 9천 건의 불필요한 데이터를 읽지 않을 수 있다.</p>
<p>또한, 다음 두 번째 컬럼으로 sent_at을 사용해 역순으로 미리 정렬하여 저장한다. 따라서 DB는 데이터를 다 뽑은 뒤에 다시 정렬할 필요 없이, 인덱스 상단에서부터 딱 20개만 읽어서 바로 사용자에게 던져줄 수 있다.</p>
<pre><code>jamjam_database=# CREATE INDEX idx_chat_message_room_sent_at 
ON chat_message_entity (room_id, sent_at DESC);
CREATE INDEX</code></pre><p>인덱스를 지정한 후 다시 쿼리를 실행해보자.</p>
<pre><code>jamjam_database=#  EXPLAIN ANALYZE                                                                                                                                                                                                                  
  SELECT * FROM chat_message_entity
  WHERE room_id = 9000                                                                                                                                       
  ORDER BY sent_at DESC
  LIMIT 20;
                                                                           QUERY PLAN                                                                            
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..60.82 rows=20 width=219) (actual time=0.033..0.075 rows=20 loops=1)
   -&gt;  Index Scan using idx_chat_message_room_sent_at on chat_message_entity  (cost=0.42..2945.22 rows=975 width=219) (actual time=0.031..0.055 rows=20 loops=1)
         Index Cond: (room_id = 9000)
 Planning Time: 0.167 ms
 Execution Time: 0.098 ms
(5 rows)</code></pre><p>가장 먼저 속도를 비교해보자. 기존 66.223 ms -&gt; 0.098 ms로 약 675배나 빨라진 것을 확인할 수 있다. 또한, 이전에는 10만건을 다 뒤져서 찾았지만 이번에는 인덱스를 통해 room_id가 9000인 메시지를 즉시 찾아낸 것을 볼 수 있다. 마지막으로, 인덱스가 이미 정렬되어 있으므로 정렬 단계 또한 사라졌음을 알 수 있다.</p>
<p>이제 다시 Jmeter에서 부하 테스트를 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/3063ff9b-f532-4f53-aa01-a991866c1434/image.png" alt="">
먼저 평균 응답 시간을 살펴보자. 이전에는 5.2초가 걸렸지만 인덱싱 후 0.07초로 무려 98.5%가량 시간이 줄어든 것을 볼 수 있다. 또한 표준편차값을 살펴보면 이도 1,104에서 31.07로 매우 줄어들어 모든 요청에서 걸린 시간이 차이가 크게 나지 않는 것을 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클래스 파일 - JVM 밑바닥까지 파헤치기]]></title>
            <link>https://velog.io/@sang_yoon54/%ED%81%B4%EB%9E%98%EC%8A%A4-%ED%8C%8C%EC%9D%BC-JVM</link>
            <guid>https://velog.io/@sang_yoon54/%ED%81%B4%EB%9E%98%EC%8A%A4-%ED%8C%8C%EC%9D%BC-JVM</guid>
            <pubDate>Tue, 03 Feb 2026 12:39:52 GMT</pubDate>
            <description><![CDATA[<h1 id="클래스-파일의-구조">클래스 파일의 구조</h1>
<p>가장 먼저, 클래스 파일의 구조부터 살펴보자.</p>
<p>자바 가상 머신 명세에 따르면, 클래스 파일에 데이터를 저장하는데는 C언어의 구조체와 비슷한 의사 구조를 이용한다. 이 의사 구조에는 부호 없는 숫자와 테이블이라는 두 가지 데이터 타입만 존재한다.</p>
<ul>
<li>부호 없는 숫자(unsign number): 기본 데이터 타입을 포함하며, u1, u2, u4, u8은 각각 1바이트, 2바이트, 4바이트, 8바이트를 표현한다. 숫자, 인덱스 참조, 수랑값을 기술하거나 UTF-8로 인코딩된 문자열 값을 구성할 수 있다.</li>
<li>테이블: 여러 개의 부호 없는 숫자나 또 다른 테이블로 구성된 복합 데이터 타입을 표현한다. 구분이 쉽도록 테이블 이름은 관례적으로 _info로 끝나며, 테이블은 계층적으로 구성된 복합 구조의 데이터를 설명하는데 사용된다. 클래스 파일 전체는 본질적으로 테이블이며 구조는 다음과 같다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/f52951c6-1a38-41d1-a355-959160a7424a/image.png" alt="">
여기서 같은 타입의 데이터 여러개를 표현할 때 그 개수가 정해져 있지 않다면 *_count 형태로 개수를 알려주며, 이처럼 {개수 + 개수만큼의 데이터 타입} 형태를 해당 타입의 컬렉션이라고 한다.</p>
<p>이제 위 클래스 파일 구조의 항목들에 대해 알아보자.</p>
<h2 id="매직-넘버와-클래스-파일의-버전">매직 넘버와 클래스 파일의 버전</h2>
<p>모든 클래스 파일의 처음 4바이트는 매직 넘버로 이루어져 있으며, 이는 가상 머신이 허용하는 클래스 파일인지 여부를 확인하는데 사용된다.
매직 넘버 다음의 4바이트는 클래스 파일의 버전 번호이고, 5<del>6번째 바이트는 마이너 버전, 7</del>8번째 바이트는 메이저 버전을 의미한다. 자바 버전 번호는 45부터 시작한다. JDK 1.1이후로 주요 JDK의 릴리스 버전은 1씩 증가하며, 상위 버전 JDK는 하위 버전을 인식할 수 있지만 하위버전 JDK는 상위 버전의 클래스 파일을 실행할 수 없다.</p>
<p>참고로 JDK1.2 부터는 마이너 버전을 사용하지 않아 모두 0으로 고정되어 있으며, JDK 12부터 일부 복잡한 새 기능을 공개 베타 형태로 출시할 때 65535로 지정해 자바 가상 머신이 인지할 수 있도록 하였다.</p>
<h2 id="상수-풀">상수 풀</h2>
<p>상수 풀은 클래스 파일의 자원 창고라 할 수 있다. 클래스 파일 구조에서 다른 클래스와 가장 많이 연관된 부분이기도 하고, 차지하는 공간도 대체로 가장 크다.</p>
<p>상수 풀에 들어있는 상수의 수는 고정적이지 않으므로 이를 알려주는 u2타입 데이터가 필요하다.  이 개수를 셀때는 0이 아닌 1부터 시작함에 유의하자. 0번째를 비운 이유는 상수 풀 인덱스를 가르키는 데이터에서 &#39;상수 풀 항목을 참조하지 않음&#39;을 표현해야 하는 특수한 경우에 인덱스를 0으로 설정하기 위함이다.</p>
<p>클래스 파일 구조에서 오직 상수 풀만이 개수를 1부터 세고 나머지 카운트는 전부 0부터 센다.</p>
<p>상수 풀에는 두 가지 유형의 상수가 담기며, 리터럴과 심벌 참조이다.
리터럴은 자바 언어 수준에서 이야기하는 상수와 비슷한 개념이다.
심벌 참조는 컴파일과 관련된 개념이며, 다음 유형의 상수들이 포함된다.</p>
<ul>
<li>모듈에서 export하거나 import하는 패키지</li>
<li>클래스와 인터페이스의 완전한 이름</li>
<li>필드 이름과 서술자</li>
<li>메서드 이름과 서술자</li>
<li>메서드 핸들과 메서드 타입</li>
<li>동적으로 계산되는 호출 사이트와 동적으로 계산되는 상수</li>
</ul>
<p>상수 풀 안의 상수 각각은 모두 테이블이며, JDK21을 기준으로 총 17가지의 상수 타입이 존재한다. 이 17가지 타입의 테이블들은 공통적으로 u1타입의 플래그 비트로 시작하며, 그 값은 현재 상수가 속한 상수 타입을 나타낸다.</p>
<p>상수 풀의 각 항목 타입은 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/dc951d7f-22c8-4d06-a81d-24079b5b5d19/image.png" alt="">
javap 명령어를 사용하면 상수 풀의 내용을 볼 수 있다.</p>
<p>아래 코드의 예시이다.</p>
<pre><code>package org.example;

public class TestClass {
    private int m;

    public int inc(){
        return m + 1;
    }
}</code></pre><p>.class 파일(bytecode)
<img src="https://velog.velcdn.com/images/sang_yoon54/post/0c86aaa2-2abb-4215-a1df-4f465c13fea8/image.png" alt=""></p>
<p>javap -v .\target\classes\org\example\TestClass.class 결과</p>
<pre><code>public class org.example.TestClass
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // org/example/TestClass
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object.&quot;&lt;init&gt;&quot;:()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // &quot;&lt;init&gt;&quot;:()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               &lt;init&gt;
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // org/example/TestClass.m:I
   #8 = Class              #10            // org/example/TestClass
   #9 = NameAndType        #11:#12        // m:I
  #10 = Utf8               org/example/TestClass
  #11 = Utf8               m
  #12 = Utf8               I
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               LocalVariableTable
  #16 = Utf8               this
  #17 = Utf8               Lorg/example/TestClass;
  #18 = Utf8               inc
  #19 = Utf8               ()I
  #20 = Utf8               SourceFile
  #21 = Utf8               TestClass.java
{
  public org.example.TestClass();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object.&quot;&lt;init&gt;&quot;:()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/example/TestClass;

  public int inc();
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #7                  // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lorg/example/TestClass;
}
SourceFile: &quot;TestClass.java&quot;</code></pre><p>이렇게 상수 풀 내의 내용들을 확인할 수 있다. 하지만 여기서 ()I, ()V, LineNumberTable같은 소스 코드에서 찾아볼 수 없는 내용들이 있는데 이는 컴파일러가 자동으로 생성한 것들로 나중 포스트에서 알아보겠다. </p>
<p>또한, 17개 상수 유형의 구조는 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/7be6fef0-fc47-4ce0-a7f5-8de18ac5bbc8/image.png" alt="">
<img src="https://velog.velcdn.com/images/sang_yoon54/post/4b80472b-7c72-4782-a8ff-b122739a5e69/image.png" alt=""></p>
<h2 id="접근-플래그">접근 플래그</h2>
<p>상수 풀 다음의 2바이트는 현재 클래스의 접근 정보를 식별하는 접근 플래그이다. 현재 클래스 파일이 표현하는 대상이 클래스인지 인터페이스인지, public인지, abstract인지, 클래스인 경우 final인지 등의 정보가 담긴다. 접근 플래그의 종류와 의미는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/7ed1ee3d-ed2b-4c39-b7cf-c96596bf873f/image.png" alt="">
access_flags의 크기는 2바이트이므로 최대 16개의 플래그 비트를 사용할 수 있지만, 현재는 9개만 정의되어 있으며 정의되지 않은 플래그 비트의 값은 모두 0이어야 한다.</p>
<h2 id="클래스-인덱스-부모-클래스-인덱스-인터페이스-인덱스">클래스 인덱스, 부모 클래스 인덱스, 인터페이스 인덱스</h2>
<p>이 정보들은 클래스 파일의 상속 관계를 규정하며, 앞의 두 인덱스는 u2타입이고 세 번째 인덱스는 u2타입 데이터들의 묶음이다.</p>
<h3 id="클래스-인덱스와-부모-클래스-인덱스">클래스 인덱스와 부모 클래스 인덱스</h3>
<p>클래스 인덱스와 부모 클래스 인덱스는 각각 현재 클래스와 부모 클래스의 완전한 이름을 결정하는데 쓰인다. 여기서 자바 언어는 다중 상속을 허용하지 않으므로 부모 클래스 인덱스는 하나뿐이다. 또한 여기서 모든 클래스들의 부모인 java.lang.Object만 부모 클래스가 존재하지 않으므로, java.lang.Object를 제외한 모든 자바 클래스의 부모 인덱스 클래스 값은 0이 아니다.</p>
<h3 id="인터페이스-인덱스">인터페이스 인덱스</h3>
<p>인터페이스 인덱스 컬렉션은 현재 클래스가 구현한 인터페이스들을 기술한다.
컬렉션 내의 인터페이스 순서는 자바 코드에서 implements키워드 뒤에 나열한 순서를 따른다.</p>
<p>클래스 인덱스, 부모 클래스 인덱스, 인터페이스 인덱스는 모두 접근 플래그 뒤에 나오며 클래스 인덱스와 부모 클래스 인덱스의 값은 CONSTANT_Class_info 타입의 클래스 서술자 상수를 가리킨다. 또한 클래스의 완전한 이름 문자열은 CONSTANT_Class_info 타입에 담긴 상수의 값을 인덱스로 하는 CONSTANT_Utf8_info 타입으로 정의된다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/1b2ffe86-ad4a-4bed-ae57-5fcea85b63db/image.png" alt="">
인터페이스 인덱스 컬렉션의 첫 항목은 u2타입이며, 값은 인덱스 테이블의 크기. 즉, 현재 클래스가 구현한 인터페이스의 수이다. </p>
<h2 id="필드-테이블">필드 테이블</h2>
<p>필드 테이블은 인터페이스나 클래스 내에 선언된 변수들을 설명하는데 쓰인다. 자바 언어에서 필드란 클래스 변수와 인스턴스 변수를 뜻하며, 메서드 내에 선언된 지역 변수는 필드가 아니다.</p>
<p>필드 테이블의 구조는 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/8c37f8a3-a907-475b-84cf-f99072771ce2/image.png" alt="">
필드의 access_flags 항목이 가질 수 있는 값은 클래스의 access_flags와 매우 비슷하며, 데이터 타입은 u2이고 지원하는 항목은 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/6754613b-ff30-4200-bd19-9b6a506a441d/image.png" alt="">
access_flags다음에는 name_index와 descriptor_index가 온다. 이 둘은 상수 풀에서 인덱스로, 각각 필드의 단순 이름과 필드 및 메서드 서술자 참조를 가리킨다.</p>
<h2 id="메서드-테이블">메서드 테이블</h2>
<p>메서드 테이블의 구조는 필드 테이블의 구조와 완전히 흡사하며, 이는 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/61f72d70-8d04-479c-bccc-c040a63f8e57/image.png" alt="">
각 데이터 항목의 의미도 일부를 제외하면 거의 비슷하다. 접근 플래그와 속성 테이블 컬렉션에서 선택할 수 있는 값만 살짝 다른 뿐이다. 메서드에서는 volatile과 transient키워드를 붙일 수 없으므로 메서드 테이블의 접근 플래그에는 ACC_SYNCHROINZED, ACC_NATIVE, ACC_STRICTFP, ACC_ABSTRACT 플래그가 추가되었다. 메서드 테이블에서 이용할 수 있는 플래그 종류와 값은 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/2cc3c0b1-fdf9-483e-8265-14d404acd3e0/image.png" alt="">
이렇게 메서드 정의를 명확하게 표현할 수 있다. 그러면 메서드 본문은 어디 있을까? 
메서드 본문은 javac 컴파일러에 의해 바이트코드로 변환된 후 메서드 속성 테이블 컬렉션의  Code 영역에 따로 저장된다.</p>
<h2 id="속성-테이블">속성 테이블</h2>
<p>그럼 이제 속성 테이블에 대해 알아보자.</p>
<p>속성 테이블은 다른 데이터 항목들에 비해 순서, 길이, 내용 등의 제약이 살짝 느슨하며 순서에도 엄격하지 않다. &lt;자바 가상 머신 명세&gt;에서도 기존 속성 이름과 중복되지 않는 한, 자체 제작한 컴파일러가 새로운 속성 정보를 속성 테이블에 추가할 수 있도록 하고 있다.</p>
<p>자바 가상 머신 명세가 인식하지 못하는 속성은 무시해버리며, JDK 21에서는 총 30개의 속성이 존재한다. 속성 목록은 다음과 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/07ea853b-901b-4d5e-b740-6f0b2ee9c00b/image.png" alt="">
<img src="https://velog.velcdn.com/images/sang_yoon54/post/865306d4-a21b-43aa-87b0-6686aef1913d/image.png" alt=""></p>
<p>속성 타입은 모드 CONSTANT_Utf8 타입 상수를 참조해 표현하며, 속성값의 길이는 u4타입으로 표현된다. 이때, 속성값 자체의 구조는 완벽하게 사용자 정의가 가능하며, 속성 테이블의 구조는 다음을 만족시켜야한다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/f5c0820d-b05b-4655-93e9-51819abb42e0/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Arthas - Java Diagnostic Tool]]></title>
            <link>https://velog.io/@sang_yoon54/Arthas-Java-Diagnostic-Tool</link>
            <guid>https://velog.io/@sang_yoon54/Arthas-Java-Diagnostic-Tool</guid>
            <pubDate>Fri, 30 Jan 2026 18:24:16 GMT</pubDate>
            <description><![CDATA[<p>이번에 JVM 밑바닥까지 파헤치기를 보면서, 지금 AWS EC2에서 운영중인 서비스를 모니터링 하고싶다는 생각이 들었다.</p>
<p>하지만 현재 서비스는 Alpine Linux 기반의 JRE 이미지를 베이스로 운영되고 있어, jps나 jstat 같은 기본적인 JDK 모니터링 툴조차 포함되어 있지 않은 상태였다. 또한 JConsole이나 VisualVM의 Remote기능을 활용하기에는 EC2에 전용 Port를 열어야 하고, 기타 설정도 만지는 등 시간이 좀 걸릴 것 같았다. </p>
<p>따라서, Alibaba에서 만든 Arthas라는 오픈소스를 이용해 보기로 했다.
<a href="https://github.com/alibaba/arthas">Arthas Git Repository</a></p>
<p>설치과정부터 알아보자.</p>
<p>먼저, 자바 프로그램을 돌리고 있는 컨테이너 안으로 접속하자.
여기서 Quick Start에 나와있는 방법대로 </p>
<pre><code>curl -O https://arthas.aliyun.com/arthas-boot.jar</code></pre><p>명령어를 입력한다. 필자는 runtime Base Image로 eclipse-temurin:21-jre-alpine를 이용하고 있어서 wget으로 진행하였다.</p>
<p>그럼 다음과 같이 arthas-boot.jar 파일이 받아진다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/f5890095-8b62-4f11-80c1-917e33f19fe3/image.png" alt=""></p>
<p>이제 다음 명령어를 입력하면, 에러가 발생한다. 자바 프로세스를 못 찾고 있으므로, pid를 설정하라는 문구이다. 혹시 에러가 발생하지 않으면, </p>
<pre><code>java -jar arthas-boot.jar</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/c20ea5f4-46a5-454a-b884-b74c145e41fa/image.png" alt=""></p>
<p>aux 명령어를 사용해 현재 프로세스를 확인하자.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/2d163d04-e711-4bfa-a966-d758c2d41405/image.png" alt=""></p>
<p>1번 프로세스에서 자바를 실행하고 있으므로, pid를 선택해준다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/0ea2ccbc-0ebe-429a-a550-8384909ab5b9/image.png" alt=""></p>
<p>그런데 또, 연결 실패 예외가 발생한다.
에러 내용을 살펴보면, com/sun/tools/attach/AgentLoadException 이런 Class를 찾지 못했다고 하는데, 이는 Arthas가 JDK에 있는 Attach API를 통해 동작하기 때문이다.</p>
<p>하지만 현재 JRE를 이용하고 있기 때문에, 다른 방법으로 설치하여야 한다.</p>
<p>먼저 다시 우분투 환경으로 돌아간다. 이곳에 JDK를 설치 후, Arthas를 설치하고, 컨테이너 내부 프로세스에 Agent를 Attach API로 주입해 줄 것이다.</p>
<pre><code>sudo apt update
sudo apt install -y openjdk-21-jdk-headless</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/9ab67119-543e-47f3-b223-114a1b59815a/image.png" alt=""></p>
<pre><code>curl -O https://arthas.aliyun.com/arthas-boot.jar</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/a515aa84-c055-4dc4-bbc0-a685efdf0998/image.png" alt=""></p>
<pre><code>CONTAINER_PID=$(sudo docker inspect -f &#39;{{.State.Pid}}&#39; jamjam-backend)
sudo java -jar arthas-boot.jar --target-ip 0.0.0.0 $CONTAINER_PID</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/31867318-f135-410e-abd2-0f19e13668a4/image.png" alt=""></p>
<p>이렇게 로그에서 연결은 실패했지만, Attach 작업은 성공하였다. 이는 도커 컨테이너에서 네트워크를 격리하고 있기 때문이다.</p>
<p>이제 다시 컨테이너 안으로 들어가자. 여기서 아까 실패했던 명령어를 다시 입력해주면, </p>
<pre><code>java -jar arthas-boot.jar 1</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/8e9862a2-d170-4875-b4a7-04890ff9b63e/image.png" alt="">
성공적으로 접속이 된 모습을 볼 수 있다.</p>
<p>이제 간단한 기능들을 알아보자.</p>
<p>먼저, 대시보드를 보자.</p>
<pre><code>dashboard</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/cbfd3a0a-cb27-481b-8388-d0f8cc66f765/image.png" alt="">
이렇게 현재 실행중인 스레드 정보, 메모리 정보, 런타임 정보를 볼 수 있다. 이는 기본값인 5초 주기로 계속 pooling되며, 옵션으로 조정이 가능하다.</p>
<p>또한, 각 메소드를 실행하는데 걸리는 시간 또한 알 수 있다.</p>
<pre><code>trace &lt;패키지경로.클래스명&gt; &lt;메서드명&gt;</code></pre><p><img src="https://velog.velcdn.com/images/sang_yoon54/post/7081d299-79dc-4150-8938-5b02293de022/image.png" alt="">
다음과 같이, 각 메소드마다 걸리는 시간이 출력된다. </p>
<p>이를 통해 어느 메소드에서 시간이 많이 소모되는지도 알 수 있다. 와일드카드 사용도 가능하니 참고하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Redis Client 비교 - Jedis]]></title>
            <link>https://velog.io/@sang_yoon54/Java-Redis-Client-%EB%B9%84%EA%B5%90-Jedis</link>
            <guid>https://velog.io/@sang_yoon54/Java-Redis-Client-%EB%B9%84%EA%B5%90-Jedis</guid>
            <pubDate>Tue, 16 Dec 2025 19:27:18 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/06e682a7-21fd-46e4-a7fd-4a77d74fa069/image.png" alt=""></p>
<p>이번 시리즈에서는 Java Redis Client중 Jedis와 Lettuce를 비교해 볼 생각이다.</p>
<p>저번 포스팅에서 이론적인 부분은 어느정도 비교를 했으니, 이번에는 코드를 살펴보며 이론적인 부분이 어떻게 구현되어 있는지 살펴보겠다.</p>
<p>가장 먼저 싱글 스레드 기준으로, 이 사용자 요청이 들어오면 가장 먼저 실행되는 Jedis.java 파일을 살펴보겠다.</p>
<p>애플리케이션 코드에서 get 요청이 들어오면, 다음 메소드가 실행된다.</p>
<pre><code>  @Override
  public String get(final String key) {
    checkIsInMultiOrPipeline(); // 1
    return connection.executeCommand(commandObjects.get(key)); // 2 
  }</code></pre><p>1번 주석은 Pipeline 모드나 multi 모드를 판별하는 역할을 하며, 싱글스레드 기준으로 살펴보겠으므로 넘어가겠다.</p>
<p>2번 주석은 실제 명령어가 실행되는 부분이다. 한번 들어가서 무슨 행동을 하는지 살펴보겠다.</p>
<pre><code>  public &lt;T&gt; T executeCommand(final CommandObject&lt;T&gt; commandObject) {
    final CommandArguments args = commandObject.getArguments(); // 1
    sendCommand(args); // 2
    if (!args.isBlocking()) {
      return commandObject.getBuilder().build(getOne()); // 3
    } else {
      try {
        setTimeoutInfinite();
        return commandObject.getBuilder().build(getOne()); // 3
      } finally {
        rollbackTimeout();
      }
    }
  }</code></pre><p>Connection 클래스의 executeCommand() 메소드이며, 크게 3부분으로 나뉜다.</p>
<ol>
<li><p>final CommandArguments args = commandObject.getArguments();
파라미터로 받은 commandObject 안에서 커맨드 종류, 인자들, 이 커맨드가 blocking인지 여부, 응답을 어떻게 만들지를 꺼내는 단계이다.</p>
</li>
<li><p>sendCommand(args)
여기서는 Redis 서버에 커맨드를 실행시키기 위한 요청을 전송하며, 이는 쓰기 작업이 이루어진다. 내부에서 connect()로 현재 커넥션이 없다면 Redis와의 Connection후 커맨드를 전송한다.</p>
</li>
<li><p>commandObject.getBuilder().build(getOne())
여기서는 isBlocking에 따라 절차가 달라지는데, 이는 커맨드의 종류 때문이다. 만약 GET, SET처럼 서버가 즉시 응답할 수 있는 메소드라면 바로 응답을 받을 수 있지만 BLPOP, BRPOP, XREAD BLOCK처럼 오래 기다려야 응답을 받을 수 있는 커맨드의 경우는 setTimeoutInfinite()으로 Timeout을 크게 늘려 응답을 받을 때 까지 Blocking하고 결과를 반환한다.</p>
</li>
</ol>
<p>그럼 이제 비교적 간단하게 한 명령을 실행해 보았는데, 이제는 JedisPool 설정을 통해 멀티스레드 환경에서 Jedis가 어떻게 동작하는지 정리해보겠다.</p>
<pre><code>@Bean
  public RedisConnectionFactory redisConnectionFactory() {
    RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
    config.setDatabase(0);

    JedisPoolConfig pool = new JedisPoolConfig();
    pool.setMaxTotal(3);
    pool.setMaxIdle(3);
    pool.setMinIdle(0);
    pool.setMaxWait(Duration.ofSeconds(2));

    JedisClientConfiguration clientCfg = JedisClientConfiguration.builder()
        .usePooling().poolConfig(pool)
        .build();

    return new JedisConnectionFactory(config, clientCfg);
  }</code></pre><p>보통 RedisConfig설정을 할때 이런식으로 할텐데, pool설정에서 개수와 대기시간같은 설정을 할 수 있다.</p>
<p>이제 사용자 Application Code에서 </p>
<pre><code>try (Jedis jedis = pool.getResource()) {
      return jedis.get(&quot;k&quot;);
    }</code></pre><p>다음과 같은 방식으로 Pool을 하나 빌려와 사용한다. 
getResource는 다음과 같은 구성으로 되어있다.</p>
<pre><code>@Override
  public Jedis getResource() {
    Jedis jedis = super.getResource();
    jedis.setDataSource(this);
    return jedis;
  }</code></pre><p>여기서는 또 부모 클래스의 getResource로 jedis 객체를 생성하고(물론 이미 존재하면 재사용한다.) 이 setDataSource로 jedis가 Pool 소속이라는걸 알려 close()를 리턴하게 한다.
그리고 상위 클래스인 Pool의 getResource는 다음과 같은 구조를 가지고 있다. </p>
<pre><code>  public T getResource() {
    try {
      return super.borrowObject();
    } catch (JedisException je) {
      throw je;
    } catch (Exception e) {
      throw new JedisException(&quot;Could not get a resource from the pool&quot;, e);
    }
  }
</code></pre><p>여기서 또 부모 클래스의 borrowObject()를 호출하는데 중요한 부분만 정리해보자면,</p>
<pre><code>public T borrowObject(Duration borrowMaxWaitDuration) throws Exception {
        this.assertOpen();
        Instant startInstant = Instant.now();
        boolean negativeDuration = borrowMaxWaitDuration.isNegative();
        Duration remainingWaitDuration = borrowMaxWaitDuration;
        AbandonedConfig ac = this.abandonedConfig;
        if (ac != null &amp;&amp; ac.getRemoveAbandonedOnBorrow() &amp;&amp; this.getNumIdle() &lt; 2 &amp;&amp; this.getNumActive() &gt; this.getMaxTotal() - 3) {
            this.removeAbandoned(ac);
        }

        PooledObject&lt;T&gt; p = null;
        boolean blockWhenExhausted = this.getBlockWhenExhausted();

        while(p == null) {
            remainingWaitDuration = remainingWaitDuration.minus(this.durationSince(startInstant));
            boolean create = false;
            p = (PooledObject)this.idleObjects.pollFirst();
            if (p == null) {
                p = this.create(remainingWaitDuration);
                if (!PooledObject.isNull(p)) {
                    create = true;
                }
            }

            if (blockWhenExhausted) {
                if (PooledObject.isNull(p)) {
                    p = negativeDuration ? (PooledObject)this.idleObjects.takeFirst() : (PooledObject)this.idleObjects.pollFirst(remainingWaitDuration);
                }

                if (PooledObject.isNull(p)) {
                    throw new NoSuchElementException(this.appendStats(&quot;Timeout waiting for idle object, borrowMaxWaitDuration=&quot; + remainingWaitDuration));
                }
            } else if (PooledObject.isNull(p)) {
                throw new NoSuchElementException(this.appendStats(&quot;Pool exhausted&quot;));
            }

            if (!p.allocate()) {
                p = null;
            }

            if (!PooledObject.isNull(p)) {
                try {
                    this.factory.activateObject(p);
                } catch (Exception e) {
                    try {
                        this.destroy(p, DestroyMode.NORMAL);
                    } catch (Exception var14) {
                    }

                    p = null;
                    if (create) {
                        NoSuchElementException nsee = new NoSuchElementException(this.appendStats(&quot;Unable to activate object&quot;));
                        nsee.initCause(e);
                        throw nsee;
                    }
                }

                if (!PooledObject.isNull(p) &amp;&amp; this.getTestOnBorrow()) {
                    boolean validate = false;
                    Throwable validationThrowable = null;

                    try {
                        validate = this.factory.validateObject(p);
                    } catch (Throwable t) {
                        PoolUtils.checkRethrow(t);
                        validationThrowable = t;
                    }

                    if (!validate) {
                        try {
                            this.destroy(p, DestroyMode.NORMAL);
                            this.destroyedByBorrowValidationCount.incrementAndGet();
                        } catch (Exception var12) {
                        }

                        p = null;
                        if (create) {
                            NoSuchElementException nsee = new NoSuchElementException(this.appendStats(&quot;Unable to validate object&quot;));
                            nsee.initCause(validationThrowable);
                            throw nsee;
                        }
                    }
                }
            }
        }

        this.updateStatsBorrow(p, this.durationSince(startInstant));
        return (T)p.getObject();
    }</code></pre><p>먼저, idle한 jedis 객체가 있는지 확인하고, 없으면 새로 만들기를 시도한다.</p>
<pre><code>p = idleObjects.pollFirst();
if (p == null) {
  p = create(remainingWaitDuration);
}</code></pre><p>하지만 사용중인 jedis객체가 아까 설정한 pool 최대 개수에 도달했으면 대기한다. 이 경우는 시간 제한을 걸어놓고 대기한다.</p>
<pre><code>if (!p.allocate()) { p = null; }</code></pre><p>만약 꺼낸 Jedis 객체가 이미 누군가에게 할당이 됐으면, p를 null로 설정해 밑의 while문에서 다른 Jedis 객체를 재할당받는다.</p>
<pre><code>while(p == null) {
            remainingWaitDuration = remainingWaitDuration.minus(this.durationSince(startInstant));
            boolean create = false;
            p = (PooledObject)this.idleObjects.pollFirst();
            if (p == null) {
                p = this.create(remainingWaitDuration);
                if (!PooledObject.isNull(p)) {
                    create = true;
                }
            }

            if (blockWhenExhausted) {
                if (PooledObject.isNull(p)) {
                    p = negativeDuration ? (PooledObject)this.idleObjects.takeFirst() : (PooledObject)this.idleObjects.pollFirst(remainingWaitDuration);
                }

                if (PooledObject.isNull(p)) {
                    throw new NoSuchElementException(this.appendStats(&quot;Timeout waiting for idle object, borrowMaxWaitDuration=&quot; + remainingWaitDuration));
                }
            } else if (PooledObject.isNull(p)) {
                throw new NoSuchElementException(this.appendStats(&quot;Pool exhausted&quot;));
            }

            if (!p.allocate()) {
                p = null;
            }

            if (!PooledObject.isNull(p)) {
                try {
                    this.factory.activateObject(p);
                } catch (Exception e) {
                    try {
                        this.destroy(p, DestroyMode.NORMAL);
                    } catch (Exception var14) {
                    }

                    p = null;
                    if (create) {
                        NoSuchElementException nsee = new NoSuchElementException(this.appendStats(&quot;Unable to activate object&quot;));
                        nsee.initCause(e);
                        throw nsee;
                    }
                }

                if (!PooledObject.isNull(p) &amp;&amp; this.getTestOnBorrow()) {
                    boolean validate = false;
                    Throwable validationThrowable = null;

                    try {
                        validate = this.factory.validateObject(p);
                    } catch (Throwable t) {
                        PoolUtils.checkRethrow(t);
                        validationThrowable = t;
                    }

                    if (!validate) {
                        try {
                            this.destroy(p, DestroyMode.NORMAL);
                            this.destroyedByBorrowValidationCount.incrementAndGet();
                        } catch (Exception var12) {
                        }

                        p = null;
                        if (create) {
                            NoSuchElementException nsee = new NoSuchElementException(this.appendStats(&quot;Unable to validate object&quot;));
                            nsee.initCause(validationThrowable);
                            throw nsee;
                        }
                    }
                }
            }
        }</code></pre><p> 이렇게 동시성 문제도 예방하고 있는것을 볼 수 있다.</p>
<p> 이런 구조를 가지고 있어, 한 스레드당 Jedis 객체 하나를 사용하고, 결과적으로는 스레드 하나당 Connection 하나를 사용하는 꼴이 된다. Jedis의 Connection은 한 소켓에 대해 sendCommand()로 write , getOne()로 read작업을 수행한다.</p>
<p>하지만 만약 다른 스레드가 Connection()을 공유 한다면, 동시에 write()작업을 수행하게 될 수 있고, TCP연결은 메세지를 한 덩어리로 보장해주지 않기 때문에, 요청이 섞여서 꼬여버릴수가 있다.</p>
<p>또한 요청과 응답이 묶이지 않는다는 문제도 있어 이러한 구조를 택해야한다.</p>
<p>이런 이유로 Jedis/Connection은 멀티스레드 공유에 대해 thread-safe를 보장하지 않으며, 풀에서 빌려 스레드당 단독 사용하도록 하는 구조를 택한다.</p>
<p>다음 글에서는 Lettuce가 이를 어떻게 해결했는지 알아보도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Redis Client]]></title>
            <link>https://velog.io/@sang_yoon54/Java-Redis-Client</link>
            <guid>https://velog.io/@sang_yoon54/Java-Redis-Client</guid>
            <pubDate>Mon, 15 Dec 2025 16:13:57 GMT</pubDate>
            <description><![CDATA[<p>이번에 Lettuce관련 스터디를 시작하기로 하였다.
부끄럽지만, Redis를 연동해서 사용해 보았는데도 불구하고 Lettuce가 뭔지도 몰라서 검색하고 나서야 어떤 기술인지 대충 알게 되었다.</p>
<p>그래서, 이번 포스팅을 진행하며 어떤 녀석인지 자세히 알아가보는 과정을 거치려고 한다.</p>
<h2 id="먼저-redis-client에는-여러-종류가-있다">먼저, Redis Client에는 여러 종류가 있다.</h2>
<p>Spring에서 Redis를 사용할때는, 대부분 RedisTemplate를 사용하기 때문에 클라이언트 종류를 신경쓰지 않는 경우가 종종 있다.</p>
<p>하지만 Redis 성능 문제나 장애가 발생했을 때, 어떤 클라이언트를 사용했는지를 아는 것이 매우 중요하다.
왜냐하면 클라이언트마다 I/O 방식, 스레드 모델, 커넥션 관리 방식이 모두 다르기 때문이다.</p>
<p>클라이언트는 대표적으로 Jedis, Lettuce, Redission이 있는데 이를 간단히 소개해보도록 하겠다.</p>
<h3 id="jedis">Jedis</h3>
<p>Jedis는 가장 오래된 Redis Java 클라이언트로, 한때 스프링에서 가장 널리 쓰이던 방식이다. 다른 클라이언트에 비해 가볍고, 구현하기 쉽다는 장점이 있다.</p>
<p>Jedis는 Blocking I/O기반으로, 요청하는 스레드가 응답을 받을때까지 멈추는 특징을 가지고 있다. 이로 인해 멀티스레드 환경에서 사용하려면 JedisPool을 설정해주어야 한다.
하지만 레디스 자체의 커넥션 수에 제한이 걸리거나 합리적인 커넥션 수를 초과하지 않기 위해, 커넥션 수를 제한해야 하는 상황이 있다는 점을 유의해야 한다.</p>
<h3 id="lettuce">Lettuce</h3>
<p>Lettuce는 Netty 기반의 비동기/논블로킹 Redis 클라이언트로,
Spring Boot 2.x 이후로 기본 채택되었다. Netty 기반의 복잡하지만 유연한 추상화를 통해 애플리케이션을 손쉽게 확장할 수 있는 구조를 제공한다는 특징이 있다.</p>
<h4 id="lettuce의-특징">Lettuce의 특징</h4>
<ul>
<li><p>Netty 기반
Lettuce는 Netty의 이벤트 루프(EventLoop) 기반으로 동작한다.
즉, 요청·응답 처리를 특정 스레드가 점유하지 않고 이벤트 기반으로 처리하고, 이를 통해 고성능/고확장성을 제공한다.</p>
<ul>
<li><p>비동기 / 논블로킹 I/O
요청 시 스레드가 블로킹되지 않는다.
비동기 Future/Promise를 통해 응답을 받을 수 있다.
Reactor 기반의 ReactiveRedisTemplate와도 자연스럽게 연동된다.</p>
</li>
<li><p>스레드 안전(Thread-safe)
Lettuce는 하나의 커넥션을 멀티스레드에서 안전하게 사용할 수 있다.
이는 Jedis 대비 커넥션 수를 크게 줄일 수 있다는 의미다.</p>
</li>
<li><p>동기, 비동기, 리액티브 모두 지원
Sync API, Async API, Reactive API(Reactor 사용) 필요한 형태에 맞게 다양하게 선택할 수 있다.</p>
</li>
</ul>
</li>
</ul>
<h3 id="redisson">Redisson</h3>
<p>Redisson은 단순 Redis 클라이언트를 넘어서 Redis 기반 분산 자료구조 및 분산 락 기능을 제공하는 고급 라이브러리다.</p>
<h4 id="redisson의-핵심-특징">Redisson의 핵심 특징</h4>
<ul>
<li>분산 락</li>
<li>분산 큐</li>
<li>분산 Set/Map/List</li>
<li>Pub/Sub</li>
<li>Scheduler 등으로, </li>
</ul>
<p>Redis를 단순 캐시가 아니라 분산 시스템을 구축하기 위한 핵심 구성요소로 활용할 때 매우 유용하다.</p>
<p>단순 캐시 용도라면 오버킬일 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Netty란? - (1)]]></title>
            <link>https://velog.io/@sang_yoon54/Netty%EB%9E%80</link>
            <guid>https://velog.io/@sang_yoon54/Netty%EB%9E%80</guid>
            <pubDate>Sat, 13 Dec 2025 19:19:50 GMT</pubDate>
            <description><![CDATA[<p>Redis Client에 대해 공부하던중, Lettuce / Redisson이 Netty 기반으로 동작한다고 해서 이에 관해 간단하게라도 알아보고 넘어가려고 한다.</p>
<p>우선 Netty를 이해하기 위해서는 사전 지식이 필요하며 이들은 Non-blocking 기반의 Multiplexing Network I/O와 ByteBuffer 그리고 EventLoop에 대한 내용이다.</p>
<p>이 글과 그림들의 내용은 binghe819님의 <a href="https://mark-kim.blog/netty_deepdive_1/">네티 이해하기 (Netty Deep Dive)</a> 기반으로 작성되었다.</p>
<h3 id="netty란">Netty란?</h3>
<p>Netty는 비동기 이벤트 기반 네트워크 애플리케이션 프레임워크로,
Java에서 고성능 서버·클라이언트 네트워크 통신을 쉽게 구현하기 위해 만들어진 라이브러리다.</p>
<p>Netty의 Reactor 패턴 동작 원리를 그림으로 표현하면 다음과 같으며, 
<img src="https://velog.velcdn.com/images/sang_yoon54/post/017ff4f8-b25d-4ffe-971d-3acfa333752e/image.png" alt=""></p>
<p>여기서 Event Loop와 Handler는 다음과 같은 역할을 한다.</p>
<ul>
<li>Event Loop
무한 반복문을 실행하며 Selector로부터 I/O 이벤트가 발생할 때까지 대기하다가 이벤트가 발생하면 해당 이벤트를 처리할 수 있는 Handler에게 디스패치한다.
보통 특정 Channel에 대한 이벤트를 큐에 삽입할 때, 해당 이벤트를 처리할 수 있는 Handler도 같이 첨부해준다.</li>
<li>Handler
이벤트를 받아 비즈니스 로직을 수행한다. (수행완료하고 결과에 맞는 이벤트를 다시 발행하기도한다.)</li>
</ul>
<p>Client와의 Connention을 통해 생성된 Socket Channel을 Event Loop에 등록하면, 이 열린 Channel의 I/O부터 close까지의 생명주기를 모두 event loop가 관리하게 된다는 뜻이다.</p>
<p>이를 통해 Channel의 제어 흐름, 멀티 스레딩, 동시성 제어등을 Event Loop가 처리하게 된다.</p>
<h3 id="netty의-기본적인-동작-방식">Netty의 기본적인 동작 방식</h3>
<p>Netty는 NIO Selector에 등록된 Channel에서 발생하는 이벤트들을 Channel에 매핑된 핸들러가 처리하는 구조이다.</p>
<p>이는 다음과 같은 그림으로 표현할 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/b1a12ae8-1722-4c7d-b5d8-c158559fcfb7/image.png" alt="">
Selector에 등록된 Socket Channel Key중 이벤트가 발생한 SelectedKey들을 Task Queue에 넣고 등록된 Channel에 Handler Pipeline에 위임하여 네트워크 read/write와 비즈니스 로직을 처리한다.</p>
<h4 id="boss-group과-child-group">Boss Group과 Child Group</h4>
<p>Netty는 설정을 통해 여러 Event Loop를 Group으로 묶을 수 있으며 Group을 조합해서 애플리케이션을 구성한다.
이는 같은 역할을 수행하는 복수의 EventLoop를 EventLoop Group으로 묶을 수 있으며, EventLoopGroup은 크게 BossGroup과 ChildGroup으로 구분된다.
아래는 Netty의 EventLoop를 구성할 때의 가장 기본이 되는 구조를 도식화한 그림이다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/45a9bf42-442c-4777-93bc-e8892222a5f8/image.png" alt=""></p>
<ul>
<li>Boss Group
새로운 클라이언트의 연결 요청만 처리한다. 이는 새로운 클라이언트와의 Socket Channel을 생성하고, accept() 이벤트를 처리한다.
Boss Group은 클라이언트와의 연결만 처리하고 나머지 read/write같은 실제 비즈니스 로직의 경우는 Child Group 내의 EventLoop에 위임하여 처리한다.</li>
<li>Child Group
Boss Group으로부터 위임받은 I/O작업을 실제로 수행한다. </li>
</ul>
<p>하지만 위 구조가 절대적이지는 않으며, 클라이언트 역할로 Netty가 구동되는 경우(Redis Java Client, gRPC 등)는 Group 하나만으로 동작한다.</p>
<h3 id="netty를-이루고있는-구성요소">Netty를 이루고있는 구성요소</h3>
<p>Netty는 실제 클라이언트-서버간의 네트워크 통신과정에서 여러 컴포넌트들이 각자의 역할을 수행하며 협력하며, 각 컴포넌트별 관계는 아래와 같다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/10b2b13f-70d6-42a4-af09-270927b2f6da/image.png" alt=""></p>
<p>이제 각 컴포넌트의 역할을 알아보겠다.</p>
<h4 id="channel">Channel</h4>
<p>Channel은 TCP 커넥션에 대한 I/O 작업을 처리하는 역할을 수행하며, 다음과 같은 구조를 가지고 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/14a46e33-da11-4288-bb11-4d5035f6bce0/image.png" alt="">
구체적으로, 다음과 같은 기능을 제공한다.</p>
<ul>
<li>Channel의 현재 상태
채널이 열렸는지, 연결됐는지 같은 정보</li>
<li>Channel에 대한 설정
send/receive 버퍼 크기 같은 정보</li>
<li>I/O명령
read, write, close, connect같은 명령</li>
<li>Channel에 대한 I/O 이벤트를 처리할 Pipeline rhksfus cjfl</li>
<li>연결된 Event Loop 조회
그리고 필요에 따라, ChannelPipeline이나 ChannelHandler를 추가 및 제거하여 Channel I/O 이벤트에 대한 처리를 수행할 수 있다.</li>
</ul>
<h4 id="unsafe-interface">Unsafe interface</h4>
<p>Channel 인터페이스를보면 내부 클래스로 Unsafe 인터페이스가 정의되어있다.
이 Unsafe란 용어는 Netty 뿐만 아니라 자바내에서도 많이 사용되는 용어이며, 보통 C나 커널 명령에 부합하는 low-level 명령을 호출할 때의 인터페이스 역할을한다.</p>
<p>Netty에서의 Unsafe도 커널내 Socket I/O 작업을 모두 수행하며, Channel내 I/O 작업이 필요하면 모두 이 Unsafe의 구현체에게 위임한다.</p>
<p>보통 유저 레벨의 코드에선 unsafe한 명령에 속하는 read, write의 I/O 명령을 직접 호출할 일이 거의 없으며 공식 문서에도 가능한 이 Unsafe 인터페이스의 구현체를 사용하길 추천하고있다.</p>
<p>실제 Unsafe 인터페이스의 구현체는 Channel의 epoll, kqueue같은 구현체별로 다 가지고 있다.</p>
<h4 id="channelfuture와-channelpromise">ChannelFuture와 ChannelPromise</h4>
<ul>
<li>ChannelFuture
Channel I/O에 대한 비동기 처리 pending completion 결과를 나타낸다.
Netty의 모든 작업은 비동기로 동작하며, 이때 모든 I/O 작업 요청의 결과로 void가 아닌 ChannelFuture를 return하게 된다. </li>
</ul>
<p>ChannelFuture는 Java의 비동기 API인 Future와 유사한 역할을 수행하며, ChannelFuture를 통하면 비동기 요청한 처리가 완료되었는지 I/O 상태를 확인할 수 있다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/6f4d52e3-810f-453d-9a9b-7cca5ec27e13/image.png" alt="">
비동기 처리기때문에 요청후 바로 결과가 반환되지만, 실제 I/O 작업은 위와 같이 uncompleted, completed(success, fail, cancel)로 나뉘어 상태가 관리된다.</p>
<p>그리고 CompletableFuture과 동일하게 작업이 성공했던 실패했던 완료된다면 Callback Listener를 붙여 I/O 완료에 대한 처리를 할 수 있다.</p>
<ul>
<li>ChannelPromise
Channel write에 대한 처리를 수행하는 Handler인 ChannelOutboundHandler의 대부분의 메서드는 작업 완료시 알림을 받기위해 ChannelPromise를 인자로 받는다.</li>
</ul>
<p>ChannelPromise는 ChannelFuture의 하위 인터페이스로써 setSuccess() 또는 setFailure() 와 같이 write 가능한 메서드를 정의하여 ChannelFuture을 불변으로 만들어준다.</p>
<h4 id="channelhandler와-channelpipeline">ChannelHandler와 ChannelPipeline</h4>
<p>Netty의 가장 핵심이자 기본이 되는 개념인 Channel이 TCP연결 후 생성되고 나면 ChannelPipeline을 Channel에 구성하게 된다. Netty는 기본적으로 데이터를 받기 위한 Input Stream을 Inbound, 내보내는 Output Stream을 Outbound라고 한다.
그리고 ChannelPipeline은 여러 ChannelHandler의 조합을 의미하며, ChannelHandler는 Channel에 대한 실질적인 read/write를 호출하고, 비즈니스를 수행하는 컴포넌트이다.</p>
<p>ChannelHandler는 개발자가 자유롭게 추가, 삭제할 수 있으며 여러 Handler를 구성하여 서로 상호작용할 수 있도록 제어할 수 있다. 또한, 각 Channel엔 여러 Handler의 조합을 나타내는 고유한 ChannelPipeline을 가지고 있다.</p>
<p>여기서 중요한점은, 새로운 Channel이 TCP연결 후 생성되고 나면, Netty는 자동으로 ChannelPipeline도 같이 생성하여 Channel에 매핑한다는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/sang_yoon54/post/038b5eb3-938f-4714-9908-bdcd5e44b1cd/image.png" alt=""></p>
<p>ChannelPipeline은 위 그림과 같이 Inbound 이벤트는 Linked-List Head에서 Tail까지의 InboundHandler로 전달되며, 반대로 Outbound 이벤트는 Linked-List의 Tail에서 Head까지 OutboundHandler로 전달된다.</p>
<h5 id="channelhandler">ChannelHandler</h5>
<p>ChannelHandler는 ChannelPipeline안에서 조합을 통해 실행된다. </p>
<ul>
<li>ChannelHandler엔 Blocking code를 금지하는것이 좋다.
ChannelPipeline내의 각 ChannelHandler는 EventLoop에서 발생한 I/O 이벤트를 전달받아 처리하게된다.</li>
</ul>
<p>이때 EventLoop는 Single Thread 기반으로 동작하기때문에, 해당 스레드를 Blocking하지 않는게 굉장히 중요하다.</p>
<p>만약 Blocking하게 된다면 해당 EventLoop에 등록된 Channel 이벤트 처리에 아주 큰 부정적인 영향을 끼치게된다.</p>
<p>이로인해 ChannelHandler내 비즈니스 로직은 무조건 비동기와 callback을 활용해 Non-blocking하게 처리하는게 좋다.
비즈니스 처리를 다른 Thread로 비동기 요청하고, ChannelFuture, CompletableFuture등을 반환받아 처리가 완료되면 callback으로 Channel에 처리 완료된 내용을 I/O 부분만 Netty EventLoop에 위임하는 방식으로 구현하거나 동일한 Non-Blocking 스펙인 Reactive Streams의 구현체를 같이 사용함으로써 모든 로직 자체를 Non-Blocking하게 사용하면 된다.</p>
<ul>
<li><p>ChannelHandlerAdaptor
ChannelInboundHandler와 ChannelOutboundHandler 모두 순수 인터페이스라 모든 메서드를 구현해줘야하는 불편함이 존재하는데, Netty는 편의를 위해 ChannelHandlerAdaptor 추상 클래스를 구현한 어탭터 클래스를 제공한다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/d8eafba3-b131-49c5-b554-f64c2da547a2/image.png" alt="">
ChannelInboundHandlerAdaptor: Inbound I/O Event 어댑터 구현체.
ChannelOutboundHandlerAdaptor: Outbount I/O Operation 어댑터 구현체.
ChannelDuplexHandler: Inbound, Outbound Event 처리용 어댑터 구현체.</p>
</li>
<li><p>ChannelHandlerContext
ChannelPipeline 내의 ChannelHandler간의 상호작용에 사용되는 객체이며, 이는 ChannelPipeline이 생성되면서 같이 생성된다.
Context 객체를 통해 Handler들은 upstream과 downstream으로 이벤트를 전달할 수도, Pipeline을 동적으로 변경시킬 수도 있으며, key:value형태의 정보를 저장할 수도 있다.</p>
</li>
</ul>
<p>ChannelHandlerContext의 기능중 하나가 바로 Handler간의 이벤트 전파 (event propagation)이다.
아래 그림은 실제 ChannelPipeline, ChannelHandler, ChannelHandlerContext가 어떤 관계를 가지는지 보여준다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/c7fa482f-9c20-4be6-81e8-5ab365ec9ce2/image.png" alt="">
그리고 실제 이벤트 전파를 위한 실질적인 실행(invoke)은 ChannelHandlerContext가 수행한다. 아래는 세 Component가 어떻게 상호작용하는지 보여준다.
<img src="https://velog.velcdn.com/images/sang_yoon54/post/54dd53ce-9eff-48f6-acc2-eb3cc547b96d/image.png" alt="">
ChannelHandlerContext는 네이밍에서 알 수 있듯이, 파이프라인내 현재 Handler 실행 컨텍스트를 저장하고 처리하는 역할을 수행한다. 쉽게 말해, 다음 Handler를 찾아 Handler에 실행을 위임하고, 현재 스레드가 어느것이냐에따라 바로 실행하거나 TaskQueue에 넣는등의 처리를 수행한다.</p>
<p>이벤트 전파를 위해 ChannelHandlerContext는 아래와 같이 InboundHandler와 OutboundHandler에 대한 전파 메서드를 제공한다.</p>
<pre><code>// inbound event propagation
ChannelHandlerContext.fireChannelRegistered()
ChannelHandlerContext.fireChannelActive()
ChannelHandlerContext.fireChannelRead(Object)
ChannelHandlerContext.fireChannelReadComplete()
ChannelHandlerContext.fireExceptionCaught(Throwable)
ChannelHandlerContext.fireUserEventTriggered(Object)
ChannelHandlerContext.fireChannelWritabilityChanged()
ChannelHandlerContext.fireChannelInactive()
ChannelHandlerContext.fireChannelUnregistered()

// outbound event propagation
ChannelOutboundInvoker.bind(SocketAddress, ChannelPromise)
ChannelOutboundInvoker.connect(SocketAddress, SocketAddress, ChannelPromise)
ChannelOutboundInvoker.write(Object, ChannelPromise)
ChannelHandlerContext.flush()
ChannelHandlerContext.read()
ChannelOutboundInvoker.disconnect(ChannelPromise)
ChannelOutboundInvoker.close(ChannelPromise)
ChannelOutboundInvoker.deregister(ChannelPromise)</code></pre><h4 id="channelinitializer">ChannelInitializer</h4>
<p>Channel이 생성되었을 때 ChannelPipeline을 생성, 초기화 및 매핑해주는 역할을 수행하는 컴포넌트이며, 코드로 알아보면 다음과 같다.</p>
<pre><code>public class ChannelInitializerExample extends ChannelInitializer&lt;SocketChannel&gt; {

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();

        pipeline.addLast(new EchoServerFirstChildHandler());
    }
}</code></pre><h4 id="bootstrap">Bootstrap</h4>
<p>Selector를 사용하면 여러 리소스를 모니터링 할 수 있으며, 이벤트가 준비되면 적절한 Handler에 위임하고 다시 모니터링을 계속한다. 이를 EventLoop라고한다.
그리고 이러한 EventLoop는 Group으로 묶여, boss와 child로 구분된 후 각자의 역할을 수행한다.</p>
<p>Bootstrap은 Channel, EventLoopGroup등 Netty로 작성한 네트워크 애플리케이션의 동작 방식과 환경을 설정하는 도우미 클래스다. 이를 통해 Netty 애플리케이션을 시동할 수 있으며, 각종 설정도 할 수 있다.</p>
<p>bootstrap으로 설정할 수 있는 요소는 다음과 같다.</p>
<ul>
<li>전송 계층 (소켓 모드와 I/O 종류)</li>
<li>이벤트 루프</li>
<li>채널 파이프라인 설정</li>
<li>소켓 주소와 포트</li>
<li>소켓 옵션</li>
</ul>
<p>코드를 보면 다음과 같다.</p>
<pre><code>EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup) // 1
            .channel(NioServerSocketChannel.class) // 2
            .handler(new ChannelInitializer&lt;NioServerSocketChannel&gt;() { // 3
                @Override
                protected void initChannel(NioServerSocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    pipeline.addLast(new EchoServerInboundHandler());
                }
            })
            .childHandler(new ChannelInitializer&lt;SocketChannel&gt;() { // 4
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    pipeline.addLast(new EchoServerChildInboundHandler());
                }
            });

    // 서버 시작
    log.info(&quot;start server...&quot;);
    ChannelFuture f = b.bind(8080).sync(); // 5
    f.channel().closeFuture().sync();
} finally {
    log.info(&quot;close server...&quot;);
    workerGroup.shutdownGracefully();
    bossGroup.shutdownGracefully();
}</code></pre><p>각 주석의 코드를 해석하면 아래와 같다.</p>
<ol>
<li>이벤트 루프 그룹 설정.</li>
<li>비동기 네트워킹에 사용될 서버 채널 설정. (NioServerSocketChannel, EpollServerSocketChannel)</li>
<li>boss 이벤트 루프 채널 초기화 클래스 설정. (ServerSocketChannel에 대한 핸들러 설정)</li>
<li>worker 이벤트 루프 채널 초기화 클래스 설정. (SocketChannel에 대한 핸들러 설정.)</li>
<li>Netty 서버를 특정 port에 bind한다.
이때 bind 메서드는 비동기이나, 바로 뒤에 sync를 호출함으로써 bind될 때까지 blocking된다.</li>
</ol>
<p>여기까지 쓰고, EventLoop부터는 다음 글에서 정리해보겠다.</p>
]]></description>
        </item>
    </channel>
</rss>