<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yukyung_16.log</title>
        <link>https://velog.io/</link>
        <description>🌱</description>
        <lastBuildDate>Mon, 12 Jan 2026 14:39:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yukyung_16.log</title>
            <url>https://velog.velcdn.com/images/yukyung_16/profile/031bb85d-a901-47f4-9849-6574d9ddfa39/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yukyung_16.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yukyung_16" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[쿠버네티스 고가용성 토폴로지]]></title>
            <link>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EA%B3%A0%EA%B0%80%EC%9A%A9%EC%84%B1-%ED%86%A0%ED%8F%B4%EB%A1%9C%EC%A7%80</link>
            <guid>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EA%B3%A0%EA%B0%80%EC%9A%A9%EC%84%B1-%ED%86%A0%ED%8F%B4%EB%A1%9C%EC%A7%80</guid>
            <pubDate>Mon, 12 Jan 2026 14:39:58 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>쿠버네티스를 공부하면서 워커 노드에 대한 내용은 비교적 자주 접했지만, 마스터 노드의 고가용성까지 깊게 고민해본 적은 많지 않았습니다.  최근 관련 지식의 부족함을 느끼게 되었고, 이를 계기로 쿠버네티스 Control Plane 고가용성 구성에 대해 정리해보고자 합니다.</p>
<p>🔗 <a href="https://kubernetes.io/ko/docs/setup/production-environment/tools/kubeadm/ha-topology/">https://kubernetes.io/ko/docs/setup/production-environment/tools/kubeadm/ha-topology/</a></p>
<br>

<h3 id="control-plane-고가용성-목표">Control Plane 고가용성 목표</h3>
<p>Control Plane 고가용성의 목표는 마스터 노드 중 하나에 장애가 발생하더라도 클러스터 관리 기능이 중단되지 않도록 하는 것입니다. 이를 위해서는 etcd 구성 방식과 Control Plane 컴포넌트를 단일 노드에 배치할지, 여러 노드로 분산할지에 대한 설계가 중요합니다.</p>
<p>핵심 요소는 다음 두 가지입니다.</p>
<p><strong>etcd 다중 노드 구성</strong></p>
<p>etcd는 클러스터의 모든 상태 정보를 저장하는 분산 키-값 저장소입니다. Control Plane 컴포넌트는 etcd에 저장된 상태를 기준으로 동작하므로, etcd가 단일 장애 지점이 되지 않도록 여러 노드에 분산 구성해야 합니다.</p>
<p><strong>Control Plane 컴포넌트 다중화</strong></p>
<p>kube-apiserver, kube-controller-manager, kube-scheduler와 같은 Control Plane 컴포넌트는 여러 마스터 노드에 분산 배치합니다. 또한 API 서버 앞단에 로드밸런서를 두어 클라이언트 요청을 여러 마스터로 분산시킵니다.</p>
<p>이러한 구성을 통해 특정 마스터 노드에 장애가 발생하더라도, 나머지 마스터 노드가 클러스터 관리 기능을 지속적으로 수행할 수 있습니다.</p>
<br>

<h3 id="고가용성-클러스터-토플로지">고가용성 클러스터 토플로지</h3>
<p>쿠버네티스 공식 문서에서는 Control Plane 고가용성을 위한 두 가지 토폴로지를 소개합니다.</p>
<ul>
<li>etcd 노드와 컨트롤 플레인 노드를 함께 위치시키는 중첩된 컨트롤 플레인 노드 방식</li>
<li>etcd와 컨트롤 플레인이 분리된 노드에서 운영되는 외부 etcd 노드 방식</li>
</ul>
<p>두 구조 모두 Control Plane 다중화와 etcd 다중화라는 기본 원칙은 동일합니다. 차이점은 etcd를 Control Plane 노드와 함께 배치할지, 아니면 완전히 분리된 노드로 운영할지에 있습니다.</p>
<br>

<h3 id="중첩된-etcd-토플로지">중첩된 etcd 토플로지</h3>
<p>중첩된 etcd 토폴로지는 etcd 클러스터를 Control Plane 노드 위에 함께 배치하는 구조입니다. 즉, Control Plane 컴포넌트와 etcd 멤버가 같은 노드에서 함께 실행됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/aeac4a10-1a65-477a-9a2c-99630cde48f6/image.png" alt=""></p>
<p><strong>구성 방식</strong></p>
<p>중첩된 토폴로지에서는 각 Control Plane 노드가 kube-apiserver, kube-scheduler, kube-controller-manager와 함께 로컬 etcd 멤버를 함께 실행합니다.</p>
<p>kube-apiserver는 로드밸런서를 통해 워커 노드와 외부 클라이언트에 노출되며, 각 노드의 etcd 멤버는 자신이 속한 노드의 kube-apiserver와만 통신합니다. 컨트롤러 매니저와 스케줄러 역시 동일 노드 기준으로 동작합니다.</p>
<p>이로 인해 Control Plane과 etcd가 노드 단위로 결합된 구조를 가집니다.</p>
<p><strong>장점</strong></p>
<ul>
<li>Control Plane과 etcd를 동일한 노드에 배치해 전체 토폴로지가 비교적 단순합니다.</li>
<li>별도의 외부 etcd 클러스터가 필요 없어 구성과 운영이 상대적으로 간편합니다.</li>
</ul>
<p>kubeadm을 사용할 경우, <code>kubeadm init</code>과 <code>kubeadm join --control-plane</code> 과정에서 Control Plane 노드를 추가하면 로컬 etcd 멤버도 자동으로 생성됩니다.</p>
<p><strong>단점과 고려사항</strong></p>
<ul>
<li>Control Plane 노드 장애 시, 해당 노드에서 실행 중인 Control Plane 컴포넌트와 etcd 멤버가 모두 중단됩니다.</li>
<li>Control Plane과 etcd가 강하게 결합되어 있어 장애 발생 시 영향 범위가 커질 수 있습니다.</li>
</ul>
<p>이러한 위험을 방지하기 위해, 중첩된 토폴로지에서는 최소 3개 이상의 Control Plane 노드 구성이 권장됩니다.</p>
<p><strong>📍 참고</strong></p>
<p>중첩된 etcd 토폴로지에서도 etcd는 노드마다 따로 동작하는 독립적인 DB가 아니라, 각 Control Plane 노드에 배치된 etcd 멤버들이 하나의 분산 etcd 클러스터를 구성하는 구조입니다.</p>
<p>각 노드의 etcd 멤버는 리더 또는 팔로워로 동작하며, Quorum을 통해 동일한 키-값 데이터를 유지합니다.</p>
<p>따라서 특정 노드가 장애로 인해 사라지더라도, 남은 멤버들이 Quorum을 유지하는 한 etcd 클러스터와 Kubernetes Control Plane은 계속 동작할 수 있습니다.</p>
<br>

<h3 id="외부-etcd-토폴로지">외부 etcd 토폴로지</h3>
<p>외부 etcd 토폴로지는 etcd 클러스터를 Control Plane 노드와 분리해 별도의 서버 그룹으로 운영하는 구조입니다. Control Plane 컴포넌트는 클러스터 상태를 로컬에 저장하지 않고, 네트워크를 통해 외부에 위치한 etcd 클러스터와 통신합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/792f441b-d095-4ef1-ad5b-b2c54aaf4f03/image.png" alt=""></p>
<p><strong>구성 방식</strong></p>
<p>외부 etcd 토폴로지에서는 Control Plane 노드와 etcd 노드를 물리적 또는 논리적으로 분리해 구성합니다.</p>
<p>각 Control Plane 노드는 kube-apiserver, kube-scheduler, kube-controller-manager를 실행하며, kube-apiserver는 로드밸런서를 통해 워커 노드 및 외부 클라이언트에 노출됩니다.</p>
<p>etcd 멤버는 전용 노드에서만 실행되며, 모든 Control Plane 노드는 동일한 외부 etcd 클러스터에 네트워크를 통해 접근합니다. 이로 인해 Control Plane과 etcd는 명확히 분리된 계층으로 동작합니다.</p>
<p><strong>장점</strong></p>
<ul>
<li>Control Plane과 etcd가 분리되어 장애 영향 범위가 명확히 분리됩니다.</li>
<li>etcd 클러스터를 독립적으로 확장 및 운영이 가능합니다.</li>
</ul>
<p><strong>단점과 고려사항</strong></p>
<ul>
<li>중첩된 토폴로지에 비해 구성과 운영 복잡도 증가합니다.</li>
<li>Control Plane–etcd 간 네트워크 의존성이 커져 지연이나 네트워크 장애에 더 민감합니다.</li>
<li>동일한 수준의 HA를 위해 최소 3대의 Control Plane 노드와 3대의 etcd 노드가 필요합니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠버네티스 클러스터 아키텍처]]></title>
            <link>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-jav3outl</link>
            <guid>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-jav3outl</guid>
            <pubDate>Sun, 11 Jan 2026 16:04:42 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>이 글은 Kubernetes의 내부 로직, 특히 클러스터와 Control Plane이 어떤 역할로 동작하는지를 정리하기 위해 작성했습니다.</p>
<p>🔗 <a href="https://kubernetes.io/docs/concepts/architecture/">https://kubernetes.io/docs/concepts/architecture/</a></p>
<br> 

<h3 id="kubernetes-클러스터">Kubernetes 클러스터</h3>
<p>Kubernetes 클러스터는 크게 두 영역으로 구성됩니다.</p>
<ul>
<li>Control Plane: 클러스터 전체의 상태를 관리하고 전반적인 동작을 결정하는 영역</li>
<li>Worker Node: 실제 애플리케이션 워크로드(Pod)가 스케줄링되고 실행되는 영역</li>
</ul>
<p>✏️ Pod를 실행하려면 최소 1개의 Worker Node가 반드시 필요합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/7cba4f43-5059-4322-a86e-c0d99624677f/image.png" alt=""></p>
<br>

<h3 id="control-plane">Control Plane</h3>
<p>Control Plane은 클러스터의 Desired State를 관리하는 영역입니다. 여러 컨트롤러는 현재 클러스터 상태를 지속적으로 감시하고, 사용자가 선언한 상태와 차이가 발생하면 이를 맞추도록 조정합니다.</p>
<p><strong>주요 구성요소</strong></p>
<ul>
<li><p>kube-apiserver: Kubernetes API를 외부와 내부에 노출하는 컴포넌트입니다. kubectl을 비롯한 모든 클라이언트와 다른 Control Plane/Node 컴포넌트는 kube-apiserver를 통해 상호작용합니다.</p>
</li>
<li><p>etcd: 클러스터의 모든 상태 데이터를 저장하는 일관성 있는 고가용성 키-값 저장소입니다. Control Plane 컴포넌트는 etcd에 저장된 데이터를 기준으로 클러스터 상태를 판단하고 조정합니다.</p>
</li>
<li><p>kube-scheduler: 아직 노드가 할당되지 않은 새로운 Pod를 감시하고, 리소스 요구사항, 제약 조건, affinity/anti-affinity 등을 고려해 적절한 노드를 선택합니다.</p>
</li>
<li><p>kube-controller-manager: 여러 종류의 컨트롤러를 하나의 프로세스로 실행하는 컴포넌트입니다. 예를 들어 Node 컨트롤러, Job 컨트롤러 등이 있으며, 각각 특정 리소스의 현재 상태가 Desired State를 만족하도록 지속적으로 조정합니다.</p>
</li>
</ul>
<br>

<h3 id="1-노드-간-pod-트래픽-흐름">1) 노드 간 Pod 트래픽 흐름</h3>
<p>쿠버네티스는 IP-per-Pod 모델을 사용합니다. 각 Pod는 고유한 IP 주소를 할당받으며, CNI 플러그인이 노드 간 라우팅을 구성해 NAT 없이 Pod 간 직접 통신이 가능하도록 합니다.</p>
<p>Service는 이 Pod들 앞에 고정된 주소를 하나 제공하고, 해당 주소로 들어온 요청을 여러 Pod 중 하나로 연결해 줍니다.</p>
<p><strong>1. Service 이름으로 요청</strong></p>
<p>애플리케이션은 Service 이름을 통해 통신합니다. 어떤 Pod가 선택되고 트래픽이 어떻게 전달되는지는 쿠버네티스가 내부적으로 처리합니다.</p>
<p><strong>2. DNS 조회로 ClusterIP 확인</strong></p>
<p>클러스터 DNS는 Service 이름에 대응하는 ClusterIP를 반환합니다. 이 ClusterIP는 노드의 네트워크 인터페이스나 라우팅 테이블에 실제로 존재하지 않는, Service를 위해 정의된 논리적인 가상 IP입니다.</p>
<p><strong>3. Pod에서 노드 네트워크로 전달</strong></p>
<p>DNS 해석 이후, Pod는 목적지 주소를 ClusterIP로 설정한 패킷을 생성합니다. 이 패킷은 Pod의 veth 인터페이스를 통해 노드 브리지로 전달됩니다.</p>
<p><strong>4. Service IP -&gt; Pod IP 변환</strong></p>
<p>각 노드에서 동작하는 kube-proxy는 Service 및 Endpoint 변경을 감지해 이에 대응하는 iptables 규칙을 사전에 구성해 둡니다. </p>
<p>패킷이 노드 커널의 netfilter 체인을 통과하는 과정에서 Service의 ClusterIP에 매칭되는 규칙이 적용되고, 목적지 주소는 실제 Pod의 IP와 포트로 DNAT 됩니다.</p>
<p><strong>5. CNI를 통한 노드 간 라우팅</strong></p>
<p>DNAT가 완료된 패킷은 커널 라우팅 테이블을 기준으로 전달됩니다. 목적 Pod가 다른 노드에 위치한 경우, 패킷은 CNI 플러그인이 구성한 Pod CIDR 경로를 따라 해당 노드로 전달됩니다.</p>
<p><strong>6. 대상 Pod로 최종 전달</strong></p>
<p>패킷은 대상 노드의 네트워크 인터페이스로 유입된 뒤, 브리지와 veth 인터페이스를 거쳐 최종적으로 목적 Pod에 전달됩니다.</p>
<p><strong>📍 kube-proxy의 역할</strong></p>
<p>kube-proxy는 패킷을 직접 전달하거나 중계하지 않습니다. iptables 모드에서는 Service 및 Endpoint 변화에 따라 iptables 규칙을 사전에 구성하며, 실제 패킷 처리는 리눅스 커널이 수행합니다.</p>
<br>

<h3 id="service를-통한-노드-간-pod-트래픽-흐름-예시">Service를 통한 노드 간 Pod 트래픽 흐름 예시</h3>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/f6cf5d14-e6fe-41c4-b57f-8667450b2902/image.png" alt=""></p>
<p><strong>시나리오 설정</strong></p>
<ul>
<li>client pod IP: 10.0.1.3</li>
<li>server pod IP: 10.0.2.2</li>
<li>Service 이름: example.default.svc.cluster.local</li>
<li>ClusterIP: 10.96.123.45</li>
</ul>
<p><strong>1. Service 이름으로 요청</strong></p>
<p>client pod 내부 애플리케이션은 실제 server pod의 IP를 알 필요 없이 Service 이름으로 요청을 보냅니다. </p>
<pre><code>목적지: example.default.svc.cluster.local</code></pre><p><strong>2. DNS 조회로 ClusterIP 확인</strong></p>
<p>클러스터 DNS(CoreDNS)는 Service 이름에 대응하는 ClusterIP(Virtual IP)를 반환합니다. 이 ClusterIP는 실제 노드의 네트워크 인터페이스나 라우팅 테이블에 존재하지 않는 논리적인 가상 주소입니다.</p>
<pre><code>example.default.svc.cluster.local -&gt; 10.96.123.45</code></pre><p><strong>3. Pod에서 노드 네트워크로 전달</strong></p>
<p>DNS 해석 이후, client pod는 다음과 같은 패킷을 생성합니다.</p>
<pre><code>출발지 IP: 10.0.1.3
목적지 IP: 10.96.123.45</code></pre><p>패킷은 Pod의 veth 인터페이스를 통해 노드1의 브리지(cbr0)로 전달됩니다. 이 단계에서도 패킷의 목적지는 여전히 Service의 ClusterIP입니다.</p>
<pre><code>client pod → veth → cbr0</code></pre><p><strong>4. Service IP -&gt; Pod IP 변환</strong></p>
<p>노드1에서 동작 중인 kube-proxy는 Service 및 Endpoint 변경을 감지해 이에 대응하는 iptables 규칙을 사전에 구성해 둡니다.</p>
<p>패킷이 노드 커널의 netfilter 체인을 통과하면서 Service의 ClusterIP에 매칭되는 규칙이 적용되고, 목적지 주소는 실제 server pod의 IP와 포트로 변환됩니다.</p>
<p>여러 Endpoint가 존재하는 경우, 이 단계에서 로드밸런싱이 함께 수행됩니다.</p>
<pre><code>DNAT 전: 10.96.123.45:80
DNAT 후: 10.0.2.2:8080</code></pre><p><strong>5. CNI를 통한 노드 간 라우팅</strong></p>
<p>DNAT가 완료된 패킷의 목적지는 이제 10.0.2.2입니다.</p>
<p>노드1의 라우팅 테이블에는 CNI 플러그인이 구성한 Pod CIDR 경로 정보가 존재합니다.</p>
<pre><code>10.0.2.0/24 -&gt; 노드2</code></pre><p>이에 따라 패킷은 노드1의 eth0을 통해 외부 네트워크로 전달됩니다. 이 구간에서는 일반 IP 라우팅이 적용되며, Pod IP는 변경되지 않습니다.</p>
<pre><code>노드1 eth0 -&gt; 라우터 -&gt; 노드2 eth0</code></pre><p><strong>6. 대상 Pod로 최종 전달</strong></p>
<p>노드2는 목적지 IP 10.0.2.2가 자신의 Pod CIDR에 속함을 확인하고, 패킷을 브리지(cbr0)로 전달합니다. server pod는 요청을 수신하고, 응답 패킷을 동일한 경로로 반환합니다.</p>
<pre><code>eth0 -&gt; cbr0 -&gt; veth -&gt; server pod</code></pre><br>

<h3 id="2-deployment-생성-후-pod가-뜨기까지의-내부-로직">2) Deployment 생성 후 Pod가 뜨기까지의 내부 로직</h3>
<p>쿠버네티스는 선언적 API와 컨트롤 루프 기반으로 동작합니다. 사용자가 kubectl apply로 원하는 상태를 정의하면, 여러 컨트롤러가 이를 지속적으로 감시하며 실제 클러스터 상태를 맞춰 나갑니다.</p>
<p><strong>1. Deployment 생성</strong></p>
<p>사용자가 kubectl apply로 Deployment를 생성하면, 해당 리소스는 kube-apiserver를 통해 인증/인가 및 유효성 검증을 거친 뒤 etcd에 저장됩니다. </p>
<p><strong>2. ReplicaSet 생성 및 조정</strong></p>
<p>Deployment Controller는 새로 생성되거나 변경된 Deployment를 감지하고, <code>spec.replicas</code>와 Pod 템플릿을 기준으로 ReplicaSet을 생성하거나 기존 ReplicaSet의 크기를 조정합니다. </p>
<p>이 과정에서 생성/수정된 ReplicaSet 역시 API 서버를 통해 etcd에 반영됩니다.</p>
<p><strong>3. Pod 리소스 생성</strong></p>
<p>ReplicaSet Controller는 ReplicaSet을 지속적으로 감시하며 현재 Pod 수가 원하는 개수와 일치하는지 확인합니다. </p>
<p>Pod 수가 부족한 경우 새로운 Pod 리소스를 생성하며, 이때 Pod는 아직 실행되지 않은 상태로 노드가 할당되지 않은 Pending 상태에 머무릅니다.</p>
<p><strong>4. 노드 선택 및 바인딩</strong></p>
<p>kube-scheduler는 노드가 지정되지 않은 Pod를 감지하고, 리소스 요청, taint/toleration, affinity/anti-affinity 등의 조건을 종합적으로 고려해 실행할 노드를 선택합니다. </p>
<p>선택된 노드는 Pod의 <code>spec.nodeName</code> 필드에 기록됩니다.</p>
<p><strong>5. 컨테이너 실행 및 상태 보고</strong></p>
<p>각 노드의 kubelet은 자신에게 할당된 Pod를 감지하고, Pod 정의에 따라 컨테이너 이미지를 Pull한 뒤 컨테이너를 생성합니다. </p>
<p>컨테이너가 정상적으로 실행되면, kubelet은 Pod 상태를 Running으로 API 서버에 보고하며 클러스터 상태가 최종적으로 동기화됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[어쩌다 보니 인턴]]></title>
            <link>https://velog.io/@yukyung_16/%EC%96%B4%EC%A9%8C%EB%8B%A4-%EB%B3%B4%EB%8B%88-%EC%9D%B8%ED%84%B4</link>
            <guid>https://velog.io/@yukyung_16/%EC%96%B4%EC%A9%8C%EB%8B%A4-%EB%B3%B4%EB%8B%88-%EC%9D%B8%ED%84%B4</guid>
            <pubDate>Tue, 09 Dec 2025 16:18:02 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yukyung_16/post/5c42ca43-7f08-46f6-b417-2a252269d895/image.png" alt=""></p>
<p>저는 좋은 기회로 노란색 클라우드 회사에서 3개월간 인턴을 하게 되었습니다. 취업 준비를 본격적으로 해본 적도 없었고, 면접도 처음이었는데, 그냥 ... 운이 좋았던 것 같습니다.</p>
<p>처음에는 환경에 적응하느라 정신없이 시간이 흘렀습니다. 모든 것이 처음이었고, 매일 새로운 것을 마주했습니다. 그래도 좋은 사람들 덕분에 질문하는 것도 부담되지 않았고, 매일 배울 수 있었습니다. 🫶</p>
<p>회사에서 저는 주로 Terraform Provider 개발했습니다. 고랭은 동아리에서 재밌어 보여서 잠깐 다뤄본 언어였는데, Terraform Provider를 개발하면서 가장 많이 사용하는 언어가 되었습니다. 고랭을 계속 쓰면서 <em>이 언어 꽤나 잘 맞는데?</em> 라는 생각도 들었고, 무엇보다 재밌었습니다. <del>(저는 자바가 미워요)</del></p>
<p>그리고 Terraform 생각보다 고려해야 할 것들이 많더라구요...? 그래도 처음부터 하나씩 만들어 가면서 배포 자동화 스크립트도 만들어 공유해보고, 다양한 도구들을 자동화해 나가는 재미도 느낄 수 있었습니다. 물론 이 과정에서 여러 장벽과 쉽지 않은 순간들도 있었지만, Terraform 커뮤니티의 분위기와 생태계를 무엇보다 직접으로 느낄 수 있었던 경험이었습니다.</p>
<p>기술적인 부분 말고도 커뮤니케이션 방식을 많이 배웠습니다. 회의가 어떻게 진행되는지, 의사결정은 어떤 흐름으로 이루어지는지, 협업은 어떤 식으로 굴러가는지 학교에서는 알 수 없던 것들을 직접 경험할 수 있었습니다.</p>
<p>무엇보다 인턴을 하면서 앞으로 무엇을 하고 싶은지를 진지하게 고민해볼 수 있었습니다. 막연히 클라우드가 좋다고 생각만 하고 있었는데, 이제는 그래도 예전보다는 방향이 조금 더 또렷해진 것 같습니다. 아직 완전히 확실하다고 말하기는 어렵지만, 선택과 고민의 시간 속에서 무너지기도 하면서도 많이 성장하고 있다는 느낌을 받았습니다.</p>
<p>인턴 기간에 처음으로 오픈소스 기여도 해보았습니다. 올해만 6개 정도의 PR이 머지된 것 같아 꽤나 뿌듯합니다.</p>
<p><strong>⭐️ 자랑</strong>
<a href="https://review.opendev.org/c/openstack/openstacksdk/+/958944">https://review.opendev.org/c/openstack/openstacksdk/+/958944</a></p>
<p>요즘은 자연스럽게 여러 오픈소스를 둘러보는 것이 일상이 되었고, 종종 PR도 올려보려고 노력하고 있습니다. 여러 프로젝트와 커뮤니티를 탐방하면서 흥미가 있는 오픈소스를 찾고 있습니다. 메인테이너가 되는 그날까지 ... 🚀</p>
<p>자연스럽게 올해를 돌아보면 꽤 빠르게 달려온 것 같기도 합니다...? 요즘은 부족한 것들이 더 선명하게 보이지만, 잘하고 싶은 마음이 커질수록 스스로에게 더 엄격해지는 게 아닐까요? ㅎㅎ</p>
<p>그래도 !! 작년의 저와 비교해보면, 정말 많은 것들을 해온 한 해이기도 합니다. 클라우드라는 세계도 처음으로 제대로 접해보고, 프로젝트를 하면서 재미를 주는 사람들을 만나고, 기술적으로도 정말 많은 것을 배울 수 있었습니다.</p>
<p>동아리에서는 좋은 사람들에게 좋은 영향을 받으며 많이 배우고, 무한한 지지와 응원을 받으며 감동도 받았습니다. 🥹 도움이 필요할 때 언제든 달려와 주는 사람들이 있다는 게 얼마나 큰 행운인지도 알게 되었습니다.</p>
<p>그래서, 앞으로도 완벽해지지는 않겠지만, 하루에 한 걸음씩만이라도 계속 앞으로 가보려고 합니다. 올해는 넓게 많은 것들을 알아가는 해였다면, 앞으로는 더 방향성을 잡고 조금 더 깊은 사람이 되고 싶습니다. 😉 그래도 이 정도면, 충분히 잘 지나온 한 해였던 것 같습니다.</p>
<p>앞으로도 아자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kubebuilder로 만드는 나만의 Kubernetes 오퍼레이터]]></title>
            <link>https://velog.io/@yukyung_16/Kubebuilder%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-%EC%98%A4%ED%8D%BC%EB%A0%88%EC%9D%B4%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Kubebuilder%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-%EC%98%A4%ED%8D%BC%EB%A0%88%EC%9D%B4%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 02 Dec 2025 17:51:54 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>스터디에서 Kubernetes 오퍼레이터를 공부하다 보니, 가장 기본적인 형태라도 직접 만들어보고 싶다는 생각이 들었습니다.</p>
<p>그래서 이번 글에서는 Kubebuilder를 사용해 Deployment가 생성될 때 자동으로 Prometheus 알림 규칙을 생성하는 Kubernetes 오퍼레이터를 구현한 과정을 정리해보았습니다.</p>
<br>

<h3 id="오퍼레이터-시퀀스">오퍼레이터 시퀀스</h3>
<ol>
<li>Deployment가 생성되면 컨트롤러가 이를 감지합니다.</li>
<li>감지된 Deployment에 맞는 AlertRule을 생성합니다.</li>
<li>AlertRule 변경을 기반으로 PrometheusRule을 생성합니다.</li>
<li>Prometheus Operator가 해당 규칙을 로드해 알림을 활성화합니다.</li>
</ol>
<br>

<h3 id="1-프로젝트-초기화">1. 프로젝트 초기화</h3>
<p><strong>Kubebuilder 설치</strong></p>
<p>Kubebuilder는 Kubernetes 오퍼레이터를 쉽게 개발할 수 있게 해주는 프레임워크입니다.</p>
<pre><code class="language-bash">brew install kubebuilder</code></pre>
<p><strong>프로젝트 초기화</strong></p>
<pre><code class="language-bash">kubebuilder init --domain example.com --repo github.com/Kim-Yukyung/k8s-alert-rule-operator</code></pre>
<p>이 명령어는 다음과 같은 기본 구조를 생성합니다.</p>
<pre><code class="language-bash">k8s-alert-rule-operator/
├── cmd/
│   └── main.go             # 오퍼레이터 진입점
├── api/                    # CRD 정의
├── config/                 # Kubernetes 매니페스트
│   ├── crd/                # CRD 정의
│   ├── rbac/               # RBAC 권한
│   ├── manager/            # Manager 배포
│   └── default/            # 기본 설정
├── internal/
│   └── controller/         # 컨트롤러 로직
├── Makefile                # 빌드 스크립트
└── PROJECT                 # 프로젝트 메타데이터</code></pre>
<p><strong>🔎 생성된 주요 파일 설명</strong></p>
<p><strong><code>cmd/main.go</code></strong></p>
<p>오퍼레이터의 시작점입니다. Controller Manager를 초기화하고 실행합니다.</p>
<pre><code class="language-go">mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme:                 scheme,
    Metrics:                metricsServerOptions,
    WebhookServer:          webhookServer,
    HealthProbeBindAddress: probeAddr,
    LeaderElection:         enableLeaderElection,
    LeaderElectionID:       &quot;a4f6a106.example.com&quot;,
})</code></pre>
<p><strong><code>config/</code> 디렉토리 구조</strong></p>
<ul>
<li><code>config/crd/</code>: Custom Resource Definition 정의</li>
<li><code>config/rbac/</code>: RBAC 설정 (ServiceAccount, Role, RoleBinding)</li>
<li><code>config/manager/</code>: Controller Manager 배포 설정</li>
<li><code>config/default/</code>: 기본 Kustomize 패치 및 통합 설정</li>
</ul>
<br>

<h3 id="2-alertrule-crd-생성">2. AlertRule CRD 생성</h3>
<p><strong>API 생성</strong></p>
<p>Kubebuilder로 API를 생성하면 AlertRule에 필요한 기본 골격이 자동으로 만들어집니다.</p>
<pre><code class="language-shell">kubebuilder create api --group monitoring --version v1 --kind AlertRule --resource --controller</code></pre>
<pre><code class="language-bash">--group monitoring  # API 그룹 이름 (monitoring.example.com)
--version v1        # API 버전
--kind AlertRule    # 생성할 Kubernetes 리소스 종류 (CRD 이름)
--resource          # CRD(리소스 타입) 관련 파일생성
--controller        # 컨트롤러 코드 생성</code></pre>
<p><strong>AlertRule 타입 정의</strong></p>
<p><code>api/v1/alertrule_types.go</code> 파일을 수정하여 알림 규칙에 필요한 필드를 정의합니다.</p>
<pre><code class="language-go">type AlertRuleSpec struct {
    // 필수 필드
    Alert string `json:&quot;alert&quot;`        // 알림 이름
    Expr  string `json:&quot;expr&quot;`         // PromQL 표현식

    // 선택 필드
    Severity     string            `json:&quot;severity,omitempty&quot;`      // critical, warning, info
    For          string            `json:&quot;for,omitempty&quot;`           // 알림 지속 시간
    Labels       map[string]string `json:&quot;labels,omitempty&quot;`        // 알림 레이블
    Annotations  map[string]string `json:&quot;annotations,omitempty&quot;`   // 알림 주석
    DeploymentRef *DeploymentReference `json:&quot;deploymentRef,omitempty&quot;` // Deployment 참조
}

type DeploymentReference struct {
    Namespace string `json:&quot;namespace&quot;`
    Name      string `json:&quot;name&quot;`
}</code></pre>
<p><strong>CRD 자동 생성</strong></p>
<p>타입 정의를 수정한 후 다음 명령어로 CRD를 생성합니다.</p>
<pre><code class="language-bash">make manifests</code></pre>
<p>이 명령어는 <code>controller-gen</code>을 사용해 Go 타입을 분석하고 Kubebuilder 마커를 반영해 최종 CRD YAML을 자동으로 생성합니다.</p>
<p>-&gt; <code>config/crd/bases/monitoring.example.com_alertrules.yaml</code></p>
<br>

<h3 id="3-deployment-컨트롤러-구현">3. Deployment 컨트롤러 구현</h3>
<p><strong>컨트롤러 생성</strong></p>
<p>Deployment를 감시하고 AlertRule을 자동 생성하는 컨트롤러를 만듭니다.</p>
<p><strong><code>internal/controller/deployment_controller.go</code>  핵심 로직</strong></p>
<pre><code class="language-go">func (r *DeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. Deployment 가져오기
    deployment := &amp;appsv1.Deployment{}
    if err := r.Get(ctx, req.NamespacedName, deployment); err != nil {
        if apierrors.IsNotFound(err) {
            return r.deleteAlertRuleForDeployment(ctx, req.Namespace, req.Name)
        }
        return ctrl.Result{}, err
    }

    // 2. AlertRule 이름 생성: {deployment-name}-alert
    alertRuleName := fmt.Sprintf(&quot;%s-alert&quot;, deployment.Name)

    // 3. 기존 AlertRule 확인
    alertRule := &amp;monitoringv1.AlertRule{}
    err := r.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: alertRuleName}, alertRule)

    if apierrors.IsNotFound(err) {
        // 4. AlertRule이 없으면 생성
        newAlertRule := r.createDefaultAlertRule(deployment, alertRuleName)
        return ctrl.Result{}, r.Create(ctx, newAlertRule)
    }

    return ctrl.Result{}, nil
}</code></pre>
<p><strong>기본 AlertRule 생성</strong></p>
<pre><code class="language-go">func (r *DeploymentReconciler) createDefaultAlertRule(deployment *appsv1.Deployment, name string) *monitoringv1.AlertRule {
    alertRule := &amp;monitoringv1.AlertRule{
        ObjectMeta: metav1.ObjectMeta{
            Name:      name,
            Namespace: deployment.Namespace,
            OwnerReferences: []metav1.OwnerReference{
                {
                    APIVersion: deployment.APIVersion,
                    Kind:       deployment.Kind,
                    Name:       deployment.Name,
                    UID:        deployment.UID,
                    Controller: func() *bool { b := true; return &amp;b }(), // Garbage Collection
                },
            },
        },
        Spec: monitoringv1.AlertRuleSpec{
            Alert:    fmt.Sprintf(&quot;%sPodDown&quot;, deployment.Name),
            Expr:     fmt.Sprintf(&quot;kube_deployment_status_replicas_available{deployment=\&quot;%s\&quot;, namespace=\&quot;%s\&quot;} == 0&quot;, 
                                 deployment.Name, deployment.Namespace),
            For:      &quot;1m&quot;,
            Severity: &quot;critical&quot;,
            Labels: map[string]string{
                &quot;deployment&quot;: deployment.Name,
                &quot;namespace&quot;:  deployment.Namespace,
            },
            Annotations: map[string]string{
                &quot;summary&quot;:     fmt.Sprintf(&quot;Pod %s is down&quot;, deployment.Name),
                &quot;description&quot;: fmt.Sprintf(&quot;Pod %s in namespace %s has been down&quot;, deployment.Name, deployment.Namespace),
            },
            DeploymentRef: &amp;monitoringv1.DeploymentReference{
                Namespace: deployment.Namespace,
                Name:      deployment.Name,
            },
        },
    }

    // OwnerReference 설정
    ctrl.SetControllerReference(deployment, alertRule, r.Scheme)
    return alertRule
}</code></pre>
<p><strong>컨트롤러 등록</strong></p>
<p><code>cmd/main.go</code>에 컨트롤러를 등록합니다.</p>
<pre><code class="language-go">if err := (&amp;controller.DeploymentReconciler{
    Client: mgr.GetClient(),
    Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
    setupLog.Error(err, &quot;unable to create controller&quot;, &quot;controller&quot;, &quot;Deployment&quot;)
    os.Exit(1)
}</code></pre>
<br>

<h3 id="4-alertrule-→-prometheusrule-변환">4. AlertRule → PrometheusRule 변환</h3>
<p><strong>PrometheusRule 생성 로직</strong></p>
<p>AlertRule이 생성되면 PrometheusRule로 변환해야 합니다. PrometheusRule은 외부 CRD이므로 <code>unstructured.Unstructured</code>를 사용합니다.</p>
<p><strong><code>internal/controller/alertrule_controller.go</code> 핵심 로직</strong></p>
<pre><code class="language-go">func (r *AlertRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. AlertRule 가져오기
    alertRule := &amp;monitoringv1.AlertRule{}
    if err := r.Get(ctx, req.NamespacedName, alertRule); err != nil {
        if apierrors.IsNotFound(err) {
            return r.deletePrometheusRule(ctx, req.Namespace, req.Name)
        }
        return ctrl.Result{}, err
    }

    // 2. PrometheusRule 생성/업데이트
    if err := r.reconcilePrometheusRule(ctx, alertRule); err != nil {
        if strings.Contains(err.Error(), &quot;no matches for kind&quot;) {
            logger.Info(&quot;PrometheusRule CRD not available, skipping&quot;)
        } else {
            return ctrl.Result{}, err
        }
    }

    // 3. Status 업데이트
    return ctrl.Result{}, r.updateStatus(ctx, alertRule)
}</code></pre>
<p><strong>PrometheusRule 생성</strong></p>
<pre><code class="language-go">func (r *AlertRuleReconciler) createPrometheusRule(alertRule *monitoringv1.AlertRule) *unstructured.Unstructured {
    prometheusRule := &amp;unstructured.Unstructured{}
    prometheusRule.SetGroupVersionKind(schema.GroupVersionKind{
        Group:   &quot;monitoring.coreos.com&quot;,
        Version: &quot;v1&quot;,
        Kind:    &quot;PrometheusRule&quot;,
    })
    prometheusRule.SetName(alertRule.Name)
    prometheusRule.SetNamespace(alertRule.Namespace)

    labels := map[string]string{
        &quot;managed-by&quot;: &quot;alert-rule-operator&quot;,
        &quot;release&quot;:    &quot;monitoring&quot;, // Prometheus Operator 선택을 위해 필수!
    }
    prometheusRule.SetLabels(labels)

    // OwnerReference 설정
    ownerRef := metav1.OwnerReference{
        APIVersion: alertRule.APIVersion,
        Kind:       alertRule.Kind,
        Name:       alertRule.Name,
        UID:        alertRule.UID,
        Controller: func() *bool { b := true; return &amp;b }(),
    }
    prometheusRule.SetOwnerReferences([]metav1.OwnerReference{ownerRef})

    // PrometheusRule spec 구성
    groups := []interface{}{
        map[string]interface{}{
            &quot;name&quot;: fmt.Sprintf(&quot;%s-group&quot;, alertRule.Name), // 유니크한 그룹 이름
            &quot;rules&quot;: []interface{}{r.buildPrometheusRule(alertRule)},
        },
    }

    spec := map[string]interface{}{
        &quot;groups&quot;: groups,
    }
    unstructured.SetNestedMap(prometheusRule.Object, spec, &quot;spec&quot;)

    return prometheusRule
}</code></pre>
<br>

<h3 id="5-빌드-및-배포">5. 빌드 및 배포</h3>
<p><strong>Docker 이미지 빌드 및 배포</strong></p>
<pre><code class="language-bash"># 이미지 빌드
make docker-build IMG=controller:latest

# 배포
make deploy IMG=controller:latest</code></pre>
<br>

<h3 id="6-테스트">6. 테스트</h3>
<p><strong>Deployment 생성</strong></p>
<pre><code class="language-bash">kubectl create deployment test-app --image=nginx:latest</code></pre>
<p><strong>자동 생성 확인</strong></p>
<pre><code class="language-bash"># AlertRule 확인
kubectl get alertrules.monitoring.example.com -A

# PrometheusRule 확인
kubectl get prometheusrules -A | grep test-app</code></pre>
<p><strong>Prometheus UI에서 확인</strong></p>
<pre><code class="language-bash"># Port forwarding
kubectl port-forward -n default svc/monitoring-kube-prometheus-prometheus 9090:9090</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/da547837-f0c2-43f7-971d-2e7dd1ed8a7d/image.png" alt="">
<img src="https://velog.velcdn.com/images/yukyung_16/post/3c070923-dc47-4596-878a-05dccf39ee8f/image.png" alt=""></p>
<br>

<h3 id="핵심-개념">핵심 개념</h3>
<p><strong>OwnerReference</strong></p>
<p>Kubernetes에서는 리소스 간 소유 관계를 설정해 두면, 부모 리소스가 삭제될 때 자식 리소스도 자동으로 삭제됩니다. 오퍼레이터가 생성하는 리소스를 계층 구조로 안전하게 관리할 수 있게 해줍니다.</p>
<pre><code class="language-go">OwnerReferences: []metav1.OwnerReference{
    {
        Controller: func() *bool { b := true; return &amp;b }(),
    },
}</code></pre>
<p><strong>Reconcile 패턴</strong></p>
<p>오퍼레이터는 Reconcile 함수를 통해 클러스터의 실제 상태를 읽고, 의도한 상태에 맞게 조정합니다. 리소스 생성, 수정, 삭제 이벤트마다 호출되며, 컨트롤러의 모든 로직이 이 함수 안에서 실행됩니다.</p>
<pre><code class="language-go">func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // Desired State와 Actual State를 비교하여 조정
}</code></pre>
<p><strong>Unstructured Client</strong></p>
<p>PrometheusRule처럼 프로젝트가 정의하지 않은 CRD는 고정된 Go 타입이 없습니다. 이때 unstructured.Unstructured를 사용하면 Group/Version/Kind만 지정해 어떤 리소스든 동적으로 생성하거나 수정할 수 있습니다.</p>
<pre><code class="language-go">prometheusRule := &amp;unstructured.Unstructured{}
prometheusRule.SetGroupVersionKind(schema.GroupVersionKind{...})</code></pre>
<p><strong>RBAC 마커</strong></p>
<p>컨트롤러가 Deployment나 PrometheusRule 같은 리소스에 접근하려면 특정 권한이 필요합니다. Kubebuilder는 주석만 추가하면 필요한 RBAC Role YAML을 자동으로 생성해 줍니다.</p>
<pre><code class="language-go">// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch
// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=prometheusrules,verbs=create</code></pre>
<br>

<h3 id="마지막으로">마지막으로</h3>
<p>짧은 시간에 오퍼레이터를 만들어보면서 쿠버네티스가 어떻게 동작하고, 내부 리소스들이 어떤 방식으로 연결되고 관리되는지 더 깊이 이해할 수 있었습니다. 아직 배워야 할 부분도 많지만, 쿠버네티스의 확장성이 확실히 느껴지는 경험이었습니다. </p>
<br>

<h3 id="전체-코드">전체 코드</h3>
<p>🔗 <a href="https://github.com/Kim-Yukyung/k8s-alert-rule-operator.git">https://github.com/Kim-Yukyung/k8s-alert-rule-operator.git</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PR 충돌? GitHub Actions이 먼저 알려드립니다]]></title>
            <link>https://velog.io/@yukyung_16/PR-%EC%B6%A9%EB%8F%8C-GitHub-Actions%EC%9D%B4-%EB%A8%BC%EC%A0%80-%EC%95%8C%EB%A0%A4%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@yukyung_16/PR-%EC%B6%A9%EB%8F%8C-GitHub-Actions%EC%9D%B4-%EB%A8%BC%EC%A0%80-%EC%95%8C%EB%A0%A4%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Tue, 02 Dec 2025 14:53:13 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>프로젝트를 진행하다 보면 동시에 작업하면서 PR이 쌓이게 됩니다. 이때 main/develop 브랜치에 새로운 커밋이 푸시되면, 기존에 열려 있던 PR이 충돌을 일으키는 일이 자주 발생합니다.</p>
<p>문제는 이런 충돌을 매번 직접 확인해야 한다는 점입니다. PR이 여러 개 쌓이면 어떤 PR에서 충돌이 발생했는지 파악하는 것만으로도 시간이 소요됩니다.</p>
<p>그래서 이번 글에서는 GitHub Actions를 활용해 PR 충돌을 자동으로 감지하고, 충돌이 발생한 PR을 Discord로 알림을 보내는 워크플로우를 어떻게 구현했는지 정리해보았습니다.</p>
<br>

<h3 id="아키텍처-설계">아키텍처 설계</h3>
<p><strong>워크플로우 트리거</strong></p>
<pre><code class="language-yaml">on:
  pull_request:
    types: [opened, synchronize, reopened]
  push:
    branches: [main, develop]</code></pre>
<p>두 가지 이벤트를 중심으로 충돌을 확인합니다.</p>
<ul>
<li><strong>pull_request</strong>: PR이 생성되거나 업데이트될 때</li>
<li><strong>push</strong>: main/develop 브랜치에 커밋이 푸시될 때</li>
</ul>
<br>

<h3 id="핵심-구현">핵심 구현</h3>
<p><strong>1. 환경 설정</strong></p>
<pre><code class="language-yaml">- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    token: ${{ secrets.PAT_KEY }}

- name: Setup
  run: |
    if ! command -v jq &amp;&gt; /dev/null; then
      sudo apt-get update &amp;&amp; sudo apt-get install -y jq
    fi</code></pre>
<p><code>fetch-depth: 0</code> 옵션을 사용해 전체 Git 히스토리를 가져오고, private 레포지토리에 접근하기 위해 PAT을 설정합니다. 이후 JSON 처리를 위해 jq가 없으면 자동으로 설치되도록 구성했습니다.</p>
<p><strong>2. PR 충돌 감지 로직</strong></p>
<pre><code class="language-bash">MERGE_BASE=$(git merge-base HEAD &quot;origin/${PR_BASE}&quot;)
CONFLICTS=$(git merge-tree &quot;$MERGE_BASE&quot; HEAD &quot;origin/${PR_BASE}&quot; | grep -c &quot;&lt;&lt;&lt;&lt;&lt;&lt;&lt; &quot;)</code></pre>
<p>git merge-base로 두 브랜치가 분기되기 전의 공통 기반 커밋을 구한 뒤, git merge-tree로 실제 병합 없이 충돌 여부만 시뮬레이션하고, &lt;&lt;&lt;&lt;&lt;&lt;&lt; 패턴 등장 여부를 검사해 충돌을 판단합니다.</p>
<p><strong>3. Discord 알림</strong></p>
<pre><code class="language-bash">PAYLOAD=$(jq -n \
  --arg title &quot;⚠️ PR 충돌 발생!&quot; \
  --arg pr_info &quot;[#${PR_NUMBER}](${PR_URL}) ${PR_TITLE}&quot; \
  &#39;{
    embeds: [{
      title: $title,
      color: 15158332,
      fields: [
        { name: &quot;PR 정보&quot;, value: $pr_info, inline: false }
      ]
    }]
  }&#39;)

curl -X POST &quot;${WEBHOOK_URL}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &quot;$PAYLOAD&quot; \
  --fail --silent --show-error</code></pre>
<p>충돌이 감지되면 jq로 JSON payload를 생성해 Discord Webhook으로 전송합니다. </p>
<p><strong>4. 병렬 처리로 성능 개선</strong></p>
<pre><code class="language-bash">check_pr() {
  local PR_NUMBER=$1
  # ... 충돌 체크 로직 ...
}

export -f check_pr
echo &quot;$OPEN_PRS&quot; | xargs -I {} -P 5 bash -c &#39;check_pr &quot;$@&quot;&#39; _ {}</code></pre>
<p><code>xargs -P 5</code>로 최대 5개의 PR을 동시에 체크합니다.</p>
<br>

<h3 id="실제-사용-예시">실제 사용 예시</h3>
<p><strong>시나리오 1: PR 생성/업데이트 시 충돌 감지</strong></p>
<p>PR이 새로 생성되거나 업데이트되면, 워크플로우가 해당 PR과 base 브랜치를 비교해 충돌 여부를 판단합니다.</p>
<pre><code>⚠️ PR 충돌 발생!
PR에서 충돌이 감지되었습니다.

PR 정보: #42 feat: 사용자 인증 구현
브랜치 정보: john | feature/auth → main</code></pre><p><strong>시나리오 2: main/develop에 Push된 변경으로 인한 충돌 감지</strong></p>
<p>base 브랜치에 새로운 커밋을 push하면, 기존에 열려 있던 모든 PR을 다시 검사합니다. 이 과정에서 특정 PR이 충돌할 경우 알림을 전송합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/127d4d3b-562f-4c91-9e07-4cb270b0d799/image.png" alt=""></p>
<h3 id="환경-설정">환경 설정</h3>
<p>GitHub Secrets에 아래 값을 저장합니다.</p>
<pre><code class="language-yaml">secrets.PAT_KEY              # Personal Access Token
secrets.DISCORD_WEBHOOK_URL  # Discord 웹훅 URL</code></pre>
<p>절대 코드에 직접 넣지 말고 반드시 Secrets 사용!</p>
<br>

<h3 id="전체-코드">전체 코드</h3>
<p>🔗 <a href="https://github.com/Kim-Yukyung/pr-conflict/blob/main/.github/workflows/conflict.yaml">https://github.com/Kim-Yukyung/pr-conflict/blob/main/.github/workflows/conflict.yaml</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠버네티스 오퍼레이터 패턴]]></title>
            <link>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%98%A4%ED%8D%BC%EB%A0%88%EC%9D%B4%ED%84%B0-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@yukyung_16/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%98%A4%ED%8D%BC%EB%A0%88%EC%9D%B4%ED%84%B0-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Mon, 01 Dec 2025 16:03:58 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>쿠버네티스를 사용하다 보면 CRD(Custom Resource Definition), 오퍼레이터(Operator) 같은 용어를 자주 접하게 됩니다. </p>
<p>👀 이 글에서는 이 개념들이 무엇이고, 어떤 역할을 하는지 정리해보려고 합니다.</p>
<p>먼저, 쿠버네티스는 기본적으로 유연성과 확장성을 위해 핵심 기능만 최소한으로 제공하고, 나머지는 API를 통해 얼마든지 확장할 수 있도록 설계되었습니다. 그리고 이러한 확장성을 구현하는 대표적인 기술이 <strong>Kubernetes Operator</strong>입니다.</p>
<br>

<h3 id="operator란">Operator란?</h3>
<p>쿠버네티스는 기본적으로 매우 강력한 자동화 기능을 제공합니다. 워크로드의 배포, 확장, 복구 같은 운영 작업뿐 아니라 쿠버네티스 자체가 동작하는 방식도 자동화할 수 있습니다.</p>
<p>Operator 패턴은 이러한 자동화 능력을 애플리케이션 수준까지 확장하기 위한 구조로, 쿠버네티스의 코드를 수정하지 않고도 클러스터의 기능을 자연스럽게 확장할 수 있게 해줍니다.</p>
<p>Operator는 쉽게 말해, 특정 Custom Resource를 감시하고, Kubernetes API를 사용해 애플리케이션 운영에 필요한 복잡한 작업을 자동으로 수행하는 컨트롤러 프로그램입니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1d940226-731d-46a3-a07d-afabab1290a6/image.png" alt=""></p>
<p><strong>🔎 여기서 개념 정리</strong></p>
<p><strong>1) Kubernetes API</strong></p>
<p>쿠버네티스에서 이루어지는 모든 작업은 API 서버를 통해 처리됩니다. kubectl 명령, 기본 컨트롤러 조정(Reconcile), 그리고 Operator가 수행하는 자동화 로직까지 리소스 상태 조회, 스케일링, 재시작과 같은 모든 동작은 Kubernetes API 호출로 실행됩니다.</p>
<p>즉, Kubernetes API는 클러스터의 모든 상태를 읽고 수정하는 표준 인터페이스이며, Operator는 이 API를 사용해 &quot;현재 상태&quot;를 확인하고 &quot;원하는 상태&quot;와 다르면 필요한 조치를 자동으로 수행합니다.</p>
<p><strong>2) CRD (Custom Resource Definition)</strong></p>
<p>쿠버네티스는 기본적으로 Pod, Deployment, Service 같은 기본 리소스를 제공합니다. 하지만 이러한 기본 리소스만으로는 복잡한 운영 요구사항을 모두 만족하기 어렵습니다.</p>
<p>그래서 쿠버네티스는 사용자가 새로운 리소스 타입을 직접 정의할 수 있는 기능, CRD(Custom Resource Definition)를 제공합니다.</p>
<p>CRD를 클러스터에 등록하면 쿠버네티스는 해당 리소스를 기본 리소스처럼 인식하며, kubectl get, apply, delete 등 명령어로 관리할 수 있게 됩니다.</p>
<pre><code class="language-yaml">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresclusters.postgres-operator.crunchydata.com
spec:
  group: postgres-operator.crunchydata.com
  names:
    kind: PostgresCluster
    plural: postgresclusters
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          ...</code></pre>
<p><strong>3) CR (Custom Resource)</strong></p>
<p>CRD가 &quot;리소스의 설계도&quot;라면, CR은 그 설계도를 바탕으로 실제로 생성된 리소스 객체입니다.</p>
<ul>
<li><p>CRD: PostgresCluster 
→ &quot;PostgresCluster라는 리소스 타입이 존재한다&quot;는 선언</p>
</li>
<li><p>CR: my-db 
→ &quot;my-db라는 이름의 Postgres 클러스터를 생성하라&quot;는 실제 리소스 객체</p>
</li>
</ul>
<pre><code class="language-yaml">apiVersion: postgres-operator.crunchydata.com/v1
kind: PostgresCluster
metadata:
  name: my-db
spec:
  instances:
    - replicas: 3</code></pre>
<br>

<h3 id="왜-operator가-필요할까">왜 Operator가 필요할까?</h3>
<p>쿠버네티스는 &quot;단순함 + 유연함 + 자동화&quot;를 목표로 설계된 컨테이너 오케스트레이터입니다. 하지만 기본 리소스(Pod, Deployment, Service 등)만으로는 복잡한 애플리케이션의 운영 로직을 처리하기 어렵습니다.</p>
<p>예를 들어 PostgreSQL, Elasticsearch, Kafka 같은 상태 기반(Stateful) 애플리케이션은 초기 설치 및 설정, 데이터 백업, 버전 업데이트, 장애 복구 ... 등과 같은 다양한 복잡한 운영 작업이 필요합니다.</p>
<p>이러한 반복적이고 복잡한 운영 로직을 자동화하는 것이 Operator입니다. 즉, 쿠버네티스가 제공하는 기본 기능을 애플리케이션 도메인 수준까지 확장해 줍니다.</p>
<br>

<h3 id="어떻게-사용할-수-있을까">어떻게 사용할 수 있을까?</h3>
<p>Operator는 Custom Resource Definition(CRD) 과 함께 동작합니다. 우선 CRD를 통해 새로운 리소스 타입을 쿠버네티스에 등록하고, Operator는 이 리소스의 상태를 감시하며 생명주기를 자동으로 관리합니다.</p>
<p>1) 기존 Operator 설치</p>
<p>Postgres, Elasticsearch, Prometheus 등 많은 오픈소스 프로젝트가 자체 Operator를 제공하며, Helm이나 OperatorHub를 통해 쉽게 설치해 사용할 수 있습니다.</p>
<p>2) 직접 Operator 구현</p>
<p>Kubebuilder나 Operator SDK를 사용해 CRD 정의, 컨트롤러(Reconcile 로직) 구현, 클러스터 배포의 순서로 직접 Operator를 개발할 수도 있습니다.</p>
<br>

<h3 id="참고">참고</h3>
<p>🔗 <a href="https://kubernetes.io/ko/docs/concepts/extend-kubernetes/operator/">https://kubernetes.io/ko/docs/concepts/extend-kubernetes/operator/</a></p>
<p>🔗 <a href="https://www.cncf.io/blog/2022/06/15/kubernetes-operators-what-are-they-some-examples/">https://www.cncf.io/blog/2022/06/15/kubernetes-operators-what-are-they-some-examples/</a></p>
<p>🔗 <a href="https://kubernetes.io/ko/docs/concepts/overview/kubernetes-api/">https://kubernetes.io/ko/docs/concepts/overview/kubernetes-api/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Go 모듈 작동 방식]]></title>
            <link>https://velog.io/@yukyung_16/Go-%EB%AA%A8%EB%93%88-%EC%9E%91%EB%8F%99-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@yukyung_16/Go-%EB%AA%A8%EB%93%88-%EC%9E%91%EB%8F%99-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Sun, 23 Nov 2025 09:29:27 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p><code>go get</code> 명령어을 사용했는데 최신 코드가 내려오지 않는 문제가 있었습니다. 처음에는 당연히 <code>github.com/~</code> 형태의 모듈이라 GitHub에서 직접 가져오는 줄 알았지만, 실제로는 다른 방식으로 동작하고 있었습니다.</p>
<p>👀 이 글에서는 Go 모듈 시스템이 어떻게 동작하는지 정리해보겠습니다.</p>
<br>

<h3 id="go-모듈이란">Go 모듈이란?</h3>
<p>Go 모듈은 관련 Go 패키지들의 집합으로, 프로젝트 루트에 위치한 go.mod 파일로 정의됩니다. 이 파일에는 모듈 경로, 필요한 Go 버전, 그리고 프로젝트가 의존하는 모듈과 그 버전 정보가 명시되어 있어 종속성 관리를 쉽게 할 수 있습니다.</p>
<pre><code class="language-go">module ebpf-route

go 1.25.0

require (
    github.com/cilium/ebpf v0.15.0
    github.com/spf13/cobra v1.10.1
    github.com/spf13/viper v1.21.0
)

require (
    github.com/fsnotify/fsnotify v1.9.0 // indirect
    github.com/go-viper/mapstructure/v2 v2.4.0 // indirect</code></pre>
<p><strong>go.mod 파일 구조</strong></p>
<p>1) require: 프로젝트가 직접적으로 의존하는 모듈과 버전을 선언합니다.</p>
<p>2) replace: 특정 모듈을 다른 경로나 버전으로 대체할 때 사용합니다. </p>
<p>3) exclude: 특정 버전의 모듈을 의존성 해석에서 제외할 때 사용합니다.</p>
<p><strong>go.sum 파일 역할</strong></p>
<p>go.sum 파일은 각 모듈 버전의 암호화된 해시(체크섬)를 저장합니다. 이 정보를 통해 동일한 버전의 모듈이 항상 동일한 내용을 갖는지 검증하며, 악의적인 코드 변경을 방지할 수 있습니다. </p>
<p>go mod tidy, go mod download, go build 명령어를 실행할 때 자동으로 업데이트됩니다.</p>
<br>

<h3 id="goget-동작-방식">go.get 동작 방식</h3>
<p><code>go get rsc.io/quote@v1.5.2</code>을 실행하면 GitHub에서 직접 가져올 것 같았지만, 그렇지 않았습니다 🫢</p>
<p>1) <a href="https://rsc.io/quote?go-get=1%EC%97%90">https://rsc.io/quote?go-get=1에</a> 접속하여 <meta name="go-import"> 태그를 조회합니다.</p>
<p>2) proxy.golang.org에서 캐시된 모듈을 먼저 조회합니다.</p>
<p>3) sum.golang.org에서 체크섬을 검증합니다.</p>
<p>4) go.sum에 해시를 기록하고 로컬에 모듈을 저장합니다.</p>
<p><strong>Go 모듈 공용 인프라</strong></p>
<p>1) proxy.golang.org (모듈 프록시)</p>
<p>모듈 다운로드 속도를 높이기 위한 캐시 서버로, go get 실행 시 기본적으로 이 프록시를 먼저 조회합니다. 원본 저장소가 접근 불가능하더라도 모듈을 다운로드할 수 있습니다.</p>
<p>2) sum.golang.org (체크섬 데이터베이스)</p>
<p>공개 Go 모듈의 모든 버전에 대한 해시 정보를 저장하는 투명 로그 기반 데이터베이스입니다.</p>
<p>3) index.golang.org (인덱스 서비스)</p>
<p>프록시가 캐싱한 모듈 버전들의 타임라인 피드를 제공합니다.</p>
<p><strong>❓ 그렇다면 여기서 왜 체크썸 데이터베이스가 필요할까?라는 궁금증이 생겼습니다</strong></p>
<p>기존의 go get은 다운로드한 코드가 원래 의도한 코드인지 검증하지 못했습니다. 즉, 공격자가 저장소나 네트워크 중간에서 코드를 바꿔도 이를 탐지할 방법이 없었습니다.</p>
<p>물론 go.sum이 도입되면서 로컬 차원의 검증은 가능해졌지만,
최초 다운로드 시점에는 비교할 대상이 없어 무결성을 보장할 수 없다는 한계가 있었습니다.</p>
<p>이 문제를 해결하기 위해 sum.golang.org(체크섬 데이터베이스) 가 도입되었습니다. 이 데이터베이스는 공개된 모든 Go 모듈 버전의 해시를 투명 로그 형태로 기록하며, 한 번 기록된 해시는 수정하거나 삭제할 수 없습니다.</p>
<p>따라서 공격자가 GitHub 저장소를 해킹하거나 네트워크를 변조해 코드를 바꾸더라도, 기록된 해시와 달라지는 순간 즉시 위조 사실이 드러납니다. 이 구조 덕분에 Go는 안전하고 신뢰할 수 있는 종속성 관리 시스템을 구현할 수 있게 되었습니다.</p>
<br>

<h3 id="문제-분석">문제 분석</h3>
<p>외부 모듈을 사용하던 프로젝트에서, 해당 모듈에 작은 수정사항을 적용한 뒤 기존과 동일한 버전(tag)으로 다시 배포하고 싶은 요구사항이 있었습니다. 즉, 새로운 버전을 만들지 않고 같은 태그 아래 변경된 코드를 덮어쓰는 방식을 기대했습니다.</p>
<p>처음에는 GitHub에서 코드를 가져올 것이므로 변경된 내용이 바로 반영될 것이라고 예상했지만, 실제로는 어떤 방법을 써도 이전 코드만 내려왔습니다. 로컬 캐시 삭제, 프록시 비활성화, 체크섬 검증 비활성화 등 다양한 시도를 했음에도 결과는 동일했습니다. 🥲</p>
<p>그 이유는 Go에서는 한 번 공개된 태그는 그 시점의 코드로 영원히 고정되기 때문입니다. 모듈이 처음 배포되는 순간 <code>코드 내용 → 체크섬 → 버전 정보</code>가 프록시 서버와 체크섬 데이터베이스에 기록되며, 이 값은 변경할 수 없습니다.</p>
<p>물론, go.sum을 삭제하고 체크섬 검증을 완전히 끄면 검증 절차가 사라지기 때문에 변경된 코드를 강제로 내려받을 수는 있었습니다. 하지만 이 방법은 검증 체계를 우회하는 상황일 뿐, Go 모듈의 보안 모델에 맞지 않습니다.</p>
<pre><code class="language-shell"># 1. 모듈 캐시 삭제
go clean -modcache

# 2. go.sum 삭제
rm go.sum

# 3. 프록시/검증 끄고 재다운로드
GOPROXY=direct GOSUMDB=off go get github.com/~
go mod tidy</code></pre>
<p>❗️ 결국 같은 버전을 다시 배포하는 방식은 Go에서 지원되지 않으며, 외부 모듈을 수정해야 한다면 항상 새로운 태그를 생성하는 것이 해결책입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform 병렬 실행 구조 알아보기]]></title>
            <link>https://velog.io/@yukyung_16/Terraform-%EB%B3%91%EB%A0%AC-%EC%8B%A4%ED%96%89-%EA%B5%AC%EC%A1%B0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Terraform-%EB%B3%91%EB%A0%AC-%EC%8B%A4%ED%96%89-%EA%B5%AC%EC%A1%B0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 13 Sep 2025 12:57:58 GMT</pubDate>
            <description><![CDATA[<h3 id="병렬-처리-기본-구조">병렬 처리 기본 구조</h3>
<p><strong>parallelism 파라미터</strong></p>
<p>Terraform CLI는 <code>--parallelism=N</code> 옵션을 통해 동시에 실행할 리소스의 최대 개수를 제한할 수 있습니다. 기본값은 10입니다.</p>
<p><a href="https://developer.hashicorp.com/terraform/cli/commands/apply">🔗 테라폼 공식 문서 바로가기</a></p>
<p><strong>internal/terraform/context.go</strong>  <a href="https://github.com/hashicorp/terraform">테라폼 코드 바로가기</a></p>
<pre><code class="language-go">par := opts.Parallelism

// 음수는 에러 반환
if par &lt; 0 {
    return nil, diags
}

// 0이면 기본값 10 적용
if par == 0 {
    par = 10
}</code></pre>
<p>parallelism 값이 0이면 기본값 10으로 설정되며, 음수 값이 입력되면 오류를 반환하며 실행을 중단합니다. 이 설정 값은 내부적으로 세마포어를 통해 병렬 실행 수를 제어하는 데 사용됩니다.</p>
<br>

<h3 id="세마포어로-병렬-실행-제한">세마포어로 병렬 실행 제한</h3>
<p>테라폼은 병렬 실행 수를 제어하기 위해 Go의 buffered channel을 기반으로 한 세마포어 구조를 사용합니다.</p>
<p><strong>internal/terraform/semaphore.go</strong></p>
<pre><code class="language-go">func NewSemaphore(n int) Semaphore {
    if n &lt;= 0 {
        panic(&quot;semaphore with limit &lt;=0&quot;)
    }
    ch := make(chan struct{}, n)
    return Semaphore(ch)
}</code></pre>
<p>내부적으로 <code>chan struct{}</code> 타입의 버퍼 채널을 사용해 세마포어를 만듭니다. 버퍼 크기 n은 병렬로 동시에 실행 가능한 최대 리소스 수를 의미합니다.</p>
<p><strong>internal/terraform/graph_walk_context.go</strong></p>
<pre><code class="language-go">func (w *ContextGraphWalker) Execute(ctx EvalContext, n GraphNodeExecutable) tfdiags.Diagnostics {
    // Acquire a lock on the semaphore
    w.Context.parallelSem.Acquire() // 세마포어 락 획득
    defer w.Context.parallelSem.Release() // 종료 후 해제

    return n.Execute(ctx, w.Operation)
}</code></pre>
<p>테라폼은 각 리소스를 실행할 때 <code>Acquire()</code>를 호출하여 세마포어 자원을 획득하고, 실행이 끝나면 <code>Release()</code>로 반환합니다. 이로 인해 설정된 개수를 초과하는 작업은 자동으로 대기 상태에 들어가며, 최대 병렬 실행 수가 제한됩니다.</p>
<p><strong>🔍 버퍼 채널이란?</strong></p>
<p>Go에서는 기본적으로 채널은 하나의 값을 주고받을 때마다 송신자와 수신자가 서로 기다려야 하지만, 버퍼 채널은 미리 정해진 크기만큼 값을 쌓아둘 수 있기 때문에 기다리지 않고 정해진 개수만큼 값을 미리 보내 둘 수 있습니다.</p>
<p>테라폼에서는 이 특성을 이용해 동시에 실행할 수 있는 작업의 수를 조절합니다. 버퍼가 가득 차면 새로운 값은 들어갈 수 없고 대기하게 되므로, 동시에 실행되는 작업 수가 n을 넘지 않도록 보장할 수 있습니다.</p>
<br>

<h3 id="dag-기반-실행--흐름-internaldagwalkgo">DAG 기반 실행  흐름 (internal/dag/walk.go)</h3>
<p>테라폼은 리소스 간의 의존 관계를 DAG(방향성 비순환 그래프)로 표현하고, 이 그래프를 따라 리소스를 실행합니다. 쉽게 말해, 어떤 리소스가 다른 리소스를 참조하거나 의존할 경우, 해당 리소스가 먼저 실행된 후에 다음 리소스가 실행되도록 설계되어 있습니다.</p>
<p><strong>Walker 구조체</strong></p>
<pre><code class="language-go">type Walker struct {
    Callback   WalkFunc    // 실제 리소스를 실행할 함수
    vertices   Set         // 모든 리소스(vertex) 목록
    edges      Set         // 의존 관계(edge) 목록
    vertexMap  map[Vertex]*walkerVertex // 각 리소스의 실행 상태 저장
    wait       sync.WaitGroup  // 전체 작업 완료 대기
    ...
}</code></pre>
<p><strong>고루틴 생성 전략</strong></p>
<p>테라폼은 각 리소스를 실행할 때 2개의 고루틴을 생성합니다.</p>
<pre><code class="language-go">// Walker will create V*2 goroutines (one for each vertex, and dependency
// waiter for each vertex). In general this should be of no concern unless
// there are a huge number of vertices.</code></pre>
<ul>
<li>waitDeps(): 이 리소스가 언제 실행할 수 있는지 판단</li>
<li>walkVertex(): 실제 리소스를 실행하는 작업 수행</li>
</ul>
<p>이렇게 분리하면 작업 실행과 의존성 대기가 서로 간섭 없이 병렬로 진행되기 때문에 리소스 수가 많아도 각 작업이 언제 시작해야 할지를 정확하고 빠르게 판단할 수 있습니다. </p>
<p><strong>실행 대기: waitDeps</strong></p>
<pre><code class="language-go">func (w *Walker) waitDeps(...) {
    for dep, depCh := range deps {
        &lt;-depCh  // 각 의존성 리소스가 끝날 때까지 대기
    }

    // 모든 의존성이 성공했는지 확인
    for dep := range deps {
        if w.diagsMap[dep].HasErrors() {
            doneCh &lt;- false // 실패한 의존성이 있음
            return
        }
    }

    doneCh &lt;- true // 모두 성공 → 실행 시작 가능
}</code></pre>
<p>의존성이 없는 리소스는 바로 실행되고, 의존성이 있는 리소스는 상위 작업이 끝날 때까지 기다립니다. 만약 상위 작업 중 하나라도 실패하면, 해당 리소스는 실행되지 않고 건너뛰게 됩니다.</p>
<p><strong>실행: walkVertex</strong></p>
<pre><code class="language-go">func (w *Walker) walkVertex(v Vertex, info *walkerVertex) {
    // 의존성 대기
    ...

    // 모든 의존성이 성공했을 때만 실행
    if depsSuccess {
        diags = w.Callback(v)  // 실제 작업 실행
    } else {
        diags = diags.Append(errors.New(&quot;upstream dependencies failed&quot;))
    }

    // 실행 결과 저장
    w.diagsMap[v] = diags
}</code></pre>
<p>모든 의존 리소스가 정상적으로 실행되었을 때만 작업을 수행합니다. 실행 후에는 성공/실패 결과를 기록하고, 다음 리소스들이 이 결과를 참고할 수 있도록 합니다.</p>
<br>

<h3 id="전체-실행-흐름-정리">전체 실행 흐름 정리</h3>
<ol>
<li>리소스 간 관계를 분석해 DAG 생성</li>
<li>각 리소스마다 의존성 감시용과 실행용 고루틴 생성</li>
<li>상위 리소스가 모두 끝날 때까지 대기</li>
<li>조건이 충족된 리소스를 최대 N개까지 동시 실행</li>
<li>모든 실행이 끝나면 성공/실패 결과 수집</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Object Storage와 CDN으로 정적 웹사이트 배포하기]]></title>
            <link>https://velog.io/@yukyung_16/Object-Storage%EC%99%80-CDN%EC%9C%BC%EB%A1%9C-%EC%A0%95%EC%A0%81-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Object-Storage%EC%99%80-CDN%EC%9C%BC%EB%A1%9C-%EC%A0%95%EC%A0%81-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 17 Aug 2025 07:08:47 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>정적 파일을 배포하는 방법은 다양하지만, 이번 프로젝트에서는 비용 효율적이면서도 클라우드 환경에 최적화된 방식을 선택했습니다.</p>
<p>KakaoCloud에서 제공하는 Object Storage와 CDN 조합을 활용하면, 별도의 서버 없이도 React/Vite 같은 프론트엔드 빌드 산출물을 안정적으로 서비스할 수 있습니다.</p>
<p><a href="https://docs.kakaocloud.com/tutorial/networking-content-delivery/object-storage-cdn-static-website">🔗 카카오클라우드 공식 튜토리얼 바로가기</a></p>
<br>

<h3 id="cdn이란">CDN이란?</h3>
<p>CDN(Content Delivery Network)은 전 세계 여러 지점에 분산된 서버 네트워크를 통해 웹 페이지, 이미지, 동영상 등 정적 콘텐츠를 빠르게 전달하는 기술입니다. 사용자가 특정 지역에서 웹사이트를 요청하면, 가장 가까운 CDN 서버가 콘텐츠를 제공하여 지연을 최소화합니다.</p>
<p>쉽게 말하면, 전 세계 캐싱 서버에 내 웹사이트를 저장해두는 것이라고 이해할 수 있습니다.</p>
<p><strong>참고 자료</strong></p>
<p><a href="https://aws.amazon.com/ko/what-is/cdn/">AWS - CDN이란 무엇인가요?</a>
<a href="https://docs.kakaocloud.com/service/bns/cdn/cdn-overview">KakaoCloud - CDN 개요</a></p>
<br>

<h3 id="1-object-stroage-만들기">1. Object Stroage 만들기</h3>
<p>먼저 KakaoCloud 콘솔에서 Object Storage를 생성합니다. 이 버킷은 정적 파일을 업로드하는 저장소로 사용됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/f3c160bc-c635-4c04-8a8d-813a365fd664/image.png" alt=""></p>
<p>생성 후, vite build 결과물(<code>dist/</code>)의 모든 파일을 업로드합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/70848c38-6625-42b4-b00a-5000dc4aac41/image.png" alt=""></p>
<h3 id="2-cdn-생성">2. CDN 생성</h3>
<p>이제 Object Storage를 오리진으로 연결하는 CDN을 생성합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/edcae71e-8c19-487c-898d-48364c1c6901/image.png" alt=""></p>
<ul>
<li>오리진 서버: 방금 만든 Object Storage</li>
<li>오리진 서버 경로: /dist</li>
<li>나머지는 기본값으로 설정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/ae66c6d1-9e6d-4aca-84fa-d71b6e249fd2/image.png" alt=""></p>
<h3 id="3-결과-확인">3. 결과 확인</h3>
<p>정상적으로 설정이 완료되면, <code>https://&lt;카카오클라우드 서비스 도메인&gt;/index.html</code>로 접속했을 때 vite로 빌드한 화면이 배포된 것을 확인할 수 있습니다 🎉</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/ff6c5e0a-e7bf-48db-bab2-d0c6e5744a87/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/d5a14121-6d8a-4a4c-8685-f07b691ac31c/image.png" alt=""></p>
<br>

<h3 id="github-actions로-자동-배포하기">GitHub Actions로 자동 배포하기</h3>
<p>수동으로 <code>dist/</code> 파일을 업로드하는 대신, GitHub Actions CI/CD 파이프라인을 구성하면 main 브랜치에 코드를 푸시할 때마다 자동으로 KakaoCloud Object Storage에 정적 파일이 배포됩니다.</p>
<p>이 워크플로우의 주요 단계는 다음과 같습니다. 😃</p>
<ol>
<li>빌드 단계</li>
</ol>
<ul>
<li>vite build 실행으로 <code>dist/</code> 폴더 생성</li>
<li>산출물(<code>dist/</code>)을 아티팩트로 업로드</li>
</ul>
<ol start="2">
<li>배포 단계</li>
</ol>
<ul>
<li>빌드된 산출물 다운로드</li>
<li>KakaoCloud IAM API로 인증 토큰 발급</li>
<li>Object Storage 버킷이 없으면 자동 생성</li>
<li><code>dist/</code> 파일 업로드</li>
</ul>
<p>아래는 전체 스크립트입니다.</p>
<pre><code class="language-shell">name: Deploy to Kakao Object Storage

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: &#39;npm&#39;

      - name: Install dependencies
        run: npm ci --no-audit

      - name: Build app (vite only, no tsc)
        run: npx vite build --mode development
        env:
          DISABLE_ESLINT_PLUGIN: true
          GENERATE_SOURCEMAP: false

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist-files
          path: dist/
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      KAKAO_REGION: ${{ secrets.KAKAO_REGION }}
      KAKAO_PROJECT_ID: ${{ secrets.KAKAO_PROJECT_ID }}
      KAKAO_BUCKET: ${{ secrets.KAKAO_BUCKET }}
      KAKAO_IAM_ACCESS_KEY_ID: ${{ secrets.KAKAO_IAM_ACCESS_KEY_ID }}
      KAKAO_IAM_SECRET_KEY: ${{ secrets.KAKAO_IAM_SECRET_KEY }}
      OBJECT_PREFIX: dist

    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: dist-files
          path: dist/

      - name: Install tools
        run: |
          sudo apt-get update -y
          sudo apt-get install -y jq file

      - name: Issue API token
        id: auth
        shell: bash
        run: |
          set -e
          if [ -z &quot;${KAKAO_IAM_ACCESS_KEY_ID}&quot; ] || [ -z &quot;${KAKAO_IAM_SECRET_KEY}&quot; ]; then
            echo &quot;KAKAO_IAM_ACCESS_KEY_ID or KAKAO_IAM_SECRET_KEY is missing&quot;
            exit 1
          fi
          AUTH_PAYLOAD=$(cat &lt;&lt;JSON
          {
            &quot;auth&quot;: {
              &quot;identity&quot;: {
                &quot;methods&quot;: [&quot;application_credential&quot;],
                &quot;application_credential&quot;: {
                  &quot;id&quot;: &quot;${KAKAO_IAM_ACCESS_KEY_ID}&quot;,
                  &quot;secret&quot;: &quot;${KAKAO_IAM_SECRET_KEY}&quot;
                }
              }
            }
          }
          JSON
          )
          RESP_FILE=$(mktemp)
          HEADERS_FILE=$(mktemp)
          HTTP_CODE=$(curl -sS -o &quot;$RESP_FILE&quot; -D &quot;$HEADERS_FILE&quot; -w &quot;%{http_code}&quot; \
            -X POST &quot;https://iam.kakaocloud.com/identity/v3/auth/tokens&quot; \
            -H &quot;Content-Type: application/json&quot; \
            -H &quot;Accept: application/json&quot; \
            -d &quot;$AUTH_PAYLOAD&quot;)
          TOKEN=$(grep -i &#39;^x-subject-token:&#39; &quot;$HEADERS_FILE&quot; | awk -F&#39;: &#39; &#39;{print $2}&#39; | tr -d &#39;\r&#39;)
          if [ -z &quot;$TOKEN&quot; ]; then
            echo &quot;Failed to get X-Subject-Token (HTTP $HTTP_CODE)&quot;
            echo &quot;---- response headers ----&quot;
            cat &quot;$HEADERS_FILE&quot; || true
            echo &quot;---- response body ----&quot;
            cat &quot;$RESP_FILE&quot; || true
            exit 1
          fi
          echo &quot;token=$TOKEN&quot; &gt;&gt; $GITHUB_OUTPUT

      - name: Ensure bucket exists
        run: |
          set -e
          BASE_URL=&quot;https://objectstorage.${KAKAO_REGION}.kakaocloud.com/v1/${KAKAO_PROJECT_ID}/${KAKAO_BUCKET}&quot;
          # Create container if not exists (Swift: PUT on container path)
          curl -s -o /dev/null -w &quot;%{http_code}&quot; -X PUT &quot;$BASE_URL&quot; \
            -H &quot;X-Auth-Token: ${{ steps.auth.outputs.token }}&quot; \
            || true

      - name: Upload dist to bucket (overwrite)
        run: |
          set -e
          BASE_URL=&quot;https://objectstorage.${KAKAO_REGION}.kakaocloud.com/v1/${KAKAO_PROJECT_ID}/${KAKAO_BUCKET}&quot;
          if [ -n &quot;${OBJECT_PREFIX}&quot; ]; then
            BASE_PATH=&quot;${BASE_URL}/${OBJECT_PREFIX}&quot;
          else
            BASE_PATH=&quot;${BASE_URL}&quot;
          fi

          # Function to get Content-Type by file extension
          get_content_type() {
            local file_path=&quot;$1&quot;
            case &quot;${file_path##*.}&quot; in
              css) echo &quot;text/css&quot; ;;
              js|mjs) echo &quot;application/javascript&quot; ;;
              json) echo &quot;application/json&quot; ;;
              html|htm) echo &quot;text/html&quot; ;;
              svg) echo &quot;image/svg+xml&quot; ;;
              png) echo &quot;image/png&quot; ;;
              jpg|jpeg) echo &quot;image/jpeg&quot; ;;
              gif) echo &quot;image/gif&quot; ;;
              webp) echo &quot;image/webp&quot; ;;
              ico) echo &quot;image/x-icon&quot; ;;
              woff) echo &quot;font/woff&quot; ;;
              woff2) echo &quot;font/woff2&quot; ;;
              ttf) echo &quot;font/ttf&quot; ;;
              otf) echo &quot;font/otf&quot; ;;
              eot) echo &quot;application/vnd.ms-fontobject&quot; ;;
              map) echo &quot;application/json&quot; ;;
              txt) echo &quot;text/plain&quot; ;;
              xml) echo &quot;application/xml&quot; ;;
              pdf) echo &quot;application/pdf&quot; ;;
              zip) echo &quot;application/zip&quot; ;;
              *) echo &quot;application/octet-stream&quot; ;;
            esac
          }

          find dist -type f ! -name &#39;index.html&#39; -print0 | while IFS= read -r -d &#39;&#39; file; do
            REL_PATH=&quot;${file#dist/}&quot;
            MIME_TYPE=$(get_content_type &quot;$REL_PATH&quot;)
            if [[ &quot;$REL_PATH&quot; =~ \.[0-9a-fA-F]{8,}\.(js|css|svg|png|jpg|jpeg|gif|webp|ico|map)$ ]]; then
              CACHE_CONTROL=&quot;public, max-age=31536000, immutable&quot;
            else
              CACHE_CONTROL=&quot;no-cache, no-store, must-revalidate&quot;
            fi
            echo &quot;Uploading ${OBJECT_PREFIX:+$OBJECT_PREFIX/}$REL_PATH (type: $MIME_TYPE, cache: $CACHE_CONTROL)&quot;
            curl --fail -s -X PUT &quot;$BASE_PATH/$REL_PATH&quot; \
              -H &quot;X-Auth-Token: ${{ steps.auth.outputs.token }}&quot; \
              -H &quot;Content-Type: $MIME_TYPE&quot; \
              -H &quot;Cache-Control: $CACHE_CONTROL&quot; \
              --data-binary @&quot;$file&quot; \
              -o /dev/null || { echo &quot;Failed to upload $REL_PATH&quot;; exit 1; }
          done

          if [ -f dist/index.html ]; then
            MIME_TYPE=&quot;text/html&quot;
            CACHE_CONTROL=&quot;no-cache, no-store, must-revalidate&quot;
            echo &quot;Uploading ${OBJECT_PREFIX:+$OBJECT_PREFIX/}index.html (type: $MIME_TYPE, cache: $CACHE_CONTROL)&quot;
            curl --fail -s -X PUT &quot;$BASE_PATH/index.html&quot; \
              -H &quot;X-Auth-Token: ${{ steps.auth.outputs.token }}&quot; \
              -H &quot;Content-Type: $MIME_TYPE&quot; \
              -H &quot;Cache-Control: $CACHE_CONTROL&quot; \
              --data-binary @&quot;dist/index.html&quot; \
              -o /dev/null || { echo &quot;Failed to upload index.html&quot;; exit 1; }
          fi

      - name: Done
        run: echo &quot;배포 완료&quot;</code></pre>
<br>

<h3 id="마지막으로">마지막으로</h3>
<p>비슷한 구성을 AWS 환경에서 구현하고 싶다면 아래 문서를 참고해도 좋을 것 같습니다 :)</p>
<p><a href="https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/tutorial-s3-cloudfront-route53-video-streaming.html">Amazon S3 + CloudFront + Route53 정적 웹사이트 배포</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[OpenStack Endpoint [CRUD] Functional Test]]></title>
            <link>https://velog.io/@yukyung_16/OpenStack-Endpoint-CRUD-Functional-Test</link>
            <guid>https://velog.io/@yukyung_16/OpenStack-Endpoint-CRUD-Functional-Test</guid>
            <pubDate>Fri, 15 Aug 2025 16:02:37 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>OpenStack SDK의 Identity Endpoint에 대한 functional test가 없어, 테스트 코드를 작성해보았습니다 😃 (with 오픈소스 컨트리뷰션!)</p>
<br>

<h3 id="openstack-endpoint란">OpenStack Endpoint란?</h3>
<p>OpenStack에서 Endpoint는 각 서비스의 접속 지점을 정의하는 핵심 구성 요소입니다. 클라이언트가 특정 서비스에 접근할 때, 어떤 URL로 요청을 보내야 하는지를 알려주는 역할을 합니다.</p>
<p><strong>CLI 명령어</strong></p>
<pre><code class="language-bash"># Endpoint 생성
openstack endpoint create compute public https://nova.example.com/v2.1 --region RegionOne

# Endpoint 목록 조회
openstack endpoint list --service compute

# Endpoint 수정 (비활성화)
openstack endpoint set &lt;endpoint-id&gt; --disable

# Endpoint 삭제
openstack endpoint delete &lt;endpoint-id&gt;</code></pre>
<p><strong>SDK에서 CRUD 매핑</strong></p>
<p><a href="https://velog.io/@yukyung_16/openstack-endpoint-CRUD-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%BD%94%EB%93%9C-%EB%8F%99%EC%9E%91-%ED%9D%90%EB%A6%84-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">🔗 OpenStack Endpoint CRUD 동작 구조</a></p>
<pre><code class="language-python"># Create
endpoint = identity.create_endpoint(...)  # POST /v3/endpoints

# Read  
endpoint = identity.get_endpoint(id)      # GET /v3/endpoints/{id}
endpoints = identity.endpoints()          # GET /v3/endpoints

# Update
endpoint = identity.update_endpoint(...)  # PATCH /v3/endpoints/{id}

# Delete
identity.delete_endpoint(endpoint)       # DELETE /v3/endpoints/{id}</code></pre>
<br>

<h3 id="functional-test란">Functional Test란?</h3>
<p>Functional Test는 Unit Test와 달리 실제 환경에서 전체 기능을 검증하는 테스트입니다.</p>
<ul>
<li>실제 OpenStack 환경에서 실행</li>
<li>실제 HTTP 요청을 서버에 전송</li>
<li>실제 데이터베이스에 리소스 생성/수정/삭제</li>
<li>End-to-End 시나리오 검증</li>
</ul>
<br>

<h3 id="테스트-코드-작성-과정">테스트 코드 작성 과정</h3>
<p><a href="https://github.com/Kim-Yukyung/openstacksdk/pull/1">🔗 전체 코드 PR</a></p>
<p>*<em>1) 테스트 구조 설계 *</em></p>
<p>먼저 기존 functional test들의 패턴을 분석했습니다. OpenStack SDK의 functional test는 다음과 같은 구조를 가집니다.</p>
<pre><code class="language-python">class TestEndpoint(base.BaseFunctionalTest):
    def setUp(self):
        super().setUp()
        # 테스트에 필요한 리소스 준비

    def test_endpoint(self):
        # 메인 CRUD 테스트

    def test_endpoint_list_filters(self):
        # 필터링 기능 테스트</code></pre>
<p><strong>2) CRUD 테스트 구현</strong></p>
<pre><code class="language-python">def test_endpoint(self):
    # CREATE: Endpoint 생성
    endpoint = self.operator_cloud.identity.create_endpoint(
        service_id=self.service.id,
        interface=&#39;public&#39;,
        url=self.test_url,
        region_id=self.region.id,
        is_enabled=True,
    )
    self.addCleanup(self._delete_endpoint, endpoint)

    # 생성 결과 검증
    self.assertIsInstance(endpoint, _endpoint.Endpoint)
    self.assertEqual(self.service.id, endpoint.service_id)
    self.assertEqual(&#39;public&#39;, endpoint.interface)
    self.assertEqual(self.test_url, endpoint.url)
    self.assertEqual(self.region.id, endpoint.region_id)
    self.assertTrue(endpoint.is_enabled)

    # UPDATE: Endpoint 수정
    endpoint = self.operator_cloud.identity.update_endpoint(
        endpoint, url=self.updated_url, is_enabled=False
    )

    # 수정 결과 검증
    self.assertEqual(self.updated_url, endpoint.url)
    self.assertFalse(endpoint.is_enabled)

    # READ: 단일 조회
    fetched_endpoint = self.operator_cloud.identity.get_endpoint(endpoint.id)
    self.assertEqual(endpoint.id, fetched_endpoint.id)
    self.assertEqual(self.updated_url, fetched_endpoint.url)

    # READ: 검색으로 조회
    found_endpoint = self.operator_cloud.identity.find_endpoint(endpoint.id)
    self.assertEqual(endpoint.id, found_endpoint.id)

    # READ: 목록 조회
    endpoints = list(self.operator_cloud.identity.endpoints())
    endpoint_ids = {ep.id for ep in endpoints}
    self.assertIn(endpoint.id, endpoint_ids)

    # DELETE는 cleanup에서 자동으로 실행</code></pre>
<p><strong>3) 필터링 테스트 구현</strong></p>
<pre><code class="language-python">def test_endpoint_list_filters(self):
    # 테스트용 Endpoint 생성
    endpoint = self.operator_cloud.identity.create_endpoint(
        service_id=self.service.id,
        interface=&#39;internal&#39;,
        url=self.test_url,
        region_id=self.region.id,
        is_enabled=True,
    )
    self.addCleanup(self._delete_endpoint, endpoint)

    # Service ID로 필터링
    service_endpoints = list(
        self.operator_cloud.identity.endpoints(service_id=self.service.id)
    )
    service_endpoint_ids = {ep.id for ep in service_endpoints}
    self.assertIn(endpoint.id, service_endpoint_ids)

    # Interface로 필터링
    internal_endpoints = list(
        self.operator_cloud.identity.endpoints(interface=&#39;internal&#39;)
    )
    internal_endpoint_ids = {ep.id for ep in internal_endpoints}
    self.assertIn(endpoint.id, internal_endpoint_ids)

    # Region ID로 필터링
    region_endpoints = list(
        self.operator_cloud.identity.endpoints(region_id=self.region.id)
    )
    region_endpoint_ids = {ep.id for ep in region_endpoints}
    self.assertIn(endpoint.id, region_endpoint_ids)

    # 존재하지 않는 Service로 필터링
    empty_endpoints = list(
        self.operator_cloud.identity.endpoints(service_id=&#39;nonexistent&#39;)
    )
    self.assertEqual([], empty_endpoints)</code></pre>
<br>

<h3 id="환경-설정-트러블슈팅">환경 설정 트러블슈팅</h3>
<p><strong>1) Cloud 설정 오류</strong></p>
<pre><code class="language-bash">$ tox -e functional -- openstack.tests.functional.identity.v3.test_endpoint
Cloud devstack-admin was not found.</code></pre>
<p>‼️ Functional Test는 여러 cloud 설정을 필요로 했습니다.</p>
<pre><code class="language-yaml"># ~/.config/openstack/clouds.yaml
clouds:
  devstack:           # 기본 cloud
    auth_type: password
    auth:
      auth_url: http://&lt;IP&gt;/identity
      username: admin
      password: admin
      project_name: admin
      user_domain_name: default
      project_domain_name: default
    region_name: RegionOne
    interface: public
    identity_api_version: 3

  devstack-alt:       # 대체 cloud (동일한 설정)
    auth_type: password
    # ... 동일한 설정

  devstack-admin:     # 관리자 cloud (동일한 설정)
    auth_type: password
    # ... 동일한 설정

  devstack-system-admin:  # 시스템 관리자 cloud (동일한 설정)
    auth_type: password
    # ... 동일한 설정</code></pre>
<p><strong>2) HTTP 401 인증 오류</strong></p>
<p>CLI에서는 인증이 성공하는데 tox 환경에서는 계속 401 오류가 발생했습니다.</p>
<pre><code class="language-bash">$ openstack --os-cloud devstack-admin token issue  # 성공
$ tox -e functional -- test_endpoint  # HTTP 401</code></pre>
<p>‼️ 여러 시도 끝에 도메인 설정 방식이 문제였음을 발견했습니다.</p>
<pre><code class="language-yaml"># 실패하던 설정
auth:
  user_domain_id: default
  project_domain_id: default

# 성공한 설정  
auth:
  user_domain_name: default
  project_domain_name: default</code></pre>
<br>

<h3 id="테스트-실행">테스트 실행</h3>
<pre><code class="language-bash">tox -e functional -- openstack.tests.functional.identity.v3.test_endpoint -v</code></pre>
<p><strong>결과</strong></p>
<pre><code class="language-bash">{0} openstack.tests.functional.identity.v3.test_endpoint.TestEndpoint.test_endpoint [1.619s] ... ok
{0} openstack.tests.functional.identity.v3.test_endpoint.TestEndpoint.test_endpoint_list_filters [1.191s] ... ok

======
Totals
======
Ran: 2 tests in 2.8094 sec.
 - Passed: 2
 - Skipped: 0
 - Failed: 0</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/87d1b249-e2d2-48c5-a9cb-68419334220a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[OpenStack Endpoint CRUD 동작 구조]]></title>
            <link>https://velog.io/@yukyung_16/openstack-endpoint-CRUD-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%BD%94%EB%93%9C-%EB%8F%99%EC%9E%91-%ED%9D%90%EB%A6%84-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/openstack-endpoint-CRUD-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%BD%94%EB%93%9C-%EB%8F%99%EC%9E%91-%ED%9D%90%EB%A6%84-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 10 Aug 2025 16:24:01 GMT</pubDate>
            <description><![CDATA[<h3 id="openstack-endpoint란">OpenStack Endpoint란?</h3>
<p>OpenStack Endpoint는 각 서비스의 접속 지점을 정의하는 핵심 요소로, Keystone에 등록된 서비스가 &quot;어디에서, 어떤 방식으로&quot; 접근 가능한지를 알려주는 주소 역할을 합니다.</p>
<p><strong>구성 요소</strong></p>
<ul>
<li>URL: 서비스에 접근할 실제 주소</li>
<li>Interface: 접근 유형<ul>
<li>public: 외부 사용자 접근용</li>
<li>internal: 내부 네트워크 전용</li>
<li>admin: 관리자 전용</li>
</ul>
</li>
<li>Region: 서비스가 속한 리전</li>
<li>Service: 연결된 서비스 (예: compute, image, identity)</li>
<li>Enabled: 활성화 여부</li>
</ul>
<p><strong>기본 CLI 명령어</strong></p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/f75c6042-c474-437e-b5d0-24697d90c7e6/image.png" alt=""></p>
<p>Endpoint 생성</p>
<pre><code class="language-bash">openstack endpoint create &lt;service&gt; &lt;interface&gt; &lt;url&gt; \
    --region &lt;region&gt; --enable</code></pre>
<p>Endpoint 조회</p>
<pre><code class="language-bash"># 전체 목록 조회
openstack endpoint list

# 특정 인터페이스만 필터링
openstack endpoint list --interface public

# 상세 정보 확인
openstack endpoint show &lt;endpoint-id&gt;</code></pre>
<p>Endpoint 관리</p>
<pre><code class="language-bash"># 비활성화
openstack endpoint set &lt;endpoint-id&gt; --disable

# 삭제
openstack endpoint delete &lt;endpoint-id&gt;</code></pre>
<br>

<h3 id="openstack-endpoint-create-동작-흐름">openstack endpoint create 동작 흐름</h3>
<p><strong>1) 사용자 입력</strong></p>
<pre><code class="language-bash">openstack endpoint create &lt;service&gt; &lt;interface&gt; &lt;url&gt; --region &lt;region-id&gt; --enable</code></pre>
<p>*<em>2) 커맨드 바인딩 *</em></p>
<p><code>endpoint create</code>는 OpenStackClient 안에 미리 등록된 커맨드 클래스(<code>CreateEndpoint</code>)로 연결됩니다. </p>
<p><strong>3) 옵션 파싱</strong></p>
<p>커맨드 클래스의 <code>get_parser()</code>가 argparse로 옵션을 정의합니다.</p>
<ul>
<li>위치 인자: service, interface, url</li>
<li>옵션 인자: --region, --enable/--disable 등</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1268990a-773b-4bcb-9b90-95b68ea9d828/image.png" alt=""></p>
<p><strong>4) 실행 로직</strong></p>
<p><code>take_action(parsed_args)</code>에서는 사용자 입력을 표준화한 뒤, OpenStack SDK에 엔드포인트 생성 요청을 보냅니다. </p>
<pre><code class="language-python">def take_action(self, parsed_args):
    # 1) 서비스/리전 식별자 정규화
    identity = self.app.client_manager.sdk_connection.identity
    service = common.find_service_sdk(identity, parsed_args.service)  # 이름 → service_id

    # 2) 엔드포인트 속성 구성
    attrs = {
        &quot;service_id&quot;: service.id,
        &quot;interface&quot;: parsed_args.interface,   # public|internal|admin
        &quot;url&quot;: parsed_args.url,
        &quot;enabled&quot;: parsed_args.enabled,       # True/False
    }

    # Keystone v3는 region_id 사용 권장
    if parsed_args.region:
        try:
            region = identity.get_region(parsed_args.region)  # 이름/ID 모두 허용
            attrs[&quot;region_id&quot;] = region.id
        except Exception:
            attrs[&quot;region_id&quot;] = parsed_args.region

    # 3) 생성 요청 (SDK 프록시 호출)
    endpoint = identity.create_endpoint(**attrs)

    # 4) 출력 포맷
    return _format_endpoint(endpoint, service=service)</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/a8a5048b-4ec8-4412-b4a5-4849723b3267/image.png" alt=""></p>
<p><strong>5) SDK 프록시</strong></p>
<p>OpenStack SDK의 Identity Proxy가 고수준 메서드(<code>create_endpoint</code>)를 제공합니다. 내부적으로 공통 헬퍼(<code>_create</code>)에 위임하여 리소스 클래스(<code>Endpoint</code>)를 생성하도록 합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/73964a77-7008-48fd-9c09-9826e18c21ed/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1e44d067-7c64-4dcc-a71e-4cd58115fa38/image.png" alt=""></p>
<p><strong>6) 리소스 레벨</strong></p>
<p>Endpoint 리소스는 다음과 같은 메타정보를 가집니다.</p>
<ul>
<li>base_path = &quot;/endpoints&quot;</li>
<li>allow_create = True</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/c73edf59-c48c-435a-b212-819b96a63105/image.png" alt=""></p>
<p><code>_create()</code>는 Endpoint 객체를 생성한 뒤, 해당 객체의 <code>create()</code> 메서드를 호출해 HTTP 요청을 실행합니다. </p>
<pre><code class="language-python">conn = self._get_connection()
res = resource_type.new(connection=conn, **attrs)
return res.create(self, base_path=base_path)</code></pre>
<p><strong>7) HTTP 요청/응답</strong></p>
<p><code>create()</code> 메서드는 Keystone API에 POST /v3/endpoints 요청을 전송합니다. 요청 본문에는 service_id, interface, url, region_id, enabled 등이 포함됩니다. Keystone 서버로부터 생성된 엔드포인트 정보를 받아 Endpoint 객체에 채웁니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/cfd42560-db5a-4b07-8375-b407ab8bf1a5/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/yukyung_16/post/c997ca2b-3bef-4b03-8baf-44a0ee9adfd8/image.png" alt=""></p>
<p><strong>8) 출력</strong></p>
<p>CLI는 완성된 Endpoint 객체에서 필요한 컬럼만 추출해 테이블 형태로 렌더링하여 출력합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/0fd46df8-01d9-423e-b207-ae42d4746467/image.png" alt=""></p>
<p><strong>📍 시퀀스 요약</strong></p>
<pre><code>User
  └─ openstack endpoint create ...

OpenStackClient
  ├─ map &quot;endpoint create&quot; → CreateEndpoint
  ├─ argparse 파싱(get_parser)
  └─ take_action(parsed_args)
       ├─ service/region 정규화 → attrs 구성
       └─ identity.create_endpoint(**attrs)

OpenStack SDK (Identity Proxy)
  └─ _create(Endpoint, **attrs)
        └─ Endpoint.new(...).create(session, base_path=&quot;/endpoints&quot;)
             ├─ POST /v3/endpoints ({&quot;endpoint&quot;: {...}})
             └─ 응답 → Endpoint 객체 채움

CLI Renderer
  └─ 표 형태로 출력</code></pre><br>

<h3 id="마지막으로">마지막으로</h3>
<p><strong>흐름 정리</strong></p>
<p>1) 사용자가 CLI 입력
2) CLI가 옵션을 파싱하고 실행 메서드(take_action) 호출
3) SDK의 Identity 프록시가 고수준 함수 실행
4) 프록시 내부 공통 헬퍼가 표준 CRUD 처리
5) Endpoint 리소스가 실제 HTTP 요청 구성/전송
6) Keystone(Identity API) 응답을 받아 화면에 출력</p>
<p><strong>CRUD 정리</strong></p>
<p>1) 생성 <code>openstack endpoint create ...</code></p>
<ul>
<li>SDK: identity.create_endpoint(...)</li>
<li>HTTP: POST /endpoints</li>
</ul>
<p>2) 조회 <code>openstack endpoint show &lt;id&gt;</code></p>
<ul>
<li>SDK: identity.find_endpoint(...) 또는 get_endpoint(...)</li>
<li>HTTP: GET /endpoints/{id}</li>
</ul>
<p>3) 목록 <code>openstack endpoint list [--service ... --interface ... --region ...]</code></p>
<ul>
<li>SDK: identity.endpoints(**filters)</li>
<li>HTTP: GET /endpoints?service_id=&amp;interface=region_id=</li>
</ul>
<p>4) 수정 <code>openstack endpoint set &lt;id&gt; [--url ... --interface ... --region ... --enable/--disable]</code></p>
<ul>
<li>SDK: identity.update_endpoint(id, **attrs)</li>
<li>HTTP: PATCH /endpoints/{id}</li>
</ul>
<p>5) 삭제 <code>openstack endpoint delete &lt;id&gt;</code></p>
<ul>
<li>SDK: identity.delete_endpoint(id)</li>
<li>HTTP: DELETE /endpoints/{id}</li>
</ul>
<p><strong>파일 정리</strong></p>
<p>1) 리소스 정의 <code>openstacksdk/openstack/identity/v3/endpoint.py</code></p>
<ul>
<li>Endpoint, ProjectEndpoint 클래스 정의</li>
<li>API 경로(base_path), CRUD 허용 여부, 쿼리 파라미터 지정</li>
</ul>
<p>2) 서비스 프록시 <code>openstacksdk/openstack/identity/v3/_proxy.py</code></p>
<ul>
<li>create_endpoint, get_endpoint, update_endpoint 등 고수준 메서드 제공</li>
<li>내부적으로 Proxy 공통 헬퍼 호출해 리소스 조작 수행</li>
</ul>
<p>3) Proxy 공통 헬퍼 <code>openstacksdk/openstack/proxy.py</code></p>
<ul>
<li>_create, _get, _update, _delete, _list 등 표준 CRUD 메서드 구현</li>
<li>리소스 객체 생성 후 해당 리소스의 메서드(create, fetch, commit, delete, list) 호출</li>
</ul>
<p>4) 리소스 베이스 <code>openstacksdk/openstack/resource.py</code></p>
<ul>
<li>HTTP 전송(POST/GET/PATCH/DELETE)</li>
<li>응답 파싱 및 리소스 객체 필드 채우기</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 쿠키 인증을 위한 Nginx HTTPS 설정]]></title>
            <link>https://velog.io/@yukyung_16/Nginx%EC%97%90-tls-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Nginx%EC%97%90-tls-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 09 Aug 2025 14:19:40 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>Spring 개발 서버에서 인증 방식을 헤더 Access Token에서 쿠키 기반으로 변경하면서 HTTPS 적용이 필요했습니다. Secure 속성이 있는 쿠키는 HTTPS 환경에서만 브라우저가 자동 전송되기 때문에, 개발 서버에도 인증서를 적용해야 합니다.</p>
<p>이번 글에서는 Nginx와 Let’s Encrypt를 활용해 간단하게 HTTPS를 적용하고, 이후 발생한 리다이렉트 루프 문제까지 해결한 과정을 공유합니다 :)</p>
<br>

<h3 id="1-사전-확인">1. 사전 확인</h3>
<p><strong>DNS 확인</strong></p>
<pre><code class="language-bash">dig +short dtalks.kro.kr A
# 서버 IP가 정상적으로 나와야 함</code></pre>
<p><strong>포트 개방 확인</strong></p>
<p>Let’s Encrypt 인증 발급은 80 포트 접근이 필수입니다.</p>
<pre><code class="language-bash">sudo ss -ltnp | grep -E &#39;:80|:443&#39;</code></pre>
<p><strong>Nginx 설치</strong></p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y nginx
sudo systemctl enable --now nginx

# 방화벽
sudo ufw allow &#39;Nginx Full&#39;   # 80, 443</code></pre>
<br>

<h3 id="2-인증서-발급-lets-encrypt">2. 인증서 발급 (Let’s Encrypt)</h3>
<p>HTTPS 적용을 위해 무료 인증서 발급 서비스인 Let’s Encrypt를 사용합니다. certbot과 python3-certbot-nginx 패키지를 설치하면, Nginx 설정과 인증서 발급 과정을 처리할 수 있습니다.</p>
<pre><code class="language-bash"># certbot 및 Nginx 플러그인 설치
sudo apt-get install -y certbot python3-certbot-nginx

# Nginx 플러그인으로 인증서 발급
sudo certbot --nginx -d &lt;도메인&gt;</code></pre>
<p>실행하면 다음 작업이 자동으로 진행됩니다.</p>
<p>1) 도메인 소유 확인</p>
<p>Let’s Encrypt가 HTTP-01 챌린지를 수행하여 해당 도메인의 소유 여부를 검증합니다. 이 과정에서 80번 포트가 열려 있어야 합니다!</p>
<p>2) 인증서 발급</p>
<p>도메인 검증이 완료되면, 다음 인증서 파일이 생성됩니다.</p>
<ul>
<li>/etc/letsencrypt/live/&lt;도메인&gt;/fullchain.pem (서버 인증서 + 체인)</li>
<li>/etc/letsencrypt/live/&lt;도메인&gt;/privkey.pem (개인 키)</li>
</ul>
<p>3) Nginx 설정 자동 수정</p>
<p>기존 Nginx 서버 블록의 <code>ssl_certificate</code>, <code>ssl_certificate_key</code> 경로가 위 Let’s Encrypt 경로로 자동 변경됩니다.</p>
<p>4) 자동 갱신 등록</p>
<p><code>/etc/cron.d/certbot</code>에 인증서 자동 갱신 크론잡이 추가됩니다.</p>
<p>발급이 완료되면 아래 명령어로 인증서 경로와 유효기간을 확인할 수 있습니다 ☺️</p>
<pre><code class="language-bash">sudo nginx -T | grep ssl_certificate
sudo certbot certificates</code></pre>
<br>

<h3 id="3-spring-boot-설정">3. Spring Boot 설정</h3>
<p>리버스 프록시(Nginx)를 거치면, 사용자가 HTTPS로 접속하더라도 애플리케이션에는 기본적으로 HTTP 요청처럼 전달됩니다. 이때 Nginx는 원래 요청이 HTTPS였다는 정보를 <code>X-Forwarded-Proto: https</code> 헤더로 함께 보내고, Spring이 이 헤더를 신뢰하도록 설정해야 합니다.</p>
<pre><code class="language-bash"># application.properties
server.forward-headers-strategy=framework</code></pre>
<br> 

<h3 id="트러블슈팅-리다이렉트-루프">트러블슈팅: 리다이렉트 루프</h3>
<p><strong>원인: 프록시 스킴 인식 불일치</strong></p>
<p>Nginx는 HTTPS로 종료했지만, Spring이 요청을 HTTP로 인식 → 다시 HTTPS로 리다이렉트 → 무한 반복</p>
<p><strong>해결 방법</strong></p>
<p>Nginx 443 블록의 location / 안에 다음 추가</p>
<pre><code class="language-nginx">proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;</code></pre>
<br>

<h3 id="최종-nginx-서버-블록">최종 Nginx 서버 블록</h3>
<pre><code class="language-nginx"># 80 → 443 리다이렉트
server {
    listen 80;
    server_name &lt;도메인&gt;;
    return 301 https://$host$request_uri;
}

# 443 → Spring Boot 프록시
server {
    listen 443 ssl http2;
    server_name &lt;도메인&gt;;

    ssl_certificate     /etc/letsencrypt/live/&lt;도메인&gt;/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/&lt;도메인&gt;/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header Authorization     $http_authorization;

        proxy_set_header Connection &quot;&quot;;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}</code></pre>
<br>

<p>🥳 이제 https://&lt;도메인&gt;으로 접속할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Barbican]]></title>
            <link>https://velog.io/@yukyung_16/Barbican</link>
            <guid>https://velog.io/@yukyung_16/Barbican</guid>
            <pubDate>Sat, 09 Aug 2025 12:12:30 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>클라우드 환경에서는 TLS 인증서, 암호화 키, API 토큰과 같은 민감 정보가 필수적으로 사용됩니다. 이러한 데이터는 안전하게 보관하고, 필요할 때만 접근할 수 있도록 하는 것이 보안의 핵심입니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/b00e0c67-2c3a-47b6-9fa2-fbaed3010d5e/image.png" alt=""></p>
<p>Barbican은 OpenStack 환경에서 이러한 역할을 전담하는 Key Management Service(KMS)입니다. Kubernetes의 Secret이 클러스터 내부 민감 정보를 관리한다면, Barbican은 OpenStack 전역을 대상으로 민감 정보를 관리합니다.</p>
<br>

<h3 id="barbican이란">Barbican이란?</h3>
<p>Barbican은 OpenStack 전반에서 민감 정보를 안전하게 관리하는 중앙 집중형 서비스입니다.</p>
<p><strong>특징</strong></p>
<ul>
<li>Nova, Cinder, Octavia 등 다른 OpenStack 서비스와 연동 가능</li>
<li>데이터는 항상 암호화된 상태로 저장</li>
<li>REST API를 통해 외부 시스템과도 연계 가능</li>
</ul>
<p><strong>구성 요소</strong></p>
<ul>
<li>Secret: 암호화된 객체 (예: 비밀번호, 인증서, 키)</li>
<li>Container: 여러 개의 Secret을 하나의 컨테이너로 묶어서 관리</li>
<li>Order: 비밀 정보를 생성/요청하는 작업 (예: 암호화 키 발급)</li>
<li>ACL: Secret에 대한 접근 권한 부여 및 제어</li>
</ul>
<br>

<h3 id="secret-저장-예시">Secret 저장 예시</h3>
<p>다음 명령어는 <code>my-secret</code>이라는 이름으로 비밀번호를 Barbican에 저장하는 예시입니다.</p>
<pre><code class="language-bash">openstack secret store \
--name &quot;my-secret&quot; \
--payload &quot;my-secret-password&quot; \
--payload-content-type text/plain</code></pre>
<p>--name : 시크릿 이름
--payload : 저장할 실제 데이터
--payload-content-type : 데이터 타입</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/d848de4c-ae79-4436-808b-aae2da227e27/image.png" alt=""></p>
<br>

<h3 id="container로-secret-그룹화">Container로 Secret 그룹화</h3>
<p>Barbican의 Container는 관련된 Secret을 하나의 그룹으로 관리할 수 있도록 합니다.</p>
<p><strong>Generic 타입</strong></p>
<p>단순히 여러 Secret을 묶어서 관리하는 용도로 사용됩니다.</p>
<pre><code class="language-bash">openstack secret container create --name &quot;yukyung-container&quot; \
  --type generic \
  --secret &quot;my_key_1=&lt;Secret URL&gt;&quot; \
  --secret &quot;my_key_2=&lt;Secret URL&gt;&quot;</code></pre>
<p>–type generic : 구조를 인식하지 않고 단순 묶음</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/7a1ed1b7-f630-4639-ab49-46c7fc72b8bf/image.png" alt=""></p>
<p><strong>Certificate 타입</strong></p>
<p>TLS/SSL 인증서 세트를 구조적으로 묶어서 관리하는 용도로 사용됩니다.</p>
<pre><code class="language-bash">openstack secret container create --name &quot;yukyung-container&quot; \
  --type certificate \
  --secret &quot;certificate=&lt;Secret URL&gt;&quot; \
  --secret &quot;private_key=&lt;Secret URL&gt;&quot;</code></pre>
<p>–type certificate : 인증서 구조 인식</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/5893af49-4a83-4909-92c7-a4b9aaf9b823/image.png" alt=""></p>
<p>고정 슬롯</p>
<ul>
<li>certificate : 서버 인증서</li>
<li>private_key : 인증서의 개인키</li>
<li>intermediates : 중간 CA 인증서 (옵션)</li>
<li>private_key_passphrase : 개인키 비밀번호 (옵션)</li>
</ul>
<br>

<h3 id="octavia-연동">Octavia 연동</h3>
<p>Certificate 타입 컨테이너는 로드밸런서 HTTPS 리스너 설정 시 직접 지정할 수 있습니다.</p>
<pre><code class="language-bash">openstack loadbalancer listener create \
  --name my-https-listener \
  --protocol HTTPS \
  --protocol-port 443 \
  --default-tls-container-ref &lt;컨테이너 href&gt; \
  &lt;로드밸런서 ID&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[gRPC]]></title>
            <link>https://velog.io/@yukyung_16/gRPC</link>
            <guid>https://velog.io/@yukyung_16/gRPC</guid>
            <pubDate>Tue, 29 Jul 2025 05:16:10 GMT</pubDate>
            <description><![CDATA[<h3 id="grpc란">gRPC란?</h3>
<p>gRPC는 Google이 개발한 고성능 오픈소스 RPC(Remote Procedure Call) 프레임워크입니다. RPC란 원격 서버의 함수를 마치 로컬 함수처럼 호출할 수 있게 해주는 프레임워크입니다.</p>
<ul>
<li>클라이언트는 원격 서버의 메서드를 로컬 함수처럼 호출</li>
<li>서버는 실제 비즈니스 로직을 구현하고 응답 제공</li>
<li>gRPC 프레임워크가 네트워크 통신, 데이터 직렬화, 에러 처리 등을 담당</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/46aaa266-501b-4249-8c79-5bc506f31752/image.png" alt=""></p>
<br>

<h3 id="grpc-vs-rest-api">gRPC vs REST API</h3>
<table>
<thead>
<tr>
<th>특징</th>
<th>REST API</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>프로토콜</td>
<td>HTTP/1.1</td>
<td>HTTP/2</td>
</tr>
<tr>
<td>데이터 포맷</td>
<td>JSON (텍스트)</td>
<td>Protocol Buffers (바이너리)</td>
</tr>
<tr>
<td>성능</td>
<td>상대적으로 느림</td>
<td>빠름</td>
</tr>
<tr>
<td>스트리밍</td>
<td>제한적</td>
<td>4가지 스트리밍 지원</td>
</tr>
<tr>
<td>타입 안정성</td>
<td>런타임에 확인</td>
<td>컴파일 타임에 확인</td>
</tr>
</tbody></table>
<br>

<h3 id="grpc와-protocol-buffers의-관계">gRPC와 Protocol Buffers의 관계</h3>
<p>gRPC는 내부적으로 데이터를 주고받을 때 Protocol Buffers (protobuf)를 사용합니다.</p>
<ul>
<li>gRPC: 서비스 정의, 네트워크 통신, 원격 호출 관리</li>
<li>Protocol Buffers: 데이터 구조 정의, 직렬화/역직렬화</li>
</ul>
<pre><code class="language-protobuf">syntax = &quot;proto3&quot;;
package user;

// 데이터 구조 정의 (Protocol Buffers)
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

message GetUserRequest {
  int32 user_id = 1;
}

// 서비스 정의 (gRPC)
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc CreateUser(User) returns (User);
  rpc ListUsers(Empty) returns (stream User);
}</code></pre>
<br>

<h3 id="grpc의-4가지-통신-방식">gRPC의 4가지 통신 방식</h3>
<p><strong>1) Unary RPC (단일 요청-응답)</strong></p>
<p>가장 기본적인 형태로, 하나의 요청에 하나의 응답을 받습니다.</p>
<pre><code class="language-protobuf">service Calculator {
  rpc Add(AddRequest) returns (AddResponse);
}

message AddRequest {
  int32 a = 1;
  int32 b = 2;
}

message AddResponse {
  int32 result = 1;
}</code></pre>
<p><strong>2) Server Streaming RPC (서버 스트리밍)</strong></p>
<p>하나의 요청에 대해 서버가 여러 개의 응답을 스트림으로 전송합니다.</p>
<pre><code class="language-protobuf">service FileService {
  rpc DownloadFile(DownloadRequest) returns (stream FileChunk);
}

message DownloadRequest {
  string filename = 1;
}

message FileChunk {
  bytes data = 1;
  int32 chunk_number = 2;
}</code></pre>
<p><strong>3) Client Streaming RPC (클라이언트 스트리밍)</strong></p>
<p>클라이언트가 여러 개의 요청을 보내고, 서버가 하나의 응답을 반환합니다.</p>
<pre><code class="language-protobuf">service FileService {
  rpc UploadFile(stream FileChunk) returns (UploadResponse);
}

message UploadResponse {
  string file_id = 1;
  int64 total_size = 2;
}</code></pre>
<p><strong>4) Bidirectional Streaming RPC (양방향 스트리밍)</strong></p>
<p>클라이언트와 서버가 동시에 여러 메시지를 주고 받습니다.</p>
<pre><code class="language-protobuf">service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message ChatMessage {
  string user_id = 1;
  string message = 2;
  int64 timestamp = 3;
}</code></pre>
<br>

<p><strong>참고 링크</strong></p>
<p><a href="https://grpc.io/docs/what-is-grpc/core-concepts/">🔗 공식 문서</a>
<a href="https://tech.ktcloud.com/entry/gRPC%EC%9D%98-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0-HTTP2-Protobuf-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D">🔗 kt cloud 기술 블로그</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Protocol Buffers]]></title>
            <link>https://velog.io/@yukyung_16/Protocol-Buffers</link>
            <guid>https://velog.io/@yukyung_16/Protocol-Buffers</guid>
            <pubDate>Tue, 29 Jul 2025 03:07:09 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>시스템 간 통신이 중요한 웹 서비스나 마이크로서비스 환경에서, JSON은 가장 널리 사용되는 데이터 포맷입니다. 하지만 Google의 Protocol Buffers는 더 높은 성능을 제공하는 대안으로 점점 더 많이 사용되고 있습니다.</p>
<p>이 글에서는 Protocol Buffers가 무엇인지, 왜 사용해야 하는지 정리해봅니다 :)</p>
<br>

<h3 id="protocol-buffers란">Protocol Buffers란?</h3>
<p>Protocol Buffers는 Google이 개발한 언어 및 플랫폼 중립적인 직렬화 포맷입니다. (공식 문서 설명 🫠)</p>
<p>쉽게 말해, 구조화된 데이터를 바이너리 형태로 작고 빠르게 저장하고 전송하기 위한 도구로, JSON이나 XML에 비해 더 고성능인 대안이라 볼 수 있습니다.</p>
<p><strong>사용 목적</strong></p>
<ul>
<li>네트워크 통신을 위한 메시지 정의</li>
<li>다양한 언어(Java, Go, Python ...) 간 공통 데이터 구조 공유</li>
<li>마이크로서비스에서 빠른 데이터 교환을 위한 포맷</li>
</ul>
<br>

<h3 id="성능-비교">성능 비교</h3>
<p>동일한 데이터를 각각 JSON과 Protocol Buffers 형식으로 직렬화하고, 결과 크기를 비교하는 코드입니다.</p>
<pre><code class="language-go">package main

import (
    &quot;encoding/json&quot;
    &quot;fmt&quot;
    &quot;log&quot;

    &quot;google.golang.org/protobuf/proto&quot;
    pb &quot;github.com/Kim-Yukyung/grpc-kafka-chat/proto&quot;
)

func main() {
    // JSON 예시
    jsonData := map[string]interface{}{
        &quot;message_id&quot;: &quot;12345&quot;,
        &quot;user_id&quot;: &quot;user123&quot;,
        &quot;username&quot;: &quot;John&quot;,
        &quot;message&quot;: &quot;안녕하세요!&quot;,
    }

    // JSON 직렬화
    // Go Date를 JSON 형식으로 변환
    jsonBytes, err := json.Marshal(jsonData)
    if err != nil {
        log.Fatal(err)
    }

    // Protocol Buffers 예시
    protoMessage := &amp;pb.ChatMessage{
        MessageId: &quot;12345&quot;,
        UserId: &quot;user123&quot;,
        Username: &quot;John&quot;,
        Message: &quot;안녕하세요!&quot;,
    }

    // Protocol Buffers 직렬화
    // Go Date를 Protocol Buffers 형식으로 변환
    protoBytes, err := proto.Marshal(protoMessage)
    if err != nil {
        log.Fatal(err)
    }

    // 결과 비교
    fmt.Printf(&quot;JSON 크기: %d bytes\n&quot;, len(jsonBytes))
    fmt.Printf(&quot;JSON 데이터: %s\n&quot;, string(jsonBytes))

    fmt.Printf(&quot;\nProtocol Buffers 크기: %d bytes\n&quot;, len(protoBytes))
    fmt.Printf(&quot;Protocol Buffers 데이터: %x\n&quot;, protoBytes)
} </code></pre>
<p>실행 </p>
<pre><code class="language-bash">go run comparison.go</code></pre>
<p>결과</p>
<pre><code class="language-bash">JSON 크기: 89 bytes
JSON 데이터: {&quot;message&quot;:&quot;안녕하세요!&quot;,&quot;message_id&quot;:&quot;12345&quot;,&quot;user_id&quot;:&quot;user123&quot;,&quot;username&quot;:&quot;John&quot;}

Protocol Buffers 크기: 40 bytes
Protocol Buffers 데이터: 0a0531323334351207757365723132331a044a6f686e2210ec9588eb8595ed9598ec84b8ec9a9421</code></pre>
<br>

<h3 id="proto-파일-구조-이해하기">.proto 파일 구조 이해하기</h3>
<p>Protocol Buffers를 사용하려면 가장 먼저 .proto 파일의 구조와 문법을 이해해야 합니다. 이 파일은 데이터를 어떤 구조로 직렬화할지 정의하는 스키마 파일입니다.</p>
<p><strong>기본 구조 예시</strong></p>
<pre><code class="language-protobuf">syntax = &quot;proto3&quot;;             // proto3 문법 사용
package chat;                  // 패키지 이름
option go_package = &quot;/proto&quot;;  // Go 코드 생성 시 패키지 경로 설정</code></pre>
<p><strong>메시지 정의 예시</strong></p>
<pre><code class="language-Protobuf">message ChatMessage {
  string message_id = 1;    // 필드 번호 1
  string user_id = 2;       // 필드 번호 2
  string username = 3;      // 필드 번호 3
}</code></pre>
<p>⚠️ 필드 번호는 한 번 정의 후 절대 변경하면 안 됩니다. 이 번호는 직렬화 시 데이터 구분에 사용되므로 변경 시 역직렬화 오류가 발생합니다.</p>
<br>

<h3 id="protocol-buffers-사용하기">Protocol Buffers 사용하기</h3>
<p><strong>1) .proto 파일 작성</strong></p>
<p>먼저 데이터를 정의하는 .proto 파일을 작성합니다.</p>
<pre><code class="language-protobuf">syntax = &quot;proto3&quot;;
package example;

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}</code></pre>
<p><strong>2) 코드 생성</strong></p>
<p>protoc 컴파일러를 사용해 언어별 코드로 변환합니다.</p>
<pre><code class="language-bash"># Go 코드 생성
protoc --go_out=. person.proto

# Java 코드 생성  
protoc --java_out=. person.proto

# Python 코드 생성
protoc --python_out=. person.proto</code></pre>
<p><strong>3) 직렬화 및 역직렬화</strong></p>
<p>생성된 코드를 활용해 데이터를 처리합니다.</p>
<pre><code class="language-go">// Go 예시
person := &amp;pb.Person{
    Name:  &quot;John&quot;,
    Id:    1234,
    Email: &quot;john@example.com&quot;,
}

// 직렬화
data, err := proto.Marshal(person)
if err != nil {
    log.Fatal(err)
}

// 역직렬화
newPerson := &amp;pb.Person{}
err = proto.Unmarshal(data, newPerson)
if err != nil {
    log.Fatal(err)
}</code></pre>
<br>

<h3 id="모든-상황에-적합한-건-아니다">모든 상황에 적합한 건 아니다!</h3>
<p>Protocol Buffers는 매우 효율적인 직렬화 도구지만, 모든 환경에 항상 적합한 것은 아닙니다. 아래와 같은 상황에서는 다른 포맷을 고려하는 것이 더 나을 수 있습니다.</p>
<ul>
<li><p>매우 큰 데이터 (수백 MB 이상)
→ 전체 메시지를 메모리에 로드해야 하므로, 메모리 사용량이 급증할 수 있습니다.</p>
</li>
<li><p>이미지, 동영상 등 대용량 바이너리 데이터
→ JPEG, MP4 등 전용 포맷을 사용하는 것이 효율적입니다.</p>
</li>
<li><p>웹 브라우저와의 직접 통신
→ 브라우저는 기본적으로 JSON을 사용하므로, 호환성과 디버깅 측면에서 JSON이 유리합니다.</p>
</li>
</ul>
<br>

<p><strong>참고 링크</strong></p>
<p><a href="https://protobuf.dev">🔗 공식 문서</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jenkins로 도커 이미지 빌드  파이프라인 구성하기]]></title>
            <link>https://velog.io/@yukyung_16/Jenkins%EB%A1%9C-%EB%8F%84%EC%BB%A4-%EC%9D%B4%EB%AF%B8%EC%A7%80-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Jenkins%EB%A1%9C-%EB%8F%84%EC%BB%A4-%EC%9D%B4%EB%AF%B8%EC%A7%80-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 26 Jul 2025 12:12:38 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>이 글에서는 Jenkins를 활용해 GitHub의 코드 변경을 자동으로 감지하고, Docker 이미지를 빌드해 Docker Hub에 푸시하는 파이프라인 구축 방법을 알아보겠습니다 👀</p>
<br>

<h3 id="1-github-personal-access-token-생성">1. GitHub Personal Access Token 생성</h3>
<p>Jenkins가 GitHub 저장소에 접근할 수 있도록 Personal Access Token(PAT)를 생성합니다.</p>
<p><strong>설정 경로</strong></p>
<p>GitHub → Settings → Developer Settings → Personal Access Tokens → Generate new token (classic)</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/a1582d34-1299-4c8a-bc45-bdbc7132b93f/image.png" alt=""></p>
<p><strong>권한 설정</strong></p>
<ul>
<li><strong>repo</strong>: 저장소 전체 접근 권한</li>
<li><strong>workflow</strong>: GitHub Actions 관련 권한</li>
<li><strong>admin:repo_hook</strong>: 웹훅 관련 권한</li>
</ul>
<p>‼️ 토큰 생성 후 다시 확인할 수 없으니, 반드시 복사해 보관하세요.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/e70214d3-e0e9-436c-b334-b639dabe93a2/image.png" alt=""></p>
<h3 id="2-젠킨스에서-github-인증-설정">2. 젠킨스에서 GitHub 인증 설정</h3>
<p>Jenkins가 GitHub 저장소에 접근할 수 있도록 자격 증명을 등록합니다.</p>
<p><strong>설정 경로</strong></p>
<p><code>Jenkins 관리</code> 들어간 후, 아래 이미지를 따라가세요 💫</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/7a7fa11b-c3d6-4afe-8826-8f8fdb0b3e86/image.png" alt="">
<img src="https://velog.velcdn.com/images/yukyung_16/post/49dc3a84-f838-4991-a9f2-ab70532d1195/image.png" alt="">
<img src="https://velog.velcdn.com/images/yukyung_16/post/0ddb0664-b388-4aec-b8e7-ebf716a4b9f2/image.png" alt=""></p>
<p><strong>입력 정보</strong></p>
<ul>
<li><strong>Kind</strong>: Username with password</li>
<li><strong>Username</strong>: GitHub 사용자명</li>
<li><strong>Password</strong>: 앞서 생성한 GitHub PAT</li>
<li><strong>ID</strong>: Jenkins 내부에서 사용할 고유 ID</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/155729e0-a0af-40b6-ba4f-09d7c74b3a07/image.png" alt=""></p>
<h3 id="3-플러그인-설치">3. 플러그인 설치</h3>
<p>Jenkins에서 GitHub 및 Docker와의 연동을 위해 다음 플러그인을 설치합니다.</p>
<p><strong>설정 경로</strong></p>
<p>Jenkins 관리 → 플러그인 관리 → Available plugins</p>
<p><strong>설치 플러그인 목록</strong></p>
<ul>
<li><strong>GitHub Integration</strong>: Jenkins와 GitHub 간 통합 기능 제공</li>
<li><strong>GitHub API Plugin</strong>: GitHub API와의 연동 기능 제공</li>
<li><strong>Generic Webhook Trigger</strong>: GitHub Webhook 이벤트 수신</li>
<li><strong>Docker Pipeline</strong>: Jenkins 파이프라인에서 Docker 작업 실행 가능</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/162ebfda-b1f2-4128-8266-5b9ac63e27e8/image.png" alt=""></p>
<h3 id="4-docker-hub-인증-설정">4. Docker Hub 인증 설정</h3>
<p>Jenkins가 Docker Hub에 이미지를 푸시할 수 있도록 Personal Access Token을 발급받아 설정해야 합니다.</p>
<p><strong>발급 방법</strong></p>
<p>Docker Hub 로그인 → Settings → Personal access tokens → Generate new token</p>
<ul>
<li>토큰 이름 입력</li>
<li>읽기/쓰기 권한 부여 (Read/Write)</li>
</ul>
<p>‼️ 이것도 토큰 생성 후 다시 확인할 수 없으니, 반드시 복사해 보관하세요.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/93d1037a-04b2-412f-ae0a-14df55fa630b/image.png" alt=""></p>
<h3 id="5-jenkins에-docker-hub-자격증명-등록">5. Jenkins에 Docker Hub 자격증명 등록</h3>
<p>Docker 이미지 푸시를 위해 Jenkins에 Docker Hub 자격증명을 등록해야 합니다.</p>
<p><strong>설정 경로</strong></p>
<p>GitHub 인증 정보를 등록할 때와 동일한 경로를 따라 설정합니다.</p>
<p><strong>입력값 설정</strong></p>
<ul>
<li><strong>Kind</strong>: Username with password</li>
<li><strong>Username</strong>: Docker Hub ID</li>
<li><strong>Password</strong>: 앞서 생성한 Docker Hub PAT</li>
<li><strong>ID</strong>: docker-registry-credentials </li>
</ul>
<p>📌 ID는 젠킨스 파이프라인 스크립트에서 사용되니 기억해두세요!</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/7c136729-41b2-4e27-b5ba-b69c811ad7e2/image.png" alt=""></p>
<h3 id="6-dockerfile-작성">6. Dockerfile 작성</h3>
<p>웹 애플리케이션을 Nginx 기반으로 컨테이너화하는 Dockerfile 예시입니다 ☺️</p>
<pre><code># Nginx 기반 이미지 사용
FROM nginx:alpine

# 작업 디렉토리 설정
WORKDIR /usr/share/nginx/html

# HTML 파일들을 컨테이너로 복사
COPY *.html ./

# Nginx 설정 파일 복사 (기본 설정 사용)
# 필요시 커스텀 nginx.conf 파일을 추가할 수 있습니다

# 포트 80 노출
EXPOSE 80

# Nginx 시작
CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;] 
</code></pre><br>

<h3 id="7-jenkinsfile-pipeline-코드">7. Jenkinsfile (Pipeline 코드)</h3>
<p>루트 디렉토리에 Jenkinsfile을 작성하여 파이프라인을 정의합니다. 아래는 GitHub 코드 변경 시 도커 이미지를 빌드하고 Docker Hub로 푸시하는 기본 파이프라인 예시입니다.</p>
<ul>
<li><strong>environment</strong>: 파이프라인 전역에서 사용할 변수 정의</li>
<li><strong>stages</strong>: 단계별 작업 정의</li>
<li><strong>post</strong>: 파이프라인 종료 후 실행할 작업 정의</li>
</ul>
<pre><code class="language-groovy">pipeline {
    agent any

    environment {
        // Docker Hub 레지스트리 정보
        REGISTRY = &quot;yukyung0&quot;
        IMAGE_NAME = &quot;dtalks-design&quot;

        // Jenkins 빌드 번호를 이미지 버전 태그로 사용
        IMAGE_TAG = &quot;${env.BUILD_NUMBER}&quot;

        // 전체 이미지 경로
        FULL_IMAGE = &quot;${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}&quot;
        LATEST_IMAGE = &quot;${REGISTRY}/${IMAGE_NAME}:latest&quot;
    }

    stages {
        stage(&#39;Checkout&#39;) {
            steps {
                // Git 저장소 코드 가져오기
                checkout scm
            }
        }

        stage(&#39;Build Docker Image&#39;) {
            steps {
                // 버전 태그와 latest 태그를 동시에 지정하여 빌드
                sh &#39;&#39;&#39;
                docker build -t $FULL_IMAGE -t $LATEST_IMAGE .
                &#39;&#39;&#39;
            }
        }

        stage(&#39;Push to Docker Registry&#39;) {
            steps {
                // Jenkins에 등록된 Docker Hub 자격 증명 사용
                withCredentials([usernamePassword(
                    credentialsId: &#39;docker-registry-credentials&#39;,
                    usernameVariable: &#39;REGISTRY_USER&#39;,
                    passwordVariable: &#39;REGISTRY_PASS&#39;
                )]) {
                    // 버전 태그 및 latest 태그 모두 push
                    sh &#39;&#39;&#39;
                    echo &quot;$REGISTRY_PASS&quot; | docker login -u &quot;$REGISTRY_USER&quot; --password-stdin

                    docker push $FULL_IMAGE
                    docker push $LATEST_IMAGE

                    docker logout
                    &#39;&#39;&#39;
                }
            }
        }
    }

    post {
        always {
            // 워크스페이스 정리
            cleanWs()
        }
        success {
            echo &quot;Pipeline completed successfully.&quot;
            echo &quot;Image pushed: ${FULL_IMAGE}, ${LATEST_IMAGE}&quot;
        }
        failure {
            echo &quot;Pipeline failed. Check logs for details.&quot;
        }
    }
}</code></pre>
<br>

<h3 id="8-jenkins-pipeline-job-생성">8. Jenkins Pipeline Job 생성</h3>
<p><strong>설정 경로</strong></p>
<ul>
<li>Jenkins 대시보드 → New Item 클릭</li>
<li>원하는 이름 입력 및 Pipeline 선택 </li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/3c41a1be-4cf0-4576-b622-2f17c35eedf8/image.png" alt=""></p>
<h3 id="9-github-연동-및-webhook-설정">9. GitHub 연동 및 Webhook 설정</h3>
<p><strong>General</strong></p>
<ul>
<li>GitHub project 체크 후 Repository URL 입력</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/3b7540be-7bd5-44ba-a94c-806c994e618d/image.png" alt=""></p>
<p><strong>Triggers</strong></p>
<ul>
<li>GitHub hook trigger for GITScm polling 체크
→ GitHub Webhook 요청이 올 때 자동으로 빌드가 실행됩니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/aeea9d4d-2724-495b-a978-4818c4a0e111/image.png" alt=""></p>
<p><strong>Pipeline</strong></p>
<ul>
<li><strong>Definition</strong>: Pipeline script from SCM</li>
<li><strong>SCM</strong>: Git</li>
<li><strong>Repository URL</strong>: GitHub 리포지토리 주소 입력</li>
<li><strong>Credentials</strong>: 앞서 등록한 GitHub 자격 증명 선택</li>
<li><strong>Branch Specifier</strong>: */main (브랜치에 맞게 입력)</li>
<li><strong>Script Path</strong>: Jenkinsfile (루트에 위치한 경우 기본값)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/e534a216-f280-4b2c-a4a7-feb7efbe7689/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/837f8462-6e0d-4084-9710-e2568652861d/image.png" alt=""></p>
<p>Webhook 설정 방법은 다음 글을 참고해주세요.
🔗 <a href="https://velog.io/@yukyung_16/%EB%A1%9C%EC%BB%AC-Jenkins%EC%97%90-GitHub-Webhook-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0">Jenkins에 GitHub Webhook 연동하기</a></p>
<br>

<h3 id="10-테스트">10. 테스트</h3>
<p>🥳 이제 커밋 한 줄만으로도 파이프라인이 자동으로 실행됩니다.</p>
<pre><code class="language-bash"># 테스트 커밋 생성
echo &quot;# Jenkins webhook trigger test&quot; &gt;&gt; README.md
git add README.md
git commit -m &quot;jenkins webhook trigger test&quot;
git push origin main</code></pre>
<p>아래와 같이 빌드부터 이미지 푸시까지 자동으로 실행되는 것을 확인할 수 있습니다!</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/31a5a8f0-b5e1-4260-bce8-92b7f286197f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/c624cc6d-4e35-44d0-acce-4eb4b41c8e5a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로컬 Jenkins에 GitHub Webhook 연결하기]]></title>
            <link>https://velog.io/@yukyung_16/%EB%A1%9C%EC%BB%AC-Jenkins%EC%97%90-GitHub-Webhook-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/%EB%A1%9C%EC%BB%AC-Jenkins%EC%97%90-GitHub-Webhook-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 26 Jul 2025 11:09:11 GMT</pubDate>
            <description><![CDATA[<h3 id="1-ngrok-설치-및-인증">1. ngrok 설치 및 인증</h3>
<pre><code>brew install ngrok
ngrok config add-authtoken &lt;your-ngrok-authtoken&gt;</code></pre><p>🔗 <a href="https://dashboard.ngrok.com/get-started/setup">ngrok 홈페이지</a>에서 발급한 토큰을 사용합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/0133b952-442b-4382-9d29-c08239a0bf53/image.png" alt=""></p>
<h3 id="2-jenkins-실행-포트-확인-및-ngrok-터널-열기">2. Jenkins 실행 포트 확인 및 ngrok 터널 열기</h3>
<p>Jenkins가 localhost:8081에서 실행 중이라면 다음 명령어로 외부 접근 주소를 생성합니다.</p>
<pre><code class="language-bash">ngrok http http://localhost:8081</code></pre>
<p>생성된 주소(<a href="https://cbdb88ccb0eb.ngrok-free.app)%EB%8A%94">https://cbdb88ccb0eb.ngrok-free.app)는</a> GitHub Webhook 설정에 사용됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/f0baed80-f0bb-44b5-b2d1-9d50ba8b0cfe/image.png" alt=""></p>
<h3 id="3-플러그인-설치">3. 플러그인 설치</h3>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/4813722e-8dcb-40d6-b96f-9a41a14f1cee/image.png" alt=""></p>
<h3 id="4-github-webhook-설정">4. GitHub Webhook 설정</h3>
<p>GitHub 리포지토리 → Settings → Webhooks → Add webhook</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/2a355a3e-f9ed-4c84-935d-650726a065b2/image.png" alt=""></p>
<ul>
<li><p><strong>Payload URL</strong>
<a href="https://xxxxx.ngrok-free.app/github-webhook/">https://xxxxx.ngrok-free.app/github-webhook/</a></p>
</li>
<li><p><strong>Content type</strong>
application/json</p>
</li>
<li><p><strong>Trigger events</strong>
Just the push event</p>
</li>
</ul>
<br>

<h3 id="5-정상-작동-확인">5. 정상 작동 확인</h3>
<p>🥳 ngrok 터미널에서 다음과 같은 로그 확인</p>
<pre><code class="language-bash">POST /github-webhook/ 200 OK</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/945d2cb7-4af8-4507-b470-ba388b567646/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jenkins에서 docker: command not found 트러블슈팅]]></title>
            <link>https://velog.io/@yukyung_16/Jenkins%EC%97%90%EC%84%9C-docker-command-not-found-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@yukyung_16/Jenkins%EC%97%90%EC%84%9C-docker-command-not-found-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Sat, 26 Jul 2025 10:16:17 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황">문제 상황</h3>
<p>Mac에 Homebrew로 Jenkins를 설치해 로컬에서 파이프라인을 실행하던 중, Docker 이미지를 빌드하는 단계에서 다음과 같은 오류가 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/4cf20df7-2052-492f-90e0-efcefe4aea83/image.png" alt=""></p>
<p>터미널에서는 which docker 명령어를 통해 <code>/usr/local/bin/docker</code> 경로가 정상적으로 확인되지만, Jenkins 파이프라인 내에서는 해당 경로를 인식하지 못하는 문제가 발생했습니다.</p>
<br>

<h3 id="원인">원인</h3>
<p>Jenkins는 macOS에서 launchd 서비스로 실행되는데, 이 환경에서는 사용자의 쉘 설정 파일(~/.zshrc, ~/.bash_profile 등)을 로드하지 않습니다.</p>
<p>그 결과, Jenkins 내부에서 사용하는 PATH에 /usr/local/bin 경로가 포함되지 않아 Docker 명령어를 찾을 수 없는 상황이 발생합니다.</p>
<p>📄 관련 공식 문서: <a href="https://www.jenkins.io/doc/book/pipeline/docker/#path-setup-for-mac-os-users">Jenkins - Path setup for macOS users</a></p>
<br>

<h3 id="해결-방법">해결 방법</h3>
<p>1) Jenkins 설정 파일 열기</p>
<pre><code class="language-bash">nano /opt/homebrew/opt/jenkins-lts/homebrew.mxcl.jenkins-lts.plist</code></pre>
<p>2) <dict> 블록에 EnvironmentVariables 키 추가</p>
<p>docker, brew, 기타 CLI 도구들이 위치한 경로를 모두 포함시켜야 합니다!</p>
<pre><code class="language-xml">&lt;key&gt;EnvironmentVariables&lt;/key&gt;
&lt;dict&gt;
  &lt;key&gt;PATH&lt;/key&gt;
  &lt;string&gt;/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin&lt;/string&gt;
&lt;/dict&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/62005cc6-5627-46f9-ac79-f0d9a0dd76c3/image.png" alt=""></p>
<p>3) Jenkins 서비스 재시작</p>
<pre><code>brew services restart jenkins-lts</code></pre><br>

<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/18ed6fa1-e237-4492-b38d-d2c1c9f20363/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jenkins 설치하기]]></title>
            <link>https://velog.io/@yukyung_16/Jenkins-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Jenkins-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 24 Jul 2025 13:41:41 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>Jenkins는 Docker, WAR 파일, Homebrew 등을 통해 다양한 방식으로 설치할 수 있습니다. 이 글에서는 macOS 환경에서 Homebrew를 이용해 Jenkins를 설치하고, 기본 포트를 변경하는 방법까지 정리합니다 :)</p>
<br>

<h3 id="jenkins란">Jenkins란?</h3>
<p>Jenkins는 오픈소스 CI/CD(Continuous Integration/Continuous Deployment) 도구로, 소프트웨어 개발 과정에서 빌드, 테스트, 배포를 자동화할 수 있게 도와주는 플랫폼입니다. </p>
<br>

<h3 id="jenkins-설치">Jenkins 설치</h3>
<p>공식 문서는 아래 두 곳을 참고하면 됩니다.</p>
<ul>
<li><a href="https://www.jenkins.io/doc/book/installing/macos/">설치 가이드</a></li>
<li><a href="https://www.jenkins.io/download/lts/macos/">다운로드</a></li>
</ul>
<p>터미널에서 다음 명령어로 Jenkins를 설치합니다.</p>
<pre><code class="language-bash">brew install jenkins-lts</code></pre>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/821d41b4-fe73-47bd-96ec-fe3597b58997/image.png" alt=""></p>
<h3 id="jenkins-실행">Jenkins 실행</h3>
<p><strong>자동 시작 설정 (백그라운드 실행)</strong></p>
<pre><code class="language-bash">brew services start jenkins-lts</code></pre>
<p><strong>수동 실행</strong></p>
<p>자동 시작을 원하지 않는 경우 다음 명령어로 수동 실행할 수 있습니다.</p>
<pre><code class="language-bash">/opt/homebrew/opt/openjdk@21/bin/java \
  -Dmail.smtp.starttls.enable=true \
  -jar /opt/homebrew/opt/jenkins-lts/libexec/jenkins.war \
  --httpListenAddress=127.0.0.1 \
  --httpPort=8080</code></pre>
<br>

<h3 id="jenkins-초기-설정">Jenkins 초기 설정</h3>
<p>1) 초기 비밀번호 확인</p>
<p>Jenkins 실행 후 초기 비밀번호를 확인해야 합니다.</p>
<pre><code class="language-bash">cat /Users/$(whoami)/.jenkins/secrets/initialAdminPassword</code></pre>
<p>출력된 비밀번호를 복사해둡니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1c524606-5d1f-4d8a-acdb-25691ce81adb/image.png" alt=""></p>
<p>2) 웹 인터페이스 접속</p>
<p>브라우저에서 <code>http://localhost:8080</code>으로 접속해 복사한 초기 비밀번호를 입력합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/3df8c7af-17bc-4e3c-8097-cfd2522415ff/image.png" alt=""></p>
<p>3) 플러그인 설치</p>
<p>초기 설정 화면에서 Install suggested plugins 옵션을 선택합니다. 기본적으로 많이 사용되는 플러그인들이 자동으로 설치되며, 별도의 선택 과정 없이 빠르게 설치를 진행할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1539f639-66d7-418e-9893-7c72763461c8/image.png" alt=""></p>
<p>4) 관리자 계정 생성</p>
<p>사용자명, 비밀번호, 이름, 이메일 주소 등을 입력하면 됩니다. 이메일 주소는 필수 항목이지만, 실제로 알림을 받지 않는 경우에는 아무 주소나 입력해도 무방합니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/8219359f-e3e4-4400-affd-1e2b29fb13b5/image.png" alt=""></p>
<p>5) Instance Configuration</p>
<p>마지막으로 젠킨스가 외부에 자신을 식별할 수 있는 기본 URL(Jenkins URL)을 설정합니다. 이 URL은 이메일 알림, Slack, PR 상태 링크, 빌드 URL 등에 사용됩니다.</p>
<p>로컬 환경에서만 사용할 경우에는 기본값인 <code>http://localhost:8080/</code>으로 두어도 무방합니다.</p>
<br>

<h3 id="jenkins-실행-포트-변경">Jenkins 실행 포트 변경</h3>
<p>Spring Boot와 포트가 겹쳐 Jenkins 기본 포트(8080)를 변경하였습니다.</p>
<pre><code class="language-bash"># Jenkins 서비스 중지
brew services stop jenkins-lts

# 설정 파일 위치로 이동
cd /opt/homebrew/opt/jenkins-lts

# 실행 포트 설정 파일 열기
nano homebrew.mxcl.jenkins-lts.plist</code></pre>
<p><code>&lt;string&gt;--httpPort=8080&lt;/string&gt;</code>을 원하는 포트로 수정</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/703f873c-c3a1-4bd1-959c-3947019afb52/image.png" alt=""></p>
<pre><code># Jenkins 서비스 재시작
brew services restart jenkins-lts</code></pre><p>🥳 이제 Jenkins는 변경한 포트에서 실행됩니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/1c548bae-5412-4515-9a7e-5ec08d8a5628/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform Associate (003) (HCTA0-003) 합격 후기]]></title>
            <link>https://velog.io/@yukyung_16/Terraform-Associate-003-HCTA0-003-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@yukyung_16/Terraform-Associate-003-HCTA0-003-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Wed, 23 Jul 2025 11:31:30 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/4ff52428-ba5d-4764-9023-295e50290282/image.png" alt=""></p>
<p>최근에 HashiCorp Certified: Terraform Associate (HCTA0-003) 자격증에 합격해서, 준비 과정과 시험 후기, 공부 방법 등을 간단히 정리해보려 합니다 🎉</p>
<br>

<h3 id="시험-정보">시험 정보</h3>
<ul>
<li>시험 유형: 객관식</li>
<li>응시 방식: 온라인</li>
<li>시험 시간: 60분 (30분 연장 가능!)</li>
<li>응시 비용: $70.50 USD</li>
<li>시험 언어: 영어</li>
<li>자격 유효 기간: 2년</li>
</ul>
<p>🔗 <a href="https://cp.certmetrics.com/hashicorp/en/login">접수 링크</a></p>
<br>

<h3 id="공부-방법">공부 방법</h3>
<p>저는 자격증 공부 시작 전에 <code>terraform plan, apply, destroy</code> 정도만 알고 있었습니다. 그리고 영어에 자신이 없어서 먼저 이 책부터 읽었습니다! Part 1이 시험 범위 대부분을 다루고 있어서 많은 도움 되었습니다. 👉🏻 <a href="https://product.kyobobook.co.kr/detail/S000214212042">테라폼으로 시작하는 IaC</a></p>
<p>본격적인 시험 준비는 약 5일 정도했고, 하루에 1~2시간씩 집중해서 준비했습니다.</p>
<p><strong>✅ 공식 튜토리얼</strong></p>
<p>먼저 책을 봐서 그런지, 이틀 정도면 전체 튜토리얼을 후다닥 읽을 수 있었습니다 😎 개념 정리하는 데 좋았어요!</p>
<p>[Terraform Associate Tutorials - HashiCorp Docs]
(<a href="https://developer.hashicorp.com/terraform/tutorials/certification-associate-tutorials-003">https://developer.hashicorp.com/terraform/tutorials/certification-associate-tutorials-003</a>)</p>
<p><strong>✅ 덤프 사이트</strong></p>
<p>대충 100문제 정도만 ... 풀었던 것 같습니다.</p>
<p><a href="https://www.examtopics.com/exams/hashicorp/terraform-associate/view/">https://www.examtopics.com/exams/hashicorp/terraform-associate/view/</a></p>
<p><a href="https://www.itdumpskr.com/TA-003-P-practice-test.html#">https://www.itdumpskr.com/TA-003-P-practice-test.html#</a></p>
<p><strong>✅ Udemy 모의고사</strong></p>
<p>4세트만 보고 갔는데, 이정도 풀 수 있으면 실제 시험도 무난하게 통과할 수 있을 것 같아요! 해설도 자세해서 좋았습니다.</p>
<p><img src="https://velog.velcdn.com/images/yukyung_16/post/796914d4-c44c-4b59-8f67-ec2acda4569d/image.png" alt=""></p>
<h3 id="시험-후기">시험 후기</h3>
<p>문제 대부분은 덤프에서 본 유형과 거의 비슷했습니다 :) 문제 수는 57문제였고, 전부 객관식이었습니다.</p>
<p>시험이 끝나고 간단한 설문조사를 제출하면 합격 여부는 바로 확인할 수 있었고, 세부 성적 리포트와 뱃지는 약 12시간 후에 메일로 도착했습니다!</p>
<br>

<h3 id="마무리">마무리</h3>
<p>테라폼 공부를 막 시작하던 중에 갑자기 삘이 꽂혀서 그대로 시험을 예약했는데 ,,,ㅎ 저는 나름 재밌게 공부하고 시험을 봤던 것 같습니다 ❕</p>
<p>물론 이 자격증 땄다고 테라폼을 마스터한 건 절대 아니지만, 개념 정리하기엔 좋은 시험이라고 생각합니다. 앞으로도 테라폼에 애정을 가지고 공부하겠습니다 🚀</p>
]]></description>
        </item>
    </channel>
</rss>