<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>헬창개발자의 성장 V.log</title>
        <link>https://velog.io/</link>
        <description>智(지)! 德(덕)! 體(체)!</description>
        <lastBuildDate>Wed, 15 Apr 2026 01:27:57 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>헬창개발자의 성장 V.log</title>
            <url>https://velog.velcdn.com/images/jeong_woo/profile/d18dce10-8e9d-4299-8020-4dc6e3dfb6ed/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 헬창개발자의 성장 V.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jeong_woo" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[인코딩 완전 정복]]></title>
            <link>https://velog.io/@jeong_woo/%EC%9D%B8%EC%BD%94%EB%94%A9-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5</link>
            <guid>https://velog.io/@jeong_woo/%EC%9D%B8%EC%BD%94%EB%94%A9-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5</guid>
            <pubDate>Wed, 15 Apr 2026 01:27:57 GMT</pubDate>
            <description><![CDATA[<h1 id="base64와-인코딩-완전-정복--바이너리→텍스트-변환의-모든-것">Base64와 인코딩 완전 정복 — 바이너리→텍스트 변환의 모든 것</h1>
<h2 id="base64는-주로-어디에-쓰일까">Base64는 주로 어디에 쓰일까?</h2>
<p>Base64는 <strong>바이너리 데이터를 텍스트(ASCII) 형태로 변환</strong>해야 할 때 사용한다. 텍스트만 허용되는 채널에서 바이너리를 안전하게 전송·저장하는 것이 핵심 목적이다.</p>
<h3 id="대표적인-사용처">대표적인 사용처</h3>
<p><strong>이메일 (MIME)</strong> — SMTP는 7-bit ASCII 기반 프로토콜이다. 이미지·첨부파일 같은 바이너리를 본문에 실으려면 base64로 인코딩해야 한다. <code>Content-Transfer-Encoding: base64</code> 헤더가 붙는 경우가 바로 이것이다.</p>
<p><strong>웹/API 통신</strong> — JSON이나 XML 같은 텍스트 포맷 안에 이미지·PDF 등 바이너리 파일을 넣을 때 활용된다. Anthropic API에 이미지를 보낼 때 <code>source.type: &quot;base64&quot;</code>로 넣는 패턴이 대표적이다. Data URI(<code>data:image/png;base64,…</code>)로 HTML/CSS에 이미지를 인라인 삽입할 때도 동일한 원리다.</p>
<p><strong>인증 헤더</strong> — HTTP Basic Authentication에서 <code>username:password</code> 문자열을 base64로 변환해 <code>Authorization: Basic &lt;encoded&gt;</code> 형태로 전송한다. 암호화가 아니라 단순 변환이므로 보안 목적은 아니다.</p>
<p><strong>인증서·키 저장</strong> — PEM 형식의 SSL/TLS 인증서, SSH 공개키 등이 base64로 표현되어 <code>-----BEGIN CERTIFICATE-----</code> 블록 안에 들어간다.</p>
<p><strong>쿠키·토큰</strong> — JWT의 header/payload 부분이 base64url로 처리되고, 쿠키에 구조화된 값을 담을 때도 종종 활용된다.</p>
<blockquote>
<p>정리하면, base64 자체는 암호화나 압축이 아니라 <strong>&quot;바이너리 → 텍스트 안전 변환&quot;</strong>이 본질이며, 텍스트 전용 프로토콜·포맷과 바이너리 사이의 브릿지 역할을 한다. 다만 원본 대비 약 33% 크기가 늘어나는 오버헤드가 있어서, 대용량 파일에는 멀티파트 전송이 더 효율적이다.</p>
</blockquote>
<hr>
<h2 id="base64-말고-다른-종류들">Base64 말고 다른 종류들</h2>
<h3 id="문자-인코딩-character-encoding">문자 인코딩 (Character Encoding)</h3>
<p>텍스트를 바이트로 표현하는 방식이다. ASCII가 영문 128자만 다루던 한계를 넘어 다국어를 지원하는 방향으로 발전해 왔다.</p>
<ul>
<li><strong>ASCII</strong> — 7비트, 영문·숫자·기본 기호만 표현 (0~127)</li>
<li><strong>EUC-KR / CP949</strong> — 한글 완성형. 과거 한국 웹에서 널리 쓰였고, 지금도 레거시 시스템에서 만난다</li>
<li><strong>UTF-8</strong> — 가변 길이(1~4바이트) 유니코드 구현체. 현재 웹 표준이며 ASCII와 하위 호환된다</li>
<li><strong>UTF-16</strong> — 2 또는 4바이트. JavaScript 내부 문자열, Windows API 등에서 채택</li>
<li><strong>UTF-32</strong> — 고정 4바이트. 처리는 단순하지만 메모리 낭비가 커서 실무에선 드물다</li>
<li><strong>ISO-8859-1 (Latin-1)</strong> — 서유럽어 확장. HTTP 기본 charset으로 오래 활용되었다</li>
</ul>
<h3 id="바이너리→텍스트-binary-to-text">바이너리→텍스트 (Binary-to-Text)</h3>
<p>base64와 같은 계열로, 바이너리를 텍스트 안전 문자열로 바꾼다.</p>
<ul>
<li><strong>Base32</strong> — A<del>Z + 2</del>7 사용. 대소문자 구분 없는 환경(DNS, OTP 시크릿)에 적합</li>
<li><strong>Base16 (Hex)</strong> — 0<del>9, A</del>F. MAC 주소, 해시값, 색상 코드(<code>#FF5733</code>) 등에서 일상적으로 볼 수 있다</li>
<li><strong>Base85 (Ascii85)</strong> — base64보다 효율적(약 25% 오버헤드). PDF 내부, Git 바이너리 패치에 쓰인다</li>
<li><strong>Quoted-Printable</strong> — 이메일에서 대부분 ASCII인 텍스트에 간헐적 비ASCII 문자가 섞일 때 활용. <code>=EC=9D=B4</code> 같은 형태</li>
<li><strong>UUencode</strong> — Unix 시절 이메일 첨부 방식. base64/MIME에 거의 대체되었다</li>
</ul>
<h3 id="웹url">웹/URL</h3>
<ul>
<li><strong>Percent-encoding (URL encoding)</strong> — URL에 넣을 수 없는 문자를 <code>%XX</code> 형태로 변환. 예: 공백 → <code>%20</code>, 한글 → <code>%ED%95%9C</code></li>
<li><strong>HTML Entity encoding</strong> — HTML 특수문자를 안전하게 표현. <code>&lt;</code> → <code>&amp;lt;</code>, <code>&amp;</code> → <code>&amp;amp;</code>, 숫자 참조 <code>&amp;#44032;</code> 등</li>
</ul>
<h3 id="전송압축-transfer-encoding">전송/압축 (Transfer Encoding)</h3>
<p>전송 효율이나 스트리밍을 위한 방식이다.</p>
<ul>
<li><strong>Chunked Transfer Encoding</strong> — HTTP/1.1에서 전체 크기를 모른 채 응답을 조각 단위로 전송</li>
<li><strong>gzip / deflate / br (Brotli)</strong> — HTTP <code>Content-Encoding</code> 헤더로 지정. 엄밀히는 압축이지만 전송 계층으로 분류되기도 한다</li>
</ul>
<h3 id="미디어-audiovideoimage">미디어 (Audio/Video/Image)</h3>
<ul>
<li><strong>영상</strong> — H.264, H.265(HEVC), VP9, AV1</li>
<li><strong>음성</strong> — AAC, Opus, MP3, FLAC</li>
<li><strong>이미지</strong> — JPEG, PNG, WebP, AVIF</li>
</ul>
<p>이들은 &quot;코덱&quot;이라고도 부르며, 손실/무손실 압축과 디코딩 규격을 함께 정의한다.</p>
<h3 id="보안-관련-변환">보안 관련 변환</h3>
<p>변환과 암호화는 다르지만, 실무에서 함께 언급되는 경우가 많다.</p>
<ul>
<li><strong>해싱</strong> — SHA-256, MD5 등. 단방향 처리라 디코딩 불가. 무결성 검증·비밀번호 저장용</li>
<li><strong>암호화</strong> — AES, RSA, ChaCha20 등. 키가 있어야 복호화 가능. 파이프라인에서 인코딩과 결합되어 활용된다 (예: 암호화 → base64 → JSON 전송)</li>
</ul>
<blockquote>
<p>핵심 구분은 <strong>목적</strong>이다. 문자 표현이면 UTF-8 계열, 바이너리의 텍스트 안전 변환이면 base64 계열, 전송 효율이면 gzip/chunked, 미디어 압축이면 코덱.</p>
</blockquote>
<hr>
<h2 id="바이너리→텍스트-인코딩-실제로-어디서-쓰이나">바이너리→텍스트 인코딩, 실제로 어디서 쓰이나?</h2>
<p>바이너리→텍스트 변환이 추상적으로 느껴지는 이유는, 대부분 &quot;이미 변환된 결과물&quot;을 매일 보면서도 그 사실을 의식하지 못하기 때문이다.</p>
<h3 id="핵심-전제-왜-필요한가">핵심 전제: 왜 필요한가?</h3>
<p>세상에는 &quot;텍스트만 통과시키는 통로&quot;가 생각보다 많다. JSON, XML, HTTP 헤더, 이메일 본문, 환경변수, 설정 파일, 소스코드 — 이런 곳에 바이너리(이미지, 인증서, 암호화 결과물 등)를 넣으려면 텍스트로 바꿔야 한다. 그 변환기가 base64, hex 같은 바이너리→텍스트 기법이다.</p>
<h3 id="1-ssh-키--ssl-인증서-base64">1. SSH 키 / SSL 인증서 (Base64)</h3>
<p><code>~/.ssh/id_rsa.pub</code>를 열어보면 이런 형태다:</p>
<pre><code>ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB... user@host</code></pre><p>가운데 긴 문자열이 base64이다. 공개키의 실체는 바이너리 숫자(큰 정수)인데, 이걸 텍스트 파일에 한 줄로 저장하고 터미널에 복사·붙여넣기 하려면 텍스트여야 한다. NHN Cloud 인스턴스에 SSH 키 등록할 때 콘솔에 붙여넣는 것도 같은 이유다.</p>
<p>SSL 인증서(<code>*.pem</code>)도 마찬가지다:</p>
<pre><code>-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkq...
-----END CERTIFICATE-----</code></pre><p>nginx 설정에서 <code>ssl_certificate</code> 경로를 지정하면 nginx가 이 base64를 디코딩해서 바이너리 인증서로 해석한다.</p>
<h3 id="2-이메일-첨부파일-base64">2. 이메일 첨부파일 (Base64)</h3>
<p>이메일로 이미지를 보내면, 실제 전송되는 원문(raw message)은 이렇다:</p>
<pre><code>Content-Type: image/png; name=&quot;photo.png&quot;
Content-Transfer-Encoding: base64

iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAA...</code></pre><p>SMTP는 7-bit 텍스트 프로토콜이라 PNG 바이너리를 그대로 보낼 수 없다. 메일 클라이언트가 자동으로 base64 처리해서 전송하고, 수신 측에서 복원하는 것이다. 우리가 의식하지 못할 뿐 매일 일어나는 일이다.</p>
<h3 id="3-api-요청-안에-파일-담기-base64">3. API 요청 안에 파일 담기 (Base64)</h3>
<p>Anthropic API에 이미지를 보내는 코드가 대표적이다:</p>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;image&quot;,
  &quot;source&quot;: {
    &quot;type&quot;: &quot;base64&quot;,
    &quot;media_type&quot;: &quot;image/jpeg&quot;,
    &quot;data&quot;: &quot;/9j/4AAQSkZJRgABAQ...&quot;
  }
}</code></pre>
<p>JSON은 텍스트 포맷이라 바이너리 바이트를 직접 넣을 수 없다. <code>multipart/form-data</code>로 파일을 따로 보내는 방법도 있지만, JSON body 하나로 깔끔하게 처리하고 싶을 때 base64가 활용된다.</p>
<h3 id="4-data-uri--htmlcss-안에-이미지-삽입-base64">4. Data URI — HTML/CSS 안에 이미지 삽입 (Base64)</h3>
<pre><code class="language-html">&lt;img src=&quot;data:image/png;base64,iVBORw0KGgo...&quot; /&gt;</code></pre>
<p>별도 HTTP 요청 없이 HTML 자체에 이미지를 포함시킨다. 아이콘 같은 작은 이미지에 적용하면 네트워크 왕복을 줄일 수 있다. 이메일 HTML 템플릿에서 특히 많이 쓰인다 (외부 이미지 URL이 차단되는 메일 클라이언트 대응).</p>
<h3 id="5-jwt-토큰-base64url">5. JWT 토큰 (Base64url)</h3>
<p>로그인 후 받는 JWT를 <code>.</code>으로 쪼개면 세 파트다:</p>
<pre><code>eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.서명부분</code></pre><p>앞 두 파트를 base64url 디코딩하면:</p>
<pre><code class="language-json">{&quot;alg&quot;:&quot;HS256&quot;}
{&quot;user_id&quot;:123}</code></pre>
<p>JSON 객체를 HTTP 헤더(<code>Authorization: Bearer ...</code>)나 URL 파라미터에 넣어야 하는데, JSON을 그대로 넣으면 <code>{</code>, <code>&quot;</code>, <code>:</code> 같은 특수문자가 문제를 일으킨다. base64url로 감싸면 알파벳·숫자·<code>-</code>·<code>_</code>만 남아서 어디에든 안전하게 들어간다.</p>
<h3 id="6-git-바이너리-diff-base85">6. Git 바이너리 diff (Base85)</h3>
<p>Git에서 이미지 파일이 변경되면 diff에 이런 내용이 나타난다:</p>
<pre><code>diff --git a/icon.png b/icon.png
GIT binary patch
literal 2345
zcmV+^3E7mhP)&lt;h;3K|Lk000e1NJLTq...</code></pre><p>텍스트 기반 diff/patch 포맷 안에 바이너리 변경분을 담기 위해 base85를 채택한다.</p>
<h3 id="7-hex-base16--해시값-mac-주소-디버깅">7. Hex (Base16) — 해시값, MAC 주소, 디버깅</h3>
<p>매일 접하는 것들이다:</p>
<pre><code class="language-bash">sha256sum file.tar.gz
# e3b0c44298fc1c149afbf4c8996fb924...  ← hex로 표현된 해시

ip link show
# link/ether 3a:2b:1c:4d:5e:6f  ← MAC 주소도 hex

hexdump -C /dev/urandom | head
# 바이너리 파일 내용을 사람이 읽을 수 있게 hex로 표시</code></pre>
<p>해시 함수의 출력은 바이너리(32바이트 등)인데, 터미널에 표시하거나 로그에 기록하려면 hex 문자열로 변환한다.</p>
<blockquote>
<p><strong>정리하면 패턴은 하나다:</strong> &quot;텍스트만 허용되는 곳에 바이너리를 넣어야 할 때.&quot; JSON, HTTP 헤더, 이메일, 설정 파일, 소스코드, 터미널 출력 — 이 모든 텍스트 전용 통로가 존재하는 한 바이너리→텍스트 기법은 계속 쓰인다. 눈에 잘 안 보이는 이유는 대부분 라이브러리나 프로토콜이 자동 처리해주기 때문이다.</p>
</blockquote>
<hr>
<h2 id="바이너리는-0과-1인데-이게-이미지라고">바이너리는 0과 1인데 이게 이미지라고?</h2>
<p>맞다. 바이너리는 0과 1의 나열이 맞고, 이미지도 음악도 영상도 결국 전부 0과 1이다. 핵심은 <strong>&quot;해석 방식이 다르다&quot;</strong>는 것이다.</p>
<h3 id="컴퓨터에서-모든-것은-바이너리">컴퓨터에서 모든 것은 바이너리</h3>
<p>텍스트 파일도 바이너리다. 예를 들어 <code>A</code>라는 글자는 내부적으로 <code>01000001</code>(65번)로 저장된다. 다만 텍스트 파일은 모든 바이트가 &quot;사람이 읽을 수 있는 문자 범위(0~127)&quot; 안에 있어서, 에디터로 열면 글자로 보이는 것이다.</p>
<p>반면 PNG 이미지 파일을 메모장으로 열어보면 <code>‰PNG\r\n...</code> 뒤에 깨진 문자가 쏟아진다. 같은 0과 1인데 &quot;문자로 해석할 수 없는 바이트&quot;가 대부분이기 때문이다.</p>
<h3 id="이미지가-바이너리인-구조">이미지가 바이너리인 구조</h3>
<p>3×2 픽셀짜리 아주 작은 이미지가 있다고 하면, 각 픽셀은 RGB 값을 가진다:</p>
<pre><code>픽셀(0,0) = 빨강 255, 초록 0, 파랑 0</code></pre><p>이걸 바이너리로 쓰면:</p>
<pre><code>11111111 00000000 00000000</code></pre><p>이게 한 픽셀이고, 이런 바이트가 수십만~수백만 개 이어지면 사진 한 장이 된다. 거기에 파일 헤더(이미지 크기, 포맷 정보 등)가 앞에 붙고, 압축 알고리즘이 적용되면 PNG나 JPEG 파일이 되는 것이다.</p>
<h3 id="텍스트와-바이너리의-실무적-구분">&quot;텍스트&quot;와 &quot;바이너리&quot;의 실무적 구분</h3>
<p>둘 다 0과 1인 건 같지만, 실무에서 말하는 구분은 이것이다:</p>
<p><strong>텍스트</strong> — 모든 바이트가 문자로 매핑되는 형태. JSON, HTML, 소스코드, 설정 파일 등. 에디터로 열면 사람이 읽을 수 있다.</p>
<p><strong>바이너리</strong> — 문자 범위 밖의 바이트가 포함된 형태. 이미지, 동영상, 실행파일, 압축 아카이브 등. 에디터로 열면 깨져 보인다.</p>
<h3 id="그래서-base64가-필요한-이유">그래서 base64가 필요한 이유</h3>
<p>JSON 같은 텍스트 포맷은 &quot;문자&quot;만 담을 수 있게 설계되어 있다. 그런데 이미지 바이너리에는 <code>0x00</code>(null), <code>0x89</code>, <code>0xFF</code> 같은 바이트가 들어 있고, 이런 값들을 JSON 문자열 안에 그대로 넣으면 파싱이 깨진다.</p>
<p>base64가 하는 일:</p>
<pre><code>원본 바이너리:  10001001 01010000 01001110 01000111 ...
                (0x89)    (P)      (N)      (G)

 ↓ base64 변환

텍스트 문자열:  &quot;iVBORw0KGgo...&quot;
                (A~Z, a~z, 0~9, +, / 만 사용)</code></pre><p><code>0x89</code> 같은 &quot;텍스트로 표현 불가능한 바이트&quot;까지 포함해서 전부 알파벳·숫자 조합으로 바꿔주는 것이다. 받는 쪽에서 다시 디코딩하면 원본이 그대로 복원된다.</p>
<blockquote>
<p>바이너리→텍스트 변환은 <strong>&quot;모든 바이트를 문자 안전 영역으로 옮기는 번역&quot;</strong>이라고 생각하면 된다.</p>
</blockquote>
<hr>
<h2 id="django에서-base64를-만나는-경우">Django에서 base64를 만나는 경우</h2>
<h3 id="django가-내부적으로-자동-처리하는-경우">Django가 내부적으로 자동 처리하는 경우</h3>
<p><strong>CSRF 토큰</strong> — <code>{% csrf_token %}</code>이 생성하는 토큰 값이 base64로 처리되어 있다. 내부적으로 랜덤 바이트를 생성한 뒤 변환해서 폼 hidden 필드나 쿠키에 넣는다. 바이너리 랜덤 값을 HTML과 쿠키(텍스트 통로)에 안전하게 담기 위해서다.</p>
<p><strong>세션</strong> — <code>SESSION_ENGINE</code>이 기본 DB 백엔드일 때, 세션 딕셔너리를 직렬화한 뒤 base64로 변환해서 저장한다. <code>django_session</code> 테이블의 <code>session_data</code> 컬럼을 직접 보면 base64 문자열이 들어 있는 것을 확인할 수 있다.</p>
<p><strong>비밀번호 해싱</strong> — <code>PBKDF2</code>로 해싱된 비밀번호가 DB에 저장될 때 형태가 이렇다:</p>
<pre><code>pbkdf2_sha256$600000$salt$hash값</code></pre><p>여기서 salt와 hash 부분이 base64로 표현된 바이너리다. 해시 함수 출력은 바이너리인데 DB varchar 컬럼(텍스트)에 저장해야 하기 때문이다.</p>
<p><strong>Signed Cookie / <code>signing</code> 모듈</strong> — <code>django.core.signing</code>이 HMAC 서명값을 base62/base64로 변환한다. 쿠키나 URL에 들어가야 하니까 텍스트 안전 형태가 필요하다.</p>
<h3 id="api-개발할-때-직접-쓰는-경우">API 개발할 때 직접 쓰는 경우</h3>
<p><strong>클라이언트에서 이미지를 JSON으로 받을 때</strong> — 모바일 앱이나 프론트엔드가 파일을 <code>multipart/form-data</code> 대신 JSON body에 base64로 담아 보내는 경우:</p>
<pre><code class="language-python">import base64
from django.core.files.base import ContentFile

def upload_profile(request):
    data = json.loads(request.body)
    # &quot;data:image/png;base64,iVBORw0KGgo...&quot; 형태로 올 수 있음
    image_data = data[&#39;image&#39;].split(&#39;,&#39;)[1]  # base64 부분만 추출
    binary = base64.b64decode(image_data)
    file = ContentFile(binary, name=&#39;profile.png&#39;)
    # 이후 모델에 저장하거나 Object Storage에 업로드</code></pre>
<p><strong>API 응답으로 작은 파일을 내려줄 때</strong> — 별도 파일 다운로드 엔드포인트를 만들기 번거로울 때, 썸네일 같은 작은 이미지를 JSON 응답에 포함시키기도 한다:</p>
<pre><code class="language-python">import base64

def get_thumbnail(request, pk):
    obj = MyModel.objects.get(pk=pk)
    with obj.thumbnail.open(&#39;rb&#39;) as f:
        encoded = base64.b64encode(f.read()).decode(&#39;ascii&#39;)
    return JsonResponse({
        &#39;thumbnail&#39;: f&#39;data:image/jpeg;base64,{encoded}&#39;
    })</code></pre>
<p><strong>외부 API 연동에서 Basic Auth</strong> — 외부 서비스를 호출할 때:</p>
<pre><code class="language-python">import base64
import requests

credentials = base64.b64encode(b&#39;api_user:api_password&#39;).decode()
response = requests.get(url, headers={
    &#39;Authorization&#39;: f&#39;Basic {credentials}&#39;
})</code></pre>
<p><strong>바이너리를 캐시에 저장할 때</strong> — Redis 캐시에 바이너리를 넣어야 할 때 base64로 변환하면 직렬화 문제를 피할 수 있다.</p>
<h3 id="공통-패턴">공통 패턴</h3>
<p>Django에서 base64가 등장하는 모든 경우를 관통하는 공통점은, <strong>DB 컬럼(텍스트), JSON 응답(텍스트), 쿠키(텍스트), HTTP 헤더(텍스트)</strong> 같은 텍스트 전용 통로에 바이너리 값(해시, 서명, 이미지, 랜덤 토큰)을 넣어야 하는 상황이라는 것이다. 프레임워크가 내부적으로 해주는 것과 개발자가 직접 하는 것의 차이만 있을 뿐, 원리는 동일하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[logrotate 서비스]]></title>
            <link>https://velog.io/@jeong_woo/logrotate-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@jeong_woo/logrotate-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Thu, 26 Mar 2026 01:57:42 GMT</pubDate>
            <description><![CDATA[<h1 id="gunicorn--logrotate--블록-스토리지-아카이브-완전-가이드">Gunicorn + Logrotate + 블록 스토리지 아카이브 완전 가이드</h1>
<p>Django + Gunicorn 환경에서 로그를 파일로 기록하고, logrotate로 자동 관리하며,
쌓인 데이터를 별도 블록 스토리지에 연/월 구조로 보관하는 전 과정을 다룬다.</p>
<hr>
<h2 id="목차">목차</h2>
<ol>
<li><a href="#1-%EB%B8%94%EB%A1%9D-%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%97%B0%EA%B2%B0-%EB%B0%8F-%EB%A7%88%EC%9A%B4%ED%8A%B8">블록 스토리지 연결 및 마운트</a></li>
<li><a href="#2-gunicorn-%EB%A1%9C%EA%B7%B8-%EC%B6%9C%EB%A0%A5-%EC%84%A4%EC%A0%95">Gunicorn 로그 출력 설정</a></li>
<li><a href="#3-logrotate-%EC%98%B5%EC%85%98-%EC%83%81%EC%84%B8-%EC%A0%95%EB%A6%AC">Logrotate 옵션 상세 정리</a></li>
<li><a href="#4-logrotate-%EC%84%A4%EC%A0%95-%EC%9E%91%EC%84%B1">Logrotate 설정 작성</a></li>
<li><a href="#5-%EC%95%84%EC%B9%B4%EC%9D%B4%EB%B8%8C-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%84%A4%EA%B3%84">아카이브 스크립트 설계</a></li>
<li><a href="#6-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%82%AC%EC%9A%A9%EB%9F%89-%EA%B3%84%EC%82%B0">디스크 사용량 계산</a></li>
<li><a href="#7-%EA%B8%B0%EC%A1%B4-%EA%B1%B0%EB%8C%80-%EB%A1%9C%EA%B7%B8-%EC%A0%95%EB%A6%AC">기존 거대 로그 정리</a></li>
<li><a href="#8-%EC%9A%94%EC%95%BD">요약</a></li>
</ol>
<hr>
<h2 id="1-블록-스토리지-연결-및-마운트">1. 블록 스토리지 연결 및 마운트</h2>
<h3 id="1-1-현재-디스크-상태-확인">1-1. 현재 디스크 상태 확인</h3>
<pre><code class="language-bash">lsblk</code></pre>
<p>마운트 여부와 관계없이 연결된 블록 디바이스를 모두 트리 형태로 보여준다.
파일시스템 타입과 UUID까지 함께 확인하려면 <code>-f</code> 옵션을 붙인다.</p>
<pre><code class="language-bash">lsblk -f</code></pre>
<p>NHN Cloud 등 클라우드 환경에서 추가 볼륨을 attach하면 <code>vdb</code>와 같은 이름으로 나타난다.</p>
<h3 id="1-2-파티션-분리-여부-판단">1-2. 파티션 분리 여부 판단</h3>
<p>클라우드 블록 스토리지를 단순 용량 확장 목적으로 쓴다면, 파티션 없이 디스크 전체를 그대로 사용하는 것이 깔끔하다.
파티션이 유용한 경우는 다음과 같다.</p>
<ul>
<li>용도별(<code>/data</code>, <code>/logs</code>)로 용량을 강제 제한하고 싶을 때</li>
<li>한 영역이 꽉 차도 나머지에 영향이 없도록 격리하고 싶을 때</li>
</ul>
<h3 id="1-3-포맷-및-마운트">1-3. 포맷 및 마운트</h3>
<pre><code class="language-bash"># ext4로 포맷
sudo mkfs.ext4 /dev/vdb

# 마운트 포인트 생성
sudo mkdir -p /mnt/data

# 마운트
sudo mount /dev/vdb /mnt/data

# 확인
df -h</code></pre>
<h3 id="1-4-재부팅-후-자동-마운트-fstab-등록">1-4. 재부팅 후 자동 마운트 (fstab 등록)</h3>
<p>장치명(vdb)은 재부팅 시 바뀔 수 있어 UUID 기반으로 등록해야 안전하다.</p>
<pre><code class="language-bash"># UUID 확인
sudo blkid /dev/vdb</code></pre>
<p>출력 예시:</p>
<pre><code>/dev/vdb: UUID=&quot;3de85581-2e2a-43f5-878e-b6a87d2fd905&quot; BLOCK_SIZE=&quot;4096&quot; TYPE=&quot;ext4&quot;</code></pre><pre><code class="language-bash"># fstab에 추가
echo &quot;UUID=3de85581-2e2a-43f5-878e-b6a87d2fd905  /mnt/data  ext4  defaults  0  2&quot; | sudo tee -a /etc/fstab

# 재부팅 없이 검증
sudo mount -a

# 최종 확인
df -h</code></pre>
<hr>
<h2 id="2-gunicorn-로그-출력-설정">2. Gunicorn 로그 출력 설정</h2>
<h3 id="2-1-문제-stdout으로-흘러가는-기본-구성">2-1. 문제: stdout으로 흘러가는 기본 구성</h3>
<p>일반적인 gunicorn.service 파일은 이렇게 되어 있다.</p>
<pre><code class="language-ini">ExecStart=/path/to/.venv/bin/gunicorn \
          --access-logfile - \
          --error-logfile - \
          ...</code></pre>
<p>여기서 <code>-</code>는 stdout/stderr로 출력한다는 의미다.
journald로만 데이터가 흘러가 별도 경로에 기록되지 않는다.
journald는 용량 제한이 있고 검색도 불편하기 때문에, 운영 환경에서는 직접 경로를 지정하는 것이 일반적이다.</p>
<h3 id="2-2-로그-디렉토리-생성">2-2. 로그 디렉토리 생성</h3>
<pre><code class="language-bash">sudo mkdir -p /var/log/mch_api
sudo chown ubuntu:www-data /var/log/mch_api
sudo chmod 755 /var/log/mch_api</code></pre>
<ul>
<li><code>ubuntu:www-data</code>: gunicorn이 <code>ubuntu</code> 계정으로 구동되므로 해당 소유자에게 쓰기 권한을 부여한다.</li>
<li>디렉토리명은 서비스에 맞게 자유롭게 지정(<code>ssc_api</code>, <code>mch_api</code> 등).</li>
</ul>
<h3 id="2-3-gunicornservice-수정">2-3. gunicorn.service 수정</h3>
<pre><code class="language-ini">[Unit]
Description=gunicorn daemon for MCH
Requires=gunicorn.socket
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/workspace/smart-silver-center/ssc-api

ExecStart=/workspace/smart-silver-center/ssc-api/.venv/bin/gunicorn \
          --access-logfile /var/log/mch_api/access.log \
          --error-logfile /var/log/mch_api/error.log \
          --workers 4 \
          --threads 2 \
          --worker-class gthread \
          --timeout 60 \
          --graceful-timeout 30 \
          --max-requests 1000 \
          --max-requests-jitter 100 \
          --keep-alive 5 \
          --bind unix:/run/gunicorn.sock \
          --preload \
          --capture-output \
          --enable-stdio-inheritance \
          --log-level info core.michuhol.wsgi:application

Environment=&quot;ENV_FILE=.env&quot;
Restart=on-failure
RestartSec=5
LimitNOFILE=4096

[Install]
WantedBy=multi-user.target</code></pre>
<p>변경점은 두 줄이다.</p>
<pre><code class="language-diff">- --access-logfile - \
- --error-logfile - \
+ --access-logfile /var/log/mch_api/access.log \
+ --error-logfile /var/log/mch_api/error.log \</code></pre>
<h3 id="2-4-적용-및-확인">2-4. 적용 및 확인</h3>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl restart gunicorn

# 경로 생성 확인
ls -la /var/log/mch_api/

# 실시간 확인
tail -f /var/log/mch_api/access.log</code></pre>
<p>기록이 안 된다면 아래 순서로 점검한다.</p>
<pre><code class="language-bash">sudo systemctl status gunicorn.service
sudo journalctl -u gunicorn.service -n 30 --no-pager
ls -la /var/log/ | grep mch_api
cat /etc/systemd/system/gunicorn.service | grep logfile</code></pre>
<hr>
<h2 id="3-logrotate-옵션-상세-정리">3. Logrotate 옵션 상세 정리</h2>
<h3 id="3-1-회전-주기">3-1. 회전 주기</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>daily</code></td>
<td>매일 회전</td>
</tr>
<tr>
<td><code>weekly</code></td>
<td>매주 회전</td>
</tr>
<tr>
<td><code>monthly</code></td>
<td>매월 회전</td>
</tr>
</tbody></table>
<p>API 서버라면 <code>daily</code>가 일반적이다.</p>
<h3 id="3-2-보관-관련">3-2. 보관 관련</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>rotate 14</code></td>
<td>회전된 항목을 최대 14개까지 유지. 초과 시 오래된 것부터 삭제</td>
</tr>
<tr>
<td><code>maxsize 100M</code></td>
<td>주기 무관하게 100MB를 넘으면 강제 회전</td>
</tr>
<tr>
<td><code>minsize 1M</code></td>
<td>1MB 미만이면 주기가 돼도 넘어감</td>
</tr>
<tr>
<td><code>maxage 30</code></td>
<td>30일 지난 항목 삭제 (rotate는 개수 기준, maxage는 날짜 기준)</td>
</tr>
</tbody></table>
<h3 id="3-3-압축-관련">3-3. 압축 관련</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>compress</code></td>
<td>gzip으로 압축</td>
</tr>
<tr>
<td><code>delaycompress</code></td>
<td>직전 회전본은 압축하지 않음. 장애 시 <code>zcat</code> 없이 바로 열람 가능</td>
</tr>
<tr>
<td><code>compresscmd bzip2</code></td>
<td>압축 프로그램 변경</td>
</tr>
</tbody></table>
<h3 id="3-4-파일-생성처리">3-4. 파일 생성/처리</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>create 0644 ubuntu www-data</code></td>
<td>회전 후 새 항목을 지정 권한/소유자로 생성</td>
</tr>
<tr>
<td><code>copytruncate</code></td>
<td>원본 복사 후 비움. 디스크립터를 새로 열지 못하는 프로세스에 유용. 복사~truncate 사이 유실 가능</td>
</tr>
<tr>
<td><code>nocreate</code></td>
<td>회전 후 새 항목 자동 생성 안 함</td>
</tr>
</tbody></table>
<h3 id="3-5-예외-처리">3-5. 예외 처리</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>missingok</code></td>
<td>대상이 없어도 에러 없이 통과</td>
</tr>
<tr>
<td><code>notifempty</code></td>
<td>비어 있으면 회전하지 않음</td>
</tr>
</tbody></table>
<h3 id="3-6-스크립트-실행">3-6. 스크립트 실행</h3>
<pre><code>postrotate
    systemctl reload gunicorn 2&gt;/dev/null || true
endscript</code></pre><table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>postrotate / endscript</code></td>
<td>회전 후 실행할 명령. 보통 서비스에 시그널을 보내 새 경로를 열게 함</td>
</tr>
<tr>
<td><code>prerotate / endscript</code></td>
<td>회전 전 실행</td>
</tr>
<tr>
<td><code>sharedscripts</code></td>
<td><code>*.log</code>처럼 여러 항목 매칭 시 postrotate를 한 번만 호출</td>
</tr>
</tbody></table>
<p><code>sharedscripts</code>가 없으면 access.log 회전 후 한 번, error.log 회전 후 또 한 번 reload가 중복 호출된다.</p>
<h4 id="2devnull-이란"><code>2&gt;/dev/null</code> 이란?</h4>
<p>리눅스의 모든 프로세스는 세 가지 기본 스트림을 가진다.</p>
<table>
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>0</code></td>
<td>stdin</td>
<td>입력</td>
</tr>
<tr>
<td><code>1</code></td>
<td>stdout</td>
<td>일반 출력</td>
</tr>
<tr>
<td><code>2</code></td>
<td>stderr</td>
<td><strong>에러 출력</strong></td>
</tr>
</tbody></table>
<p>즉 <code>2&gt;/dev/null</code>은 stderr(에러 메시지)를 <code>/dev/null</code>(쓰레기통)로 버리라는 의미다.</p>
<pre><code class="language-bash">systemctl reload gunicorn 2&gt;/dev/null || true
#                          ↑              ↑
#             에러 메시지 무시    실패해도 종료 코드 0으로 처리</code></pre>
<p>gunicorn이 내려가 있거나 reload 자체가 실패해도, logrotate 전체가 오류로 처리되지 않도록 하는 방어 코드다.</p>
<h3 id="3-7-네이밍">3-7. 네이밍</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>dateext</code></td>
<td><code>access.log.1</code> 대신 <code>access.log-20260304</code> 형식으로 날짜 기반 이름 생성</td>
</tr>
<tr>
<td><code>dateformat -%Y%m%d-%s</code></td>
<td>날짜 포맷 커스터마이징</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-logrotate-설정-작성">4. Logrotate 설정 작성</h2>
<h3 id="4-1-설정-파일-생성">4-1. 설정 파일 생성</h3>
<p><code>/etc/logrotate.d/</code> 아래에 서비스명으로 생성한다.
파일 이름은 서비스명과 일치할 필요 없다. 중요한 건 안에 적는 경로다.</p>
<pre><code class="language-bash">sudo nano /etc/logrotate.d/ssc_api</code></pre>
<h3 id="4-2-내용">4-2. 내용</h3>
<pre><code>/var/log/ssc_api/*.log {
    daily
    missingok
    rotate 14
    maxsize 100M
    compress
    delaycompress
    notifempty
    create 0644 ubuntu www-data
    dateext
    sharedscripts
    postrotate
        systemctl reload gunicorn 2&gt;/dev/null || true
        /usr/local/bin/archive_logs.sh &gt;&gt; /var/log/archive_logs.log 2&gt;&amp;1
    endscript
}</code></pre><h3 id="4-3-검증-및-실행">4-3. 검증 및 실행</h3>
<pre><code class="language-bash"># 문법 검증 (dry-run, 실제 동작 없음)
sudo logrotate -d /etc/logrotate.d/ssc_api

# 강제 실행 + verbose
sudo logrotate -vf /etc/logrotate.d/ssc_api

# 결과 확인
ls -lh /var/log/ssc_api/</code></pre>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>-d</code></td>
<td>dry-run. 실제 동작 없이 문제만 점검</td>
</tr>
<tr>
<td><code>-v</code></td>
<td>verbose. 과정을 출력하며 실행</td>
</tr>
<tr>
<td><code>-f</code></td>
<td>force. 주기 무관하게 강제 회전</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>logrotate는 데몬이 아니므로 설정 수정 후 별도 재시작이 필요 없다.</strong>
cron이 매일 호출하는 구조이기 때문에, 저장 즉시 다음 실행부터 반영된다.</p>
</blockquote>
<h4 id="자주-보이는-메시지">자주 보이는 메시지</h4>
<pre><code>glob finding logs to compress failed
glob finding old rotated logs failed</code></pre><p>에러가 아니다. &quot;이전에 회전된 항목을 찾으려 했는데 아직 없다&quot;는 정보성 메시지로, 최초 실행 시 정상적으로 출력된다.</p>
<hr>
<h2 id="5-아카이브-스크립트-설계">5. 아카이브 스크립트 설계</h2>
<h3 id="5-1-전체-구조">5-1. 전체 구조</h3>
<pre><code>/var/log/ssc_api/              ← 현재 활성 로그 (그대로 유지)
    gunicorn_access.log
    gunicorn_error.log
    gunicorn_error.log-20260304  ← delaycompress 대기 중 (미압축)

/mnt/data/logs/ssc_api/        ← 압축 완료된 항목 아카이브
    2026/
        03/
            gunicorn_access.log-20260301.gz
            gunicorn_error.log-20260304.gz
        04/
            ...</code></pre><h3 id="5-2-디렉토리-준비">5-2. 디렉토리 준비</h3>
<pre><code class="language-bash">sudo mkdir -p /mnt/data/logs/ssc_api
sudo chown ubuntu:ubuntu /mnt/data/logs/ssc_api</code></pre>
<h3 id="5-3-스크립트-작성">5-3. 스크립트 작성</h3>
<pre><code class="language-bash">sudo nano /usr/local/bin/archive_logs.sh</code></pre>
<pre><code class="language-bash">#!/bin/bash

SRC=&quot;/var/log/ssc_api&quot;
DEST=&quot;/mnt/data/logs/ssc_api&quot;

# .gz 항목만 대상 (delaycompress로 압축 완료된 것만)
for f in &quot;$SRC&quot;/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.gz; do
    [ -f &quot;$f&quot; ] || continue

    filename=$(basename &quot;$f&quot;)

    # 파일명에서 날짜 추출 (예: gunicorn_access.log-20260304.gz → 20260304)
    datestr=$(echo &quot;$filename&quot; | grep -oP &#39;\d{8}&#39;)
    [ -z &quot;$datestr&quot; ] &amp;&amp; continue

    year=&quot;${datestr:0:4}&quot;
    month=&quot;${datestr:4:2}&quot;

    target_dir=&quot;$DEST/$year/$month&quot;
    mkdir -p &quot;$target_dir&quot;

    mv &quot;$f&quot; &quot;$target_dir/&quot;
    echo &quot;[$(date)] Archived: $filename → $target_dir/&quot;
done</code></pre>
<pre><code class="language-bash">sudo chmod +x /usr/local/bin/archive_logs.sh</code></pre>
<h3 id="5-4-⚠️-delaycompress와의-충돌-주의">5-4. ⚠️ delaycompress와의 충돌 주의</h3>
<p><code>delaycompress</code>는 직전 회전본을 하루 동안 압축하지 않은 채 둔다.
glob 패턴을 <code>.gz</code> 없이 작성하면 미압축 항목도 매칭되어 <code>/mnt/data</code>로 이동해버린다.
다음 날 logrotate가 압축 대상을 찾지 못해 <strong>아카이브에 비압축 항목이 쌓이는 결과</strong>로 이어진다.</p>
<pre><code class="language-bash"># ❌ 잘못된 패턴 (미압축도 매칭됨)
&quot;$SRC&quot;/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*

# ✅ 올바른 패턴 (압축 완료본만 이동)
&quot;$SRC&quot;/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.gz</code></pre>
<h3 id="5-5-전체-동작-흐름-검증">5-5. 전체 동작 흐름 검증</h3>
<pre><code>Day 1 logrotate 실행:
  - access.log → access.log-20260303 (압축 안 됨, delaycompress)
  - postrotate: archive_logs.sh → .gz 없으므로 아무것도 안 함 ✅

Day 2 logrotate 실행:
  - access.log-20260303 → access.log-20260303.gz (압축 완료)
  - access.log → access.log-20260304 (새로 회전)
  - postrotate: archive_logs.sh → access.log-20260303.gz를 /mnt/data/2026/03/ 으로 이동 ✅

/var/log/ssc_api/ 에는:
  - gunicorn_access.log          (현재 활성)
  - gunicorn_access.log-20260304 (어제, 아직 압축 전)
  → 깔끔하게 유지 ✅</code></pre><h3 id="5-6-cron-등록-보조-보험용">5-6. cron 등록 (보조 보험용)</h3>
<p>수동 생성 항목 등 누락 케이스를 대비한 추가 트리거다.</p>
<pre><code class="language-bash">sudo crontab -e</code></pre>
<pre><code># 매일 새벽 2시 실행
0 2 * * * /usr/local/bin/archive_logs.sh &gt;&gt; /var/log/archive_logs.log 2&gt;&amp;1</code></pre><p>이미 이동된 항목은 glob 매칭에서 걸리지 않으므로 중복 실행해도 무방하다.</p>
<hr>
<h2 id="6-디스크-사용량-계산">6. 디스크 사용량 계산</h2>
<h3 id="6-1-일일-생성량-파악">6-1. 일일 생성량 파악</h3>
<pre><code>gunicorn_access.log   2.6GB / 5일 → 약 520MB/day
gunicorn_error.log    582MB / 5일 → 약 116MB/day</code></pre><h3 id="6-2-회전-빈도-계산">6-2. 회전 빈도 계산</h3>
<p><code>maxsize 100M</code> 적용 시:</p>
<ul>
<li>access: 520MB ÷ 100MB = 하루 약 <strong>5회</strong> 회전</li>
<li>error: 116MB ÷ 100MB = 하루 약 <strong>1~2회</strong> 회전</li>
</ul>
<h3 id="6-3-실제-보관-기간">6-3. 실제 보관 기간</h3>
<p><code>rotate 14</code>는 회전된 항목 <strong>14개</strong>를 유지한다는 뜻이다.
하루에 5회 회전한다면 14개는 약 2~3일치에 불과하다.</p>
<table>
<thead>
<tr>
<th>대상</th>
<th>하루 회전 횟수</th>
<th>rotate 14 기준 실제 보관 기간</th>
</tr>
</thead>
<tbody><tr>
<td>access.log</td>
<td>5회</td>
<td>약 2~3일</td>
</tr>
<tr>
<td>error.log</td>
<td>1~2회</td>
<td>약 7~14일</td>
</tr>
</tbody></table>
<p><strong>14일치 보장이 필요하다면:</strong></p>
<pre><code>rotate 70      # 하루 5회 × 14일 = 70개</code></pre><h3 id="6-4-디스크-사용량-추정">6-4. 디스크 사용량 추정</h3>
<p>gzip 압축 시 텍스트 로그는 원본의 약 10~15% 수준이 된다.</p>
<p><strong>access.log 기준 (rotate 14 적용):</strong></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>크기</th>
</tr>
</thead>
<tbody><tr>
<td>현재 활성 (최대)</td>
<td>100MB</td>
</tr>
<tr>
<td>delaycompress 대기 (미압축 1개)</td>
<td>100MB</td>
</tr>
<tr>
<td>압축 완료 12개 (100MB × 0.12 × 12)</td>
<td>약 144MB</td>
</tr>
<tr>
<td><strong>소계</strong></td>
<td><strong>약 344MB</strong></td>
</tr>
</tbody></table>
<p>error.log도 동일 구조로 약 344MB.
<strong>총 합계: 약 700MB</strong> (기존 7GB 대비 1/10 수준)</p>
<p>rotate 70 적용 시 약 <strong>1.2GB</strong> 수준으로 여전히 기존 대비 대폭 절감된다.</p>
<hr>
<h2 id="7-기존-거대-로그-정리">7. 기존 거대 로그 정리</h2>
<p>logrotate 도입 전 이미 쌓인 대용량 항목은 수동으로 처리한다.</p>
<pre><code class="language-bash"># 압축 보관
sudo gzip /var/log/ssc_api/gunicorn_access.log
sudo gzip /var/log/ssc_api/gunicorn_error.log

# 불필요하다면 삭제
sudo rm /var/log/ssc_api/gunicorn_access.log

# 여유 공간 확인
df -h /var/log</code></pre>
<hr>
<h2 id="8-요약">8. 요약</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>작업</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>블록 스토리지 attach 후 ext4 포맷 및 <code>/mnt/data</code> 마운트</td>
</tr>
<tr>
<td>2</td>
<td>fstab에 UUID 기반으로 등록해 자동 마운트 보장</td>
</tr>
<tr>
<td>3</td>
<td>gunicorn의 <code>--access-logfile -</code>를 실제 경로로 변경</td>
</tr>
<tr>
<td>4</td>
<td><code>/etc/logrotate.d/</code> 아래에 설정 작성</td>
</tr>
<tr>
<td>5</td>
<td><code>archive_logs.sh</code> 작성 시 glob 패턴을 <code>.gz</code>로 한정 (delaycompress 충돌 방지)</td>
</tr>
<tr>
<td>6</td>
<td>postrotate에서 archive_logs.sh 호출, cron으로 보조 트리거 등록</td>
</tr>
<tr>
<td>7</td>
<td><code>sudo logrotate -d</code>로 문법 점검 → <code>sudo logrotate -vf</code>로 강제 실행 확인</td>
</tr>
<tr>
<td>8</td>
<td>로그 생성량에 따라 <code>rotate</code> 값 조정으로 원하는 보관 기간 확보</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[prometheus & grafana]]></title>
            <link>https://velog.io/@jeong_woo/%ED%94%84%EB%A1%9C%EB%A9%94%ED%85%8C%EC%9A%B0%EC%8A%A4</link>
            <guid>https://velog.io/@jeong_woo/%ED%94%84%EB%A1%9C%EB%A9%94%ED%85%8C%EC%9A%B0%EC%8A%A4</guid>
            <pubDate>Thu, 26 Mar 2026 01:51:00 GMT</pubDate>
            <description><![CDATA[<h1 id="nginx-rtmp-미디어-서버-모니터링-구축기-prometheus--grafana">nginx-rtmp 미디어 서버 모니터링 구축기 (Prometheus + Grafana)</h1>
<blockquote>
<p>libnginx-mod-rtmp + ffmpeg 기반 HLS 미디어 서버에 Prometheus와 Grafana를 붙여 생사 여부를 실시간으로 파악하는 대시보드를 구축한 과정을 기록한다.</p>
</blockquote>
<hr>
<h2 id="아키텍처-개요">아키텍처 개요</h2>
<pre><code>nginx-rtmp (/stat) ──→ Python Exporter (9101)  ─┐
node_exporter (9100)                              ├──→ Prometheus (9090) ──→ Grafana (3000)
ffmpeg 프로세스 상태                              ─┘</code></pre><p>세 가지 수집 레이어로 구성된다.</p>
<ul>
<li><strong>node_exporter</strong> : 서버 CPU/메모리/디스크/네트워크 등 시스템 지표</li>
<li><strong>커스텀 Python exporter</strong> : nginx-rtmp <code>/stat</code> XML을 파싱해 스트림 상태를 Prometheus 포맷으로 변환</li>
<li><strong>Prometheus</strong> : 위 두 exporter를 주기적으로 스크랩해 시계열 DB에 저장</li>
<li><strong>Grafana</strong> : Prometheus를 데이터소스로 연결해 대시보드 시각화</li>
</ul>
<hr>
<h2 id="1단계--prometheus-설치">1단계 — Prometheus 설치</h2>
<h3 id="사용자-및-디렉토리-준비">사용자 및 디렉토리 준비</h3>
<pre><code class="language-bash">sudo useradd --no-create-home --shell /bin/false prometheus

sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /etc/prometheus /var/lib/prometheus</code></pre>
<h3 id="바이너리-다운로드">바이너리 다운로드</h3>
<pre><code class="language-bash">cd /tmp
wget https://github.com/prometheus/prometheus/releases/download/v3.4.1/prometheus-3.4.1.linux-amd64.tar.gz
tar xvf prometheus-3.4.1.linux-amd64.tar.gz
cd prometheus-3.4.1.linux-amd64

sudo cp prometheus promtool /usr/local/bin/
sudo chown prometheus:prometheus /usr/local/bin/prometheus /usr/local/bin/promtool</code></pre>
<p>console 패키지는 3 버전에서 더이상 제공되지 않습니다.
왜냐하면 관리, 유지보수가 어렵고 그냥 모든 사람들도 grafana 와 같은 third-party library 와 함께 사용하는 게 국룰이 되어버렸기 때문에 tar 로 압축을 풀어봐도 console 패키지가 없으니 스킵!</p>
<h3 id="prometheusyml-작성">prometheus.yml 작성</h3>
<pre><code class="language-bash">sudo nano /etc/prometheus/prometheus.yml</code></pre>
<pre><code class="language-yaml">global:
  scrape_interval:     15s
  evaluation_interval: 15s
  scrape_timeout:      15s

scrape_configs:
  - job_name: &#39;prometheus&#39;
    static_configs:
      - targets: [&#39;localhost:9090&#39;]

  - job_name: &#39;node&#39;
    static_configs:
      - targets: [&#39;localhost:9100&#39;]

  - job_name: &#39;nginx_rtmp&#39;
    static_configs:
      - targets: [&#39;localhost:9101&#39;]
    scrape_interval: 10s</code></pre>
<pre><code class="language-bash">sudo chown prometheus:prometheus /etc/prometheus/prometheus.yml</code></pre>
<h3 id="systemd-서비스-등록">systemd 서비스 등록</h3>
<pre><code class="language-bash">sudo nano /etc/systemd/system/prometheus.service</code></pre>
<pre><code class="language-ini">[Unit]
Description=Prometheus Monitoring
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/usr/local/bin/prometheus \
    --config.file=/etc/prometheus/prometheus.yml \
    --storage.tsdb.path=/var/lib/prometheus/ \
    --storage.tsdb.retention.time=30d \
    --web.console.templates=/etc/prometheus/consoles \
    --web.console.libraries=/etc/prometheus/console_libraries \
    --web.listen-address=0.0.0.0:9090 \
    --web.enable-lifecycle

[Install]
WantedBy=multi-user.target</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now prometheus</code></pre>
<p><code>--web.enable-lifecycle</code> 플래그를 추가해두면 재시작 없이 설정 리로드가 가능하다.</p>
<pre><code class="language-bash"># 설정 변경 후 무중단 리로드
curl -X POST http://localhost:9090/-/reload</code></pre>
<hr>
<h2 id="2단계--node_exporter-설치">2단계 — node_exporter 설치</h2>
<pre><code class="language-bash">cd /tmp
wget https://github.com/prometheus/node_exporter/releases/download/v1.9.0/node_exporter-1.9.0.linux-amd64.tar.gz
tar xvf node_exporter-1.9.0.linux-amd64.tar.gz

sudo useradd --no-create-home --shell /bin/false node_exporter
sudo install -m 755 node_exporter-1.9.0.linux-amd64/node_exporter /usr/local/bin/node_exporter
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter</code></pre>
<blockquote>
<p><strong>Tip.</strong> 기존에 node_exporter가 실행 중인 상태에서 <code>cp</code>로 덮어쓰려 하면 <code>Text file busy</code> 오류가 발생한다. 이때는 <code>install</code> 명령을 쓰거나 서비스를 먼저 중단한 뒤 복사한다.</p>
<pre><code class="language-bash"># 방법 1: install 명령 (서비스 중단 불필요)
sudo install -m 755 node_exporter /usr/local/bin/node_exporter

# 방법 2: 서비스 중단 후 복사
sudo systemctl stop node_exporter
sudo cp node_exporter /usr/local/bin/
sudo systemctl start node_exporter</code></pre>
</blockquote>
<pre><code class="language-bash">sudo nano /etc/systemd/system/node_exporter.service</code></pre>
<pre><code class="language-ini">[Unit]
Description=Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter \
    --collector.systemd \
    --collector.processes

[Install]
WantedBy=multi-user.target</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter</code></pre>
<hr>
<h2 id="3단계--nginx-rtmp-커스텀-exporter">3단계 — nginx-rtmp 커스텀 Exporter</h2>
<p>nginx-rtmp의 <code>/stat</code> XML 엔드포인트를 파싱해 Prometheus 포맷으로 9101번 포트에 노출하는 Python 스크립트다.</p>
<h3 id="패키지-설치">패키지 설치</h3>
<pre><code class="language-bash"># apt로 시스템 전역 설치 (pip install은 systemd 서비스에서 인식 못함)
sudo apt install -y python3-lxml python3-requests
sudo apt install -y python3-prometheus-client
# apt에 없다면:
# sudo pip3 install prometheus_client --break-system-packages</code></pre>
<blockquote>
<p><code>pip3 install</code>을 일반 사용자 권한으로 실행하면 시스템 Python에 반영되지 않는다. systemd 서비스는 시스템 Python을 바라보므로 반드시 <code>apt</code> 또는 <code>sudo pip3</code>로 설치해야 한다.</p>
</blockquote>
<p>설치 후 한 번에 검증:</p>
<pre><code class="language-bash">python3 -c &quot;import prometheus_client, requests, lxml; print(&#39;모두 OK&#39;)&quot;</code></pre>
<h3 id="exporter-스크립트">exporter 스크립트</h3>
<pre><code class="language-bash">sudo mkdir -p /opt/nginx_rtmp_exporter
sudo nano /opt/nginx_rtmp_exporter/exporter.py</code></pre>
<pre><code class="language-python">#!/usr/bin/env python3
&quot;&quot;&quot;
nginx-rtmp → Prometheus Exporter
포트 9101에서 메트릭 노출
&quot;&quot;&quot;

import time
import requests
import logging
from lxml import etree
from prometheus_client import start_http_server, Gauge

logging.basicConfig(level=logging.INFO, format=&#39;%(asctime)s %(levelname)s %(message)s&#39;)

RTMP_STAT_URL  = &quot;http://localhost:8888/stat&quot;
SCRAPE_INTERVAL = 10

# ── 메트릭 정의 ────────────────────────────────────────────────────────────
rtmp_up = Gauge(&#39;nginx_rtmp_up&#39;, &#39;nginx-rtmp 서버 접근 가능 여부 (1=정상, 0=장애)&#39;)

rtmp_stream_count = Gauge(&#39;nginx_rtmp_stream_count&#39;, &#39;현재 활성 스트림 수&#39;, [&#39;app&#39;])
rtmp_clients      = Gauge(&#39;nginx_rtmp_clients&#39;,      &#39;스트림별 클라이언트 수&#39;, [&#39;app&#39;, &#39;stream&#39;])
rtmp_bw_in        = Gauge(&#39;nginx_rtmp_bw_in_bps&#39;,    &#39;입력 대역폭 (bps)&#39;,     [&#39;app&#39;, &#39;stream&#39;])
rtmp_bw_out       = Gauge(&#39;nginx_rtmp_bw_out_bps&#39;,   &#39;출력 대역폭 (bps)&#39;,     [&#39;app&#39;, &#39;stream&#39;])
rtmp_bytes_in     = Gauge(&#39;nginx_rtmp_bytes_in_total&#39;,&#39;누적 수신 바이트&#39;,      [&#39;app&#39;, &#39;stream&#39;])
rtmp_ffmpeg_count = Gauge(&#39;nginx_rtmp_ffmpeg_processes&#39;, &#39;실행 중인 ffmpeg 프로세스 수&#39;)
hls_segment_count = Gauge(&#39;nginx_rtmp_hls_segment_count&#39;, &#39;HLS .ts 세그먼트 파일 수&#39;, [&#39;stream&#39;])

# ── 수집 함수 ──────────────────────────────────────────────────────────────
def count_ffmpeg_processes() -&gt; int:
    import subprocess
    try:
        r = subprocess.run([&#39;pgrep&#39;, &#39;-c&#39;, &#39;ffmpeg&#39;], capture_output=True, text=True)
        return int(r.stdout.strip()) if r.returncode == 0 else 0
    except Exception:
        return 0

def count_hls_segments(hls_root: str = &#39;/var/hls&#39;) -&gt; dict:
    import os
    counts = {}
    try:
        for entry in os.scandir(hls_root):
            if entry.is_dir():
                counts[entry.name] = len([f for f in os.listdir(entry.path) if f.endswith(&#39;.ts&#39;)])
    except FileNotFoundError:
        pass
    return counts

def collect():
    try:
        resp = requests.get(RTMP_STAT_URL, timeout=5)
        resp.raise_for_status()
        rtmp_up.set(1)
    except Exception as e:
        logging.warning(f&quot;stat 엔드포인트 접근 실패: {e}&quot;)
        rtmp_up.set(0)
        return

    try:
        root = etree.fromstring(resp.content)
        for server in root.findall(&#39;.//server&#39;):
            for app in server.findall(&#39;application&#39;):
                app_name = app.findtext(&#39;name&#39;, default=&#39;unknown&#39;)
                live = app.find(&#39;live&#39;)
                if live is None:
                    continue
                streams = live.findall(&#39;stream&#39;)
                rtmp_stream_count.labels(app=app_name).set(len(streams))
                for stream in streams:
                    name   = stream.findtext(&#39;name&#39;, default=&#39;unknown&#39;)
                    rtmp_clients.labels(app=app_name, stream=name).set(len(stream.findall(&#39;.//client&#39;)))
                    rtmp_bw_in.labels(app=app_name,   stream=name).set(int(stream.findtext(&#39;bw_in&#39;,    &#39;0&#39;)))
                    rtmp_bw_out.labels(app=app_name,  stream=name).set(int(stream.findtext(&#39;bw_out&#39;,   &#39;0&#39;)))
                    rtmp_bytes_in.labels(app=app_name,stream=name).set(int(stream.findtext(&#39;bytes_in&#39;, &#39;0&#39;)))
    except Exception as e:
        logging.error(f&quot;XML 파싱 오류: {e}&quot;)

    rtmp_ffmpeg_count.set(count_ffmpeg_processes())
    for sname, cnt in count_hls_segments().items():
        hls_segment_count.labels(stream=sname).set(cnt)

if __name__ == &#39;__main__&#39;:
    start_http_server(9101)
    logging.info(&quot;nginx-rtmp exporter 가동 — :9101&quot;)
    while True:
        collect()
        time.sleep(SCRAPE_INTERVAL)</code></pre>
<h3 id="systemd-서비스-등록-1">systemd 서비스 등록</h3>
<pre><code class="language-bash">sudo nano /etc/systemd/system/nginx_rtmp_exporter.service</code></pre>
<pre><code class="language-ini">[Unit]
Description=nginx-rtmp Prometheus Exporter
After=network.target

[Service]
User=prometheus
ExecStart=/usr/bin/python3 /opt/nginx_rtmp_exporter/exporter.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now nginx_rtmp_exporter

# 정상 동작 확인
curl http://localhost:9101/metrics | grep nginx_rtmp</code></pre>
<hr>
<h2 id="4단계--수집-타겟-확인">4단계 — 수집 타겟 확인</h2>
<p>설정 변경 후에는 반드시 리로드하고 타겟 상태를 점검한다.</p>
<pre><code class="language-bash">curl -X POST http://localhost:9090/-/reload

sleep 30 &amp;&amp; curl -s http://localhost:9090/api/v1/targets \
  | python3 -m json.tool \
  | grep -E &#39;&quot;job&quot;|&quot;health&quot;|&quot;lastError&quot;&#39;</code></pre>
<p>세 job 모두 <code>&quot;health&quot;: &quot;up&quot;</code> 이어야 정상이다.</p>
<pre><code class="language-json">&quot;job&quot;: &quot;nginx_rtmp&quot;  →  &quot;health&quot;: &quot;up&quot;
&quot;job&quot;: &quot;node&quot;        →  &quot;health&quot;: &quot;up&quot;
&quot;job&quot;: &quot;prometheus&quot;  →  &quot;health&quot;: &quot;up&quot;</code></pre>
<blockquote>
<p><strong>주의.</strong> Prometheus를 재시작하지 않고 <code>prometheus.yml</code>만 수정하면 변경 사항이 반영되지 않는다. <code>--web.enable-lifecycle</code> 플래그가 있다면 위처럼 reload API를 활용하면 되고, 없다면 <code>sudo systemctl restart prometheus</code>로 재시작해야 한다.</p>
</blockquote>
<hr>
<h2 id="5단계--grafana-설치">5단계 — Grafana 설치</h2>
<pre><code class="language-bash">sudo apt install -y apt-transport-https software-properties-common
wget -q -O - https://apt.grafana.com/gpg.key \
  | sudo gpg --dearmor -o /usr/share/keyrings/grafana.gpg
echo &quot;deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main&quot; \
  | sudo tee /etc/apt/sources.list.d/grafana.list

sudo apt update &amp;&amp; sudo apt install grafana -y
sudo systemctl enable --now grafana-server</code></pre>
<hr>
<h2 id="6단계--nginx-리버스-프록시--리다이렉트-루프-해결">6단계 — nginx 리버스 프록시 + 리다이렉트 루프 해결</h2>
<p>서브경로(<code>/grafana/</code>)로 Grafana를 서빙하려면 <strong>nginx 설정</strong>과 <strong>grafana.ini</strong> 두 곳을 모두 올바르게 맞춰야 한다. 한쪽이라도 어긋나면 301 무한 루프가 발생한다.</p>
<h3 id="자주-하는-실수들">자주 하는 실수들</h3>
<table>
<thead>
<tr>
<th>실수</th>
<th>증상</th>
</tr>
</thead>
<tbody><tr>
<td><code>server_name</code>을 <code>your-domain.com</code> 플레이스홀더로 방치</td>
<td>요청이 엉뚱한 서버 블록으로 라우팅됨</td>
</tr>
<tr>
<td>기존 도메인 블록과 별도 server 블록 중복 생성</td>
<td>블록 충돌로 예측 불가 동작</td>
</tr>
<tr>
<td><code>proxy_pass http://localhost:3000/</code> (끝에 <code>/</code>)</td>
<td>prefix <code>/grafana/</code>가 제거되어 Grafana가 <code>/grafana/</code>로 redirect → 루프</td>
</tr>
<tr>
<td><code>grafana.ini</code>의 <code>domain</code>, <code>root_url</code>, <code>serve_from_sub_path</code> 주석 상태</td>
<td>Grafana가 <code>localhost</code>로 redirect</td>
</tr>
</tbody></table>
<h3 id="nginx--기존-서버-블록에-location-추가">nginx — 기존 서버 블록에 location 추가</h3>
<p>별도 conf 파일을 만들지 말고, 해당 도메인의 기존 블록에 아래 location을 추가한다.</p>
<pre><code class="language-nginx">http {

        ##
        # Basic Settings
        ##

        ...

        server {
                listen 8888;
                allow 127.0.0.1;
                deny all;

                location /stat {
                        rtmp_stat all;
                        rtmp_stat_stylesheet stat.xsl;
                }
        }

}
</code></pre>
<pre><code class="language-nginx">location /grafana/ {
    proxy_pass            http://localhost:3000;   # 끝 슬래시 없음!
    proxy_set_header      Host $host;
    proxy_set_header      X-Real-IP $remote_addr;
    proxy_set_header      X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header      X-Forwarded-Proto https;  # $scheme 대신 하드코딩
    proxy_redirect        off;
}</code></pre>
<blockquote>
<p><code>proxy_pass http://localhost:3000/</code> (슬래시 있음)으로 설정하면 <code>/grafana/login</code> 요청이 <code>http://localhost:3000/login</code>으로 전달된다. Grafana는 <code>/login</code>을 받으면 <code>/grafana/login</code>으로 301을 내리고, nginx가 다시 <code>/login</code>으로 벗겨서 보내는 무한 루프가 생긴다. 슬래시를 빼면 <code>/grafana/login</code> 그대로 <code>http://localhost:3000/grafana/login</code>으로 전달된다.</p>
</blockquote>
<h3 id="grafanaini">grafana.ini</h3>
<pre><code class="language-bash">sudo nano /etc/grafana/grafana.ini</code></pre>
<pre><code class="language-ini">[server]
protocol            = http
domain              = your-domain.com          # 실제 도메인
root_url            = https://your-domain.com/grafana/
serve_from_sub_path = true</code></pre>
<p>주석 기호(<code>;</code>)를 반드시 제거해야 적용된다.</p>
<h3 id="적용">적용</h3>
<pre><code class="language-bash">sudo nginx -t &amp;&amp; sudo systemctl reload nginx
sudo systemctl restart grafana-server</code></pre>
<p>브라우저에서 <code>https://your-domain.com/grafana/</code> 접속 후 초기 계정 <code>admin / admin</code>으로 로그인.</p>
<hr>
<h2 id="7단계--prometheus-데이터소스-연결">7단계 — Prometheus 데이터소스 연결</h2>
<p>Grafana UI에서 <strong>Connections → Data Sources → Add → Prometheus</strong> 선택 후:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>URL</td>
<td><code>http://localhost:9090</code></td>
</tr>
<tr>
<td>Access</td>
<td>Server (default)</td>
</tr>
</tbody></table>
<hr>
<h2 id="8단계--대시보드-주요-promql">8단계 — 대시보드 주요 PromQL</h2>
<pre><code class="language-promql"># 서버 생존 여부
nginx_rtmp_up

# 활성 스트림 수
nginx_rtmp_stream_count{app=&quot;live&quot;}

# 전체 시청자 합계
sum(nginx_rtmp_clients)

# 입력 비트레이트 (Mbps)
sum(nginx_rtmp_bw_in_bps) / 1000000

# ffmpeg 프로세스 수
nginx_rtmp_ffmpeg_processes

# CPU 사용률 (%)
100 - (avg by(instance)(rate(node_cpu_seconds_total{mode=&quot;idle&quot;}[1m])) * 100)

# 메모리 사용률 (%)
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100

# 네트워크 수신 (Mbps)
rate(node_network_receive_bytes_total{device!=&quot;lo&quot;}[1m]) * 8 / 1000000</code></pre>
<hr>
<h2 id="포트-정리-및-보안-그룹-권장-설정">포트 정리 및 보안 그룹 권장 설정</h2>
<table>
<thead>
<tr>
<th>포트</th>
<th>용도</th>
<th>외부 오픈</th>
</tr>
</thead>
<tbody><tr>
<td>9090</td>
<td>Prometheus</td>
<td>❌ 내부 전용</td>
</tr>
<tr>
<td>9100</td>
<td>node_exporter</td>
<td>❌ 내부 전용</td>
</tr>
<tr>
<td>9101</td>
<td>rtmp exporter</td>
<td>❌ 내부 전용</td>
</tr>
<tr>
<td>3000</td>
<td>Grafana</td>
<td>✅ nginx 프록시 경유</td>
</tr>
<tr>
<td>80 / 443</td>
<td>nginx</td>
<td>✅</td>
</tr>
</tbody></table>
<p>Prometheus와 각 exporter는 외부에 노출할 이유가 없다. NHN Cloud 등 클라우드 보안 그룹에서 9090/9100/9101은 차단하고, Grafana는 nginx 리버스 프록시를 통해서만 접근하도록 구성하는 것을 권장한다.</p>
<hr>
<h2 id="트러블슈팅-요약">트러블슈팅 요약</h2>
<h3 id="exporter가-9101에서-응답하지-않음">exporter가 9101에서 응답하지 않음</h3>
<pre><code class="language-bash">sudo journalctl -u nginx_rtmp_exporter -n 30</code></pre>
<p><code>ModuleNotFoundError: No module named &#39;lxml&#39;</code> → <code>sudo apt install -y python3-lxml python3-requests python3-prometheus-client</code></p>
<h3 id="prometheus-타겟이-1개뿐">Prometheus 타겟이 1개뿐</h3>
<p>설정 파일 수정 후 reload를 빠뜨린 경우.</p>
<pre><code class="language-bash">curl -X POST http://localhost:9090/-/reload</code></pre>
<h3 id="grafana-접속-시-500-에러">Grafana 접속 시 500 에러</h3>
<p>nginx 에러 로그와 <code>curl -sIL</code> 리다이렉트 체인을 먼저 확인한다.</p>
<pre><code class="language-bash">sudo tail -50 /var/log/nginx/error.log
curl -sIL --max-redirs 5 https://your-domain.com/grafana/</code></pre>
<h3 id="301-무한-루프">301 무한 루프</h3>
<ul>
<li><code>proxy_pass</code> 끝 슬래시 제거 (<code>http://localhost:3000</code> ← 슬래시 없음)</li>
<li><code>grafana.ini</code>의 <code>domain</code>, <code>root_url</code>, <code>serve_from_sub_path</code> 주석 해제 및 올바른 값 입력</li>
<li><code>proxy_set_header X-Forwarded-Proto https</code> 하드코딩 (변수 대신)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[clawdbot(moltbot), telegram 연결 및 service 등록]]></title>
            <link>https://velog.io/@jeong_woo/clawdbotmoltbot-telegram-%EC%97%B0%EA%B2%B0-%EB%B0%8F-service-%EB%93%B1%EB%A1%9D</link>
            <guid>https://velog.io/@jeong_woo/clawdbotmoltbot-telegram-%EC%97%B0%EA%B2%B0-%EB%B0%8F-service-%EB%93%B1%EB%A1%9D</guid>
            <pubDate>Thu, 29 Jan 2026 11:46:19 GMT</pubDate>
            <description><![CDATA[<h1 id="clawdbotmoltbot-telegram-연결">clawdbot(moltbot) telegram 연결</h1>
<h2 id="1-telegram-가입">1. telegram 가입</h2>
<p>현재로선 구글링한 어떤 방법으로도 2천원을 내지 않고 telegram 에 신규 가입할 수 있는 방법은 없습니다.
우선 2천원을 내고 신규 가입을 합니다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/ed2837cf-43d4-451d-83d5-399ee7ea5298/image.png" alt=""></p>
<p>그리고 오른쪽 위에 검색을 눌러 <code>BotFather</code> 를 누릅니다.
이때, 굉장히 다양한 가짜 계정이 있으니 반드시 blue check 을 확인합니다.</p>
<h2 id="2-bot-생성">2. bot 생성</h2>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/5a549cbc-3ea4-4156-9776-54aeee3e5ba2/image.png" alt=""></p>
<p>굉장히 다양한 메뉴가 있는데 <code>/newbot</code> 클릭 </p>
<p>이름을 정해합니다.
처음 정하는 이름은 상태창에 뜨는 이름이고 두번째 정하는 이름은 bot 자체의 이름입니다.
이때, 만약 본인이 자비스를 만들고 싶다면 </p>
<ol>
<li>Javis </li>
<li>Jav13_bot 등 으로 지으면 됩니다. (뒤에는 bot 으로 끝나야하고 id 처럼 이미 선점된 bot 이름은 설정 불가)
<img src="https://velog.velcdn.com/images/jeong_woo/post/3ac81317-edd6-4c20-9ce2-74518cbbef5f/image.png" alt=""></li>
</ol>
<p>이때, 나온 HTTP API 키를 잘 보관 
형식은 <code>숫자:문자열</code> 형식.</p>
<h2 id="3-clawd-bot-연결">3. clawd bot 연결</h2>
<p>clawdbot 에 연결할 때 따로 json 에서 설정을 잡아줘도 되지만 <code>clawdbot dashboard</code> 로 간편하게 해당 부분만 설정해도 됩니다.
이때, 저장해준 <code>숫자:문자열</code> 형식의 token 을 입력 후 상단의 본인 bot 을 클릭하면 대화창이 뜨게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/b00349f0-1bfb-412a-914e-9ae4eb1afdd5/image.png" alt=""></p>
<p>그리고 여기서 <code>user id</code> 를 저장! (Pairing code 아님)
이 부분을 web UI 에서 <code>Allow From</code> 부분을 추가하고 사용하면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/4254b2f7-8c30-49e8-b848-6a30bb3d2517/image.png" alt=""></p>
<p>그리고 말을 걸어보면 잘 연결 된 것을 확인할 수 있읍니다.</p>
<h2 id="4-service-등록">4. service 등록</h2>
<p>window 에서 service 등록 방법엔 여러가지가 있는데 가장 편리한 것은 WinSW 가 편리할 듯 합니다.
예전에 window 서버에 .jar 말아서 올리던 <a href="https://velog.io/@jeong_woo/winsw-log-%EC%84%9C%EB%B9%84%EC%8A%A4">기억</a>이 있어서 저에게 가장 편한 WinSW 로 서비스화 하였습니다.
이제 pc 를 재부팅 해도 자연스럽게 telegram 으로 연결이 됩니다.</p>
<h2 id="5-moltbot-vs-claud-desktop">5. moltbot VS Claud desktop</h2>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/dad17ab5-68bb-48df-8ee3-a0c76a756ead/image.png" alt=""></p>
<h4 id="moltbot이-더-적합한-경우">Moltbot이 더 적합한 경우</h4>
<ol>
<li>local 에서 완전한 자동화 권한(shell, file, browser, cron)을 주고 AI가 “백그라운드에서 알아서 하게” 하고 싶을 때.</li>
<li>claude 외에 GPT, 로컬 모델을 섞어서 쓰고 싶거나, 자체 스킬/플러그인을 만들어 붙이고 싶은 경우.</li>
</ol>
<h4 id="claude-desktop이-더-적합한-경우">Claude Desktop이 더 적합한 경우</h4>
<ol>
<li>claude 계정 기반 워크플로우(project, code, cowork)를 데스크톱에서 안정적으로 쓰고 싶은 경우.</li>
<li>미친 장점 OAuth 방식이라 구독료 이외의 지출 X</li>
</ol>
<h1 id="사설">사설</h1>
<p>moltbot 에 claude 를 붙이기는 너무 사치고 qwen 같은 가성비 모델을 붙여서 막 굴리는 게 좋지 않을까
물론 정보는 중국에 넘어갈지도..?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA['moltbot'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
배치 파일이 아닙니다.]]></title>
            <link>https://velog.io/@jeong_woo/moltbot%EC%9D%80%EB%8A%94-%EB%82%B4%EB%B6%80-%EB%98%90%EB%8A%94-%EC%99%B8%EB%B6%80-%EB%AA%85%EB%A0%B9-%EC%8B%A4%ED%96%89%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%98%90%EB%8A%94%EB%B0%B0%EC%B9%98-%ED%8C%8C%EC%9D%BC%EC%9D%B4-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@jeong_woo/moltbot%EC%9D%80%EB%8A%94-%EB%82%B4%EB%B6%80-%EB%98%90%EB%8A%94-%EC%99%B8%EB%B6%80-%EB%AA%85%EB%A0%B9-%EC%8B%A4%ED%96%89%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%98%90%EB%8A%94%EB%B0%B0%EC%B9%98-%ED%8C%8C%EC%9D%BC%EC%9D%B4-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Thu, 29 Jan 2026 06:43:48 GMT</pubDate>
            <description><![CDATA[<h1 id="moltbot은는-내부-또는-외부-명령-실행할-수-있는-프로그램-또는-배치-파일이-아닙니다">&#39;moltbot&#39;은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.</h1>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/52f0e39f-4a92-4002-9391-0db2500cb227/image.png" alt=""></p>
<p>window 에서는 현재까지 moltbot 이 아닌, clawdbot 으로 써야합니다.</p>
<h2 id="disconnected-1008-unauthorized-gateway-token-missing-open-a-tokenized-dashboard-url-or-paste-token-in-control-ui-settings">disconnected (1008): unauthorized: gateway token missing (open a tokenized dashboard URL or paste token in Control UI settings)</h2>
<p>web ui 에서 다음과 같이 뜬다면 아래 명령어를 입력하면 됨.</p>
<p><a href="https://www.answeroverflow.com/m/1465993262190956688">https://www.answeroverflow.com/m/1465993262190956688</a></p>
<pre><code class="language-sh">clawdbot dashboard</code></pre>
<h2 id="claudebot-onboard">claudebot onboard</h2>
<p>위 <code>help</code> 의 wizard settup 에서 볼 수 있듯, 혹시 telegram 2천원 때문에 <code>@clawdbot/line</code> 으로 시도하려다 설치가 안 돼서 설정이 꼬인 분들은 <code>clawd onboard</code> 로 reset all 을 선택하시면 설정을 처음부터 다시 잡을 수 있습니다.</p>
<p>참고로 npm 에 들어가보니 베트남에서 많이 쓰이는 Zola 가 존재하고 line 은 없더라구요.
심지어 line 은 일본, 베트남 등 동남아에서 많이 쓰는 메신져 앱이라고 소개됨.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[대용량 sql 파일 덤프하기]]></title>
            <link>https://velog.io/@jeong_woo/%EB%8C%80%EC%9A%A9%EB%9F%89-sql-%ED%8C%8C%EC%9D%BC-%EB%8D%A4%ED%94%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/%EB%8C%80%EC%9A%A9%EB%9F%89-sql-%ED%8C%8C%EC%9D%BC-%EB%8D%A4%ED%94%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 22 Oct 2025 02:18:43 GMT</pubDate>
            <description><![CDATA[<h1 id="대용량-sql-파일-덤프">대용량 sql 파일 덤프</h1>
<p>RDB 중 가장 버그가 안 나고 편리한 DB tool 중 Heidsql 을 사용하고 있다.
문제는 5Mb 가 넘는 대용량 sql 파일을 import 할 때 주로 에러가 나온다.
이때, CMD로 훨씬 빠르게 실행하는 방법이 있다.</p>
<h2 id="1-로컬-pc-에-mysql-서버-설치하기">1. 로컬 pc 에 mysql 서버 설치하기</h2>
<p>본인 pc 환경에 맞는 MSI 를 설치한다. (<a href="https://dev.mysql.com/downloads/mysql/">링크</a>)
<img src="https://velog.velcdn.com/images/jeong_woo/post/46ae78b9-2d55-41fe-99ac-e2541b904381/image.png" alt=""></p>
<h2 id="2-db-서버-세팅">2. DB 서버 세팅</h2>
<ol>
<li>DB 서버에 user 접속 권한이 전체 권한인지 확인<pre><code class="language-bash"># DB 접속 둘 중 하나
sudo mysql -u root -p
mysql -u SmartSilverAdmin -p</code></pre>
</li>
</ol>
<pre><code class="language-sql"># 호스트 확인
SELECT user, host FROM mysql.user;</code></pre>
<p><code>%</code> 설정이 안 되어있다면</p>
<pre><code class="language-sql">ALTER USER &#39;SmartSilverAdmin&#39;@&#39;localhost&#39; IDENTIFIED BY &#39;비밀번호&#39;;
CREATE USER &#39;SmartSilverAdmin&#39;@&#39;%&#39; IDENTIFIED BY &#39;비밀번호&#39;;
GRANT ALL PRIVILEGES ON SmartSilverCenter.* TO &#39;SmartSilverAdmin&#39;@&#39;%&#39;;
FLUSH PRIVILEGES;</code></pre>
<ol start="2">
<li><p>DB 서버에 DB 포트가 뚫려있는지 확인 기본값: 3306</p>
<pre><code class="language-bash">sudo ufw status | grep 3306
sudo ufw allow 3306/tcp
sudo ufw reload</code></pre>
</li>
<li><p>DB 서버에 원격으로 접속 가능한지 .conf 값 설정</p>
<pre><code class="language-bash">sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
bind-address = 0.0.0.0</code></pre>
</li>
<li><p>DB 서버가 설치된 인스턴스에 인그레스 방화벽 확인</p>
</li>
</ol>
<h2 id="3-로컬-pc-에서-mysql-서버-접속하기">3. 로컬 pc 에서 mysql 서버 접속하기</h2>
<ol>
<li><p>이제 로컬 서버에서 mysql 서버의 DB 포트에 붙을 수 있는지 확인</p>
<pre><code class="language-bash">Test-NetConnection -ComputerName [DB서버IP주소] -Port 3306</code></pre>
</li>
<li><p>앞서 설치한 mysql server 위치에서 MySQL 콘솔 접속</p>
<pre><code class="language-bash">C:\Program Files\MySQL\MySQL Server 8.4\bin&gt;mysql -h [DB서버IP주소] -P 3306 -u SmartSilverAdmin -p
# 아래 문구가 뜨면 성공
Enter password:</code></pre>
</li>
</ol>
<p>아니면 시스템 환경변수로 등록해줘도 됨.</p>
<ol start="3">
<li>덤프 파일 바로 복원<pre><code class="language-bash"># exit 으로 나간 후
C:\Program Files\MySQL\MySQL Server 8.4\bin&gt;mysql -h [DB서버IP주소] -P 3306 -u SmartSilverAdmin -p [DB 명] &lt; &quot;C:\경로\대용량.sql&quot;</code></pre>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[RTSP vs RTMP]]></title>
            <link>https://velog.io/@jeong_woo/RTSP-vs-RTMP</link>
            <guid>https://velog.io/@jeong_woo/RTSP-vs-RTMP</guid>
            <pubDate>Fri, 17 Oct 2025 09:07:46 GMT</pubDate>
            <description><![CDATA[<h1 id="rtsp-vs-rtmp">RTSP vs RTMP</h1>
<h2 id="📌-rtsp-real-time-streaming-protocol">📌 RTSP (Real Time Streaming Protocol)</h2>
<h4 id="🛠️-용도">🛠️ 용도</h4>
<p>&quot;컨트롤&quot; 프로토콜에 가까움.
→ 영상/오디오 데이터를 직접 실어나르기보다는 재생, 일시정지, 중지 같은 명령 제어에 집중.</p>
<h4 id="📦-데이터-전달">📦 데이터 전달</h4>
<p>보통 RTP(Real-time Transport Protocol)랑 짝꿍으로 사용. 
실제 영상 데이터는 RTP로 전달하고, RTSP는 그걸 관리.</p>
<h4 id="⚙️-사용처">⚙️ 사용처</h4>
<p>CCTV, IP 카메라, 실시간 모니터링 시스템. (네트워크 카메라 주소가 rtsp://...인 경우가 많음.)</p>
<h4 id="👍-특징">👍 특징</h4>
<p>낮은 지연 시간(수백 ms 수준). 다만 방화벽이나 NAT 환경에서 잘 막히기도 함.</p>
<h2 id="📌-rtmp-real-time-messaging-protocol">📌 RTMP (Real Time Messaging Protocol)</h2>
<h4 id="🛠️-용도-1">🛠️ 용도</h4>
<p>원래 Adobe Flash Player 용으로 만든 &quot;데이터 전송&quot; 프로토콜.
→ 영상/오디오 데이터를 서버로 업로드하거나 내려받을 때 직접 씀.</p>
<h4 id="📦-데이터-전달-1">📦 데이터 전달</h4>
<p>TCP 기반, 자체 포맷으로 전송. 지금은 HLS/DASH 같은 표준 스트리밍으로 변환할 때 업로드(ingest) 프로토콜로 자주 사용.</p>
<h4 id="⚙️-사용처-1">⚙️ 사용처</h4>
<p>유튜브 라이브, 트위치, 페이스북 라이브 등 방송 플랫폼 송출.</p>
<h4 id="👍-특징-1">👍 특징</h4>
<p>지연 시간은 RTSP보다 약간 크지만(1~5초), 범용 지원이 좋고 안정적임. 방화벽 우회도 상대적으로 쉬움.</p>
<h1 id="ffmpeg-으로-미디어-스트림-hls-변환">ffmpeg 으로 미디어 스트림 hls 변환</h1>
<h2 id="0-libnginx-mod-rtmp-라이브러리">0. libnginx-mod-rtmp 라이브러리</h2>
<h4 id="📜-정의">📜 정의</h4>
<p>Debian/Ubuntu에서 제공하는 Nginx용 RTMP 동적 모듈 패키지다.
업스트림 오픈소스인 nginx-rtmp-module(일반적으로 arut/nginx-rtmp-module)을 배포판 포맷으로 묶어둔 것이라 보면 됨.</p>
<h4 id="⚙️-역할">⚙️ 역할</h4>
<ol>
<li>Nginx에 rtmp {} 블록과 application {}/live on; 같은 RTMP 서버 기능을 추가</li>
<li>OBS 같은 퍼블리셔가 RTMP로 업로드(publish) 하면 이를 받아 중계(play/relay)</li>
<li>on_publish, on_play 등 이벤트 콜백(HTTP)로 인증/로깅을 붙일 수 있음. (<a href="https://github.com/arut/nginx-rtmp-module?tab=readme-ov-file#example-nginxconf">관련 훅 링크</a>)</li>
<li>exec로 FFmpeg를 자동 스폰하여 트랜스코딩/HLS 세그먼팅 파이프라인을 바로 붙일 수 있음.</li>
<li>(선택) 모듈 자체의 HLS/DASH 세그먼팅 기능도 있으나, 세밀한 제어·ABR 구성은 보통 FFmpeg가 더 유연함.</li>
</ol>
<h4 id="⚠️-주의제한">⚠️ 주의/제한</h4>
<ol>
<li>Nginx 공식 번들 모듈이 아닌 서드파티 모듈임. (배포판에서 편하게 쓰도록 패키징한 것)</li>
<li>RTMPS(SSL)를 네이티브로 직접 처리하지 않ㅓ는다. 
필요하면 stunnel/Nginx stream 프록시 등으로 감싸 쓰는 패턴을 사용.</li>
<li>무거운 실시간 트랜스코딩은 FFmpeg(또는 SRS 내장/외부 트랜스코드)로 처리하는 것이 일반적임.</li>
</ol>
<h2 id="1-rtmp-ingest-방식">1. RTMP Ingest 방식</h2>
<h3 id="패키지-설치">패키지 설치</h3>
<pre><code class="language-bash">sudo apt update
sudo apt install -y nginx libnginx-mod-rtmp ffmpeg
sudo mkdir -p /var/www/hls
sudo chown -R www-data:www-data /var/www/hls</code></pre>
<h3 id="nginxconf-작성">nginx.conf 작성</h3>
<p>동적 모듈 로딩 + RTMP 인젯 + FFmpeg 자동 변환 + HLS 서빙</p>
<pre><code class="language-bash"># 동적 RTMP 모듈 로드 (Ubuntu/Debian 표준 경로)
load_module modules/ngx_rtmp_module.so;

worker_processes auto;

events { worker_connections 1024; }

# ---------- RTMP(Ingest) 이 부분은 아예 새로 추가 ----------
rtmp {
    server {
        listen 1935;          # RTMP default 포트
        chunk_size 4096;

        application live {
            live on;          # publish/play 허용
            record off;

            # (선택) 퍼블리시 인증 HTTP 콜백 예시
            # on_publish http://127.0.0.1:8080/auth?name=$name&amp;key=$arg_key;

            # 스트림이 들어오면 스트림 이름($name)별로 FFmpeg를 띄워 HLS를 생성
            # : ABR(1080/720/480) 3종, 2초 짜리 세그먼트, 오래된 세그먼트 삭제
            exec_kill_signal term;
            exec /usr/bin/ffmpeg -loglevel error -hide_banner \
                -reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 \
                -i rtmp://127.0.0.1/live/$name \
                -c:v libx264 -preset veryfast -profile:v high -level 4.1 -pix_fmt yuv420p \
                -sc_threshold 0 -g 60 -keyint_min 60 \
                -c:a aac -ar 48000 -ac 2 -b:a 128k \
                -filter:v:0 &quot;scale=w=-2:h=1080&quot; -b:v:0 6000k -maxrate:0 6420k -bufsize:0 12000k \
                -filter:v:1 &quot;scale=w=-2:h=720&quot;  -b:v:1 3500k -maxrate:1 3740k -bufsize:1 7000k  \
                -filter:v:2 &quot;scale=w=-2:h=480&quot;  -b:v:2 1200k -maxrate:2 1320k -bufsize:2 2400k  \
                -map 0:v:0 -map 0:a:0 -map 0:v:0 -map 0:a:0 -map 0:v:0 -map 0:a:0 \
                -hls_time 2 -hls_list_size 6 \
                -hls_flags delete_segments+program_date_time+independent_segments \
                -master_pl_name &quot;$name/master.m3u8&quot; \
                -var_stream_map &quot;v:0,a:0 name:1080p v:1,a:1 name:720p v:2,a:2 name:480p&quot; \
                -hls_segment_type mpegts \
                -hls_segment_filename &quot;/var/www/hls/$name/v%v/seg_%06d.ts&quot; \
                &quot;/var/www/hls/$name/v%v/index.m3u8&quot;;
        }
    }
}

# ---------- HLS 서빙(HTTP) 일부 옵션 추가 ----------
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    server {
        listen 80;
        server_name _;

        # HLS 정적 파일 제공
        location /hls/ {
            types {
                application/vnd.apple.mpegurl m3u8;
                video/mp2t ts;
            }
            alias /var/www/hls/;
            add_header Cache-Control no-cache;
            add_header Access-Control-Allow-Origin *;
            add_header Access-Control-Allow-Headers *;
        }
    }
}
</code></pre>
<h3 id="적용">적용:</h3>
<pre><code class="language-bash">sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>
<h3 id="obs-설정">OBS 설정</h3>
<ul>
<li>서버: rtmp://&lt;서버IP 또는 도메인&gt;/live</li>
<li>스트림 키: 예) mystream
(인증을 붙였다면 mystream?key=YOURSECRET 식으로)</li>
</ul>
<p>푸시가 성공하면 HLS는 다음 경로에 생긴다.</p>
<ul>
<li>마스터 플레이리스트: http://&lt;서버&gt;/hls/mystream/master.m3u8</li>
<li>개별 레벨: /hls/mystream/v0/index.m3u8(1080p), v1(720p), v2(480p)</li>
</ul>
<h2 id="2-rtmp-ingress">2. RTMP Ingress</h2>
<p>exec를 쓰지 않고, 고정 스트림을 별도 서비스로 돌리는 방법
이게 훨씬 보기가 좋음.
대충 <code>-p</code> 옵션으로 <code>/usr/local/bin/rtmp2hls.sh</code> 작성</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -Eeuo pipefail

IN=&quot;rtmp://127.0.0.1/live/mystream&quot;   # OBS가 푸시하는 RTMP
OUTDIR=&quot;/var/www/hls/mystream&quot;

mkdir -p &quot;$OUTDIR&quot;
exec /usr/bin/ffmpeg -loglevel error -hide_banner \
  -reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 \
  -i &quot;$IN&quot; \
  -c:v libx264 -preset veryfast -profile:v high -level 4.1 -pix_fmt yuv420p \
  -sc_threshold 0 -g 60 -keyint_min 60 \
  -c:a aac -ar 48000 -ac 2 -b:a 128k \
  -hls_time 2 -hls_list_size 6 \
  -hls_flags delete_segments+program_date_time+independent_segments \
  -f hls -hls_segment_filename &quot;$OUTDIR/seg_%06d.ts&quot; \
  &quot;$OUTDIR/index.m3u8&quot;
</code></pre>
<p>권한 추가</p>
<pre><code class="language-bash">sudo chmod +x /usr/local/bin/rtmp2hls.sh</code></pre>
<p>서비스 파일(.ini) 작성</p>
<pre><code class="language-bash"># /etc/systemd/system/rtmp2hls.service
[Unit]
Description=RTMP -&gt; HLS (FFmpeg)
After=network.target nginx.service

[Service]
User=www-data
Group=www-data
ExecStart=/usr/local/bin/rtmp2hls.sh
Restart=always
RestartSec=2
WorkingDirectory=/var/www/hls

[Install]
WantedBy=multi-user.target
</code></pre>
<p>데몬 로드 후 서비스 자동 등록 및 실행</p>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now rtmp2hls</code></pre>
<h2 id="3-체크리스트">3. 체크리스트</h2>
<p>권한: /var/www/hls에 www-data(또는 Nginx/FFmpeg가 도는 사용자) 쓰기 권한이 있어야 함.</p>
<p>경로: load_module modules/ngx_rtmp_module.so;가 실패하면 modules 경로(대개 /usr/lib/nginx/modules/)를 확인.</p>
<p>CORS: 웹 플레이어에서 크로스도메인 요청이면 /hls/에 CORS 헤더 추가(위 예시 포함).</p>
<p>키프레임 간격: 세그먼트 길이(예: 2초)의 정수배로 -g(GOP) 맞추는 것이 안정적.</p>
<p>성능: -preset veryfast부터 시작해서 여유되면 faster/fast로 올리기.</p>
<p>브라우저 RTMP: 불가. 웹은 HLS(또는 LL-HLS/WEBRTC)를 사용.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[yt-dlp 를 사용하여 구현하기 (상세)]]></title>
            <link>https://velog.io/@jeong_woo/yt-dlp-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%83%81%EC%84%B8</link>
            <guid>https://velog.io/@jeong_woo/yt-dlp-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%83%81%EC%84%B8</guid>
            <pubDate>Fri, 17 Oct 2025 08:04:55 GMT</pubDate>
            <description><![CDATA[<h1 id="yt-dlp-파일-작성">yt-dlp 파일 작성</h1>
<h2 id="1-systemd-서비스-설정">1. systemd 서비스 설정</h2>
<h3 id="yt-dlp-설치-및-경로-설정">yt-dlp 설치 및 경로 설정</h3>
<p>yt-dlp 를 반드시 설치해야하는데, <code>pipx</code>로 설치하면 yt-dlp는 보통 <code>~/.local/bin/yt-dlp</code> 또는 <code>pipx venv</code> 경로에 다
따라서 서비스에 PATH를 명시하고 스크립트에 절대경로를 없애거나 또는 정확한 절대경로로 수정 (<code>/home/ubuntu/.local/bin/yt-dlp</code>) 하면 된다.</p>
<h3 id="서비스-파일ini-작성-예시">서비스 파일(.ini) 작성 예시</h3>
<pre><code class="language-bash">[Unit]
Description=HLS live stream author.KJW
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/live-stream
Environment=&quot;PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin&quot;
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/bash -lc /opt/live-stream/live-stream.sh
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=false
ProtectSystem=full
ReadWritePaths=/var/www/videos/hls/live/720p /opt/live-stream
StandardOutput=journal
StandardError=inherit

[Install]
WantedBy=multi-user.target</code></pre>
<p>권한/마운트 이슈를 우회하고 진단도 쉬운 형태로 설정.</p>
<pre><code class="language-ini">ExecStart=/usr/bin/bash -lc /opt/live-stream/live-stream.sh</code></pre>
<p>참고로 <code>-l(login)</code>은 /etc/profile 등을 읽어서 PATH가 더 풍부해지지만, 사용자 셸 설정(~/.bashrc)은 기본으론 안 읽힐 수 있다. 그래서 서비스 파일에서 <code>Environment=PATH=...</code>를 명시하는 것이다.</p>
<h3 id="스크립트-파일-작성-예시">스크립트 파일 작성 예시</h3>
<pre><code class="language-bash">#!/usr/bin/env bash
set -Eeuo pipefail

VIDEO_URL=&quot;${VIDEO_URL:-https://www.youtube.com/watch?v=채널_id}&quot;
COOKIES=&quot;${COOKIES:-/opt/live-stream/cookies.txt}&quot;
OUTPUT_DIR=&quot;${OUTPUT_DIR:-/var/www/videos/hls/live/720p}&quot;

YTDLP_BIN=&quot;${YTDLP_BIN:-$(command -v yt-dlp)}&quot;
FFMPEG_BIN=&quot;${FFMPEG_BIN:-$(command -v ffmpeg)}&quot;

if [[ -z &quot;${YTDLP_BIN}&quot; ]]; then
  echo &quot;yt-dlp not found in PATH&quot; &gt;&amp;2; exit 127
fi
if [[ -z &quot;${FFMPEG_BIN}&quot; ]]; then
  echo &quot;ffmpeg not found in PATH&quot; &gt;&amp;2; exit 127
fi

mkdir -p &quot;$OUTPUT_DIR&quot;

RESTART_DELAY=15
UA=&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115 Safari/537.36&quot;
REF=&quot;https://www.youtube.com/&quot;

if [[ -s &quot;$COOKIES&quot; ]]; then
  COOKIES_ARG=(--cookies &quot;$COOKIES&quot;)
  FFMPEG_COOKIE_ARG=()  # 필요 시 -headers &quot;Cookie: ...&quot; 방식으로 교체
else
  COOKIES_ARG=()
  FFMPEG_COOKIE_ARG=()
fi

while true; do
  echo &quot;[$(date)] Fetching fresh HLS URL from YouTube...&quot;
  if ! HLS_URL=&quot;$(&quot;$YTDLP_BIN&quot; -g &quot;${COOKIES_ARG[@]}&quot; --no-warnings -f &quot;bv*[height&lt;=720]+ba/b&quot; &quot;$VIDEO_URL&quot;)&quot;; then
    echo &quot;[$(date)] yt-dlp failed. Retrying in $RESTART_DELAY sec...&quot;
    sleep &quot;$RESTART_DELAY&quot;; continue
  fi

  if [[ -z &quot;$HLS_URL&quot; ]]; then
    echo &quot;[$(date)] Empty HLS URL. Retrying in $RESTART_DELAY sec...&quot;
    sleep &quot;$RESTART_DELAY&quot;; continue
  fi

  echo &quot;[$(date)] Got HLS URL: $HLS_URL&quot;
  echo &quot;[$(date)] Starting ffmpeg HLS restream...&quot;

  set +e
  &quot;$FFMPEG_BIN&quot; -hide_banner -loglevel warning \
    -user_agent &quot;$UA&quot; -referer &quot;$REF&quot; &quot;${FFMPEG_COOKIE_ARG[@]}&quot; \
    -re -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 15 \
    -rw_timeout 20000000 -timeout 20000000 -seekable 0 \
    -fflags +genpts+discardcorrupt -probesize 15M -analyzeduration 20M \
    -i &quot;$HLS_URL&quot; -c copy -bufsize 20M -max_delay 5000000 \
    -f hls -hls_time 10 -hls_list_size 20 \
    -hls_flags delete_segments+append_list+omit_endlist \
    &quot;$OUTPUT_DIR/index.m3u8&quot;
  CODE=$?
  set -e

  echo &quot;[$(date)] ffmpeg exited with code $CODE. Waiting $RESTART_DELAY sec before restart...&quot;
  sleep &quot;$RESTART_DELAY&quot;
done</code></pre>
<h2 id="쓰기-경로-권한부여">쓰기 경로 권한부여</h2>
<p>출력 경로(<code>/var/www/videos/hls/live/720p</code>)는 User=ubuntu가 쓰기 가능해야 한다.</p>
<pre><code class="language-bash">sudo mkdir -p /var/www/videos/hls/live/720p
sudo chown -R ubuntu:www-data /var/www/videos
sudo chmod -R 775 /var/www/videos</code></pre>
<p>혹은 서비스에 아래 줄을 넣어주면 되는데,</p>
<pre><code class="language-bash">ReadWritePaths=/var/www/videos/hls/live/720p /opt/live-stream</code></pre>
<p>이 코드는 <code>systemd sandbox</code> 가 쓰기를 허용하는 줄이다. 
참고로 디렉터리 자체 권한, 소유권까지 제대로 맞춰줘야 한다.</p>
<h2 id="쿠키헤더-옵션">쿠키/헤더 옵션</h2>
<p>작동 후 품질 개선을 위해 쿠키 및 헤더 옵션을 넣어준다.
<code>ffmpeg</code>의 <code>-cookies</code> 옵션은 <code>name=value; name2=value2</code> 형태가 일반적인데,
<code>-cookies &quot;file=/path&quot;</code> 는 ffmpeg 표준 옵션은 아니니(빌드마다 다를 수 있음) 헤더로 넘기는 방법을 고려하였다.</p>
<p>쿠키 파일을 헤더로 붙이는 예 (Netscape cookies.txt라면 변환 필요)</p>
<pre><code class="language-bash">FFMPEG_COOKIE_ARG=(-headers &quot;Cookie: $(awk &#39;BEGIN{ORS=&quot;; &quot;}$0 !~ /^#/ {print $6&quot;=&quot;$7}&#39; &quot;$COOKIES&quot;)&quot;)</code></pre>
<p>일단 지금처럼 yt-dlp -g로 m3u8을 받아서 -user_agent와 -referer만 맞춰도 동작하는 경우가 많으니, 우선 권한/경로부터 해결하고 필요 시 다듬자.</p>
<h1 id="트러블-슈팅-방법">트러블 슈팅 방법</h1>
<h2 id="1-스크립트와-경로-권한-확인">1. 스크립트와 경로 권한 확인</h2>
<pre><code class="language-bash"># 각 경로 구성요소의 권한을 한 눈에 보기
namei -l /opt/live-stream/live-stream.sh

# 퍼미션/소유자 확인
ls -l /opt/live-stream/live-stream.sh
ls -ld /opt/live-stream</code></pre>
<p>조건:
<code>live-stream.sh에</code> 실행 비트가 있어야 함 =&gt; <code>-rwxr-xr-x(0755)</code> 추천
<code>/opt</code> 와 <code>/opt/live-stream</code> 디렉터리에 실행 권한이 있어야 <code>User=사용자명</code> 가 경로를 탐색(traverse) 가능 (보통 755)</p>
<p>수정</p>
<pre><code class="language-bash">sudo chown 사용자명:사용자명 /opt/live-stream/live-stream.sh /opt/live-stream
sudo chmod 0755 /opt/live-stream /opt/live-stream/live-stream.sh</code></pre>
<blockquote>
<p>참고: 디렉터리의 x 권한은 “들어갈 수 있음”이다. 따라서 아무리 파일을 755로 바꿔도, 디렉터리가 700이면 여전히 Permission denied가 뜰 수 있다.</p>
</blockquote>
<h2 id="2-crlf윈도우-개행-및-shebang-확인">2) CRLF(윈도우 개행) 및 shebang 확인</h2>
<p>윈도우에서 만든 스크립트면 커널이 인터프리터를 못 찾아서 문제를 낼 수 있다.</p>
<pre><code class="language-bash">head -n1 /opt/live-stream/live-stream.sh
file /opt/live-stream/live-stream.sh</code></pre>
<p>첫번째 명령어는 쉬뱅인 <code>#!/usr/bin/env bash</code> 가,
두번째 명령어는 <code>with CRLF line terminators</code> 가 보이면 안 된다.</p>
<p>캐리지 리턴, 라인 피드 정리를 위해선 아래 명령어를 입력하면 된다.</p>
<pre><code class="language-bash">sudo sed -i &#39;s/\r$//&#39; /opt/live-stream/live-stream.sh</code></pre>
<h2 id="3-noexec-마운트-여부-확인">3) noexec 마운트 여부 확인</h2>
<p>참고로 그럴일은 없겠지만 <code>opt</code>가 <code>noexec</code>로 마운트 되어있으면 실행 자체가 막힌다.
따라서 아래 명령어를 실행한 뒤,</p>
<pre><code class="language-bash">findmnt -no TARGET,OPTIONS /opt
# 또는
mount | grep &#39; /opt &#39;</code></pre>
<p><code>noexec</code> 가 보이면 가급적 스크립트를 noexec 아닌 경로(예: /usr/local/bin)로 옮기거나, 서비스에서 인터프리터로 실행하면 된다.
<code>ExecStart=/usr/bin/bash /opt/live-stream/live-stream.sh</code>
참고로 이 방식은 스크립트에 <code>x 비트</code>가 없어도 동작한다. 대신 보안상 최선은 아님.</p>
<h2 id="정리">정리.</h2>
<pre><code class="language-bash"># 1) 권한/소유/개행 정리
sudo chown ubuntu:ubuntu /opt/live-stream /opt/live-stream/live-stream.sh
sudo chmod 0755 /opt/live-stream /opt/live-stream/live-stream.sh
sudo sed -i &#39;s/\r$//&#39; /opt/live-stream/live-stream.sh

# 2) 출력 디렉터리 준비
sudo mkdir -p /var/www/videos/hls/live/720p
sudo chown -R ubuntu:www-data /var/www/videos
sudo chmod -R 775 /var/www/videos

혹은 서비스 파일에 명시

# 3) /opt noexec 여부 점검(필요 시만 보통은 필요 없을꺼임.)
findmnt -no TARGET,OPTIONS /opt

# 4) 서비스 파일 수정(예: /etc/systemd/system/live-stream.service)
#   - ExecStart=/usr/bin/bash -lc /opt/live-stream/live-stream.sh
#   - Environment=&quot;PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin&quot;

# 5) systemd 반영 및 재기동
sudo systemctl daemon-reload
sudo systemctl restart live-stream
sudo journalctl -u live-stream -e --no-pager</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[nginx로 HLS 정적 서빙 (feat. yt-dlp)]]></title>
            <link>https://velog.io/@jeong_woo/nginx%EB%A1%9C-HLS-%EC%A0%95%EC%A0%81-%EC%84%9C%EB%B9%99-feat.-yt-dlp</link>
            <guid>https://velog.io/@jeong_woo/nginx%EB%A1%9C-HLS-%EC%A0%95%EC%A0%81-%EC%84%9C%EB%B9%99-feat.-yt-dlp</guid>
            <pubDate>Fri, 17 Oct 2025 07:18:43 GMT</pubDate>
            <description><![CDATA[<h1 id="0-서론">0. 서론</h1>
<p>비록 정책 문제와 맞물려 채택되지 못한 아이디어이지만 굉장히 참신한 아디어라 소개해본다.</p>
<h1 id="1-아이디어">1. 아이디어</h1>
<ol start="0">
<li>특정 주소 접속해서 <code>라이브 변환기</code> 가 켜져있는지 확인.</li>
<li><code>대상 채널 코드</code>을 <code>youtube api</code>에 주고, 켜져 있는 라이브 방송이 있는지 알아본다. (100 가스 소요.)</li>
</ol>
<p>--&gt; response <code>라이브 방송 클립코드</code>
2. <code>라이브 방송 클립코드</code>가 live인지 체크한다. (1 가스 소요.)
3. <code>라이브 방송 채널 코드</code>를 주고, <code>yt-dlp</code>로 돌린걸 <code>ffmpeg</code>으로 넘겨준다.
3.1 <code>yt-dlp</code>로 hls 주소를 확인한다.
3.2 방송을 <code>ffmpeg</code>으로 포장 -&gt; response <code>ffmpeg</code>으로 넘겨준 <code>master.m3u8</code> 경로
4. nginx가 <code>master.m3u8</code>을 서빙
5. 2번, 3번을 서비스로 등록</p>
<h1 id="2-youtube-api">2. Youtube API</h1>
<h2 id="1-youtube-api-설정">1) youtube API 설정</h2>
<ol>
<li><p>gcp 콘솔에서 프로젝트를 하나 만들고 API 및 서비스에 접근한다.</p>
</li>
<li><p>사용자 인증 정보 탭에 들어가서 API 키를 생성한다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/b20293a4-40dc-4ae4-b734-aed2f1e5474d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/896c55ba-4b45-47d2-9016-49968000148d/image.png" alt=""></p>
<p>이때, API키 수정을 누른 다음
<img src="https://velog.velcdn.com/images/jeong_woo/post/821514e1-ec4c-4116-acd1-0b34a8210225/image.png" alt=""></p>
<p>YouTube Data API v3 만 선택하여 사용하도록 하자.</p>
<h2 id="2-youtube-api-사용">2) youtube API 사용</h2>
<p><code>Search: list</code> 를 사용해야한다. (<a href="https://developers.google.com/youtube/v3/docs/search/list?hl=ko">관련 문서</a>)</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/9dcfed3b-c120-46fa-8498-b54c794d18e5/image.png" alt=""></p>
<p>요청 req url 예시</p>
<pre><code>https://www.googleapis.com/youtube/v3/search?part=snippet&amp;q=@CHANNEL_HANDLE&amp;eventType=live&amp;type=video&amp;key=YOUR_KEY</code></pre><h1 id="4-구현">4. 구현</h1>
<h2 id="1-hls-패스스루">1) HLS 패스스루</h2>
<p>“라이트 리스팀” — YouTube Live → (내 서버) HLS 패스스루
유튜브의 라이브 HLS(m3u8)를 받아서, 내 도메인에서 HLS로 재공급하는 방식이다.</p>
<p>이 방식의 문제는 cookie, 정책 등의 문제로 아쉽게도 탈락 되었다.
일일이 수동으로 해야할 경우가 많고 문제를 일으킬 요소도 굉장히 많았기 때문이다.</p>
<p>장점은 Youtube Live Url 을 인코딩 없이 <code>-c copy</code>로 리패키징(패스스루) 하면 CPU 부담 거의 없고 Youtube Live Url은 만료 토큰이 있으므로 주기적으로 새 URL을 갱신하는 작은 스크립트만 추가하면 되기 때문에 굉장히 간단하다.</p>
<p>하지만 담점으로 약관/저작권 이슈를 스스로 관리해야 하고(특히 외부 공개 재배포 시), URL 만료 시 자동 재연결 로직이 필요하다.</p>
<h2 id="2-최소-구현-예시">2) 최소 구현 예시</h2>
<h4 id="0-필수-lib-설치">0. 필수 lib 설치</h4>
<pre><code class="language-bash">pipx install yt-dlp
source ~/.bashrc
which yt-dlp</code></pre>
<p>여기서 yt-dlp 가 잘 나오는 것을 확인.</p>
<h4 id="1-유튜브-라이브-스트리밍-hlsm3u8-url-얻기">1. 유튜브 라이브 스트리밍 HLS(m3u8) URL 얻기</h4>
<p>유튜브에서 방송 중인 채널의 HLS 주소를 직접 얻기 위해 youtube download player 를 사용</p>
<pre><code class="language-bash">yt-dlp --cookies 쿠키경로 -g &quot;https://www.youtube.com/watch?v=스트리밍ID&quot;

# ex)
yt-dlp --cookies /opt/yt-restream/cookies.txt -g &quot;https://www.youtube.com/watch?v=스트리밍ID&quot;</code></pre>
<h4 id="2-ffmpeg로-재인코딩--hls-세그먼트-생성">2. ffmpeg로 재인코딩 &amp; HLS 세그먼트 생성</h4>
<p>유튜브의 HLS 스트림을 ffmpeg가 받아서, 우리 서버에서 쓸 수 있는 HLS로 변환하는 작업이다.</p>
<pre><code class="language-bash">ffmpeg \
    -i &quot;$(yt-dlp -g https://www.youtube.com/watch?v=스트리밍ID)&quot; \
    -c copy \
    -f hls \
    -hls_time 4 \
    -hls_list_size 5 \
    -hls_flags delete_segments \
    /var/www/html/stream/index.m3u8


# ex)
ffmpeg -i &quot;$(yt-dlp --cookies /opt/yt-restream/cookies.txt -g https://www.youtube.com/watch?v=스트리밍ID)&quot; \
    -c copy -f hls -hls_time 4 -hls_list_size 5 -hls_flags delete_segments \
    /var/www/html/stream/index.m3u8
</code></pre>
<p>설명:
<code>-c copy</code> → 재인코딩 없이 원본 그대로 복사 (CPU 부담 ↓)
<code>-hls_time 4</code> → 세그먼트 길이 4초
<code>-hls_list_size 5</code> → m3u8에 최신 5개 세그먼트만 유지
<code>-hls_flags delete_segments</code> → 오래된 ts 파일 삭제
<code>/var/www/html/stream/index.m3u8</code> → Nginx 같은 웹서버에서 접근 가능한 경로</p>
<ul>
<li>참고로 이때, 지정한 path 에 폴더가 있어야한다.</li>
</ul>
<h4 id="3-옵션-권한-설정하기">3. (옵션) 권한 설정하기</h4>
<pre><code class="language-bash">sudo mkdir -p /opt/yt-restream
sudo nano /opt/yt-restream/restream.sh
sudo chmod +x /opt/yt-restream/restream.sh</code></pre>
<h4 id="4-nginx-설정-해주고">4. nginx 설정 해주고</h4>
<p>이때, CORS 필요한 경우 nginx에 <code>add_header Access-Control-Allow-Origin *;</code> 등 옵션 추가</p>
<h4 id="5-이제-위-수동-테스트를-바탕으로-자동화-sh-파일-작성">5. 이제 위 수동 테스트를 바탕으로 자동화 sh 파일 작성</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
set -Eeuo pipefail

VIDEO_URL=&quot;https://www.youtube.com/watch?v=NJUjU9ALj4A&quot;
COOKIES=&quot;/opt/yt-restream/cookies.txt&quot;
OUTPUT_DIR=&quot;/var/www/html/stream&quot;

mkdir -p &quot;$OUTPUT_DIR&quot;

while true; do
    echo &quot;[$(date)] Fetching new m3u8 URL...&quot;
    M3U8_URL=$(/home/ubuntu/.local/bin/yt-dlp --cookies &quot;$COOKIES&quot; -g &quot;$VIDEO_URL&quot;)

    echo &quot;[$(date)] Starting ffmpeg restream...&quot;
    ffmpeg \
        -loglevel error \
        -i &quot;$M3U8_URL&quot; \
        -c copy \
        -f hls \
        -hls_time 4 \
        -hls_list_size 5 \
        -hls_flags delete_segments \
        &quot;$OUTPUT_DIR/index.m3u8&quot;

    echo &quot;[$(date)] ffmpeg stopped. Restarting in 5 seconds...&quot;
    sleep 5
done
</code></pre>
<p>이때, ffmpeg 옵션으로 <code>-hls_time 2</code>, <code>-hls_list_size</code> <code>6</code> 이면 보통 <code>6</code>~<code>12</code>초. 더 낮추면 끊김 위험이 높다.</p>
<h4 id="6-서비스-등록">6. 서비스 등록</h4>
<p>systemd 서비스 파일 작성</p>
<pre><code class="language-ini">[Unit]
Description=YouTube to HLS Restream Service
After=network.target

[Service]
Type=simple
User=ubuntu
ExecStart=/opt/yt-restream/restream.sh
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target</code></pre>
<h4 id="7-실행-및-로그">7. 실행 및 로그</h4>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable 서비스이름
sudo systemctl start 서비스이름</code></pre>
<p>로그 확인</p>
<pre><code class="language-bash">journalctl -u 서비스이름 -f</code></pre>
<h1 id="결론-이-아이디어가-채택이-안-된-이유">결론. 이 아이디어가 채택이 안 된 이유.</h1>
<p>트랜스코딩 없이 패스스루(-c copy)라 CPU는 가볍지만, 원본 코덱(보통 H.264/MP4 또는 VP9/WEBM)에 따라 일부 레거시 플레이어 호환성에 차이가 날 수 있다. 이때, 문제가 생기면 <code>-c:v h264 -c:a aac</code> 로 트랜스코딩을 고려하면 되는데 대신 CPU/GPU 코스트가 상승한다.
참고로 CDN을 앞단에 두면 대규모 시청자도 안정적으로 처리가 가능하다.</p>
<p>무튼 이 아이디어가 채택이 안 된 이유는 요청 빈도(yt-dlp &amp; ffmpeg) 때문이다.
동작 과정이 </p>
<ol>
<li>yt-dlp
<code>yt-dlp -g</code> 명령을 실행하는 순간, 유튜브 페이지를 1<del>2번 요청해서 실제 m3u8 주소를 뽑습는다.
평상시에 1초마다 계속 호출하는 건 아니고, 명령 실행할 때만 요청한다.
m3u8 URL은 보통 몇 분</del>몇 시간 유효하지만, 유튜브 라이브는 1시간 이내 만료되는 경우가 많다.
→ 그래서 주기적으로 새로 받아와야 함 (예: 30분~1시간마다)</li>
<li>ffmpeg
그리고 사실 여기가 문제인데 ffmpeg는 yt-dlp가 뽑아준 m3u8 URL을 읽어오는데,
이건 내부적으로 유튜브의 세그먼트(ts) 파일을 받아오는 HTTP 요청을 세그먼트 길이(내 경우 12초)마다 보낸다.
그럼 youtube 측에서는 요청을 너무 많이 보내서 429 too many req 에러를 보낸다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[electron 기초]]></title>
            <link>https://velog.io/@jeong_woo/electron-%EA%B8%B0%EC%B4%88</link>
            <guid>https://velog.io/@jeong_woo/electron-%EA%B8%B0%EC%B4%88</guid>
            <pubDate>Thu, 11 Sep 2025 05:14:01 GMT</pubDate>
            <description><![CDATA[<h1 id="간단한-앱-만들기">간단한 앱 만들기</h1>
<p>마치 web-app 처럼 electron 을 사용해서 mini-pc 에 application 을 만들어볼 예정이다.</p>
<h2 id="1-electron">1. electron</h2>
<p>electron 의 구성은 일단 
<code>index.html</code> =&gt; splash 화면
<code>package.json</code> =&gt; 명세서 역할
<code>main.js</code> =&gt; landing-page 역할</p>
<p>여기에 추가적으로 본인이 원하는 파일을 목적에 만들면 된다.</p>
<p><code>preload.js</code> =&gt; 해당 app 이 뜨기 전에 값을 가져오는 역할
<code>updater.js</code> =&gt; s3, object box 와 같이 자동으로 update 될 수 있도록 람다를 역할을 하는 곳.</p>
<p>그럼 이제 하나하나 천천히 살펴보자.</p>
<h3 id="1-indexhtml">1. index.html</h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Simple Electron App&lt;/title&gt;
    &lt;style&gt;
        body, html {
            margin: 0;
            padding: 0;
            height: 100%;
            overflow: hidden;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .background-image {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-image: url(&#39;splash.jpg&#39;); /* Replace with your image URL */
            background-size: cover;
            background-position: center;
            z-index: -1;
        }

        .text-container {
            position: absolute;
            top: 80%;
            color: white;
            font-family: Arial, sans-serif;
            font-size: 2rem;
            text-align: center;
            background: rgba(0, 0, 0, 0.5); /* Optional: Adds a translucent background */
            padding: 20px;
            border-radius: 10px;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
  var Request = function () {
    this.getParameter = function (name) {
      var rtnval = &#39;&#39;;
      var nowAddress = unescape(location.href);
      var parameters = (nowAddress.slice(nowAddress.indexOf(&#39;?&#39;) + 1,
        nowAddress.length)).split(&#39;&amp;&#39;);
      for (var i = 0; i &lt; parameters.length; i++) {
        var varName = parameters[i].split(&#39;=&#39;)[0];
        if (varName.toUpperCase() == name.toUpperCase()) {
          rtnval = parameters[i].split(&#39;=&#39;)[1];
          break;
        }
      }
      return rtnval;
    }
  }
  var request = new Request();
&lt;/script&gt;

&lt;div class=&quot;background-image&quot;&gt;&lt;/div&gt;
&lt;div class=&quot;text-container&quot; id=&quot;did&quot;&gt;device ID&lt;/div&gt;

&lt;script type=&quot;text/javascript&quot;&gt;
  var deviceParam = request.getParameter(&#39;deviceID&#39;);
  var versionParam = request.getParameter(&#39;ver&#39;);
  document.getElementById(&#39;did&#39;).innerHTML = deviceParam + &#39;&lt;br&gt;Ver.&#39; + versionParam;
&lt;/script&gt;

&lt;script&gt;
  var timer = setTimeout(function () {
    var webclient_url = &#39;http://localhost:3000&#39;;
    window.location.href = `${webclient_url}?deviceID=${deviceParam}`;
  }, 3000);
&lt;/script&gt;

&lt;script&gt;
  document.addEventListener(&#39;keydown&#39;, (event) =&gt; {
    if (event.key === &#39;Enter&#39;) {
      console.log(&#39;✔ OK 버튼이 브라우저에서 감지됨!&#39;);
    }
  });
&lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>뭐 이런식으로 간단하게 작성해도 된다.</p>
<p>하지만 나는 electron work flow 가 <code>package.json</code> =&gt; <code>main.js</code> 로 실행되어 createWindow 에서 바로 react 서버를 요청했고 이를 가져와 표출하기로 했다.
참고로 react 에서 type 만 잘 잡아두고, electron 에서 ipc 를 사용하여 터널만 잘 뚫어 놓으면 OS 단의 정보를 react 에서도 받아볼 수 있다.</p>
<h2 id="2-packagejson">2. package.json</h2>
<pre><code class="language-js">{
  &quot;author&quot;: &quot;kjw&quot;,
  &quot;name&quot;: &quot;레포 이름&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;레포 설명&quot;,
  &quot;main&quot;: &quot;main.js&quot;,
  &quot;scripts&quot;: {
    &quot;start&quot;: &quot;electron .&quot;,
    &quot;dev&quot;: &quot;electron . --enable-logging&quot;,
    &quot;build&quot;: &quot;electron-builder&quot;,
    &quot;dist&quot;: &quot;electron-builder --publish=never&quot;,
    &quot;build:linux64&quot;: &quot;electron-builder --linux --x64&quot;,
    &quot;build:win64&quot;: &quot;electron-builder --win --x64&quot;,
    &quot;postinstall&quot;: &quot;electron-builder install-app-deps&quot;
    &quot;build-publish&quot;: &quot;electron-builder --publish=always&quot;,
    &quot;deploy&quot;: &quot;node deploy-to-gcs.js&quot;
  },
  &quot;author&quot;: &quot;kjw&quot;,
  &quot;license&quot;: &quot;ISC&quot;,
  &quot;devDependencies&quot;: {
    &quot;electron-builder&quot;: &quot;^26.0.12&quot;,
    &quot;electron-log&quot;: &quot;^5.4.2&quot;,
    &quot;electron-updater&quot;: &quot;^6.6.2&quot;
  }
  &quot;build&quot;: {
    &quot;appId&quot;: &quot;앱 아이디&quot;,
    &quot;productName&quot;: &quot;앱 이름&quot;,
    &quot;asar&quot;: true,
    &quot;asarUnpack&quot;: [
      &quot;node_modules/node-hid/**&quot;
    ],
    &quot;protocols&quot;: {
      &quot;name&quot;: &quot;SmartSilverClient&quot;,
      &quot;schemes&quot;: [
        &quot;SmartSilverClient&quot;
      ]
    },
    &quot;files&quot;: [
      &quot;**/*&quot;
    ],
    &quot;win&quot;: {
      &quot;target&quot;: [
        &quot;zip&quot;,
        &quot;nsis&quot;
      ],
      &quot;icon&quot;: &quot;./resources/installer/Icon.ico&quot;
    },
    &quot;linux&quot;: {
      &quot;target&quot;: [
        &quot;AppImage&quot;,
        &quot;zip&quot;,
        &quot;tar.gz&quot;
      ],
      &quot;icon&quot;: &quot;./resources/linuxicon&quot;
    },
    &quot;nsis&quot;: {
      &quot;oneClick&quot;: false,
      &quot;allowToChangeInstallationDirectory&quot;: true
    },
    &quot;directories&quot;: {
      &quot;buildResources&quot;: &quot;./resources/installer/&quot;,
      &quot;output&quot;: &quot;./dist/&quot;,
      &quot;app&quot;: &quot;.&quot;
    }
  },
  &quot;type&quot;: &quot;commonjs&quot;,
  &quot;dependencies&quot;: {
    &quot;@electron/rebuild&quot;: &quot;^4.0.1&quot;,
    &quot;electron&quot;: &quot;^37.2.5&quot;,
    &quot;node-hid&quot;: &quot;^3.2.0&quot;
  },

}</code></pre>
<p>electron 에서 package.json 의 build 옵션은 굉장히 중요한 역할을 한다.
AppImage 를 생성하는 옵션들을 여기서 설정하기 때문이다.</p>
<h3 id="기본-설정">기본 설정</h3>
<p>productName: 최종 사용자에게 표시될 애플리케이션 이름이다. 여기서 설정한 이름 + 버전.AppImage 로 빌드됨.
appId: 애플리케이션의 고유 식별자로, 주로 도메인 역순 형식을 사용한다.
asar: 애플리케이션 소스 코드를 asar 아카이브로 패키징한다. 성능 향상 + 코드 보호</p>
<h3 id="build---파일-설정">build - 파일 설정</h3>
<p>files: 최종 빌드에 포함될 파일들을 지정.</p>
<pre><code class="language-js">&quot;files&quot;: [
  &quot;index.html&quot;,
  &quot;main.js&quot;,
  &quot;splash.jpg&quot;,
  &quot;node_modules/node-hid/**/*&quot;
  // 아니면 그냥 프로젝트 내 모든 파일 설정
  &quot;**/*&quot;
]</code></pre>
<h3 id="build---asarunpack">build - asarUnpack</h3>
<p>asar 아카이브로 패키징하지 않을 파일들을 지정하는 필드</p>
<pre><code class="language-js">&quot;asarUnpack&quot;: [
  &quot;node_modules/node-hid/**&quot;
],</code></pre>
<p>참고로 node-hid 모듈은 네이티브 모듈이므로 asar에서 풀어야 정상 작동한다.</p>
<h3 id="build---protocols">build - protocols</h3>
<p>프로토콜 설정
protocols: 커스텀 URL 스킴을 정의합니다.</p>
<pre><code class="language-js">&quot;protocols&quot;: {
  &quot;name&quot;: &quot;앱 이름&quot;,
    &quot;schemes&quot;: [
      &quot;앱 이름&quot;
    ]
},</code></pre>
<p>이를 통해 <code>앱 이름://</code> 형식의 URL로 애플리케이션을 실행할 수 있다.</p>
<h3 id="build---windows">build - Windows</h3>
<p><code>electron-builder</code> 설정의 일부</p>
<pre><code class="language-js">&quot;win&quot;: {
  &quot;target&quot;: [
    &quot;zip&quot;,
    &quot;nsis&quot;
  ],
    &quot;icon&quot;: &quot;./resources/installer/Icon.ico&quot;
},</code></pre>
<p>zip: 압축 파일 형태로 배포
nsis: Windows 설치 프로그램으로 배포
win.icon: Windows 애플리케이션 아이콘</p>
<h4 id="window-필드">window 필드</h4>
<pre><code>1. NSIS 는 사용자가 window 에서 설치할 때 필요한 건가?</code></pre><p>NSIS (Nullsoft Scriptable Install System)는 Windows 운영체제에서 소프트웨어 설치 파일을 만들 때 사용하는 도구이다.
NSIS는 다양한 역할을 하는데, 1. Windows 설치 파일(exe) 생성 2. <code>.nsi</code> 확장자를 작성하여 설치 과정을 세밀하게 제어 가능 3. 다양한 기능제공 예를 들어 설치 경로 설정, 구성 요소 선택, 언인스톨러 생성, 프로그램 실행 여부 설정 등 그리고 무엇보다 무료 및 오픈 소스이다. 
    2. asar 아카이브란?
asar(Atom Shell Archive Format) 아카이브는 Electron 애플리케이션을 위해 특별히 설계된 간단한 확장 아카이브 포맷이다. 
tar와 비슷한 방식으로 작동한다. 무슨 말이냐면 애플리케이션의 모든 소스 코드와 리소스 파일들을 압축 없이 하나의 파일로 묶어주는 역할을 한다.
파일 통합: 여러 파일을 하나로 묶어 관리가 용이
성능 최적화: asar 아카이브를 압축 해제 없이 임의로 접근하여 필요한 파일만 읽어들일 수 있음
경로 문제 해결: Windows에서 발생할 수 있는 긴 경로 이름 관련 문제를 완화
소스 코드 보호: 애플리케이션 배포 시 소스 코드를 직접 노출하지 않고 패키징할 수 있음
이러한 장점이 있어, win build 시 asar 아카이브로 패키징하도록 설정을 한다.</p>
<h3 id="build---linux">build - Linux</h3>
<p><code>electron-builder</code> 설정의 일부</p>
<pre><code class="language-js">&quot;linux&quot;: {
  &quot;target&quot;: [
    &quot;AppImage&quot;,
    &quot;zip&quot;,
    &quot;tar.gz&quot;
  ],
  &quot;icon&quot;: &quot;./resources/linuxicon&quot;,
  &quot;category&quot;: &quot;Utility&quot;
},</code></pre>
<p>AppImage: 설치 없이 실행 가능한 독립형 애플리케이션 파일
zip 및 tar.gz: 압축 파일 형태
linux.icon: 애플리케이션 아이콘</p>
<p><code>&quot;build:linux64&quot;: &quot;electron-builder --linux --x64&quot;,</code>
target 속성을 통해 Linux에서 어떤 배포 형식(어떤 형태의 패키지)으로 배포할지 지정한다.
AppImage: 설치 없이 실행 가능한 독립형 애플리케이션 파일
deb: Debian 기반 리눅스(Ubuntu 등)에서 사용하는 패키지 형식
아키텍처 지정: arch 속성은 지원할 CPU 아키텍처를 지정한다.
x64: 64비트 인텔/AMD 프로세서
arm64: 64비트 ARM 프로세서(라즈베리파이 등)
이렇게 설정하면 두 가지 배포 형식(AppImage, deb)과 두 가지 아키텍처(x64, arm64)에 대해 총 4개의 다른 빌드 파일이 생성된다.</p>
<ul>
<li>category 필드는 Linux 데스크톱 환경에서 애플리케이션의 분류를 지정한다.
메뉴 분류: Linux 데스크톱 환경(GNOME, KDE 등)의 애플리케이션 메뉴에서 어느 카테고리에 표시될지 결정합니다. &quot;Utility&quot;로 설정되어 있으므로 유틸리티 도구 섹션에 표시된다.
데스크톱 파일 정보: Linux에서는 .desktop 파일에 이 카테고리 정보가 포함되어, 시스템이 애플리케이션을 적절히 분류하고 표시할 수 있게 한다.
참고로 다른 카테고리로는 &quot;Development&quot;, &quot;Graphics&quot;, &quot;Network&quot;, &quot;Office&quot;, &quot;System&quot; 등이 있다.</li>
</ul>
<h3 id="build---nsis-설치-프로그램-설정">build - NSIS 설치 프로그램 설정</h3>
<p>nsis.oneClick: false - 원클릭 설치 대신 사용자 정의 설치 옵션을 제공하는 옵션
nsis.allowToChangeInstallationDirectory: true - 사용자가 설치 디렉토리를 변경할 수 있도록 도와주는 옵션</p>
<h3 id="build---directory">build - directory</h3>
<pre><code class="language-js">&quot;directories&quot;: {
  &quot;buildResources&quot;: &quot;./resources/installer/&quot;,
    &quot;output&quot;: &quot;./dist/&quot;,
      &quot;app&quot;: &quot;.&quot;
}</code></pre>
<p>buildResources: 빌드에 필요한 리소스 파일 위치
output: 빌드된 파일이 저장될 폴더 명
app: 애플리케이션 소스 코드 위치 참고로 <code>.</code>는 현재 디렉토리를 사용한다는 의미임.</p>
<h3 id="keywords">keywords</h3>
<p>keywords 필드는 패키지를 설명하는 키워드 모음이다.</p>
<ol>
<li>검색 최적화: npm에 패키지를 등록했을 때, 다른 개발자들이 관련 키워드로 검색했을 때 발견될 수 있게 도와준다.</li>
<li>패키지 설명: 패키지의 용도나 특성을 간략하게 표현한다. 예를 들어 &quot;electron&quot;, &quot;smartsilver&quot;, &quot;webclient&quot;라는 키워드를 통해 이 애플리케이션이 Electron 기반의 SmartSilver 웹 클라이언트임을 나타낸다.</li>
<li>분류: npm 생태계에서 비슷한 용도의 패키지들과 함께 분류될 수 있도록 한다.</li>
</ol>
<h2 id="자동-실행-스크립트">자동 실행 스크립트</h2>
<p>이제 electron 의 기본 골조와 package.json 을 알아봤으면 linux 환경에서 자동으로 실행할 수 있도록 스크립트를 아래와 같이 작성해주면 된다.</p>
<p><code>/homr/user/.config/autostart/xxx.desktop</code> 에 다음과 같이 작성해주고</p>
<pre><code class="language-bash">[Desktop Entry]
Type=Application
Exec=/home/user/launch-settop.sh
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
Name[en_US]=settop
Name=settop
Comment[en_US]=
Comment=</code></pre>
<p>해당 스크립트를 작성해준다.</p>
<pre><code class="language-bash">#!/bin/bash
LATEST_APP=$(ls -t /home/user/본인앱이름-*.AppImage | head -1)
exec &quot;$LATEST_APP&quot;</code></pre>
<p>그리고 아래 권한을 부여하면</p>
<pre><code class="language-bash">chmod +x /home/user/launch-settop.sh</code></pre>
<p>이제 pc 가 켜지면 자동으로 해당 app 이 실행된다.</p>
<h2 id="npm-run-dev">npm run dev</h2>
<p>우리가 테스트를 할 때 development 환경에서 실행할 때가 있다.
이를 위해 package.json 을 이렇게 바꿔주고, </p>
<pre><code class="language-js">&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;cross-env NODE_ENV=development electron . --enable-logging&quot;
}</code></pre>
<p>Electron 자체의 Linux SUID sandbox 문제를 해소하기 위해 다음과 같이 명령어를 입력해주면 된다.</p>
<pre><code class="language-bash">sudo chown root:root /home/ubuntu/dev/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/ubuntu/dev/node_modules/electron/dist/chrome-sandbox</code></pre>
<p>이를 실행하는 이유는 Linux에서 Electron을 설치하면 sandbox 기능 때문에 일반 사용자로 바로 실행하면 오류가 발생할 수 있기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[norigin-spatial-navigation]]></title>
            <link>https://velog.io/@jeong_woo/norigin-spatial-navigation</link>
            <guid>https://velog.io/@jeong_woo/norigin-spatial-navigation</guid>
            <pubDate>Wed, 10 Sep 2025 07:11:01 GMT</pubDate>
            <description><![CDATA[<h1 id="norigin-spatial-navigation">norigin-spatial-navigation</h1>
<p>리모컨을 웹에서 동작하여 electron 을 사용하여 앱을 만들 수 있다.
그럼 마치 tvOS 처럼 동작하는데 이를 위해 web-client 에서 리모컨을 사용할 수있도록 도와주는 library 가 있다.
이에 <a href="https://github.com/NoriginMedia/Norigin-Spatial-Navigation">Norigin-Spatial-Navigation</a> 을 소개하고자 한다.</p>
<h2 id="norigin-spatial-navigation-1">Norigin-Spatial-Navigation</h2>
<p>Norigin-Spatial-Navigation 은 리모컨 또는 키보드 기반의 UI 탐색(특히 TV 앱)을 위한 React 중심의 <code>spatial navigation</code> 라이브러리이다. 
넷플릭스나 유튜브 TV처럼 리모컨 방향키로 UI 요소 간 이동이 필요한 환경에서 매우 유용하다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/f18422fb-d676-4400-bc34-fd026dc9ffb4/image.gif" alt=""></p>
<h2 id="동작-방식">동작 방식</h2>
<p>위 사진을 보면 이 library 가 어떻게 동작할지 궁금할텐데
이 library 는 기본적으로 DOM 기반으로 동작한다. 
즉, 렌더링된 DOM 요소들의 위치와 크기를 계산하고, 어떤 요소가 포커스 가능한지(tabIndex, data-sn 속성 등)를 파악하여 공간적으로 다음 포커스 위치를 결정한다.</p>
<p>참고로 React key prop 과 focusable 의 key 는 전혀 관련이 없다.</p>
<p><code>useFocusable</code> 훅을 통해 <code>focusKey</code>를 각 컴포넌트에 부여하고 <code>trackChildren</code> 속성을 설정함으로써, 라이브러리는 이 <code>focusKey</code>들을 사용하여 포커스 가능한 요소들을 관리하고, 포커스 이동 시 어떤 요소가 현재 활성화되어 있는지 등을 추적한다. </p>
<p>그리고 이 과정에서 CSS(DOM 요소의 레이아웃 정보)가 포커스 이동 방향을 결정하는 데 중요한 역할을 한다. 예를 들어, 왼쪽/오른쪽 화살표 키를 눌렀을 때, 현재 포커스된 요소의 DOM 위치를 기준으로 &quot;오른쪽에 있는&quot; 다음 포커스 가능한 요소를 찾는다.</p>
<h3 id="install">install</h3>
<pre><code class="language-bash">npm i @noriginmedia/norigin-spatial-navigation --save</code></pre>
<h3 id="usage">usage</h3>
<h4 id="init">init</h4>
<pre><code class="language-ts">useEffect(() =&gt; {
  init({
    // debug: true,
    // visualDebug: true,
  });

  setKeyMap({
    left: [&#39;ArrowLeft&#39;, 37],
    up: [&#39;ArrowUp&#39;, 38],
    right: [&#39;ArrowRight&#39;, 39],
    down: [&#39;ArrowDown&#39;, 40],
    enter: [&#39;Enter&#39;, 13]
  });
}, []);</code></pre>
<p>init 은 본인이 navigate 하고 싶은 모든 요소를 포함하는 부모 컴포넌트에 위치하면 된다.</p>
<h4 id="usefocusable">useFocusable</h4>
<p>이게 핵심 훅이다.</p>
<p>이때 대개 2가지 종류로 작성할 수 있다.</p>
<ol>
<li>Provider</li>
</ol>
<pre><code class="language-tsx">const {ref: busPageRef, focusKey: busInfoPageFocusKey, focusSelf: focusBusInfoPage} = useFocusable({
  focusKey: &quot;bus-page&quot;,
  trackChildren: true,
});

    // 중략

return (
  &lt;FocusContext.Provider value={busInfoPageFocusKey}&gt;
  &lt;Container ref={busPageRef}&gt;
    &lt;BusCard
direction=&quot;left&quot;
stationName={busesInfo.station_1_info.name + &quot; (상행)&quot;}
highlightBusNumber={arrivalSoonBusL}
buses={station1Sorted}
/&gt;
  &lt;BusCard
direction=&quot;right&quot;
stationName={busesInfo.station_2_info.name + &quot; (하행)&quot;}
highlightBusNumber={arrivalSoonBusR}
buses={station2Sorted}
/&gt;
  &lt;/Container&gt;
&lt;/FocusContext.Provider&gt;
);</code></pre>
<ol start="2">
<li><p>Consumer
아래는 위 Provider 의 BusCard 컴포넌트의 일부이다.</p>
<pre><code class="language-tsx">const {ref: busCardRef, focused} = useFocusable({
focusKey: `bus-card-${direction}`,
onArrowPress: (direction) =&gt; {
 if (focused &amp;&amp; scrollContainerRef.current) {
   const container = scrollContainerRef.current;
   const scrollAmount = 100;

   if (direction === &#39;up&#39;) {
     container.scrollTop = Math.max(0, container.scrollTop - scrollAmount);
     return false;
   } else if (direction === &#39;down&#39;) {
     container.scrollTop = Math.min(
       container.scrollHeight - container.clientHeight,
       container.scrollTop + scrollAmount
     );
     return false;
   } else if (direction === &quot;left&quot;) {
     if (getCurrentFocusKey() === &quot;bus-card-left&quot;) {
       setFocus(&quot;busArrived&quot;);
     }
   }
 }
 return true;
}
});
</code></pre>
</li>
</ol>
<p>return (
  &lt;CardWrapper ref={busCardRef} $focused={focused} $direction={direction}&gt;
    <Title>{stationName}</Title>
    &lt;Header $direction={direction}&gt;
      <div>곧 도착</div>
      <div>
        {highlightBusNumber.map((num, idx) =&gt; (
          <span key={idx}>
            &lt;img
              src={direction === &quot;left&quot; ? BusR : BusB}
              alt=&quot;버스&quot;
              style={{width: &quot;1.2rem&quot;, height: &quot;1.2rem&quot;, marginRight: &quot;0.25rem&quot;}}
              /&gt;
            {num}
          </span>
        ))}
      </div>
    </Header>
    &lt;Table ref={scrollContainerRef} $direction={direction}&gt;
      // 생략
    </Table>
    </CardWrapper>
  );</p>
<pre><code>



* 아래는 예시로 최대한 다양한 옵션을 적어보았다.

```ts
const {
  ref,
  focusSelf,
  focusKey,
} = useFocusable({
  focusable: true, // 컨테이너 자체는 포커스 불가
  saveLastFocusedChild: true,
  trackChildren: true,
  autoRestoreFocus: true,
  isFocusBoundary: true,
  focusBoundaryDirections: [&quot;up&quot;, &quot;down&quot;, &quot;left&quot;],
  focusKey: currentRemoteFocus,
  preferredChildFocusKey: undefined,
  extraProps: { foo: &quot;bar&quot; },
});</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Auto Update]]></title>
            <link>https://velog.io/@jeong_woo/Auto-Update</link>
            <guid>https://velog.io/@jeong_woo/Auto-Update</guid>
            <pubDate>Thu, 04 Sep 2025 03:23:22 GMT</pubDate>
            <description><![CDATA[<h1 id="1-gcsgoogle-cloud-storage-서비스">1. GCS(google cloud storage) 서비스</h1>
<p>GCS 는 AWS 의 S3 포지션으로 static 파일을 서빙하는 역할을 한다.
그래서 나는 다음과 같이 pipe-line 을 구상해봤다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/9757b240-ac73-47dd-b32f-e1e0415cffcb/image.png" alt=""></p>
<p>자, 그럼 우선 코드부터 작성해보자.</p>
<h2 id="0-packagejson-수정">0. package.json 수정</h2>
<p>publish 설정은 빌드 시 업데이트 메타데이터 파일을 생성함.</p>
<pre><code>latest.yml (Windows)
latest-mac.yml (macOS)
latest-linux.yml (Linux)</code></pre><p>이 파일들에는 최신 버전 정보, 다운로드 URL, 파일 해시값 등이 포함됨.</p>
<pre><code>{
  &quot;name&quot;: &quot;프로젝트 이름&quot;,
  &quot;version&quot;: &quot;프로젝트 버전&quot;,
  &quot;description&quot;: &quot;프로젝트 설명&quot;
  &quot;main&quot;: &quot;main.js&quot;,
  &quot;scripts&quot;: {
    &quot;start&quot;: &quot;electron .&quot;,
    &quot;dev&quot;: &quot;electron . --enable-logging&quot;,
    &quot;build&quot;: &quot;electron-builder&quot;,
    &quot;dist&quot;: &quot;electron-builder --publish=never&quot;,
    &quot;build:linux64&quot;: &quot;electron-builder --linux --x64&quot;,
    &quot;postinstall&quot;: &quot;electron-builder install-app-deps&quot;,
    &quot;build-publish&quot;: &quot;electron-builder --publish=always&quot;,
    &quot;deploy&quot;: &quot;node deploy-to-gcs.js&quot;
  },
  &quot;author&quot;: &quot;kjw&quot;,
  &quot;license&quot;: &quot;ISC&quot;,
  &quot;devDependencies&quot;: {
    &quot;electron&quot;: &quot;^37.2.5&quot;,
    &quot;electron-builder&quot;: &quot;^26.0.12&quot;,
    &quot;electron-updater&quot;: &quot;^6.6.2&quot;,
    &quot;electron-log&quot;: &quot;5.4.3&quot;
  },
  &quot;build&quot;: {
    &quot;appId&quot;: &quot;앱 id&quot;,
    &quot;productName&quot;: &quot;SmartSilverClient&quot;,
    &quot;directories&quot;: {
      &quot;output&quot;: &quot;dist&quot;
    },
    &quot;files&quot;: [
      &quot;**/*&quot;
    ],
    &quot;publish&quot;: {
      &quot;provider&quot;: &quot;generic&quot;,
      &quot;url&quot;: &quot;https://storage.cloud.google.com/tv-client-releases/&quot;
    },
    &quot;linux&quot;: {
      &quot;target&quot;: [
        &quot;AppImage&quot;
      ],
      &quot;category&quot;: &quot;Utility&quot;
    }
  },
  &quot;type&quot;: &quot;commonjs&quot;,
  &quot;packageManager&quot;: &quot;npm&quot;,
  &quot;dependencies&quot;: {
    &quot;@google-cloud/storage&quot;: &quot;^7.17.0&quot;
  }
}
</code></pre><p>provider와 url 옵션의 역할</p>
<p>provider: &quot;generic&quot;: 커스텀 서버를 사용한다는 의미 (GitHub, S3 등이 아닌)
url: 업데이트 파일들이 위치한 베이스 URL
electron-updater는 이 URL에 /latest.yml을 붙여서 메타데이터를 가져옴.
참고로 파일 url 은 AppImage 가 들어있는 폴더까지 작성해주면 된다.</p>
<h2 id="1-google-cloudstorage-를-사용한-자동-deploy-로직-작성">1. @google-cloud/storage 를 사용한 자동 deploy 로직 작성</h2>
<p><a href="https://www.npmjs.com/package/@google-cloud/storage">@google-cloud/storage</a> 에서 다운 받으면 되고 <a href="https://github.com/googleapis/nodejs-storage">공식문서</a>에서 upload 부분을 보면 된다.</p>
<pre><code class="language-js">const {Storage} = require(&quot;@google-cloud/storage&quot;);
const fs = require(&#39;fs&#39;);
const path = require(&#39;path&#39;);
const {log} = require(&quot;electron-log&quot;);

const storage = new Storage({
    keyFilename: &#39;&lt;본인 서비스 계정 .json&gt;&#39;,
    projectId: &#39;&lt;본인 project id&gt;&#39;
});

const bucketName = &#39;&lt;본인 버킷 이름&gt;&#39;;
const bucket = storage.bucket(bucketName);

async function uploadFiles() {
    const distPath = &#39;./dist&#39;; // 본인 build 결과물 통상 ./dist
    const files = fs.readdirSync(distPath);

    // 본인은 linux 만 사용하여 AppImage 와 -linux.yml 만 등록
    const targetFiles = files.filter(file =&gt;
        file.endsWith(&#39;.AppImage&#39;) || file.endsWith(&#39;-linux.yml&#39;)
    );

    for (const file of targetFiles) {
        const filePath = path.join(distPath, file);
        const destination = file;

        try {
            await bucket.upload(filePath, {
                destination: destination,
                metadata: {
                    cacheControl: &#39;no-cache&#39;,
                },
            });
            log(`✅ ${file} uploaded to ${bucketName}/${destination}`);
        } catch (error) {
            error(`❌ Failed to upload ${file}:`, error);
        }
    }
}

uploadFiles().catch(console.error);</code></pre>
<p>참고로 electron 은 빌드하면 본인이 package.json 을 어떻게 꾸렸냐에 따라 빌드 결과물이 바뀐다.
만약 크로스 빌딩 등을 위해 모든 OS 에 대한 정보를 작성했다면 아래와 같은 결과물이 나온다.</p>
<pre><code>버킷이름/
├── 폴더이름/
│   ├── latest.yml (Windows)
│   ├── latest-mac.yml (macOS)
│   ├── latest-linux.yml (Linux)
│   ├── your-app-1.0.0.exe
│   ├── your-app-1.0.0.dmg
│   └── your-app-1.0.0.AppImage</code></pre><p>그리고 작성한 파일을 명령어로써 등록해주면 된다.</p>
<pre><code class="language-js">  // 윗줄 생략

  &quot;main&quot;: &quot;main.js&quot;,
  &quot;scripts&quot;: {
    &quot;start&quot;: &quot;electron .&quot;,
    &quot;dev&quot;: &quot;electron . --enable-logging&quot;,
    &quot;build&quot;: &quot;electron-builder&quot;,
    &quot;dist&quot;: &quot;electron-builder --publish=never&quot;,
    &quot;build:linux64&quot;: &quot;electron-builder --linux --x64&quot;,
    &quot;postinstall&quot;: &quot;electron-builder install-app-deps&quot;,
    &quot;build-publish&quot;: &quot;electron-builder --publish=always&quot;,
    &quot;deploy&quot;: &quot;node deploy-to-gcs.js&quot;
  },
  &quot;author&quot;: &quot;kjw&quot;,
  &quot;license&quot;: &quot;ISC&quot;,

  // 아랫줄 생략</code></pre>
<h2 id="2-mainjs-에-자동-update-기능-넣어주기">2. main.js 에 자동 update 기능 넣어주기</h2>
<p>보통은 이제 해당 electron App 이 뜰 때, update 여부를 yml 로 검사하여 다르다면 이벤트에 따라 분기처리해주면 된다.</p>
<pre><code class="language-js">app.whenReady().then(() =&gt; {
    createWindow();

    // macOS에서 독 아이콘 클릭 시 윈도우 재생성
    app.on(&#39;activate&#39;, () =&gt; {
        if (BrowserWindow.getAllWindows().length === 0) {
            createWindow();
        }
    });

    // 디버깅 메뉴 생성
    createMenu();

    // 앱 실행 후 업데이트 확인
    if (!isDev) {
        setTimeout(() =&gt; {
            log.info(&#39;업데이트 확인을 시작합니다...&#39;);
            autoUpdater.checkForUpdatesAndNotify();
        }, 5000);
    }
});</code></pre>
<p>업데이터 설정</p>
<pre><code class="language-js">autoUpdater.on(&#39;checking-for-update&#39;, () =&gt; {
    log.info(&#39;업데이트 확인 중...&#39;);
});

autoUpdater.on(&#39;update-available&#39;, (info) =&gt; {
    log.info(`새 업데이트가 있습니다: v${info.version}`);
    if (mainWindow) {
        mainWindow.webContents.send(&#39;update-available&#39;, info.version);
    }
});

autoUpdater.on(&#39;update-not-available&#39;, (info) =&gt; {
    log.info(`현재 최신 버전입니다: v${info.version}`);
});

autoUpdater.on(&#39;error&#39;, (err) =&gt; {
    log.error(&#39;업데이트 에러:&#39;, err);
});

autoUpdater.on(&#39;download-progress&#39;, (progressObj) =&gt; {
    const percent = Math.round(progressObj.percent);
    log.info(`업데이트 다운로드 중: ${percent}% (${progressObj.transferred}/${progressObj.total} bytes)`);

    if (mainWindow) {
        mainWindow.webContents.send(&#39;download-progress&#39;, percent);
    }
});

autoUpdater.on(&#39;update-downloaded&#39;, (info) =&gt; {
    log.info(&#39;업데이트 다운로드 완료. 5초 후 재시작합니다.&#39;);

    if (mainWindow) {
        mainWindow.webContents.send(&#39;update-ready&#39;);
    }

    setTimeout(() =&gt; {
        autoUpdater.quitAndInstall();
    }, 5000);
});</code></pre>
<p>setFeedURL()로 동적 설정 (런타임에 설정)
package.json의 publish 설정 (빌드타임에 설정)
main.js에서 setFeedURL 제거하고, package.json만 사용
단, 동적으로 URL을 변경해야 하는 경우에만 setFeedURL 사용</p>
<h3 id="옵션rendererjs-를-운용한다면-ㄱ-ui-업뎃">(옵션.renderer.js 를 운용한다면) ㄱ. UI 업뎃</h3>
<pre><code class="language-js">// renderer.js
const { ipcRenderer } = require(&#39;electron&#39;);

// 수동 업데이트 체크 버튼
document.getElementById(&#39;check-update&#39;).addEventListener(&#39;click&#39;, () =&gt; {
  ipcRenderer.send(&#39;check-for-updates&#39;);
});

// 업데이트 상태 수신
ipcRenderer.on(&#39;update-available&#39;, () =&gt; {
  document.getElementById(&#39;update-status&#39;).textContent = &#39;새 업데이트가 있습니다. 다운로드 중...&#39;;
});

ipcRenderer.on(&#39;update-downloaded&#39;, () =&gt; {
  document.getElementById(&#39;update-status&#39;).textContent = &#39;업데이트가 준비되었습니다. 재시작합니다.&#39;;
});</code></pre>
<h3 id="옵션rendererjs-를-운용한다면-ㄴ-main-에서-핸들링">(옵션.renderer.js 를 운용한다면) ㄴ. Main 에서 핸들링</h3>
<pre><code class="language-js">ipcMain.handle(&quot;check-for-updates&quot;, async () =&gt; {
    if (!isDev) {
        try {
            return await autoUpdater.checkForUpdatesAndNotify();
        } catch (error) {
            log(&#39;업데이트 체크 실패:&#39;, error);
            throw error;
        }
    }
    return null;
});

ipcMain.handle(&quot;quit-and-install&quot;, () =&gt; {
    if (!isDev) {
        autoUpdater.quitAndInstall();
    }
});</code></pre>
<h2 id="3-gcs-설정하기">3. GCS 설정하기</h2>
<p>여기서는 서비스 계정을 생성해줘야한다.</p>
<h3 id="서비스-계정이란">서비스 계정이란?</h3>
<p>사람이 아닌 워크로드(앱, 배치, 컨테이너, VM)가 GCP 리소스에 안전하게 접근하기 위한 전용 신원이다. 
개발자 개인 계정 대신, 애플리케이션 자체가 인증·권한 부여를 받을 수 있게 해주는 bot 이라고 생각하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/45c20e8d-59c7-41ae-bc87-950bc6cbb47f/image.png" alt=""></p>
<p>계정을 생성 후 json 을 다운 받으면 된다.
다음 원하는 버킷으로 가서 <code>엑세스 권한 부여</code> 를 누른 다음, 생성한 서비그 계정의 e-mail 입력해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/c15d4327-b529-4d5a-b461-386fd35d6d38/image.png" alt=""></p>
<p>그 후 오른쪽 상속 편집을 눌러 권한을 다음과 같이 지정해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/9296807e-ce88-459d-b2de-7df97dc269ef/image.png" alt=""></p>
<h2 id="4-build-및-deploy">4. build 및 deploy</h2>
<p>그리고 다시 deploy 로직에서 본인의 프로젝트 id, 본인 서비스 계정의 .json 을 등록해준 후 build, deploy 를 해주면 성공적으로 static 파일이 올라간 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/f54db455-9cfa-48ed-9e72-f6587ce5d186/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Android SDK 설치 non-ASCII 에러]]></title>
            <link>https://velog.io/@jeong_woo/Android-SDK-%EC%84%A4%EC%B9%98-non-ASCII-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@jeong_woo/Android-SDK-%EC%84%A4%EC%B9%98-non-ASCII-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Fri, 29 Aug 2025 01:49:02 GMT</pubDate>
            <description><![CDATA[<p>신규 회사 pc 를 받았는데 사용자 이름이 한국어로 되어있는 불상사가 생겼다.
첫 출근도 전이라 내가 손쓸 틈 없이 이름이 한국어로 설정되어버렸는데 이때 밀고 다시 설치했어야했는데... 또 2,3 시간을 날릴 순 없어서 그냥 받았다.</p>
<p>하지만 아니나 다를까 결국 문제가 생겼다. </p>
<p>이를 우회하는 방법은 다행이 굉장히 간단하다.
Android Studio 는 Android\Sdk\ 를 사용하기 때문에 해당 directory 들을 미리 만들어 두고 symbolic link 만 만들면 된다.</p>
<pre><code class="language-bash">mklink /D &quot;C:\Android_Sdk&quot; &quot;C:\Users\강정우\AppData\Local\Android\Sdk&quot;</code></pre>
<ul>
<li>옵션들
<code>/D</code> : 디렉토리 심볼릭 링크를 생성 (Directory symbolic link)
<code>/H</code> : 하드 링크를 생성 (Hard link)
<code>/J</code> : 디렉토리 정션을 생성 (Directory junction)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/04ac7646-9882-4ed0-b0d7-0017dbc3d983/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/c77d24f1-37c7-49b2-bbee-0f21b4e07d4c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/6000b69d-7f33-4003-b4c3-04180fbc8503/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2 WinSCP]]></title>
            <link>https://velog.io/@jeong_woo/EC2-WinSCP</link>
            <guid>https://velog.io/@jeong_woo/EC2-WinSCP</guid>
            <pubDate>Mon, 18 Aug 2025 05:34:17 GMT</pubDate>
            <description><![CDATA[<h1 id="1-key-생성만으로-winscp-접속이-안-됨">1. key 생성만으로 WinSCP 접속이 안 됨.</h1>
<p>보통 기록들을 보면 instance 최초 생성 시 key 만을 등록한다.
하지만 집, 회사 굉장히 다양한 곳에서 접근할 수 있다.
그럴 땐 새로 키를 생성해서 그걸로 접근만 하면 되는 것이 아닌, 이를 새로 등록을 해줘야한다.</p>
<h1 id="2-공개키-등록의-원리">2. 공개키 등록의 원리</h1>
<p>SSH 서버는 접속할 때 클라이언트의 비밀키로 만든 전자서명을 검증한다.</p>
<p>검증하려면 서버가 매칭되는 공개키를 알고 있어야 하는데, 그게 바로 <code>~/.ssh/authorized_keys</code> 파일에 들어간다.</p>
<p>따라서 여기에 등록을 해주면 된다.</p>
<pre><code class="language-ssh">echo &quot;ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ...생략...&quot; &gt;&gt; ~/.ssh/authorized_keys</code></pre>
<blockquote>
<p><code>&gt;&gt;</code> 를 쓰는 이유는 기존 키들을 지우지 않고 새 키를 &quot;추가&quot;하기 위함이다.
만약 <code>&gt;</code> 를 쓰면 기존 키들이 날아가니까 조심해야 한다.</p>
</blockquote>
<h1 id="3-권한-확인">3. 권한 확인</h1>
<p>아마 AWS 는 필요없을 것 같긴 한데 SSH는 권한에 굉장히 민감하기 때문에 조치 후 아래 권한대로 맞추는 것을 권장한다.</p>
<pre><code class="language-bash">chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[ObjectBox 사용법]]></title>
            <link>https://velog.io/@jeong_woo/ObjectBox-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@jeong_woo/ObjectBox-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Sat, 02 Aug 2025 07:52:42 GMT</pubDate>
            <description><![CDATA[<p>최근 flutter 의 DB 에 관해 알아보는 중 Hive 를 알게 되었다.
그러나 지원을 중단한 듯 보였다. 
<img src="https://velog.velcdn.com/images/jeong_woo/post/a904551a-1276-4975-bc45-bb1e3c6b6295/image.png" alt=""></p>
<p>무려 개발중인 버전도 2년 전이 마지막 업데이트다.
이때, 다른 대안이 없을까 서치하다가 완벽한 대안인 Object Box 에 대해 알게되었다.</p>
<h1 id="1-objectbox-개념">1. ObjectBox 개념</h1>
<p>ObjectBox는 순수 Dart 객체를 그대로 영속화하면서 SQLite 대비 최대 10배 빠른 성능을 제공하는 ACID 준수, 임베디드 NoSQL-DB 이다. 
말은 SQL이 필요없지만 JPA 와 결이 비슷하다고 느낀 나로서는 아무래도 오히려 SQL 에 어느정도 지식이 있어야 사용이 용이하겠다고 판단하였다.
또 다른 장점은 필요 시 데이터 동기화·벡터검색까지 확장할 수 있다.</p>
<pre><code>|구분|요약|비고|
|:---:|:---:|:--:|
|데이터 모델|    @Entity 클래스로 정의, @Id() 자동증가 기본값    |Null-safety 완전 지원|
|저장소(Store)|    Store 객체 1개가 DB 파일과 연동    |openStore() 헬퍼 함수 권장|
|Box|    store.box&lt;T&gt;()로 얻는 엔티티 단위 DAO    |CRUD·쿼리·트랜잭션 담당|
|쿼리|    타입 안전 QueryBuilder / 플러그식 조건 API|    findAsync()로 격리 Isolate 실행 가능|
|관계(Link)|    ToOne·ToMany 내장, LAZY 로딩 지원    |양방향은 Backlink 사용|
|반응형|    query.watch() 또는 box.watch() → Stream|    UI 자동 갱신에 용이|
|코드 생성|    build_runner로 objectbox.g.dart 생성|    변경 시마다 재실행 필요|</code></pre><h1 id="2-define-entity">2. define entity</h1>
<pre><code class="language-dart">import &#39;package:objectbox/objectbox.dart&#39;;

@Entity()
class Person {
  @Id()
  int id = 0;
  String firstName;
  String lastName;

  Person({required this.firstName, required this.lastName});
}</code></pre>
<p>이때, id가 0이면 put() 호출 시 자동 할당
JPA와 동일하게 @Entity()는 클래스, @Id()는 식별자이며, 이 둘은 필수이다.
원시 타입(Int, double, bool, String, DateTime, Uint8List) 및 조인을 위한 ToOne, ToMany 도 지원된다.</p>
<h1 id="3-generate-code">3. generate code</h1>
<pre><code class="language-dart">flutter pub run build_runner build --delete-conflicting-outputs</code></pre>
<p>이제 entity 를 다 작성하였다면 위 명령어를 실행한다.</p>
<p>그러면 <code>objectbox.g.dart</code>, <code>objectbox-model.json</code>이 생성된다.
analyzer 버전 불일치 등으로 78 오류가 나면 objectbox_generator 최신 버전으로 올리거나 캐시를 지운다.</p>
<h1 id="4-init-store">4. init store</h1>
<pre><code class="language-dart">import &#39;objectbox.g.dart&#39;; // 반드시 포함
import &#39;package:path_provider/path_provider.dart&#39;;
import &#39;package:path/path.dart&#39; as p;

late final Store store;

Future&lt;void&gt; initStore() async {
    final dir = await getApplicationSupportDirectory(); // Android는 /files
    store = await openStore(directory: p.join(dir.path, &#39;person_db&#39;)); // 권장
}</code></pre>
<h1 id="5-crud">5. CRUD</h1>
<h3 id="1-box-가져오기">1. Box 가져오기</h3>
<pre><code class="language-dart">final personBox = store.box&lt;Person&gt;();</code></pre>
<h3 id="2-create-·-read-·-update-·-delete">2. Create · Read · Update · Delete</h3>
<pre><code class="language-dart">// C
final id = personBox.put(Person(firstName: &#39;Jane&#39;, lastName: &#39;Doe&#39;)); 
// R
final jane = personBox.get(id);
// U
jane!.lastName = &#39;Black&#39;;
personBox.put(jane);
// D
personBox.remove(jane.id);</code></pre>
<p>put()은 내부 트랜잭션 포함; 대량 삽입은 putMany(list) 사용해 속도 10배 이상 향상</p>
<h3 id="3-querybuilder">3. QueryBuilder</h3>
<p>이 부분이 바로 sql 을 몰라도 된다고 하는데 sql 을 알아야 query 를 작성할 수 있을 것으로 보인다.
물롱 sql 과 nosql 간의 괴리가 있을 수 있겠다.</p>
<pre><code class="language-dart">final query = personBox
  .query(Person_.lastName.equals(&#39;Black&#39;) &amp;
         Person_.firstName.startsWith(&#39;J&#39;))
  .order(Person_.id, flags: Order.descending) // 정렬
  .build();

final results = query.find();  // List&lt;Person&gt;
query.close();                 // 리소스 해제</code></pre>
<p><code>AND</code>, <code>OR</code> 은 <code>&amp;,</code> <code>|</code> 연산자로 표현한다.
참고로 pagenation 을 위한 오프셋, 제한은 <code>query.offset = 40;</code> <code>query.limit = 20;</code></p>
<h3 id="4-비동기-쿼리">4. 비동기 쿼리</h3>
<pre><code class="language-dart">final people = await query.findAsync(); // Isolate에서 실행</code></pre>
<h1 id="6-relations">6. Relations</h1>
<h3 id="1-toone">1. ToOne</h3>
<pre><code class="language-dart">@Entity()
class Order {
  int id = 0;
  final customer = ToOne&lt;Customer&gt;(); // Target entity
}

@Entity()
class Customer {
  int id = 0;
  String name;
}</code></pre>
<pre><code class="language-dart">order.customer.target = customerObj;
box.put(order) </code></pre>
<p>하면 두 객체 모두 저장.</p>
<h3 id="2-tomany--backlink">2. ToMany &amp; Backlink</h3>
<pre><code class="language-dart">@Entity()
class Tag {
  int id = 0;
  String name;
  @Backlink(&#39;tags&#39;)
  final tasks = ToMany&lt;Task&gt;();
}

@Entity()
class Task {
  int id = 0;
  String title;
  final tags = ToMany&lt;Tag&gt;();
}</code></pre>
<p>Backlink는 반대편에서 자동으로 역참조 컬렉션 생성.</p>
<h3 id="3-relation-query">3. Relation Query</h3>
<pre><code class="language-dart">final urgentTasks = taskBox
  .query(Task_.tags.contains(Tag_.name, &#39;urgent&#39;))
  .build()
  .find();</code></pre>
<h1 id="7-반응형-스트림">7. 반응형 스트림</h1>
<p>ObjectBox는 데이터 변경을 Stream으로 내보내 UI를 실시간으로 업데이트할 수 있다.</p>
<pre><code class="language-dart">final query = personBox.query().build();
final subscription = query.watch(triggerImmediately: true)
  .listen((Query&lt;Person&gt; q) {
    final list = q.find();
    setState(() =&gt; persons = list);
  });</code></pre>
<p>triggerImmediately 옵션으로 초기 데이터 전송.
limit을 사용하려면 query.limit = 10 후 watch() 호출.
subscription.cancel() 시 Query 자동 close.</p>
<h1 id="8-트랜잭션-및-동시성">8. 트랜잭션 및 동시성</h1>
<pre><code class="language-dart">store.runInTransaction(TxMode.write, () {
  for (final p in persons) personBox.put(p);
});</code></pre>
<p>다트 Isolate 전송용 runInTransactionAsync()도 제공해 CPU 탐색 쿼리를 오프로드할 수 있다.
CPU 집약적인 데이터베이스 작업을 메인(UI) 스레드에서 직접 실행하지 않고, <strong>별도의 Isolate(백그라운드 스레드)</strong>에서 실행하도록 넘길 수 있다는 의미이다.</p>
<p>작업이 다른 Isolate에서 진행되는 동안, 메인 UI 스레드는 계속해서 사용자 인터페이스를 원활하게 갱신하고 사용자 입력을 처리할 수 있다. 그 후 무거운 작업이 완료되면, 그 결과만 메인 스레드로 다시 전달받게 된다.</p>
<h1 id="9-마이그레이션스키마-업데이트하는-법">9. 마이그레이션(스키마 업데이트)하는 법</h1>
<ol>
<li><code>엔티티·필드 이름 변경</code>: 원본에 @Id() 값 유지, 새 필드 추가 후 build_runner 재실행하면 자동 마이그레이션.</li>
<li><code>삭제</code>: <code>필드를 주석 처리 후 빌드</code> → <code>앱 배포</code> → <code>코드에서 제거</code> 이렇게 귀찮지만 2-step 절차 권장. 
바로 삭제하면 ID 재사용 충돌 가능.</li>
<li>스키마 충돌 오류(예: last index ID 1 is higher than 0)는 모델 JSON·DB 파일 간 불일치로 발생한다. 
사전 모델 동기화 후 배포해야 한다.</li>
</ol>
<h1 id="10-성능-최적화">10. 성능 최적화</h1>
<table>
<thead>
<tr>
<th align="center">팁</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">인덱스 추가</td>
<td align="center">@Index() 애너테이션으로 검색 속도 향상</td>
</tr>
<tr>
<td align="center">대량 작업</td>
<td align="center">putMany()·runInTransaction() 사용, 거래당 I/O 최소화</td>
</tr>
<tr>
<td align="center">쿼리 재사용</td>
<td align="center">Query 인스턴스 캐싱, 필요 시 query.reset()으로 매개변수만 교체</td>
</tr>
<tr>
<td align="center">스트림 최소화</td>
<td align="center">동일 박스에 다수 watch를 두면 비용 증가, 가능하면 하나의 Query로 합산</td>
</tr>
<tr>
<td align="center">디렉터리 지정</td>
<td align="center">데스크톱·테스트는 고유 path 지정해 파일 충돌·삭제 방지</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[nginx prod level 설정하기 (feat. Django)]]></title>
            <link>https://velog.io/@jeong_woo/nginx-prod-level-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-feat.-Django</link>
            <guid>https://velog.io/@jeong_woo/nginx-prod-level-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-feat.-Django</guid>
            <pubDate>Thu, 24 Jul 2025 01:33:00 GMT</pubDate>
            <description><![CDATA[<h1 id="1-django-설정">1. Django 설정</h1>
<p>설정 중 DB 풀 설정, 로깅등은 제외하고 네트워크와 관련된 부분 알아보자.</p>
<h2 id="1-기본-및-핵심-설정">1. 기본 및 핵심 설정</h2>
<pre><code class="language-py">from .base import *
import os

DEBUG = False
ALLOWED_HOSTS = os.getenv(&#39;ALLOWED_HOSTS&#39;, &#39;&#39;).split(&#39;,&#39;)
SWAGGER_ENABLED = False</code></pre>
<p><code>ALLOWED_HOSTS = os.getenv(&#39;ALLOWED_HOSTS&#39;, &#39;&#39;).split(&#39;,&#39;)</code>: Host header attacks 방어.</p>
<h2 id="2-보안-헤더-설정">2. 보안 헤더 설정</h2>
<pre><code class="language-py"># 보안 헤더 설정
SECURE_CROSS_ORIGIN_OPENER_POLICY = &quot;same-origin&quot;
SECURE_REFERRER_POLICY = &#39;strict-origin-when-cross-origin&#39;
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True</code></pre>
<p>웹 애플리케이션의 기본적인 보안을 강화</p>
<p><code>SECURE_CROSS_ORIGIN_OPENER_POLICY = &quot;same-origin&quot;</code>: 다른 출처의 팝업 창이 현재 문서에 접근하는 것을 제한한다. 
Clickjacking과 같은 공격을 완화하는 데 도움이 된다.</p>
<p><code>SECURE_REFERRER_POLICY = &#39;strict-origin-when-cross-origin&#39;</code>: Referrer 정보를 보낼 때의 정책을 정의한다.
다른 출처로 요청을 보낼 때만 오리진 정보를 포함하고, HTTPS에서 HTTP로 요청할 때는 보내지 않아 민감 정보 노출을 방지한다.</p>
<p><code>SECURE_CONTENT_TYPE_NOSNIFF = True</code>: 브라우저가 MIME type sniffing을 통해 콘텐츠 타입을 유추하는 것을 방지한다. 
이는 악의적인 콘텐츠가 잘못된 타입으로 해석되어 실행되는 것을 막는다.</p>
<p><code>SECURE_BROWSER_XSS_FILTER = True</code>: 브라우저 내장 XSS(Cross-Site Scripting) 필터를 활성화하여 XSS 공격을 방어한다.</p>
<h2 id="3-https-설정">3. HTTPS 설정</h2>
<pre><code class="language-py"># HTTPS 설정
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000  # 1년
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True</code></pre>
<p>Nginx에서 이미 HTTPS 리다이렉션과 HSTS를 처리하고 있으면 필요없음 하지만 애플리케이션 레벨에서도 설정하여 혹시 모를 누락을 방지</p>
<p><code>SECURE_HSTS_SECONDS = 31536000 # (1년)</code>: HSTS(HTTP Strict Transport Security)를 활성화. 
브라우저가 특정 기간(여기서는 1년) 동안 이 도메인에 대한 모든 요청을 HTTPS로만 보내도록 강제. 
이는 중간자 공격(Man-in-the-Middle)을 통한 SSL 스트리핑 공격을 방지.</p>
<p><code>SECURE_HSTS_INCLUDE_SUBDOMAINS = True</code>: HSTS 정책을 모든 서브도메인에도 적용한다.</p>
<p><code>SECURE_HSTS_PRELOAD = True</code>: HSTS Preload List에 등록될 자격을 부여한다. 
Preload List에 등록되면, 사용자가 도메인에 최초 접속 시에도 HTTP 요청을 보내지 않고 바로 HTTPS로 연결을 시도하게 된다.</p>
<h2 id="4-세션-및-csrf-보안">4. 세션 및 CSRF 보안</h2>
<pre><code class="language-py"># 세션 보안
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = &#39;Strict&#39;  # 최고 보안

# CSRF 보안
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = &#39;Strict&#39;  # 최고 보안</code></pre>
<p>쿠키를 통한 세션 및 CSRF 토큰 관리에 대한 보안 강화</p>
<p><code>SESSION_COOKIE_SECURE = True</code>: 세션 쿠키가 HTTPS 연결을 통해서만 전송되도록</p>
<p><code>SESSION_COOKIE_HTTPONLY = True</code>: 자바스크립트가 세션 쿠키에 접근하는 것을 방지.
XSS 공격 시 쿠키 탈취를 어렵게 만듦</p>
<p><code>SESSION_COOKIE_SAMESITE = &#39;Strict&#39;</code>: CSRF(Cross-Site Request Forgery) 공격을 방지하는 강력한 방법
&#39;Strict&#39; 모드는 동일 사이트 요청(Same-site request)에서만 쿠키가 전송되도록 한다. 
(예: 다른 사이트에서 링크를 통해 접속하더라도 해당 사이트의 쿠키는 전송되지 않음.) 
이는 보안을 크게 강화하지만, 경우에 따라 Lax 모드가 더 적합할 수도 있음.</p>
<p><code>CSRF_COOKIE_SECURE = True, CSRF_COOKIE_HTTPONLY = True, CSRF_COOKIE_SAMESITE = &#39;Strict&#39;</code>: CSRF 토큰 쿠키에도 세션 쿠키와 동일한 강력한 보안 설정이 적용</p>
<h2 id="5-cors-설정">5. CORS 설정</h2>
<pre><code class="language-py"># CORS 설정 (프로덕션 - 제한적)
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = os.getenv(&#39;CORS_ALLOWED_ORIGINS&#39;, &#39;&#39;).split(&#39;,&#39;)
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_METHODS = ( ... )
CORS_ALLOW_HEADERS = ( ... )</code></pre>
<p><code>CORS_ALLOW_ALL_ORIGINS = False</code>: 모든 출처에서의 CORS 요청을 허용하지 않음.</p>
<h1 id="2-nginx-설정">2. nginx 설정</h1>
<p>보통은 <code>/api/</code> 로 경로를 나누어 요청하는 것이 일반적이지만 팀원의 요청으로 인하여 <code>:8000</code> 로 분기처리를 하였다.</p>
<pre><code class="language-ini">server {
    listen 80;
    listen [::]:80;
    server_name cnn.parking.monster;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name cnn.parking.monster;

    # SSL 설정 (참고로 아래 경로들은 cerbot 를 사용했다면 기본 경로들)
    ssl_certificate /etc/letsencrypt/live/[도메인 네임]/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/[도메인 네임]/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4 valid=300s;
    resolver_timeout 5s;

    # 보안 헤더 (옵션, 권장)
    add_header Strict-Transport-Security &quot;max-age=31536000; includeSubDomains; preload&quot; always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header X-XSS-Protection &quot;1; mode=block&quot; always;
    add_header Referrer-Policy &quot;strict-origin-when-cross-origin&quot; always;

    # 파일 업로드 크기 제한 (옵션)
    client_max_body_size 10M;

    # 타임아웃 설정 (옵션)
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;

    # API 엔드포인트 (옵션 -&gt; 여기선 8000 포트로 나눔.)
    location /api/ {
        proxy_pass http://unix:/run/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # WebSocket 지원 (옵션)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;

        # 버퍼링 설정 (옵션)
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # 정적 파일 서빙
    location / {
        root [build 파일 위치];
        try_files $uri $uri/ /index.html;

        # 404 에러 처리
        error_page 404 /index.html;

        # HTML 파일 캐싱 방지
        location ~* \.html$ {
            expires -1;
            add_header Cache-Control &quot;no-cache, no-store, must-revalidate, proxy-revalidate&quot;;
            add_header Pragma &quot;no-cache&quot;;
            add_header Last-Modified $date_gmt;
            add_header ETag &quot;&quot;;
            if_modified_since off;
        }

        # 정적 자원 캐싱
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot|map)$ {
            expires 1y;
            add_header Cache-Control &quot;public, immutable&quot;;
            add_header Vary &quot;Accept-Encoding&quot;;

            # Gzip 압축 활성화 (옵션)
            gzip_static on;
        }
    }

    # 로그 설정
    access_log /var/log/nginx/cnn.parking.monster.access.log;
    error_log /var/log/nginx/cnn.parking.monster.error.log;
}

server {
    listen 8000 ssl http2;
    listen [::]:8000 ssl http2;
    server_name [도메인 네임];

    # SSL 설정
    ssl_certificate /etc/letsencrypt/live/[도메인 네임]/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/[도메인 네임]/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # 보안 헤더
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;

    # 파일 업로드 크기 제한 (옵션)
    client_max_body_size 10M;

    # 타임아웃 설정
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;

    location / {
        proxy_pass http://unix:/run/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # HTTP 버전 및 버퍼링 설정
        proxy_http_version 1.1;
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # 로그 설정
    access_log /var/log/nginx/cnn.parking.monster.8000.access.log;
    error_log /var/log/nginx/cnn.parking.monster.8000.error.log;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[GCP 파일 전송하기 (feat. WinSCP)]]></title>
            <link>https://velog.io/@jeong_woo/GCP-%ED%8C%8C%EC%9D%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/GCP-%ED%8C%8C%EC%9D%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 24 Jul 2025 01:04:14 GMT</pubDate>
            <description><![CDATA[<h1 id="1-cloud-storage-버킷">1. Cloud Storage (버킷)</h1>
<p>사실 가장 간단한 방법은 bucket 을 사용하는 것이다.</p>
<p>로컬 → Cloud Storage 업로드</p>
<pre><code class="language-bash">gsutil cp localfile.txt gs://your-bucket-name/</code></pre>
<p>Cloud Storage → 로컬 다운로드</p>
<pre><code class="language-bash">gsutil cp gs://your-bucket-name/file.txt .
gsutil은 Cloud SDK를 설치하면 함께 제공됩니다.</code></pre>
<p>하지만 이는 무료가 아니다.
따라서 우리는 local PC 에서 파일을 WinSCP 를 사용하여 GCP 로 보내는 방법에 대알아보자.</p>
<h1 id="2-winscp">2. WinSCP</h1>
<h2 id="1-key-gen">1. key gen</h2>
<p>보통 window 환경이니 <a href="https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html">putty</a> 를 설치하자. 
보통 intel 일테니 64bit installer 를 설치하고 putty 를 설치하면 putty Gen 도 함께 설치되는데 여기서 가장 많이 사용하는 RSA 방식으로 키를 생성하자. </p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/56910fd2-5bf8-4892-b8a2-23be9e199044/image.png" alt=""></p>
<blockquote>
<p>그리고 key commnet 는 본인 gcp 계정 이름을 넣어줘야지 gcp 설정에서 key 를 넣고 이 comment 를 로그인한 gcp id 와 대조하여 로그인을 시켜준다.</p>
</blockquote>
<p>참고로 위 public key 는 복사하지 않아도 그냥 private key 를 load 해도 다시 나오니 걱정 안 해도 된다.</p>
<h2 id="2-gcp-등록">2. gcp 등록</h2>
<p>일단 위 public key 를 복사하고 gcp로 가서 수정에 들어간 다음 </p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/6dc24198-ad2e-422a-8f52-ad365dec6b53/image.png" alt=""></p>
<p>스크롤을 한참 내리면 기본적으로 2개의 key 가 들어있는데 이제 마지막에 방금 생성한 키를 넣어주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/002dce79-04fc-4bda-a76f-be66ec9c7867/image.png" alt=""></p>
<h2 id="3-winscp-로-접속">3. WinSCP 로 접속</h2>
<p>WinSCP 접속 후 고급 -&gt; 개인키 파일 -&gt; 로그인 순서대로 하면 된다.
<img src="https://velog.velcdn.com/images/jeong_woo/post/762e6cda-c33f-4fd5-8c80-e0664cfed8bd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/d9fecbe9-dfe0-4960-bee9-9ff6ce210267/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Web 화상회의 기술에 기본]]></title>
            <link>https://velog.io/@jeong_woo/Web-%ED%99%94%EC%83%81%ED%9A%8C%EC%9D%98-%EA%B8%B0%EC%88%A0%EC%97%90-%EA%B8%B0%EB%B3%B8</link>
            <guid>https://velog.io/@jeong_woo/Web-%ED%99%94%EC%83%81%ED%9A%8C%EC%9D%98-%EA%B8%B0%EC%88%A0%EC%97%90-%EA%B8%B0%EB%B3%B8</guid>
            <pubDate>Thu, 03 Jul 2025 14:28:48 GMT</pubDate>
            <description><![CDATA[<h1 id="개념">개념</h1>
<h2 id="필수-단어">필수 단어</h2>
<h3 id="1-webrtc-란">1. WebRTC 란?</h3>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/d7699396-4068-4116-8a76-29f83ec541c9/image.gif" alt=""></p>
<p>WebRTC(Web Real-Time Communication)는 브라우저나 앱이 직접 P2P로 오디오·비디오·데이터를 송수신하는 표준 기술을 뜻한다.
즉, WebRTC는 통신 프로토콜과 API의 집합이야. ()
WebRTC 자체에는 특정한 “라우팅 방식”(MCU, SFU 등)을 강제하지 않는다.
👉 WebRTC만으로도 A ↔ B 단순 P2P 통신이 가능하고, 여러명이면 메시에 가까운 연결도 가능.</p>
<blockquote>
<p>WebRTC는 &quot;클라이언트 ↔ 서버(또는 클라이언트 ↔ 클라이언트) 사이 미디어 전송에 항상 쓰이는 표준 프로토콜이다.</p>
</blockquote>
<p>✅ ICE (Interactive Connectivity Establishment)
WebRTC의 “네트워크 후보”를 찾고 연결을 시도하는 프레임워크.
예: 로컬 IP, 공인 IP, TURN 릴레이 IP 등 여러 경로를 테스트해서 최적의 경로를 찾음.
즉, ICE = 연결 성립 프로세스의 총칭.</p>
<p>✅ STUN (Session Traversal Utilities for NAT)
NAT(Network Address Translation)를 통과하기 위한 서버.
브라우저가 “내 공인 IP를 알 수 있게” 도와줌.
아주 가볍고 빠름. P2P 통신 시 주로 사용.</p>
<p>✅ TURN (Traversal Using Relays around NAT)
STUN으로 연결이 안 될 때 중계 서버를 통해 릴레이.
예: 방화벽이 너무 빡세서 직접 연결 불가할 때.
TURN 서버는 대역폭도 많이 쓰고 비용도 큼.</p>
<blockquote>
<p>ICE = 후보를 모아 연결 시도
 └─ STUN = 내 공인 IP 알려주기
 └─ TURN = 연결 안 되면 릴레이 중계</p>
</blockquote>
<h3 id="2-sfu-selective-forwarding-unit-란">2. SFU (Selective Forwarding Unit) 란?</h3>
<p>SFU는 다자간 화상회의의 미디어 라우팅 아키텍처를 말하는 거야.</p>
<p>보통 다자간 통화에서는 모든 참가자가 모든 다른 참가자에게 각각 스트림을 보내면 부담이 커지는데,
SFU는 참가자가 서버에 하나만 업로드하고, 서버가 각 수신자에게 스트림을 “포워딩”해 줘.</p>
<p>SFU는 WebRTC 위에 구축될 수 있어.
즉, 미디어 전송은 WebRTC를 쓰고, 전송 방식(라우팅 아키텍처)으로 SFU를 적용하는 거지.</p>
<h3 id="3-mcu-multipoint-control-unit-란">3. MCU (Multipoint Control Unit) 란?</h3>
<p>다자간 화상회의의 미디어 서버 방식이야.
그런데 SFU랑 결정적으로 다른 점이 있어:</p>
<table>
<thead>
<tr>
<th align="center">🏷️</th>
<th align="center">MCU</th>
<th align="center">SFU</th>
</tr>
</thead>
<tbody><tr>
<td align="center">역할</td>
<td align="center">미디어 믹싱 (합성)</td>
<td align="center">미디어 선택적 전달</td>
</tr>
<tr>
<td align="center">서버 동작</td>
<td align="center">서버가 모든 참가자의 스트림을 수신해 하나로 믹싱(합성) 후 각 클라이언트에 한 스트림만 전송</td>
<td align="center">서버가 참가자별로 필요한 스트림만 선택해서 전달 (믹싱 안 함)</td>
</tr>
<tr>
<td align="center">클라이언트 부하</td>
<td align="center">클라이언트 부하 적음 (서버가 믹싱해서 한 스트림만 수신)</td>
<td align="center">클라이언트가 여러 스트림 수신 (부하 더 큼)</td>
</tr>
<tr>
<td align="center">서버 부하</td>
<td align="center">서버 부하 큼 (연산량 많음)</td>
<td align="center">서버 부하 상대적으로 적음</td>
</tr>
<tr>
<td align="center">지연(latency)</td>
<td align="center">믹싱 때문에 지연 더 큼</td>
<td align="center">지연 더 낮음</td>
</tr>
</tbody></table>
<p>✅ MCU 간단 예시
<img src="https://velog.velcdn.com/images/jeong_woo/post/28f61a6f-79ef-4efb-89b6-78adb9e7c183/image.png" alt=""></p>
<ol>
<li>4명이 회의에 접속</li>
<li>각자 서버에 스트림을 업로드</li>
<li>서버가 모든 참가자의 비디오·오디오를 하나로 합성 (예: 화면에 4명 다 들어간 하나의 큰 비디오)</li>
<li>서버가 합성된 하나의 비디오를 모든 참가자에게 전송</li>
<li>클라이언트는 하나의 비디오만 디코딩하면 됨</li>
</ol>
<p>✅ SFU 간단 예시
<img src="https://velog.velcdn.com/images/jeong_woo/post/ec44daf8-a722-47ba-b9aa-d11ce3aacb66/image.png" alt=""></p>
<ol>
<li>4명이 회의에 접속</li>
<li>각자 서버에 스트림을 업로드</li>
<li>서버는 믹싱 없이 단순 포워딩 (예: A의 스트림은 B,C,D,E에게 개별 전달, B의 스트림도 마찬가지)</li>
<li>클라이언트는 4개의 개별 스트림을 디코딩해서 갤러리 뷰 구성</li>
</ol>
<h3 id="4-mesh-란">4. Mesh 란?</h3>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/22cb39ea-b69b-4755-8624-819cb309aace/image.png" alt=""></p>
<p>Mesh(메시) 는 가장 단순한 다자간 통신 방식으로, 서버를 거치지 않고 모든 참가자가 서로 직접 P2P 연결을 맺는 모델이다.
🌟 Mesh 방식의 특징
각 참가자가 다른 모든 참가자에게 직접 스트림을 전송
예: 4명이면 각자가 3개의 스트림을 보냄</p>
<pre><code class="language-css">복사
편집
A ─────────▶ B
A ─────────▶ C
A ─────────▶ D
(B, C, D도 마찬가지)</code></pre>
<p>별도의 서버에서 믹싱이나 포워딩을 하지 않음
순수 WebRTC P2P 이다.</p>
<p>장점.
구현이 가장 간단
지연(latency)이 가장 낮음 (직접 연결)
서버 비용이 거의 안 듦 (시그널링 서버만 있으면 됨)</p>
<p>단점.
참가자 수가 늘수록 트래픽 폭발 (연결 복잡도 <em>O(n×(n−1))</em> )
CPU/대역폭 요구량이 급증
모바일/저사양 기기에서는 현실적으로 3~4명 이상 지원하기 어려움.</p>
<blockquote>
<p>🧩 다시 요약
✅ WebRTC = 스트림 송수신 프로토콜/기술
✅ SFU/MCU/Mesh = 스트림을 어떻게 처리해 다자간으로 중계할지 결정하는 서버 아키텍처</p>
</blockquote>
<h1 id="대표-라이브러리-소개">대표 라이브러리 소개</h1>
<p>우선 소개하기 앞서 현재 우리 상황에서 MCU 를 사용하면 아주 좋지 않을까? 생각할 수 있다. 나도 그랬다.
각 경로당 네트워크가 해당 현재 우리 회사 서비스 전용망이 아니기 때문에 화질이 매우 자주 깨진다. 그래서 하나의 비디오로 인코딩해서 내려보내면 좋을 것이라 생각했다.</p>
<p>그러나 이는 오산이었다.</p>
<ol>
<li><p>❌ 서버 CPU 부하가 엄청나게 큼
참여자 수 * 비디오 해상도 * FPS 만큼 인코딩해야 한다. (예) 10명 → 10개 스트림 믹싱 후 다시 10개로 인코딩)
이 때문에 50~100명 정도만 넘어가도 서버 증설 비용이 기하급수적으로 증가한다.
당연한 소리지만 이윤을 창출하는 기업에선 서버 비용도 고려해야할 매우 중요한 요소 중 하나다.</p>
</li>
<li><p>❌ 지연(latency) 증가
믹싱과 인코딩 시간이 걸린다. SFU의 실시간성에 비해 딜레이 체감된다.</p>
</li>
<li><p>❌ 대규모 회의에 부적합
결론적으로 CPU, 메모리 비용 때문에 요즘은 거의 SFU로 대체되는 추세이기 때문이다.</p>
</li>
</ol>
<p>그러므로 우리는 SFU 라이브러리 위주로 살펴볼 것이다.
참고로 그나마 명맥을 유지중이던 Kurento가 그나마 오픈소스 중 유명했지만, 요즘은 Maintain 활동이 많이 줄어들었다.</p>
<p>그리고 아래 전반적인 표를 보고 가장 눈길을 끄는 <code>Jitsi</code> 와 <code>Mediasoup</code> 에 대해 알아보자.</p>
<table>
<thead>
<tr>
<th align="center">라이브러리/API</th>
<th align="center">오픈소스</th>
<th align="center">커스터마이징</th>
<th align="center">확장성</th>
<th align="center">비용</th>
<th align="center">주요 단점</th>
</tr>
</thead>
<tbody><tr>
<td align="center">WebRTC</td>
<td align="center">O</td>
<td align="center">매우 높음</td>
<td align="center">중</td>
<td align="center">무료</td>
<td align="center">복잡한 시그널링, 대규모 한계</td>
</tr>
<tr>
<td align="center">Jitsi</td>
<td align="center">O</td>
<td align="center">높음</td>
<td align="center">중</td>
<td align="center">무료</td>
<td align="center">UI 커스터마이징 필요</td>
</tr>
<tr>
<td align="center">Mediasoup</td>
<td align="center">O</td>
<td align="center">높음</td>
<td align="center">중</td>
<td align="center">무료</td>
<td align="center">웹소켓, HTTP 시그널링 등 직접 구축 필요</td>
</tr>
<tr>
<td align="center">Janus</td>
<td align="center">O</td>
<td align="center">매우</td>
<td align="center">높음</td>
<td align="center">중</td>
<td align="center">무료</td>
</tr>
<tr>
<td align="center">Twilio</td>
<td align="center">X</td>
<td align="center">보통</td>
<td align="center">높음</td>
<td align="center">유료</td>
<td align="center">비용, 일부 기능 부족</td>
</tr>
<tr>
<td align="center">Agora</td>
<td align="center">X</td>
<td align="center">중</td>
<td align="center">높음</td>
<td align="center">유료</td>
<td align="center">비용, 커스터마이징 난이도</td>
</tr>
<tr>
<td align="center">CometChat</td>
<td align="center">X</td>
<td align="center">보통</td>
<td align="center">높음</td>
<td align="center">유료</td>
<td align="center">확장성 비용, 백엔드 한계</td>
</tr>
<tr>
<td align="center">Sendbird Calls</td>
<td align="center">X</td>
<td align="center">보통</td>
<td align="center">높음</td>
<td align="center">유료</td>
<td align="center">비용, 일부 고급 기능 미지원</td>
</tr>
<tr>
<td align="center">Zoom SDK</td>
<td align="center">X</td>
<td align="center">낮음</td>
<td align="center">높음</td>
<td align="center">유료</td>
<td align="center">보안 이슈, 무료 플랜 제한</td>
</tr>
</tbody></table>
<h2 id="1-mediasoup">1. Mediasoup</h2>
<p>Node.js 기반의 서버 사이드 WebRTC SFU(Selective Forwarding Unit) 라이브러리로, 대규모 멀티파티 화상회의 및 실시간 스트리밍에 최적화되어 있음.</p>
<h4 id="🧩-주요-구성">🧩 주요 구성</h4>
<ol>
<li>아키텍처: Node.js 프로세스와 복수의 C++ 워커(worker)가 CPU 코어별로 분산 실행되어, 하나의 서버에서 수백~수천 명의 미디어 스트림을 효율적으로 처리 가능.</li>
<li>확장성: router.pipeToRouter() 기능을 통해 여러 서버/워커 간에 트래픽을 분산시켜, 단일 서버 한계를 넘는 대규모 회의도 지원.</li>
<li>코덱 지원: VP8, VP9, H.264, Opus 등 다양한 미디어 코덱을 유연하게 지원하며, 송출자별로 코덱을 다르게 설정하는 등 세밀한 제어 가능.</li>
<li>커스터마이징: 신호 처리부터 미디어 라우팅, 외부 미디어 연동(FFmpeg, GStreamer 등)까지 거의 모든 부분을 개발자가 직접 설계·구현할 수 있음.</li>
<li>UI 미포함: Mediasoup 자체에는 사용자 인터페이스가 없으며, 모든 클라이언트·시그널링·UI를 직접 개발해야 함.</li>
</ol>
<h4 id="💪-장점">💪 장점</h4>
<ol>
<li>최고 수준의 확장성: 수백~수천 명의 동시 접속자 처리에 강점. SFU 구조라 서버 부하 분산 및 네트워크 효율이 뛰어남.</li>
<li>유연성/모듈성: 원하는 기능만 선택적으로 구현 가능. 다양한 비즈니스 요구에 맞는 맞춤형 플랫폼 구축에 적합.</li>
<li>고급 미디어 처리: 시뮬캐스트, SVC, 다양한 코덱, 외부 미디어 연동 등 고급 기능 지원.</li>
<li>낮은 레이턴시미디어 디코딩/인코딩이 없으므로 빠름.</li>
</ol>
<h4 id="❌-단점">❌ 단점</h4>
<ol>
<li>높은 개발 난이도: 신호 처리, 미디어 라우팅, 클라이언트·UI 개발까지 모두 직접 구현해야 하므로, WebRTC 및 미디어 서버에 대한 깊은 이해 필요.</li>
<li>커뮤니티/문서: Jitsi에 비해 커뮤니티와 문서가 적은 편.</li>
<li>운영 난이도: 대규모 환경에서는 워커/라우터/로드밸런싱 등 인프라 설계가 복잡함.</li>
</ol>
<blockquote>
<p>🏗️ 대규모 회의 고려사항
300명이라면 &quot;모두가 모두의 영상&quot;은 불가능 (브라우저가 300개의 비디오 트랙을 처리 못 함)
적절한 &quot;레이아웃 정책&quot; 설계 필요
발표자 1~2명: 영상/음성 스트림
청취자: 오디오만 구독
상호간 채팅 + 제한적 마이크 권한
필요 시 여러 Worker를 띄워 부하 분산</p>
</blockquote>
<h2 id="2-jitsi">2. Jitsi</h2>
<p>완성형 오픈소스 화상회의 솔루션(Jitsi Meet)으로, 서버 및 클라이언트(웹/모바일)까지 모두 제공.</p>
<p>핵심 구성 요소:</p>
<ol>
<li>Jitsi Videobridge: SFU 서버</li>
<li>Jicofo: 회의 관리 (시그널링 중심)</li>
<li>Prosody: XMPP 서버(시그널링)</li>
<li>Jitsi Meet: 웹 UI</li>
</ol>
<h4 id="🧩-주요-특징">🧩 주요 특징</h4>
<ol>
<li>아키텍처: Jitsi Meet(프론트엔드), Jicofo(회의 관리), JVB(Jitsi Video Bridge, SFU), Prosody(XMPP 시그널링) 등으로 구성된 모듈형 구조.</li>
<li>확장성: JVB(Videobridge)를 여러 대 추가하고, Octo(분산 처리) 및 로드밸런싱을 적용하면 200~250명 이상의 대규모 회의도 지원.</li>
<li>기본 기능: 화면 공유, 채팅, 녹화, 브라우저 기반 UI, 모바일 앱 등 대부분의 화상회의 기능이 기본 제공.</li>
<li>커스터마이징: 로고, UI, 기능 추가 등 커스터마이징 가능하지만, Mediasoup만큼의 자유도는 아님.</li>
</ol>
<h4 id="💪-장점-1">💪 장점</h4>
<ol>
<li>빠른 구축: 별도 개발 없이 바로 사용 가능. 완성형 UI와 서버 제공.</li>
<li>확장성: JVB 추가 및 서버 최적화, Octo 활용 시 200<del>250명(기본), 최적화 시 수백</del>수천 명까지 확장 가능.</li>
<li>커뮤니티/문서: 대규모 커뮤니티와 풍부한 문서, 다양한 사례.</li>
<li>보안: E2EE(1:1), 서버 자체 운영, 다양한 보안 옵션 지원.</li>
</ol>
<h4 id="❌-단점-1">❌ 단점</h4>
<ol>
<li>커스터마이징 한계: UI/기능 커스터마이징은 가능하지만, 완전히 새로운 아키텍처나 신호 처리 로직 구현은 제한적.</li>
<li>리소스 사용량: 대규모 회의 시 클라이언트(브라우저)와 서버 모두 높은 리소스 요구. 특히, 모든 참가자가 비디오를 켜면 브라우저 성능이 한계에 도달할 수 있음.</li>
<li>기본 구조: Mediasoup에 비해 미디어 처리의 세밀한 제어(코덱, 라우팅 등)는 어려움.</li>
<li>XMPP 의존: 시그널링이 Prosody XMPP 기반이라 러닝커브 있음.</li>
</ol>
<blockquote>
<p>🏗️ 대규모 회의 고려사항</p>
</blockquote>
<ul>
<li>Simulcast 필수
발표자 비디오 고화질 + 청취자 저화질</li>
<li>Octo 모드
Videobridge 여러 대 연결하여 부하 분산</li>
<li>Client 제한
UI에서 &quot;타일뷰&quot;를 제한하여 표시 수 축소</li>
<li>대규모 방에서는 청취자 전용 모드 권장</li>
</ul>
<table>
<thead>
<tr>
<th align="center">항목</th>
<th align="center">Mediasoup</th>
<th align="center">Jitsi Meet</th>
</tr>
</thead>
<tbody><tr>
<td align="center">확장성</td>
<td align="center">매우 높음 (수백~수천명, 워커/라우터 분산)</td>
<td align="center">높음 (JVB/Octo로 200~250명, 확장 가능)</td>
</tr>
<tr>
<td align="center">아키텍처</td>
<td align="center">SFU, Node.js+C++, 완전 모듈/커스텀 (Node.js + C++ SFU)</td>
<td align="center">완성형 솔루션, 모듈 구조 (XMPP + Videobridge + Web UI)</td>
</tr>
<tr>
<td align="center">시그널링</td>
<td align="center">직접 구현</td>
<td align="center">내장 (Prosody)</td>
</tr>
<tr>
<td align="center">대규모 확장성</td>
<td align="center">매우 유연함 (Worker 분산)</td>
<td align="center">Octo로 다중 브리지 가능</td>
</tr>
<tr>
<td align="center">커스터마이징</td>
<td align="center">매우 높음 (모든 기능 직접 구현)</td>
<td align="center">중간 (UI/기능 위주)</td>
</tr>
<tr>
<td align="center">개발 난이도</td>
<td align="center">높음 (WebRTC/미디어 서버 지식 필요)</td>
<td align="center">낮음 (즉시 사용 가능)</td>
</tr>
<tr>
<td align="center">UI 제공</td>
<td align="center">없음 (직접 개발)</td>
<td align="center">있음 (웹/모바일)</td>
</tr>
<tr>
<td align="center">커뮤니티</td>
<td align="center">중간</td>
<td align="center">매우 활발</td>
</tr>
<tr>
<td align="center">주요 용도</td>
<td align="center">맞춤형 대규모 플랫폼, 고급 미디어 처리</td>
<td align="center">일반 화상회의 서비스, 빠른 구축</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[HeidiSQL 에 서버 DB 붙이기]]></title>
            <link>https://velog.io/@jeong_woo/HeidiSQL-%EC%97%90-%EC%84%9C%EB%B2%84-DB-%EB%B6%99%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/HeidiSQL-%EC%97%90-%EC%84%9C%EB%B2%84-DB-%EB%B6%99%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Thu, 03 Jul 2025 06:05:23 GMT</pubDate>
            <description><![CDATA[<h1 id="heidisql-에-서버-db-붙이기">HeidiSQL 에 서버 DB 붙이기</h1>
<p>자~ Django 에서 mysqlclient 를 사용하여 다음과 같이 DB 를 잡는다고 가정하자.</p>
<pre><code class="language-py"># mysql 스크립트 연동하여 사용하는 방법
DATABASES = {
    &#39;default&#39;: {
        &#39;ENGINE&#39;: &#39;django.db.backends.mysql&#39;,
        &#39;NAME&#39;: &#39;teakwondo&#39;,
        &#39;USER&#39;: &#39;teakwondo&#39;,
        &#39;PASSWORD&#39;: &#39;teakwondo123&#39;,
        &#39;HOST&#39;: &#39;localhost&#39;,
        &#39;PORT&#39;: &#39;3306&#39;,
        &#39;OPTIONS&#39;:{
            &#39;init_command&#39;: &quot;SET sql_mode=&#39;STRICT_TRANS_TABLES&#39;&quot;,
        }
    }
}</code></pre>
<p>우선 서버에 DB 부터 설치해보자.</p>
<h2 id="mysql-server-설치">mysql-server 설치</h2>
<pre><code class="language-bash">sudo apt update
sudo apt install mysql-server
sudo mysql_secure_installation</code></pre>
<p>그리고 <code>mysql_secure_installation</code> 를 설치하면 다음 몇개의 질문을 받는다.</p>
<ol>
<li><code>VALIDATE PASSWORD COMPONENT?</code> (비밀번호 유효성 검사 구성 요소를 사용하시겠습니까?)
y 또는 n을 선택. 
비밀번호 보안 수준을 높이는 기능인데, 개발 환경에서는 n을 선택해도 무방함.</li>
<li><code>New password:</code> 및 <code>Re-enter new password:</code>
MySQL root 사용자의 비밀번호를 설정.</li>
<li><code>Remove anonymous users?</code> (익명 사용자 제거?) 
Y를 입력하는 것을 강권함.</li>
<li><code>Disallow root login remotely?</code> (원격으로 루트 로그인 금지?) 
보안을 위해 Y를 입력 후 다른 계정을 생성하는 것을 권하나 그냥 귀찮으면 N를 눌러도 됨.</li>
<li><code>Remove test database and access to it?</code> (테스트 데이터베이스 및 접근 권한 제거?) 
Y를 입력하는 것이 좋음.</li>
<li><code>Reload privilege tables now?</code> (권한 테이블 지금 다시 로드?) 
Y를 입력하여 변경 사항을 즉시 적용.</li>
</ol>
<pre><code class="language-bash">sudo systemctl status mysql</code></pre>
<p>마지막으로 위 명령어로 잘 돌아가고 있는지 확인 후, 사용자를 만들어보자.</p>
<h2 id="mysql-사용자-생성">mysql 사용자 생성</h2>
<pre><code class="language-bash">sudo mysql -u root -p</code></pre>
<p>아마 <code>mysql_secure_installation</code> 를 하지 않았으면 기본 비밀번호는 <code>blank(아무것도 설정 X)</code> 일것이다.
무튼 mysql 접속 후 위 Django 설정에 맞춰 유저를 생성하면</p>
<pre><code class="language-SQL">CREATE DATABASE IF NOT EXISTS teakwondo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER &#39;teakwondo&#39;@&#39;%&#39; IDENTIFIED BY &#39;teakwondo123&#39;;
GRANT ALL PRIVILEGES ON teakwondo.* TO &#39;teakwondo&#39;@&#39;%&#39;;
FLUSH PRIVILEGES;
EXIT;</code></pre>
<blockquote>
<p>MySQL 5.7.6 버전 이상부터 <code>validate_password</code> 플러그인이 기본적으로 활성화되어 있어서, 설정된 비밀번호 정책에 맞지 않는 비밀번호는 사용할 수 없다.
즉, 대문자, 소문자, 숫자, 특수문자 조합으로 PW 를 만들어라는 것이다.
물론 비밀번호 정책 완화를 하면 된다.</p>
</blockquote>
<p><code>teakwondo123</code> =&gt; <code>Teakwondo!23</code></p>
<p>물론 이거에 맞춰서 Django 설정 파일도 바꿔줘야한다.</p>
<h3 id="비밀번호-정책-완화">비밀번호 정책 완화</h3>
<p>우선 mysql 접속 후 아래 명령어로 현재 비밀번호 정책상태를 파악 한다.</p>
<pre><code class="language-bash">SHOW VARIABLES LIKE &#39;validate_password&#39; </code></pre>
<p>그리고 비밀번호 정책 완화를 아래 명령어로 진행한다.</p>
<pre><code class="language-bash">SET GLOBAL validate_password.special_char_count = 0;
SET GLOBAL validate_password.mixed_case_count = 0;
FLUSH PRIVILEGES;</code></pre>
<p>후에 사용자의 비밀번호 변경 및 인증 플러그인을 설정한다.
여기서 인증 플러그인을 (mysql_native_password로) 설정하는 이유는 <code>caching_sha2_password</code> 문제로 인해 접속이 안 되었을 가능성을 해소하기 위함이다.</p>
<pre><code class="language-bash">ALTER USER &#39;teakwondo&#39;@&#39;%&#39; IDENTIFIED WITH mysql_native_password BY &#39;teakwondo123&#39;;
FLUSH PRIVILEGES;
EXIT;</code></pre>
<p>그러고 mysql 을 재실행하면 된다.</p>
<pre><code class="language-bash">sudo systemctl restart mysql</code></pre>
<h2 id="mysql-설정-파일-수정">mysql 설정 파일 수정</h2>
<p><code>bind-address</code> 을 반드시 <code>0.0.0.0</code> 혹은 본인 <code>공인 ip</code>로 설정해줘야한다.</p>
<pre><code class="language-bash">sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf</code></pre>
<pre><code class="language-Ini"># Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address          = 0.0.0.0
mysqlx-bind-address   = 0.0.0.0</code></pre>
<p><code>mysqlx-bind-address</code>: <code>MySQL 8.0</code> 부터 도입된 <code>X Protocol</code>(mysqlx 프로토콜)에 대한 바인딩 주소이다. 
일반적인 MySQL 클라이언트 연결(3306 포트)과는 직접적인 관련이 없다. 하지만 이 또한 127.0.0.1로 설정되어 있어서 외부 X Protocol 연결도 차단한다.</p>
<p>그리고 이를 적용하려면 mysql 을 재실행해줘야한다.</p>
<pre><code>sudo systemctl restart mysql</code></pre><h2 id="포트-및-방화벽-확인-후-유효성-검사">포트 및 방화벽 확인 후 유효성 검사</h2>
<pre><code class="language-bash">Test-NetConnection -ComputerName [MySQL_서버_IP_주소] -Port 3306

# 예시
# ComputerName           : 203.0.113.45
# RemoteAddress          : 203.0.113.45
# RemotePort             : 3306
# InterfaceAlias         : Ethernet
# SourceAddress          : [내_PC_IP_주소]
# TcpTestSucceeded       : True</code></pre>
<h2 id="django-에서-mysql-db-접근에-필요한-library-들-설치하기">Django 에서 mySQL DB 접근에 필요한 library 들 설치하기</h2>
<blockquote>
<p>이때 mysqlclient는 파이썬에서 MySQL/MariaDB 데이터베이스와 통신하기 위한 C 언어 기반 라이브러리에 의존한다. 
따라서 반드시 시스템 레벨의 개발 라이브러리를 먼저 설치해줘야 한다.</p>
</blockquote>
<p>따라서 다음과 같이 필요한 library 들을 설치 후</p>
<pre><code class="language-bash"># 가상환경 실행 중이라면 (deactivate)
sudo apt-get update
sudo apt-get install python3-dev default-libmysqlclient-dev build-essential pkg-config</code></pre>
<p><code>python3-dev</code>: 파이썬 개발 헤더 파일 및 라이브러리. C 확장 모듈을 컴파일하는 데 필요하다.
<code>default-libmysqlclient-dev</code>: MySQL 클라이언트 라이브러리의 개발 파일이다. mysqlclient가 MySQL 서버와 통신할 수 있도록 해준다.
<code>build-essential</code>: 컴파일에 필요한 기본적인 도구들 (gcc, g++ 등)을 포함한다.
<code>pkg-config</code>: 패키지 정보를 질의하는 데 사용되는 도구이다.</p>
<pre><code class="language-bash">(가상환경 실행 후)
pip install mysqlclient</code></pre>
<p>자~ 그럼 다 설치가 되었으니 local 에서 돌려서 잘 실행되는지 확인하면 된다.</p>
<pre><code class="language-bash"># (8000 은 기존 gunicorn 서비스가 사용중일 수도 있으니 8001)
gunicorn --workers 1 --bind 0.0.0.0:8001 tkd_api.wsgi:application

# 아래 명령어로 로그 확인
sudo journalctl -u gunicorn.service -f</code></pre>
<pre><code class="language-py">DATABASES = {
    &#39;default&#39;: {
        &#39;ENGINE&#39;: &#39;django.db.backends.mysql&#39;,
        &#39;NAME&#39;: &#39;teakwondo&#39;,
        &#39;USER&#39;: &#39;teakwondo&#39;,
        &#39;PASSWORD&#39;: &#39;teakwondo123&#39;,
        &#39;HOST&#39;: &#39;localhost&#39;,
        &#39;PORT&#39;: &#39;3306&#39;,
        &#39;OPTIONS&#39;:{
            &#39;init_command&#39;: &quot;SET sql_mode=&#39;STRICT_TRANS_TABLES&#39;&quot;,
        }
    }
}</code></pre>
<p><code>&#39;default&#39;</code>: DB 연결 설정의 이름. 
Django 프로젝트 내에서 여러 데이터베이스를 사용할 경우, 각 데이터베이스에 고유한 이름을 부여하여 구별한다. 
&#39;default&#39;는 Django가 기본적으로 사용하는 데이터베이스 연결을 의미한다.</p>
<p><code>&#39;ENGINE&#39;</code>: 어떤 종류의 데이터베이스를 사용할지 지정.
<code>&#39;django.db.backends.mysql&#39;</code>은 Django가 MySQL 데이터베이스와 통신하는 데 필요한 백엔드 모듈을 사용하겠다는 의미한다.
참고로 다른 데이터베이스 종류에 따라 postgresql_psycopg2 (PostgreSQL), sqlite3 (SQLite), oracle (Oracle) 등으로 변경될 수 있다.</p>
<p><code>&#39;NAME&#39;</code>: 연결할 데이터베이스의 이름. 
MySQL 서버 내에 &#39;teakwondo&#39;라는 이름의 데이터베이스가 존재해야 한다. 
Django는 이 데이터베이스에 테이블을 생성하고 데이터를 저장하게 된다.</p>
<p><code>&#39;USER&#39;</code>: 데이터베이스에 연결할 때 사용할 사용자 이름. 
MySQL 서버에 &#39;teakwondo&#39;라는 사용자 계정이 존재해야 하며, 해당 계정은 &#39;teakwondo&#39; 데이터베이스에 접근할 권한을 가지고 있어야 한다.</p>
<p><code>&#39;PASSWORD&#39;</code>: 데이터베이스 사용자 &#39;teakwondo&#39;의 비밀번호.</p>
<p><code>&#39;HOST&#39;</code>: 데이터베이스 서버가 실행 중인 호스트 이름 또는 IP 주소.
<code>&#39;localhost&#39;</code> 는 데이터베이스 서버가 Django 애플리케이션과 동일한 컴퓨터에서 실행되고 있음을 의미한다. 
만약 데이터베이스가 원격 서버에 있다면 해당 서버의 IP 주소나 도메인 이름을 여기에 입력하면 된다.</p>
<p><code>&#39;PORT&#39;</code>: 데이터베이스 서버가 수신 대기하는 포트.
<code>&#39;3306&#39;</code>은 MySQL의 기본 포트이다. 다른 데이터베이스 시스템은 다른 기본 포트를 사용하거나, MySQL이라도 관리자가 다른 포트를 사용하면 해당 포트를 적으면 된다.</p>
<p><code>&#39;OPTIONS&#39;</code>: 데이터베이스 연결이 이루어질 때 실행될 추가적인 옵션 또는 명령을 정의하는 부분.
<code>&#39;init_command&#39;</code>는 데이터베이스 연결이 성공적으로 수립된 직후에 실행될 SQL 명령을 지정한다.
<code>&quot;SET sql_mode=&#39;STRICT_TRANS_TABLES&#39;&quot;</code>는 MySQL의 sql_mode를 설정하는 SQL 명령이다.</p>
<p><code>STRICT_TRANS_TABLES</code>: 해당 모드는 MySQL이 데이터 삽입 또는 업데이트 시 엄격한 규칙을 적용하도록 지시한다. 
예를 들어, NOT NULL로 정의된 컬럼에 NULL 값을 삽입하려고 하거나, 정의된 컬럼의 최대 길이를 초과하는 문자열을 삽입하려고 할 경우, MySQL은 경고 대신 오류를 발생시키고 해당 작업을 거부한다. 
이는 데이터 무결성을 강화하고, 예기치 않은 데이터 잘림이나 오류를 방지하는 데 도움이 된다.</p>
<p>그럼 짠~ 하고 붙는다.
<img src="https://velog.velcdn.com/images/jeong_woo/post/2ae0809a-c990-469e-ab63-3e0a5bad8844/image.png" alt=""></p>
<h1 id="참고">참고</h1>
<p>sqlite3 는 파일 기반의 경량 데이터베이스이다. 
별도의 서버 프로세스가 필요하지 않으며, 모든 데이터는 단일 파일에 저장된다. 즉, 마치 flutter 의 SHARED_PREFERENCE 와 같다고 생각하면 된다.</p>
<pre><code class="language-py"># 장고 기본 db 사용하는 방법 (sqlite3)
DATABASES = {
    &#39;default&#39;: {
        &#39;ENGINE&#39;: &#39;django.db.backends.sqlite3&#39;,
        &#39;NAME&#39;: BASE_DIR / &#39;db.sqlite3&#39;,
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[네트워크 인터페이스]]></title>
            <link>https://velog.io/@jeong_woo/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4</link>
            <guid>https://velog.io/@jeong_woo/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4</guid>
            <pubDate>Wed, 02 Jul 2025 04:44:36 GMT</pubDate>
            <description><![CDATA[<h1 id="네트워크-인터페이스란">네트워크 인터페이스란?</h1>
<p>네트워크 인터페이스 이름은 서버나 컴퓨터가 네트워크에 연결되기 위해 사용하는 하드웨어 및 소프트웨어 구성 요소의 식별자이다. 
리눅스 시스템에서는 각 네트워크 카드나 인터페이스에 고유한 이름을 부여하여 구별하고 관리한다.</p>
<h2 id="현대-네트워크-인터페이스-명명-규칙">현대 네트워크 인터페이스 명명 규칙</h2>
<p>Ubuntu 16.04 이후부터는 예측 가능한 네트워크 디바이스명(Predictable Network Interface Names) 규칙을 사용한다. 
이전의 eth0, eth1 같은 단순한 명명 방식에서 벗어나 더 체계적인 이름을 사용한다.
그럼 이제 예를 들어 명명 규칙 구조를 알아보자.</p>
<h4 id="앞-두-글자-인터페이스-타입을-표시">앞 두 글자: 인터페이스 타입을 표시</h4>
<p>en: 이더넷(Ethernet)
wl: 무선 LAN
ww: 무선 WAN</p>
<h4 id="뒤-부분-하드웨어-위치나-특성을-표시">뒤 부분: 하드웨어 위치나 특성을 표시</h4>
<p>o&lt;숫자&gt;: 온보드 디바이스 (예: eno1)
s&lt;숫자&gt;: PCI Express 핫플러그 슬롯 인덱스 (예: ens33)
p&lt;버스&gt;s&lt;슬롯&gt;: PCI 위치 (예: enp2s0)
x&lt;MAC주소&gt;: MAC 주소 기반 (예: enxb23fd2asff)</p>
<h4 id="systemd가-다음-우선순위에-따라-인터페이스-이름을-결정한다">systemd가 다음 우선순위에 따라 인터페이스 이름을 결정한다.</h4>
<p>펌웨어/BIOS 정보 기반 온보드 디바이스 인덱스 (eno1)
펌웨어/BIOS 정보 기반 PCI Express 슬롯 인덱스 (ens1)
물리적 커넥터 위치 정보 (enp2s0)
MAC 주소 기반 (선택적)
전통적인 예측 불가능한 이름 (eth0)</p>
<h2 id="네트워크-인터페이스-이름-확인하는-방법">네트워크 인터페이스 이름 확인하는 방법</h2>
<pre><code class="language-bash">ls /sys/class/net

# 더 권장
ip link show</code></pre>
<p>그럼 대충 아래 처럼 나온다. 여기서 알 수 있는 법은.</p>
<pre><code class="language-bash">1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN
2: ens33: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP</code></pre>
<h4 id="lo-loopback-interface">lo (Loopback Interface)</h4>
<p>용도: 시스템 내부 통신용 가상 인터페이스
상태: UP, LOWER_UP (정상 작동)
특징: 모든 리눅스 시스템에 기본적으로 존재하는 루프백 인터페이스
사용 여부: SSH 접속용으로는 사용하지 않음</p>
<p>그리고 만약 docker 를 깔았다면 <code>docker0 (Docker Bridge Interface)</code> 도 있을 수 있다.</p>
]]></description>
        </item>
    </channel>
</rss>