<?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>Tue, 21 Jul 2026 06:11:23 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[GS 인증 자료 조사]]></title>
            <link>https://velog.io/@jeong_woo/GS-%EC%9D%B8%EC%A6%9D-%EC%9E%90%EB%A3%8C-%EC%A1%B0%EC%82%AC</link>
            <guid>https://velog.io/@jeong_woo/GS-%EC%9D%B8%EC%A6%9D-%EC%9E%90%EB%A3%8C-%EC%A1%B0%EC%82%AC</guid>
            <pubDate>Tue, 21 Jul 2026 06:11:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/53059f74-4d57-4502-9896-7d11b79d5ea4/image.jpg" alt=""></p>
<h1 id="gs-인증-준비-자료">GS 인증 준비 자료</h1>
<blockquote>
<p>양방향 소통시스템 v2.0 (AIX-SBS-OnPrem-v2.0) GS 인증 취득을 위한 조사/정리 자료.
출처: 온라인 후기 6건(2020~2024) + 5개 인증기관 공식 안내 + 사내 상담 결과.</p>
</blockquote>
<hr>
<h2 id="1-gs-인증-개요">1. GS 인증 개요</h2>
<ul>
<li><strong>평가 근거 표준</strong>: ISO/IEC 25023, 25041, 25051. 기관마다 같은 기준을 다른 관점으로 해석하므로 평가 방식에 차이가 있음.</li>
<li><strong>평가 방식</strong>: 블랙박스 테스트 (소스코드·설계 문서는 확인하지 않고, 실행 소프트웨어만으로 평가).</li>
<li><strong>등급 체계</strong>: 1등급 심사에서 탈락하면 2등급으로 내려가는 게 아니라 완전 탈락. 1등급/2등급은 별도 트랙으로 진행됨.</li>
<li><strong>인증 범위</strong>: 기능리스트에 등재한 모든 기능이 시험 대상이 됨 → 범위가 넓을수록 기간·비용·결함 발생 가능성 증가. 인증이 반드시 필요한 핵심 기능만 선별하는 것이 일반적 전략.</li>
<li><strong>소요 기간</strong>: 대기 기간(신청 후 최소 3개월 대기) + 시험 기간(인증 범위/기능 수에 따라 변동, 워크데이 기준 약 7일 사례 있음). 총 3개월 내외가 일반적.<ul>
<li>연말은 심사 신청이 몰려 일정 조정이 불리함 (8~9월 계약 → 11월 시험 진행 사례 있음).</li>
</ul>
</li>
<li><strong>비용</strong>: 수백만 원 ~ 천만 원 이상 (인증 범위와 기관에 따라 편차 큼).</li>
<li><strong>오류 수정 기회</strong>: 시험 기간 중 원칙적으로 <strong>2회</strong> (1차 결함보고서 → 수정 → 2차 리뷰 → 마지막 수정). 이후 회귀 테스트 진행 후 기관에서 결과보고서 작성.<ul>
<li>수정은 기관이 지정한 날짜·시간대에 기관 방화벽(원격 접속 환경)으로 들어가 진행하는 방식.</li>
<li>기능상 결함으로 인증이 불가능할 경우, 심사 중단을 <strong>1회에 한해</strong> 요청 가능.</li>
<li>무결점으로 통과 시 시험비 일부(약 20%) 환급 사례 있음 (매우 드문 경우).</li>
</ul>
</li>
<li><strong>시험 장소</strong>: 원칙적으로 신청 기업이 시험소에 직접 방문해 SW를 설치. 시험소 환경은 전부 한국어이며 영어 소통이 원활하지 않으므로, 통상 회사 근처 기관을 선택하는 것이 유리함.</li>
</ul>
<h3 id="필요-시간을-잡아먹는-실수-패턴-후기-공통-지적">필요 시간을 잡아먹는 실수 패턴 (후기 공통 지적)</h3>
<ul>
<li>급하게 신청하지 말 것. 시험 전 상담을 충분히 거쳐 기본 작업을 끝낼 수 있는 일정으로 잡을 것 (기관마다 잡혀있는 시험 일정이 다름).</li>
<li>오류 수정 기회가 2회로 제한되므로, 신청 전 자체 테스트를 최대한 진행한 뒤 시험에 들어갈 것.</li>
<li>시험 기간에는 개발자를 포함해 팀이 GS 대응에만 집중할 수 있도록 업무를 조정할 것.</li>
<li>SW에 변동이 생기면 제품설명서·사용자설명서 등 서류도 함께 업데이트해야 함 — 대기 기간 중 SW 수정이 있었다면 서류 동기화 여부를 반드시 재확인.</li>
<li>실제 시험 환경(운영체제 등)과 SW의 기본 구동 환경이 다르면 문제가 되므로, 시험합의서에 기재하는 시험 환경값을 실제 운영 환경과 일치시킬 것.</li>
</ul>
<h3 id="사전-준비가-필요한-이유--인증기준-참고-서류실행-sw-공통-평가-관점">사전 준비가 필요한 이유 — 인증기준 (참고: 서류/실행 SW 공통 평가 관점)</h3>
<p><strong>제품설명서</strong></p>
<ul>
<li>내용 일관성 유지 (용어·버전 통일)</li>
<li>확인 불가한 추상적 문구·홍보성 문구 금지</li>
<li>제조자와 공급자가 다를 경우 구분 명시</li>
<li>제약사항 정보 명시 필수</li>
</ul>
<p><strong>사용자설명서</strong></p>
<ul>
<li>각 기능의 용도 및 사용 방법 포함 필수</li>
<li>필수 데이터 백업·복구 방법 안내 포함</li>
<li>기술된 모든 내용이 실행 소프트웨어와 100% 동일해야 함</li>
<li>목차 및 색인 정보 포함</li>
</ul>
<p><strong>실행 소프트웨어</strong></p>
<ul>
<li>평가 기간 내 일정 기간 가용성 보장</li>
<li>높은 신뢰성이 요구되는 경우 회피(우회) 수단 존재</li>
<li>효율적이고 정확하게 수정/변경 가능해야 함</li>
<li>설치가 용이해야 함</li>
</ul>
<hr>
<h2 id="2-사내-qa-체크리스트-프로토타입-단계부터-반영">2. 사내 QA 체크리스트 (프로토타입 단계부터 반영)</h2>
<p>블랙박스 평가에서는 &quot;사용자가 예상 밖의 행동을 했을 때&quot; 소프트웨어가 어떻게 반응하는지가 핵심 평가 대상이 됨. 개발 초기부터 다음 항목을 반영할 것.</p>
<ul>
<li>가이드 팝업 제작</li>
<li>창 크기 조절(리사이징) 시 UI 깨짐 없는지 확인</li>
<li>예외 처리<ul>
<li>사용자가 임의로 여러 버튼/메뉴를 눌러볼 때의 대응</li>
<li>설정 파일 등이 없을 때의 에러 처리</li>
<li>사용자가 비정상적인 값을 입력했을 때의 대응</li>
<li>텍스트를 과도하게 길게 입력했을 때의 대응</li>
</ul>
</li>
<li>프로토타입을 최대한 빨리 만들어 사내 품질팀과 공유하고 피드백 받기 — A-Z를 다 완성한 뒤 공유하지 말고, 만들어지는 대로 바로바로 확인받을 것.</li>
</ul>
<hr>
<h2 id="3-인증기관-5곳-비교">3. 인증기관 5곳 비교</h2>
<table>
<thead>
<tr>
<th>기관</th>
<th>위치</th>
<th>사전 상담 방법</th>
<th>계정 상태</th>
</tr>
</thead>
<tbody><tr>
<td><strong>TTA</strong> 한국정보통신기술협회</td>
<td>상암(누리꿈스퀘어 비즈니스타워 404호) / 분당 / 영남(대구 수성구)</td>
<td>이메일 <code>ttags@tta.or.kr</code>, 제목 <code>[GS인증 사전 상담요청] 기업명 제품명</code></td>
<td>계정 있음 (ID: aixcon)</td>
</tr>
<tr>
<td><strong>KTL</strong> 한국산업기술시험원</td>
<td>서울 구로구 디지털로26길 87 (IT융합기술센터)</td>
<td>이메일 <code>gs@ktl.re.kr</code> 또는 직접 방문</td>
<td>마스터 계정 없음, 개인계정 승인 대기 중</td>
</tr>
<tr>
<td><strong>KTC</strong> 한국기계전기전자시험연구원</td>
<td>경기 군포시 흥안대로27번길 22 (AI·SW융합센터)</td>
<td>전화 031-428-3773, 온라인 신청</td>
<td>개인계정 가입 완료</td>
</tr>
<tr>
<td><strong>KTR</strong> 한국화학융합시험연구원</td>
<td>경기 과천시 교육원로98</td>
<td>이메일 <code>gs@ktr.or.kr</code> (담당자 3인 별도 연락처 보유)</td>
<td>회원등록 불필요</td>
</tr>
<tr>
<td><strong>CIDI</strong> 부산IT융합부품연구소</td>
<td>부산 부산진구 엄광로 176 (동의대 가야캠퍼스)</td>
<td>전화 051-890-2777/2780, 온라인 신청(기업회원 가입 후)</td>
<td>기업회원 가입 완료</td>
</tr>
</tbody></table>
<p><strong>상담 시 반드시 확인할 것</strong>: 합격보증제 해당 여부 (한 번에 합격 시 환급액 등 조건 확인).</p>
<hr>
<h2 id="4-기관별-상담-필요-서류">4. 기관별 상담 필요 서류</h2>
<h3 id="공통-서류-모든-기관-공통으로-요구">공통 서류 (모든 기관 공통으로 요구)</h3>
<ol>
<li><strong>기능리스트</strong> — 스튜디오 프로그램 / 시청자 클라이언트 프로그램 / 통합관리 프로그램별로 기능을 구분해 작성.</li>
<li><strong>제품설명서</strong></li>
<li><strong>사용자매뉴얼(취급설명서)</strong></li>
<li><strong>사업자등록증</strong></li>
<li>(해당 시) 중소기업확인서 등 수수료 할인 증빙</li>
</ol>
<h3 id="tta-상암시험소">TTA (상암시험소)</h3>
<ul>
<li>GS 시험인증 사전 상담요청서 (지정 워드 양식)</li>
<li>기능리스트 (지정 엑셀 양식)</li>
<li>매뉴얼 (사용자설명서, 형식 자유 — pdf/hwp/word)</li>
<li>사업자등록증</li>
<li>(선택) 중소기업확인서</li>
</ul>
<blockquote>
<p>TTA는 <strong>품질특성별 제품 정보</strong>를 상담 전 별도로 요청함 (시험 완료 전까지 TTA와 합의 하에 변경 가능):</p>
<ul>
<li>회사·제품 정보 (설립일, 자본금/매출액, 종업원 수, 주요 사업 분야, 최초 출시일, 주요 납품처)</li>
<li>기능 적합성: 제품 사용 목적 및 관련 기능</li>
<li>성능 효율성: <strong>수용 가능 최대 동시 사용자 수</strong> — 여기 기재한 인원수대로 실제 동시 접속 시험을 진행하므로, 과도하게 높게 잡으면 동시 접속 시 예상치 못한 오류가 결함으로 간주될 위험이 있음. 신중하게 설정할 것.</li>
<li>호환성: 연동하는 외부 제품 목록 및 연동 목적 (예: 엑셀 내보내기 기능이 있으면 엑셀을 기재)</li>
<li>사용성: 다국어 지원 언어 (다국어 설정 시 정상 작동 여부만 판단 대상)</li>
<li>신뢰성: 이중화 및 데이터 복구 기능 포함 여부</li>
<li>보안성: OpenSSL, 업데이트 서버 등 사용 여부</li>
<li>유지보수성: 라이선스 파일에 따른 기능 활성화/비활성화 가능 여부 (해당 시 담당 시험관과 반드시 사전 상의)</li>
<li>이식성: 설치 예상 소요시간, 최초 출시 여부, 이전 버전 데이터 사용 여부</li>
</ul>
<p>또한 <strong>시험합의서</strong>를 통해 시험 대상(실행 SW, 제품설명서, 사용자설명서)과 시험 환경(OS, 하드웨어 사양, 네트워크 환경 등)을 사전 합의함 — 기재한 환경대로 시험이 진행되므로 실제 운영 환경과 반드시 일치시킬 것.</p>
</blockquote>
<h3 id="ktl-구로">KTL (구로)</h3>
<ul>
<li>GS인증견적의뢰서</li>
<li>제품설명서, 사용자설명서</li>
<li>사업자등록증 사본 (KTL 최초 신청 시)</li>
<li>(해당 시) 중소기업확인서, 중견기업연합회/한국SW협회 회원 증빙 (수수료 할인)</li>
</ul>
<h3 id="ktc-군포">KTC (군포)</h3>
<ul>
<li>소프트웨어 품질인증 신청서</li>
<li>소프트웨어 품질인증 사전점검 체크리스트</li>
<li>소프트웨어 품질인증 저작권확인서</li>
<li>개인정보 수집·이용 및 제3자 제공 동의서</li>
<li>온라인 신청 시 제품정보(성적서 기재정보) 입력 필요</li>
</ul>
<h3 id="ktr-과천">KTR (과천)</h3>
<ul>
<li><strong>사전상담 단계</strong>: GS인증 상담요청서, 소프트웨어 품질인증 신청 시 유의사항 및 고객 동의서, 개인정보 동의서, 기능리스트</li>
<li><strong>신규신청 단계</strong>: GS인증 신청서, GS인증 신청인 확인서, (사전상담 서류 동일 재제출)</li>
<li><strong>변경신청 단계</strong> (인증 후 규격 추가 시): 소프트웨어 품질인증 변경신청서, 인증제품 변경 영향 분석서</li>
<li>참고: GS인증 준비를 위한 사전점검체크리스트, GS인증 기준 설명서(일반 SW용 / SaaS용 별도)</li>
</ul>
<h3 id="cidi-부산">CIDI (부산)</h3>
<ul>
<li>절차: 기업회원 가입 → 제품설명서·사용자취급설명서 작성 → &#39;GS인증 사전상담요청&#39; 메뉴에 정보 입력 및 서류 업로드 → 상담 요청 완료</li>
<li>제품설명서</li>
<li>사용자취급설명서</li>
<li>참고: GS인증 기준 설명서, GS인증 신청을 위한 체크리스트, 개요·운영환경 작성예시</li>
</ul>
<hr>
<h2 id="5-심사-진행-팁">5. 심사 진행 팁</h2>
<ul>
<li><strong>가이드북(부가 자료) 준비 권장</strong>: SW 전문성이 높을수록 시험관이 스스로 평가하기 어려워지므로, 아래 내용을 포함한 가이드북을 준비하면 심사가 원활함.<ul>
<li>제품 구동 시 최소/권장 사양</li>
<li>프로그램 로그 저장 위치</li>
<li>고객지원 방식</li>
<li>설치/삭제/실행 방법</li>
<li>프로그램 전체 사용 목적</li>
<li>전문 용어 및 조작 설명 (일반적이지 않은 용어가 있는 경우)</li>
<li>모든 버튼에 대한 설명</li>
<li>모든 입력 칸에 대한 설명 (입력 글자 제한 수 등)</li>
<li>FAQ</li>
<li><strong>테스트 가이드북(입력값-출력값 샘플 포함)을 제공하면 심사가 더 빠르고 효율적으로 진행됨</strong> (사내 상담 시 확인된 팁).</li>
</ul>
</li>
<li><strong>매뉴얼 작성 시 유의사항</strong>:<ul>
<li>사용자가 실제 참고할 만한 FAQ 내용이 있어야 함.</li>
<li>제품 버전 정보와 가이드북 버전 정보가 반드시 일치해야 함.</li>
<li>제품 출시 이후 업데이트/오류 개선 이력이 있으면 날짜와 내용을 별첨으로 기록 (기록이 없으면 소급 작성 필요).</li>
</ul>
</li>
</ul>
<hr>
<h2 id="6-조달청-공공조달길잡이-문의-결과-2025-10-24">6. 조달청 공공조달길잡이 문의 결과 (2025-10-24)</h2>
<p><strong>Q1. GS인증을 SW의 핵심 기능만으로 범위를 좁혀 받아도 되는지?</strong>
→ 인증 범위 밖의 기능은 이후 &quot;규격추가(업그레이드)&quot; 형태로 재신청 가능하나, <strong>가급적 전체 범위를 GS인증 받는 것이 좋고, 범위를 좁히면 심사위원 평가에 영향을 줄 수 있음</strong>. 규격추가는 지정된 기간 중 신청 가능하며 신청 서류는 약 17부.</p>
<ul>
<li>담당: 본청 우수제품과 김보라 (042-724-7233)</li>
</ul>
<p><strong>Q2. 우수조달물품은 1차 심사 4회 이상 탈락 시 재신청 불가인데, 벤처나라는 여러 번 신청해도 불이익이 없는지?</strong>
→ 심사서(<a href="https://www.pps.go.kr/kor/content.do?key=00302">조달청 우수제품지정제도 안내책자</a> p.195) 기준으로 GS 인증 시 심사특례가 적용됨. 승산 여부는 해당 심사서로 자체 판단.</p>
<p><strong>Q3. 우수조달물품(지정기간 3년, 1년씩 최대 3년 연장) vs 벤처나라(지정기간 6년, 연장 불가) — 어느 순서로 접근하는 게 유리한지?</strong>
→ 벤처나라는 실적·판로·대외적 인정 측면에서 큰 혜택이 없으므로, 조건이 충족되면 벤처나라를 거치지 않고 <strong>바로 MAS(다수공급자계약)나 우수조달로 진입하는 것이 유리</strong>. 지정기간이 끝나도 &quot;우수조달이었다&quot;는 이력 자체는 대외적으로 유의미하게 어필 가능.</p>
<p><strong>Q4. MAS vs 우수조달물품 진입 난이도 차이</strong>
→ MAS는 기존 쇼핑몰에 경쟁업체가 이미 등록되어 있으면 공고 확인 후 비교적 수월하게 진입 가능(경쟁은 필요). 유사 물품이 쇼핑몰에 없으면 공공조달성 심사를 새로 요청해야 함. 우수조달물품은 심사가 매우 까다로운 대신 진입 시 혜택이 큼. <em>(추가 확인 필요 — 미확정)</em></p>
<p><strong>우수조달 지정 시 필요한 저작권 관련 서류</strong>
→ 기술소명자료 중 &quot;저작권 등록증(구 프로그램등록증) 및 프로그램 등록부(최근 3개월 이내)&quot;가 요구됨. 한국저작권위원회에 별도로 SW 저작권 등록을 진행해야 발급 가능한 것으로 추정 — <strong>확인 필요</strong>.</p>
<hr>
<h2 id="7-각-인증기관에-확인해야-할-사항-액션-아이템">7. 각 인증기관에 확인해야 할 사항 (액션 아이템)</h2>
<ol>
<li><strong>출시일</strong> — 최초 출시 예정일 확정 필요 (TTA 품질특성별 제품 정보 등 여러 서류에서 요구됨).</li>
<li><strong>인증 받고자 하는 기능의 범위</strong> (기능리스트 기반) — 메시지 수신, 날씨/버스 확인 등 생활편의기능도 인증 범위에 포함할지 여부 확인 필요. (§7 Q1 참고: 가급적 전체 포함 권장)</li>
<li><strong>사용자매뉴얼 작성을 위한 캡처 시점</strong> — 매뉴얼은 모든 기능을 실제 화면으로 캡처해 설명해야 함(입력필드명/입력값 유효 범위/사용메시지 내용/메시지 표시조건 포함). 스튜디오/시청자 클라이언트/통합관리 3개 프로그램을 각각 실사용해 볼 수 있는 시점 확인 필요 (QA 단계에서 가능한지, 개발 일정상 언제인지).</li>
<li><strong>시청자 클라이언트 프로그램의 사용 환경</strong> — TV로만 사용 가능한 제품인지 확인 필요. 만약 그렇다면, 매뉴얼 캡처를 카메라로 TV 화면을 촬영하는 방식으로 진행하는 것이 맞는지 확인 필요.</li>
<li><strong>제품 구성도</strong> — 최신 구성도 작성 필요.</li>
<li><strong>저작권 정책</strong> — 정리 필요.</li>
<li><strong>제품 운영환경</strong> — 서버/클라이언트 각각의 운영환경(OS, 하드웨어 등) 최신화 필요.</li>
<li><strong>준수 기준 명시</strong> — 특정 표준/권고안을 따라 구현한 경우 명시. (예시 문구: &quot;본 제품은 소프트웨어 개발 보안을 위해 행정안전부의 C 언어 시큐어코딩가이드(2014)를 적용하여 개발되었습니다.&quot;)</li>
<li><strong>제한사항 및 성능</strong> — 명시 필요.</li>
<li><strong>사용자 관점 서술</strong><ul>
<li>오류 방지 (입력값 검증 등)</li>
<li>인터페이스 설명</li>
<li>커스텀 가능한 인터페이스 여부</li>
</ul>
</li>
<li><strong>백업 및 복구, 유지관리 내용</strong> — 정리 필요.</li>
<li><strong>제품에 적용된 보안사항</strong> — 정리 필요.</li>
<li><strong>생산, 설치, 고객지원</strong> — 프로세스 정리 필요.</li>
</ol>
<blockquote>
<p>참고: 위 다수 항목은 &quot;규제의 신속확인 요청서&quot; 작성과도 연결되는 항목으로 보임.</p>
</blockquote>
<hr>
<h2 id="부록-기관별-원본-링크">부록: 기관별 원본 링크</h2>
<ul>
<li>TTA: <a href="https://cs.tta.or.kr/tta/introduce/introCont.do?menuId=700&amp;tnc_lab=T000003&amp;up_tnc_cls_no=T000020&amp;tnc_cls_no=T000127&amp;tabMode=cont">https://cs.tta.or.kr/tta/introduce/introCont.do?menuId=700&amp;tnc_lab=T000003&amp;up_tnc_cls_no=T000020&amp;tnc_cls_no=T000127&amp;tabMode=cont</a></li>
<li>KTL: <a href="https://customer.ktl.re.kr/web/contents/K101010800.do">https://customer.ktl.re.kr/web/contents/K101010800.do</a></li>
<li>KTC: <a href="https://ktc.re.kr/web_united/task/task.asp?pagen=2540">https://ktc.re.kr/web_united/task/task.asp?pagen=2540</a></li>
<li>KTR: <a href="https://www.ktr.or.kr/test-evaluation/led/contentsid/2000/index.do">https://www.ktr.or.kr/test-evaluation/led/contentsid/2000/index.do</a></li>
<li>CIDI: <a href="https://www.cidi.re.kr/sub06/sub06_01.php">https://www.cidi.re.kr/sub06/sub06_01.php</a></li>
<li>조달청 우수제품지정제도 안내책자: <a href="https://www.pps.go.kr/kor/content.do?key=00302">https://www.pps.go.kr/kor/content.do?key=00302</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[공공 영상회의 표준 연계 아키텍처]]></title>
            <link>https://velog.io/@jeong_woo/%EA%B3%B5%EA%B3%B5-%EC%98%81%EC%83%81%ED%9A%8C%EC%9D%98-%ED%91%9C%EC%A4%80-%EC%97%B0%EA%B3%84-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@jeong_woo/%EA%B3%B5%EA%B3%B5-%EC%98%81%EC%83%81%ED%9A%8C%EC%9D%98-%ED%91%9C%EC%A4%80-%EC%97%B0%EA%B3%84-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Thu, 25 Jun 2026 01:15:46 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/76ac82ef-5873-4f2b-a704-d7cb81575b51/image.png" alt=""></p>
<h1 id="공공-영상회의-표준-연계-아키텍처">공공 영상회의 표준 연계 아키텍처</h1>
<blockquote>
<p>작성 목적: 행정안전부 고시 「행정·공공기관 영상회의시스템 상호연계 기술 표준규격」 인증 대응을 위한 기술 분석 및 구현 계획 정리<br>현재 스택: 8x8 JaaS / lib-jitsi-meet (SFU) · Django/DRF · NHN Cloud</p>
</blockquote>
<hr>
<h2 id="1-배경--표준-규격이란">1. 배경 — 표준 규격이란</h2>
<p>행정안전부는 기관마다 제각각 구축된 영상회의 시스템 간 상호 통화·회의를 가능하게 하기 위해 <strong>「행정·공공기관 영상회의시스템 상호연계 기술 표준규격」</strong> 을 고시로 제정했다. 가장 최근 개정은 2021년 12월 13일이다.</p>
<p>공공조달 사업 참여 또는 지자체·행정기관 네트워크 연계 시 아래 5개 요건 충족이 요구된다.</p>
<hr>
<h2 id="2-5개-인증-요건--현황-분석">2. 5개 인증 요건 — 현황 분석</h2>
<table>
<thead>
<tr>
<th>요건</th>
<th>내용</th>
<th>JaaS 현재 상태</th>
</tr>
</thead>
<tbody><tr>
<td>H.264</td>
<td>영상 코덱</td>
<td>✅ WebRTC 기본 지원</td>
</tr>
<tr>
<td>SIP 연동</td>
<td>SIP(RFC 3261) 시그널링</td>
<td>❌ 미지원 — Jigasi 필요</td>
</tr>
<tr>
<td>콘텐츠 공유</td>
<td>H.239 또는 BFCP(RFC 4582)</td>
<td>❌ 미지원 — BFCP 브리지 필요</td>
</tr>
<tr>
<td>연계 GW 연동</td>
<td>범정부 SIP/TLS trunk 등록</td>
<td>❌ 미지원 — Asterisk 필요</td>
</tr>
<tr>
<td>E.164 번호</td>
<td>국제 전화번호 체계 다이얼인</td>
<td>❌ 미지원 — 번호 레지스트리 필요</td>
</tr>
</tbody></table>
<h3 id="왜-jaas만으로는-부족한가">왜 JaaS만으로는 부족한가</h3>
<p>JaaS / lib-jitsi-meet는 순수 WebRTC 스택이다. 행정기관 영상회의 인프라는 SIP(RFC 3261) 또는 H.323 기반 전용 장비 중심으로 구성되어 있어, 두 세계 사이를 잇는 별도 게이트웨이 레이어가 없으면 연결이 불가능하다.</p>
<hr>
<h2 id="3-전체-아키텍처">3. 전체 아키텍처</h2>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│                        행정기관 측                            │
│  H.323 단말 ──┐                                              │
│  SIP 소프트폰 ─┼── SIP/TLS or H.323 ──→ [ sip-gw 인스턴스 ] │
│  범정부 GW ───┘                                              │
└─────────────────────────────────────────────────────────────┘
                                │
                    ┌───────────▼───────────┐
                    │   sip-gw (신규 VM)   │
                    │ sip-gw.도메인        │
                    │                     │
                    │  Asterisk           │
                    │    ↓                │
                    │  Jigasi             │
                    │    ↓                │
                    │  E.164 레지스트리     │
                    │  (Redis 캐시)        │
                    │    ↓                │
                    │  BFCP 브리지         │
                    └───────────┬───────────┘
                                │  WebRTC (JaaS 참여자로 join)
                    ┌───────────▼───────────┐
                    │    8x8 JaaS 클라우드  │
                    │  Jitsi Videobridge   │
                    │  (SFU · 변경 없음)    │
                    └───────────┬───────────┘
                                │  WebRTC (기존 그대로)
                    ┌───────────▼───────────┐
                    │   기존 클라이언트     │
                    │  Electron 스튜디오   │
                    │  웹 클라이언트(경로당) │
                    └───────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  Django 백엔드 (icsc-platform)                          │
│  기존: 룸·JWT 관리  |  추가: E.164 API  |  추가: Jigasi 웹훅 │
└─────────────────────────────────────────────────────────────┘</code></pre><h3 id="핵심-원칙">핵심 원칙</h3>
<ul>
<li><strong>JaaS는 변경하지 않는다.</strong> Jigasi가 WebRTC 참여자로 JaaS 룸에 join하므로 JaaS 입장에서는 클라이언트 한 명이다.</li>
<li><strong>기존 서버(icsc-platform, mch-media)는 건드리지 않는다.</strong> 신규 VM 1대만 추가한다.</li>
<li><strong>경로당 클라이언트 영향 없음.</strong> 행정기관 연계 트래픽만 sip-gw를 경유한다.</li>
</ul>
<hr>
<h2 id="4-sip-gw-인스턴스-상세">4. sip-gw 인스턴스 상세</h2>
<h3 id="4-1-asterisk">4-1. Asterisk</h3>
<p>SIP 세계의 교환기 역할. 인스턴스에서 가장 먼저 외부 트래픽을 수신한다.</p>
<p><strong>주요 역할</strong></p>
<ul>
<li>행정기관 SIP 단말로부터 SIP/TLS 콜 수신 (포트 5061)</li>
<li>H.323 단말 인입 시 H.323 → SIP 변환 (<code>chan_h323</code> 또는 외부 GW 연동)</li>
<li>Django E.164 API(또는 Redis 캐시)를 조회해 목적지 룸 ID 확인</li>
<li>범정부 연계 GW에 SIP trunk로 등록 — 행안부 GW 입장에서 스마트실버가 SIP 서버 한 대로 인식됨</li>
<li>내부에서 Jigasi로 콜 전달</li>
</ul>
<p><strong>보안 구성</strong></p>
<table>
<thead>
<tr>
<th>구간</th>
<th>프로토콜</th>
</tr>
</thead>
<tbody><tr>
<td>외부 (행정기관 ↔ Asterisk)</td>
<td>SIP/TLS (포트 5061) + SRTP</td>
</tr>
<tr>
<td>내부 (Asterisk ↔ Jigasi)</td>
<td>SIP (포트 5060)</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-2-jigasi">4-2. Jigasi</h3>
<p>이 아키텍처의 핵심 브리지. SIP 콜을 WebRTC로 변환해 JaaS 룸에 참여시킨다.</p>
<p><strong>동작 흐름</strong></p>
<pre><code>Asterisk → [SIP 콜 전달]
  → Jigasi: Django 웹훅에 JWT 발급 요청
  → JWT 수신
  → JaaS 룸에 WebRTC 참여자로 join
  → 미디어 브리징 시작</code></pre><p><strong>미디어 처리</strong></p>
<table>
<thead>
<tr>
<th>방향</th>
<th>처리 내용</th>
</tr>
</thead>
<tbody><tr>
<td>오디오</td>
<td>G.711 / G.722 ↔ Opus 실시간 트랜스코딩</td>
</tr>
<tr>
<td>영상</td>
<td>H.264 pass-through (가능 시) / 불가 시 트랜스코딩</td>
</tr>
</tbody></table>
<p>Jigasi는 Jitsi 프로젝트의 공식 컴포넌트로, JaaS 연동 레퍼런스가 공식 문서에 정리되어 있다.</p>
<hr>
<h3 id="4-3-e164-레지스트리-redis-캐시">4-3. E.164 레지스트리 (Redis 캐시)</h3>
<p>Django가 원본 번호 매핑 데이터를 관리하지만, Asterisk가 콜마다 Django를 직접 호출하면 지연이 발생한다. sip-gw 내부에 Redis로 번호 매핑 테이블을 캐싱한다.</p>
<pre><code>Django (원본 DB)
  → 번호 변경 시 Redis 갱신 or Asterisk reload 신호
  ↓
Redis on sip-gw (캐시)
  ← Asterisk 조회 (콜 수신 시)</code></pre><p>번호 매핑 예시:</p>
<pre><code>+82-2-XXXX-1234  →  room-id: smartsilver-gyeongrodang-42
+82-2-XXXX-1235  →  room-id: smartsilver-gyeongrodang-15</code></pre><hr>
<h3 id="4-4-bfcp-브리지">4-4. BFCP 브리지</h3>
<p>5개 요건 중 구현 난이도가 가장 높다.</p>
<p><strong>BFCP란?</strong> RFC 4582. SIP 환경에서 화면 공유 권한(floor)을 협상하는 프로토콜. H.323 환경에서는 H.239가 동일 역할을 한다.</p>
<p><strong>문제:</strong> JaaS의 화면 공유는 WebRTC <code>getDisplayMedia()</code> 기반이며 BFCP/H.239 시그널링을 전혀 사용하지 않는다.</p>
<p><strong>구현 방식 (커스텀)</strong></p>
<pre><code>SIP 단말 → BFCP floor request 수신
  → BFCP 브리지가 floor 승인
  → 헤드리스 브라우저(Puppeteer 등)가 JaaS 룸에서 화면 공유 세션 시작
  → SIP 단말의 H.239 콘텐츠 스트림을 두 번째 비디오 트랙으로 JaaS에 publish</code></pre><p>표준 구현체가 없어 직접 개발이 필요하다. <strong>인증 심사에서 이 요건을 어느 수준까지 검증하는지 먼저 확인하고 작업 범위를 결정하는 것을 권장한다.</strong></p>
<hr>
<h3 id="4-5-포트-정리">4-5. 포트 정리</h3>
<table>
<thead>
<tr>
<th>포트</th>
<th>프로토콜</th>
<th>방향</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>5060</td>
<td>SIP UDP/TCP</td>
<td>내부</td>
<td>Asterisk ↔ Jigasi</td>
</tr>
<tr>
<td>5061</td>
<td>SIP TLS</td>
<td>외부 인바운드</td>
<td>행정기관, 범정부 GW</td>
</tr>
<tr>
<td>10000–20000</td>
<td>RTP/SRTP UDP</td>
<td>양방향</td>
<td>미디어 스트림</td>
</tr>
<tr>
<td>2855</td>
<td>BFCP TCP</td>
<td>외부 인바운드</td>
<td>화면 공유 floor control</td>
</tr>
<tr>
<td>6379</td>
<td>Redis TCP</td>
<td>내부</td>
<td>E.164 캐시</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-django-추가-api">5. Django 추가 API</h2>
<p>기존 icsc-platform에 엔드포인트 2개만 추가한다. 기존 룸·JWT 관리 로직은 변경하지 않는다.</p>
<h3 id="5-1-e164-번호-↔-룸-매핑-crud-api">5-1. E.164 번호 ↔ 룸 매핑 CRUD API</h3>
<pre><code>GET    /api/sip/numbers/              # 전체 번호 매핑 목록
POST   /api/sip/numbers/              # 번호 등록
PATCH  /api/sip/numbers/{number}/     # 매핑 수정
DELETE /api/sip/numbers/{number}/     # 번호 삭제</code></pre><p>Asterisk가 콜 수신 시 이 API(또는 Redis 캐시)를 조회해 목적지 룸 ID를 얻는다. 번호 변경 시 Redis 갱신 신호도 여기서 발송한다.</p>
<h3 id="5-2-jigasi-웹훅-jwt-발급">5-2. Jigasi 웹훅 (JWT 발급)</h3>
<pre><code>POST /api/sip/jwt/
Body: { &quot;room_id&quot;: &quot;smartsilver-gyeongrodang-42&quot; }
Response: { &quot;jwt&quot;: &quot;&lt;JaaS JWT token&gt;&quot; }</code></pre><p>Jigasi가 JaaS 룸에 join하려면 유효한 JWT가 필요하다. Jigasi가 이 엔드포인트를 호출하면 Django가 기존 JWT 발급 로직을 재사용해 토큰을 돌려준다. 기존 코드를 감싸는 얇은 레이어다.</p>
<hr>
<h2 id="6-서버-구성-요약">6. 서버 구성 요약</h2>
<table>
<thead>
<tr>
<th>서버</th>
<th>역할</th>
<th>변경 여부</th>
</tr>
</thead>
<tbody><tr>
<td><code>icsc-platform</code></td>
<td>Django 백엔드</td>
<td>API 2개 추가</td>
</tr>
<tr>
<td><code>mch-media</code></td>
<td>nginx-rtmp / HLS</td>
<td>변경 없음</td>
</tr>
<tr>
<td><code>sip-gw</code> <strong>(신규)</strong></td>
<td>Asterisk + Jigasi + BFCP 브리지</td>
<td>신규 생성</td>
</tr>
<tr>
<td>8x8 JaaS (클라우드)</td>
<td>Videobridge / Jicofo</td>
<td>변경 없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-구현-우선순위">7. 구현 우선순위</h2>
<p>난이도와 의존성을 고려한 권장 순서다.</p>
<pre><code>1단계  Asterisk 설치 + SIP 수신 기본 동작 확인
         ↓
2단계  Jigasi 설치 + JaaS 룸 join 연동 검증
         ↓
3단계  Django E.164 API + Redis 캐시 구성
         ↓
4단계  범정부 연계 GW에 SIP trunk 등록
         ↓
5단계  BFCP 브리지 (심사 요건 수준 확인 후 범위 결정)</code></pre><p>1~4단계가 완료되면 SIP 연동·연계 GW·E.164 세 가지 요건이 충족된다. H.264는 JaaS가 이미 지원하므로 4개 요건이 동시에 해결된다. BFCP는 별도 단계로 분리 진행한다.</p>
<hr>
<h2 id="8-참고-자료">8. 참고 자료</h2>
<ul>
<li><a href="https://www.mois.go.kr/frt/bbs/type001/commonSelectBoardArticle.do?bbsId=BBSMSTR_000000000045&amp;nttId=89780">행안부 영상회의시스템 상호연계 기술 표준규격 개정 안내 (2021.12.13)</a></li>
<li><a href="https://github.com/jitsi/jigasi">Jigasi 공식 GitHub</a></li>
<li><a href="https://developer.8x8.com/jaas">JaaS 개발자 문서</a></li>
<li>RFC 3261 — SIP</li>
<li>RFC 4582 — BFCP</li>
<li>ITU-T H.239 — 콘텐츠 공유 (H.323 환경)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Django 알림 허브 운영 환경 구축하기]]></title>
            <link>https://velog.io/@jeong_woo/Django-%EC%95%8C%EB%A6%BC-%ED%97%88%EB%B8%8C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/Django-%EC%95%8C%EB%A6%BC-%ED%97%88%EB%B8%8C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Jun 2026 04:14:13 GMT</pubDate>
            <description><![CDATA[<h1 id="django-알림-허브-운영-환경-구축기--redis--celery로-비동기-이메일-발송-띄우기">Django 알림 허브 운영 환경 구축기 — Redis + Celery로 비동기 이메일 발송 띄우기</h1>
<blockquote>
<p>2026-06-19 · Smart Silver Center(ssc-api) · Django 5.2 / DRF / Celery / Redis</p>
</blockquote>
<p>경로당 관리 시스템에 &quot;알림 허브&quot;를 붙이는 작업의 마지막 퍼즐 — <strong>실제로 이메일이 나가는 운영 파이프라인</strong>을 세웠다. 코드는 이미 있었지만, 비동기 발송이 동작하려면 인프라(Redis 브로커, Celery 워커, SMTP)가 받쳐줘야 한다. 그 과정을 섹션별로 남긴다.</p>
<hr>
<h2 id="1-배경--notify-한-함수로-모든-알림이-통과한다">1. 배경 — <code>notify()</code> 한 함수로 모든 알림이 통과한다</h2>
<p>알림 허브의 핵심은 단일 코어 함수 <code>notify()</code>다. 비즈니스 이벤트(연계 공지, 긴급방송, 가족 가입)가 발생하면 이 함수를 호출하고, 함수는 이렇게 동작한다.</p>
<pre><code class="language-python">def notify(*, event_code, idempotency_prefix, object_id, context=None, source=&quot;ssc-api&quot;):
    # 1. EventType 활성 확인 (없으면 조용히 return)
    # 2. is_default 템플릿 확인
    # 3. 구독자 조회 (event_type별)
    # 4. 수신자별 NotificationLog(status=&quot;queued&quot;) 생성 — idempotency_key unique로 중복 차단
    # 5. transaction.on_commit으로 Celery enqueue (호출부 트랜잭션 롤백 시 발송 안 함)
    # 반환: {&quot;queued&quot;: N}</code></pre>
<p>설계 원칙 두 가지가 인상적이었다.</p>
<ul>
<li><strong>절대 예외를 호출부로 던지지 않는다.</strong> 전체를 <code>try/except</code>로 감싼다. 알림 발송이 실패해도 &quot;공지 생성&quot; 같은 비즈니스 로직이 롤백되면 안 되기 때문.</li>
<li><strong>동기 코어 + 비동기 발송 분리.</strong> <code>notify()</code>는 조회·검증·로그·enqueue까지만 동기로 하고 즉시 반환. 실제 SMTP 발송은 Celery 워커에 넘겨 뷰 응답을 막지 않는다.</li>
</ul>
<p>진입점은 두 갈래다.</p>
<ul>
<li><strong>경로 A (코드 직접 호출):</strong> ssc-api 내부 3곳에서 함수를 그대로 호출.</li>
<li><strong>경로 B (HTTP):</strong> <code>POST /api/v2/notifications/notify/</code> — 외부 서비스용. <code>X-Service-Token</code> + IP 화이트리스트로 이중 방어.</li>
</ul>
<p>(끝까지 안 만든 경로 C — Loki/Alertmanager 로그 기반 알림 — 은 다음 작업으로 남겼다.)</p>
<hr>
<h2 id="2-smtp-설정도-안-했는데-메일이-간다고--착각의-정체">2. &quot;SMTP 설정도 안 했는데 메일이 간다고?&quot; — 착각의 정체</h2>
<p>처음엔 테스트가 통과하니 메일이 나가는 줄 알았다. 코드를 열어보니 아니었다.</p>
<pre><code class="language-python"># core/base.py
EMAIL_BACKEND = &quot;django.core.mail.backends.smtp.EmailBackend&quot;  # 진짜 SMTP 백엔드
EMAIL_HOST = os.getenv(&quot;EMAIL_HOST&quot;)  # ← env 없으면 None → 발송 시 예외</code></pre>
<p>테스트가 &quot;성공&quot;한 건 두 장치 덕이었다.</p>
<pre><code class="language-python">@override_settings(CELERY_TASK_ALWAYS_EAGER=True, DEFAULT_FROM_EMAIL=&quot;from@ssc.com&quot;)
@patch(&quot;notifications.tasks.EmailMessage.send&quot;)  # 실제 send를 mock으로 치환
def test_success_marks_sent(self, mock_send): ...</code></pre>
<p>즉 테스트의 <code>sent</code>는 <strong>mock된 가짜 성공</strong>이지 실제 메일이 아니다. 운영에서 실제로 메일이 나가려면 세 가지가 모두 필요했다.</p>
<table>
<thead>
<tr>
<th>필요 조건</th>
<th>없으면</th>
</tr>
</thead>
<tbody><tr>
<td>SMTP env (EMAIL_HOST 등)</td>
<td>task가 <code>failed</code>로 떨어짐</td>
</tr>
<tr>
<td>Redis 브로커</td>
<td><code>delay()</code> 적재 실패 → 즉시 <code>failed</code></td>
</tr>
<tr>
<td>Celery 워커 프로세스</td>
<td>큐에 쌓이기만 하고 영영 안 보냄</td>
</tr>
</tbody></table>
<p>이 세 개를 채우는 게 이번 작업의 전부였다.</p>
<hr>
<h2 id="3-왜-celery--redis인가--기술-선택의-근거">3. 왜 Celery + Redis인가 — 기술 선택의 근거</h2>
<p>&quot;이메일 하나 비동기로 보내는 데 굳이 Celery랑 Redis까지 필요한가?&quot;는 정당한 질문이다. 선택지를 하나씩 따져봤다.</p>
<h3 id="먼저-왜-동기-발송은-안-되나">먼저, 왜 동기 발송은 안 되나</h3>
<p>가장 단순한 길은 뷰 안에서 <code>send_mail()</code>을 그냥 호출하는 것이다. 하지만 SMTP 발송은 외부 서버(Gmail)와의 네트워크 왕복이라 <strong>수 초가 걸리고, 언제든 실패·타임아웃할 수 있다.</strong> 이걸 요청 스레드에서 동기로 하면:</p>
<ul>
<li>공지 생성 API 응답이 메일 발송이 끝날 때까지 <strong>블로킹</strong>된다. 구독자가 10명이면 10번의 SMTP 왕복을 사용자가 기다린다.</li>
<li>Gmail이 일시적으로 느리거나 막히면 <strong>그 API 전체가 같이 느려지거나 죽는다.</strong> 알림이 비즈니스 기능을 인질로 잡는 셈.</li>
<li>gunicorn 워커 스레드가 메일 I/O에 묶여 처리량이 떨어진다.</li>
</ul>
<p>그래서 &quot;받아서 큐에 넣고 즉시 응답, 발송은 백그라운드에서&quot;라는 <strong>비동기 작업 큐</strong>가 필요했다. 이 지점부터 선택지가 갈린다.</p>
<h3 id="후보-비교">후보 비교</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
<th>이 프로젝트에서</th>
</tr>
</thead>
<tbody><tr>
<td><strong>동기 <code>send_mail()</code></strong></td>
<td>인프라 0, 가장 단순</td>
<td>응답 블로킹, 실패가 비즈니스 로직에 전파, 재시도 없음</td>
<td>✗ 위 이유로 탈락</td>
</tr>
<tr>
<td><strong><code>threading</code>/<code>ThreadPoolExecutor</code></strong></td>
<td>의존성 없음, 응답은 안 막음</td>
<td>프로세스 죽으면 작업 유실, 재시도·모니터링 없음, gunicorn <code>--preload</code>/멀티워커와 충돌 위험</td>
<td>✗ 신뢰성 부족</td>
</tr>
<tr>
<td><strong>DB 기반 큐 (django-q 등)</strong></td>
<td>브로커 불필요(기존 MySQL 재사용)</td>
<td>DB 폴링 부하, 처리량 한계, 생태계 작음</td>
<td>△ 가능하나 확장성 아쉬움</td>
</tr>
<tr>
<td><strong>Celery + Redis</strong></td>
<td>표준·성숙, 재시도/백오프 내장, 워커 수평 확장, 모니터링 도구 풍부</td>
<td>브로커(Redis) + 워커 프로세스 운영 부담</td>
<td>✓ <strong>채택</strong></td>
</tr>
<tr>
<td><strong>Celery + RabbitMQ</strong></td>
<td>메시지 보장 가장 강력(AMQP)</td>
<td>무겁고 운영 복잡, 이 규모엔 과함</td>
<td>✗ 오버스펙</td>
</tr>
</tbody></table>
<h3 id="왜-celery였나">왜 Celery였나</h3>
<ul>
<li><strong>재시도·지수 백오프가 선언형으로 내장.</strong> SMTP는 일시 실패가 흔한데, <code>@shared_task(bind=True, max_retries=3, retry_backoff=True)</code> 한 줄로 &quot;실패 시 점점 간격을 늘려 3번 재시도&quot;가 끝난다. 직접 구현하면 의외로 손이 많이 가는 부분.</li>
<li><strong>Django 생태계의 사실상 표준.</strong> <code>autodiscover_tasks()</code>로 각 앱의 <code>tasks.py</code>를 자동 수집하고, settings에 <code>CELERY_*</code> 네임스페이스로 통합된다. 자료·troubleshooting이 압도적으로 많다.</li>
<li><strong>워커를 수평 확장</strong>할 수 있다. 알림량이 늘면 <code>--concurrency</code>를 올리거나 워커 프로세스를 추가하면 된다. 동기/스레드 방식은 여기서 막힌다.</li>
<li><strong>로컬/테스트에서 인프라 없이 돌릴 수 있다.</strong> <code>CELERY_TASK_ALWAYS_EAGER=True</code>면 브로커·워커 없이 task가 그 자리에서 동기 실행된다(테스트에서 이걸 썼다). 운영과 개발의 코드가 동일한 게 큰 장점.</li>
</ul>
<h3 id="왜-rabbitmq가-아니라-redis였나">왜 RabbitMQ가 아니라 Redis였나</h3>
<p>Celery의 브로커로는 RabbitMQ가 &quot;정석&quot;으로 꼽히지만, 이 프로젝트엔 Redis가 더 맞았다.</p>
<ul>
<li><strong>규모가 작다.</strong> 알림 메일은 초당 수천 건이 아니라 이벤트당 수~수십 건이다. RabbitMQ의 강력한 메시지 라우팅·보장은 여기선 안 쓰는 기능에 운영 복잡도만 더한다.</li>
<li><strong>운영이 가볍다.</strong> <code>apt install redis-server</code> 한 번에 끝나고, localhost 바인딩만 하면 보안도 단순하다. RabbitMQ는 Erlang 런타임·vhost·exchange 설정 등 학습/운영 곡선이 가파르다.</li>
<li><strong>브로커 + 결과 백엔드를 하나로.</strong> Celery는 작업 큐(브로커)와 결과 저장(result backend)이 둘 다 필요한데, Redis는 DB 인덱스만 나눠(<code>/0</code> 브로커, <code>/1</code> 결과) 한 서버로 둘 다 처리한다. RabbitMQ는 결과 백엔드를 따로 둬야 한다.</li>
<li><strong>이미 친숙한 스택.</strong> 캐시·세션 용도로도 재활용 가능해 한 번 띄워두면 쓰임이 넓다.</li>
</ul>
<blockquote>
<p>정리하면 — &quot;비동기 + 재시도 + 확장 가능&quot;이 필요해서 Celery, &quot;이 규모엔 가볍고 충분&quot;해서 Redis. 트래픽이 폭증하거나 메시지 유실이 절대 불가한 요구가 생기면 그때 RabbitMQ로 브로커만 교체하면 된다(Celery 코드는 그대로).</p>
</blockquote>
<hr>
<h2 id="4-redis-설치--상황-파악-먼저">4. Redis 설치 — &quot;상황 파악 먼저&quot;</h2>
<p>운영 서버(Ubuntu 22.04)에 Redis를 올리기 전에, 충돌·중복을 막으려고 현재 상태부터 진단했다.</p>
<pre><code class="language-bash">which redis-server redis-cli || echo &quot;redis 미설치&quot;
sudo ss -tlnp | grep 6379 || echo &quot;6379 사용 안 함(깨끗)&quot;
. /etc/os-release &amp;&amp; echo &quot;$NAME $VERSION&quot;
ps aux | grep &quot;[c]elery&quot; || echo &quot;celery 워커 없음&quot;</code></pre>
<p>결과는 &quot;엄청 깨끗&quot; — 미설치, 포트 비어있음, 워커 없음. 그대로 진행했다.</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y redis-server

# 보안: localhost 전용 바인딩 확인 (Redis는 기본 인증이 없어 외부 노출이 위험)
grep -E &quot;^bind|^protected-mode&quot; /etc/redis/redis.conf
# → bind 127.0.0.1 -::1 / protected-mode yes  (Ubuntu 기본값이 이미 안전)

sudo systemctl enable --now redis-server</code></pre>
<p>검증은 코드가 실제로 쓸 DB 인덱스까지 확인했다. 브로커는 DB 0, 결과 백엔드는 DB 1을 쓰도록 설정돼 있었다(<code>redis://localhost:6379/0</code>, <code>/1</code>).</p>
<pre><code class="language-bash">redis-cli -n 0 set ssc:smoke ok &amp;&amp; redis-cli -n 0 get ssc:smoke &amp;&amp; redis-cli -n 0 del ssc:smoke
redis-cli -n 1 ping
# → OK / &quot;ok&quot; / (integer) 1 / PONG</code></pre>
<hr>
<h2 id="5-celery-워커-systemd-서비스--가장-큰-함정">5. Celery 워커 systemd 서비스 — 가장 큰 함정</h2>
<p>기존 <code>gunicorn.service</code>를 본떠 워커 서비스를 만들었는데, 여기서 <strong>이번 작업에서 제일 중요한 발견</strong>이 나왔다.</p>
<p>gunicorn은 실행 인자로 <code>core.incheon.wsgi:application</code>을 줘서 settings를 강제한다. 그런데 <strong>Celery 워커에는 그런 인자가 없다.</strong> 그리고 코드의 기본 폴백은 dev로 잡혀 있다.</p>
<pre><code class="language-python"># manage.py — ENV_FILE에 ics/mch가 없으면 dev로 폴백
default_settings = next((s for k, s in MAP.items() if k in env_file), &quot;core.dev.settings&quot;)

# core/celery.py — 아무것도 안 정하면 dev
os.environ.setdefault(&quot;DJANGO_SETTINGS_MODULE&quot;, &quot;core.dev.settings&quot;)</code></pre>
<p><code>ENV_FILE=.env</code>만 보고 워커를 띄우면 <strong>운영 DB가 아닌 dev DB를 바라보는 사고</strong>가 난다. 그래서 워커 서비스에 settings를 명시적으로 박았다.</p>
<pre><code class="language-ini">[Service]
Environment=&quot;ENV_FILE=.env&quot;
Environment=&quot;DJANGO_SETTINGS_MODULE=core.incheon.settings&quot;   # ★ 이 한 줄이 핵심
ExecStart=/.../.venv/bin/celery -A core worker \
          --loglevel=info --concurrency=2 \
          --logfile=/var/log/ssc_api/celery_worker.log

[Unit]
Requires=redis-server.service
After=network.target redis-server.service</code></pre>
<p><code>-A core</code>는 <code>core/celery.py</code>의 app을 가리킨다 (gunicorn이 <code>core.incheon.wsgi</code>를 쓰는 것과 같은 <code>core</code> 패키지).</p>
<hr>
<h2 id="6-삽질--status203exec">6. 삽질 — <code>status=203/EXEC</code></h2>
<p>첫 기동에서 워커가 바로 죽었다.</p>
<pre><code>Active: activating (auto-restart) (Result: exit-code)
Process: ExecStart=... (code=exited, status=203/EXEC)</code></pre><p><code>203/EXEC</code>는 systemd가 <strong>실행 파일 자체에 도달하지 못했다</strong>는 뜻이다. celery 바이너리에 닿지도 못했으니 <code>--logfile</code>도 안 생겼다. 원인 후보는 셋: 경로 오타 / 실행권한 / <strong>바이너리 부재</strong>.</p>
<p>진단해보니 마지막이었다. 이 브랜치는 아직 서버에 완전히 배포·동기화되지 않아 <code>.venv/bin/celery</code>가 없었다. <code>uv add celery redis</code>는 의존성 선언만 추가할 뿐, 서버에서 실제 설치는 <code>uv sync</code>를 해야 한다.</p>
<pre><code class="language-bash">cd /app/smart-silver-center/ssc-api
uv sync
.venv/bin/celery --version   # 이제 동작</code></pre>
<blockquote>
<p>교훈: <code>uv add</code>(선언) ≠ 서버에 설치됨. 배포 서버에선 <code>uv sync</code>가 별도로 필요하다.</p>
</blockquote>
<hr>
<h2 id="7-end-to-end-검증--실제-메일이-도착하다">7. End-to-End 검증 — 실제 메일이 도착하다</h2>
<p>dev 서버에서 먼저 검증(운영 적용 전 안전장치). Django shell로 본인 메일을 구독자로 등록하고 <code>notify()</code>를 직접 호출했다. 멱등성 skip을 피하려고 <code>object_id</code>에 timestamp를 넣은 게 포인트.</p>
<pre><code class="language-python">import time
oid = f&quot;e2e-{int(time.time())}&quot;   # 매번 고유 → 중복 차단에 안 걸림
result = notify(event_code=&quot;notice&quot;, idempotency_prefix=&quot;notice&quot;, object_id=oid)
# → {&#39;queued&#39;: 1}</code></pre>
<p>워커 로그가 전 구간을 증명했다.</p>
<pre><code>[10:06:00] Connected to redis://localhost:6379/0
[10:06:01] celery@smartsilver-dev-server ready.
[10:18:02] Task ...send_notification_email[72b287e6...] received      ← Redis 큐에서 꺼냄
[10:18:05] Task ...send_notification_email[72b287e6...] succeeded in 2.41s  ← SMTP 발송 성공</code></pre><p>그리고 실제 메일함에 도착. <strong>notify → Redis → Celery 워커 → SMTP → 수신</strong> 전 구간이 운영 상태로 연결됐다.</p>
<hr>
<h2 id="8-배운-것들-요약">8. 배운 것들 (요약)</h2>
<ul>
<li><strong>문서를 믿지 말고 코드/서버를 확인하라.</strong> 인프라 문서의 경로(<code>/app/...</code>), 계정(<code>shbae</code>), 서비스명(<code>gunicorn-ics</code>)이 실제 서버(<code>/home/ubuntu/...</code>, <code>ubuntu</code>, <code>gunicorn.service</code>)와 전부 달랐다. 진단 명령으로 실물을 확인한 게 사고를 막았다.</li>
<li><strong>워커는 settings를 자동으로 모른다.</strong> 웹 서버가 인자로 강제하는 settings를, 백그라운드 워커는 환경변수로 명시해줘야 한다. 안 하면 dev DB로 조용히 새어 나간다.</li>
<li><strong><code>uv add</code> ≠ 설치.</strong> 배포 서버에선 <code>uv sync</code>. 203/EXEC의 범인.</li>
<li><strong>mock된 테스트 성공 ≠ 실제 동작.</strong> 외부 I/O(SMTP)는 운영에서 한 번은 실제로 흘려봐야 한다.</li>
<li><strong>dev에서 먼저, prod는 나중.</strong> 같은 절차를 dev에서 검증한 뒤 prod에 적용하는 순서가 안전하다.</li>
<li><strong>기술 선택은 규모에 맞춰라.</strong> 비동기·재시도·확장이 필요해서 Celery, 이 규모엔 가볍고 충분해서 Redis를 골랐다. RabbitMQ 같은 &quot;정석&quot;이 항상 정답은 아니다 — 요구가 커지면 그때 브로커만 교체하면 된다.</li>
</ul>
<hr>
<h2 id="부록--전체-데이터-흐름">부록 — 전체 데이터 흐름</h2>
<pre><code>[경로 A] 코드 직접 호출 (notice/broadcasting/accounts)  ──┐
[경로 B] HTTP POST /notify/ (X-Service-Token + IP)      ──┤
[경로 C] Alertmanager webhook (미구현)                   ──┘
                          │
                          ▼
              notify() 코어 (동기, 예외 안 던짐)
                          │  NotificationLog(queued) + on_commit enqueue
                          ▼
              Redis 브로커 (DB0)  ──►  Celery 워커
                                          │  EmailMessage.send()
                                          ▼
                                    📧 SMTP → 수신</code></pre><p>남은 작업: 경로 C(Alertmanager 어댑터) 구현 — <code>alerts[]</code> 배열 순회, <code>fingerprint:status</code> 멱등성, Bearer 인증.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (3)]]></title>
            <link>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0-3</link>
            <guid>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0-3</guid>
            <pubDate>Tue, 16 Jun 2026 08:13:08 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/fcccb2a1-8582-45c4-9536-8f0d9d31d029/image.png" alt=""></p>
<h1 id="분산된-django-서버의-로그를-통합하고-이벤트-알림-보내기-3--알림-아키텍처-설계-운영-알림과-비즈니스-이벤트를-가르다">분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (3) — 알림 아키텍처 설계: 운영 알림과 비즈니스 이벤트를 가르다</h1>
<p>2편까지 &quot;로그 → Loki → Ruler → Alertmanager → 알림&quot;이라는 파이프라인을 완성했다. 자연스러운 다음 질문은 이것이다. <strong>그럼 우리가 보내고 싶은 모든 알림을 이 Loki 파이프라인에 태우면 되는가?</strong></p>
<p>답은 &quot;아니오&quot;였다. 이 편은 코드 한 줄보다 그 판단 과정이 핵심이다.</p>
<h2 id="발단-사용자가-등록한-이벤트를-그-사용자에게-보내고-싶다">발단: &quot;사용자가 등록한 이벤트를 그 사용자에게 보내고 싶다&quot;</h2>
<p>운영 알림(에러 급증, 서버 다운)은 Loki Ruler로 잘 잡힌다. 그런데 요구사항이 하나 더 있었다.</p>
<blockquote>
<p>ssc-api의 관리 UI에서 <strong>사용자</strong>와 <strong>이벤트</strong>를 등록해두고, 해당 이벤트가 발생하면 <strong>그 사용자의 이메일</strong>로, 미리 만들어둔 <strong>템플릿</strong>을 보낸다.</p>
</blockquote>
<p>처음엔 &quot;이것도 Loki Ruler + Alertmanager로 하면 되겠지&quot;라고 생각했다. 하지만 따져보니 잘 맞지 않았다.</p>
<h2 id="개념-운영-알림-vs-비즈니스-이벤트-알림">개념: 운영 알림 vs 비즈니스 이벤트 알림</h2>
<p>두 종류의 알림은 성격이 근본적으로 다르다.</p>
<table>
<thead>
<tr>
<th></th>
<th>운영 알림</th>
<th>비즈니스 이벤트 알림</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>운영자(고정)</td>
<td>등록된 사용자(동적)</td>
</tr>
<tr>
<td>내용</td>
<td>시스템 상태</td>
<td>미리 정한 템플릿</td>
</tr>
<tr>
<td>출처</td>
<td>로그에 나타나는 신호</td>
<td>코드 안의 비즈니스 로직</td>
</tr>
<tr>
<td>예시</td>
<td>&quot;5xx 급증&quot;, &quot;서버 다운&quot;</td>
<td>&quot;메시지 처리 실패 알림&quot;</td>
</tr>
<tr>
<td>적합 도구</td>
<td>Loki Ruler + Alertmanager</td>
<td>애플리케이션 레벨 발송</td>
</tr>
</tbody></table>
<p><strong>Alertmanager는 본질적으로 운영자용 도구다.</strong> 수신자를 <code>alertmanager.yml</code>에 미리 박아두는 정적 구조라, &quot;UI에서 사용자가 등록한 임의의 이메일에 동적으로 보낸다&quot;와는 결이 안 맞는다. 게다가 Alertmanager가 보내는 건 집계된 alert라, 개별 이벤트의 풍부한 내용을 담기에도 제약이 있다.</p>
<h2 id="함정-매몰비용--이미-만들었으니까">함정: 매몰비용 — &quot;이미 만들었으니까&quot;</h2>
<p>여기서 내가 빠질 뻔한 함정이 있다. &quot;Loki/Alertmanager를 이만큼 구축했으니, 이걸 재활용해서 비즈니스 알림도 처리하자&quot;는 생각이다.</p>
<p>이건 전형적인 <strong>매몰비용(sunk cost)</strong>의 논리다. 짚어야 할 사실은, <strong>비즈니스 알림을 다른 방식으로 처리해도 Loki/Alertmanager는 버려지지 않는다</strong>는 것이다. 로그 통합과 Grafana 조회는 그 자체로 값을 하고, 운영 알림은 계속 Alertmanager로 나간다. 두 시스템은 공존하는 거지 택일이 아니다. 따라서 &quot;이미 만들었으니까&quot;는 이 결정의 근거가 못 된다.</p>
<h2 id="핵심-통찰-비즈니스-이벤트는-로그를-우회하라">핵심 통찰: 비즈니스 이벤트는 로그를 우회하라</h2>
<p>결정적인 깨달음은 이거였다. 감지하려는 이벤트가 <strong>코드 안의 비즈니스 로직에서 발생</strong>한다면, 그걸 굳이 로그로 내보냈다가 다시 주워올 이유가 없다.</p>
<p>비즈니스 이벤트를 Loki Ruler로 잡으면 흐름이 이렇게 된다.</p>
<pre><code>코드에서 이미 이벤트를 앎
  → 로그로 출력 → Alloy 수집 → Loki push
  → Ruler 폴링 평가 → Alertmanager → webhook → 발송</code></pre><p>코드가 이미 모든 정보를 쥐고 있는데, 이 긴 우회로를 도는 것이다. 여기서 떠안는 비용이 셋이다.</p>
<ul>
<li><strong>지연</strong> — Ruler는 폴링이라 기본 1분 단위로 늦는다.</li>
<li><strong>유실</strong> — 로그 로테이션, 에이전트 재시작, Loki 다운 등 단계마다 유실 지점이 생긴다. 중요한 비즈니스 알림을 로그 파이프라인 신뢰성에 맡기는 건 위험하다.</li>
<li><strong>취약성</strong> — 감지가 정규식 파싱에 의존하니, 로그 포맷이 한 번 바뀌면 조용히 안 잡힌다.</li>
</ul>
<p>반대로 코드에서 직접 처리하면 즉시·확실·구조화된 채로 끝난다. <strong>&quot;코드로 잡을 수 있는 이벤트를 로그 파이프라인으로 감지하는 것&quot;은 안티패턴에 가깝다.</strong></p>
<p>(예외: 코드를 수정할 수 없는 서드파티/레거시이거나, 순수하게 로그에만 나타나는 신호라면 Loki Ruler가 맞다. 운영 알림이 바로 이 경우다.)</p>
<h2 id="패턴-알림-허브-notification-hub">패턴: 알림 허브 (Notification Hub)</h2>
<p>그렇다면 비즈니스 알림은 어떻게 보내야 할까. 여기서 좋은 직관이 하나 있었다. <strong>&quot;다른 서비스들도 이메일을 보낼 일이 있을 텐데, 이메일 발송 엔드포인트를 하나 만들어두면 좋지 않을까?&quot;</strong></p>
<p>이게 바로 <strong>알림 허브(notification hub)</strong> 패턴이다. ssc-api에 단일 알림 엔드포인트를 두고, 여러 서비스가 그걸 호출한다. 모든 메일이 한 지점을 통과하므로 <strong>템플릿·발송 로그·레이트리밋·수신자 정책을 일원화</strong>할 수 있다.</p>
<p>중요한 구분: 이 허브의 가치는 &quot;Alertmanager webhook이라서&quot;가 아니라 <strong>&quot;허브를 하나 둔다&quot;</strong>는 것 자체에 있다. 그래서 이 허브는 <strong>누가 호출하든 상관없다.</strong></p>
<ul>
<li>ssc-api 자신의 비즈니스 이벤트 → 내부 함수로 직접 호출(HTTP 불필요)</li>
<li>ssc-msg, 다른 서비스의 이벤트 → HTTP로 호출</li>
<li>운영 알림(Loki Ruler가 잡은 것) → Alertmanager가 webhook으로 호출</li>
</ul>
<p>즉 비즈니스 이벤트는 Loki를 우회해 허브를 직접 부르고, 운영 알림만 Loki Ruler를 거쳐 같은 허브로 들어온다.</p>
<h2 id="최종-설계-역할-분리">최종 설계: 역할 분리</h2>
<p>정리하면 알림을 세 갈래로 나눴다.</p>
<pre><code>[비즈니스 이벤트]
ssc-api 코드 ──(내부 함수 호출)──┐
ssc-msg 코드 ──(HTTP POST)───────┤
다른 서비스   ──(HTTP POST)───────┤
                                 ▼
                  ssc-api  POST /internal/notify/    ← 알림 허브
                  - event_code 로 구독자·템플릿 조회
                  - 멱등성·레이트리밋 검사 → 비동기 발송
                                 ▲
[운영 알림]                      │
Loki Ruler → Alertmanager ──(webhook)──┘
            POST /internal/notify/alertmanager/  (어댑터)</code></pre><table>
<thead>
<tr>
<th>알림 종류</th>
<th>감지</th>
<th>경로</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 이벤트</td>
<td>코드 훅</td>
<td>코드 → 허브 직접</td>
</tr>
<tr>
<td>운영 알림</td>
<td>Loki Ruler</td>
<td>Ruler → Alertmanager → webhook → 허브</td>
</tr>
<tr>
<td>로그 조회·디버깅</td>
<td>—</td>
<td>Grafana Explore(알림 아님, 상시 가시성)</td>
</tr>
</tbody></table>
<h2 id="구현-허브-데이터-모델">구현: 허브 데이터 모델</h2>
<table>
<thead>
<tr>
<th>모델</th>
<th>주요 필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>EventType</code></td>
<td><code>code</code>(unique), <code>name</code>, <code>cooldown_seconds</code></td>
<td>이벤트 정의</td>
</tr>
<tr>
<td><code>EmailTemplate</code></td>
<td><code>event_type</code>(FK), <code>subject</code>, <code>body_html</code></td>
<td>정적 템플릿</td>
</tr>
<tr>
<td><code>Subscriber</code></td>
<td><code>event_type</code>(FK), <code>email</code>, <code>is_active</code></td>
<td>UI에서 등록하는 수신자</td>
</tr>
<tr>
<td><code>NotificationLog</code></td>
<td><code>event_type</code>, <code>recipient</code>, <code>idempotency_key</code>(unique), <code>status</code></td>
<td>발송 이력·멱등성</td>
</tr>
</tbody></table>
<h2 id="구현-alertmanager-어댑터">구현: Alertmanager 어댑터</h2>
<p>2편에서 캡처한 webhook 페이로드를 받아 허브의 공통 발송 로직으로 넘기는 어댑터다. 페이로드 구조를 다시 보면, <code>alerts</code>가 배열이고 각 alert에 <code>labels.alertname</code>, <code>status</code>, <code>fingerprint</code>가 있었다.</p>
<pre><code class="language-python">ALERTNAME_TO_EVENT = {
    &quot;GunicornErrorBurst&quot;: &quot;ops_error_burst&quot;,
    &quot;High5xxRate&quot;:        &quot;ops_high_5xx&quot;,
    &quot;SscApiNoLogs&quot;:       &quot;ops_no_logs&quot;,
}

@require_service_token                                # 토큰 + IP 화이트리스트
def alertmanager_webhook(request):
    payload = json.loads(request.body)
    for alert in payload.get(&quot;alerts&quot;, []):           # ← 배열 순회 필수
        event_code = ALERTNAME_TO_EVENT.get(alert[&quot;labels&quot;].get(&quot;alertname&quot;))
        if not event_code:
            continue                                  # 매핑 없으면 스킵
        status = alert.get(&quot;status&quot;, &quot;firing&quot;)
        notify(
            event_code=event_code,
            idempotency_key=f&quot;{alert[&#39;fingerprint&#39;]}:{status}&quot;,  # ← status 포함
            source=&quot;alertmanager&quot;,
            extra={
                &quot;status&quot;: status,
                &quot;summary&quot;: alert[&quot;annotations&quot;].get(&quot;summary&quot;),
                &quot;description&quot;: alert[&quot;annotations&quot;].get(&quot;description&quot;),
            },
        )
    return JsonResponse({&quot;status&quot;: &quot;ok&quot;})</code></pre>
<p>여기서 두 가지가 핵심이다.</p>
<p><strong><code>alerts</code>는 배열이다.</strong> Alertmanager는 <code>group_by</code>로 묶인 여러 alert를 한 요청에 담아 보낸다. 첫 원소만 처리하면 나머지를 놓친다.</p>
<p><strong>멱등성 키에 <code>status</code>를 반드시 포함한다.</strong> <code>fingerprint</code>는 라벨 조합의 해시라 firing이든 resolved든 동일하다. fingerprint만 키로 쓰면 resolved 알림이 &quot;이미 본 것&quot;으로 무시된다. 그래서 <code>fingerprint:status</code>로 만들어 firing 1번, resolved 1번이 각각 나가되 중복은 막는다.</p>
<h2 id="구현-비즈니스-이벤트-직접-호출">구현: 비즈니스 이벤트 직접 호출</h2>
<p>ssc-api 자신의 이벤트는 HTTP를 거치지 않고 내부 함수를 부른다.</p>
<pre><code class="language-python">from notifications.service import notify

notify(
    event_code=&quot;message_delivery_failed&quot;,
    idempotency_key=f&quot;msg-{msg.id}&quot;,
    source=&quot;ssc-api&quot;,
)</code></pre>
<p><code>notify()</code>는 어댑터와 같은 코어를 공유하므로, 운영 알림과 비즈니스 알림이 동일한 발송 로직·발송 로그를 탄다.</p>
<h2 id="보안·신뢰성-체크리스트">보안·신뢰성 체크리스트</h2>
<p>허브는 메일을 쏘는 엔드포인트이므로 다음이 필수다.</p>
<ul>
<li><strong>노출 차단·인증</strong> — 공개망에 두면 메일 발사대가 된다. 서비스 토큰(Bearer) + IP 화이트리스트로 잠근다. 공개 HTTPS 도메인으로 노출했다면 인증은 선택이 아니라 전제다.</li>
<li><strong>멱등성</strong> — <code>idempotency_key</code>에 unique 제약. 호출자가 재시도해도 중복 발송이 안 되게.</li>
<li><strong>레이트리밋</strong> — <code>event_code</code>별 쿨다운. 같은 이벤트 폭주 시 메일 쏟아짐 방지. Alertmanager 쪽의 그룹핑/<code>repeat_interval</code>이 1차 방어, 허브 쿨다운이 2차 방어.</li>
<li><strong>비동기·재시도</strong> — 발송은 Celery 같은 큐로 분리해 요청 경로를 막지 않게 하고, 실패는 지수 백오프로 재시도.</li>
</ul>
<h2 id="마치며-이-시리즈의-핵심-교훈">마치며: 이 시리즈의 핵심 교훈</h2>
<p>세 편을 관통하는 교훈을 하나로 압축하면 이렇다.</p>
<p><strong>&quot;로그를 모으는 것&quot;과 &quot;이벤트를 알리는 것&quot;은 다른 문제다. 그리고 모든 알림을 하나의 도구로 처리하려 하면 어딘가 무리가 생긴다.</strong></p>
<ul>
<li>로그 통합은 디버깅·운영 가시성이라는 본질적 가치를 준다(Loki + Grafana).</li>
<li>로그에만 나타나는 운영 신호는 Loki Ruler가 감지해 Alertmanager로 알린다.</li>
<li>코드로 잡을 수 있는 비즈니스 이벤트는 로그를 우회해 알림 허브를 직접 부른다.</li>
</ul>
<p>도구의 화려함이 아니라 <strong>&quot;이 신호의 출처가 어디인가&quot;</strong>를 기준으로 경로를 나누는 것 — 그게 이 작업에서 얻은 가장 큰 설계 원칙이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (2)]]></title>
            <link>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0-2</link>
            <guid>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0-2</guid>
            <pubDate>Tue, 16 Jun 2026 08:12:28 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/e071bbbb-0f82-4b78-ac97-f488e127c560/image.png" alt=""></p>
<h1 id="분산된-django-서버의-로그를-통합하고-이벤트-알림-보내기-2--loki-ruler--alertmanager로-운영-알림-자동화하기">분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (2) — Loki Ruler + Alertmanager로 운영 알림 자동화하기</h1>
<p>1편에서 두 서버의 로그를 Loki에 모았다. 이제 그 로그를 <strong>자동으로 감시하다가 조건이 충족되면 알림을 보내는</strong> 파이프라인을 만든다. 이 편은 함정이 가장 많았던 만큼, 디버깅 과정을 그대로 기록했다.</p>
<h2 id="개념-loki-ruler란">개념: Loki Ruler란</h2>
<p><strong>Loki Ruler</strong>는 Loki에 내장된 룰 평가 엔진이다. Prometheus의 ruler와 같은 개념으로, 일정 주기(<code>evaluation_interval</code>, 보통 1분)마다 LogQL 쿼리를 실행하고, 결과가 조건을 만족하면 <strong>alert를 발생시켜 Alertmanager로 보낸다.</strong></p>
<p>여기서 중요한 구분. <strong>Loki 자체는 &quot;이벤트를 감지해 메일을 보내는&quot; 일을 하지 않는다.</strong> Loki는 저장소일 뿐이고, 실제 일꾼은 그 위에 얹은 Ruler(감지)와 Alertmanager(발송)다. 그래서 &quot;Loki로 알림을 보낸다&quot;는 표현은 정확히는 &quot;Loki Ruler가 평가하고 Alertmanager가 보낸다&quot;가 맞다.</p>
<h2 id="개념-alertmanager란">개념: Alertmanager란</h2>
<p><strong>Alertmanager</strong>는 Prometheus 생태계의 알림 처리기다. 단순히 메일을 쏘는 게 아니라, 알림의 품질을 관리한다.</p>
<ul>
<li><strong>그룹핑(grouping)</strong> — 비슷한 alert를 묶어 한 번에 보낸다(메일 폭주 방지).</li>
<li><strong>중복제거(deduplication)</strong> — 같은 alert가 여러 번 와도 한 번만 처리.</li>
<li><strong>억제(inhibition)</strong> — 상위 알림이 떴을 때 하위 알림을 누른다.</li>
<li><strong>무음(silence)</strong> — 점검 중에는 특정 알림을 잠시 끈다.</li>
<li><strong>라우팅(routing)</strong> — 라벨에 따라 다른 수신자/채널로 보낸다.</li>
<li><strong>재발송 간격(repeat_interval)</strong> — 문제가 지속될 때 얼마 만에 다시 알릴지.</li>
</ul>
<p>직접 스크립트로 &quot;로그 보고 메일 쏘기&quot;를 짜면, 처음엔 30줄이면 되지만 운영하다 보면 결국 이 기능들을 하나씩 재구현하게 된다. 그래서 Alertmanager에 위임하는 게 낫다.</p>
<h2 id="개념-logql-알림-룰은-메트릭-쿼리여야-한다">개념: LogQL 알림 룰은 &quot;메트릭 쿼리&quot;여야 한다</h2>
<p>가장 많이 막히는 지점이다. Loki alert 룰의 <code>expr</code>은 <strong>숫자를 내놓는 메트릭 쿼리</strong>여야 한다. 단순히 로그를 고르는 셀렉터(<code>{service=&quot;ssc-api&quot;} |= &quot;ERROR&quot;</code>)만으로는 안 된다. 반드시 <code>count_over_time</code>, <code>rate</code>, <code>sum</code> 같은 집계 함수로 감싸 숫자를 만든 뒤 비교해야 한다.</p>
<pre><code class="language-logql"># ❌ 안 됨 — 로그 라인을 고를 뿐, 숫자가 아님
{service=&quot;ssc-api&quot;} |= &quot;ERROR&quot;

# ✅ 됨 — 5분간 ERROR 발생 횟수를 세어 비교
sum(count_over_time({service=&quot;ssc-api&quot;} |= &quot;ERROR&quot; [5m])) &gt; 10</code></pre>
<h2 id="개념-alert의-생애주기-inactive-→-pending-→-firing">개념: alert의 생애주기 (inactive → pending → firing)</h2>
<p>룰을 이해하려면 상태 전이를 알아야 한다.</p>
<ul>
<li><strong>inactive</strong> — 평가됐고 조건 미충족. 평소의 정상 상태.</li>
<li><strong>pending</strong> — 조건은 충족됐지만 <code>for</code>에 지정한 시간만큼 아직 안 지났다. &quot;정말 지속되는 문제인지&quot; 기다리는 단계.</li>
<li><strong>firing</strong> — <code>for</code> 시간만큼 조건이 유지됐다. 실제로 alert가 Alertmanager로 발사된다.</li>
</ul>
<p><code>for: 2m</code>이면, 조건이 충족돼도 2분간 유지돼야 firing이 된다. 일시적 스파이크로 알림이 튀는 걸 막는 장치다.</p>
<h2 id="개념-fake-테넌트">개념: <code>fake</code> 테넌트</h2>
<p>1편에서 Loki를 <code>auth_enabled: false</code>로 설정했다. 이 경우 Loki 내부의 테넌트 ID가 <strong><code>fake</code>로 고정</strong>된다. 이게 룰 파일의 위치를 결정한다. 룰은 <code>ruler.storage.local.directory</code> 아래 <strong><code>fake/</code> 서브디렉토리</strong>에 둬야 한다. 이걸 모르면 룰을 아무리 넣어도 조용히 무시된다(에러도 안 난다).</p>
<hr>
<h2 id="구축-1-alertmanager-설치-server-c">구축 1: Alertmanager 설치 (Server C)</h2>
<p>이미 Prometheus가 있는 Server C에 Alertmanager를 같이 둔다.</p>
<pre><code class="language-bash">cd /tmp
VER=0.28.1   # 릴리스 페이지에서 최신 확인
wget https://github.com/prometheus/alertmanager/releases/download/v${VER}/alertmanager-${VER}.linux-amd64.tar.gz
tar xzf alertmanager-${VER}.linux-amd64.tar.gz
sudo cp alertmanager-${VER}.linux-amd64/{alertmanager,amtool} /usr/local/bin/
sudo useradd --no-create-home --shell /bin/false alertmanager
sudo mkdir -p /etc/alertmanager /var/lib/alertmanager
sudo chown -R alertmanager:alertmanager /var/lib/alertmanager</code></pre>
<p>systemd 서비스로 등록한다.</p>
<pre><code class="language-ini"># /etc/systemd/system/alertmanager.service
[Unit]
Description=Alertmanager
Wants=network-online.target
After=network-online.target

[Service]
User=alertmanager
Group=alertmanager
Type=simple
ExecStart=/usr/local/bin/alertmanager \
  --config.file=/etc/alertmanager/alertmanager.yml \
  --storage.path=/var/lib/alertmanager \
  --web.listen-address=:9093
Restart=always

[Install]
WantedBy=multi-user.target</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now alertmanager   # ← 이걸 빼먹으면 나중에 대형 삽질 (아래)</code></pre>
<blockquote>
<p><strong>함정 ①: 서비스 등록을 빼먹으면 9093이 안 뜬다.</strong> 우리는 PoC 한다고 Alertmanager를 수동으로만 띄우다가 <code>enable --now</code>를 빼먹었다. 그 결과 <code>amtool ... :9093</code>도, <code>curl :9093/-/reload</code>도 전부 <code>connection refused</code>가 났다. &quot;포트를 잘못 썼나?&quot; 한참 의심했는데, 원인은 단순히 <strong>Alertmanager 프로세스가 안 떠 있던 것</strong>이었다. <code>sudo systemctl status alertmanager</code> / <code>journalctl -u alertmanager</code> 로 먼저 살아있는지 확인하는 습관이 중요하다.</p>
</blockquote>
<p>설정 파일은 운영 알림을 허브로 넘기는 webhook 방식으로 둔다(3편에서 이 설계 이유를 설명한다).</p>
<pre><code class="language-yaml"># /etc/alertmanager/alertmanager.yml
route:
  receiver: &#39;ssc-notify-hub&#39;
  group_by: [&#39;alertname&#39;, &#39;service&#39;]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h

receivers:
  - name: &#39;ssc-notify-hub&#39;
    webhook_configs:
      - url: &#39;https://your-api.example.com/api/internal/notify/alertmanager/&#39;
        http_config:
          authorization:
            type: Bearer
            credentials: &#39;&lt;SERVICE_TOKEN&gt;&#39;   # 공개 엔드포인트면 인증 필수
        send_resolved: true</code></pre>
<blockquote>
<p><strong>함정 ②: 클라우드는 보통 아웃바운드 25번 포트를 막는다.</strong> 만약 webhook 대신 Alertmanager가 직접 SMTP로 메일을 보낸다면, 포트 25는 거의 안 된다. 587(STARTTLS)이나 465(TLS) 릴레이를 써야 한다.</p>
</blockquote>
<h2 id="구축-2-loki-ruler-활성화-server-b">구축 2: Loki Ruler 활성화 (Server B)</h2>
<p>Loki 설정 파일에 <code>ruler</code> 블록을 추가한다.</p>
<pre><code class="language-yaml">ruler:
  storage:
    type: local
    local:
      directory: /var/lib/loki/rules
  rule_path: /var/lib/loki/rules-temp
  alertmanager_url: http://(Server C URL):9093   # Server C 의 Alertmanager
  enable_alertmanager_v2: true
  enable_api: true
  ring:
    kvstore:
      store: inmemory</code></pre>
<p>룰 파일은 <code>fake/</code> 디렉토리에 둔다.</p>
<pre><code class="language-bash">sudo mkdir -p /var/lib/loki/rules/fake</code></pre>
<blockquote>
<p><strong>함정 ③: <code>rule_path</code> 디렉토리 권한과 &quot;invalid user&quot;.</strong> 처음에 <code>sudo install -o loki -g loki -d /var/lib/loki/rules-temp</code> 를 실행했더니 <code>install: invalid user &#39;loki&#39;</code> 가 떴다. 시스템에 <code>loki</code>라는 유저가 없었던 것이다. Loki를 어떤 유저로 실행하느냐는 환경마다 다르다. <strong>이미 Loki가 쓰고 있는 <code>/var/lib/loki</code> 하위 디렉토리의 소유자를 보면 실제 실행 유저를 알 수 있다.</strong> <code>ls -la /var/lib/loki</code> 로 확인해 그 유저로 <code>rule_path</code>를 만들면 된다(root로 돈다면 그냥 mkdir만 해도 된다). <code>rule_path</code>는 Loki가 룰을 임시로 펼치는 작업 디렉토리라 <strong>쓰기 가능</strong>해야 한다.</p>
</blockquote>
<h2 id="구축-3-운영-알림-룰-작성">구축 3: 운영 알림 룰 작성</h2>
<p><code>/var/lib/loki/rules/fake/operational.yml</code> 에 운영 신호를 감지하는 룰을 둔다. 여기엔 <strong>코드로는 잡기 어려운, 로그에만 나타나는 신호</strong>만 넣는다.</p>
<pre><code class="language-yaml">groups:
  - name: ssc-operational
    rules:
      # 에러 로그 급증
      - alert: GunicornErrorBurst
        expr: |
          sum by (service) (
            count_over_time({job=&quot;gunicorn_error&quot;, service=~&quot;ssc-api|ssc-msg&quot;} |= &quot;ERROR&quot; [5m])
          ) &gt; 10
        for: 2m
        labels: { severity: warning, category: operational }
        annotations:
          summary: &quot;{{ $labels.service }} 에러 로그 급증&quot;
          description: &quot;최근 5분간 ERROR {{ $value }}건&quot;

      # 5xx 응답 급증
      - alert: High5xxRate
        expr: |
          sum by (service) (
            count_over_time({job=&quot;gunicorn_access&quot;, status=~&quot;5..&quot;} [5m])
          ) &gt; 20
        for: 2m
        labels: { severity: critical, category: operational }
        annotations:
          summary: &quot;{{ $labels.service }} 5xx 급증&quot;
          description: &quot;5분간 5xx {{ $value }}건&quot;

      # 로그 유입 중단 = 서비스 다운 징후
      - alert: SscApiNoLogs
        expr: |
          sum(count_over_time({job=&quot;gunicorn_access&quot;, service=&quot;ssc-api&quot;} [10m])) == 0
        for: 5m
        labels: { severity: critical, category: operational }
        annotations:
          summary: &quot;ssc-api 로그 유입 중단&quot;
          description: &quot;10분간 access 로그 없음 — 서비스/수집 파이프라인 점검&quot;</code></pre>
<p><code>High5xxRate</code>가 동작하는 건 1편에서 Alloy가 <code>status</code>를 라벨로 뽑아둔 덕분이다. 라벨이 있으니 <code>status=~&quot;5..&quot;</code> 로 바로 필터된다.</p>
<blockquote>
<p><strong>함정 ④: &quot;로그 끊김 = 다운&quot; 룰을 트래픽이 적은 서버에 그대로 걸지 마라.</strong> <code>SscApiNoLogs</code>를 사용률 0~1%인 ssc-msg에도 똑같이 걸면, 정상 상태인데도 평소 로그가 거의 없어 상시 오탐이 난다. 그래서 이 룰은 트래픽이 꾸준한 ssc-api에만 한정했다. 트래픽이 적은 서비스의 생존 확인은 access 로그가 아니라 별도 신호(heartbeat 로그, process exporter 등)로 봐야 한다.</p>
</blockquote>
<blockquote>
<p><strong>함정 ⑤: 룰을 여러 파일에 중복으로 두지 마라.</strong> 우리는 예시로 만든 룰 파일과 운영 룰 파일을 둘 다 뒀다가, 같은 사건에 두 룰이 각각 발화해 중복 알림이 가는 구조를 만들 뻔했다. 특히 <code>&gt; 0 / for: 0m</code>(에러 1건이라도 즉시) 같은 룰은 정상 운영 중에도 가끔 뜨는 ERROR에 매번 반응해 <strong>alert fatigue</strong>(전부 무시하게 됨)를 부른다. 운영 알림은 &quot;1건이라도&quot;가 아니라 &quot;급증&quot;을 잡아야 한다.</p>
</blockquote>
<hr>
<h2 id="검증-1-룰이-로드됐는가">검증 1: 룰이 로드됐는가</h2>
<p>Loki를 재시작하고 확인한다.</p>
<pre><code class="language-bash">sudo systemctl restart loki
curl -s http://localhost:3100/loki/api/v1/rules</code></pre>
<p><code>ssc-operational</code> 그룹과 룰들이 보이면 로드 성공. 비어 있으면 <code>fake/</code> 위치나 YAML 문법 문제다.</p>
<h2 id="검증-2-룰이-평가되고-있는가">검증 2: 룰이 평가되고 있는가</h2>
<pre><code class="language-bash">curl -s http://localhost:3100/prometheus/api/v1/rules | python3 -m json.tool</code></pre>
<p>여기서 봐야 할 필드는 <code>state</code>, <code>health</code>, <code>lastEvaluation</code>이다.</p>
<blockquote>
<p><strong>함정 ⑥: 재시작 직후엔 <code>state: unknown</code>이 정상이다.</strong> 처음 이 명령을 치면 <code>state: &quot;unknown&quot;</code>, <code>health: &quot;unknown&quot;</code>, <code>lastEvaluation: &quot;0001-01-01T00:00:00Z&quot;</code>(zero value)가 나온다. 이건 <strong>아직 한 번도 평가되지 않았다</strong>는 뜻이지 오류가 아니다. <code>evaluation_interval</code>(1분)이 지나면 첫 평가가 돌면서 <code>health: &quot;ok&quot;</code>, <code>state: &quot;inactive&quot;</code>로 바뀌고 <code>lastEvaluation</code>이 현재 시각으로 갱신된다. 즉 1분만 기다렸다 다시 확인하면 된다.</p>
</blockquote>
<p>정상 평가가 시작되면 이렇게 나온다.</p>
<pre><code class="language-json">{
  &quot;state&quot;: &quot;inactive&quot;,
  &quot;health&quot;: &quot;ok&quot;,
  &quot;lastError&quot;: &quot;&quot;,
  &quot;lastEvaluation&quot;: &quot;2026-06-16T16:11:54.348446425+09:00&quot;,
  &quot;evaluationTime&quot;: 0.001854222
}</code></pre>
<h2 id="검증-3-webhook-페이로드-구조-확인-poc">검증 3: webhook 페이로드 구조 확인 (PoC)</h2>
<p>알림 허브를 만들기 전에, Alertmanager가 webhook으로 <strong>무엇을</strong> 보내는지 알아야 한다. 임시 수신기를 띄워 페이로드를 캡처했다. 전부 Server C(Alertmanager가 있는 곳)에서, 같은 호스트의 <code>127.0.0.1</code>에 두면 네트워크 고민이 없다.</p>
<pre><code class="language-python"># /tmp/webhook_test.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class H(BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get(&#39;Content-Length&#39;, 0))
        raw = self.rfile.read(n).decode()
        print(&quot;=&quot;*60)
        try: print(json.dumps(json.loads(raw), indent=2, ensure_ascii=False))
        except Exception: print(raw)
        self.send_response(200); self.end_headers(); self.wfile.write(b&#39;ok&#39;)
    def log_message(self, *a): pass
print(&quot;listening on 127.0.0.1:9000&quot;)
HTTPServer((&#39;127.0.0.1&#39;, 9000), H).serve_forever()</code></pre>
<p>alertmanager.yml의 webhook url을 잠깐 <code>http://127.0.0.1:9000/</code>로 돌리고, alert를 주입한다.</p>
<blockquote>
<p><strong>함정 ⑦: <code>amtool</code>의 <code>--alertmanager.url</code>은 &quot;Alertmanager 주소&quot;다.</strong> 우리는 이걸 임시 수신기(9000)로 줬다가 헷갈렸다. 그러면 amtool이 Alertmanager가 아니라 <strong>임시 수신기한테 직접</strong> alert를 쏘기 때문에, 수신기에 찍힌 JSON은 &quot;amtool이 보낸 입력 형식&quot;이지 &quot;Alertmanager가 가공해 내보낸 webhook 출력&quot;이 아니다(구조가 다르다 — <code>version</code>/<code>status</code>/<code>groupLabels</code>/<code>fingerprint</code>가 없다). alert는 반드시 <strong>9093(Alertmanager)</strong>에 넣고, webhook은 Alertmanager가 <strong>9000(수신기)</strong>으로 보내게 해야 한다.</p>
</blockquote>
<p>올바르게는 9093에 주입한다(한글 파싱 경고를 피하려 curl/JSON 권장).</p>
<pre><code class="language-bash">curl -X POST http://localhost:9093/api/v2/alerts \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;[{&quot;labels&quot;:{&quot;alertname&quot;:&quot;GunicornErrorBurst&quot;,&quot;service&quot;:&quot;ssc-api&quot;,&quot;severity&quot;:&quot;warning&quot;},
       &quot;annotations&quot;:{&quot;summary&quot;:&quot;ssc-api 에러 급증&quot;,&quot;description&quot;:&quot;ERROR 15건&quot;}}]&#39;</code></pre>
<p><code>group_wait</code>(30초) 뒤에 임시 수신기에 진짜 페이로드가 찍힌다.</p>
<pre><code class="language-json">{
  &quot;receiver&quot;: &quot;ssc-notify-hub&quot;,
  &quot;status&quot;: &quot;firing&quot;,
  &quot;alerts&quot;: [
    {
      &quot;status&quot;: &quot;firing&quot;,
      &quot;labels&quot;: { &quot;alertname&quot;: &quot;GunicornErrorBurst&quot;, &quot;service&quot;: &quot;ssc-api&quot;, &quot;severity&quot;: &quot;warning&quot; },
      &quot;annotations&quot;: { &quot;summary&quot;: &quot;ssc-api 에러 급증&quot;, &quot;description&quot;: &quot;ERROR 15건&quot; },
      &quot;startsAt&quot;: &quot;2026-06-16T15:31:51+09:00&quot;,
      &quot;endsAt&quot;: &quot;0001-01-01T00:00:00Z&quot;,
      &quot;fingerprint&quot;: &quot;d365a1e15c8e4923&quot;
    }
  ],
  &quot;groupLabels&quot;: { &quot;alertname&quot;: &quot;GunicornErrorBurst&quot;, &quot;service&quot;: &quot;ssc-api&quot; },
  &quot;version&quot;: &quot;4&quot;,
  &quot;groupKey&quot;: &quot;{}:{alertname=\&quot;GunicornErrorBurst\&quot;, service=\&quot;ssc-api\&quot;}&quot;
}</code></pre>
<p>이 구조가 알림 허브 어댑터 설계의 입력이 된다(3편에서 사용). 핵심은 <code>alerts</code>가 <strong>배열</strong>이라는 것(그룹핑으로 여러 개가 묶여 온다), 그리고 <code>fingerprint</code>가 라벨 해시라 멱등성 키 재료가 된다는 것이다.</p>
<h2 id="검증-4-실제-발화-테스트-end-to-end">검증 4: 실제 발화 테스트 (end-to-end)</h2>
<p>마지막으로 진짜 로그를 흘려 전 구간을 확인한다. ssc-api에서 ERROR를 임계 초과로 주입한다.</p>
<pre><code class="language-bash">for i in $(seq 1 15); do
  echo &quot;$(date &#39;+[%Y-%m-%d %H:%M:%S]&#39;) [ERROR] ruler firing test $i&quot; &gt;&gt; /var/log/ssc_api/gunicorn_error.log
done</code></pre>
<p>타임라인은 <code>count_over_time[5m]</code> + <code>for: 2m</code> + 평가주기 1분이라 이렇게 흐른다.</p>
<ol>
<li><strong>주입 직후</strong> — Alloy가 tail → Loki 적재. LogQL로 카운트 확인 가능.</li>
<li><strong>~1분 뒤</strong> — 평가에서 <code>&gt; 10</code> 충족 → <code>state: pending</code>.</li>
<li><strong>~3분 뒤</strong> — pending 2분 경과 → <code>state: firing</code> → Alertmanager로 전송.</li>
</ol>
<p>각 단계 확인:</p>
<pre><code class="language-bash"># Loki 가 카운트를 잡는가 (Server B)
curl -sG http://localhost:3100/loki/api/v1/query \
  --data-urlencode &#39;query=sum by (service)(count_over_time({job=&quot;gunicorn_error&quot;, service=~&quot;ssc-api|ssc-msg&quot;} |= &quot;ERROR&quot; [5m]))&#39;

# 룰이 pending → firing 으로 가는가 (Server B)
curl -s http://localhost:3100/prometheus/api/v1/rules | python3 -m json.tool

# Alertmanager 까지 도달하는가 (Server C)
amtool alert --alertmanager.url=http://localhost:9093</code></pre>
<p>마지막 명령에서 이렇게 뜨면 성공이다.</p>
<pre><code>Alertname           Starts At                Summary              State
GunicornErrorBurst  2026-06-16 07:18:54 UTC  ssc-api 에러 로그 급증   active</code></pre><p>이로써 <strong>로그 → Loki → Ruler → Alertmanager</strong> 전 구간이 자동으로 이어진 게 검증됐다. (Starts At이 UTC인 건 Loki/Alertmanager가 UTC를 쓰기 때문 — 1편의 타임존 메모 참고.)</p>
<p>주입한 ERROR 라인은 5분 윈도우를 벗어나면 카운트에서 빠져 자동으로 <code>inactive</code>로 복귀하고, <code>send_resolved: true</code> 덕에 resolved alert도 한 번 정리된다.</p>
<hr>
<h2 id="함정-총정리">함정 총정리</h2>
<p>이 편에서 마주친 함정을 모으면 이렇다.</p>
<ol>
<li><strong>서비스 등록을 빼먹으면 9093이 안 뜬다</strong> — <code>connection refused</code>의 단골 원인. 프로세스 생존부터 확인.</li>
<li><strong>클라우드의 25번 포트 차단</strong> — SMTP는 587/465 릴레이로.</li>
<li><strong><code>rule_path</code> 권한과 &#39;invalid user&#39;</strong> — 실제 Loki 실행 유저로 디렉토리를 만들어야.</li>
<li><strong>트래픽 적은 서버의 NoLogs 룰 오탐</strong> — 생존 확인은 별도 신호로.</li>
<li><strong>룰 중복과 alert fatigue</strong> — &quot;1건이라도&quot;가 아니라 &quot;급증&quot;을.</li>
<li><strong>재시작 직후 <code>state: unknown</code></strong> — 오류 아님, 첫 평가 대기 중.</li>
<li><strong><code>amtool --alertmanager.url</code>은 Alertmanager 주소</strong> — 수신기 주소를 주면 안 됨.</li>
</ol>
<p>그리고 개념적으로 가장 중요한 것: <strong>alert 룰의 <code>expr</code>은 반드시 집계된 메트릭 쿼리여야 한다.</strong> 로그 셀렉터만으로는 안 된다.</p>
<p>다음 편에서는 한 걸음 물러나, &quot;그럼 모든 알림을 이 Loki Ruler로 처리하면 되는가?&quot;라는 질문을 던진다. 답은 &quot;아니오&quot;였고, 그 이유가 이 시리즈에서 가장 중요한 설계 결정이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (1)]]></title>
            <link>https://velog.io/@jeong_woo/Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Tue, 16 Jun 2026 08:12:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/6d8932e6-b289-4e51-91f8-e98242727b6b/image.png" alt=""></p>
<h1 id="분산된-django-서버의-로그를-통합하고-이벤트-알림-보내기-1--loki--alloy로-분산-로그-통합하기">분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (1) — Loki + Alloy로 분산 로그 통합하기</h1>
<p>이 글에서는 두 서버에 흩어진 로그를 한 곳에 모으고 Grafana에서 보는 것까지 다룬다. 먼저 개념을 충분히 잡고, 실제 설정으로 넘어간다.</p>
<h2 id="개념-중앙-집중식-로깅이란">개념: 중앙 집중식 로깅이란</h2>
<p>서버 한 대일 때는 <code>tail -f /var/log/...</code> 로 충분하다. 하지만 서비스가 여러 서버에 나뉘면, 하나의 사용자 요청이 여러 서버를 거치며 로그를 흩뿌린다. 장애를 추적하려면 각 서버를 돌아다니며 로그를 보고, 시간순으로 머릿속에서 맞춰야 한다.</p>
<p><strong>중앙 집중식 로깅(centralized logging)</strong>은 각 서버의 로그를 한 저장소로 모아, 한 화면에서 검색·필터·시각화하는 구조다. 구성요소는 보통 셋이다.</p>
<ol>
<li><strong>수집 에이전트(agent)</strong> — 각 서버에서 로그를 읽어 중앙으로 전송</li>
<li><strong>저장·쿼리 백엔드(store)</strong> — 로그를 모아 보관하고 검색에 응답</li>
<li><strong>시각화 UI</strong> — 사람이 로그를 보고 검색하는 화면</li>
</ol>
<p>이 셋의 조합으로 가장 유명한 게 ELK 스택(Elasticsearch + Logstash + Kibana)이고, 우리가 택한 건 Grafana 진영의 <strong>Loki + Alloy + Grafana</strong>다.</p>
<h2 id="개념-loki란-무엇인가">개념: Loki란 무엇인가</h2>
<p><strong>Grafana Loki</strong>는 로그 집계 시스템이다. 한 줄로 요약하면 &quot;메트릭의 Prometheus에 대응하는, 로그를 위한 시스템&quot;이다.</p>
<p>Loki의 가장 중요한 설계 특징은 <strong>로그 본문 전체를 인덱싱하지 않는다</strong>는 점이다. Elasticsearch는 모든 단어를 색인해 풀텍스트 검색을 빠르게 하지만, 그만큼 저장·메모리 비용이 크다. 반대로 Loki는 <strong>라벨(label)만 인덱싱</strong>하고, 로그 본문은 압축해서 통째로 저장한다. 검색할 때는 라벨로 후보를 좁힌 뒤, 그 안에서 텍스트를 훑는다(grep 하듯이).</p>
<p>이 차이가 비용과 운영 부담을 크게 가른다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Elasticsearch (ELK)</th>
<th>Loki</th>
</tr>
</thead>
<tbody><tr>
<td>인덱싱</td>
<td>로그 본문 전체(풀텍스트)</td>
<td>라벨만</td>
</tr>
<tr>
<td>검색 속도</td>
<td>매우 빠름(임의 단어)</td>
<td>라벨로 좁힌 뒤 스캔</td>
</tr>
<tr>
<td>자원 소모</td>
<td>큼(메모리 多)</td>
<td>작음</td>
</tr>
<tr>
<td>운영 난이도</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>Grafana 통합</td>
<td>별도(Kibana)</td>
<td>네이티브</td>
</tr>
</tbody></table>
<p>소규모~중규모 서비스, 특히 이미 Grafana를 쓰고 있다면 Loki가 비용·일관성 면에서 합리적이다. 우리는 Server C에서 <strong>이미 Prometheus + Grafana로 메트릭을 보고 있었기 때문에</strong>, 메트릭과 로그를 같은 Grafana 안에서 나란히 보기 위해 Loki를 택했다. ELK는 Elasticsearch가 메모리를 많이 먹어 우리 규모엔 과했다.</p>
<h2 id="개념-loki와-grafana는-별개다">개념: Loki와 Grafana는 별개다</h2>
<p>여기서 흔한 오해 하나. <strong>&quot;Loki를 설치하면 UI도 같이 깔리나?&quot;</strong> 아니다.</p>
<p>Loki는 <strong>로그를 저장하고 쿼리에 응답하는 백엔드</strong>일 뿐, 자체 화면이 없다. 외부에 노출하는 건 HTTP API(<code>/loki/api/v1/...</code>)와 <code>logcli</code>라는 커맨드라인 도구 정도다. 그 Loki에 쿼리를 던져 그래프와 로그를 그려주는 건 <strong>Grafana</strong>의 몫이다. 둘은 &quot;데이터소스 ↔ 대시보드&quot; 관계이며, Prometheus와 Grafana가 별개인 것과 똑같다.</p>
<p>따라서 이미 Grafana를 운영 중이라면 <strong>Grafana를 새로 설치할 필요가 없다.</strong> 기존 Grafana에 Loki를 데이터소스로 한 줄 추가하면, 메트릭과 로그를 한 화면에서 보게 된다.</p>
<h2 id="개념-grafana-alloy-수집-에이전트">개념: Grafana Alloy (수집 에이전트)</h2>
<p><strong>Grafana Alloy</strong>는 로그·메트릭·트레이스를 수집해 전송하는 에이전트다. 과거의 Promtail을 잇는 후속으로, 신규 구축이라면 Alloy로 가는 게 흐름에 맞다.</p>
<p>우리 구조에서 Alloy는 각 서버(A, B)에서 gunicorn 로그 파일을 읽어(tail) 가공한 뒤 Loki로 push하는 역할을 한다.</p>
<h3 id="왜-앱에서-직접-push가-아니라-에이전트가-tail인가">왜 &quot;앱에서 직접 push&quot;가 아니라 &quot;에이전트가 tail&quot;인가</h3>
<p>Django에서 <code>python-logging-loki</code> 같은 핸들러로 Loki에 <strong>직접 push</strong>하는 방법도 있다. 간단해 보이지만 권장하지 않는다. Loki가 잠깐 죽거나 네트워크가 끊기면 로그가 유실되거나, 동기 핸들러면 요청 스레드가 블로킹될 수 있다. 로깅 파이프라인의 장애가 서비스 장애로 번지는 건 피해야 한다.</p>
<p>그래서 <strong>Django는 파일에만 로그를 남기고, Alloy가 그 파일을 tail해서 push</strong>하는 디커플링 구조가 안정적이다. 앱은 로그만 뱉으면 되고, 전송 책임은 에이전트가 진다.</p>
<h2 id="개념-라벨-모델과-카디널리티-가장-중요">개념: 라벨 모델과 카디널리티 (가장 중요)</h2>
<p>Loki를 쓸 때 반드시 이해해야 하는 개념이 <strong>라벨 카디널리티(label cardinality)</strong>다.</p>
<p>Loki는 라벨 조합마다 별도의 <strong>스트림(stream)</strong>을 만든다. 예를 들어 <code>{service=&quot;ssc-api&quot;, level=&quot;ERROR&quot;}</code> 와 <code>{service=&quot;ssc-api&quot;, level=&quot;INFO&quot;}</code> 는 서로 다른 스트림이다. 라벨 값의 종류가 적으면(예: service는 2종, level은 5종) 스트림 수가 관리 가능하다.</p>
<p>문제는 <strong>값이 무한히 다양한 것을 라벨로 만들 때</strong> 발생한다. 대표적으로 <code>path</code>(URL), <code>request_id</code>, <code>user_id</code> 같은 것이다. 이걸 라벨로 올리면 스트림이 수만~수백만 개로 폭발하면서 Loki가 메모리를 먹고 죽는다. 이것이 <strong>카디널리티 폭발</strong>이다.</p>
<p><strong>원칙: 라벨은 종류가 적고 고정적인 것(<code>service</code>, <code>host</code>, <code>env</code>, <code>level</code>, <code>status</code>)만. 값이 다양한 것은 로그 본문에 두고, 검색할 때 LogQL의 라인 필터(<code>|=</code>)나 쿼리 타임 파싱(<code>| regexp</code>)으로 처리한다.</strong></p>
<p>이 원칙은 2편의 알림 룰에서도 그대로 적용된다(특정 path 감지 등).</p>
<hr>
<h2 id="구축-alloy-설정">구축: Alloy 설정</h2>
<p>이제 실제 설정이다. Alloy 설정은 HCL 비슷한 문법으로, &quot;어디서 읽어(source) → 어떻게 가공해(process) → 어디로 보낸다(write)&quot;는 파이프라인을 선언한다.</p>
<h3 id="server-a-ssc-api">Server A (ssc-api)</h3>
<pre><code class="language-alloy">// Loki 엔드포인트 — Server B 의 Loki 로 보낸다
loki.write &quot;default&quot; {
  endpoint {
    url = &quot;http://(Server B URL):3100/loki/api/v1/push&quot;   // Server B(통합 Loki)
  }
}

// gunicorn access 로그
loki.source.file &quot;gunicorn_access&quot; {
  targets = [{
    __path__ = &quot;/var/log/ssc_api/gunicorn_access.log&quot;,
    job      = &quot;gunicorn_access&quot;,
    host     = &quot;ssc-api&quot;,
    service  = &quot;ssc-api&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.process.gunicorn_access.receiver]
}

loki.process &quot;gunicorn_access&quot; {
  stage.regex {
    expression = `\[(?P&lt;timestamp&gt;[^\]]+)\] &quot;(?P&lt;method&gt;[A-Z]+) (?P&lt;path&gt;\S+)[^&quot;]*&quot; (?P&lt;status&gt;\d{3})`
  }
  stage.labels {
    values = { method = &quot;&quot;, status = &quot;&quot; }   // method, status 만 라벨화 (path 는 일부러 제외)
  }
  forward_to = [loki.write.default.receiver]
}

// gunicorn error 로그
loki.source.file &quot;gunicorn_error&quot; {
  targets = [{
    __path__ = &quot;/var/log/ssc_api/gunicorn_error.log&quot;,
    job      = &quot;gunicorn_error&quot;,
    host     = &quot;ssc-api&quot;,
    service  = &quot;ssc-api&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.write.default.receiver]
}</code></pre>
<p>여기서 핵심은 <code>stage.regex</code>로 <code>method</code>, <code>path</code>, <code>status</code>를 <strong>추출</strong>하지만, <code>stage.labels</code>로는 <code>method</code>와 <code>status</code>만 <strong>라벨화</strong>하고 <code>path</code>는 일부러 뺀 부분이다. 앞서 설명한 카디널리티 원칙 그대로다. path는 URL이 무한히 다양하므로 라벨로 만들면 안 된다. (그래도 나중에 특정 path를 감지할 수 있다 — 2편에서.)</p>
<blockquote>
<p><strong>추출(extract)과 라벨화(label)의 차이</strong>: <code>stage.regex</code>가 뽑은 값은 임시 맵에 들어갈 뿐, <code>stage.labels</code>로 올리지 않으면 Loki에 저장되지 않는다. 즉 라벨로 안 올린 추출값은 버려진다. 단, <strong>원본 로그 라인 자체는 그대로 저장</strong>되므로, 나중에 쿼리 시점에 다시 파싱해 쓸 수 있다.</p>
</blockquote>
<h3 id="server-b-ssc-msg">Server B (ssc-msg)</h3>
<p>거의 동일하되, 이 서버에는 Loki가 같이 떠 있으므로 <code>localhost</code>로 보낸다. error 로그에서는 <code>level</code>을 추출해 라벨화한다.</p>
<pre><code class="language-alloy">loki.write &quot;default&quot; {
  endpoint {
    url = &quot;http://localhost:3100/loki/api/v1/push&quot;
  }
}

loki.source.file &quot;gunicorn_error&quot; {
  targets = [{
    __path__ = &quot;/var/log/gunicorn/error.log&quot;,
    job      = &quot;gunicorn_error&quot;,
    host     = &quot;ssc-msg&quot;,
    service  = &quot;ssc-msg&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.process.gunicorn_error.receiver]
}

loki.process &quot;gunicorn_error&quot; {
  stage.regex {
    expression = `\[.*?\] \[\d+\] \[(?P&lt;level&gt;[A-Z]+)\]`
  }
  stage.labels {
    values = { level = &quot;&quot; }
  }
  forward_to = [loki.write.default.receiver]
}</code></pre>
<p><code>service</code> 라벨(<code>ssc-api</code> / <code>ssc-msg</code>)이 두 서버를 구분하는 핵심이다. 통합 후에는 <code>{service=&quot;ssc-api&quot;}</code> / <code>{service=&quot;ssc-msg&quot;}</code> 로 골라보게 된다.</p>
<h2 id="구축-loki-설정-server-b">구축: Loki 설정 (Server B)</h2>
<p>Loki를 통합 저장소로 Server B에 둔다. 규모가 작으니 분산 모드가 아니라 <strong>single-binary(monolithic) 모드</strong>로 충분하다.</p>
<pre><code class="language-yaml">auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096

common:
  instance_addr: 127.0.0.1
  path_prefix: /var/lib/loki
  storage:
    filesystem:
      chunks_directory: /var/lib/loki/chunks
      rules_directory: /var/lib/loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  allow_structured_metadata: false   # Loki 3.x 에서 기본값이 바뀌어 명시 필요
  retention_period: 168h             # 7일 보관

compactor:
  working_directory: /var/lib/loki/compactor
  retention_enabled: true
  delete_request_store: filesystem</code></pre>
<p>설정의 의미를 짚으면,</p>
<ul>
<li><code>auth_enabled: false</code> — 멀티테넌시를 끈다. 단일 조직 내부용이라 인증을 Loki 자체에서 하지 않는다. <strong>대신 네트워크로 보호해야 한다</strong>(아래 함정 참고). 이 설정 때문에 내부 테넌트 ID가 <code>fake</code>로 고정되는데, 이게 2편에서 룰 디렉토리 위치를 결정한다.</li>
<li><code>tsdb</code> + <code>filesystem</code> — 인덱스는 TSDB, 청크는 로컬 파일시스템에 저장. 오브젝트 스토리지(S3 등) 없이 단순하게 간다.</li>
<li><code>retention_period: 168h</code> — 7일치만 보관. <code>compactor</code>의 <code>retention_enabled: true</code>와 짝을 이뤄 오래된 로그를 자동 삭제한다. <strong>이걸 안 켜면 디스크가 조용히 차오르다 어느 날 Loki가 멈춘다.</strong></li>
</ul>
<h2 id="구축-grafana에-loki-데이터소스-등록-server-c">구축: Grafana에 Loki 데이터소스 등록 (Server C)</h2>
<p>마지막으로 Server C의 기존 Grafana에 Loki를 데이터소스로 연결한다. UI에서 클릭으로 해도 되지만, 코드로 관리하려면 provisioning 파일이 깔끔하다.</p>
<pre><code class="language-yaml"># /etc/grafana/provisioning/datasources/loki.yaml
apiVersion: 1
datasources:
  - name: Loki-SSC
    type: loki
    access: proxy
    url: http://(Server B URL):3100   # Server B 의 Loki
    jsonData:
      maxLines: 1000</code></pre>
<pre><code class="language-bash">sudo systemctl restart grafana-server</code></pre>
<p>Grafana의 Explore 탭에서 <code>{service=&quot;ssc-api&quot;}</code>, <code>{service=&quot;ssc-msg&quot;}</code> 가 모두 나오면 통합 성공이다.</p>
<blockquote>
<p><strong><code>access: proxy</code>의 의미</strong>: 이 모드에서는 브라우저가 아니라 <strong>Grafana 서버가 직접</strong> Loki로 통신한다. 즉 Grafana가 떠 있는 Server C에서 Loki(<code>Server B URL:3100</code>)로 네트워크가 뚫려 있어야 한다. 이게 막혀 있으면 &quot;데이터소스 연결 실패&quot;만 뜨고 원인 찾느라 헤맨다. 먼저 <code>curl http://(Server B URL):3100/ready</code> 로 경로부터 확인하자.</p>
</blockquote>
<hr>
<h2 id="실무-함정-정리">실무 함정 정리</h2>
<ul>
<li><strong>path를 라벨로 만들지 마라.</strong> 카디널리티 폭발의 주범이다. 우리 설정이 <code>stage.labels</code>에서 path를 뺀 게 정답이다.</li>
<li><strong><code>access: proxy</code>는 Grafana 서버에서 Loki로 가는 경로가 필요하다.</strong> 데이터소스 연결 실패의 단골 원인.</li>
<li><strong>retention을 반드시 켜라.</strong> single-binary로 띄우면 디스크가 조용히 찬다.</li>
<li><strong>시간 동기화와 타임존.</strong> 두 서버 시간이 어긋나면 통합 로그의 순서가 엉킨다(chrony/NTP 필수). Loki는 UTC로 저장하므로, Django는 UTC로 로깅하고 Grafana 표시 단에서 KST로 보는 게 깔끔하다. KST로 저장해두면 나중에 다른 UTC 소스와 섞일 때 고생한다.</li>
<li><strong><code>auth_enabled: false</code>인 Loki(3100)는 절대 공개망에 노출하지 마라.</strong> 누구나 로그를 넣고 조회할 수 있다. 사설망/VPN 내부에만 두자.</li>
</ul>
<p>다음 편에서는 이렇게 모인 로그를 <strong>자동으로 감시해 알림을 보내는</strong> Loki Ruler + Alertmanager를 다룬다. 검증 과정에서 함정이 줄줄이 터지는데, 그 디버깅 과정이 사실 가장 배울 게 많았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기]]></title>
            <link>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/%EB%B6%84%EC%82%B0%EB%90%9C-Django-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%A1%9C%EA%B7%B8%EB%A5%BC-%ED%86%B5%ED%95%A9%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Tue, 16 Jun 2026 08:11:37 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_woo/post/d6bce60a-4e5d-4b98-b584-f2437f3e3c39/image.png" alt=""></p>
<h1 id="분산된-django-서버의-로그를-통합하고-이벤트-알림-보내기-0--개요와-아키텍처">분산된 Django 서버의 로그를 통합하고 이벤트 알림 보내기 (0) — 개요와 아키텍처</h1>
<blockquote>
<p>서로 다른 서버에서 돌아가는 여러 Django 서비스의 로그를 한 곳에 모으고, 특정 이벤트가 발생하면 메일로 알림을 받기까지의 기록. 이 시리즈는 단순한 설치 매뉴얼이 아니라, 중간에 마주친 함정과 &quot;왜 이 선택을 했는가&quot;라는 의사결정 과정을 함께 담았다.</p>
</blockquote>
<h2 id="왜-통합-로그였나">왜 통합 로그였나</h2>
<p>운영 중인 시스템에는 역할이 다른 Django 서비스가 두 대의 서버에 나뉘어 떠 있었다.</p>
<ul>
<li><strong>Server A (ssc-api)</strong> — 실제 백엔드 API 서버</li>
<li><strong>Server B (ssc-msg)</strong> — 사용자 메시지를 받아 저장·처리하는 서버 (사용률은 0~1% 수준)</li>
</ul>
<p>문제는 단순했다. 장애를 추적하려면 A에 SSH로 들어가 로그를 보고, 다시 B에 들어가 로그를 보고, 두 화면을 머릿속에서 시간순으로 맞춰야 했다. 서버가 둘이라 이 정도지, 더 늘어나면 감당이 안 된다.</p>
<p>그래서 목표를 두 가지로 잡았다.</p>
<ol>
<li><strong>A·B에서 각각 발생하는 로그를 한 곳(Server B)에 통합한다.</strong></li>
<li><strong>통합 로그를 주기적으로 관찰하다가 특정 이벤트가 발생하면 이메일을 보낸다.</strong></li>
</ol>
<p>처음엔 &quot;통합 로그 대시보드를 만든다&quot;가 목적인 줄 알았지만, 파고들수록 진짜 목적은 <strong>이벤트 감지 → 알림</strong>이라는 걸 알게 됐다. 이 깨달음이 나중에 아키텍처를 크게 바꾼다(3편 참고).</p>
<h2 id="전체-그림">전체 그림</h2>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/84bb7a42-d067-42f9-9717-c0525840355d/image.png" alt=""></p>
<p>최종적으로 구성한 아키텍처는 이렇다.
<del>역시 drawio 혹은 mermaid 로 만들면 좋겠지만 귀찮으니 paint 로</del></p>
<pre><code>[로그 수집·통합]
Server A (ssc-api) ── Alloy ──┐
                              ├──► Server B 의 Loki ◄── Alloy ── Server B (ssc-msg)
                              │
                  Server C (media) 의 Grafana 가 Loki 를 조회 (Explore/대시보드)

[운영 알림 — 로그 기반 자동 감지]
Loki Ruler ──► Alertmanager(Server C) ──(webhook)──► 알림 허브 ──► 이메일

[비즈니스 이벤트 알림 — 코드에서 직접]
ssc-api / ssc-msg 코드 ──(직접 호출)──► 알림 허브 ──► 이메일</code></pre><p>세 대의 서버가 각자 역할을 맡는다.</p>
<table>
<thead>
<tr>
<th>서버</th>
<th>역할</th>
<th>설치된 것</th>
</tr>
</thead>
<tbody><tr>
<td>Server A (ssc-api)</td>
<td>백엔드 API</td>
<td>Alloy (로그 수집 에이전트)</td>
</tr>
<tr>
<td>Server B (ssc-msg)</td>
<td>메시지 처리 + <strong>통합 로그 저장소</strong></td>
<td>Alloy, <strong>Loki</strong>(+Ruler)</td>
</tr>
<tr>
<td>Server C (media)</td>
<td>RTMP 인코딩 + <strong>모니터링 허브</strong></td>
<td>Prometheus, Grafana, <strong>Alertmanager</strong></td>
</tr>
</tbody></table>
<p>이미 Server C에서 Prometheus + Grafana로 RTMP 스트림을 모니터링하고 있었다는 점이 중요하다. 메트릭을 보는 Grafana가 이미 있으니, 로그도 같은 생태계(Loki)로 묶는 게 자연스러웠다. 이 결정의 이유는 1편에서 자세히 다룬다.</p>
<h2 id="기술-스택-한눈에">기술 스택 한눈에</h2>
<ul>
<li><strong>Grafana Loki</strong> — 로그 집계·저장·쿼리 엔진 (메트릭의 Prometheus에 대응하는 로그 버전)</li>
<li><strong>Grafana Alloy</strong> — 로그 수집 에이전트 (Promtail의 후속)</li>
<li><strong>Grafana</strong> — 통합 로그·메트릭 시각화 UI</li>
<li><strong>Loki Ruler</strong> — 로그를 주기적으로 평가해 알림 조건을 판단하는 룰 엔진</li>
<li><strong>Alertmanager</strong> — 알림 라우팅·그룹핑·중복제거·발송</li>
<li><strong>Django</strong> — 비즈니스 이벤트를 직접 잡아 알림 허브로 보내는 애플리케이션</li>
</ul>
<h2 id="시리즈-구성">시리즈 구성</h2>
<ul>
<li><strong>0편 (이 글)</strong> — 개요와 전체 아키텍처</li>
<li><strong>1편 — Loki + Alloy로 분산 로그 통합하기</strong>: 중앙 집중 로깅의 개념, Loki가 무엇이고 ELK와 어떻게 다른지, Loki와 Grafana의 관계, Alloy로 로그를 수집해 한 곳에 모으고 Grafana에서 보기까지.</li>
<li><strong>2편 — Loki Ruler + Alertmanager로 운영 알림 자동화하기</strong>: 룰 엔진과 Alertmanager의 개념, LogQL로 알림 룰 짜기, Alertmanager 설치와 연동, 그리고 검증 과정에서 줄줄이 터진 함정들.</li>
<li><strong>3편 — 알림 아키텍처 설계: 운영 알림과 비즈니스 이벤트를 가르다</strong>: 모든 알림을 Loki로 처리하면 안 되는 이유, 매몰비용의 함정, &quot;알림 허브&quot; 패턴, 그리고 무엇을 어디에 태울지에 대한 설계 결정.</li>
</ul>
<h2 id="결과-요약">결과 요약</h2>
<p>다 끝내고 나니 이런 그림이 됐다.</p>
<ul>
<li>두 서버의 로그가 Server B의 Loki에 통합되어, Server C의 Grafana 한 화면에서 LogQL로 검색·디버깅이 가능해졌다.</li>
<li>에러 급증·5xx 급증·로그 유입 중단 같은 <strong>운영 신호</strong>는 Loki Ruler가 자동 감지해 Alertmanager를 거쳐 알림으로 나간다.</li>
<li>코드에서 직접 잡을 수 있는 <strong>비즈니스 이벤트</strong>는 로그 파이프라인을 우회해 알림 허브를 직접 호출한다.</li>
</ul>
<p>핵심 교훈을 하나 미리 꼽자면 이렇다. <strong>&quot;로그를 모으는 것&quot;과 &quot;이벤트를 알리는 것&quot;은 다른 문제이며, 모든 알림을 하나의 도구로 처리하려 하면 어딘가 무리가 생긴다.</strong> 이 시리즈는 그 경계를 찾아가는 과정이기도 하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Loki + Alloy로 멀티 서버 Django 로그 통합하기]]></title>
            <link>https://velog.io/@jeong_woo/alloy</link>
            <guid>https://velog.io/@jeong_woo/alloy</guid>
            <pubDate>Tue, 16 Jun 2026 05:09:49 GMT</pubDate>
            <description><![CDATA[<h1 id="grafana-loki--alloy로-멀티-서버-django-로그-통합하기">Grafana Loki + Alloy로 멀티 서버 Django 로그 통합하기</h1>
<blockquote>
<p>추가 인스턴스 없이 기존 서버 2대에 Loki와 Grafana Alloy를 올려 Django/Gunicorn 로그를 단일 관제 체계로 통합한 과정을 정리합니다.</p>
</blockquote>
<hr>
<h2 id="배경">배경</h2>
<p>운영 중인 Smart Silver Center 프로젝트는 두 개의 Django 서비스가 서로 다른 서버에 올라가 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Server A</th>
<th>Server B</th>
</tr>
</thead>
<tbody><tr>
<td>hostname</td>
<td>ICSC-platform</td>
<td>icsc-integrated-control</td>
</tr>
<tr>
<td>서비스</td>
<td>ssc-api</td>
<td>ssc-msg</td>
</tr>
<tr>
<td>로그 경로 (access)</td>
<td><code>/var/log/ssc_api/gunicorn_access.log</code></td>
<td><code>/var/log/gunicorn/access.log</code></td>
</tr>
<tr>
<td>로그 경로 (error)</td>
<td><code>/var/log/ssc_api/gunicorn_error.log</code></td>
<td><code>/var/log/gunicorn/error.log</code></td>
</tr>
<tr>
<td>OS</td>
<td>Ubuntu 22.04 LTS</td>
<td>Ubuntu 22.04 LTS</td>
</tr>
</tbody></table>
<p>각 서버 로그가 분산되어 있어 장애 추적이 번거로웠고, 인스턴스를 추가로 빌릴 수 없는 상황이었다.
트래픽이 거의 없는 <strong>Server B에 Loki를 올리고</strong>, 두 서버 모두 Alloy로 중앙 수집하는 구조로 결정했다.</p>
<hr>
<h2 id="개념-정리">개념 정리</h2>
<h3 id="prometheus-vs-loki">Prometheus vs Loki</h3>
<table>
<thead>
<tr>
<th></th>
<th>Prometheus</th>
<th>Loki</th>
</tr>
</thead>
<tbody><tr>
<td>수집 대상</td>
<td>메트릭 (숫자)</td>
<td>로그 (텍스트)</td>
</tr>
<tr>
<td>수집 방식</td>
<td><strong>PULL</strong> (Prometheus가 scrape)</td>
<td><strong>PUSH</strong> (Agent가 밀어넣음)</td>
</tr>
<tr>
<td>쿼리 언어</td>
<td>PromQL</td>
<td>LogQL</td>
</tr>
<tr>
<td>인덱싱</td>
<td>전체 인덱싱</td>
<td><strong>레이블만</strong> 인덱싱 (로그 내용은 압축 저장)</td>
</tr>
<tr>
<td>시각화</td>
<td>Grafana</td>
<td>Grafana (동일)</td>
</tr>
</tbody></table>
<p>Loki가 로그 내용을 인덱싱하지 않는 덕분에 Elasticsearch 대비 스토리지 비용이 낮다.
대신 로그 내용으로 필터링할 때는 LogQL의 <code>|=</code>, <code>| regex</code> 등을 사용한다.</p>
<h3 id="수집-에이전트-비교">수집 에이전트 비교</h3>
<pre><code>Prometheus 스택:  node_exporter(/metrics 노출) ← Prometheus(scrape, PULL)
Loki 스택:        Alloy(tail 후 push, PUSH)    → Loki</code></pre><p><code>node_exporter : Promtail = Prometheus : Loki</code> 구조로 이해하면 쉽다.
단, 결정적인 차이는 <strong>수집 주체</strong>다.</p>
<ul>
<li>Prometheus: Prometheus 자신이 각 서버에 접속해 데이터를 가져간다 (PULL)</li>
<li>Loki: 각 서버에 설치된 Agent가 Loki로 데이터를 밀어넣는다 (PUSH)</li>
</ul>
<h3 id="grafana-alloy-구-promtail">Grafana Alloy (구 Promtail)</h3>
<p><img src="https://velog.velcdn.com/images/jeong_woo/post/fa2c1529-9360-412a-be5f-a4741d22695c/image.png" alt=""></p>
<p>Promtail은 <strong>2026년 3월 2일 EOL</strong>이 됐다. Grafana Labs는 logs / metrics / traces / profiles를 하나의 바이너리로 처리하는 <strong>Grafana Alloy</strong>로 통합했다. <a href="https://grafana.com/blog/grafana-loki-3-4-standardized-storage-config-sizing-guidance-and-promtail-merging-into-alloy/?utm_source=chatgpt.com">링크</a></p>
<ul>
<li>Promtail: YAML 설정, Loki 전용</li>
<li>Alloy: 컴포넌트 기반 파이프라인 문법(<code>.alloy</code>), OpenTelemetry 호환, Loki/Prometheus/Tempo 모두 지원</li>
</ul>
<blockquote>
<p>Promtail config는 <code>alloy convert --source-format=promtail</code> 명령으로 자동 변환 가능하다.</p>
</blockquote>
<hr>
<h2 id="최종-아키텍처">최종 아키텍처</h2>
<pre><code>[Server A]  Alloy ──┐
                     ├──(push)──→ [Server B] Loki :3100 ←── [Server C] Grafana
[Server B]  Alloy ──┘</code></pre><hr>
<h2 id="step-1--loki-설치-server-b">Step 1 — Loki 설치 (Server B)</h2>
<h3 id="바이너리-설치">바이너리 설치</h3>
<pre><code class="language-bash">cd ~
wget https://github.com/grafana/loki/releases/download/v3.7.2/loki-linux-amd64.zip
unzip loki-linux-amd64.zip
chmod +x loki-linux-amd64
sudo mv loki-linux-amd64 /usr/local/bin/loki
rm loki-linux-amd64.zip

# 데이터 디렉토리 생성
sudo mkdir -p /etc/loki /var/lib/loki/{chunks,rules,wal}</code></pre>
<blockquote>
<p><strong>주의</strong>: <code>/etc</code> 디렉토리에서 wget을 실행하면 <code>Permission denied</code>가 난다. 홈(<code>~</code>)이나 <code>/tmp</code>에서 다운로드 후 <code>sudo mv</code>로 옮겨야 한다.</p>
</blockquote>
<h3 id="configyaml-작성">config.yaml 작성</h3>
<pre><code class="language-yaml"># /etc/loki/config.yaml
auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: warn

common:
  instance_addr: 127.0.0.1
  path_prefix: /var/lib/loki
  storage:
    filesystem:
      chunks_directory: /var/lib/loki/chunks
      rules_directory: /var/lib/loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  allow_structured_metadata: false
  retention_period: 168h   # 7일

compactor:
  working_directory: /var/lib/loki/compactor
  retention_enabled: true
  delete_request_store: filesystem   # ← Loki 3.x 필수</code></pre>
<h3 id="systemd-서비스-등록">systemd 서비스 등록</h3>
<pre><code class="language-bash">sudo tee /etc/systemd/system/loki.service &gt; /dev/null &lt;&lt;EOF
[Unit]
Description=Loki log aggregation system
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/loki -config.file=/etc/loki/config.yaml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now loki

# 정상 확인
curl -s http://localhost:3100/ready
# 출력: ready</code></pre>
<hr>
<h2 id="step-2--grafana-alloy-설치-공통">Step 2 — Grafana Alloy 설치 (공통)</h2>
<p>Server A, B 양쪽에 동일하게 설치한다.</p>
<pre><code class="language-bash">sudo mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | \
  sudo tee /etc/apt/keyrings/grafana.gpg &gt; /dev/null

echo &quot;deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main&quot; | \
  sudo tee /etc/apt/sources.list.d/grafana.list

sudo apt-get update
sudo apt-get install alloy

alloy --version</code></pre>
<p>systemd 서비스는 apt 설치 시 자동 등록된다.</p>
<hr>
<h2 id="step-3--alloy-config-server-b">Step 3 — Alloy config (Server B)</h2>
<p>Alloy는 YAML이 아닌 <strong>컴포넌트 기반 파이프라인 문법</strong>을 사용한다.</p>
<pre><code>loki.source.file  →  loki.process  →  loki.write
(파일 tail)          (레이블 추출)      (Loki push)</code></pre><pre><code># /etc/alloy/config.alloy  (Server B: ssc-msg)

loki.write &quot;default&quot; {
  endpoint {
    url = &quot;http://localhost:3100/loki/api/v1/push&quot;
  }
}

loki.source.file &quot;gunicorn_access&quot; {
  targets = [{
    __path__ = &quot;/var/log/gunicorn/access.log&quot;,
    job      = &quot;gunicorn_access&quot;,
    host     = &quot;server-b&quot;,
    service  = &quot;ssc-msg&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.process.gunicorn_access.receiver]
}

loki.process &quot;gunicorn_access&quot; {
  stage.regex {
    expression = `\[(?P&lt;timestamp&gt;[^\]]+)\] &quot;(?P&lt;method&gt;[A-Z]+) (?P&lt;path&gt;\S+)[^&quot;]*&quot; (?P&lt;status&gt;\d{3})`
  }
  stage.labels {
    values = {
      method = &quot;&quot;,
      status = &quot;&quot;,
    }
  }
  forward_to = [loki.write.default.receiver]
}

loki.source.file &quot;gunicorn_error&quot; {
  targets = [{
    __path__ = &quot;/var/log/gunicorn/error.log&quot;,
    job      = &quot;gunicorn_error&quot;,
    host     = &quot;server-b&quot;,
    service  = &quot;ssc-msg&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.process.gunicorn_error.receiver]
}

loki.process &quot;gunicorn_error&quot; {
  stage.regex {
    expression = `\[.*?\] \[\d+\] \[(?P&lt;level&gt;[A-Z]+)\]`
  }
  stage.labels {
    values = {
      level = &quot;&quot;,
    }
  }
  forward_to = [loki.write.default.receiver]
}</code></pre><hr>
<h2 id="step-4--alloy-config-server-a">Step 4 — Alloy config (Server A)</h2>
<p>Server B와 동일한 구조, <code>loki.write</code> URL과 레이블만 다르다.</p>
<pre><code># /etc/alloy/config.alloy  (Server A: ssc-api)

loki.write &quot;default&quot; {
  endpoint {
    url = &quot;http://&lt;SERVER_B_IP&gt;:3100/loki/api/v1/push&quot;
  }
}

loki.source.file &quot;gunicorn_access&quot; {
  targets = [{
    __path__ = &quot;/var/log/ssc_api/gunicorn_access.log&quot;,
    job      = &quot;gunicorn_access&quot;,
    host     = &quot;server-a&quot;,
    service  = &quot;ssc-api&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.process.gunicorn_access.receiver]
}

loki.process &quot;gunicorn_access&quot; {
  stage.regex {
    expression = `\[(?P&lt;timestamp&gt;[^\]]+)\] &quot;(?P&lt;method&gt;[A-Z]+) (?P&lt;path&gt;\S+)[^&quot;]*&quot; (?P&lt;status&gt;\d{3})`
  }
  stage.labels {
    values = {
      method = &quot;&quot;,
      status = &quot;&quot;,
    }
  }
  forward_to = [loki.write.default.receiver]
}

loki.source.file &quot;gunicorn_error&quot; {
  targets = [{
    __path__ = &quot;/var/log/ssc_api/gunicorn_error.log&quot;,
    job      = &quot;gunicorn_error&quot;,
    host     = &quot;server-a&quot;,
    service  = &quot;ssc-api&quot;,
    env      = &quot;production&quot;,
  }]
  forward_to = [loki.write.default.receiver]
}</code></pre><h3 id="alloy-시작">Alloy 시작</h3>
<pre><code class="language-bash"># config 문법 검사 (실행 전 필수)
alloy fmt /etc/alloy/config.alloy

sudo systemctl enable --now alloy
sudo journalctl -u alloy -f</code></pre>
<p>정상 기동 시 로그:</p>
<pre><code>{^_^} Alloy is running
start tailing file ... path=/var/log/.../access.log</code></pre><blockquote>
<p><code>failed to register collector with remote server ... err=&quot;noop client&quot;</code> 에러는 Grafana Cloud remotecfg 기능 관련 메시지로, 자체 호스팅 환경에서는 정상적으로 출력된다. 무시해도 된다.</p>
</blockquote>
<hr>
<h2 id="step-5--grafana-데이터소스-추가-server-c">Step 5 — Grafana 데이터소스 추가 (Server C)</h2>
<p><strong>Connections → Data sources → Add → Loki</strong></p>
<pre><code>Name : Loki
URL  : http://&lt;SERVER_B_IP&gt;:3100</code></pre><p>저장 후 <code>Data source connected and labels found</code> 메시지 확인.</p>
<p>Grafana가 Server B에 직접 접근하므로 필요 시 방화벽 추가:</p>
<pre><code class="language-bash"># Server B에서 Grafana 서버 IP 허용
sudo ufw allow from &lt;GRAFANA_IP&gt; to any port 3100 proto tcp</code></pre>
<hr>
<h2 id="유용한-logql-쿼리">유용한 LogQL 쿼리</h2>
<pre><code class="language-logql">-- 두 서버 통합 조회
{job=&quot;gunicorn_access&quot;}

-- 서버별 필터
{job=&quot;gunicorn_access&quot;, host=&quot;server-a&quot;}
{job=&quot;gunicorn_access&quot;, host=&quot;server-b&quot;}

-- 서비스별 필터
{job=&quot;gunicorn_access&quot;, service=&quot;ssc-api&quot;}

-- HTTP 5xx 에러
{job=&quot;gunicorn_access&quot;, status=~&quot;5..&quot;}

-- HTTP 4xx (404 스캐닝 탐지 등)
{job=&quot;gunicorn_access&quot;, status=&quot;404&quot;} |= &quot;phpinfo&quot;

-- Gunicorn worker 재시작 추적
{job=&quot;gunicorn_error&quot;} |= &quot;Booting worker&quot;

-- 에러 레벨 필터
{job=&quot;gunicorn_error&quot;, level=&quot;ERROR&quot;}

-- Server A polling 엔드포인트
{job=&quot;gunicorn_access&quot;, host=&quot;server-a&quot;} |= &quot;/api/center/polling/&quot;</code></pre>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="1-wget-permission-denied">1. <code>wget</code> Permission denied</h3>
<pre><code>loki-linux-amd64.zip: Permission denied</code></pre><p><strong>원인</strong>: <code>/etc</code> 등 시스템 디렉토리에서 wget 실행<br><strong>해결</strong>: <code>cd ~</code> 후 홈 디렉토리에서 다운로드</p>
<hr>
<h3 id="2-loki-기동-실패--delete-request-store-에러">2. Loki 기동 실패 — <code>delete-request-store</code> 에러</h3>
<pre><code>CONFIG ERROR: invalid compactor config:
compactor.delete-request-store should be configured when retention is enabled</code></pre><p><strong>원인</strong>: Loki 3.x에서 <code>retention_enabled: true</code> 시 <code>delete_request_store</code> 필수<br><strong>해결</strong>: <code>config.yaml</code>의 <code>compactor</code> 섹션에 추가</p>
<pre><code class="language-yaml">compactor:
  working_directory: /var/lib/loki/compactor
  retention_enabled: true
  delete_request_store: filesystem   # ← 추가</code></pre>
<hr>
<h3 id="3-allow_structured_metadata-불일치">3. <code>allow_structured_metadata</code> 불일치</h3>
<p>Loki 3.x + Alloy 3.x 조합에서 Alloy가 structured metadata를 자동으로 붙여 보내는데,
Loki 설정과 불일치 시 push가 실패할 수 있다.<br><strong>해결</strong>: <code>limits_config</code>에 명시</p>
<pre><code class="language-yaml">limits_config:
  allow_structured_metadata: false</code></pre>
<hr>
<h2 id="레이블-설계-원칙">레이블 설계 원칙</h2>
<p>Loki는 레이블만 인덱싱하므로 <strong>카디널리티 관리가 핵심</strong>이다.</p>
<table>
<thead>
<tr>
<th>레이블</th>
<th>권장 여부</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>job</code></td>
<td>✅</td>
<td>고정값 (gunicorn_access 등)</td>
</tr>
<tr>
<td><code>host</code></td>
<td>✅</td>
<td>서버 수 = 레이블 수, 카디널리티 낮음</td>
</tr>
<tr>
<td><code>service</code></td>
<td>✅</td>
<td>서비스 수 = 레이블 수</td>
</tr>
<tr>
<td><code>method</code></td>
<td>✅</td>
<td>GET/POST/PUT/DELETE 한정</td>
</tr>
<tr>
<td><code>status</code></td>
<td>✅</td>
<td>200/404/500 등 한정</td>
</tr>
<tr>
<td><code>path</code></td>
<td>❌</td>
<td>URL 파라미터 포함 시 카디널리티 폭발</td>
</tr>
<tr>
<td><code>user_id</code></td>
<td>❌</td>
<td>사용자 수만큼 레이블 생성 → 성능 저하</td>
</tr>
</tbody></table>
<p>URL 경로로 필터링하려면 레이블 대신 LogQL 필터를 사용한다:</p>
<pre><code class="language-logql">{job=&quot;gunicorn_access&quot;} |= &quot;/api/center/polling/&quot;</code></pre>
<hr>
<h2 id="다음-단계">다음 단계</h2>
<ul>
<li><input disabled="" type="checkbox"> Grafana Explore에서 LogQL 쿼리 검증</li>
<li><input disabled="" type="checkbox"> Django <code>settings.py</code>에 파일 핸들러 추가 (애플리케이션 레벨 로그 분리)</li>
<li><input disabled="" type="checkbox"> Loki 기반 알람 설정 (5xx 급증, 특정 에러 패턴)</li>
<li><input disabled="" type="checkbox"> Alloy로 Prometheus metrics scraping 통합 (node_exporter 대체 고려)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Windows 네이티브 MySQL에 타임존 테이블 적재하기]]></title>
            <link>https://velog.io/@jeong_woo/Windows-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-MySQL%EC%97%90-%ED%83%80%EC%9E%84%EC%A1%B4-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%A0%81%EC%9E%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_woo/Windows-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-MySQL%EC%97%90-%ED%83%80%EC%9E%84%EC%A1%B4-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%A0%81%EC%9E%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 09 Jun 2026 02:18:27 GMT</pubDate>
            <description><![CDATA[<h1 id="windows-네이티브-mysql에-타임존-테이블-적재하기-convert_tz-살리기">Windows 네이티브 MySQL에 타임존 테이블 적재하기 (CONVERT_TZ 살리기)</h1>
<blockquote>
<p><code>__date</code> / <code>__year</code> / <code>__month</code> 같은 날짜 필터가 로컬에서만 0건 나온다면, 로컬 MySQL의 타임존 테이블이 비어 있어서 <code>CONVERT_TZ</code>가 <code>NULL</code>을 반환하는 게 원인일 수 있다. (배경 개념은 <a href="https://velog.io/@jeong_woo/MySQL-%ED%83%80%EC%9E%84%EC%A1%B4">Django + MySQL 타임존 글</a> 참고)
이 글은 <strong>Windows에 직접 설치된 MySQL</strong>에서 공식 SQL 덤프로 타임존 테이블을 적재하는 실전 절차다.</p>
</blockquote>
<h2 id="tldr">TL;DR</h2>
<ol>
<li><a href="https://dev.mysql.com/downloads/timezones.html">https://dev.mysql.com/downloads/timezones.html</a> 에서 <strong>POSIX standard</strong> zip을 받는다.</li>
<li>압축을 풀어 <code>timezone_posix.sql</code>을 꺼낸다.</li>
<li>PowerShell에서 <code>Get-Content ...sql | mysql.exe -u root -p mysql</code> 로 import.</li>
<li><code>CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;,&#39;UTC&#39;,&#39;Asia/Seoul&#39;)</code> 가 <code>NULL</code>이 아니라 <code>14:00:00</code>이면 성공.</li>
</ol>
<hr>
<h2 id="사전-확인--정말-비어-있는가">사전 확인 — 정말 비어 있는가</h2>
<p>먼저 문제가 실재하는지 확인한다. (읽기 전용, 안전)</p>
<pre><code class="language-powershell">&amp; &quot;C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe&quot; -u root -p -e &quot;SELECT COUNT(*) AS tz_rows FROM mysql.time_zone_name; SELECT CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;,&#39;UTC&#39;,&#39;Asia/Seoul&#39;) AS converted;&quot;</code></pre>
<blockquote>
<p><code>mysql.exe</code>는 보통 PATH에 등록돼 있지 않다. <code>C:\Program Files\MySQL\MySQL Server &lt;버전&gt;\bin\mysql.exe</code> 전체 경로로 호출한다. (경로에 공백이 있어 PowerShell에서는 <code>&amp;</code> 호출 연산자가 필요하다.)</p>
</blockquote>
<p>적재 전 결과 예:</p>
<pre><code>+---------+
| tz_rows |
+---------+
|       1 |   &lt;- 사실상 비어 있음
+---------+
+-----------+
| converted |
+-----------+
| NULL      |   &lt;- CONVERT_TZ 실패
+-----------+</code></pre><p><code>converted</code>가 <strong>NULL</strong>이면 타임존 테이블 적재가 필요하다.</p>
<hr>
<h2 id="step-1--공식-타임존-sql-덤프-다운로드">STEP 1 — 공식 타임존 SQL 덤프 다운로드</h2>
<p><a href="https://dev.mysql.com/downloads/timezones.html">https://dev.mysql.com/downloads/timezones.html</a> 에 접속하면 두 종류가 보인다.</p>
<pre><code>timezone_2026b_posix_sql.zip - POSIX standard                 ← 이것을 받는다
timezone_2026b_leaps_sql.zip - Non POSIX with leap seconds    ← 받지 않는다</code></pre><h3 id="posix-standard를-받아야-하는-이유">POSIX standard를 받아야 하는 이유</h3>
<ul>
<li><strong>leap seconds(윤초) 버전은 피한다.</strong> 1972년 이후 누적된 윤초(현재 27초)를 시각 계산에 반영해서, 일반 애플리케이션에서는 오히려 시각이 수십 초 어긋날 수 있다.</li>
<li>목적은 <code>Asia/Seoul</code>(UTC+9 고정, 서머타임 없음) 날짜 변환을 프로덕션과 동일하게 만드는 것이다. 프로덕션도 일반적으로 POSIX 기준이므로 <strong>POSIX를 받아야 동작이 일치</strong>한다.</li>
<li>파일명의 <code>2026b</code>는 IANA 타임존 DB 버전이다. 한국은 서머타임이 없어 버전 차이의 영향을 받지 않으니, 페이지에 보이는 최신 POSIX 버전을 받으면 된다.</li>
</ul>
<blockquote>
<p>다운로드에는 Oracle 계정 로그인이 요구될 수 있다. 페이지 하단의 &quot;No thanks, just start my download&quot; 링크로 로그인 없이 받을 수도 있다.</p>
</blockquote>
<hr>
<h2 id="step-2--압축-해제">STEP 2 — 압축 해제</h2>
<p>받은 zip을 풀면 <code>timezone_posix.sql</code> 파일이 들어 있다.</p>
<pre><code class="language-powershell">Expand-Archive -Path &quot;$env:USERPROFILE\Downloads\timezone_2026b_posix_sql.zip&quot; -DestinationPath &quot;$env:USERPROFILE\Downloads\timezone_2026b_posix_sql&quot; -Force
Get-ChildItem &quot;$env:USERPROFILE\Downloads\timezone_2026b_posix_sql&quot; -Recurse -Filter *.sql | Select-Object FullName</code></pre>
<p>출력된 <code>.sql</code> <strong>전체 경로</strong>를 STEP 3에 사용한다. (예: <code>C:\Users\&lt;사용자&gt;\Downloads\timezone_2026b_posix_sql\timezone_posix.sql</code>)</p>
<hr>
<h2 id="step-3--시스템-스키마-mysql에-import">STEP 3 — 시스템 스키마 <code>mysql</code>에 import</h2>
<p>root 비밀번호 입력이 필요하므로, 비밀번호가 로그에 남지 않도록 터미널에서 직접 실행한다.</p>
<pre><code class="language-powershell">Get-Content &quot;C:\Users\&lt;사용자&gt;\Downloads\timezone_2026b_posix_sql\timezone_posix.sql&quot; | &amp; &quot;C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe&quot; -u root -p mysql</code></pre>
<p><code>Enter password:</code> 프롬프트에 root 비밀번호를 입력한다. 에러 없이 프롬프트로 돌아오면 성공이다.</p>
<h3 id="명령-구조-해설">명령 구조 해설</h3>
<ul>
<li><strong><code>Get-Content ...sql |</code></strong> — PowerShell은 <code>mysql &lt; file.sql</code> 같은 입력 리다이렉션을 지원하지 않는다(<code>&#39;&lt;&#39; 연산자는 예약되어 있습니다</code> 에러). 대신 <code>Get-Content</code>로 파일을 읽어 파이프로 넘긴다.</li>
<li><strong><code>&amp; &quot;...\mysql.exe&quot;</code></strong> — 경로에 공백(<code>Program Files</code>)이 있으므로 <code>&amp;</code>(호출 연산자)로 실행한다.</li>
<li><strong><code>-u root -p</code></strong> — <code>-p</code>만 쓰면 프롬프트로 비밀번호를 물어본다. 명령줄에 비밀번호를 붙이지 않는 편이 안전하다.</li>
<li><strong>맨 끝의 <code>mysql</code></strong> — import <strong>대상 데이터베이스 이름</strong>이다. 앱 DB(<code>smartsilvercenter</code>)가 아니라 <strong>시스템 스키마 <code>mysql</code></strong>에 넣는다. 타임존 테이블은 서버 전역으로 공유되므로, 한 번 넣으면 모든 DB의 쿼리에 자동 적용된다. 이 인자를 빼면 <code>No database selected</code> 에러가 난다.</li>
</ul>
<blockquote>
<p>즉, &quot;특정 DB에 적용&quot;이 아니라 <strong>&quot;이 MySQL 서버 전체에 적용&quot;</strong>이다. 결과적으로 <code>smartsilvercenter</code>의 <code>CONVERT_TZ</code>가 동작하게 된다.</p>
</blockquote>
<hr>
<h2 id="step-4--검증">STEP 4 — 검증</h2>
<pre><code class="language-powershell">&amp; &quot;C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe&quot; -u root -p -e &quot;SELECT COUNT(*) AS tz_rows FROM mysql.time_zone_name; SELECT CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;,&#39;UTC&#39;,&#39;Asia/Seoul&#39;) AS converted;&quot;</code></pre>
<p>적재 후 결과 예:</p>
<pre><code>+---------+
| tz_rows |
+---------+
|     598 |   &lt;- 타임존 이름이 채워짐
+---------+
+---------------------+
| converted           |
+---------------------+
| 2026-06-08 14:00:00 |   &lt;- UTC 05:00 + 9 = KST 14:00, 정상 동작
+---------------------+</code></pre><table>
<thead>
<tr>
<th>항목</th>
<th>적재 전</th>
<th>적재 후</th>
</tr>
</thead>
<tbody><tr>
<td><code>tz_rows</code></td>
<td><code>1</code></td>
<td><code>598</code></td>
</tr>
<tr>
<td><code>converted</code></td>
<td><code>NULL</code></td>
<td><code>2026-06-08 14:00:00</code></td>
</tr>
</tbody></table>
<p><code>converted</code>가 <code>NULL</code>에서 한국시간으로 바뀌면 <code>CONVERT_TZ</code>가 정상 동작하는 것이다.</p>
<hr>
<h2 id="step-5--실제-애플리케이션에서-확인">STEP 5 — 실제 애플리케이션에서 확인</h2>
<p>개념 검증(<code>CONVERT_TZ</code>)이 끝났으면, 실제로 깨졌던 날짜 필터 API가 정상 응답하는지 확인한다.</p>
<pre><code>/api/v2/broadcasting/?start_date=2026-04-26&amp;end_date=2026-06-07</code></pre><p>적재 전 0건이던 응답이 정상 건수로 돌아오면 완전히 해결된 것이다.</p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<p><strong><code>ERROR 1045 (28000): Access denied for user &#39;root&#39;@&#39;localhost&#39; (using password: YES)</code></strong>
→ 비밀번호 오타. 명령을 다시 실행하고 비밀번호를 정확히 입력한다. (이 경우 import는 아무것도 적재되지 않았으므로, 올바른 비밀번호로 STEP 3를 다시 실행하면 된다.)</p>
<p><strong><code>&#39;&lt;&#39; 연산자는 나중에 사용하도록 예약되어 있습니다</code></strong>
→ PowerShell에서 <code>mysql &lt; file.sql</code>을 쓴 경우다. <code>Get-Content file.sql | mysql.exe ...</code> 파이프 형태로 바꾼다.</p>
<p><strong><code>&#39;mysql&#39;은(는) ... 인식되지 않습니다</code></strong>
→ <code>mysql</code>이 PATH에 없다. <code>&amp; &quot;C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe&quot;</code> 전체 경로로 호출한다.</p>
<p><strong><code>No database selected</code></strong>
→ import 명령 끝에 대상 DB <code>mysql</code>을 빠뜨린 경우다. <code>... mysql.exe -u root -p mysql</code>처럼 맨 끝에 <code>mysql</code>을 붙인다.</p>
<hr>
<h2 id="한-줄-정리">한 줄 정리</h2>
<blockquote>
<p>Windows 네이티브 MySQL에서 <code>CONVERT_TZ</code>가 <code>NULL</code>을 뱉으면, MySQL 공식 사이트의 <strong>POSIX standard 타임존 SQL 덤프</strong>를 받아 <code>Get-Content ...sql | mysql.exe -u root -p mysql</code>로 시스템 스키마 <code>mysql</code>에 import하면 된다. 코드 변경 없이 로컬이 프로덕션과 동일하게 동작한다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[MySQL 타임존]]></title>
            <link>https://velog.io/@jeong_woo/MySQL-%ED%83%80%EC%9E%84%EC%A1%B4</link>
            <guid>https://velog.io/@jeong_woo/MySQL-%ED%83%80%EC%9E%84%EC%A1%B4</guid>
            <pubDate>Tue, 09 Jun 2026 01:11:26 GMT</pubDate>
            <description><![CDATA[<h1 id="django--mysql-타임존-__date-필터가-로컬에서만-0건-나오는-이유">Django + MySQL 타임존: <code>__date</code> 필터가 로컬에서만 0건 나오는 이유</h1>
<blockquote>
<p><strong>DB 에 저장은 됐는데 조회해보면 로컬은 0건</strong>. 코드 버그처럼 보였지만 범인은 로컬 MySQL의 타임존 테이블이었다. 그 과정을 디버깅하며 정리한 타임존 개념 노트.</p>
</blockquote>
<h2 id="tldr">TL;DR</h2>
<ul>
<li><code>CONVERT_TZ</code>는 <strong>설정이 아니라 시각 변환 계산기</strong>다. DB에 저장된 값을 바꾸지 않고, 읽을 때 임시로 계산만 한다.</li>
<li><code>USE_TZ=True</code>면 Django는 <strong>서버 시간과 무관하게 무조건 UTC로 저장</strong>한다.</li>
<li>KST 변환은 두 군데서 따로 일어난다: <strong>보여주기 = 파이썬</strong>, <strong>날짜 필터(<code>__date</code> 등) = DB의 <code>CONVERT_TZ</code></strong>.</li>
<li><code>CONVERT_TZ(&#39;...&#39;,&#39;UTC&#39;,&#39;Asia/Seoul&#39;)</code>처럼 <strong>타임존 이름</strong>을 쓰면 MySQL에 타임존 테이블(이름표 사전)이 적재돼 있어야 한다. 없으면 <code>NULL</code> 반환 → 모든 비교가 거짓 → <strong>0건</strong>.</li>
</ul>
<hr>
<h2 id="증상">증상</h2>
<p>방송 목록 API에 날짜 필터를 걸었다.</p>
<pre><code>/api/v2/broadcasting/?start_date=2026-04-26&amp;end_date=2026-06-07</code></pre><ul>
<li><strong>로컬</strong>: 0건 ❌</li>
<li><strong>프로덕션</strong>: 정상 ✅</li>
</ul>
<p>같은 코드인데 결과가 달랐다.</p>
<h2 id="진단--어디서-깨졌나">진단 — 어디서 깨졌나</h2>
<p>네 가지 경로로 같은 데이터를 조회해봤다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>결과</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>API 호출</td>
<td>0건</td>
<td>증상</td>
</tr>
<tr>
<td><code>ScheduleFilter</code> (ORM)</td>
<td>0건</td>
<td>증상 재현</td>
</tr>
<tr>
<td><code>start_time__date</code> 범위 (CONVERT_TZ 경로)</td>
<td>0건</td>
<td><strong>여기가 깨짐</strong></td>
</tr>
<tr>
<td><code>start_time</code> raw <code>__gte/__lte</code> (CONVERT_TZ 안 씀)</td>
<td><strong>199건</strong></td>
<td>데이터는 멀쩡</td>
</tr>
</tbody></table>
<p>마지막 줄이 결정적이었다. <code>__date</code>를 거치지 않는 raw 비교는 199건이 정상으로 나온다.
→ <strong>데이터/저장 문제가 아니라, 그 필터가 의존하는 DB 기능(<code>CONVERT_TZ</code>)이 고장난 것.</strong></p>
<hr>
<h2 id="개념-1--convert_tz는-설정이-아니라-계산기다">개념 1 — <code>CONVERT_TZ</code>는 설정이 아니라 계산기다</h2>
<pre><code class="language-sql">CONVERT_TZ(시각, &#39;이 타임존에서&#39;, &#39;저 타임존으로&#39;)</code></pre>
<p>시각 하나를 받아 다른 타임존 기준으로 바꿔 <strong>돌려주는 함수</strong>일 뿐이다. 서버나 DB에 무언가를 설정하지 않는다.</p>
<pre><code class="language-sql">CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;, &#39;UTC&#39;, &#39;Asia/Seoul&#39;)
-- 결과: &#39;2026-06-08 14:00:00&#39;   (UTC + 9시간)</code></pre>
<ul>
<li>DB에 저장된 원본 값을 바꾸지 않는다.</li>
<li>읽어올 때 <strong>임시로 계산만</strong> 한다. 원본은 그대로 UTC.</li>
</ul>
<hr>
<h2 id="개념-2--저장은-django가-날짜-필터는-db가-변환한다">개념 2 — 저장은 Django가, 날짜 필터는 DB가 변환한다</h2>
<p>변환의 <strong>주체가 방향에 따라 다르다.</strong> 이게 가장 헷갈리는 지점.</p>
<h3 id="저장할-때-django-→-db">저장할 때 (Django → DB)</h3>
<pre><code>Django(한국시간 14:00) → Django가 직접 UTC 05:00으로 변환 → DB에 05:00 저장</code></pre><ul>
<li>변환 주체: <strong>Django(파이썬)</strong>.</li>
<li><code>USE_TZ=True</code>면 <strong>서버 시간(UTC든 KST든)과 무관하게</strong> 무조건 UTC로 저장.</li>
<li>이유: 서버를 어디로 옮기든 DB 값은 늘 같은 단일 기준(UTC)이어야 어긋나지 않으니까.</li>
<li>여기선 <code>CONVERT_TZ</code>가 쓰이지 않는다.</li>
</ul>
<blockquote>
<p>주의: 흔히 &quot;서버가 UTC라서 UTC로 저장된다&quot;고 생각하지만, 정확히는 <strong>&quot;Django 설정이 UTC로 저장한다&quot;</strong>. 서버 시간은 보지 않는다.</p>
</blockquote>
<h3 id="읽을-때--보여주기-db-→-화면">읽을 때 — 보여주기 (DB → 화면)</h3>
<pre><code>DB에서 05:00(UTC) 꺼냄 → 파이썬이 14:00(KST)으로 표시</code></pre><ul>
<li>변환 주체: <strong>Django(파이썬)</strong>.</li>
<li><code>CONVERT_TZ</code> 안 씀.</li>
</ul>
<h3 id="읽을-때--날짜-필터집계-__date-__year-__month">읽을 때 — 날짜 필터/집계 (<code>__date</code>, <code>__year</code>, <code>__month</code>)</h3>
<pre><code>DB가 WHERE 단계에서 05:00(UTC) → 14:00(KST) 변환 후 날짜 비교</code></pre><ul>
<li>변환 주체: <strong>DB(MySQL)</strong>.</li>
<li>이때 <code>CONVERT_TZ</code>를 쓴다.</li>
</ul>
<p><strong>왜 필터만 DB에서 변환하나?</strong>
&quot;6월 7일 방송만 줘&quot;라는 조건은 DB가 <strong>행을 고르는 단계(WHERE)</strong> 에서 날짜를 잘라야 한다. 파이썬이 데이터를 꺼낸 뒤에 거를 수 없다. DB 안에 값은 UTC로 있으니, DB가 KST로 바꿔서 날짜를 잘라야 정확하다.</p>
<p>핵심 구분:</p>
<ul>
<li><strong>데이터를 꺼낸 뒤의 변환 = 파이썬</strong></li>
<li><strong>데이터를 고르는 중(WHERE)의 변환 = DB(<code>CONVERT_TZ</code>)</strong></li>
</ul>
<pre><code class="language-sql">-- start_time__date=2026-06-07 는 내부적으로 이렇게 바뀐다
WHERE DATE(CONVERT_TZ(start_time, &#39;UTC&#39;, &#39;Asia/Seoul&#39;)) = &#39;2026-06-07&#39;</code></pre>
<hr>
<h2 id="개념-3--타임존-이름표-사전이-있어야-한다">개념 3 — 타임존 &quot;이름표 사전&quot;이 있어야 한다</h2>
<p><code>CONVERT_TZ</code>가 <code>&#39;Asia/Seoul&#39;</code>이 UTC+9라는 걸 알려면, MySQL의 <code>mysql.time_zone*</code> 시스템 테이블(이름표 사전)이 적재돼 있어야 한다.</p>
<pre><code class="language-sql">-- 로컬 (사전 없음)
CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;, &#39;UTC&#39;, &#39;Asia/Seoul&#39;)  →  NULL    ❌

-- 프로덕션 (사전 있음)
CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;, &#39;UTC&#39;, &#39;Asia/Seoul&#39;)  →  &#39;2026-06-08 14:00:00&#39;  ✅</code></pre>
<p><code>NULL = &#39;2026-06-07&#39;</code> 비교는 항상 거짓이므로 → <strong>전부 0건</strong>.</p>
<blockquote>
<p>참고: <code>CONVERT_TZ(&#39;...&#39;, &#39;+00:00&#39;, &#39;+09:00&#39;)</code>처럼 <strong>숫자 오프셋</strong>으로 쓰면 사전이 필요 없다. Django가 자동으로 <strong>이름</strong>(<code>&#39;Asia/Seoul&#39;</code>)을 쓰기 때문에 사전이 필요했던 것.</p>
</blockquote>
<hr>
<h2 id="전체-그림">전체 그림</h2>
<pre><code>[저장] — 문제 없음 ✅
  Django(KST 14:00) → Django가 UTC 05:00으로 변환 → DB에 05:00 저장
  (서버 시간 무관, CONVERT_TZ 안 씀)

[보여주기] — 문제 없음 ✅
  DB에서 05:00(UTC) 꺼냄 → 파이썬이 14:00(KST)으로 표시
  (CONVERT_TZ 안 씀)

[날짜 필터] — 여기가 깨짐 ❌
  DB가 WHERE에서 05:00(UTC) → 14:00(KST) 변환 시도 → 이름표 사전 없음 → NULL → 0건</code></pre><hr>
<h2 id="해결--코드-변경-0">해결 — 코드 변경 0</h2>
<p>로컬 MySQL에 타임존 테이블을 한 번만 적재하면 된다.</p>
<pre><code class="language-bash"># Docker MySQL (호스트에서):
mysql_tzinfo_to_sql /usr/share/zoneinfo | docker exec -i &lt;컨테이너이름&gt; mysql -u root -p mysql

# 컨테이너 안에서:
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql</code></pre>
<p>적재 후 확인:</p>
<pre><code class="language-sql">SELECT CONVERT_TZ(&#39;2026-06-08 05:00:00&#39;,&#39;UTC&#39;,&#39;Asia/Seoul&#39;);
-- NULL이 아니라 &#39;2026-06-08 14:00:00&#39; 이면 성공</code></pre>
<ul>
<li>맨 끝의 <code>mysql</code> 인자는 적재 <strong>대상 DB 이름</strong>이다 (앱 DB가 아니라 시스템 스키마 <code>mysql</code>).</li>
<li>시스템 테이블을 건드리므로 <strong>root 권한</strong>이 필요하다.</li>
<li>한 번 적재하면 컨테이너를 재생성하지 않는 한 유지된다.</li>
</ul>
<blockquote>
<p>Windows + Docker라 호스트에 <code>/usr/share/zoneinfo</code>가 없다면, 컨테이너 안에서 실행한다:</p>
<pre><code class="language-powershell">docker exec -i &lt;컨테이너이름&gt; sh -c &quot;mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p&#39;&lt;루트비번&gt;&#39; mysql&quot;</code></pre>
</blockquote>
<hr>
<h2 id="한-줄-정리">한 줄 정리</h2>
<blockquote>
<p><code>__date</code> / <code>__year</code> / <code>__month</code> 같은 <strong>날짜 부분 필터</strong>는 <code>USE_TZ=True</code>인 Django에서 <code>CONVERT_TZ(컬럼,&#39;UTC&#39;,&#39;Asia/Seoul&#39;)</code>로 변환된다. 로컬 MySQL에 타임존 이름표 사전이 비어 있으면 그 계산이 <code>NULL</code>이 되어 모든 결과가 0건이 된다. <strong>코드 버그가 아니라 로컬 MySQL 환경 문제</strong>이고, 타임존 테이블만 적재하면 해결된다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[인코딩 디버깅 — CP949, UTF-8]]></title>
            <link>https://velog.io/@jeong_woo/%EC%9D%B8%EC%BD%94%EB%94%A9-%EB%94%94%EB%B2%84%EA%B9%85-CP949-UTF-8</link>
            <guid>https://velog.io/@jeong_woo/%EC%9D%B8%EC%BD%94%EB%94%A9-%EB%94%94%EB%B2%84%EA%B9%85-CP949-UTF-8</guid>
            <pubDate>Tue, 09 Jun 2026 00:42:22 GMT</pubDate>
            <description><![CDATA[<h1 id="인코딩-디버깅--cp949-utf-8">인코딩 디버깅 — CP949, UTF-8</h1>
<blockquote>
<p>production DB 백업(.sql.gz)을 로컬 MySQL에 복원하는 PowerShell 스크립트가
<code>ERROR 1062 Duplicate entry &#39;&#39; for key &#39;linkserver_linkserver.name&#39;</code> 로 죽었다.
dump 파일도 멀쩡하고 스크립트 로직도 멀쩡한데, 왜?
범인은 <strong>콘솔 인코딩</strong>이었고, 잡는 데 한참 헤맨 이유는 <strong>&quot;내 환경에선 재현이 안 돼서&quot;</strong> 였다.</p>
</blockquote>
<hr>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>텍스트는 <strong>시스템 경계를 넘을 때마다</strong> bytes ↔ text 변환(인코딩)이 일어난다. 경계마다 인코딩이 다르면 깨진다.</li>
<li>PowerShell이 외부 프로세스(mysql)의 stdin으로 보낼 때, 그 인코딩은 <strong>콘솔의 <code>[Console]::OutputEncoding</code></strong> 을 상속한다. 한글 Windows 콘솔은 기본 <strong>CP949</strong>다.</li>
<li>CP949로 인코딩된 한글 바이트를 mysql이 <code>utf8mb4</code>로 읽으니 <strong>한글이 통째로 깨져 빈 문자열 <code>&#39;&#39;</code></strong> 이 됐고, UNIQUE 제약에서 <code>&#39;&#39;</code> 중복 → 1062.</li>
<li><strong>&quot;내 환경에선 됨&quot;은 결론이 아니라 단서다.</strong> 환경 의존 버그는 실패하는 환경의 조건을 재현해야 잡힌다.</li>
</ul>
<hr>
<h2 id="1-증상">1. 증상</h2>
<pre><code>기존 DB 초기화: DROP &amp; CREATE DATABASE `smartsilvercenter` ... 초기화 완료
ERROR 1062 (23000) at line 1753: Duplicate entry &#39;&#39; for key &#39;linkserver_linkserver.name&#39;</code></pre><p>DB 초기화는 성공했는데, 데이터를 부어 넣는 도중 죽었다. 키워드 두 개:</p>
<ul>
<li><strong><code>Duplicate entry &#39;&#39;</code></strong> — 중복된 값이 NULL이 아니라 <strong>빈 문자열</strong>이다.</li>
<li><strong><code>key &#39;linkserver_linkserver.name&#39;</code></strong> — <code>name</code> 컬럼에 UNIQUE 제약이 걸려 있다.</li>
</ul>
<blockquote>
<p>💡 MySQL의 UNIQUE 인덱스는 <strong>NULL은 여러 개 허용하지만 빈 문자열 <code>&#39;&#39;</code> 은 하나만</strong> 허용한다.
즉 여러 행의 <code>name</code> 이 전부 <code>&#39;&#39;</code> 로 들어가려 해서 충돌한 것.</p>
</blockquote>
<h2 id="2-dump-파일은-멀쩡했다">2. dump 파일은 멀쩡했다</h2>
<p><code>.sql.gz</code> 를 풀어 해당 테이블의 INSERT를 직접 확인:</p>
<pre><code class="language-sql">INSERT INTO `linkserver_linkserver` VALUES
 (1,&#39;인천시&#39;,...),(2,&#39;강화군&#39;,...),(4,&#39;미추홀&#39;,...);</code></pre>
<p>이름이 <strong>셋 다 다르고, 빈 값도 없다.</strong> 그러니 이 파일을 그대로 복원하면 1062가 날 이유가 없다.
→ <strong>빈 문자열은 &quot;복원 과정 어딘가&quot;에서 생겨났다</strong>는 뜻.</p>
<h2 id="3-데이터-흐름을-경계로-쪼개기">3. 데이터 흐름을 경계로 쪼개기</h2>
<p>복원 스크립트는 dump를 한 줄씩 읽어 mysql의 stdin 파이프로 흘려보낸다. 경계를 그려보면:</p>
<pre><code>dump(.sql.gz, UTF-8 bytes)
  └─[StreamReader: UTF-8 decode]→ 메모리의 올바른 &quot;인천시&quot;
       └─[StreamWriter: ??? encode]→ mysql stdin (bytes)
            └─[mysql: utf8mb4 decode]→ DB</code></pre><p><code>StreamWriter</code> 가 무슨 인코딩으로 내보내는가? 이게 핵심 경계였다.</p>
<h2 id="4-헤맨-구간--정직한-기록">4. 헤맨 구간 — 정직한 기록</h2>
<p>여기서 한참 돌았다. 가설은 처음부터 맞았다(&quot;stdin 인코딩이 깨진다&quot;). 그런데:</p>
<ul>
<li>내 개발 환경에서 똑같은 로직을 돌리면 <strong>항상 성공</strong>했다.</li>
<li>그래서 &quot;인코딩이 원인이 아니다&quot;라고 <strong>잘못 결론 내리고 수정을 되돌리길 반복</strong>했다.</li>
</ul>
<p>문제는 검증을 <strong>내 환경에서만</strong> 했다는 것. 알고 보니:</p>
<table>
<thead>
<tr>
<th>환경</th>
<th><code>$proc.StandardInput</code> 인코딩</th>
</tr>
</thead>
<tbody><tr>
<td>내 개발 환경(콘솔이 UTF-8)</td>
<td><strong>CP65001 (UTF-8)</strong> → 안 깨짐</td>
</tr>
<tr>
<td>사용자 콘솔(한글 Windows)</td>
<td><strong>CP949</strong> → 깨짐</td>
</tr>
</tbody></table>
<p>결정타는 사용자 환경에서 딱 한 줄 측정한 값:</p>
<pre><code class="language-powershell">$p=[Diagnostics.Process]::Start((New-Object Diagnostics.ProcessStartInfo -Property @{
  FileName=&#39;cmd&#39;;RedirectStandardInput=$true;UseShellExecute=$false}))
&quot;stdin=$($p.StandardInput.Encoding.WebName) CP$($p.StandardInput.Encoding.CodePage)&quot;
# → stdin=ks_c_5601-1987 CP949</code></pre>
<p><code>CP949</code>. 범인 확정.</p>
<h2 id="5-재현-→-수정-→-재검증">5. 재현 → 수정 → 재검증</h2>
<p>콘솔을 CP949로 강제(<code>chcp 949</code>)하니 <strong>그제서야</strong> 원래 코드가 1062로 죽었다. 비로소 진짜 재현.</p>
<p><strong>수정:</strong> 콘솔 설정에 의존하지 말고 stdin을 UTF-8로 직접 고정한다.</p>
<pre><code class="language-powershell">$proc = [System.Diagnostics.Process]::Start($psi)
# .NET Framework/PS5.1 엔 ProcessStartInfo.StandardInputEncoding 속성이 없으므로
# BaseStream 위에 UTF-8(BOM 없음) StreamWriter 를 직접 씌운다.
$stdin = New-Object System.IO.StreamWriter(
    $proc.StandardInput.BaseStream,
    (New-Object System.Text.UTF8Encoding($false)))</code></pre>
<p>CP949 콘솔에서 검증:</p>
<table>
<thead>
<tr>
<th>버전</th>
<th>writer CP</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>원래 코드</td>
<td>949</td>
<td><strong>exit 1, ERROR 1062</strong> (재현됨)</td>
</tr>
<tr>
<td>수정 코드</td>
<td>65001</td>
<td><strong>exit 0, 한글 정상 저장</strong> ✅</td>
</tr>
</tbody></table>
<hr>
<h2 id="오늘-배운-것-인코딩-너머의-일반-원칙">오늘 배운 것 (인코딩 너머의 일반 원칙)</h2>
<h3 id="①-인코딩은-저장이-아니라-경계를-건널-때-일어나는-변환이다">① 인코딩은 &quot;저장&quot;이 아니라 &quot;경계를 건널 때&quot; 일어나는 변환이다</h3>
<p>&quot;텍스트가 깨졌다&quot; = <em>어떤 경계의 encode 인코딩 ≠ 다음 경계의 decode 인코딩</em>.
깨진 결과를 보지 말고 <strong>경계의 목록을 그려라</strong>: 파일 읽기 / 파이프 / 네트워크 / DB 연결 / 터미널 출력.</p>
<h3 id="②-내-환경에선-되는데요는-버그-리포트의-절반이다">② &quot;내 환경에선 되는데요&quot;는 버그 리포트의 절반이다</h3>
<p>재현 불가는 <strong>결론이 아니라 단서</strong>다. &quot;A 성공 + B 실패&quot; = 두 환경의 <em>차이</em>에 범인이 있다.
→ 추측한 원인을 내 환경에서 검증하지 말고, <strong>실패하는 환경의 조건을 재현</strong>하라.</p>
<h3 id="③-에러의-값은-지문-숫자는-좌표계를-의심하라">③ 에러의 &quot;값&quot;은 지문, &quot;숫자&quot;는 좌표계를 의심하라</h3>
<ul>
<li><code>&#39;&#39;</code>(빈 문자열) ← 값이 통째로 사라짐 = 인코딩 절단의 지문.</li>
<li><code>at line 1753</code> ← dump 파일 줄이 아니라 mysql 입력 스트림 줄. <strong>숫자는 항상 &quot;어느 좌표계?&quot;를 물어라.</strong></li>
</ul>
<h3 id="④-추측으로-고치지-말고-가설-→-최소검증-→-확정-→-수정">④ 추측으로 고치지 말고: 가설 → 최소검증 → 확정 → 수정</h3>
<p>같은 곳을 3번 두드리면 멈추고 &quot;내가 검증하는 <em>방법</em>이 틀린 건 아닐까&quot;를 의심하라.</p>
<hr>
<h2 id="한글-windows--powershell-51-실전-함정-모음">한글 Windows + PowerShell 5.1 실전 함정 모음</h2>
<table>
<thead>
<tr>
<th>함정</th>
<th>증상</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>경로의 대괄호 <code>[ ]</code></td>
<td><code>Test-Path</code>/<code>Resolve-Path</code>/<code>cd</code> 가 와일드카드로 오해 → 파일 못 찾음</td>
<td><code>-LiteralPath</code></td>
</tr>
<tr>
<td>외부 프로세스 stdin 인코딩</td>
<td>콘솔 CP949 상속 → 한글 깨짐</td>
<td><code>BaseStream</code> 위 UTF-8 StreamWriter</td>
</tr>
<tr>
<td><code>.ps1</code> 파일 BOM 없음</td>
<td>한글 .ps1을 ANSI로 오독 → 줄 깨짐</td>
<td>UTF-8 <strong>with BOM</strong> 저장</td>
</tr>
<tr>
<td><code>2&gt;&amp;1</code> 로 native exe stderr</td>
<td>exit 0인데 ErrorRecord로 감싸져 throw</td>
<td>리다이렉트 말고 <code>$LASTEXITCODE</code> 로 판단</td>
</tr>
<tr>
<td>PS 5.1 = .NET Framework</td>
<td>.NET Core 전용 API 없음</td>
<td>API 도입 버전 확인 후 우회</td>
</tr>
</tbody></table>
<blockquote>
<p>근본 해법: 시스템/터미널을 UTF-8로 통일하거나 PowerShell 7+로 이주.
방어적으로는 스크립트 상단에
<code>$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)</code>.</p>
</blockquote>
<hr>
<h2 id="보너스-①--유니코드-정규화-normalization">보너스 ① — 유니코드 정규화 (Normalization)</h2>
<p>같은 글자가 <strong>여러 바이트 표현</strong>을 가질 수 있다. 한글 <code>한</code>:</p>
<ul>
<li><strong>NFC(완성형):</strong> <code>한</code> = 코드포인트 1개 (U+D55C)</li>
<li><strong>NFD(조합형):</strong> <code>ㅎ+ㅏ+ㄴ</code> = 자모 3개 (U+1112 U+1161 U+11AB)</li>
</ul>
<p>눈엔 똑같지만 <code>&quot;한&quot;(NFC) == &quot;한&quot;(NFD)</code> 은 <strong>false</strong>, 길이도 1 vs 3.</p>
<blockquote>
<p>⚠️ <strong>macOS 함정:</strong> macOS 파일시스템은 한글 파일명을 <strong>NFD</strong>로 저장한다.
&quot;Mac에서 만든 한글 파일명을 git에 올렸더니 Windows에서 깨지거나 중복으로 보인다&quot;가 여기서 온다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>형식</th>
<th>의미</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>NFC</strong></td>
<td>합쳐서 하나</td>
<td><strong>웹/DB 저장 표준</strong> ✅</td>
</tr>
<tr>
<td>NFD</td>
<td>분해</td>
<td>macOS 내부</td>
</tr>
<tr>
<td>NFKC/NFKD</td>
<td>호환 분해(<code>①</code>→<code>1</code>, <code>㈜</code>→<code>(주)</code>)</td>
<td>검색 인덱싱</td>
</tr>
</tbody></table>
<p><strong>원칙:</strong> 외부 입력(사용자 입력/업로드 파일명/API)은 <strong>저장 전 NFC로 정규화</strong>.</p>
<pre><code class="language-python">import unicodedata
clean = unicodedata.normalize(&quot;NFC&quot;, user_input)</code></pre>
<h2 id="보너스-②--db-charset--collation">보너스 ② — DB charset / collation</h2>
<ul>
<li><strong>charset(문자셋):</strong> 글자를 <em>어떤 바이트로 저장</em> → 데이터의 <strong>표현</strong></li>
<li><strong>collation(정렬규칙):</strong> 글자를 <em>어떻게 비교·정렬</em> → 데이터의 <strong>동작</strong> (UNIQUE / WHERE / ORDER BY / GROUP BY)</li>
</ul>
<blockquote>
<p>오늘의 <code>&#39;&#39;</code> 중복은 바로 collation의 영역 — 두 값을 &quot;같다&quot;고 판정해 UNIQUE를 위반시켰다.</p>
</blockquote>
<p><strong>MySQL 필수 상식:</strong></p>
<ol>
<li><p><strong><code>utf8</code> 은 가짜다.</strong> 글자당 최대 3바이트 → 이모지·일부 한자 저장 불가. <strong>항상 <code>utf8mb4</code>.</strong></p>
</li>
<li><p><strong>collation 접미사가 동작을 결정한다:</strong></p>
<table>
<thead>
<tr>
<th>collation</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><code>utf8mb4_general_ci</code></td>
<td><code>_ci</code>=대소문자 무시, 빠르지만 부정확(구식)</td>
</tr>
<tr>
<td><code>utf8mb4_unicode_ci</code></td>
<td>유니코드 정렬, 더 정확</td>
</tr>
<tr>
<td><code>utf8mb4_0900_ai_ci</code></td>
<td>MySQL 8.0+ 기본·권장 ✅ (<code>_ai</code>=악센트 무시)</td>
</tr>
<tr>
<td><code>..._bin</code></td>
<td>바이트 그대로, 전부 구분</td>
</tr>
</tbody></table>
</li>
<li><p><strong>악명 높은 사고 — collation 불일치 JOIN:</strong></p>
<pre><code class="language-sql">SELECT * FROM A JOIN B ON A.name = B.name;
-- ERROR 1267: Illegal mix of collations</code></pre>
<p>서버/DB/테이블/컬럼 + <strong>클라이언트 연결</strong>까지 전부 동일 charset·collation으로 통일해야 한다.
(오늘 버그도 DB는 utf8mb4였지만 <em>연결로 보낸 바이트</em>가 어긋나 깨졌다 — &quot;DB만 맞아선 부족하다&quot;는 산 증거.)</p>
</li>
</ol>
<hr>
<h2 id="두-보너스를-잇는-한-줄">두 보너스를 잇는 한 줄</h2>
<blockquote>
<p><strong>정규화(NFC)는 &quot;같은 글자를 같은 바이트로&quot; 만드는 입구 작업이고,
collation은 &quot;그 바이트들을 어떻게 같다고 볼지&quot; 정하는 DB 규칙이다.
둘 다 통일하지 않으면, 분명 같아 보이는 값이 검색·UNIQUE·JOIN에서 제멋대로 갈라진다.</strong></p>
</blockquote>
<p>그리고 오늘 가장 크게 남은 한 줄:</p>
<blockquote>
<p><strong>데이터가 경계를 넘을 때마다 인코딩 변환이 일어나고,
&quot;내 환경에선 됨&quot;은 환경 차이라는 단서이며, 버그는 추측이 아니라 재현으로 잡는다.</strong></p>
</blockquote>
]]></description>
        </item>
        <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>
    </channel>
</rss>