<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dooo_it_ly.log</title>
        <link>https://velog.io/</link>
        <description>CS 마스터를 향해 ..</description>
        <lastBuildDate>Sat, 13 Dec 2025 09:32:28 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dooo_it_ly.log</title>
            <url>https://velog.velcdn.com/images/dooo_it_ly/profile/2bba8bbf-df5d-418a-a7f7-a3819f894d81/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dooo_it_ly.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dooo_it_ly" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[25-2 취준 결산]]></title>
            <link>https://velog.io/@dooo_it_ly/25-2-%EC%B7%A8%EC%A4%80-%EA%B2%B0%EC%82%B0</link>
            <guid>https://velog.io/@dooo_it_ly/25-2-%EC%B7%A8%EC%A4%80-%EA%B2%B0%EC%82%B0</guid>
            <pubDate>Sat, 13 Dec 2025 09:32:28 GMT</pubDate>
            <description><![CDATA[<p>2025년 마무리에 앞서 25년도 2학기에 했던 제 첫 취준에 대해 간단히 정리해보고자 합니다.</p>
<br>

<h3 id="서론">서론</h3>
<p>우선 저는 25-2에 <strong>4학년 1학기</strong>를 재학하면서 <strong>22학점</strong>을 수강했습니다. 물론 온라인 수업 비중이 커서 숫자에 비해 큰 부담이 있지는 않았지만, 학교를 4~5일 오면서 나름 바쁘게 살았답니다.</p>
<p>처음부터 4-1에 취준을 할 생각은 아니었습니다. 근데 어쩌다보니 .. 그렇게 됐습니다.</p>
<br>

<p>저는 <strong>서비스업 백엔드 개발자</strong> 직무를 희망했고 공기업 / 은행권보다는 제가 주도적으로 몰입해서 근무할 수 있는 환경을 꿈꾸는 상태입니다.</p>
<p>아직 졸업도 멀었고 면접 스터디라던가 자소서 첨삭이라던가 이런거 없이 그저 제 현재 상태를 점검해보고 싶었습니다. 그래서 도메인에 크게 얽히지 않고 골고루 서류를 넣었습니다. 또 제게 낯선 도메인을 다루는 회사에 가면 제가 얼마나 잘 적응할지 궁금하기도 했구요.</p>
<p><br><br></p>
<h3 id="합불-현황">합불 현황</h3>
<p><strong>11개</strong>의 기업에 서류를 제출했고, 그 중 <strong>4개</strong>의 기업에서 서류 합격을 했습니다.</p>
<p>서비스 기업은 정규직 공고를 위주로 서류를 넣었고, 그 외의 기업은 인턴 공고를 위주로 서류를 넣었습니다. 금융권도 5개정도 골고루 넣은 것 같습니다.</p>
<p>서류 및 면접 합불 현황은 다음과 같습니다. 
제시된 순서대로 면접을 봤고, 결론부터 말하자면 안랩에서 인턴으로 6개월간 근무하게 되었습니다! 야호!</p>
<table>
<thead>
<tr>
<th align="center">회사명</th>
<th align="center">고용형태</th>
<th align="center">결과</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>미리디</strong></td>
<td align="center">채용연계형 인턴</td>
<td align="center">면접 탈락</td>
</tr>
<tr>
<td align="center"><strong>채널톡</strong></td>
<td align="center">정규직</td>
<td align="center">면접 탈락</td>
</tr>
<tr>
<td align="center"><strong>토스뱅크</strong></td>
<td align="center">정규직</td>
<td align="center">면접 탈락</td>
</tr>
<tr>
<td align="center"><strong>안랩</strong></td>
<td align="center">체험형 인턴</td>
<td align="center">최종 합격</td>
</tr>
</tbody></table>
<p><br><br></p>
<h3 id="서류-및-포트폴리오">서류 및 포트폴리오</h3>
<p>포트폴리오를 싹 갈아엎으며 그동안 했던 동아리 경험, 수상, 활동 등 모든 경험을 정리했습니다.</p>
<p>또한 해당 <strong>경험</strong>이 어떤 <strong>역량</strong>과 매치될 수 있는지를 고려하며 <strong>제가 어떤 사람인지를 정의</strong>하려고 고민했습니다. </p>
<p>평소에 모든 활동을 기록해두고 분기별로 다시 정리하고 있어서 이 작업이 오래 걸리지는 않았습니다. 옛날에 쓴 동아리 서류 보니까 가관이더군요</p>
<br>

<p>작년에 <a href="https://disquiet.io/product/%EB%AA%A8%EC%95%84%EB%AA%A8%EC%95%84-moamoa">취준생을 위한 역량 정리 서비스 모아모아</a>를 런칭했는데, 막상 취준생이 되고 나니 노션을 가장 많이 쓴다는 점이 참 아이러니합니다 ..</p>
<br>

<p>포트폴리오는 4장, 이력서는 2장으로 구성해서 정리했습니다. 
제출하는 서류의 내용도 포트폴리오와 최대한 유사하게 가져가서 해당 버전의 포폴에 대한 합불 현황을 기록했습니다.</p>
<p><br><br></p>
<h3 id="면접">면접</h3>
<p>보통 작은 회사부터 면접 경험을 쌓으며 다듬어가라는 말이 많습니다. 아쉽게도 전 그러지 못했지만, 각 면접 하나하나가 너무 소중한 경험이 되었습니다.</p>
<p>면접 경험을 쌓을 수록, 포폴 내용에 대해 더 깊게 공부할 수 있었고 지식을 더 확장할 수 있었습니다. 생각보다 면접 준비하는게 재밌더라구요</p>
<br>

<p>저는 면접이 끝나자마자 바로 면접 회고를 하는데요.
제가 방어한답시고 대답했던 내용이 면접을 거듭할 수록 퀄이 높아지고 정돈되는 것 같아 나름의 성취감도 느낄 수 있었습니다. </p>
<p>근데 4번 면접 보는 동안 인성 질문 준비한 걸 단 한번도 말하지 못했습니다.. 다 기술 질문이었어요.</p>
<br>


<p>이번 분기에는 부득이하게 학교 일정이랑 많이 겹쳤어서 조금 정신없었지만, 솔직히 이건 핑계고 6개월 후에는 더 더 잘할 수 있을 것 같습니다.</p>
<p><br><br></p>
<h3 id="마무리">마무리</h3>
<p>약 1~2개월동안 취준 겸 학업 병행하느라 잔뜩 경직된 상태로 지냈습니다. 취준 너무 어렵네요 ..</p>
<p>제가 그동안 공부를 하는 척만 했던가 싶기도 하고, 나름 열심히 살았구나 싶기도 해서 싱숭생숭한 마음도 많이 들었네요. </p>
<p>곧 시작할 인턴 활동에서 제가 어떤 방향으로 성장할지, 6개월 뒤에는 어떤 결과가 나올 지 기대됩니다. </p>
<p>아자아자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS/OS] File System Crash Consistency (FSCK/Journaling)]]></title>
            <link>https://velog.io/@dooo_it_ly/CSOS-File-System-Crash-Consistency-FSCKJournaling</link>
            <guid>https://velog.io/@dooo_it_ly/CSOS-File-System-Crash-Consistency-FSCKJournaling</guid>
            <pubDate>Thu, 20 Nov 2025 12:15:49 GMT</pubDate>
            <description><![CDATA[<h1 id="1-redundancy">1. Redundancy</h1>
<hr>
<h3 id="1-file-system-redundancy">1. File System Redundancy</h3>
<p>File System은 어느정도의 <strong>중복된 데이터</strong>를 포함하고 있습니다.</p>
<ul>
<li>superblock : file system에 있는 <strong>전체 block의 개수</strong>를 저장하고 있음 (N개)</li>
<li>inode : data block을 가리키는 <strong>포인터</strong>를 포함하고 있음</li>
</ul>
<p>이 정보들은 중복되었지만, 이를 통해 <strong>N번째 포인터나 그 이후의 포인터는 invalid</strong>하다는 것을 알 수 있습니다. </p>
<br>

<h3 id="2-특징">2. 특징</h3>
<p><strong>1. 장점</strong>
이러한 redundancy를 통해 <strong>reliability</strong>를 높일 수 있습니다.</p>
<p>또한, Raid 미러링을 통해 읽기 속도를 개선하거나, FS의 bitmap 등을 통해 look up을 수행하며 <strong>performance</strong>가 향상되기도 합니다.
<br></p>
<p><strong>2. 단점</strong>
그러나, 이러한 데이터들로 인해 <strong>consistency</strong> 이슈가 발생하기도 합니다.</p>
<p><br><br></p>
<h1 id="2-consistency-challenging">2. Consistency Challenging</h1>
<hr>
<h2 id="1-crash-consistency">1. Crash Consistency</h2>
<p>File system가 2개의 redundant block에 data를 작성하는 상황을 가정해봅시다. 그런데 1개의 block에만 write했는데, 갑자기 예상치 못한 <strong>interrupt</strong>가 발생했습니다. </p>
<p>결과적으로 한 block은 다른 값이 저장되었고, 또 다른 block은 기존 데이터로 남아있어 <strong>inconsistent</strong>한 state가 되었습니다.</p>
<br>

<p>둘 중 어느 block의 데이터가 옳은 것인지 어느 기준을 가지고 판단해야 할까요?
또한 어떻게 disk의 상태를 되돌려야 할까요? 
<br></p>
<h2 id="2-senario">2. Senario</h2>
<p>먼저, crash 시나리오를 바탕으로 <strong>consistent / inconsistent state를 구분</strong>해봅시다.
<br></p>
<p>File System이 새로운 데이터를 write하려고 합니다. </p>
<p><strong>1. inode
2. data bitmap
3. data block</strong></p>
<p>이 세 가지를 모두 업데이트하려는데 중간에 Crash가 발생했습니다. 어느 데이터를 갱신했을 때 어떤 상황이 되는지 알아보겠습니다.</p>
<br>

<h4 id="1-bitmap만-disk에-썼을-때">1. bitmap만 disk에 썼을 때</h4>
<ul>
<li>data block은 작성되지 않았는데 bitmap만 갱신됨 -&gt; <strong>data loss</strong></li>
<li>해당 block은 이미 bitmap이 작성되었기 때문에 재사용도 불가함 -&gt; <strong>space leak</strong></li>
</ul>
<p>** -&gt; inconsistent** 
<br></p>
<h4 id="2-data-block만-disk에-썼을-때">2. data block만 disk에 썼을 때</h4>
<ul>
<li>FS입장에서는 아무 일도 일어나지 않았음</li>
<li>inode는 작성되지 않았으므로, 해당 data block에 나중에 다른 값으로 덮어씌워질 수도 있음 -&gt; <strong>data loss</strong></li>
</ul>
<p><strong>-&gt; consistent</strong> 
<br></p>
<h4 id="3-inode만-disk에-썼을-때">3. <strong>inode</strong>만 disk에 썼을 때</h4>
<ul>
<li>data block은 작성하지 않았으므로, 이상한 다른 <strong>garbage</strong>를 가리키게 됨</li>
<li>bitmap을 작성하지 않았으므로, 다른 inode와 <strong>중복 할당</strong>이 일어날 수도 있음</li>
</ul>
<p><strong>-&gt; inconsistent</strong> 
<br></p>
<h4 id="4-bitmap--data-block만-disk에-썼을-때">4. <strong>bitmap + data block</strong>만 disk에 썼을 때</h4>
<ul>
<li>block loss</li>
</ul>
<p><strong>-&gt; inconsistent</strong> 
<br></p>
<h4 id="5-bitmap--inode만-disk에-썼을-때">5. <strong>bitmap + inode</strong>만 disk에 썼을 때</h4>
<ul>
<li>data block을 남이 덮어서 작성할 수는 없음</li>
<li>그러나 data를 작성하지 않았으므로 <strong>garbage</strong>를 가리키게 됨</li>
</ul>
<p><strong>-&gt; consistent</strong>
    <br></p>
<h4 id="6-data-block--inode만-disk에-썼을-때">6. <strong>data block + inode</strong>만 disk에 썼을 때</h4>
<ul>
<li>data bitmap을 작성하지 않았으므로, data block을 다른 파일이 나중에 덮어씌울 수도 있음 -&gt; <strong>data loss</strong></li>
</ul>
<p><strong>-&gt; inconsistent</strong>
    <br></p>
<blockquote>
<p>결과적으로, <strong>bitmap, inode를 동시에 갱신하거나 둘 다 갱신하지 못했다면 consistent</strong>합니다. <br>
둘 중 하나만 갱신했다면 inconsistent하며, data block은 다시 갱신하면 되기 때문에 중요하지 않습니다.</p>
</blockquote>
<br>

<h2 id="3-inconsistent-state">3. Inconsistent State</h2>
<p>위의 시나리오에서 확인할 수 있는 inconsistent state는 다음과 같습니다. </p>
<ol>
<li><p><strong>inode가 bitmap에 의해 할당되지 않은 잘못된 block을 참조</strong> 
inode만 갱신된 경우 → 3, 6</p>
</li>
<li><p><strong>사용되지 않은 data block에 bitmap을 할당</strong>
bitmap만 갱신된 경우 → 1, 4</p>
</li>
</ol>
<br>

<p>이러한 문제를 해결하기 위해, <strong>파일의 갱신은 atomic하게</strong> 이루어져야 하며, Crash Consistency 문제를 해결하기 위해 OS는 <strong>fsck</strong>, <strong>journaling</strong> 등의 방법을 이용합니다. </p>
<p><br><br></p>
<h1 id="3-fsck">3. fsck</h1>
<hr>
<p>fsck는 inconsistent state를 발견하고 고치는 <strong>도구</strong>입니다.</p>
<h2 id="1-strategy">1. Strategy</h2>
<ul>
<li>crash 이후에 전체 disk를 scan하며 고칠 수 있으면 고침</li>
<li>FSCK가 완료될 때까지 offline 상태로 두어야 함</li>
</ul>
<br>

<h2 id="2-동작-원리">2. 동작 원리</h2>
<p>fsck의 동작 원리는 다음과 같습니다. </p>
<ul>
<li>*<em>Superblock *</em>내용 오류 검사<ul>
<li>FS의 크기보다 superblock에 정의된 전체 블락의 개수가 더 큰지 확인</li>
</ul>
</li>
<li><strong>Free block</strong> 검사<ul>
<li>inode block, indirect block 등 검사</li>
<li>기존 bitmap과 일치하지 않으면 inode 정보를 기반으로 <strong>block bitmap을 재구성</strong></li>
</ul>
</li>
<li><strong>inode</strong> 검사<ul>
<li>각 inode의 손상 여부 확인</li>
<li>해결 불가하면 inode 초기화 + inode bitmap 갱신</li>
</ul>
</li>
<li><strong>inode reference count</strong> 검사<ul>
<li>root부터 탐색해 연결된 링크 개수를 수집</li>
<li><strong>수집한 개수와 저장된 개수가 다르면 inode 갱신</strong></li>
<li>할당된 inode는 있으나 참조하는 디렉토리가 없을 경우 <strong>/lost+found</strong> 디렉토리에서 찾음</li>
</ul>
</li>
<li><strong>duplicate</strong> 검사<ul>
<li>중복된 포인터, 동일한 data block을 가리키는 다른 inode가 있는지 확인함</li>
</ul>
</li>
<li>*<em>bad pointer *</em>검사<ul>
<li>포인터 목록을 스캔하며 함께 수행</li>
<li>bad : 해당 포인터가 유효하지 않은 공간을 참조하고 있음</li>
</ul>
</li>
<li><strong>directory</strong> 검사<ul>
<li>파일의 내용은 확인하지 못하나 dir의 내용은 확인할 수 있음</li>
<li>첫 항목이 <code>‘.’</code> , <code>‘..’</code>인지, inode가 할당되어 있는지 확인</li>
</ul>
</li>
</ul>
<br>

<h2 id="3-특징">3. 특징</h2>
<p>fsck는 개념적으로 간단하며, fix를 위한 별도의 writing overhead가 적습니다. </p>
<p>그러나, <strong>모든 inconsistent state를 해결하지 못한다</strong>는 한계가 있습니다. fsck는 Metadata간의 일치를 목적으로 하기 때문에, 시나리오의 2, 5번과 같은 garbage data를 읽는 문제는 해결하지 못합니다.  </p>
<p>또한, 속도가 느리고 file system에 대한 많은 지식이 요구된다는 단점이 있습니다.</p>
<p><br><br></p>
<h1 id="4-journaling">4. journaling</h1>
<hr>
<h2 id="1-strategy-1">1. Strategy</h2>
<p>journaling은 file system 자체에 도입된 메커니즘으로, crash 이후 <strong>recovery 작업</strong>을 수행해 correct state로 되돌리는 것을 목적으로 합니다.</p>
<p>journaling의 주요 전략은 다음과 같습니다.</p>
<ol>
<li><strong>디스크에 모든 새로운 데이터가 안전하게 저장되기 전까지는 기존 데이터를 절대 삭제하지 않음</strong></li>
<li>redundancy로 인해 발생한 문제를 해결하기 위해 <strong>redundancy를 추가함</strong></li>
</ol>
<br>

<h2 id="2-기본-동작-원리">2. 기본 동작 원리</h2>
<p>block 0에 10, block 1에 5를 write하는 상황을 가정해봅시다.</p>
<p>이 때, FS는 <strong>disk에 바로 data를 작성하지 않고, extra block에 data를 작성</strong>합니다. 그리고 valid bit을 이용해 <strong>모든 변경사항이 모두 extra에 작성되었는지를 표시</strong>합니다. </p>
<br>

<p>이러한 상황에서 crash가 났을 때,</p>
<p>valid bit이 0이라면, 아직 모든 변경사항이 extra에 작성되지 않은 상태이므로, <strong>기존 disk에 있는 데이터</strong>로 롤백합니다.</p>
<p>반대로 valid bit이 1이라면, 모든 변경사항이 extra에 작성된 상태이므로 <strong>extra의 데이터</strong>로 롤백하면 됩니다.</p>
<br>

<p>실제 FS에서는 extra block, valid bit이 아닌 아래와 같은 명칭으로 불립니다.</p>
<table>
<thead>
<tr>
<th>terminology</th>
<th>description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>journal</strong></td>
<td>extra blocks</td>
</tr>
<tr>
<td><strong>journal transaction</strong></td>
<td>journal에 data를 write하는 행위</td>
</tr>
<tr>
<td><strong>journal commit block</strong></td>
<td>마지막에 위치하는 valid bit 역할을 하는 block</td>
</tr>
</tbody></table>
<p><br><br></p>
<h2 id="3-optimization">3. Optimization</h2>
<p>위와 같은 간단한 journaling system이 존재한다고 가정해봅시다. 이 때, 이 system의 성능을 높이기 위해 어떤 최적화가 필요한지 하나씩 살펴봅시다.</p>
<h3 id="1-small-journal-size">1. Small journal size</h3>
<p>data block이 N개일 때, 저널링을 위해 <strong>N+1</strong>개만큼의 블락이 추가로 필요합니다. 때문에, 최악의 경우 bandwidth가 1/2가 되어 성능이 저하됩니다. </p>
<ul>
<li>journal block size = N</li>
<li>journal commit block = 1</li>
</ul>
<br>

<p>이를 해결하기 위해, 아래와 같은 방식으로 변환할 수 있습니다. </p>
<blockquote>
<p><strong>저널의 크기를 줄이고 디스크에는 업데이트 된 데이터만 기록하자</strong></p>
</blockquote>
<p>트랜잭션마다 헤더를 생성하고 헤더에 데이터를 작성할 <strong>블락의 번호를 저장</strong>합니다. 이후, <strong>헤더를 기반으로 disk에 실제 위치에 데이터를 작성</strong>하면 됩니다.</p>
<p>이 때, 저널에 있는 데이터를 실제 위치에 작성하는 행위를 <strong>check point</strong>라고 합니다.</p>
<br>

<p>위의 내용을 바탕으로 <strong>4번 블락에 C, 6번 블락에 D를 작성</strong>하는 상황을 가정했을 때, 아래와 같이 동작합니다.</p>
<ol>
<li><p>처음 commit block은 0
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/3c76cdc0-193c-42e3-9b5c-bdb269039a83/image.png" alt=""></p>
</li>
<li><p>9번 block(header)에 4, 6이라는 block 위치 작성 + 저널링 완료했으므로 commit block 1 표시
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/4a4d5428-fade-4df3-877f-cc17c924aa16/image.png" alt=""></p>
</li>
<li><p>실제 4, 6번 block에 데이터를 작성하고 commit block 0 표시
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/4173b992-b07a-4150-b601-7c16c4c05d3c/image.png" alt=""></p>
</li>
</ol>
<br>

<h3 id="2-barriers">2. Barriers</h3>
<blockquote>
<p><strong>4번 블락에 C, 6번 블락에 D를 작성할 때 어떤 작업이 수반되어야 하는가</strong></p>
</blockquote>
<ol>
<li>9번 : 헤더 읽어서 위치 확인</li>
<li>10번, 11번 : 어떤 데이터 읽을지 확인</li>
<li>12번 : commit 블락 확인</li>
<li>4번 : 데이터 작성</li>
<li>6번 : 데이터 작성</li>
<li>12번 : commit 블락 확인</li>
</ol>
<p>위와 같이 지금까지의 방식은 write order이 <strong>random write</strong>를 야기해서 비효율적입니다.</p>
<br>

<p>따라서, random write 횟수를 줄이기 위해, <strong>쓰기 작업을 그룹화</strong>해 sequential write를 유도하고자 합니다.</p>
<p>barrier을 세워, 저널이 꽉 차지 않아도 조건을 만족하면 check point를 수행합니다. check point를 수행하는 Key point, 즉 barrier을 세우는 조건은 다음과 같습니다. </p>
<ol>
<li><strong>journal commit 전</strong><ul>
<li>journal transaction entry 완료 ~</li>
<li>9, 10, 11번</li>
</ul>
</li>
<li><strong>checkpoint 전</strong><ul>
<li>journal commit 완료 ~</li>
<li>12번</li>
</ul>
</li>
<li><strong>journal을 free하기 이전</strong><ul>
<li>check point 완료 ~</li>
<li>4, 6번</li>
</ul>
</li>
</ol>
<br>

<h3 id="3-checksums">3. Checksums</h3>
<p>2번의 내용대로라면, 9, 10, 11 | 12 | 4, 6 | 12 순서대로 실행됩니다.
이 때, 11, 12 사이의 barrier를 없앨 수 없을까요?</p>
<br>

<p>이를 위해 앞선 트랜잭션의 내용을 요약한 <strong>check sum</strong>을 만들어 commit block에 기록하는 방법이 도입됩니다.</p>
<p>12번 블락에 9, 10, 11에 대한 check sum이 들어가는거죠.</p>
<blockquote>
<p><strong>checksum 기반 복구 검증 로직</strong></p>
</blockquote>
<ol>
<li>commit block 읽어 check sum 값 확인</li>
<li>해당 트랜잭션의 내용인 블락의 내용 읽고 check sum 재계산</li>
<li>두 check sum이 일치할 경우, 트랜잭션은 유효함</li>
<li>일치하지 않을 경우, 문제가 있으니 해당 트랜잭션 무시 및 롤백</li>
</ol>
<br>

<h3 id="4-circular-journal">4. Circular journal</h3>
<p>앞선 내용들로 보았을 때, 저널링은 sequential하지만 checkpoint는 random합니다. 따라서, 성능 개선을 위해 <strong>checkpoint 시점을 뒤로 미루어</strong> 한 번에 write하고자 합니다. </p>
<p><strong>circular buffer</strong>를 도입해, 디스크에서 check point가 발생하기 전까지 데이터를 메모리에 저장해둡니다.</p>
<ul>
<li>오래된 저널부터 checkpoint 수행, 해당 공간은 재사용<ul>
<li><strong>head</strong> : 새로운 트랜잭션이 저장되는 위치</li>
<li><strong>tail</strong> : 가장 오래된 트랜잭션의 위치</li>
</ul>
</li>
<li>동작원리<ul>
<li>새로운 데이터가 오면 head를 늘려서 메모리에 저장</li>
<li>백그라운드에서 tail에 있는 데이터를 틈틈이 checkpoint 수행 후 tail을 앞으로 당김</li>
<li>head가 tail을 따라잡으면 disk에 공간이 없으니 checkpoint 수행해 공간 확보</li>
</ul>
</li>
</ul>
<br>


<h3 id="5-logical-logging">5. Logical Logging</h3>
<p>지금까지의 내용은 physical logging입니다. </p>
<p>블락의 변경된 실제 내용이 아니라 <strong>연산이나 명령어</strong>를 기록하는 <strong>logical logging</strong> 방법이 등장합니다. 트랜잭션의 시작(TxB), 끝(TxE)에 <strong>identifier</strong> 표시를 함께 남겨 중복 연산을 수행하지 않도록 합니다.</p>
<blockquote>
<p><strong>logical logging 기반 복구 검증 로직</strong>
로그를 읽고 해당 연산부터 다시 실행하면 됨</p>
</blockquote>
<ul>
<li>현재 트랜잭션 identifier가 로그의 번호보다 크다면 
→ 이미 실행한 연산이므로 무시</li>
<li>작다면 
→ crash로 인해 수행하지 못한 연산이므로, 해당 연산 실행</li>
</ul>
<p><br><br></p>
<h2 id="4-journaling-종류">4. Journaling 종류</h2>
<h3 id="1-data-journaling">1. Data Journaling</h3>
<p>필요한 모든 정보를 기록하는 방식입니다. 지금까지의 내용은 data journaling 방식입니다.</p>
<p>그러나, data를 저널에 기록하고 또 블락에 기록하기 때문에 <strong>기록을 두 번 수행</strong>한다는 문제점이 있습니다.</p>
<br>

<h3 id="2-metadata-journaling">2. Metadata Journaling</h3>
<p>1번과 다르게 <strong>meta data만 저널링</strong>해서 중복 기록을 피하는 방식입니다. 속도는 빠르나 data block의 복구가 안될수도 있습니다.</p>
<br>

<p>해당 방식은 option으로 두 방식을 선택할 수 있습니다.</p>
<p><strong>1. ordered journaling</strong></p>
<ul>
<li>default</li>
<li>데이터가 완전히 작성된 이후에야 meta data를 기록함<ul>
<li>동작원리<ol>
<li>실제 데이터를 실제 위치에 작성함</li>
<li>데이터가 모두 작성된 것이 확인되면 메타 데이터를 저널링하고 commit함</li>
<li>나중에 check point를 수행해 메타 데이터를 disk에 작성함</li>
</ol>
</li>
<li>장점<ul>
<li>crash가 나도 garbage value가 없음</li>
<li>meta data가 가리키는 곳에는 유효한 데이터가 저장되어 있음</li>
</ul>
</li>
<li>단점<ul>
<li>시간이 오래 걸림</li>
</ul>
</li>
</ul>
</li>
</ul>
<br>

<p><strong>2. writeback journaling</strong></p>
<ul>
<li>동작원리<ol>
<li>메타 데이터 저널링<ol start="2">
<li>실제 데이터는 시간 날 때 disk에 작성함</li>
<li>check point 수행해 메타 데이터 disk에 작성함</li>
</ol>
<ul>
<li>장점</li>
</ul>
</li>
</ol>
<ul>
<li>ordered journaling에 비해 시간이 빠름<ul>
<li>단점</li>
</ul>
</li>
<li>crash가 났을 때 garbage value가 있을 수 있음</li>
</ul>
</li>
</ul>
<p><br><br></p>
<h2 id="5-example">5. Example</h2>
<p>위의 내용을 기반으로 현재 가장 많이 사용되는 *<em>metadata journaling + ordered *</em>방식의 저널링 예시를 간단하게 살펴보겠습니다.</p>
<p>사용자는 example.txt라는 파일을 작성하려고 한다고 가정합니다.</p>
<ol>
<li>트랜잭션 시작<ul>
<li>이 작업을 하나의 트랜잭션으로 묶음</li>
</ul>
</li>
<li>데이터 쓰기<ul>
<li>실제 파일의 내용을 disk에 작성함</li>
<li>작성이 완료될 때까지 대기 (barrier)</li>
</ul>
</li>
<li>meta 데이터 저널링<ul>
<li>inode N번을 생성 / 파일 명은 example.txt / data block은 100~102번</li>
</ul>
</li>
<li>저널 커밋<ul>
<li>저널에 commit block을 씀</li>
</ul>
</li>
<li>checkpoint<ul>
<li>나중에 저널에 적힌 meta data를 disk에 옮겨 적음</li>
</ul>
</li>
</ol>
<br>

<p>이러한 상황에서 write 도중 crash가 발생할 경우 각 케이스에 따른 복구 과정과 결과는 다음과 같습니다. </p>
<blockquote>
<p><strong>Crash 1 : 데이터 쓰기 도중 Crash 발생</strong></p>
</blockquote>
<ul>
<li>복구<ul>
<li>저널을 확인함</li>
<li>저널 커밋이 없으므로 해당 트랜잭션은 완료되지 않은 것으로 간주함</li>
<li>따라서 해당 트랜잭션을 무시함</li>
</ul>
</li>
<li>결과<ul>
<li>파일 유실</li>
<li>그러나 consistent함</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>Crash 2 : 메타 데이터 저널링 후 저널 커밋 도중 Crash 발생</strong></p>
</blockquote>
<ul>
<li>복구<ul>
<li>1번 예시와 동일하게 저널 커밋이 없으므로 무시</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>Crash 3 : 저널 커밋 이후 Crash 발생</strong></p>
</blockquote>
<ul>
<li>복구<ul>
<li>저널을 확인함</li>
<li>저널 커밋이 있으므로 저널의 메타데이터를 disk에 다시 작성함</li>
</ul>
</li>
<li>결과<ul>
<li>data는 이미 작성되었고, 메타 데이터를 올바르게 옯겨 써서 파일을 안전하게 복구할 수 있음</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS/Java] Garbage Collection의 동작 원리]]></title>
            <link>https://velog.io/@dooo_it_ly/CSJava-Garbage-Collection%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC</link>
            <guid>https://velog.io/@dooo_it_ly/CSJava-Garbage-Collection%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC</guid>
            <pubDate>Wed, 19 Nov 2025 07:36:55 GMT</pubDate>
            <description><![CDATA[<h2 id="1-garbage-collection">1. Garbage Collection</h2>
<hr>
<h3 id="1-garbage-collection-1">1. Garbage Collection?</h3>
<p>Garbage Collection은 <strong>&quot;JVM에서 자동으로 Heap 영역에 할당된 객체 중 사용하지 않는 객체를 탐지해 메모리를 해지하는 기법&quot;</strong>입니다.
<br></p>
<p>덕분에 개발자는 직접 메모리를 해제하지 않아도 되어 <strong>개발 편의성</strong>이 높아집니다.</p>
<p>그러나, 메모리가 언제 해제되는지 알지 못해 제어가 어렵습니다. 또한, GC가 수행될 때는 애플리케이션이 멈춰 오버헤드가 발생합니다.</p>
<p>이를 <strong>&quot;Stop the world&quot;</strong>라고 부르며, 이 시간을 최소화하는 것이 GC 알고리즘의 핵심 목표가 됩니다.</p>
<br>

<h3 id="2-gc의-대상">2. GC의 대상</h3>
<p>GC는 <strong>참조하는 Reference가 없는 객체</strong>를 청소 대상으로 지정합니다.</p>
<p>따라서, 객체가 NULL이거나 블럭 실행 종료 후, 블럭 내에서 생성된 객체 등이 GC의 대상이 됩니다.</p>
<br>

<h3 id="3-gc-과정">3. GC 과정</h3>
<p>가장 기본적인 GC 과정입니다.</p>
<h4 id="1-mark">1. Mark</h4>
<ul>
<li>모든 객체를 순회하며 <strong>Reference 존재 여부</strong>에 따라 Mark함</li>
<li>reachable / unreachable</li>
</ul>
<h4 id="2-sweep">2. Sweep</h4>
<ul>
<li>Heap에서 unreachable 객체들을 삭제함</li>
</ul>
<h4 id="3-compacting">3. Compacting</h4>
<ul>
<li>분산된 객체들을 Heap 시작주소로 모아 압축함</li>
<li>객체가 존재하는 공간과 존재하지 않는 공간으로 분리함</li>
</ul>
<p><br><br></p>
<h2 id="2-generational-garbage-collection">2. Generational Garbage Collection</h2>
<hr>
<h3 id="1-weak-hypothesis">1. Weak Hypothesis</h3>
<blockquote>
<ol>
<li><strong>대부분의 객체는 금방 unreachable 상태가 된다</strong></li>
<li><strong>오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다</strong></li>
</ol>
</blockquote>
<p>Heap 영역은 위의 가설을 전제하고 설계되었습니다.</p>
<p>위의 가설에 따르면 <strong>대부분의 객체는 일회성이고 메모리에 오래 남는 경우가 적다</strong>는 것을 유추할 수 있습니다.</p>
<p>따라서 개발자들은 이 특성을 기반으로 더 효율적인 설계를 하기 위해, Heap 영역을 <strong>Old 영역과 Young 영역</strong>으로 구분했습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/3ff7f75d-3c36-478f-b785-c587c96e198e/image.png" alt=""></p>
<br>


<h3 id="2-young-space">2. Young Space</h3>
<p>Eden 영역과 두 개의 Survivor 영역으로 구분된 Young Space는 <strong>새로운 객체가 할</strong>당되는 공간입니다.</p>
<p>대부분의 객체가 일회성이라는 가설에 알맞게, 대부분의 객체는 이 영역에서 할당되었다가 사라지며, Young Space에서 일어나는 GC는 <strong>Minor GC</strong>라고 칭합니다.</p>
<h4 id="eden">Eden</h4>
<ul>
<li>new를 통해 새롭게 생성된 객체가 할당됨</li>
<li>공간이 가득 차면 살아남은 객체들을 survivor space로 보냄</li>
</ul>
<h4 id="survivor-space">Survivor Space</h4>
<ul>
<li>최소 1회 이상 살아남은 객체들이 존재하는 공간</li>
<li>둘 중 한 공간은 반드시 비어있어야 함</li>
</ul>
<br>

<h3 id="3-old-space">3. Old Space</h3>
<p>Young Space에서 살아남은 객체가 복사되는 공간이며, Young Space보다 더 큰 공간이 할당됩니다.</p>
<p>Young Space보다 GC가 적게 발생하며, 이를 <strong>Major GC</strong>라고 칭합니다.</p>
<br>

<h3 id="4-permanent">4. Permanent</h3>
<p>ClassLoader에 의해 load되는 class나 method의 meta data를 포함하는 공간입니다.</p>
<p>Java7까지는 Heap에 존재했으며 Java8부터는 Method Stack으로 편입되었습니다.</p>
<p><br><br></p>
<h2 id="3-generational-garbage-collection-과정">3. Generational Garbage Collection 과정</h2>
<h3 id="1-minor-gc">1. Minor GC</h3>
<ol>
<li>새롭게 생성된 객체는 Eden 영역에 할당됨
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/7a948c6a-3396-4f53-a91b-c5881e2169f0/image.png" alt=""></li>
</ol>
<ol start="2">
<li>객체가 계속 생성되어 Eden 영역이 꽉 차면 Minor GC가 발생함
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/ae2a5c0d-69ac-49eb-b564-8d439c2aa668/image.png" alt=""></li>
</ol>
<ol start="3">
<li>Eden 영역을 탐색해 Mark를 수행함
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/11d23f06-5229-4db5-a938-049114b7a65b/image.png" alt=""></li>
</ol>
<ol start="4">
<li>reachable 객체들은 S0으로 이동시키고 unreachable 객체들은 Eden 영역이 clear될 때 해제됨
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/0a06f9ab-69ec-477d-a149-02990b3ec337/image.png" alt=""></li>
</ol>
<ol start="5">
<li>살아남은 객체들의 age를 1 증가시킴
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/49ec8ba5-04ba-4573-b669-b1ec79f73a66/image.png" alt=""></li>
</ol>
<ol start="6">
<li>또 다시 Eden 영역이 꽉 차면 Minor GC가 발생함</li>
<li>Mark 수행함</li>
<li>Eden 영역의 reachable 객체들과 S0의 reachable 객체들을 S1로 이동시킴
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/fd00d6b2-010d-4a64-b61f-67a5bf4869aa/image.png" alt=""></li>
</ol>
<ol start="9">
<li>살아남은 객체들의 age를 1 증가시킴
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/bd7e9029-5e15-43d6-aa96-8a002e13565f/image.png" alt=""></li>
</ol>
<ol start="10">
<li>이러한 과정을 반복함</li>
</ol>
<br>

<h3 id="2-major-gc">2. Major GC</h3>
<ol>
<li>age가 임계치에 도달하면 old space로 이동시킴 == <strong>promotion</strong>
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/a3d2c741-d503-4d8b-96a8-2cdb061defa6/image.png" alt="">
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/fe51ca4f-9711-4391-98c9-87a85bce8c03/image.png" alt=""></li>
</ol>
<ol start="2">
<li>minor GC가 반복해서 일어나 old space가 꽉 차면 Major GC가 발생함</li>
</ol>
<p><br><br></p>
<p>이 때, Major GC는 Minor GC보다 실행시간이 배로 느리므로, stop the world 오버헤드가 발생합니다.</p>
<p>이러한 관점에서, 성능 개선을 위한 Major GC Algorithm에도 여러 종류가 존재합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS/OS] 동기화 도구(SpinLock/Mutex/Semaphore/Monitor)]]></title>
            <link>https://velog.io/@dooo_it_ly/CSOS-%EB%8F%99%EA%B8%B0%ED%99%94-%EB%8F%84%EA%B5%ACSpinLockMutexSemaphoreMonitor</link>
            <guid>https://velog.io/@dooo_it_ly/CSOS-%EB%8F%99%EA%B8%B0%ED%99%94-%EB%8F%84%EA%B5%ACSpinLockMutexSemaphoreMonitor</guid>
            <pubDate>Wed, 19 Nov 2025 03:04:08 GMT</pubDate>
            <description><![CDATA[<p>OS에서 Critical section에 대한 <strong>상호 배제</strong>를 보장하고 <strong>Synchronization</strong>를 제어하는 방법에는 무엇이 있는가</p>
<br>


<h2 id="1-하드웨어적-해결-방법">1. 하드웨어적 해결 방법</h2>
<hr>
<p>소프트웨어만으로는 완벽한 상호배제가 어렵거나 느리기 때문에 하드웨어의 도움을 받음</p>
<h3 id="1-인터럽트-금지">1. 인터럽트 금지</h3>
<p><strong>1. 원리</strong></p>
<ul>
<li>Interrupt 자체를 Turn Off</li>
<li>OS가 Context Switch하지 못하도록 막음</li>
</ul>
<p><strong>2. 특징</strong></p>
<ul>
<li>OS가 Interrupt에 의해 주도권을 변경하는 것을 막을 수 있음</li>
<li>따라서 명령어가 Atomic하게 수행됨을 보장할 수 있음</li>
</ul>
<p><strong>3. 한계</strong></p>
<ul>
<li>단일 프로세서에서만 유효함<ul>
<li>다른 CPU에서 실행되는 스레드는 막을 수 없음</li>
</ul>
</li>
<li>유저 레벨에서는 사용 불가<ul>
<li>커널에 준하는 권한을 유저 레벨에게 부여해야 함</li>
<li>이는 보안 측면에서 비권장</li>
</ul>
</li>
<li>기능 상실<ul>
<li>Interrupt가 꺼지면, OS의 스케쥴링이 동작하지 않아 프로세스가 종료될 때까지 다른 프로세스는 대기해야 함</li>
<li>I/O 이후 돌아오는 프로세스의 시그널을 놓칠 수도 있음</li>
</ul>
</li>
</ul>
<pre><code class="language-c">void lock() {
    disableInterrupts();
}

void unlock() {
    enableInterrupts();
}</code></pre>
<br>

<h3 id="2-atomic-instructions">2. Atomic Instructions</h3>
<h4 id="test-and-set">Test-And-Set</h4>
<ul>
<li>읽기와 쓰기를 원자적으로 수행함</li>
<li>single shared variable을 이용함</li>
<li>주로 <strong>Spinlock</strong> 구현에 사용</li>
</ul>
<pre><code class="language-c">typedef struct __lock_t {
    int flag;
} lock_t;

void init(lock_t *lock) {
    lock-&gt;flag = 0;
}

int testAndSet(int *old_ptr, int new) {
    // old의 값을 new로 바꾼 후 old값 리턴
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

void lock(lock_t *lock) {
    // 결과 값이 0 : 반복문을 탈출하고 내가 lock을 획득
    // 결과 값이 1 : 다른 프로세스가 이미 lock을 획득한 상태. 반복문을 탈출하지 못하고 무한정 대기
    while (testAndSet(&amp;lock-&gt;flag, 1) == 1);
}

void unlock(lock_t *lock) {
    lock-&gt;flag = 0;
}</code></pre>
<br>

<h4 id="compare-and-swap">Compare-And-Swap</h4>
<ul>
<li>값의 비교와 교체를 원자적으로 수행</li>
<li>TestAndSet보다 더 세밀한 제어가 가능함</li>
<li>Mutex, Semaphore 등에 이용됨</li>
<li>Lock-Free / Wait-Free 동기화 등에 사용</li>
</ul>
<pre><code class="language-c">typedef struct __lock_t {
    int flag;
} lock_t;

void init(lock_t *lock) {
    lock-&gt;flag = 0;
}

int compareAndSwap(int *old_ptr, int expected, int new) {
    int old = *old_ptr;
    if (old == expected) {
        *old_ptr = new;
    }
    return old;
}

void lock(lock_t *lock) {
    // 결과 값이 0 : 반복문을 탈출하고 내가 lock을 획득 (0이랑 비교해서 true, 1로 swap)
    // 결과 값이 1 : 다른 프로세스가 이미 lock을 획득한 상태. 반복문 탈출하지 못하고 무한정 댁정 대기
    while (compareAndSwap(&amp;lock-&gt;flag, 0, 1) == 1);
}

void unlock(lock_t *lock) {
    lock-&gt;flag = 0;
}</code></pre>
<br>

<blockquote>
<h4 id="lock-free-algorithm">Lock-Free Algorithm</h4>
</blockquote>
<ul>
<li>Lock 없이 멀티 프로그래밍의 동시성을 극대화하기 위한 <strong>Non-Blocking</strong> 알고리즘</li>
<li>CAS를 기반으로 동작함<ul>
<li>우선 공유 변수의 값을 변경하고 값이 맞는지 확인함</li>
<li>맞지 않다면, 다시 시도함</li>
</ul>
</li>
<li>ABA 문제<pre><code class="language-c">void lockFree(int *ptr) {
  while (1) {
      int old = *ptr; // 현재 값을 읽음
      int new = old + 1; // 우선 값을 변경
      // 업데이트 시도
      if (compareAndSwap(&amp;ptr, old, new) == old) {
          // 업데이트 성공 -&gt; 반복문 탈출
          break;
      }
      // 업데이트 실패 -&gt; 다시 시도
  }
}</code></pre>
</li>
</ul>
<p><br><br></p>
<h2 id="2-os시스템-레벨의-동기화-도구">2. OS/시스템 레벨의 동기화 도구</h2>
<hr>
<h3 id="1-spinlock">1. Spinlock</h3>
<p>상호 배제, 즉 <strong>Critical Section에 단 하나의 쓰레드만 들어올 수 있도록</strong> 하기 위해 lock이 사용됨</p>
<p><strong>1. 원리</strong></p>
<ul>
<li><strong>Busy-Waiting 방식</strong></li>
<li>루프를 돌며 대기함</li>
</ul>
<p><strong>2. 특징</strong></p>
<ul>
<li>Context Switch 비용 없음</li>
<li>Critical Section에 오직 하나의 스레드만 진입하도록 하여 상호 배제 보장</li>
</ul>
<p><strong>3. 사용</strong></p>
<ul>
<li>Critical Section이 짧을 때 유리</li>
</ul>
<p><strong>4. 단점</strong></p>
<ul>
<li>대기 시간이 길어지면 <strong>CPU 자원을 낭비</strong>함</li>
<li>starvation이 발생할 수 있음</li>
</ul>
<p><strong>5. 해결책</strong></p>
<ol>
<li><strong><code>yield()</code></strong><ul>
<li>busy-waiting 대신 CPU 양보</li>
<li>context switch 비용 + starvation 가능성</li>
</ul>
</li>
<li><strong>queue + sleep</strong><ul>
<li>queue에 들어가 sleep하고 대기</li>
<li><blockquote>
<p>mutex</p>
</blockquote>
</li>
</ul>
</li>
</ol>
<br>

<h3 id="2-mutex">2. Mutex</h3>
<p><strong>1. 원리</strong></p>
<ul>
<li><strong>Blocking/Sleep 방식</strong></li>
<li>lock/unlock 단계에서 spin lock을 통해 우선 guard를 얻음</li>
<li><strong>락을 못 얻으면 Sleeping Queue로 들어감</strong> → Context Switch 발생</li>
<li>락을 해제할 때 <strong>queue가 비어있지 않으면 대기하는 쓰레드를 깨움</strong></li>
</ul>
<p><strong>2. 특징</strong></p>
<ul>
<li>Binary Semaphore와 유사</li>
<li><strong>제어권</strong>을 명확하게 관리함<ul>
<li>thread가 다음 thread를 <code>unpark()</code>로 깨우며 제어권을 전달함     </li>
</ul>
</li>
</ul>
<p><strong>3. 사용</strong></p>
<ul>
<li>Critical Section이 길 때 유리</li>
</ul>
<blockquote>
<p>🚨 <strong>lock()에서 park() 직전에 scheduling이 되면?</strong> <br></p>
</blockquote>
<ol>
<li><code>lock()</code>에서 A 쓰레드가 guard를 해제함</li>
<li><code>park()</code>하기 전에, <strong>즉 sleep하지 않은 상태로 제어권이 B 쓰레드로 넘어감</strong></li>
<li>B 쓰레드가 작업을 마친 뒤, <code>unlock()</code>에서 <code>unpark(A)</code>를 수행해 queue에 있는 A 쓰레드를 깨우려고 함 </li>
<li><strong>A 쓰레드는 sleep 상태가 아니라 요청이 무시될 수 있음</strong></li>
<li>다시 A 쓰레드로 제어권이 넘어옴</li>
<li><code>park()</code>를 마저 실행함</li>
<li><strong>A를 unpark해줄 쓰레드가 없기 때문에, A 쓰레드는 영원히 깨어나지 못할 수도 있음 → deadlock</strong> <br></li>
</ol>
<p>*<em>→ OS 차원에서 원자적 SysCall을 제공해야 함
*</em></p>
<br>

<pre><code class="language-c">typedef struct __lock_t {
    int guard; // 보호장치
    int flag; // 실제 락
    queue_t *q; // sleep하는 프로세스들이 대기하는 queue
} lock_t;

void init(lock_t *lock) {
    lock-&gt;guard = 0;
    lock-&gt;flag = 0;
    queue_init(lock-&gt;q);
}

void lock(lock_t *lock) {
    // spin-lock 이용해 guard 획득
    while (testAndSet(&amp;lock-&gt;guard, 1) == 1);

    if (lock-&gt;flag == 0) {
        // lock 획득
        lock-&gt;flag = 1;
        // guard 해제
        lock-&gt;guard = 0;
    } else {
        queue_add(lock-&gt;q, gettid());

        // sleep 이전에 quard 먼저 해제
        // deadlock 발생 가능!
        lock-&gt;guard = 0;
        park();
    }
}

void unlock(lock_t *lock) {
    // spin-lock 이용해 guard 획득
    while (testAndSet(&amp;lock-&gt;guard, 1) == 1);

    if (queue_empty(lock-&gt;q)) {
        // lock 해제
        lock-&gt;flag = 0;
    } else {
        // queue에서 가장 오래 대기한 쓰레드 깨움
        unpark(queue_remove(lock-&gt;q));
    }
    lock-&gt;guard = 0;
}</code></pre>
<br>

<h3 id="3-conditional-variable">3. Conditional Variable</h3>
<p>1, 2번과 다르게, 각 쓰레드의 <strong>실행 순서를 보장</strong>하기 위해 CV가 이용됨</p>
<p><strong>1. 원리</strong></p>
<ul>
<li>조건이 만족될 때까지 락을 풀고 대기하는 <strong>대기실</strong></li>
<li>thread가 특정 조건을 만족하지 못하면, <strong>lock을 반납하고 Sleeping Queue로 들어감</strong></li>
<li>다른 thread가 작업을 진행하고 signal을 보내면, <strong>깨어나서 lock을 다시 잡고 작업을 수행함</strong></li>
</ul>
<p><strong>2. 특징</strong></p>
<ul>
<li>CV는 <strong>상태를 저장하지 않고</strong> 그저 thread를 잠재우는 queue의 역할만 수행함</li>
<li><strong>반드시 mutex와 함께 실행되어야 함</strong> → deadlock 방지</li>
<li><strong>while</strong>을 이용해 조건을 확인함</li>
</ul>
<br>

<pre><code class="language-c">void child() {
    lock(&amp;m); // mutex lock 획득   
    done = 1;
    signal(&amp;c); // 대기하는 thread 깨움
    unlock(&amp;m);
}

void parent() {
    lock(&amp;m);
    while (done == 0) {
        wait(&amp;c, &amp;m); // m 반납 -&gt; CV에 들어감 -&gt; 다시 일어나면 m 획득
    }
    unlock(&amp;m); 
}</code></pre>
<blockquote>
<h4 id="producer-consumer-problem">Producer-Consumer Problem</h4>
</blockquote>
<ul>
<li>Producer : 빵을 생산하는 쓰레드<ul>
<li>empty : producer가 대기하는 CV</li>
</ul>
</li>
<li>Consumer : 빵을 소비하는 쓰레드<ul>
<li>fill : consumer가 대기하는 CV<pre><code class="language-c">void *producer(void *arg) {
// 정해진 분량만큼 빵을 생산함 (loop개만큼)
for (int i = 0; i &lt; loops; i++) {
  lock(&amp;m);
  // 빵을 최대치만큼 만들었을 경우 (남은 버퍼가 없을 경우)
  while (count == max) {
      // empty라는 CV에 producer을 넣고 대기함
      wait(&amp;empty, &amp;m);
  }
  // 빵 생산
  put(i);
  // fill이라는 consumer의 CV에서 한 쓰레드를 깨움
  signal(&amp;fill);
  unlock(&amp;m);
}
}</code></pre>
<pre><code class="language-c">void *consumer(void *arg) {
// 버퍼에 데이터가 들어오면 처리
while (1) {
  lock(&amp;m);    
  // 빵이 더이상 없을 경우 (읽을 데이터가 없을 경우)
  while (count == 0) {
      // fill이라는 CV에 consumer 넣고 대기
      wait(&amp;fill, &amp;m);
  }
  // 빵 소비
  get();
  // empty라는 producer의 CV에서 한 쓰레드를 깨움
  signal(&amp;empty);
  unlock(&amp;m);
}
}</code></pre>
</li>
</ul>
</li>
</ul>
<br>

<h3 id="4-semaphores">4. Semaphores</h3>
<p>CV에 더해서 <strong>상호 배제 + 실행 순서 보장</strong>을 위해 Semaphore가 이용됨</p>
<p><strong>1. 원리</strong></p>
<ul>
<li>Integer Value로 <strong>State</strong>를 관리함<ul>
<li><code>wait()</code> : 조건을 검사하고 sleep하거나 value를 1 감소시킴</li>
<li><code>post()</code> : value를 1 증가시키고 대기하는 쓰레드를 깨움</li>
</ul>
</li>
</ul>
<p><strong>2. 특징</strong></p>
<ul>
<li>semaphore은 Lock + CV로 구현할 수 있음</li>
<li>value의 초기값 설정에 따라 다른 기능을 구현할 수 있음<ul>
<li>1로 초기화 → binary semaphore → mutex와 같은 상호 배제 기능 수행</li>
<li>n으로 초기화 → counting semaphore → 자원 개수 관리</li>
</ul>
</li>
<li>소유권 제어가 없음<ul>
<li>락을 건 사람이 아닌 다른 사람이 락을 해제할 수도 있음</li>
<li>mutex와의 결정적인 차이점</li>
<li>이 특징 덕분에 ordering이 가능해짐</li>
</ul>
</li>
</ul>
<pre><code class="language-c">/**
    Semaphore을 LOCK + CV로 구성
**/
typedef struct __sem_t {
    int value;
    lock_t lock;
    cond_t cond; // CV
} sem_t;

void init(sem_t *sem, int value) {
    sem-&gt;value = value; // 허용할 자원 개수 초기화
    lock_init(&amp;sem-&gt;lock);
    cond_init(&amp;sem-&gt;cond);
}

void wait(sem_t *sem) {
    lock(sem-&gt;lock);

    while (sem-&gt;value &lt;= 0)
        cond_wait(&amp;sem-&gt;cond);

    sem-&gt;value--;
    unlock(sem-&gt;lock);
}

void post(sem_t *sem) {
    lock(sem-&gt;lock);
    sem-&gt;value++;
    cond_signal(&amp;sem-&gt;cond);
    unlock(sem-&gt;lock);
}</code></pre>
<pre><code class="language-c">/**
    Mutex(Lock)을 Binary Semaphore로 구성
    wait =&gt; lock (1 -&gt; 0)
    post =&gt; unlock (0 -&gt; 1)
**/
typedef struct __lock_t {
    sem_t sem;
} lock_t;

void init(lock_t *lock) {
    // 1로 초기화: 최대 하나의 쓰레드가 접근할 수 있어야 함
    sem_init(&amp;lock-&gt;sem, 1);
}

void lock(lock_t *lock) {
    // sem의 value -1
    wait(&amp;lock-&gt;sem);
}

void unlock(lock_t *lock) {
    // sem의 value +1
    post(&amp;lock-&gt;sem);
}</code></pre>
<p><br><br></p>
<h2 id="3-고수준-언어-차원의-도구">3. 고수준 언어 차원의 도구</h2>
<hr>
<p>개발자가 쓰기 편하게 프로그래밍 언어 차원에서 도구를 제공함</p>
<h3 id="1-monitors">1. Monitors</h3>
<p><strong>1. 특징</strong></p>
<ul>
<li>ADT : 공유 변수와 메서드를 하나의 클래스로 묶음</li>
<li>컴파일러/언어가 lock 관리를 자동으로 해줌</li>
<li>언어 차원에서 <strong>상호배제</strong>를 관리해줌</li>
<li>Ordering이 필요할 때는 CV를 이용함</li>
</ul>
<p><strong>2. 사용</strong></p>
<ul>
<li>Java : <code>synchronized</code></li>
</ul>
<pre><code class="language-java">public class SynchronizedCounter {
    private int c = 0;
    public synchronized void increment() {
        c++;
    }
    public synchronized void decrement() {
        c--;
    }
    public synchronized int value() {
        return c;
    }
}</code></pre>
<br>

<h4 id="semantics">Semantics</h4>
<p>Ordering을 위해 CV를 이용하는 상황에서, <code>signal()</code>을 보낸 쓰레드와 깨어난 쓰레드 간의 <strong>제어권 이동 방식의 차이</strong></p>
<p><strong>1. Hoare Semantics</strong></p>
<ul>
<li>signal-and-wait</li>
<li>실행 중인 쓰레드는 block하고, <strong>깨어난 쓰레드에게 바로 제어권이 넘어감</strong></li>
<li>따라서, if절로 조건을 확인해도 무방함</li>
<li>오버헤드가 큼</li>
</ul>
<pre><code class="language-c">if (conditions_not_met)
    wait();</code></pre>
<p><strong>2. Mesa Semantics</strong></p>
<ul>
<li>signal-and-continue</li>
<li>실행 중인 쓰레드는 이어서 실행되고, 깨어난 쓰레드는 <strong>대기 큐에 들어가 lock을 잡기 위해 경</strong>쟁 함</li>
<li>hoare보다 더 효율적이고 현대 OS에서 더 많이 사용되는 방식</li>
</ul>
<pre><code class="language-c">while (conditions_not_met)
    wait();</code></pre>
<p><br><br></p>
<h2 id="4--비교">4.  비교</h2>
<hr>
<h4 id="mutex-vs-binary-semaphores">Mutex vs Binary Semaphores</h4>
<table>
<thead>
<tr>
<th></th>
<th><strong>Mutex</strong></th>
<th><strong>Binary Semaphore</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>핵심 개념</strong></td>
<td><strong>Locking</strong></td>
<td><strong>Signaling</strong></td>
</tr>
<tr>
<td><strong>소유권</strong></td>
<td><strong>있음</strong></td>
<td><strong>없음</strong></td>
</tr>
<tr>
<td><strong>해제 주체</strong></td>
<td>락을 건 스레드 <strong>본인만</strong> 해제 가능</td>
<td><strong>누구든지</strong> 해제 가능</td>
</tr>
<tr>
<td><strong>목적</strong></td>
<td>상호 배제</td>
<td>순서 제어 + 상호 배제</td>
</tr>
<tr>
<td>우선순위 역전 문제</td>
<td>해결 가능</td>
<td>해결 어려움</td>
</tr>
</tbody></table>
<br>

<h4 id="cv-vs-semaphores">CV vs Semaphores</h4>
<table>
<thead>
<tr>
<th></th>
<th><strong>Condition Variable</strong></th>
<th><strong>Semaphore</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>핵심 개념</strong></td>
<td>조건 대기 큐</td>
<td>자원 카운터</td>
</tr>
<tr>
<td><strong>상태</strong></td>
<td>없음</td>
<td>있음</td>
</tr>
<tr>
<td><strong>Lock 필요 여부</strong></td>
<td><strong>필수</strong></td>
<td><strong>불필요</strong></td>
</tr>
<tr>
<td><strong>동작 방식</strong></td>
<td><code>Wait</code> (Unlock+Sleep) / <code>Signal</code></td>
<td><code>P</code> (Dec &amp; Wait) / <code>V</code> (Inc &amp; Wake)</td>
</tr>
<tr>
<td>목적</td>
<td><strong>순서 제어</strong></td>
<td><strong>자원 관리 + 상호 배제</strong></td>
</tr>
<tr>
<td><strong>비유</strong></td>
<td>직원이 부를 때까지 대기실에서 쉼</td>
<td>식권 자판기 (식권 있으면 입장, 없으면 대기)</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SpringBoot] Kotlin + Spring에 @Cacheable 적용하기]]></title>
            <link>https://velog.io/@dooo_it_ly/SpringBoot-Kotlin-Spring%EC%97%90-Cacheable-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/SpringBoot-Kotlin-Spring%EC%97%90-Cacheable-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 17 Nov 2025 07:04:35 GMT</pubDate>
            <description><![CDATA[<p>외국인 유학생을 위한 AI 기반 한국어 학습 서비스, <strong>LearnMate</strong> 개발기입니다!</p>
<p>AppStore 👉 <a href="https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353">https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353</a></p>
<br>


<p>LearnMate 홈 화면에서는 한국어 학습 현황을 확인할 수 있습니다. 이 정보는 새로운 레벨을 클리어해야만 업데이트됩니다. </p>
<p>즉, Write보다 Read가 훨씬 빈번하게 발생하는 구조인 것이죠. 이러한 트래픽 패턴을 고려해 캐싱 전략을 적용하여 조회 성능을 개선했습니다.</p>
<p><br><br></p>
<h2 id="1-기존-로직">1. 기존 로직</h2>
<p>학습 현황 조회 API의 기존 로직입니다. </p>
<ol>
<li>모든 course list를 조회</li>
<li>userId를 이용해 유저가 풀이한 <code>stepProgressStep</code> 리스트를 Set으로 변환</li>
<li>course list를 순회하며 각 course가 stepProgressStepSet 안에 존재하는지를 판단하며 상태 체크</li>
<li>결과 dto list 반환</li>
</ol>
<pre><code class="language-kotlin">override fun getCourseInfo(userId: Long): CourseListDto {
        val courseList = CourseType.getAllCourseList()
        val completedStepSet = getAllCompletedStepTypeSet(userId)

        val courseDtoList = courseList.map {

            val courseStatus = getCourseStatus(it, completedStepSet)
            val stepDtoList = getStepDtoList(it, courseStatus, completedStepSet)
            val progress = getCourseProgress(stepDtoList)

            CourseDto.toCourseDto(
                course = it,
                stepList = stepDtoList,
                progress = progress,
                courseStatus = courseStatus
            )
        }

        return CourseListDto.toCourseListDto(courseDtoList)
    }</code></pre>
<p>DB 접근 횟수가 많은 로직은 아니지만, list를 순회하고 각 요소에 대해 3번의 상태 체크를 수행합니다.</p>
<p>때문에 조회 요청이 들어올 때마다 이러한 동일한 결과 데이터를 산출하기 위해 불필요한 작업이 수행되게 됩니다.</p>
<p><br><br></p>
<h2 id="2-redis-도입">2. Redis 도입</h2>
<p>캐시 도입 전략은 다음과 같습니다.</p>
<ol>
<li>해당 API 요청이 들어올 때 캐시 확인</li>
<li>Hit -&gt; 값 리턴</li>
<li>Miss -&gt; 로직 수행 후 캐시에 값 저장</li>
<li>학습 마무리 API 요청 시 기존 캐시 evict</li>
</ol>
<br>

<h3 id="1-cacheable">1. @Cacheable</h3>
<p><a href="https://docs.spring.io/spring-boot/reference/io/caching.html">https://docs.spring.io/spring-boot/reference/io/caching.html</a>
스프링은 AOP를 기반으로 작동하는 캐시 기능을 제공하며, 다양한 캐시 저장소와 호환됩니다.</p>
<p>또한 어노테이션을 기반으로 동작하기 때문에 비즈니스 로직과 캐시 로직이 분리되어 코드 결합도가 느슨해진다는 장점도 있습니다.</p>
<br>

<h3 id="2-의존성-추가">2. 의존성 추가</h3>
<pre><code class="language-kotlin">implementation(&quot;org.springframework.boot:spring-boot-starter-data-redis&quot;)
implementation(&quot;org.springframework.boot:spring-boot-starter-cache&quot;)</code></pre>
<br>

<h3 id="3-캐시-기능-활성화">3. 캐시 기능 활성화</h3>
<p><code>@EnableCaching</code> 어노테이션을 추가해 스프링 캐시 기능을 사용함을 명시해줍니다.
해당 어노테이션은 4번에 나올 config에 붙여도 무관합니다.</p>
<pre><code class="language-kotlin">@EnableCaching // 추가
@EnableJpaAuditing
@SpringBootApplication
class DevApplication

fun main(args: Array&lt;String&gt;) {
    runApplication&lt;DevApplication&gt;(*args)
}</code></pre>
<br>

<h3 id="4-redis-캐시-설정">4. Redis 캐시 설정</h3>
<p>Redis에 DTO를 어떤 <strong>형식</strong>으로 넣을 것인지 명시해주어야 합니다.</p>
<pre><code class="language-kotlin">@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            // String으로 Key 직렬화
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
            )
            // JSON으로 Value 직렬화
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer())
            )
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }
}</code></pre>
<br>

<h3 id="5-캐시-읽기">5. 캐시 읽기</h3>
<p>캐시 기능을 도입할 함수 위에 <code>@Cacheable</code> 어노테이션을 명시해줍니다.
<strong>#</strong>를 이용해 <strong>userId</strong>를 key값으로 명시하고, courses라는 이름으로 하여 캐싱하도록 설정했습니다.</p>
<pre><code class="language-kotlin">    @Cacheable(cacheNames = [&quot;courses&quot;], key = &quot;#userId&quot;)
    override fun getCourseInfo(userId: Long): CourseListDto {
        val courseList = CourseType.getAllCourseList()
        val completedStepSet = getAllCompletedStepTypeSet(userId)
        ...
       }</code></pre>
<br>

<h3 id="6-캐시-삭제">6. 캐시 삭제</h3>
<p><code>endStep()</code>이 호출될 때, 즉 course를 마무리할 때 학습 현황이 update됩니다.
따라서 해당 함수에 <code>@CacheEvict</code>를 명시해 <strong>#userId</strong> 키를 가진 캐시를 삭제합니다.</p>
<pre><code class="language-kotlin"> @CacheEvict(cacheNames = [&quot;courses&quot;], key = &quot;#userId&quot;)
 @Transactional
 override fun endStep(userId: Long, stepProgressId: Long) {
        val stepProgress = getStepProgress(stepProgressId, userId)
        stepProgress.ensureNotCompleted()
        stepProgress.completeStep()
    }</code></pre>
<p>추가로, <code>@CachePut</code> 이라는 어노테이션도 존재하는데요, 이는 함수의 <strong>리턴 값</strong>을 캐시의 내용으로 변환하는 것입니다.</p>
<p>해당 함수는 리턴값이 없으므로 Put 대신 Evict 기능을 이용했습니다.</p>
<p><br><br></p>
<h2 id="3-트러블-슈팅">3. 트러블 슈팅</h2>
<h3 id="1-문제">1. 문제</h3>
<p>실행하니 다음과 같은 <strong>SerializationException</strong>이 발생했습니다.</p>
<pre><code class="language-plain">org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Cannot construct instance of `learn_mate_it.dev.domain.course.application.dto.response.CourseListDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 84] 
</code></pre>
<br>

<h3 id="2-원인">2. 원인</h3>
<p>Jackson이 JSON을 객체로 변환할 때, 기본적으로 다음과 같은 순서로 작동합니다.</p>
<ol>
<li><code>CourseListDto</code>의 기본 생성자를 이용해 빈 껍데기 객체를 먼저 생성</li>
<li>JSON의 필드명과 객체의 프로퍼티명을 비교하여 값을 채워 넣음</li>
</ol>
<p>그런데 Kotlin의 <code>data class</code>는 주 생성자에 선언된 프로퍼티를 기반으로 만들어지기 때문에, <strong>별도로 설정하지 않으면 기본 생성자를 만들 방법이 없습니다.</strong> 그래서 Jackson이 1단계에서 실패하고 예외를 던지는 겁니다.</p>
<br>

<h3 id="3-시도-1---jackson-module">3. 시도 1 - jackson module</h3>
<p>위의 문제를 해결하기 위해, 아래 모듈을 도입했습니다.</p>
<p>해당 모듈을 도입함으로써 기본 생성자 없이 <code>data class</code>의 <strong>주생성자를 이용</strong>해 JSON 데이터를 객체로 변환할 수 있음을 기대했습니다.</p>
<pre><code class="language-kotlin">implementation(&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;)</code></pre>
<br>

<p>그러나, 다시 돌려보니 동일한 문제가 발생했습니다.</p>
<p>RedisConfig에서 <code>StringRedisSerializer()</code>을 new를 이용해 생성하면, 이는 스프링이 관리하고 코틀린 모듈을 아는 ObjectMapper가 아니라, 코틀린을 모르는 <strong>순수 자바용 ObjectMapper가 생성</strong>됩니다.</p>
<p>때문에 새롭게 추가한 모듈을 읽지 못해 동일한 오류가 나는 것이었고, 따라서 Serializer이 코틀린을 알도록 수정해야 합니다.</p>
<br>

<h3 id="4-시도-2---redis-config-object-mapper-등록">4. 시도 2 - Redis Config Object Mapper 등록</h3>
<p>Redis Config에서 직접 <strong>ObjectMapper을 생성</strong>한 후 <strong>Kotlin Module을 등록</strong>했습니다.</p>
<p>이후, <code>GenericJackson2JsonRedisSerializer()</code>의 생성자에 kotlin module이 등록된 <strong>ObjectMapper을 넘겨줌</strong>으로써 3번의 문제를 해결했습니다.</p>
<pre><code class="language-kotlin">@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
        val kotlinModule = KotlinModule.Builder().build()

        // 1. Object Mapper 생성 및 Kotlin Module 등록
        val objectMapper = ObjectMapper()
            .registerModule(kotlinModule)

        // 2. Serializer의 생성자에 직접 생성한 objectMapper 전달
        val jsonSerializer = GenericJackson2JsonRedisSerializer(objectMapper)

        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
            )
            .serializeValuesWith(
                // 3. 생성한 jsonSerializer 이용               
                RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)
            )
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }

}</code></pre>
<br>

<p>이걸로 문제가 해결되는 줄 알았습니다 ㅎㅎ
실행해보니 다음과 같은 <strong>ClassCastException</strong>이 발생했습니다.</p>
<pre><code class="language-plain">java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class learn_mate_it.dev.domain.course.application.dto.response.CourseListDto (java.util.LinkedHashMap is in module java.base of loader &#39;bootstrap&#39;; learn_mate_it.dev.domain.course.application.dto.response.CourseListDto is in unnamed module of loader &#39;app&#39;)
at learn_mate_it.dev.domain.course.application.service.impl.CourseServiceImpl$$SpringCGLIB$$0.getCourseInfo(&lt;generated&gt;)
at</code></pre>
<p>Redis에 들어간 데이터를 확인해보니, <strong>해당 타입의 정보 없이</strong> 데이터만 들어가있었습니다.</p>
<p>그래서 역직렬화 할 때, 타입 정보가 없으니 가장 만만한 LinkedHashMap으로 만든 후, 이를 courseListDto로 만들려다가 ClassCastException이 발생한 것입니다.</p>
<p>따라서, <strong>타입 정보를 Redis에 같이 저장</strong>해야 역직렬화 문제를 해결할 수 있습니다.</p>
<br>

<h3 id="5-시도-3---타입-정보-저장">5. 시도 3 - 타입 정보 저장</h3>
<p>우선, 시도 1에서 추가한 dependency 대신 <code>.registerKotlinModule()</code>를 이용해 코틀린 모듈을 명시하도록 수정했습니다.</p>
<p>그리고 <code>activateDefaultTyping()</code>를 이용해 해당 타입의 정보를 같이 명시하도록 설정했습니다.</p>
<p>수정된 RedisConfig의 objectMapper 부분은 다음과 같습니다. </p>
<pre><code class="language-kotlin">val objectMapper = ObjectMapper()
            .registerKotlinModule()
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                    .allowIfBaseType(Any::class.java)
                    .build(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
            )</code></pre>
<br>

<p>다시 실행해보니 또 <strong>SerializationException</strong>이 발생했습니다! ㅎㅎ</p>
<pre><code class="language-plain">org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Unexpected token (START_OBJECT), expected START_ARRAY: need Array value to contain `As.WRAPPER_ARRAY` type information for class java.lang.Object
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1] 
at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:305)</code></pre>
<p>타입 정보를 이용해 역직렬화 하라고 명시했음에도, 데이터를 저장할 때 <code>@class</code>와 같은 타입 정보가 저장되지 않아 역직렬화가 실패한 것으로 보입니다.</p>
<br>

<p>저는 <code>data class</code> 타입의 dto를 사용하고 있었는데, 코틀린은 <code>data class</code>를 <strong>final</strong>로 취급합니다. </p>
<p>그래서 config의 <code>ObjectMapper.DefaultTyping.NON_FINAL</code> &lt;- 이 부분 때문에 dto class의 타입 정보가 들어가지 않았습니다.  </p>
<br>

<p>해당 문제를 해결하기 위해, 아래와 같은 방법을 고려했습니다.</p>
<p>*<em>1. DefaultTyping의 타입 완화 
2. data class -&gt; class로 변환 *</em></p>
<p>1번에서 DefaultTying을 <code>EVERYTHING</code>으로 바꾸는 것을 고려했습니다. 그런데 deprecated되었다고 해서 대신 <code>NON_FINAL_AND_ENUM</code> 으로 변경하고 2번 방법을 채택하기로 했습니다.</p>
<br>

<h3 id="6-해결---data-class---open-class">6. 해결 - data class -&gt; open class</h3>
<p>최종적으로 리턴할 dto의 타입을 <code>open class</code>로 변환하면서 문제가 해결되었습니다. 코틀린은 class도 내부적으로 final로 취급하기 때문에 <code>open</code> 키워드를 붙여주었습니다. </p>
<p>최종 Redis Config의 코드는 다음과 같습니다. </p>
<pre><code class="language-kotlin">@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager {
        val objectMapper = ObjectMapper()
            .registerKotlinModule()
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                    .allowIfBaseType(Any::class.java)
                    .build(),
                ObjectMapper.DefaultTyping.NON_FINAL_AND_ENUM,
                JsonTypeInfo.As.PROPERTY
            )

        val jsonSerializer = GenericJackson2JsonRedisSerializer(objectMapper)

        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }
}</code></pre>
<p><br><br></p>
<h2 id="4-성능-테스트">4. 성능 테스트</h2>
<p>간단하게 캐시를 도입하기 전과 이후의 조회 성능을 로컬에서 JMeter을 이용해 테스트해봤습니다. DB의 Row는 약 24000개입니다.</p>
<p>해당 API를 위한 조회 쿼리는 아래 포스팅과 같이 이미 인덱스가 붙은 상태입니다. 
<a href="https://velog.io/@dooo_it_ly/DB-PostgreSQL-Partial-Index%EB%A1%9C-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0">https://velog.io/@dooo_it_ly/DB-PostgreSQL-Partial-Index로-쿼리-성능-개선하기</a></p>
<h3 id="1-캐시-도입-이전">1. 캐시 도입 이전</h3>
<blockquote>
<p><strong>동시에 5000명이 동시에 요청을 보낼 때의 시간 지표</strong>
평균 요청 처리 시간 : <strong>150ms</strong>
최소 요청 처리 시간 : 2ms
최대 요청 처리 시간 : 940ms
에러 발생 확률 : 0%</p>
</blockquote>
<br>

<h3 id="2-캐시-도입-이후">2. 캐시 도입 이후</h3>
<blockquote>
<p><strong>동시에 5000명이 동시에 요청을 보낼 때의 시간 지표</strong>
평균 요청 처리 시간 : <strong>30ms</strong>
최소 요청 처리 시간 : 1ms
최대 요청 처리 시간 : 254ms
에러 발생 확률 : 0%</p>
</blockquote>
<br>




]]></description>
        </item>
        <item>
            <title><![CDATA[[CS/OS] File System]]></title>
            <link>https://velog.io/@dooo_it_ly/CSOS-File-System</link>
            <guid>https://velog.io/@dooo_it_ly/CSOS-File-System</guid>
            <pubDate>Fri, 14 Nov 2025 06:26:20 GMT</pubDate>
            <description><![CDATA[<h1 id="1-file-system">1. File System</h1>
<hr>
<h2 id="1-file">1. File</h2>
<ul>
<li><strong>byte 단위의 linear한 배열</strong></li>
<li>메모리와 다르게 <strong>persistent</strong>함</li>
<li>읽고 쓸 수 있음</li>
<li><strong>일반파일, 디렉토리, 링크, 특수파일</strong></li>
</ul>
<br>

<h2 id="2-file-system">2. File System</h2>
<ul>
<li>OS가 File을 관리하고 디스크 상에 구성하는 방식</li>
</ul>
<table>
<thead>
<tr>
<th>파일 시스템 종류</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>EXT</td>
<td>리눅스 초기에 사용되던 파일 시스템</td>
</tr>
<tr>
<td>EXT2</td>
<td>현재 가장 많이 사용하는 파일 시스템, fsck 지원</td>
</tr>
<tr>
<td>EXT3</td>
<td>EXT2 파일 시스템의 수정 버전, 저널링 지원</td>
</tr>
<tr>
<td>minix</td>
<td>과거 Minix에서 사용된 파일 시스템으로 가장 오래되고 기본이 됨</td>
</tr>
<tr>
<td>hpfs OS/2</td>
<td>OS/2의 파일 시스템이며 현재는 읽기 전용임</td>
</tr>
<tr>
<td>isofs CD-ROM</td>
<td>ISO 기준을 따르는 표준 CD-ROM의 파일 시스템, 록 브릿지가 기본적으로 지원됨</td>
</tr>
<tr>
<td>nfs</td>
<td>네트워크 파일 시스템</td>
</tr>
<tr>
<td>JFS</td>
<td>IBM사가 개발한 저널링 파일 시스템</td>
</tr>
</tbody></table>
<p><br><br></p>
<h1 id="2-file의-유형">2. File의 유형</h1>
<hr>
<h2 id="1-일반-파일">1. 일반 파일</h2>
<ul>
<li>텍스트, C언어 소스 코드, 쉘 스크립트, 바이너리 프로그램</li>
<li>모든 것은 파일로 취급됨</li>
</ul>
<br>

<h2 id="2-directory">2. Directory</h2>
<ul>
<li>디렉토리의 이름 + 파일 및 디렉토리에 대한 <strong>포인터</strong>를 가진 파일</li>
<li>파일의 <strong>이름과 inode 번호 목록</strong>만 가지고 있음<ul>
<li>&lt;user가 읽을 수 있는 파일이름, 시스템이 읽을 수 있는 파일이름&gt;</li>
</ul>
</li>
<li>파일의 경로, 이름이 저장되는 유일한 장소임</li>
<li>dir은 서로 <strong>중첩</strong>될 수 있음</li>
</ul>
<table>
<thead>
<tr>
<th>path name</th>
<th>inode number</th>
</tr>
</thead>
<tbody><tr>
<td>. (현재 dir)</td>
<td>342</td>
</tr>
<tr>
<td>.. (상위 dir)</td>
<td>8310</td>
</tr>
<tr>
<td>bar.txt</td>
<td>23</td>
</tr>
<tr>
<td>project (하위 dir)</td>
<td>3048</td>
</tr>
</tbody></table>
<br>


<h2 id="3-link">3. Link</h2>
<h3 id="1-hard-link">1. Hard Link</h3>
<ul>
<li><strong>두 path name은 동일한 inode number를 사용함</strong><ul>
<li>하나의 데이터에 여러 개의 이름을 붙인다</li>
<li>a, b 파일이 hard linked 되었다면 두 파일은 <strong>동일</strong>함</li>
</ul>
</li>
<li>만들 때마다 inode의 reference count가 하나씩 증가함</li>
<li>dir에 대해서 link할 수 없음 - 순환 구조 방지</li>
</ul>
<blockquote>
<p>🕹️ <code>ln origin hardlink</code></p>
</blockquote>
<ol>
<li>origin이 가리키는 <strong>inode numbe</strong>r을 확인 → 30</li>
<li>hardlink라는 <strong>새로운 경로 생성</strong></li>
<li>해당 경로가 <strong>30번 inode를 가리키도록</strong> directory entry에 추가함</li>
<li>inode의 <strong>reference count</strong>를 1 증가시킴</li>
<li><code>ls -li</code> → reference count가 2</li>
</ol>
<br>

<h3 id="2-soft--symbolic-link">2. Soft / Symbolic Link</h3>
<ul>
<li><strong>second path name</strong>을 가리킴<ul>
<li>원본 파일의 경로 정보</li>
</ul>
</li>
<li><strong>dangling reference</strong></li>
<li>다른 parent를 가짐</li>
<li>dir에 대해서 link할 수 있음</li>
<li>파일 타입 = <code>l</code></li>
<li>inode가 아닌 <strong>경로</strong>를 이용함<ul>
<li>서로 다른 하드 드라이브, 파티션 간에도 링크 가능</li>
<li>하드 링크는 동일한 파티션(FS) 내에서만 가능함</li>
</ul>
</li>
</ul>
<blockquote>
<p>🕹️ <code>ln -s /path/to/origin softlink</code></p>
</blockquote>
<ol>
<li>softlink라는 <strong>새로운 파일을 생성함</strong></li>
<li>origin을 가리키는 inode와는 별개의 <strong>다른 inode number을 할당받음</strong></li>
<li>softlink 파일의 내용은 <strong>origin의 경로 이름으로 저장됨</strong> <code>(&quot;/path/to/origin&quot;)</code></li>
</ol>
<blockquote>
<p>💡 <strong>사용자가 심볼릭 링크를 열려고 하면?</strong></p>
</blockquote>
<ol>
<li>파일의 타입 읽음</li>
<li><code>l</code>이므로 해당 파일의 내용을 읽음</li>
<li>내용 == 원본 파일의 경로이므로, 경로에 있는 파일을 open</li>
</ol>
<table>
<thead>
<tr>
<th></th>
<th>Hard Link</th>
<th>Soft Link</th>
</tr>
</thead>
<tbody><tr>
<td>명령어</td>
<td><code>ln origin_file link_name</code></td>
<td><code>ln -s origin_file link_name</code></td>
</tr>
<tr>
<td>Inode</td>
<td>동일</td>
<td>다름</td>
</tr>
<tr>
<td>내용</td>
<td>동일</td>
<td>원본의 경로</td>
</tr>
<tr>
<td>directory</td>
<td>X (순환 참조 위험)</td>
<td>O</td>
</tr>
<tr>
<td>파티션</td>
<td>동일한 파티션 내에서만</td>
<td>다른 파티션/드라이브 가능</td>
</tr>
<tr>
<td>원본 삭제 시</td>
<td>reference count가 0이 될 때까지 데이터 유지</td>
<td>참조 불가 (링크 깨짐)</td>
</tr>
</tbody></table>
<p><br><br></p>
<h1 id="3-file의-이름">3. File의 이름</h1>
<hr>
<h2 id="1-inode-number">1. inode number</h2>
<ul>
<li><strong>inode : 파일에 대한 정보</strong>를 저장하는 구조체</li>
<li><strong>각 파일은 하나의 inode number를 가지며, 이는 파일 시스템 내에서 unique함</strong><ul>
<li>동일한 파일 시스템에서 서로 다른 파일이 같은 inode number을 가진다면 link를 이용한 것임</li>
<li>다른 파일 시스템에서는 서로 다른 파일의 inode number가 같을 수 있음</li>
</ul>
</li>
<li>파일 시스템은 파일의 이름이 아닌 inode number을 이용함</li>
<li>파일과 inode 간의 연결을 삭제하고, inode를 재사용할 수 있음</li>
</ul>
<br>

<h2 id="2-path">2. path</h2>
<ul>
<li>문자열로 파일의 주소를 나타냄</li>
<li>inode number보다 친숙함 → 사용자 중심</li>
<li>directory에 <strong>파일의 문자열, 파일의 inode 정보</strong>가 맵핑됨</li>
<li>경로는 tree 형태의 계층적 구조로 저장됨
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/78637ea7-db37-4a36-8d10-d5825b10fc40/image.png" alt=""></li>
</ul>
<br>


<blockquote>
<p>🏃 <strong><code>/bar/file.txt</code> 파일을 찾는 과정</strong></p>
</blockquote>
<ol>
<li>보통 1번 혹은 2번 inode는 root inode로 지정됨 (FS마다 상이)</li>
<li>2번 inode를 읽고 해당하는 데이터 블락을 읽음</li>
<li><code>/</code> directory의 엔트리에서 <code>bar</code>에 해당하는 inode number 찾음 (8)</li>
<li>8번 inode를 읽고 해당하는 데이터 블락을 읽음</li>
<li><code>bar</code> directory의 엔트리에서 <code>file.txt</code>에 해당하는 inode number 찾음 (15)</li>
<li>15번 inode를 읽고 해당하는 데이터 블락을 읽음</li>
<li><code>file.txt</code>의 정보를 읽음!</li>
</ol>
<br>

<h2 id="3-file-descriptor">3. file descriptor</h2>
<ul>
<li>path는 final node를 찾기 위해 traval해야 하기 때문에, 이 횟수를 한 번으로 줄이고자 함</li>
<li>file descriptor을 open하고, fd에 <strong>inode에 대한 포인터와 offset</strong>을 저장함<ul>
<li>disk 대신 fd를 이용해 I/O 작업을 수행 → cost 감소
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/4566ec26-9fff-4835-b0aa-123bff848353/image.png" alt=""></li>
</ul>
</li>
</ul>
<p><br><br></p>
<h1 id="4-disk-공간-할당">4. Disk 공간 할당</h1>
<hr>
<h2 id="1-contiguous-allocation">1. Contiguous Allocation</h2>
<ul>
<li>disk의 <strong>연속된 sector</strong>에 각 파일을 할당함</li>
<li>meta data<ul>
<li>시작 블록</li>
<li>파일 크기</li>
</ul>
</li>
<li>OS가 free space를 찾아 할당함</li>
<li>IBM OS/360</li>
<li>장점<ul>
<li>sequential access 비용이 적음</li>
<li>overhead 적음</li>
</ul>
</li>
<li>단점<ul>
<li>external fragmentation이 많이 발생함</li>
<li>빈 공간을 찾아 할당하는 과정이 비효율적일 수도 있음</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>fragmentation</th>
<th>external fragmentation</th>
<th>❌</th>
</tr>
</thead>
<tbody><tr>
<td>파일의 크기를 키울 수 있는가</td>
<td>일부는 가능</td>
<td>➖</td>
</tr>
<tr>
<td>sequential access의 비용</td>
<td>좋음</td>
<td>✅</td>
</tr>
<tr>
<td>random access의 속도가 어떤가</td>
<td>연산 쉬움</td>
<td>✅</td>
</tr>
<tr>
<td>meta data의 overhead가 어떤가</td>
<td>overhead 적음</td>
<td>✅</td>
</tr>
</tbody></table>
<br>

<h2 id="2-small--of-extent">2. Small # of Extent</h2>
<ul>
<li><p>파일 별로 연속된 region을 여러 개 할당함</p>
</li>
<li><p>meta data</p>
<ul>
<li>각 extent를 나타내는 entry를 가지는 array</li>
<li>각 entry : 시작 블록, 파일 크기</li>
</ul>
</li>
<li><p>장점</p>
<ul>
<li>sequential access 비용이 적음</li>
<li>overhead 적음</li>
<li>1번 방식보다 fragmentation 완화</li>
</ul>
</li>
<li><p>단점</p>
<ul>
<li><p>로그와 같이 계속해서 확장하는 파일을 담기는 어려움</p>
</li>
<li><p>external fragmentation</p>
<p>  → Linked Allocation / Indexed Allocation으로 개선</p>
</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>fragmentation</th>
<th>external fragmentation 조금 완화</th>
<th>❌</th>
</tr>
</thead>
<tbody><tr>
<td>파일의 크기를 키울 수 있는가</td>
<td>extent 한계까지 가능</td>
<td>➖</td>
</tr>
<tr>
<td>sequential access의 비용</td>
<td>굿</td>
<td>✅</td>
</tr>
<tr>
<td>random access의 속도가 어떤가</td>
<td>간단함</td>
<td>✅</td>
</tr>
<tr>
<td>meta data의 overhead가 어떤가</td>
<td>조금</td>
<td>✅</td>
</tr>
</tbody></table>
<br>

<h2 id="3-linked-allocation">3. Linked Allocation</h2>
<ul>
<li><strong>고정된 크기</strong>의 블락 단위로 linked-list 구조 할당</li>
<li>meta data<ul>
<li>head의 위치</li>
<li>다음 블락을 가리키는 포인터</li>
</ul>
</li>
<li>TOPS-10, Alto</li>
</ul>
<table>
<thead>
<tr>
<th>fragmentation</th>
<th>없음</th>
<th>✅</th>
</tr>
</thead>
<tbody><tr>
<td>파일의 크기를 키울 수 있는가</td>
<td>가능</td>
<td>✅</td>
</tr>
<tr>
<td>sequential access의 비용</td>
<td>데이터 구조에 따라 다름</td>
<td>➖</td>
</tr>
<tr>
<td>random access의 속도가 어떤가</td>
<td>아주 나쁨</td>
<td>❌</td>
</tr>
<tr>
<td>meta data의 overhead가 어떤가</td>
<td>(블록 개수 * 포인터 크기) 만큼 낭비가 생김</td>
<td>❌</td>
</tr>
</tbody></table>
<br>

<h2 id="4-file-allocation-table">4. File Allocation Table</h2>
<ul>
<li>linked allocation의 확장<ul>
<li>FAT 테이블에 모든 파일에 대한 linked list 정보를 저장함</li>
</ul>
</li>
<li>meta data<ul>
<li>파일의 첫 번째 블락 위치</li>
</ul>
</li>
</ul>
<blockquote>
<p>🏃 <strong>file.txt를 읽는 과정</strong></p>
</blockquote>
<ol>
<li>디렉토리를 뒤져서 <code>file.txt</code> 항목을 찾아 시작 블록 번호(4)를 찾음</li>
<li>4번 블록을 읽고 FAT[4]를 확인 해 다음 읽을 블록 번호(9)를 찾음</li>
<li>9번 블록을 읽고 FAT[9]를 확인</li>
<li>FAT[9]가 EOF라면 파일을 다 읽었음을 확인</li>
</ol>
<ul>
<li><p>장점</p>
<ul>
<li>linked list의 매번 따라가야 하는 문제를 개선 (look up)</li>
<li>호환성이 좋음</li>
</ul>
</li>
<li><p>단점</p>
<ul>
<li><p>한 번 읽을 때마다 look up 두 번 소요</p>
<ul>
<li><strong>FAT를 메모리에 cache</strong>해서 개선 가능</li>
</ul>
</li>
<li><p>내부 단편화</p>
</li>
<li><p>FAT에 의존하게 됨</p>
</li>
<li><p>random access 불가</p>
<p>  → Indexed Allocation으로 개선</p>
</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>fragmentation</th>
<th>내부 단편화</th>
<th>➖</th>
</tr>
</thead>
<tbody><tr>
<td>파일의 크기를 키울 수 있는가</td>
<td>가능</td>
<td>✅</td>
</tr>
<tr>
<td>sequential access의 비용</td>
<td>데이터 구조에 따라 다름</td>
<td>➖</td>
</tr>
<tr>
<td>random access의 속도가 어떤가</td>
<td>아주 나쁨</td>
<td>❌</td>
</tr>
<tr>
<td>meta data의 overhead가 어떤가</td>
<td>FAT table</td>
<td>❌</td>
</tr>
</tbody></table>
<br>

<h2 id="5-indexed-allocation">5. Indexed Allocation</h2>
<ul>
<li>고정된 크기의 블락 단위 할당</li>
<li>meta data<ul>
<li><strong>index block (inode)</strong></li>
<li>block <strong>pointer</strong>를 가지는 고정된 크기의 배열<ul>
<li>이 배열은 파일이 생성될 때 공간이 할당됨</li>
</ul>
</li>
</ul>
</li>
<li>디렉토리 엔트리는 인덱스 블록의 주소를 가리킴 (name-inode num mapping)</li>
</ul>
<blockquote>
<p>🏃 <strong>file.txt를 읽는 과정</strong></p>
</blockquote>
<ol>
<li>디렉토리를 뒤져서 <code>file.txt</code> 의 디렉토리 엔트리를 찾아 연결된 inode number(3) 찾음</li>
<li>3번 inode를 읽어 그 안에 있는 포인터 배열을 찾음 [10, 5, 22]</li>
<li>10번 → 5번 → 22번 순서대로 블락을 읽어 데이터를 읽음</li>
</ol>
<ul>
<li><p>장점</p>
<ul>
<li>external fragmentation x</li>
<li>random access 성능이 좋음<ul>
<li>inode만 읽으면, 포인터 배열을 이용해 원하는 블록 주소로 바로 점프 가능</li>
</ul>
</li>
<li>파일 크기 키울 수 있음<ul>
<li>빈 블록 찾아서 주소 할당하고 포인터 배열에 블락 주소 append</li>
</ul>
</li>
</ul>
</li>
<li><p>단점</p>
<ul>
<li><p>meta data의 overhead가 큼</p>
<ul>
<li>모든 파일은 인덱스 블락 하나를 반드시 할당받아야 함</li>
</ul>
</li>
<li><p>파일 크기 제한</p>
<ul>
<li><p>인덱스 블락 하나에 저장할 수 있는 포인터 개수가 한정되어 있음</p>
<p>→ Multi-level Indexing으로 개선</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>fragmentation</th>
<th>내부 단편화</th>
<th>➖</th>
</tr>
</thead>
<tbody><tr>
<td>파일의 크기를 키울 수 있는가</td>
<td>가능</td>
<td>✅</td>
</tr>
<tr>
<td>sequential access의 비용</td>
<td>인덱스 읽고 블락 접근</td>
<td>➖</td>
</tr>
<tr>
<td>random access의 속도가 어떤가</td>
<td>좋음</td>
<td>✅</td>
</tr>
<tr>
<td>meta data의 overhead가 어떤가</td>
<td>파일마다 <strong>인덱스 블록</strong> 할당</td>
<td>❌</td>
</tr>
</tbody></table>
<br>

<h2 id="6-multi-level-indexing">6. Multi-level Indexing</h2>
<ul>
<li>indexed allocation의 변형<ul>
<li>필요에 따라 block 계층을 동적으로 할당함</li>
</ul>
</li>
<li>최대 $n ^{indirect 개수}$개 사용 가능</li>
<li>12개의 포인터는 맨 처음 블락에 직접 저장함 (데이터 블락 가리킴)<ul>
<li>13번째부터는 인덱스 블락을 가리키는 인덱스 블락을 가리킴 → 단일 간접</li>
</ul>
</li>
<li>meta data<ul>
<li>포인터 개수는 FS마다 상이함 (아래 예시는 리눅스 EX2 기준)</li>
<li>직접 블록 12개<ul>
<li>초기에 static하게 할당됨</li>
</ul>
</li>
<li>단일 간접 블록<ul>
<li>13번째 포인터가 필요할 때 동적으로 할당됨</li>
<li>인덱스 블록, 즉 포인터 배열을 가리킴</li>
</ul>
</li>
<li>이중 간접 블록<ul>
<li>14번째 포인터가 필요할 때 동적으로 할당됨</li>
<li>인덱스 블록을 가리키는 인덱스 블록을 가리킴</li>
</ul>
</li>
<li>삼중 간접 블록</li>
</ul>
</li>
<li>UNIX FFS-based file system, ex2, ex3</li>
<li>장점<ul>
<li>불필요한 포인터에 대한 낭비가 없음</li>
<li>빠른 접근</li>
</ul>
</li>
<li>단점<ul>
<li>indirect block 포인터를 계산하기 위한 <strong>disk access 횟수가 커짐</strong><ul>
<li>메모리 캐시 도입 시 속도 개선 가능</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><br><br></p>
<h1 id="5-file-system의-구조">5. File System의 구조</h1>
<hr>
<h2 id="1-super-block">1. Super Block</h2>
<ul>
<li>파일 시스템의 기본적인 크기나 형태에 대한 정보를 저장함</li>
<li>파일 시스템은 해당 블록의 정보를 이용해 파일 시스템을 활용하고 유지함<ul>
<li>block size</li>
<li>inode 개수</li>
<li>root(<code>/</code>)의 inode number</li>
</ul>
</li>
<li>해당 sector에 대해 오류가 뜨면 해당 디스크는 사용할 수 없음<ul>
<li>나머지 정보 중 읽을 수 있는 정보를 읽어 rebuild하면 recovery 일부 가능</li>
</ul>
</li>
</ul>
<br>

<h2 id="2-bitmap">2. Bitmap</h2>
<ul>
<li>Block Bitmap<ul>
<li>블록의 할당 상태를 나타냄</li>
</ul>
</li>
<li>inode Bitmap<ul>
<li>inode의 할당 상태를 나타냄</li>
</ul>
</li>
</ul>
<br>

<h2 id="3-inode">3. inode</h2>
<ul>
<li><strong>파일에 대한 정보</strong>를 저장하는 구조체</li>
<li>고유 number를 가지고, 이는 file system 내에서 <strong>unique</strong>함</li>
<li>보통 256byte 단위<ul>
<li>disk block 크기가 4KB라면 한 block에 16개 들어감</li>
</ul>
</li>
<li>삭제 후, 재사용 가능</li>
<li><code>ls -i</code></li>
</ul>
<table>
<thead>
<tr>
<th>number</th>
<th>해당 inode의 파일 시스템 내부에서의 고유한 번호를 가리킴</th>
</tr>
</thead>
<tbody><tr>
<td>size</td>
<td>파일 사이즈 (Byte 단위)</td>
</tr>
<tr>
<td>mode</td>
<td>파일의 모드, 접근 권한 정보, 파일 종류, 스티키 비트</td>
</tr>
<tr>
<td>link</td>
<td>링크된 파일 개수 등의 정보</td>
</tr>
<tr>
<td>time stamp</td>
<td>생성된 시간, 수정된 시간</td>
</tr>
<tr>
<td>data block pointer</td>
<td>가리키는 data block에 대한 포인터</td>
</tr>
<tr>
<td>sector address</td>
<td>파일이 존재하는 실제 디스크의 위치 정보</td>
</tr>
</tbody></table>
<br>

<h2 id="4-data-block">4. Data Block</h2>
<ul>
<li>데이터를 저장함
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/4cdc646c-fa2e-4b0c-b5ee-7fa4273014e2/image.png" alt=""></li>
</ul>
<p><br><br></p>
<h1 id="6-file-api">6. File API</h1>
<hr>
<h2 id="1-create">1. Create</h2>
<p><code>/etc/file.txt</code></p>
<ol>
<li>root inode Read</li>
<li>root data Read<ul>
<li><code>/</code> directory entry에서 etc directory의 inode number 확인</li>
</ul>
</li>
<li>etc inode Read</li>
<li>etc data Read<ul>
<li>file.txt라는 이름이 이미 중복되어 있는지 확인</li>
</ul>
</li>
<li>inode bitmap Read<ul>
<li>빈 inode를 찾음</li>
</ul>
</li>
<li>inode bitmap Write<ul>
<li>빈 inode에 대한 정보 작성</li>
</ul>
</li>
<li>etc data Write<ul>
<li>file inode에 대한 경로 + inode directory entry 추가</li>
</ul>
</li>
<li>file inode Read<ul>
<li>기존 invalid한 데이터가 남아있지 않은지 확인</li>
</ul>
</li>
<li>file inode Write<ul>
<li>파일 정보 작성</li>
</ul>
</li>
<li>etc inode Write<ul>
<li>etc data의 파일이 수정되었으므로, updated time 수정</li>
</ul>
</li>
</ol>
<br>

<h2 id="2-open">2. Open</h2>
<ol>
<li>root inode Read</li>
<li>root data Read<ul>
<li>etc의 inode number 확인</li>
</ul>
</li>
<li>etc inode Read</li>
<li>etc data Read<ul>
<li>file의 inode number 확인</li>
</ul>
</li>
<li>file inode Read</li>
</ol>
<br>

<h2 id="3-write">3. Write</h2>
<ol>
<li>file inode Read</li>
<li>data bitmap Read</li>
<li>data bitmap Write</li>
<li>file data Write</li>
<li>file inode Write</li>
</ol>
<br>

<h2 id="4-read">4. Read</h2>
<ol>
<li>file inode Read</li>
<li>file data Read</li>
<li>file inode Read<ul>
<li>last accessed time update</li>
</ul>
</li>
</ol>
<br>

<h2 id="5-close">5. Close</h2>
<ul>
<li>disk상의 수행은 없음</li>
<li>file descriptor close</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SpringBoot] 이메일 인증 트러블 슈팅 : UNIQUE, 트랜잭션 분리, LOCK]]></title>
            <link>https://velog.io/@dooo_it_ly/SpringBoot-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-UNIQUE-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-LOCK</link>
            <guid>https://velog.io/@dooo_it_ly/SpringBoot-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-UNIQUE-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-LOCK</guid>
            <pubDate>Thu, 06 Nov 2025 05:36:53 GMT</pubDate>
            <description><![CDATA[<p>외국인 유학생을 위한 AI 기반 한국어 학습 서비스, <strong>LearnMate</strong> 개발기입니다!</p>
<p>AppStore 👉 <a href="https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353">https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353</a></p>
<br>

<h2 id="1-문제-상황">1. 문제 상황</h2>
<p>개발을 마치고, 스토어에 배포해 운영하던 중, 회원가입 로직 중 <strong>이메일 인증 단계</strong>에서 Exception log가 발생하는 것을 확인했습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/78df424b-4609-412a-b02a-086a3af2e645/image.png" alt=""></p>
<h3 id="1-기존-로직">1. 기존 로직</h3>
<p>기존 메일 인증 로직은 다음과 같습니다.</p>
<ol>
<li>email을 받아 해당 email 주소를 가지고 있는 entity가 있는지 조회</li>
<li>없으면 새로 생성, 있으면 해당 entity 사용</li>
<li>code 생성 후 entity 필드값 업데이트 및 저장</li>
<li>confirm 요청이 올 경우, 1번 수행</li>
<li>해당 entity에 저장된 code와 동일한지 확인 + 유효시간 5분 확인</li>
<li>코드가 동일하고 유효하다면 인증 성공</li>
</ol>
<br>

<p>관련 코드는 다음과 같습니다.</p>
<pre><code class="language-kotlin">    @Transactional
    override fun sendVerificationCodeToEmail(email: String) {
        val code = createVerificationCode()
        val verification = emailVerificationRepository.findByEmail(email)
            ?: EmailVerification(email, code)

        verification.updateCode(code)
        emailVerificationRepository.save(verification)

        emailSendService.sendEmail(email, code)
    }

    @Transactional
    override fun confirmEmailVerificationCode(email: String, code: String) {
        val verification = getEmailVerificationByEmail(email)

        if (verification.isExpired()) {
            throw GeneralException(ErrorStatus.EXPIRED_EMAIL_VERIFICATION_CODE)
        }

        if (verification.code != code) {
            throw GeneralException(ErrorStatus.INVALID_EMAIL_VERIFICATION_CODE)
        }

        verification.verify()
    }</code></pre>
<br>

<h3 id="2-문제점">2. 문제점</h3>
<p>유저들의 피드백과 DB를 확인해본 결과, 발견된 문제점은 다음과 같습니다.</p>
<ol>
<li><strong>유저가 요청을 동시에 여러 번 반복하거나 쓰레드 간 경쟁 상태가 일어날 경우, 앞선 조건을 만족하지 않는 상황이 발생함</strong><ul>
<li>즉, 동일한 email에 대해 2개 이상의 entity가 save되는 경우가 생김</li>
<li>1번에서 entity를 조회할 때 <strong>2개 이상의 entity가 조회되어 Exception이 발생</strong>하며, 인증 실패</li>
</ul>
</li>
<li><strong>메일 전송 로직이 entity 저장 로직과 같은 트랜잭션 내에 존재</strong><ul>
<li>response time이 길어짐</li>
<li>쓰레드 점유 시간이 불필요하게 길어짐</li>
<li>정합성 보장이 어려움</li>
</ul>
</li>
</ol>
<br>

<p>1번 상황에 대한 흐름을 도표로 그려보면 다음과 같습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/9825c4d8-cea7-4f4f-b337-f292814f2834/image.png" alt=""></p>
<p><br><br></p>
<h2 id="2-해결방안">2. 해결방안</h2>
<p>위의 문제를 해결하기 위해 아래 세 가지 해결방안을 도입했습니다.</p>
<h3 id="1-unique-칼럼-지정">1. Unique 칼럼 지정</h3>
<p>동일한 email 주소에 대해 여러 개의 레코드가 생기는 문제를 방지하고자, <code>EmailVerification</code> entity의 email 칼럼을 <strong>unique</strong>로 지정했습니다.</p>
<pre><code class="language-kotlin">@Column(nullable = false, unique = true)
    val email: String,</code></pre>
<br>

<p>혹은 PostgreSQL에 아래 쿼리를 이용해 직접 설정을 할 수도 있습니다.</p>
<pre><code class="language-sql">ALTER TABLE email_verification
ADD CONSTRAINT uk_email_verification_email UNIQUE (email);</code></pre>
<br>

<h3 id="2-메일-전송-로직-트랜잭션-분리">2. 메일 전송 로직 트랜잭션 분리</h3>
<p>Event, Listener을 이용해 저장 로직과 메일 전송 로직 분리했습니다.</p>
<pre><code class="language-kotlin">@Transactional
    override fun sendVerificationCodeToEmail(email: String) {
        ...

        verification.updateCode(code)
        emailVerificationRepository.save(verification)

        // entity 저장 및 업데이트 이후 메일 전송 이벤트 발행
        applicationEventPublisher.publishEvent(EmailSendEvent(email, code))
    }</code></pre>
<br>

<p>리스너가 이벤트를 받아 메일 전송 로직을 호출하도록 위임하여 로직을 분리했습니다.
<code>TransactionPhase.AFTER_COMMIT</code>을 이용해 entity 저장, 즉 커밋이 완료된 후 메일 전송되도록 보장합니다.</p>
<pre><code class="language-kotlin">data class EmailSendEvent(
    val email: String,
    val code: String
)</code></pre>
<pre><code class="language-kotlin">@Component
class EmailSendListener(
    val emailSendService: EmailSendService
) {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handleEmailSendEvent(event: EmailSendEvent) {
         // 이메일 전송 로직 호출
        emailSendService.sendEmail(event.email, event.code)
    }

}</code></pre>
<br>

<p>리스너에 <code>@Async</code> 이용해 메일 전송 쓰레드를 별도로 분리하여 응답시간 단축했습니다.
해당 기능을 사용하기 위해서는 Config 파일을 추가로 작성해야 합니다.</p>
<pre><code class="language-kotlin">@Configuration
@EnableAsync
class AsyncConfig: AsyncConfigurer {

    override fun getAsyncExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()

        val coreCount = Runtime.getRuntime().availableProcessors()
        executor.corePoolSize = coreCount // 기본 스레드 수
        executor.maxPoolSize = coreCount * 2  // 최대 스레드 수
        executor.queueCapacity = 10

        executor.setThreadNamePrefix(&quot;EmailAsync-&quot;)
        executor.initialize()
        return executor
    }
}</code></pre>
<p><br><br></p>
<h3 id="3-비관적-락-write-lock-적용">3. 비관적 락 (WRITE LOCK) 적용</h3>
<p>경쟁상태를 방지하고자 Repository에서 email 주소를 이용해 entity를 찾는 함수에 <code>@Lock(LockModeType.PESSIMISTIC_WRITE)</code> 추가했습니다.</p>
<pre><code class="language-kotlin">interface EmailVerificationRepository: JpaRepository&lt;EmailVerification, Long&gt; {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findByEmail(email: String): EmailVerification?
}</code></pre>
<br>

<p>락을 적용함에 따라, 이메일 인증 entity를 조회하고 save하는 과정은 아래 흐름과 같이 개선되었습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/bf12ed46-06b9-4ff6-9194-fc8416014753/image.png" alt=""></p>
<p><br><br></p>
<p>위의 세 가지 방법들을 적용한 이후, Race Condition을 해결하고, 메일 전송에 대한 Response Time을 단축시킬 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] MVCC]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-MVCC</link>
            <guid>https://velog.io/@dooo_it_ly/DB-MVCC</guid>
            <pubDate>Tue, 04 Nov 2025 10:27:44 GMT</pubDate>
            <description><![CDATA[<p><strong>&quot;데이터베이스에 여러 명의 사용자가 접근할 때 어떻게 일관성 있는 데이터를 반환할 수 있을까?&quot;</strong> 라는 물음에 대해 어떤 답변을 할 수 있을까요?</p>
<p>동시성을 제어하는 방법에는 아주 여러가지 다양한 방법이 있으나, 이번에는 DB에서 데이터의 동시성을 컨트롤하는 MVCC에 대해 알아보고자 합니다.</p>
<br>

<h2 id="1-mvcc">1. MVCC?</h2>
<ul>
<li><strong>Multi-Version Concurrency Control</strong></li>
<li>Lock을 이용한 동시성 제어 방법은 로직이 복잡하며 오버헤드가 많이 든다는 단점이 있음<ul>
<li>→ lock을 이용하지 않고 동시성을 제어하고자 함</li>
</ul>
</li>
<li>각 <a href="https://velog.io/@dooo_it_ly/DB-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80">트랜잭션의 격리 수준</a>을 이용</li>
<li>작동 방식 표준이 존재하지 않아 DB마다 상이함</li>
<li><strong>특정 시점에 존재했던 데이터의 스냅샷</strong>을 이용해 작동함<ul>
<li>트랜잭션은 실행 기간에 관계없이 데이터를 일관되게 볼 수 있음</li>
<li>트랜잭션마다 동일한 테이블에서 다른 데이터를 동시에 볼 수 있음</li>
<li>하나의 레코드에 대해 다양한 버전이 존재하게 됨</li>
</ul>
</li>
<li>잠금 없는 읽기를 수행할 수 있게 됨</li>
<li><code>READ COMMITTED</code>, <code>REPEATEABLE READ</code> 격리 수준에서만 작동함</li>
</ul>
<p><br><br></p>
<h2 id="2-innodb의-mvvc-구현-방식">2. InnoDB의 MVVC 구현 방식</h2>
<h3 id="id-할당">ID 할당</h3>
<ul>
<li>트랜잭션이 시작된 후 데이터를 처음 읽을 때 id 할당</li>
</ul>
<br>

<h3 id="update">UPDATE</h3>
<ul>
<li>트랜잭션 내에서 레코드를 수정, 삭제하면 커밋 여부와 관계없이 버퍼풀은 변경된 내용으로 업데이트됨</li>
<li>기존 내용은 <strong>언두 로그</strong>에 언두 레코드로 기록됨<ul>
<li><strong>트랜잭션의 롤백 포인터가 해당 언두 레코드를 가리킴</strong></li>
<li>언두 로그는 변경 내용을 되돌리는 방법을 설명함</li>
<li><strong>리두</strong> 로그 처리됨</li>
<li>트랜잭션이 롤백을 해야 할 경우, 해당 포인터를 이용해 레코드를 복구함</li>
</ul>
</li>
</ul>
<blockquote>
<p>💡 <strong>RollBack</strong>
언두 영역의 레코드를 버퍼 풀로 복구
언두 영역의 내용을 더 필요로 하는 트랜잭션이 없다면 언두 영역의 내용 삭제</p>
</blockquote>
<br>

<h3 id="select">SELECT</h3>
<ul>
<li>레코드의 transaction id와 읽기를 요청한 트랜잭션의 읽기 뷰를 비교함</li>
<li>변경된 트랜잭션이 아직 커밋되지 않은 경우, <strong>요청한 트랜잭션이 볼 수 있는 트랜잭션 id에 도달할 때까지 언두 로그 레코드를 추적해서 적용함</strong><ul>
<li>롤백 포인터를 추적해서 언두 로그 체인을 거슬러 올라감</li>
</ul>
</li>
</ul>
<br>

<h3 id="delete">DELETE</h3>
<ul>
<li>레코드의 <code>info flags</code>에서 deleted 비트를 설정함 → soft delete</li>
<li>언두 로그에 <code>remove deleted mark</code> 기록</li>
<li>이후 purge thread가 어떤 트랜잭션도 참조하지 않는 언두 로그 공간과 마크된 레코드를 물리적으로 제거함</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/845b8721-d297-4b48-8ccd-d4249e9e5b20/image.png" alt=""></p>
<p><br><br></p>
<blockquote>
<p><strong>Reference</strong></p>
</blockquote>
<ul>
<li><a href="https://product.kyobobook.co.kr/detail/S000061776793">MySQL 성능 최적화</a></li>
<li><a href="https://product.kyobobook.co.kr/detail/S000001766482">Real MySQL</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] 트랜잭션 격리 수준]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80</link>
            <guid>https://velog.io/@dooo_it_ly/DB-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80</guid>
            <pubDate>Sun, 02 Nov 2025 10:09:14 GMT</pubDate>
            <description><![CDATA[<h2 id="트랜잭션-격리-수준">트랜잭션 격리 수준</h2>
<ul>
<li>동시에 여러 트랜잭션을 수행할 때, 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정함</li>
<li>격리 수준이 낮을 수록 동시성이 높아지고 오버헤드가 줄어드나, 일관성이 떨어짐</li>
</ul>
<br>

<h3 id="read-uncommitted">READ UNCOMMITTED</h3>
<ul>
<li>커밋되지 않은 트랜잭션의 결과를 볼 수 있음</li>
<li>성능이 좋지 않기 때문에 실제로 거의 사용되지 않음</li>
<li>정합성 측면에서 문제가 많음</li>
</ul>
<blockquote>
<p>👀 <strong>Dirty Read</strong>
커밋되지 않은 데이터를 읽음</p>
</blockquote>
<br>

<h3 id="read-committed">READ COMMITTED</h3>
<ul>
<li>MySQL을 제외한 대부분 DB의 기본 격리 수준</li>
<li><strong>트랜잭션이 시작된 후 커밋된 트랜잭션으로 인한 변경 사항 확인 가능</strong></li>
<li>fuzzy read</li>
<li>커밋될 때까지의 변경 사항은 다른 사람에게 표시되지 않음</li>
</ul>
<blockquote>
<p>👀 <strong>Nonrepeatable Read</strong>
동일한 명령문을 두 번 실행했을 때 다른 데이터를 볼 수도 있음</p>
</blockquote>
<br>

<h3 id="repeatable-read">REPEATABLE READ</h3>
<ul>
<li>MySQL의 기본 트랜잭션 격리 수준</li>
<li><strong>트랜잭션이 레코드에 최초로 접근하기 전에 커밋된 데이터만 확인 가능</strong></li>
<li>트랜잭션이 읽는 모든 행이 동일한 트랜잭션 내에서 후속 읽기에서 동일하게 보이도록 함</li>
</ul>
<blockquote>
<p>👀 <strong>Phantom Read</strong>
동일한 트랜잭션에서 동일한 값에 대해 여러 번 조회 쿼리를 수행했을 때, 존재하던 레코드가 사라지거나 없어지는 현상 <br>
조회쿼리를 수행하는 과정에서 다른 트랜잭션에서 레코드 insert / delete를 수행하고 커밋했을 경우, 해당 내용이 반영됨</p>
</blockquote>
<br>


<h3 id="serializable">SERIALIZABLE</h3>
<ul>
<li>트랜잭션이 충돌하지 않도록 강제로 트랜잭션을 <strong>정렬</strong>함 → 팬텀 리드 해결</li>
<li><strong>읽는 모든 행에 잠금 설정</strong><ul>
<li>먼저 레코드를 읽은 트랜잭션이 종료되기 전에 다른 트랜잭션에 해당 레코드에 대해 커밋하지 못하게 강제함</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>격리 수준</th>
<th>Dirty Read</th>
<th>Nonrepeatable Read</th>
<th>팬텀 리드</th>
<th>잠금 리드</th>
</tr>
</thead>
<tbody><tr>
<td>READ UNCOMMITTED</td>
<td>O</td>
<td>O</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>READ COMMITTED</td>
<td>X</td>
<td>O</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>REPEATABLE READ</td>
<td>X</td>
<td>X</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td>X</td>
<td>X</td>
<td>X</td>
<td>O</td>
</tr>
</tbody></table>
<p><br><br></p>
<blockquote>
<p>💡 <strong>MySQL의 Repeatable read 수준에서는 Phantom Read가 발생하지 않는다!</strong></p>
</blockquote>
<ul>
<li>MySQL의 기본 격리 수준은 Repeatable read이며, 해당 격리 수준은 Phantom read가 발생할 수 있으나, mysql은 <a href="https://velog.io/@dooo_it_ly/DB-MVCC"><strong>MVCC</strong></a> 덕분에 발생하지 않음</li>
<li>MySQL은 MVCC를 이용해 값을 업데이트 할 경우, <strong>언두로그에 기존 내용을 기록함</strong></li>
<li>다른 트랜잭션은 레코드가 아니라 <strong>언두로그의 내용을 조회</strong>하기 때문에 일관성 있는 데이터를 확인할 수 있음</li>
<li><code>SELECT … FOR UPDATE</code> 문을 수행할 경우, 예외적으로 phantom read가 발생할 수 있음<ul>
<li>변경 쿼리처럼 exclusive lock을 거는데, 언두 레코드에는 락을 걸 수 없어 실제 레코드에 락을 걸고 실제 레코드값을 가져오게 됨</li>
</ul>
</li>
</ul>
<p><br><br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] Index Scan의 종류]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-Index-Scan%EC%9D%98-%EC%A2%85%EB%A5%98</link>
            <guid>https://velog.io/@dooo_it_ly/DB-Index-Scan%EC%9D%98-%EC%A2%85%EB%A5%98</guid>
            <pubDate>Sun, 12 Oct 2025 12:34:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>해당 포스팅은 <a href="https://product.kyobobook.co.kr/detail/S000061696048">오라클 성능 고도화 원리와 해법2</a>의 내용을 기반으로 작성되었습니다.</p>
</blockquote>
<br>

<h2 id="1-index의-구조">1. Index의 구조</h2>
<p>Index의 Scan에 대해 언급하기 전에, 기본적인 Index의 구조에 대해 알아봅시다.</p>
<ul>
<li><strong>root block, branch block, leaf block</strong>으로 구분</li>
<li>root, branch block <ul>
<li><strong>key value</strong>와 하단 노드의 <strong>data block address</strong>를 가짐</li>
</ul>
</li>
<li>leaf block<ul>
<li><strong>인덱스</strong> <strong>key col</strong>과 실제 테이블의 레코드 주소인 <strong>rowid</strong>를 가짐</li>
<li>항상 key col을 기준으로 <strong>정렬</strong>되어 있음 → range scan 가능</li>
<li>key col이 동일할 경우 rowid를 기준으로 정렬됨</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/d0f9d3f0-3ed8-4841-b39b-9a1b744d1bd3/image.png" alt=""></p>
<p>위의 구조에 나타난 <strong>lmc</strong>는 key value를 가지지 않는 특별한 엔트리로, 가장 왼쪽에 위치한 블럭을 뜻합니다.</p>
<br>

<p>또한, 인덱스를 탐색하기 위해서는, <strong>수직적 탐색</strong>과 <strong>수평적 탐색</strong> 단계가 필요합니다. </p>
<p>인덱스의 root block에서부터 시작해, branch block를 거쳐 탐색에 적절한 leaf block을 찾아 내려가는 과정을 <strong>수직적 탐색</strong>, 해당 leaf block을 시작으로 좌우로 스캔해 적절한 record를 찾는 과정을 <strong>수평적 탐색</strong>이라고 칭합니다.</p>
<p><br><br></p>
<h2 id="2-index-range-scan">2. Index Range Scan</h2>
<ul>
<li>root block에서 leaf block까지 수직적으로 탐색한 후 <strong>leaf block을 필요한 범위만 스캔</strong>하는 방식</li>
<li>B*Tree index의 가장 일반적이고 정상적인 액세스 방식</li>
<li><strong>Range를 얼마나 줄이는지</strong>, <strong>table access 횟수를 얼마나 줄일 수 있는지</strong>가 튜닝의 핵심임</li>
<li><strong>인덱스를 구성하는 선두 컬럼이 조건절에 사용되어야 함</strong><ul>
<li>그렇지 않다면 옵티마이저는 Table full scan을 선택함</li>
<li>정렬을 생략하거나 I/O를 보다 줄일 수 있다면 Index full scan을 사용함</li>
</ul>
</li>
<li>결과집합은 인덱스 컬럼 순으로 <strong>정렬</strong>된 상태임<ul>
<li>order by 처리 생략하거나 min, max 값을 빠르게 가져올 수 있음</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/552361dc-0432-4890-9972-7df779eea84f/image.png" alt=""></p>
<p><br><br></p>
<h2 id="3-index-full-scan">3. Index Full Scan</h2>
<ul>
<li><strong>수직적 탐색없이 leaf block을 처음부터 끝까지 수평적으로 스캔</strong>하는 방식</li>
<li>보통 최적의 인덱스가 없을 경우 선택됨</li>
<li>실제로는 첫 번째 leaf block으로 찾아가기 위한 수직적 탐색이 일어남 (실질적인 탐색은 X)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/58655336-d6cb-4867-8c48-1184135ddd98/image.png" alt=""></p>
<p><br><br></p>
<h2 id="4-index-unique-scan">4. Index Unique Scan</h2>
<ul>
<li><strong>수직적 탐색만으로 데이터를 찾는 스캔 방식</strong></li>
<li><strong>unique index</strong>를 이용해 <code>=</code> 조건으로 탐색하는 경우 작동함</li>
<li>unique index가 존재하는 칼럼에 대해서는 DBMS 차원에서 <strong>정합성</strong>이 관리됨<ul>
<li>따라서 <code>=</code>을 이용해 데이터를 찾으면 그 뒤에는 데이터가 없음이 보장됨으로 바로 스캔을 멈출 수 있음</li>
</ul>
</li>
<li>Range Scan으로 처리되는 경우<ul>
<li><code>=</code>이 아닌 범위검색 조건(between, 부등호, like)일 경우</li>
<li>unique index가 결합 인덱스인데 일부 컬럼에 대해서만 검색할 경우</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/959dc3af-2463-441f-9292-4e88aabfa2f5/image.png" alt=""></p>
<p><br><br></p>
<p>보통 index를 제대로 타기 위해서는 <strong>index를 구성하는 선두 컬럼이 조건절에 명시</strong>되어야 효과적으로 범위가 필터링되어 제 성능을 발휘합니다. <strong>선두 컬럼</strong>이 명시되지 않을 경우 옵티마이저는 인덱스 대신 풀스캔을 택하곤 하죠. </p>
<p>하지만 오라클에서 선두 컬럼이 조건절에 사용되지 않았을 때도 인덱스를 활용할 수 있도록 새로운 방법을 선보였습니다.</p>
<h2 id="5-index-skip-scan">5. Index Skip Scan</h2>
<ul>
<li><strong>인덱스를 구성하는 선두 컬럼이 조건절에 사용되지않았을 때도 인덱스를 활용하는 스캔 방식</strong></li>
<li>root, branch block에서 읽은 컬럼 정보를 이용해 <strong>해당 레코드를 포함할 가능성이 있</strong>는 leaf block을 골라서 access함</li>
<li>조건절에 빠진 인덱스 선두 컬럼의 Distinct value 개수가 적고 후행 컬럼의 Distinct value 개수가 많을 때 유용함</li>
</ul>
<br>

<p>(성별, 연봉)으로 구성된 인덱스가 다음과 같이 구성되어있다고 가정해봅시다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/57453e4e-9cd3-4a9c-9efe-392b40277b18/image.png" alt=""></p>
<p>먼저, 아래 쿼리와 같이 인덱스에 나타나는 <strong>모든 칼럼이 조건절에 존재했을 때</strong>를 가정해봅시다.</p>
<pre><code class="language-sql">select * from 사원 where 성별 = &#39;남&#39; and 연봉 between 2000 and 4000;</code></pre>
<p>위와 같은 쿼리를 수행하기 위해, Index Range Scan이 아래 순서대로 진행될 것입니다. </p>
<ol>
<li>root block의 레코드를 확인해 성별이 남이고 연봉이 조건을 만족하는 첫 번째 레코드를 찾음</li>
<li>3번 레코드의 DBA를 이용해 3번 leaf block으로 이동함</li>
<li>Range Scan을 하며 leaf block을 순회하다가, 조건이 만족하지 않는 레코드를 만나면 스캔을 멈춤 </li>
</ol>
<p><br><br></p>
<p>두 번째로, 아래 쿼리와 같이 인덱스를 구성하는 선두 컬럼이 조건절에 존재하지 않을 때를 가정해봅시다.</p>
<pre><code class="language-sql">select * from 사원 where 연봉 between 2000 and 4000;</code></pre>
<ol>
<li><strong>1번</strong> leaf block을 방문함
연봉 조건은 부합하지 않으나, <strong>‘남’보다 작은 성별</strong>이 존재할 가능성이 있기 때문</li>
<li><strong>3번</strong> leaf block을 방문함
연봉 조건이 부합함</li>
<li><strong>5번</strong> leaf block을 방문함
 연봉 조건은 부합하지 않으나, <strong>‘남’과 ‘여’ 사이에 다른 성별</strong>이 존재할 가능성이 있기 때문</li>
<li><strong>6번</strong> leaf block을 방문함
연봉 조건이 부합함</li>
<li><strong>9번</strong> leaf block을 방문함
 연봉 조건은 부합하지 않으나, <strong>‘여’보다 큰 성별</strong>이 존재할 가능성이 있기 때문</li>
</ol>
<p>이렇듯, Index Skip Scan은 다른 Scan방식처럼 수평적 탐색을 하지 않고, <strong>상위 block에서 다음으로 이동할 leaf block을 결정</strong>합니다. 이 때, <strong>첫 번째, 마지막 leaf block은 반드시 방문</strong>합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/9accd47a-44ab-432b-887a-9cc0e68099d2/image.png" alt=""></p>
<p><br><br></p>
<h2 id="6-index-fast-full-scan">6. Index Fast Full Scan</h2>
<ul>
<li><strong>인덱스 트리 구조를 무시하고 segment 전체를 multiblock read 방식으로 스캔하는 방식</strong></li>
<li>Index Full Scan보다 빠름</li>
</ul>
<br>

<p>아래는 논리적 순서로 배치된 블록 구조입니다. Index Full Scan의 경우, root → branch1 → leaf1 → … → leaf8 순서대로 블록을 읽어들입니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/95ec81fa-1b6b-4509-a191-6a1575e2f04b/image.png" alt=""></p>
<p>위의 구조를 물리적 순서로 다시 배치한 블록 구조입니다. Index Fast Full Scan의 경우, leaf1 → leaf2 → leaf8 → leaf3 → leaf7 → leaf6 → leaf4 → leaf5 순서대로 블록을 읽어들입니다.</p>
<p>이 과정에서 root, branch1도 읽어들이나, 결과적으로 필요하지 않은 블록이므로 버리게 됩니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/c4ed8d54-67d9-46b7-9a7a-5fb70835b3bd/image.png" alt=""></p>
<ul>
<li>디스크로부터 대량의 인덱스 블록을 읽어야 하는 경우 유용함</li>
<li>leaf node의 양방향 연결 리스트를 이용하지 않기 때문에 <strong>결과값이 정렬되어 있지 않음</strong></li>
<li><strong>사용되는 모든 컬럼이 인덱스 컬럼에 포함되어 있을 때만 사용 가능</strong></li>
<li>인덱스가 파티션 되어 있지 않더라도 <strong>병렬 쿼리</strong>가 가능함</li>
<li>버퍼 캐시 히트율이 낮아 디스크 I/O가 많이 발생할 때 유리함</li>
<li>컬럼 개수가 많아 테이블보다 인덱스 크기가 현저히 작은 상황에서 유리함</li>
</ul>
<table>
<thead>
<tr>
<th></th>
<th>Index Full Scan</th>
<th>Index Fast Full Scan</th>
</tr>
</thead>
<tbody><tr>
<td>스캔 범위</td>
<td>인덱스 구조 따라 스캔</td>
<td>세그먼트 전체 스캔</td>
</tr>
<tr>
<td>결과집합 순서 보장</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>Block I/O</td>
<td>Single Block</td>
<td>Multiblock</td>
</tr>
<tr>
<td>병렬스캔</td>
<td>X (파티션 되어 있지 않다면)</td>
<td>O</td>
</tr>
<tr>
<td>인덱스에 포함되지 않은 컬럼 조회</td>
<td>사용 가능</td>
<td>사용 불가</td>
</tr>
</tbody></table>
<p><br><br></p>
<h2 id="7-index-range-scan-descending">7. Index Range Scan Descending</h2>
<ul>
<li><strong>Index Range Scan과 동일하나, 뒤에서부터 탐색해 내림차순으로 정렬된 결과를 얻는 스캔 방식</strong></li>
<li>옵티마이저가 알아서 인덱스를 거꾸로 읽는 plan을 수립함</li>
<li>max 값을 구하는 경우에도 이용함</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/17f9c55f-21a9-4f40-bf82-85a18d75e3ba/image.png" alt=""></p>
<p><br><br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] Clustered Index VS NonClustered Index]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-Clustered-Index-VS-NonClustered-Index</link>
            <guid>https://velog.io/@dooo_it_ly/DB-Clustered-Index-VS-NonClustered-Index</guid>
            <pubDate>Sat, 04 Oct 2025 13:39:55 GMT</pubDate>
            <description><![CDATA[<p>서버 애플리케이션의 성능 개선을 위해 눈여겨보고는 하는 <strong>인덱스</strong>, 이 인덱스는 크게 클러스터 인덱스와 논클러스터 인덱스로 구분됩니다.</p>
<p>쉽게 얘기해 클러스터 인덱스는 실제 데이터를 물리적으로 정렬해 클러스터로 꾸려둔 것이고, 논클러스터 인덱스는 인덱스 페이지를 이용하는 그 외의 것들을 지칭한다고 볼 수 있는데요, 각 인덱스의 특징이 무엇이고 무엇이 다른지 알아보겠습니다.</p>
<br>


<h2 id="1-클러스터-인덱스">1. 클러스터 인덱스</h2>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/a5a16df4-cc67-4296-bad6-2bb35778cb42/image.png" alt=""></p>
<h3 id="1-특징">1. 특징</h3>
<ul>
<li>테이블당 1개만 허용됨</li>
<li>B-Tree 구조임</li>
<li><strong>물리적으로 행을 재배열</strong>하며 항상 정렬 상태를 유지함</li>
<li>테이블에 PK를 설정할 시, 자동으로 해당 칼럼에 대해 클러스터 인덱스가 만들어짐</li>
<li><strong>리프 페이지로 데이터 페이지를 가짐</strong></li>
<li>따라서 논클러스터 인덱스보다 조회 속도가 빠르나, 입력, 수정, 삭제의 속도는 느림</li>
<li>인덱스 페이지를 따로 만들지 않으므로, 논클러스터 인덱스보다 차지하는 용량이 적음</li>
</ul>
<br>

<h3 id="2-유용한-경우">2. 유용한 경우</h3>
<ul>
<li>테이블 데이터가 자주 업데이트되지 않고, <strong>조회</strong>가 더 많이 일어나는 경우</li>
<li>값이 물리적으로 정렬되어 있기 때문에, Between같이 <strong>범위</strong> 값을 반환하거나 Order by같이 <strong>정렬</strong>하는 쿼리와 함께 사용하면 성능이 좋음</li>
</ul>
<p><br><br></p>
<h2 id="2-논클러스터-인덱스">2. 논클러스터 인덱스</h2>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/0ce5c44e-08db-4dc4-8eaf-29d355724472/image.png" alt=""></p>
<h3 id="1-특징-1">1. 특징</h3>
<ul>
<li>테이블당 약 240개가 허용됨</li>
<li>로그 파일에 인덱스 페이지가 저장됨<ul>
<li>즉, 실제 데이터가 저장된 곳(Heap)과 인덱스 페이지는 별도의 공간에 존재함</li>
</ul>
</li>
<li>레코드의 물리적 행을 정렬하지 않고, <strong>인덱스 페이지만 정렬함</strong><ul>
<li>즉, 실제 데이터는 클러스터 인덱스와 다르게 정렬되어 있지 않음</li>
</ul>
</li>
<li>리프 페이지는 데이터 페이지가 아닌, 데이터 페이지의 위치를 나타내는 <strong>포인터(RID)</strong>임</li>
<li>따라서 클러스터 인덱스보다 조회 속도는 느리나, 입력, 수정, 삭제의 속도는 빠름</li>
<li>인덱스 페이지를 생성하기 때문에 클러스터 인덱스에 비해 차지하는 용량이 큼</li>
</ul>
<blockquote>
<p>👀 <strong>RID?</strong></p>
</blockquote>
<ul>
<li>Row Id</li>
<li>DB에서 데이터가 가진 실제 주소</li>
<li>데이터를 구분할 수 있도록, 모든 데이터는 유니크한 주소를 가지고 있음</li>
</ul>
<br>

<h3 id="2-유용한-경우-1">2. 유용한 경우</h3>
<ul>
<li>데이터가 조회보다는 업데이트가 더 많이 일어나는 경우</li>
</ul>
<p><br><br></p>
<p>이렇듯, 클러스터 인덱스와 논클러스터 인덱스는 데이터 구조뿐만 아니라, 데이터에 접근하는 과정도 상이합니다. </p>
<p>클러스터 인덱스는 데이터에 바로 접근할 수 있는 반면, 논클러스터 인덱스는 목차에서 해당 페이지의 번호를 찾아 데이터에 접근하는 단계가 더 필요하죠.</p>
<p>인덱스 구조에 대해 공부하는 과정에서 해당 내용에 대해 간단하게 다루어봤는데요, 차차 인덱스, DB 관련 내용을 포스팅해보도록 하겠습니다.👍</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SpringBoot] FCM 알림 전송하기]]></title>
            <link>https://velog.io/@dooo_it_ly/SpringBoot-FCM-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/SpringBoot-FCM-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 21 Sep 2025 10:47:26 GMT</pubDate>
            <description><![CDATA[<p>앱 서비스를 만들다보면 점점 필수가 되어가는 알림 기능 ..
FCM 연결해봅시다</p>
<br>


<h2 id="1-로직">1. 로직</h2>
<p>구상 중인 FCM 알림 전송 로직은 다음과 같습니다.
알림 소실을 최소화하기 위해 RabbitMQ를 도입하고, 이벤트 발행 로직도 eventListener을 이용해 비동기로 처리할 계획입니다.</p>
<ol>
<li><strong>[클라이언트]</strong> 앱 접속 및 가입</li>
<li><strong>[클라이언트 → Firebase]</strong> FCM 토큰 발급 요청</li>
<li><strong>[Firebase → 클라이언트]</strong> FCM 토큰 발급</li>
<li><strong>[클라이언트 → 서버]</strong> FCM 토큰 전달</li>
<li><strong>[서버]</strong> FCM 토큰 저장</li>
<li><strong>[서버]</strong> 이벤트 발생</li>
<li><strong>[서버 → RabbitMQ]</strong> 알림에 필요한 정보 발행</li>
<li><strong>[RabbitMQ → 서버]</strong> Consumer가 큐를 구독하다가 메시지 가져와 처리</li>
<li><strong>[서버]</strong> DB에서 FCM 토큰 조회</li>
<li><strong>[서버 → FCM]</strong> FCM 토큰, 메시지를 FCM 서버에 전송</li>
<li><strong>[FCM → 클라이언트]</strong> 알림 전송</li>
</ol>
<p><br><br></p>
<h2 id="2-firebase-설정">2. Firebase 설정</h2>
<h3 id="1-firebase-sdk">1. Firebase SDK</h3>
<p>FCM 연결을 위한 Firebase 설정을 해봅시다.</p>
<ol>
<li>파이어베이스 콘솔로 이동</li>
<li>프로젝트 생성
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/2745c2c7-c083-40b0-9f3b-f9fd06cba778/image.png" alt=""></li>
<li>프로젝트 → 프로젝트 설정 → 서비스 계정
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/7e9d859a-a68b-44f6-87cf-2c7c1e927104/image.png" alt=""></li>
</ol>
<ol start="4">
<li>새 비공개 키 생성해 Json 파일 획득</li>
</ol>
<br>


<h3 id="2-firebaseconfig">2. FirebaseConfig</h3>
<ol>
<li>의존성 추가</li>
</ol>
<pre><code class="language-kotlin">implementation(&quot;com.google.firebase:firebase-admin:9.2.0&quot;)</code></pre>
<ol start="2">
<li>위에서 다운로드한 키 파일 인식<ul>
<li><code>resource/</code>에 다운로드한 비공개 키 위치</li>
<li><code>application.yml</code>에 키 이름 명시</li>
</ul>
</li>
</ol>
<pre><code class="language-yaml">fcm:
  credentials:
    path: classpath:${FIREBASE_KEY}</code></pre>
<ol start="3">
<li>config 파일 작성</li>
</ol>
<pre><code class="language-kotlin">@Configuration
class FirebaseConfig(
    @Value(&quot;\${fcm.credentials.path}&quot;) private val accountKey: Resource
) {

    @PostConstruct
    fun initialize() {
        try {
            val serviceAccount = accountKey.inputStream

            val options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                .build()

            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options)
            }

        } catch (e: Exception) {
            e.printStackTrace()
            throw GeneralException(ErrorStatus.INITIAL_FIREBASE_INTERNAL_SERVER_ERROR)
        }
    }

}</code></pre>
<p><br><br></p>
<h2 id="3-fcm-token-관리">3. FCM Token 관리</h2>
<h3 id="1-fcm-token">1. FCM Token</h3>
<p>앞서 계속해서 언급한 FCM Token이란, 사용자의 <strong>기기</strong>를 구분하기 위한 토큰입니다. 알림을 어떤 기기로 발송할지 구분하기 위해 필요한 정보죠.</p>
<br>


<h3 id="2-fcm-token-등록-삭제">2. FCM Token 등록, 삭제</h3>
<p>이 FCM Token을 어떻게 관리해야 할까요</p>
<p>우선 <code>User</code> Entity에 FCM Token을 저장할 필드를 생성했습니다. </p>
<p>서비스의 확장성을 고려한다면 <code>Token</code> 테이블을 생성해서 1:N으로 관계를 맺을 수도 있으나, 품이 작은 관계로, 1:1 관계로 간주하고 user에 필드를 생성하는 방향으로 구현했습니다.</p>
<pre><code class="language-kotlin">@Column
var fcmToken: String? = fcmToken</code></pre>
<p>토큰 등록의 경우, 회원가입 시, 클라이언트로부터 FCM Token을 전달받아 entity에 함께 저장할 수 있겠습니다.</p>
<p>또한 로그아웃 및 회원탈퇴 시 해당 기기에 알림이 전송되면 안되므로, FCM Token을 삭제해야겠지요.</p>
<p><br><br></p>
<p>이번 포스팅에서는 우선적으로 FCM 연결을 위한 기본 세팅을 진행했습니다.
다음 포스팅부터는 FCM 전송로직, RabbitMQ 연결 등의 내용을 다루어보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SpringBoot] Logback 커스텀해 디스코드 알림 전송하기]]></title>
            <link>https://velog.io/@dooo_it_ly/SpringBoot-Logback-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%B4-%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/SpringBoot-Logback-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%B4-%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 01 Sep 2025 04:24:12 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@dooo_it_ly/SpringBoot-AOP%EB%A1%9C-Exception-%EA%B0%90%EC%A7%80-WebHook%EC%9C%BC%EB%A1%9C-%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%95%8C%EB%A6%BC-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0">https://velog.io/@dooo_it_ly/SpringBoot-AOP로-Exception-감지-WebHook으로-디스코드-알림-연동하기</a></p>
<p>👆👆 이전 글에서 AOP와 WebHook을 이용해 디스코드 알림을 연동해보았습니다. 이번에는 로깅 라이브러리 중 하나이며 스프링에서 사용되는 <strong>Logback</strong>을 커스텀하여 알림을 전송해보겠습니다.</p>
<br>

<h2 id="1-디스코드-연동">1. 디스코드 연동</h2>
<p>디스코드 연동 과정은 기존과 동일합니다. </p>
<h3 id="1-webhook-url-생성">1. WebHook URL 생성</h3>
<p>알림을 보낼 채널에 web hook을 연동해줍니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/c750d6d8-a1eb-45e9-b6bf-26852286b097/image.png" alt=""></p>
<br>


<h3 id="2-환경변수-설정">2. 환경변수 설정</h3>
<pre><code class="language-yaml">logging:
  discord:
    web-hook-url: ${DISCORD_WEB_HOOK_URL}
  config: classpath:logback-spring.xml</code></pre>
<br>

<h3 id="3-의존성-추가">3. 의존성 추가</h3>
<pre><code class="language-kotlin">    implementation (&quot;com.github.napstr:logback-discord-appender:1.0.0&quot;)</code></pre>
<p><br><br></p>
<h2 id="2-logback-xml-커스텀">2. Logback xml 커스텀</h2>
<p>profile이 local일 때 나오는 에러는 콘솔에, dev일 때 나오는 에러는 디스코드와 콘솔에 출력하고자 합니다.</p>
<p><code>resources</code> 폴더에 <code>logback-spring.xml</code> 파일을 생성하고, 각 profile일 때의 로그 appender을 지정해봅시다</p>
<pre><code class="language-xml">&lt;configuration&gt;

    &lt;!--local log--&gt;
    &lt;springProfile name=&quot;local&quot;&gt;
        &lt;include resource=&quot;org/springframework/boot/logging/logback/console-appender.xml&quot;/&gt;

        &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
            &lt;encoder&gt;
                &lt;Pattern&gt;%d{HH:mm:ss.SSS} %highlight(%-5level) [%t] %logger{36} - %msg%n&lt;/Pattern&gt;
                &lt;charset&gt;utf8&lt;/charset&gt;
            &lt;/encoder&gt;
        &lt;/appender&gt;

        &lt;root level=&quot;INFO&quot;&gt;
            &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
        &lt;/root&gt;
    &lt;/springProfile&gt;

    &lt;!--dev log--&gt;
    &lt;springProfile name=&quot;dev&quot;&gt;
        &lt;property resource=&quot;application-secret.yml&quot;/&gt;
        &lt;springProperty name=&quot;DISCORD_WEBHOOK_URL&quot; source=&quot;logging.discord.web-hook-url&quot;/&gt;

                &lt;!--디스코드로 알림을 보내기 위한 appender--&gt;
        &lt;appender name=&quot;DISCORD&quot; class=&quot;com.github.napstr.logback.DiscordAppender&quot;&gt;
            &lt;webhookUri&gt;${DISCORD_WEBHOOK_URL}&lt;/webhookUri&gt;
            &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
                &lt;pattern&gt;[%d{HH:mm:ss}] [%-5level] %logger{36} %n %msg%n```%ex{full}```&lt;/pattern&gt;
            &lt;/layout&gt;
            &lt;username&gt;[LearnMate]DEV SERVER ERROR&lt;/username&gt;
            &lt;tts&gt;false&lt;/tts&gt;
        &lt;/appender&gt;

                &lt;!--디스코드 appender를 비동기로 처리하기 위한 상위 appender--&gt;
        &lt;appender name=&quot;ASYNC_DISCORD&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
            &lt;appender-ref ref=&quot;DISCORD&quot; /&gt;
            &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
                    &lt;!--로그가 ERROR LEVEL일 때만 디스코드 알림을 전송함--&gt;
                &lt;level&gt;ERROR&lt;/level&gt;
            &lt;/filter&gt;
        &lt;/appender&gt;

        &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
            &lt;encoder&gt;
                &lt;Pattern&gt;%d{HH:mm:ss.SSS} %highlight(%-5level) [%t] %logger{36} - %msg%n&lt;/Pattern&gt;
                &lt;charset&gt;utf8&lt;/charset&gt;
            &lt;/encoder&gt;
        &lt;/appender&gt;

        &lt;root level=&quot;INFO&quot;&gt;
            &lt;appender-ref ref=&quot;ASYNC_DISCORD&quot;/&gt;
            &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
        &lt;/root&gt;
    &lt;/springProfile&gt;


&lt;/configuration&gt;</code></pre>
<br>

<p>예외가 발생하면, 디스코드에 이렇게 알림이 옵니다. 단순히 발생할 로그의 형태를 지정한 후 그대로 알림을 전송하는거라, 어떤 요청으로 인해 에러가 발생한 건지 등의 부가 정보는 알 수 없어서 아쉽습니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/7900dee2-711c-435a-98c4-89a9c48d9198/image.png" alt=""></p>
<p><br><br></p>
<h2 id="3-logback-커스텀하기">3. Logback 커스텀하기</h2>
<p>요청과 응답에 대한 부가 정보를 더 가져올 수 있도록 커스텀을 추가해봅시다.</p>
<h3 id="1-filter">1. Filter</h3>
<p>MDC는 로깅 라이브러리의 기능으로, 현재 실행 중인 쓰레드의 메타 정보를 관리할 수 있는 공간입니다.</p>
<p><code>OncePerRequestFilter</code>단에서 Request, Response 정보를 받아낸 후, 필요한 값들을 <code>MDC</code>라는 컨텍스트에 저장해둡니다. </p>
<p>나중에 알림을 보낼 때 로그 정보와 저장해둔 이 정보들을 함께 조합해서 보낼 것입니다.</p>
<pre><code class="language-kotlin">@Component
class RequestLoggingFilter: OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        // request 정보를 MDC 컨텍스트에 저장
        MDC.put(&quot;requestURI&quot;, request.requestURL.toString())
        MDC.put(&quot;requestMethod&quot;, request.method)
        MDC.put(&quot;clientIP&quot;, request.remoteAddr)

        try {
            filterChain.doFilter(request, response)
        } finally {
           MDC.clear()
        }
    }
}</code></pre>
<br>

<h3 id="2-error-layout">2. Error Layout</h3>
<p>디스코드로 알림을 보낼 메세지 레이아웃을 생성합니다.</p>
<p>2번의 xml 커스텀은 레이아웃을 따로 만들지 못했어서 아쉬움이 있었는데, 이 아쉬움을 충족시키고자 레이아웃 커스텀을 시도하게 되었습니다.</p>
<p><code>LayoutBase&lt;ILoggingEvent&gt;()</code> 이라는 Logback 라이브러리의 클래스를 상속해서 레이아웃을 커스텀 할 것이며, 이는 로그 이벤트를 받아 최종 문자열을 반환하는 클래스입니다.</p>
<pre><code class="language-kotlin">class DiscordErrorLayout: LayoutBase&lt;ILoggingEvent&gt;() {

    override fun doLayout(event: ILoggingEvent?): String {
        val sb = StringBuilder()
        val timestamp = LocalDateTime.ofInstant(Instant.ofEpochMilli(event!!.timeStamp), ZoneId.systemDefault())

        sb.append(&quot;🚨 **서버 에러 발생** 🚨\n\n&quot;)
        sb.append(&quot;**에러 정보**\n&quot;)
        sb.append(&quot;```\n&quot;)
        sb.append(event.formattedMessage)
        sb.append(&quot;\n```\n&quot;)

        sb.append(&quot;**에러 발생 시간**\n&quot;)
        sb.append(&quot;`&quot;).append(timestamp.format(DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH시 mm분 ss초&quot;))).append(&quot;`\n\n&quot;)

                sb.append(&quot;**요청 URL**\n&quot;)
        sb.append(&quot;`[&quot;)
            .append(event.mdcPropertyMap[&quot;requestMethod&quot;]) 
            .append(&quot;] &quot;)
            .append(event.mdcPropertyMap[&quot;requestURL&quot;] ?: &quot;&quot;) 
            .append(&quot;`\n\n&quot;)

        sb.append(&quot;**요청 클라이언트**\n&quot;)
        sb.append(&quot;`[IP]: &quot;).append(event.mdcPropertyMap[&quot;clientIP&quot;] ?: &quot;&quot;).append(&quot;`\n\n&quot;)

        if (event.throwableProxy != null) {
            sb.append(&quot;**에러 스택 트레이스**\n&quot;)
            sb.append(&quot;```\n&quot;)
            sb.append(ThrowableProxyUtil.asString(event.throwableProxy))
            sb.append(&quot;\n```&quot;)
        }

        return sb.toString()
    }

}</code></pre>
<br>

<h3 id="3-layout-적용">3. Layout 적용</h3>
<p>아래와 같이 디스코드 appender의 layout을 위에서 생성한 layout의 클래스 명으로 변경해줍니다.</p>
<pre><code class="language-xml">    &lt;!--dev log--&gt;
    &lt;!-- 1. 디스코드에 보낼 로그용 로거 이름 명시 --&gt;
    &lt;logger name=&quot;Logger&quot; additivity=&quot;false&quot; level=&quot;INFO&quot;&gt;
        &lt;appender-ref ref=&quot;ASYNC_DISCORD&quot;/&gt;
    &lt;/logger&gt;

    &lt;springProfile name=&quot;dev&quot;&gt;
        &lt;property resource=&quot;application-secret.yml&quot;/&gt;
        &lt;springProperty name=&quot;DISCORD_WEBHOOK_URL&quot; source=&quot;logging.discord.web-hook-url&quot;/&gt;

        &lt;appender name=&quot;DISCORD&quot; class=&quot;com.github.napstr.logback.DiscordAppender&quot;&gt;
            &lt;webhookUri&gt;${DISCORD_WEBHOOK_URL}&lt;/webhookUri&gt;
            &lt;!-- 2. layout 변경 --&gt;
            &lt;layout class=&quot;learn_mate_it.dev.common.log.dto.DiscordErrorLayout&quot; /&gt;
            &lt;username&gt;[LearnMate]DEV SERVER ERROR&lt;/username&gt;
            &lt;tts&gt;false&lt;/tts&gt;
        &lt;/appender&gt;

        &lt;appender name=&quot;ASYNC_DISCORD&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
            &lt;appender-ref ref=&quot;DISCORD&quot; /&gt;
            &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
                &lt;level&gt;ERROR&lt;/level&gt;
            &lt;/filter&gt;
        &lt;/appender&gt;

        ...</code></pre>
<br>

<h3 id="4-테스트">4. 테스트</h3>
<p>3번의 xml에서 명시한 <strong>Logger</strong> 로거를 불러와, 디스코드로 알림을 보낼 곳에 error 로그를 찍어줍니다.</p>
<pre><code class="language-kotlin">private val discordLog = LoggerFactory.getLogger(&quot;Logger&quot;)</code></pre>
<p>아래와 같이 레이아웃이 예쁘게 적용된 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/41e9fbec-6f5b-42d4-a0c9-fa4535aaffe8/image.png" alt=""></p>
<br>

<blockquote>
<h4 id="reference">Reference</h4>
<p><a href="https://leeeeeyeon-dev.tistory.com/3">https://leeeeeyeon-dev.tistory.com/3</a>
<a href="https://velog.io/@cjh8746/Log-Back-%EC%A0%81%EC%9A%A9%EA%B8%B0">https://velog.io/@cjh8746/Log-Back-%EC%A0%81%EC%9A%A9%EA%B8%B0</a>
<a href="https://tech.kakaopay.com/post/podo-elk-threadcontext-part-1/#requestloggingfilter%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-request-%EC%9A%94%EC%B2%AD-%EB%A1%9C%EA%B9%85">https://tech.kakaopay.com/post/podo-elk-threadcontext-part-1/#requestloggingfilter%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-request-%EC%9A%94%EC%B2%AD-%EB%A1%9C%EA%B9%85</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Infra] Grafana Alerting 기능 이용해 슬랙 알림 받기]]></title>
            <link>https://velog.io/@dooo_it_ly/Infra-Grafana-Alerting-%EA%B8%B0%EB%8A%A5-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%8A%AC%EB%9E%99-%EC%95%8C%EB%A6%BC-%EB%B0%9B%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/Infra-Grafana-Alerting-%EA%B8%B0%EB%8A%A5-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%8A%AC%EB%9E%99-%EC%95%8C%EB%A6%BC-%EB%B0%9B%EA%B8%B0</guid>
            <pubDate>Mon, 01 Sep 2025 03:01:53 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@dooo_it_ly/Infra-Grafana-Prometheus%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0">https://velog.io/@dooo_it_ly/Infra-Grafana-Prometheus로-모니터링-시스템-구축하기</a> 
👆 앞선 포스팅에서, grafana와 prometheus를 이용해 모니터링 시스템을 구축하는 실습을 진행했습니다. </p>
<p>이어서, grafana의 Alerting 기능을 이용해 특정 조건에 부합할 시 Slack 알림을 전송하는 실습을 진행해보겠습니다.</p>
<br>

<h2 id="1-slack-app-설정">1. Slack App 설정</h2>
<p><a href="https://api.slack.com/apps">https://api.slack.com/apps</a></p>
<p>슬랙의 API 사이트에 가서 Create on App 버튼을 클릭합니다.
<strong>From scratch</strong> 탭을 눌러 앱의 이름과 슬랙봇을 연동할 워크스페이스를 지정합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/58586798-d376-4327-a483-0b4ce785f267/image.png" alt=""><img src="https://velog.velcdn.com/images/dooo_it_ly/post/0045824a-2998-40e1-8ad4-4e2f2aab9c23/image.png" alt=""></p>
<p>앱을 생성하면, 아래와 같이 앱에 대한 기본 정보들을 확인할 수 있습니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/b141ccc2-f291-432d-8c8b-ce7a010cc6cb/image.png" alt=""></p>
<p><br><br></p>
<h2 id="2-scope-설정">2. Scope 설정</h2>
<p>이후, <strong>OAuth&amp;Permission</strong> 탭에서 슬랙 봇의 scope를 설정해줍니다.
<code>chat:write</code>와 <code>files:write</code>를 추가해 이미지 전송을 비롯한 채팅 작성이 가능하도록 합니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/51e71181-db6d-47ef-830e-b2fffc4d9ab5/image.png" alt=""></p>
<p><br><br></p>
<h2 id="3-bot-token-발급">3. Bot Token 발급</h2>
<p>app 설정에서 지정한 워크스페이스에 Bot을 설치해줍니다.<img src="https://velog.velcdn.com/images/dooo_it_ly/post/84864a8d-9c38-4da6-9edc-532c128a85f7/image.png" alt=""></p>
<p>설치를 완료하면 다음과 같이 Bot User OAuth Token을 발급받을 수 있습니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/1999574a-41a4-40ea-802d-7d9c837df8bf/image.png" alt=""></p>
<p><br><br></p>
<h2 id="4-web-hook-설정">4. Web Hook 설정</h2>
<p><strong>Incoming Webhooks</strong> 탭에서 슬랙의 Webhook 기능을 활성화합니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/05085877-80c2-4145-ae19-d47659ea8752/image.png" alt=""></p>
<p>이후 슬랙에 메시지를 전송할 채널을 선택한 후, 연결할 웹훅 URI를 생성합니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/bb2bffcf-3f91-4a8b-b599-5d5f3b412030/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/28cebff8-2674-4a0a-8e81-ef0983586d69/image.png" alt=""></p>
<p><br><br></p>
<h2 id="5-grafana-alert-설정">5. Grafana Alert 설정</h2>
<p><strong>Home-Alerting-Contact Info</strong> 탭에서 알림 전송을 위한 contact point를 추가해줍니다.</p>
<p>integration을 Slack으로 설정한 후, 앞서 발급받은 Webhook URI를 입력합니다. 
입력을 완료한 후, test를 눌러보면, 정상적으로 지정한 슬랙에 알림이 오는 것을 확인할 수 있습니다.</p>
<p>입력을 완료했다면, contact point를 save합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/158bb15f-0410-4082-9b2c-021cdfd15760/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/f70b01e0-bee3-4cb2-9368-40d6fef306f1/image.png" alt=""></p>
<p><br><br></p>
<h2 id="6-대시보드-alert-rule-설정">6. 대시보드 Alert Rule 설정</h2>
<p><strong>Home-Alerting-Alert rules</strong> 탭에서 알림을 전송할 기준을 설정해줍니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/70072c59-284f-4f7d-8ae7-c9b90735a2bb/image.png" alt=""></p>
<p>알림을 받을 조건, 메트릭을 설정합니다. 빠른 테스트를 위해, 500 에러 발생 시, 알림을 보내도록 설정했습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/51f33931-cb33-4c0f-a7c3-6a37db209f0c/image.png" alt=""></p>
<p>folder, evaluation group은 임의로 새로 생성해줬습니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/acd2477a-8ec1-4cc4-95c9-004f5b040c95/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/636f4dd6-813a-4eef-8ef9-ae7c792a1c2c/image.png" alt=""></p>
<p><strong>Configure notifications</strong>에는 앞서 설정한 contact info를 지정해줍니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/95a59b09-35f5-4374-94b3-4a7c7a1776a4/image.png" alt=""></p>
<p><br><br></p>
<h2 id="7-notification-policy-설정">7. Notification Policy 설정</h2>
<p>그라파나의 기본 noti는 이메일로 전송됩니다. 따라서, 슬랙으로 정상적으로 알림을 받기 위해서는 이 정책을 수정해야 합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/c13f04b6-63dd-4fcd-a99e-0e708f088223/image.png" alt=""></p>
<p>default policy의 contact point를 알맞게 변경해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/e3926877-b1aa-444e-aa39-083cfc306518/image.png" alt=""></p>
<p><br><br></p>
<h2 id="8-테스트">8. 테스트</h2>
<p>아래처럼 500 에러를 throw하는 API를 호출했을 때, 정상적으로 슬랙에 알림이 오는 것을 확인할 수 있습니다.</p>
<p>이번 실습에서는 아주 간단한 기능만 테스트해봤지만, 실제 운영 환경에서는 CPU usage나 비정상적인 네트워크 접근 등의 지표를 모니터링 할 때 grafana를 효율적으로 사용할 수 있겠습니다!
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/3ef3d2e0-7f6c-452f-89f9-6b0ba02d0c88/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/7780f0bb-9666-429a-b4c6-b032ab4965de/image.png" alt=""></p>
<br>

<blockquote>
<h4 id="reference">Reference</h4>
<p><a href="https://pixx.tistory.com/339">https://pixx.tistory.com/339</a>
<a href="https://dgjinsu.tistory.com/56">https://dgjinsu.tistory.com/56</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Infra] Grafana + Prometheus로 모니터링 시스템 구축하기]]></title>
            <link>https://velog.io/@dooo_it_ly/Infra-Grafana-Prometheus%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/Infra-Grafana-Prometheus%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 31 Aug 2025 13:26:41 GMT</pubDate>
            <description><![CDATA[<p>서비스를 운영하다보면, 다양한 메트릭 로그와 데이터를 수집하게 됩니다. 이 때, Grafana + Prometheus를 이용해 이 메트릭 데이터를 유연하게 다룰 수 있습니다.</p>
<p>서비스의 로그를 prometheus를 이용해 수집하고, 이를 grafana를 이용해 시각화해서 메트릭 데이터를 확인하는 것입니다.</p>
<p>간단하게 springboot + prometheus + garafana 툴을 연동하고, 특정 이벤트가 발생했을 때 개발자에게 grafana에서 slack 알림을 전송하는 실습을 진행해보겠습니다.</p>
<br>


<h2 id="1-그라파나-프로메테우스">1. 그라파나, 프로메테우스?</h2>
<h3 id="1-grafana">1. Grafana</h3>
<ul>
<li>데이터 <strong>시각화</strong>를 위한 <strong>대시보드</strong></li>
<li>시간에 따라 변화하는 데이터를 분석하고 시각화하는 데에 유용한 오픈소스 툴</li>
<li>실시간 모니터링, 성능 분석 등의 유용한 기능들이 있음</li>
</ul>
<h3 id="2-prometheus">2. Prometheus</h3>
<ul>
<li>일정 시간 간격으로 앱에 접근해 <strong>메트릭 데이터를 수집</strong>하는 오픈소스 툴<ul>
<li>메트릭 : 시간 흐름에 따라 추이가 변경되는 데이터 (CPU 사용률)</li>
</ul>
</li>
<li>PromQL이라는 언어를 이용해 데이터를 쿼리할 수 있음</li>
<li>Pull 방식을 이용해 데이터를 수집함</li>
</ul>
<p><br><br></p>
<h2 id="2-프로메테우스-설정">2. 프로메테우스 설정</h2>
<h3 id="1-프로메테우스-설치">1. 프로메테우스 설치</h3>
<p>아래 링크를 통해 알맞은 프로메테우스를 다운로드합니다.</p>
<p><a href="https://prometheus.io/download/">https://prometheus.io/download/</a></p>
<br>

<h3 id="2-프로메테우스-실행">2. 프로메테우스 실행</h3>
<pre><code class="language-bash">./prometheus</code></pre>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/ca967e59-e66b-4c9f-bb52-c1df94b2c3a3/image.png" alt=""></p>
<p><strong>localhost:9090</strong> 에 접속하면 아래와 같은 프로메테우스 초기 화면을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/04d44cdd-2d48-4d1e-b07f-91c6d7400fd6/image.png" alt=""></p>
<p><br><br></p>
<h2 id="3-프로메테우스---springboot-연동">3. 프로메테우스 - SpringBoot 연동</h2>
<h3 id="1-의존성-추가">1. 의존성 추가</h3>
<p>아래와 같이 프로메테우스와 로그 수집을 위한 actuator 의존성을 추가해줍니다. </p>
<p>actuator에서 micrometer는 메트릭 데이터를 수집한 후, 프로메테우스가 이용할 수 있도록 <code>micrometer-prometheus</code> 형태로 데이터를 변환합니다.
프로메테우스는 이 형태의 메트릭 데이터를 pull하여 수집하게 됩니다.</p>
<pre><code class="language-kotlin">implementation(&quot;org.springframework.boot:spring-boot-starter-actuator&quot;)
implementation(&quot;io.micrometer:micrometer-registry-prometheus&quot;)</code></pre>
<p>이후, <code>application.yml</code>에 actuator을 위한 엔드포인트를 추가적으로 설정해줍니다.</p>
<pre><code class="language-yaml">management:
  endpoints:
    web:
      exposure:
        include: prometheus</code></pre>
<br>

<p>이제 <strong>localhost:8080/actuator/prometheus</strong> 에 접속하면 아래와 같은 화면을 확인할 수 있습니다. </p>
<p>이는 수집한 메트릭 데이터를 프로메테우스 형식으로 변형한 데이터들이며, 프로메테우스는 해당 URL에 접근해 메트릭 데이터를 PULL해옵니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/1378471c-3337-4e8f-8970-1deae2c18c49/image.png" alt=""></p>
<br>

<h3 id="2-프로메테우스-yml-수정">2. 프로메테우스 yml 수정</h3>
<p>위에서 설정한 메트릭 수집 URL을 적용시키기 위해, 프로메테우스의 yml 파일을 수정해줍니다.</p>
<pre><code class="language-bash"># yml 파일 오픈
vi prometheus.yml</code></pre>
<p>아래와 같이, job을 새롭게 추가해줍니다.</p>
<pre><code class="language-yaml">  - job_name: &quot;spring-application&quot;
    metrics_path: &#39;/actuator/prometheus&#39;
    scrape_interval: 5s
    static_configs:
      - targets: [&quot;localhost:8080&quot;]</code></pre>
<br>

<h3 id="3-연동-확인">3. 연동 확인</h3>
<p>변경 사항을 저장하고, 프로메테우스를 다시 실행해봅시다.</p>
<p>기존 9090 포트로 접속한 후, <code>Status → Target health</code> 탭에 들어가보면 새로운 target이 추가된 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/37b2d87c-a266-4f4a-92c9-757b78686495/image.png" alt=""></p>
<p>스프링 앱 로그에서도 yml에 설정한대로 5초마다 한 번씩 해당 URI에 접속한 흔적이 있음을 확인할 수 있습니다</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/bea359bd-6353-4529-ac56-1e687afeec28/image.png" alt=""></p>
<br>

<p><code>Query</code> 탭에서 PromQL 쿼리를 이용해 수집한 데이터를 확인할 수 있습니다.</p>
<p><code>http_server_requests_seconds_count{uri=&quot;/actuator/prometheus&quot;}</code> 이라는 쿼리를 이용해 해당 URI에 request를 요청한 횟수를 알아보는 쿼리를 실행한 후 결과를 그래프로 확인할 수 있습니다!</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/7efa6ac1-4d4b-4da0-b39f-663ca99f57af/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/42605389-2ea5-4740-a0cd-0afee78f066a/image.png" alt=""></p>
<p><br><br></p>
<h2 id="4-그라파나-설정">4. 그라파나 설정</h2>
<h3 id="1-그라파나-설치">1. 그라파나 설치</h3>
<p><a href="https://grafana.com/grafana/download">https://grafana.com/grafana/download</a></p>
<p>위의 사이트에서 운영체제에 알맞는 그라파나를 설치해줍니다.</p>
<p>아래 명령어는 MacOS기준 그라파나 설치 명령어입니다.</p>
<pre><code class="language-bash">curl -O https://dl.grafana.com/grafana-enterprise/release/12.1.1/grafana-enterprise_12.1.1_16903967602_darwin_amd64.tar.gz
tar -zxvf grafana-enterprise_12.1.1_16903967602_darwin_amd64.tar.gz</code></pre>
<br>

<h3 id="2-그라파나-실행">2. 그라파나 실행</h3>
<pre><code class="language-bash">./bin/grafana-server</code></pre>
<p><strong>localhost:3000</strong> 으로 접속하면 아래와 같은 초기 화면을 확인할 수 있습니다.
처음 접속하면 기본 정보는 admin, admin이므로, 우선 해당 정보로 로그인을 해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/023503da-a92f-47ee-b237-608c133708f2/image.png" alt=""></p>
<br>

<h3 id="3-프로메테우스-연동">3. 프로메테우스 연동</h3>
<p>프로메테우스 연동을 위해, <strong>Home-Connections-Data sources</strong> 탭으로 이동해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/35fa3bd0-c5f1-4cca-9359-92ab6ddfd3d5/image.png" alt=""></p>
<p><strong>Prometheus</strong>를 클릭합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/eec2df00-f28f-4509-ab8a-d87635f87d63/image.png" alt=""></p>
<p>아래처럼 사전에 설정한 prometheus의 <strong>서버 주소</strong>를 입력한 후 <strong>Save&amp;Test</strong> 버튼을 클릭해 data source 추가를 완료합니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/abcba111-d395-4c0e-8311-61b321521524/image.png" alt=""> <img src="https://velog.velcdn.com/images/dooo_it_ly/post/785cea79-3e4a-40e0-8cb0-2b3b31e00397/image.png" alt=""></p>
<p><br><br></p>
<h2 id="5-대시보드-만들기">5. 대시보드 만들기</h2>
<p>위에서 생성한 prometheus data source를 위한 대시보드를 구축해보겠습니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/6c88cd58-77e5-4d47-a8a2-2a52d0a2397f/image.png" alt=""></p>
<p>대시보드 구축 첫 화면입니다. 기존 다른 대시보드를 import 하는 방법도 있으며 아래 사진처럼 panel을 직접 만드는 방법도 존재합니다.<img src="https://velog.velcdn.com/images/dooo_it_ly/post/b8217c31-0db4-4d93-a0d6-9c5444099784/image.png" alt=""></p>
<p>Builder 기능을 이용하면, 다양한 Metric을 커스텀해 추가할 수 있습니다. PromQL에 능숙하시다면, Code 기능을 이용해 더욱 유연하게 Metric을 편집할 수 있습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/15642b36-cbd3-4b95-a305-45a0a3e2b2bc/image.png" alt=""></p>
<p>각 Metric에 대한 연산도 함께 제공하니, 확인하고 싶은 지표에 따라 커스텀하면 되겠습니다.<img src="https://velog.velcdn.com/images/dooo_it_ly/post/b0d802fc-40d8-4ca5-9f6b-ba514b1cc1c0/image.png" alt=""></p>
<p>cpu usage와 관련된 메트릭을 추가하면 아래와 같이 차트로 지표를 확인할 수 있습니다. <img src="https://velog.velcdn.com/images/dooo_it_ly/post/7344373e-28b5-4b5a-820f-e5ae32cf66ac/image.png" alt=""></p>
<p>visualization 탭에서 다양한 뷰들을 제공하니, 지표에 따라 적절한 보기를 선택하시면 되겠습니다! <img src="https://velog.velcdn.com/images/dooo_it_ly/post/f515af34-0e69-4a48-b305-530fbf47adc4/image.png" alt=""></p>
<br>

<p>이렇게 로컬에서 Grafana + Prometheus 기반 모니터링 툴을 구축해보았습니다. 필요에 따라 대시보드를 적절히 이용해서 운영 서버의 상태를 유용하게 관리할 수 있습니다. </p>
<p>이어서, 그라파나의 Alerting 기능을 이용해, 특정 이벤트가 발생할 경우 Slack 알림을 전송하는 기능을 구현해보겠습니다!👇👇👇</p>
<p><a href="https://velog.io/@dooo_it_ly/Infra-Grafana-Alerting-%EA%B8%B0%EB%8A%A5-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%8A%AC%EB%9E%99-%EC%95%8C%EB%A6%BC-%EB%B0%9B%EA%B8%B0">https://velog.io/@dooo_it_ly/Infra-Grafana-Alerting-기능-이용해-슬랙-알림-받기</a></p>
<br>


<blockquote>
<h4 id="reference">Reference</h4>
<p><a href="https://lordofkangs.tistory.com/327">https://lordofkangs.tistory.com/327</a>
<a href="https://ttl-blog.tistory.com/1366">https://ttl-blog.tistory.com/1366</a></p>
</blockquote>
<br>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Infra] ELK 스택으로 모니터링 시스템 구축하기]]></title>
            <link>https://velog.io/@dooo_it_ly/Infra-ELK-%EC%8A%A4%ED%83%9D%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/Infra-ELK-%EC%8A%A4%ED%83%9D%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 27 Aug 2025 07:17:24 GMT</pubDate>
            <description><![CDATA[<h2 id="1-elk란">1. ELK란?</h2>
<p><strong>ElasticSearch</strong> + <strong>Logstash</strong> + <strong>Kibana</strong> 의 약어로, 로그 수집을 위해 오픈소스 툴들을 이용해 만든 아키텍처의 일환입니다.</p>
<ol>
<li><strong>Logstash</strong><ul>
<li>데이터 처리를 담당함</li>
<li>앱으로부터 받은 로그를 가공함</li>
</ul>
</li>
<li><strong>ElasticSearch</strong><ul>
<li>전처리된 로그 데이터를 저장함</li>
</ul>
</li>
<li><strong>Kibana</strong><ul>
<li>데이터를 시각화 해 확인할 수 있음</li>
</ul>
</li>
</ol>
<br>

<p>서버에서 발생하는 로그들을 수집해 더욱 효율적으로 확인하기 위한 모니터링 스택의 일종으로, 로컬에서 ELK 스택을 구축하는 실습을 진행해보겠습니다.</p>
<p><br><br></p>
<h2 id="2-elasticsearch-설정">2. Elasticsearch 설정</h2>
<h3 id="1-elasticsearch-설치">1. Elasticsearch 설치</h3>
<p>아래 코드를 통해 elasticsearch를 설치하고 압축을 해제해줍니다.</p>
<pre><code class="language-bash"># Elasticsearch 설치
curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-8.14.3-darwin-x86_64.tar.gz
curl https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-8.14.3-darwin-x86_64.tar.gz.sha512 | shasum -a 512 -c - 

# Elasticsearch 압축 해제
tar -xzf elasticsearch-8.14.3-darwin-x86_64.tar.gz
</code></pre>
<br>

<h3 id="2-elasticsearch-실행">2. Elasticsearch 실행</h3>
<pre><code class="language-bash">cd elasticsearch-8.14.3/

# Elasticsearch 실행
./bin/elasticsearch</code></pre>
<p>elasticsearch를 처음 실행하면 다음과 같이 주요 정보들을 확인할 수 있습니다.
password나 키바나 연동 토큰은 이후 작업에서 사용되니 따로 메모해둡시다!
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/c95cf699-2066-452c-9fb4-3c0cfa6822f5/image.png" alt=""></p>
<br>

<h3 id="3-elasticsearch-config">3. Elasticsearch Config</h3>
<p>기본적으로 elasticsearch는 <code>https</code>를 이용해 통신하기 때문에, 로컬에서 테스트할 분들은 <code>config/elasticsearch.yml</code>를 아래와 같이 변경해줍니다.</p>
<pre><code class="language-bash">xpack.security.enabled: false
xpack.security.http.ssl.enabled: false
xpack.security.transport.ssl.enabled: false</code></pre>
<p><br><br></p>
<h2 id="3-logstash-설정">3. Logstash 설정</h2>
<h3 id="1-logstash-설치">1. Logstash 설치</h3>
<p>아래 링크를 통해 Logstash를 설치 후 압축을 해제합니다.
<a href="https://www.elastic.co/kr/downloads/logstash">https://www.elastic.co/kr/downloads/logstash</a></p>
<br>

<h3 id="2-logstash-config">2. Logstash Config</h3>
<p>config 폴더에 <code>logstash.conf</code>라는 이름의 설정 파일을 생성해줍니다.</p>
<pre><code class="language-bash">cd /logstash-9.1.2

# config 파일 생성
vi config/logstash.conf</code></pre>
<br>

<p><code>logstash.conf</code>는 logstash의 입력과 출력에 대한 세부 설정을 명시해주는 파일입니다. 5001포트로 tcp를 이용해 데이터를 수집하고, 이를 elasticsearch로 보낸다는 내용이죠.</p>
<ul>
<li><strong>hosts</strong> : elasticsearch의 host, port num 정보</li>
<li><strong>index</strong> : 수집할 데이터를 저장할 인덱스</li>
<li><strong>user</strong> : elastic</li>
<li><strong>password</strong> : 3-2에서 확인한 elasticsearch pwd 기입</li>
</ul>
<p>만약 앱에서 filebeat를 이용해 로그를 수집한 후 logstash로 보낼 경우, input에 <code>tcp {}</code>가 아닌 <code>beats {}</code>를 이용하면 됩니다.</p>
<pre><code class="language-bash">input {
  tcp {
    port =&gt; 5001
    codec =&gt; json_lines
  }
}

output {
  elasticsearch {
    hosts =&gt; [&quot;localhost:9200&quot;] # elasticsearch host, port num
    index =&gt; &quot;logstash-log&quot;  
    user =&gt; &quot;elastic&quot;
    password =&gt; {elastic_pwd}
  }
  stdout {} # 모니터링용으로 console에 출력함
}</code></pre>
<br>

<p>변경사항을 logstash의 파이프라인에 올바르게 적용하기 위해,  <code>pipelines.yml</code>에 아래 코드를 추가해줍니다.</p>
<pre><code class="language-bash">- pipeline.id: springboot-logstash
  queue.type: persisted
  path.config: config/logstash.conf</code></pre>
<p><br><br></p>
<h2 id="4-springboot-연동">4. SpringBoot 연동</h2>
<p>이제 서비스 단에서 로그를 Logstash로 전송하는 기능을 구현해봅시다.</p>
<h3 id="1-logback-dependency">1. logback dependency</h3>
<p>logback custom을 위한 의존성을 추가해줍니다.</p>
<pre><code class="language-kotlin">implementation(&quot;net.logstash.logback:logstash-logback-encoder:7.4&quot;)</code></pre>
<br>

<h3 id="2-logback-custom">2. logback custom</h3>
<p>우선, 앞서 설정한 logstash의 host, port 정보를 <code>application-secret.yml</code>에서 관리하겠습니다. 
springboot와 logstash는 <strong>tcp</strong>를 이용해 통신하므로, host 정보에 추가적으로 <code>http://</code>와 같은 다른 프로토콜을 등록하면 오류가 발생합니다.</p>
<pre><code class="language-yaml">logstash:
  host: localhost:5001</code></pre>
<br>



<p><code>/resources</code> 폴더에 <code>logback-spring.xml</code> 이라는 이름의 파일을 생성해줍니다.</p>
<blockquote>
<p>보통 logback 커스텀 파일명은 <code>logback.xml</code> 으로 지정하지만, 아래 보시는 것처럼 <code>springProperty</code>를 이용할 경우, 파일명에 반드시 <code>-spring</code>이 붙어야 합니다. <br>
spring 관련 값을 불러오기 전에 <code>logback.xml</code> 파일을 먼저 읽기 때문에, 올바른 값을 넣지 못해 태그를 해석할 수 없어 오류가 발생합니다. 하지만 파일명에 <code>-spring</code>을 넣게 되면, 바로 logback에 넘기지 않고 <code>application.yml</code>에서 관련 값을 찾아 넣고 실행하기 때문에 오류를 방지할 수 있습니다!</p>
</blockquote>
<pre><code class="language-xml">&lt;configuration&gt;

    &lt;!--application.yml에서 목적지 주소 관리--&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;LOGSTASH_HOST&quot; source=&quot;logstash.host&quot;/&gt;
    &lt;!--인덱스로 추가할 서비스명--&gt;
    &lt;springProperty scope=&quot;context&quot; name=&quot;APP_NAME&quot; source=&quot;spring.application.name&quot;/&gt;

    &lt;appender name=&quot;LOGSTASH&quot; class=&quot;net.logstash.logback.appender.LogstashTcpSocketAppender&quot;&gt;
        &lt;!--로그 수집 후 출력할 주소--&gt;
        &lt;destination&gt;${LOGSTASH_HOST}&lt;/destination&gt;
        &lt;encoder class=&quot;net.logstash.logback.encoder.LogstashEncoder&quot;&gt;
            &lt;!--커스텀해 키바나에서 추가적으로 확인할 필드--&gt;
            &lt;customFields&gt;{&quot;index&quot;:&quot;${APP_NAME}&quot;}&lt;/customFields&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;

  &lt;!-- logging level --&gt;
    &lt;root level=&quot;WARN&quot;&gt;
        &lt;appender-ref ref=&quot;LOGSTASH&quot; /&gt;
    &lt;/root&gt;

&lt;/configuration&gt;</code></pre>
<p><br><br></p>
<h2 id="5-kibana-설정">5. Kibana 설정</h2>
<h3 id="1-kibana-설치">1. Kibana 설치</h3>
<p>아래 명령어를 이용해 Kibana를 설치하고 압축을 해제합니다.</p>
<pre><code class="language-bash"># Kibana 설치
curl -O https://artifacts.elastic.co/downloads/kibana/kibana-8.14.3-darwin-x86_64.tar.gz
curl https://artifacts.elastic.co/downloads/kibana/kibana-8.14.3-darwin-x86_64.tar.gz.sha512 | shasum -a 512 -c - 

# Kibana 압축 해제
tar -xzf kibana-8.14.3-darwin-x86_64.tar.gz</code></pre>
<br>

<h3 id="2-kibana-실행">2. Kibana 실행</h3>
<pre><code class="language-bash">cd kibana-8.14.3/ 

## Kibana 실행
./bin/kibana</code></pre>
<br>

<h3 id="3-elasticsearch-연동">3. Elasticsearch 연동</h3>
<p><code>localhost:5601</code> 으로 접속하면 아래와 같은 키바나 화면을 마주할 수 있습니다.
elasticsearch로부터 얻은 키바나 연동 토큰을 입력해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/038ad1bf-a667-4eb1-b365-736191b47174/image.png" alt=""></p>
<p>토큰 입력을 완료하면, 키바나 서버 콘솔에 인증 코드가 나타납니다. 이 코드를 입력해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/32b54666-7c3c-4086-a447-5d08ecad73e0/image.png" alt="">
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/3aa22c47-df80-48f6-a382-d5fec72ad25a/image.png" alt=""></p>
<p>이후, elasticsearch를 처음 시동해서 얻은 비밀번호를 이용해 로그인하면, 두 스택의 연동이 완료됩니다!
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/e31130e2-07e4-4250-a673-1f250541e14d/image.png" alt="">
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/6a0db804-3253-4ca7-9ba1-20259c92ae96/image.png" alt=""></p>
<br>

<h3 id="4-kibana-대시보드-구축">4. Kibana 대시보드 구축</h3>
<p>Kibana에서 Elasticsearch로부터 들어온 로그를 확인할 수 있도록 Data View를 생성해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/f4a64d57-e9f5-4d12-9986-c7616c2de532/image.png" alt=""></p>
<p><code>index pattern</code>에는 logstash config에 작성한 인덱스명을 작성해줍니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/8cc723e1-2abe-4109-905a-7224e19ee023/image.png" alt=""></p>
<p>설정을 마치면, discover 탭에서 아래와 같이 내가 설정한 로그들을 한 눈에 대시보드로 파악할 수 있습니다!
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/64449e5d-25c4-419c-a9ed-ddd067057b5b/image.png" alt=""></p>
<p><br><br></p>
<blockquote>
<h4 id="reference">Reference</h4>
<p><a href="https://kghworks.tistory.com/204">https://kghworks.tistory.com/204</a>
<a href="https://tech.ktcloud.com/255">https://tech.ktcloud.com/255</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SpringBoot] Apple 소셜 로그인 적용하기]]></title>
            <link>https://velog.io/@dooo_it_ly/SpringBoot-Apple-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/SpringBoot-Apple-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 24 Aug 2025 12:45:07 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 하며 구글 소셜 로그인, 애플 소셜 로그인, 자체 로그인을 모두 구현해야 하는 상황이 생겼습니다. 애플 소셜 로그인은 처음 도전하는 거라, 중간 과정을 기록하고자 합니다.</p>
<br>

<h2 id="1-로직">1. 로직</h2>
<p>정리한 로직은 다음과 같습니다.</p>
<ol>
<li><strong>[User → Apple]</strong><ul>
<li>유저가 애플 로그인 버튼 클릭</li>
<li>iOS의 <code>AuthenticationServices</code> 기능 호출해 로그인 수행</li>
<li>네이티브 인증 화면을 통해 애플 로그인 및 인증 진행</li>
</ul>
</li>
<li><strong>[Apple → Client]</strong><ul>
<li><code>identity Token</code> 전달 : <strong>사용자의 고유 정보</strong>가 담긴 JWT</li>
<li><code>authorization Code</code> 전달 : 일회성 인증 코드</li>
</ul>
</li>
<li><strong>[Client → Server]</strong><ul>
<li>애플 로그인 API 호출</li>
<li><code>identity Token</code> 전달</li>
</ul>
</li>
<li><strong>[Server → Apple]</strong><ul>
<li>Apple의 공개키 이용해 <code>identity Toke</code> 검증 수행</li>
</ul>
</li>
<li><strong>[Server → Client]</strong><ul>
<li>회원가입 로직 처리 및 자체 JWT 발급 후 반환</li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/7976e724-8d29-48cc-a4d9-9cf35d5ecd00/image.png" alt=""></p>
<p>애플은 클라이언트에서 로그인 요청 시, <code>identity Token</code>에 사용자 정보를 담아 전달해줍니다. 때문에, 서버는 그 토큰을 검증한 후 토큰에 있는 정보를 이용해 회원가입을 진행하면 됩니다.</p>
<p>이후, <code>authorizationCode</code>를 이용해 애플 측에서 토큰을 받아와 발급하는 경우도 있지만, 이번 프로젝트의 경우 자체 JWT를 발급하는 로직으로 구현하였기에 해당 부분은 고려하지 않았습니다.</p>
<br>

<h2 id="2-기존-구조와의-차이점">2. 기존 구조와의 차이점</h2>
<p>기존에 oauth2를 이용해 구글 소셜 로그인을 구현해둔 상태라, 최대한 기존 구조를 변경하지 않고 애플 소셜 로그인 기능을 붙여보고자 했습니다.</p>
<br>

<p>기존 google login의 경우,</p>
<ol>
<li>클라이언트가 security가 생성해준 API 호출<ul>
<li><code>/oauth2/authorization/google</code> </li>
</ul>
</li>
<li>웹 상에서 구글 로그인 진행</li>
<li>security가 생성해준 서버 리다렉 주소를 이용해 인증 진행 및 토큰 발급<ul>
<li><code>/login/oauth2/code/google</code></li>
</ul>
</li>
<li>서버에서 회원가입 진행 및 토큰 발급</li>
<li>앱(클라이언트)으로 딥링크 발송</li>
</ol>
<p>따라서, 4번을 진행할 <code>oAuthLoginSuccessHandler</code>만 구현하면 됐었습니다.</p>
<br>

<p>그러나 이번에 새롭게 구현할 apple login의 경우,</p>
<ol>
<li>클라이언트가 애플 로그인 진행</li>
<li>서버에게 Identify token 검증 요청 (API 호출)    <ul>
<li><code>/api/auth/apple/login</code></li>
</ul>
</li>
<li>서버가 pub key 이용해 identify token 검증   </li>
<li>검증 후 <code>authentication</code> 객체 생성</li>
<li><code>oAuthLoginSuccessHandler</code> 호출 (회원가입 진행 및 토큰 발급 + 딥링크)</li>
</ol>
<p>의 구조를 띠게 됩니다.</p>
<br>

<p>따라서, 중간 토큰 검증 로직을 추가하고, handler에 <code>AppleUserInfo</code>를 파싱하는 부분만 수정하면 큰 구조 변경 없이 로그인 기능을 붙일 수 있습니다.</p>
<br>

<h2 id="3-public-key로-토큰-검증하기">3. Public Key로 토큰 검증하기</h2>
<p>그럼 우선 애플 공개 키로 들어온 <code>identity Token</code>을 검증해봅시다.
<a href="https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature">https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature</a></p>
<br>

<p>검증 로직은 다음과 같습니다. </p>
<ol>
<li><p><a href="https://appleid.apple.com/auth/keys">https://appleid.apple.com/auth/keys</a> 에 GET 요청을 보냄</p>
</li>
<li><p>여러 개의 public key가 담긴 리스트를 반환 받음</p>
</li>
<li><p>각 public key는 kid, alg 필드가 존재하며, 이전에 받은 <code>identityToken</code> header의 kid와 일치하는 key를 선택함</p>
<ul>
<li>token의 <code>header[’kid’]</code>는 디코딩해서 사용해야 함</li>
</ul>
</li>
<li><p>위에서 선택한 key를 이용해 <code>identityToken</code>을 검증함</p>
</li>
</ol>
<pre><code class="language-kotlin">    /**
     * Validate Apple&#39;s Identify Token And Get Claims
     */
    private fun validateAppleToken(identityToken: String): Claims {
        val pubKeyResponse = appleClient.getPublicKey()
        val kid = jwtUtil.getKidFromToken(identityToken)

        val matchedPubKey = getMatchedPublicKey(kid, pubKeyResponse.keys)
        val pubKey = createPublicKey(matchedPubKey)

        return jwtUtil.getClaimFromIdentityTokenWithPubKey(identityToken, pubKey)
    }</code></pre>
<br>

<h3 id="1-apple-pub-key-가져오기">1. Apple Pub Key 가져오기</h3>
<p>public Key를 저장할 DTO는 다음과 같습니다.</p>
<pre><code class="language-kotlin">data class ApplePublicKeyResponse(
    val keys: List&lt;Key&gt;
)

data class Key(
    val kti: String,
    val kid: String,
    val use: String,
    val alg: String,
    val n: String,
    val e: String
)</code></pre>
<br>

<p>webClient를 이용해, apple로부터 N개의 공개키 리스트를 받아옵니다.</p>
<pre><code class="language-kotlin">@Service
class AppleClientImpl(
    @Value(&quot;\${apple.pub-key-uri}&quot;) private val publicKeyUri: String,
    private val webClient: WebClient
): AppleClient {

    private val log = LoggerFactory.getLogger(this::class.java)

    /**
     * Get Public Key From Apple Server
     */
    override fun getPublicKey(): ApplePublicKeyResponse {
        return webClient.get()
            .uri(publicKeyUri)
            .retrieve()
            .onStatus({ it.is4xxClientError }) { response -&gt;
                response.bodyToMono(String::class.java)
                    .flatMap {
                        log.error(&quot;Client error body: {}&quot;, it)
                        Mono.error(GeneralException(ErrorStatus.APPLE_LOGIN_PUB_KEY_CLIENT_ERROR))
                    }
            }
            .onStatus({ it.is5xxServerError }) { response -&gt;
                response.bodyToMono(String::class.java)
                    .flatMap {
                        log.error(&quot;Server error body: {}&quot;, it)
                        Mono.error(GeneralException(ErrorStatus.APPLE_LOGIN_PUB_KEY_SERVER_ERROR))
                    }
            }
            .bodyToMono(ApplePublicKeyResponse::class.java)
            .block()!!
    }

}</code></pre>
<br>

<h3 id="2-공개키-추출">2. 공개키 추출</h3>
<p>클라이언트로부터 받은 토큰의 헤더를 디코딩해, KID 값을 추출합니다.</p>
<pre><code class="language-kotlin">    fun getKidFromToken(identityToken: String): String {
        val tokenParts = identityToken.split(&quot;.&quot;)
        if (tokenParts.size &lt; 3) {
            throw GeneralException(ErrorStatus.INVALID_IDENTITY_TOKEN_FORMAT)
        }

        val encodedHeader = tokenParts[0]
        val decodedHeader = String(Base64.getUrlDecoder().decode(encodedHeader))

        val headerNode = objectMapper.readTree(decodedHeader)
        val kid = headerNode.get(&quot;kid&quot;)?.asText()

        return kid ?: throw GeneralException(ErrorStatus.APPLE_LOGIN_KID_DECODE_SERVER_ERROR)
    }</code></pre>
<br>

<p>이 KID값과 동일한 KID값을 가진 공개 키가 우리가 토큰 검증에 사용할 공개 키입니다.</p>
<pre><code class="language-kotlin">private fun getMatchedPublicKey(kid: String, pubKeyList: List&lt;Key&gt;): Key {
        return pubKeyList.firstOrNull { it.kid == kid}
            ?: throw GeneralException(ErrorStatus.APPLE_LOGIN_NO_MATCHING_PUB_KEY)
    }</code></pre>
<br>

<p>이 공개키를 이용해 자체적 키를 생성합니다.
키 생성에는 n, e를 이용해 RSA Pub Key를 생성합니다.</p>
<pre><code class="language-kotlin">    private fun createPublicKey(matchedPubKey: Key): PublicKey {
        val n = BigInteger(1, Base64.getUrlDecoder().decode(matchedPubKey.n))
        val e = BigInteger(1, Base64.getUrlDecoder().decode(matchedPubKey.e))

        val keySpec = RSAPublicKeySpec(n, e)
        val keyFactory = KeyFactory.getInstance(matchedPubKey.kty)
        return keyFactory.generatePublic(keySpec)
    }</code></pre>
<br>

<h3 id="3-토큰-검증">3. 토큰 검증</h3>
<p>마지막으로, 방금 생성한 키를 이용해 identityToken을 검증해 Claim을 반환하면 됩니다.</p>
<pre><code class="language-kotlin">    fun getClaimFromIdentityTokenWithPubKey(identityToken: String, pubKey: PublicKey): Claims {
        try {
            return Jwts.parser()
                .verifyWith(pubKey)
                .build()
                .parseSignedClaims(identityToken)
                .payload
        } catch (e: ExpiredJwtException) {
          log.warn(&quot;[*] JWT APPLE Identity Token Expiration error : ${e.message}&quot;)
          throw GeneralException(ErrorStatus.EXPIRED_TOKEN_ERROR)
        } catch (e: Exception) {
            log.warn(&quot;[*] JWT APPLE Identity Token error : ${e.message}&quot;)
            throw GeneralException(ErrorStatus.APPLE_LOGIN_VERIFY_IDENTITY_TOKEN_SERVER_ERROR)
        }
    }</code></pre>
<p><br><br></p>
<h2 id="4-authentication-생성-및-회원가입">4. Authentication 생성 및 회원가입</h2>
<h3 id="1-authentication-생성">1. Authentication 생성</h3>
<p>이제 검증한 토큰 속 정보를 이용해 회원가입을 진행합니다.
OAuth2의 소셜 로그인 로직을 이용하면, 자동으로 유저 정보가 <code>OAuth2AuthenticationToken</code>으로 생성되지만, 여기서는 그렇지 않기 때문에, 직접 만들어 처리 핸들러에게 넘겨줍시다.</p>
<pre><code class="language-kotlin">    /**
     * Valid Apple&#39;s Identity Token And Handle Social Sign-Up
     */
    override fun authenticateWithApple(request: AppleLoginRequest): Authentication {
        val claims = validateAppleToken(request.identityToken)

        val attributes = claims
        val authorities = listOf(SimpleGrantedAuthority(&quot;ROLE_USER&quot;))
        val principal = DefaultOAuth2User(authorities, attributes, &quot;sub&quot;)

        return OAuth2AuthenticationToken(
            principal,
            authorities,
            &quot;apple&quot;
        )
    }</code></pre>
<br>

<h3 id="2-controller">2. Controller</h3>
<p>조금 늦게 등장했지만, 클라이언트에서 호출하는 애플 소셜 로그인 컨트롤러입니다.
앞서 말씀드린대로 identity Token을 넘겨 검증한 후, 회원가입을 처리하는 단계로 구성되어있습니다.</p>
<p>4-1에서 <code>Authentication</code>을 생성했으니, 기존에 만들어둔 <code>oAuthLoginSuccessHandler</code>로 객체를 넘겨 회원가입을 마무리해줍니다.</p>
<pre><code class="language-kotlin">    @PostMapping(&quot;/apple/login&quot;)
    fun appleLogin(
        @RequestBody request: AppleLoginRequest,
        httpRequest: HttpServletRequest,
        httpResponse: HttpServletResponse
    ) {
        val authentication = authService.authenticateWithApple(request)
        oAuthLoginSuccessHandler.onAuthenticationSuccess(httpRequest, httpResponse, authentication)
    }</code></pre>
<br>

<h3 id="3-oauthloginsuccesshandler">3. oAuthLoginSuccessHandler</h3>
<p>handler의 전체 코드입니다.
구글 소셜 로그인의 경우, 리다이렉트되어 돌아온 <code>authentication</code> 객체를 처리하고 애플 로그인의 경우 컨트롤러에서 넘겨준 객체를 처리하는 핸들러입니다.</p>
<br>

<p>로직은 다음과 같습니다.</p>
<ol>
<li>유저에 대한 정보를 받아와 <code>OAuth2UserInfo</code> dto로 변환</li>
<li>이미 존재하는 유저인지 확인</li>
<li>존재하지 않으면 User 정보 저장</li>
<li>자체 JWT 토큰 생성</li>
<li>앱 딥링크로 리다이렉트 전송</li>
</ol>
<pre><code class="language-kotlin">@Component
class OAuthLoginSuccessHandler(
    private val tokenService: TokenService,
    private val userRepository: UserRepository
): AuthenticationSuccessHandler {

    override fun onAuthenticationSuccess(
        request: HttpServletRequest?,
        response: HttpServletResponse?,
        authentication: Authentication
    ) {
        // extract user&#39;s oauth information
        val oAuthToken = authentication as OAuth2AuthenticationToken
        val userInfo = getOAuth2UserInfo(oAuthToken)

        // save new user or deal with existing user
        val user = userRepository.findByProviderId(userInfo.getProviderId())
            ?: userRepository.save(
                User(
                    username = userInfo.getName(),
                    providerId = userInfo.getProviderId(),
                    email = userInfo.getEmail(),
                    provider = PROVIDER.from(userInfo.getProvider())
                )
            )

        // create access, refresh token
        val (accessToken, refreshToken) = tokenService.createAndSaveToken(user.userId)

        // set response
        val redirectUri = &quot;APP_LINK?accessToken=$accessToken&quot;
        response?.sendRedirect(redirectUri)
    }

    private fun getOAuth2UserInfo(oAuthToken: OAuth2AuthenticationToken) : OAuth2UserInfo {
        val provider = oAuthToken.authorizedClientRegistrationId
        val principal = oAuthToken.principal

        return when(provider) {
            &quot;google&quot; -&gt; GoogleUserInfo(principal.attributes)
            &quot;apple&quot; -&gt; AppleUserInfo(principal.attributes)
            else -&gt; throw GeneralException(ErrorStatus.INVALID_OAUTH_PROVIDER)
        }
    }

}</code></pre>
<p>여기서 등장하는 <code>OAuthUserInfo</code>란, 자체적으로 유저 정보를 쉽게 가져오기 위해 만든 인터페이스입니다.</p>
<pre><code class="language-kotlin">interface OAuth2UserInfo {
    fun getProviderId(): String
    fun getName(): String
    fun getEmail(): String
    fun getProvider(): String
}</code></pre>
<p>애플 로그인의 경우 다음과 같이 작성해 핸들러에서 보다 쉽게 유저의 정보를 가져올 수 있겠죠.</p>
<pre><code class="language-kotlin">class AppleUserInfo(
    private val attributes: Map&lt;String, Any&gt;
) : OAuth2UserInfo {

    override fun getProviderId(): String {
        return attributes[&quot;sub&quot;].toString()
    }

    override fun getName(): String {
        return (attributes[&quot;email&quot;] as? String)?.substringBefore(&quot;@&quot;) ?: &quot;Apple&quot;
    }

    override fun getEmail(): String {
        return attributes[&quot;email&quot;].toString()
    }

    override fun getProvider(): String {
        return PROVIDER.APPLE.name
    }
}</code></pre>
<br>


<p>애플 자체 토큰을 사용하지 않았기 때문에, 비교적 빠르게 로그인 기능을 구현해볼 수 있었습니다. 중간 로직 정리만 잘 하면 다른 소셜 로그인과 크게 다르지 않았습니다.</p>
<p>다음에 기회가 된다면, 애플 자체 토큰도 사용해보고 포스팅하도록 하겠습니다. 아자아자</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] Bitmap Index란?]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-Bitmap-Index%EB%9E%80</link>
            <guid>https://velog.io/@dooo_it_ly/DB-Bitmap-Index%EB%9E%80</guid>
            <pubDate>Tue, 29 Jul 2025 10:15:49 GMT</pubDate>
            <description><![CDATA[<h2 id="1-bitmap-index">1. Bitmap Index</h2>
<p>비트맵 인덱스란, 인덱스 컬럼의 데이터를 0과 1, 즉 <strong>비트로 변환해 인덱스의 키로 사용</strong>하는 방법입니다. 이는 인덱스 키 value, 즉 컬럼의 데이터를 포함하는 행의 주소를 제공합니다.</p>
<br>


<p>bitmap index에 대해 그림으로 먼저 살펴봅시다.
<code>Student</code> table의  <code>major</code>, <code>grade</code> 컬럼에 대해 bitmap index를 생성하면 아래와 같습니다.</p>
<p><code>major</code> 컬럼에 대한 인덱스는 비트를 값으로 가지는 맵 형태로 표현할 수 있습니다.
인덱스의 행은 컬럼의 도메인 값(Computer, Software, Law), 컬럼은 <code>Student</code> table의 PK(1, 2, 3, 4, 5, 6)가 됩니다.</p>
<pre><code class="language-sql">SELECT *
FROM Student
WHERE major = Software
AND grade = 2</code></pre>
<p>이라는 SQL문 수행을 위해서는 WHERE절의 두 번의 조건을 위한 필터링을 거쳐야 합니다. </p>
<p>먼저, <code>major = Software</code> 조건을 위해 major 인덱스에서 Software행을 찾습니다. 마찬가지로, <code>grade = 2</code> 조건을 위해 grade 인덱스에서 2행을 찾아 <code>AND</code> 연산을 수행합니다. 결과가 <strong>1</strong>인 칼럼의 값을 반환하면 빠르게 필터링을 수행할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/741d73a0-7c6a-4026-a1b9-dbcce953f5bc/image.png" alt=""></p>
<br>


<h2 id="2-bitmap-index의-특징">2. Bitmap Index의 특징</h2>
<p>위의 내용을 살펴보았을 때, bitmap index의 특징은 다음과 같습니다.</p>
<ul>
<li><strong>비트맵 인덱스의 길이는 레코드의 수와 동일</strong>합니다.<ul>
<li>그러나 테이블의 레코드 수만큼 인덱스를 생성하면 크기가 매우 커져 오히려 성능이 저하될 수 있습니다.</li>
<li>따라서, 인덱스 엔트리에 Range를 이용해 시작 레코드 id, 끝 레코드 id를 저장해 관리합니다.</li>
</ul>
</li>
<li>B-Tree에 비해 <strong>다양한 연산</strong>을 수행할 수 있습니다.</li>
<li>데이터가 비트로 표현되기 때문에 <strong>저장 공간에 대한 효율성</strong>이 좋습니다.</li>
<li><strong>다중 조건</strong>을 만족하는 튜플 계산에 유용합니다.</li>
<li>칼럼의 중복도, 즉 <strong>분포도가 높을 때 사용</strong>하기 좋습니다.<ul>
<li>분포도가 낮을 경우, 유니크한 값마다 인덱스를 생성해야 하기 때문에 오히려 성능이 저하됩니다.</li>
</ul>
</li>
<li><strong>읽기 연산</strong>이 많이 일어나는 경우에 사용하기 좋습니다.<ul>
<li>삭제, 수정 연산이 많이 일어나는 경우, 1값을 0으로 바꾸는 등의 연산이 필요합니다.</li>
</ul>
</li>
</ul>
<p><br><br></p>
<p>B-Tree와 다르게, 중복도가 높은 데이터에 대한 읽기 연산을 할 경우 유용하게 사용되는 인덱스입니다. Oracle 등 bitmap index가 지원되는 DB를 사용할 경우, 알아두면 유용할 것 같습니다. 참고로 MySQL은 bitmap index를 지원하지 않습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] PostgreSQL Partial Index로 쿼리 성능 개선하기]]></title>
            <link>https://velog.io/@dooo_it_ly/DB-PostgreSQL-Partial-Index%EB%A1%9C-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/DB-PostgreSQL-Partial-Index%EB%A1%9C-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 09 Jul 2025 06:14:03 GMT</pubDate>
            <description><![CDATA[<h2 id="1-partial-index">1. Partial Index</h2>
<p>PostgreSQL에는 <strong>Partial Index</strong>라는 기능이 있습니다.
속히 부분 인덱스라고 불리는데, 말 그대로 특정 칼럼에 대해 <strong>조건</strong>을 걸어 해당 조건에 부합하는 행에 대해서만 인덱스를 생성할 수 있습니다.</p>
<p>전체 테이블에 대해 생성하지 않고 부분적으로만 인덱스를 생성하기 때문에, 인덱스의 크기가 보다 감소하게 되는데요, 이를 통해 공간 효율성과 성능 향상을 기대해볼 수 있습니다.</p>
<p>인덱스는 전체 행에 대비해 크기가 작은 행을 찾을 때 사용하면 더욱 효과적입니다. partial index를 기반으로 조건을 통해 더 작은 크기의 인덱스를 생성한다면, 이 인덱스를 보다 더욱 효과적으로 사용할 수 있습니다.</p>
<br>

<h2 id="2-조회-쿼리-성능-개선하기">2. 조회 쿼리 성능 개선하기</h2>
<p>partial index를 이용해 기존에 사용하던 조회 쿼리의 성능을 개선해보겠습니다. 예제에 등장하는 테이블의 로우 개수는 <strong>약 8000개</strong>입니다.</p>
<br>

<h3 id="2-1-기존-쿼리-분석">2-1. 기존 쿼리 분석</h3>
<p>기존 쿼리는 다음과 같습니다.</p>
<pre><code class="language-sql">SELECT usp1_0.step_progress_id, usp1_0.completed_at, 
    usp1_0.created_at,usp1_0. duration_time, usp1_0.step_type, 
    usp1_0.updated_at, usp1_0.user_id 
FROM user_step_progress usp1_0 
WHERE usp1_0.user_id=? 
    AND usp1_0.completed_at IS NOT NULL</code></pre>
<p>WHERE절에서 <code>user_id</code>필드에 대해 등호 연산자를 사용한 뒤, <code>completed_at</code>필드가 NULL이 아닌 것에 대한 필터를 수행하고 있습니다.</p>
<br>

<p>해당 쿼리에 대해 <code>EXPLAIN ANALYZE</code> 결과를 보면, WHERE절 조건을 확인하기 위해 <strong>Seq Scan</strong>을 하고 있는 것을 확인할 수 있습니다.</p>
<pre><code class="language-sql">Seq Scan on user_step_progress usp1_0  (cost=0.00..173.11 rows=8 width=56) (actual time=0.028..1.332 rows=2 loops=1)
  Filter: ((completed_at IS NOT NULL) AND (user_id = 3))
  Rows Removed by Filter: 8007
Planning Time: 0.573 ms
Execution Time: 1.366 ms</code></pre>
<br>

<h3 id="2-2-인덱스-추가">2-2. 인덱스 추가</h3>
<p><code>user_id = ?</code> 에 대한 조건을 위해 인덱스를 추가하기 위해서는, JPA의 <code>@Index</code> 기능을 이용해 entity 파일에 쉽게 인덱스를 생성할 수 있습니다. </p>
<p>그러나, 이 포스팅의 주요 요점은 partial index입니다. JPA의 <code>@Index</code>는 partial index 생성을 지원하지 않기 때문에 다른 방법을 고안해야합니다.</p>
<br>

<blockquote>
<ol>
<li>db 콘솔에서 직접 SQL문을 작성해 인덱스 추가</li>
<li>SQL문을 따로 파일로 관리
 2-1. flyway 이용해 마이그레이션 버전 관리
 2-2. 매번 init</li>
</ol>
</blockquote>
<p>1번 방법의 경우 SQL문 관리가 힘들기 때문에 저는 2번 방법을 택했습니다.</p>
<p><strong>flyway</strong>는 db 마이그레이션 버전 관리를 돕는 오픈소스 툴입니다. 해당 툴을 이용해 SQL문을 관리하고자 했으나, postgresql 17.5버전을 아직 지원하지 않아 사용할 수 없었습니다. 추후 기회가 된다면, 해당 방법을 적용해볼 예정입니다.</p>
<p>결과적으로, SQL문을 파일로 관리하되, 프로그램 실행 시 SQL문을 init하는 방법을 택했습니다. 운영보다는 실습이 중심인 프로젝트이기 때문에, 우선은 버전 관리를 크게 고려하지 않았습니다.</p>
<br>

<p><code>application.yml</code>에 <code>sql.init.mode</code> 옵션을 alway로 변경합니다.</p>
<pre><code class="language-yml">spring:
  sql:
    init:
      platform: postgresql
      mode: always</code></pre>
<br>

<p>resources 폴더에 <code>schema.sql</code> 이라는 파일을 두고 SQL문을 작성했습니다. 애플리케이션을 실행하면 해당 파일에 작성된 SQL문이 실행되어 DB에 적용됩니다.</p>
<p><code>user_id</code> 필드에 대한 인덱스와 더불어, <code>completed_at</code> 필드가 null이 아닌 조건을 추가로 걸어 partial index를 생성했습니다.</p>
<pre><code class="language-sql">/**
  Courses
 */
CREATE INDEX IF NOT EXISTS idx_user_step_progress_user_completed_partial
    ON user_step_progress(user_id)
    WHERE completed_at IS NOT NULL;</code></pre>
<br>


<h3 id="2-3쿼리-재분석">2-3.쿼리 재분석</h3>
<p>인덱스를 추가한 이후, 쿼리의 실행 계획을 다시 살펴보았습니다.</p>
<p>기존에 조건절을 살펴보기 위해 테이블을 순차적으로 스캔했던 것에 비해, 생성한 인덱스를 이용해 <strong>Index Scan</strong>을 하는 것을 확인할 수 있습니다.</p>
<pre><code class="language-sql">Index Scan using idx_user_completed_partial on user_step_progress usp1_0  (cost=0.13..12.27 rows=8 width=56) (actual time=0.047..0.048 rows=2 loops=1)
  Index Cond: (user_id = 3)
Planning Time: 0.287 ms
Execution Time: 0.073 ms</code></pre>
<p>실행 시간도 기존 실행 시간에 비해 <strong>18배</strong>나 감소했습니다!</p>
<br>

<h3 id="2-4-부분-인덱스가-아니라면">2-4. 부분 인덱스가 아니라면?</h3>
<p>2-3의 결과는 where절 조건에 딱 부합하는 partial index를 이용해 나온 최적의 결과입니다. partial index가 아닌 <code>user_id</code>, <code>completed_at</code> 칼럼에 대한 일반적인 인덱스를 활용하면 성능이 얼마나 개선될지 확인해봅시다.</p>
<br>

<p>다음과 같은 인덱스를 생성해주고 실행 계획을 살펴보았습니다.</p>
<pre><code class="language-sql">CREATE INDEX user_step_progress_user_id_completed_at_index
    ON user_step_progress(user_id, completed_at);</code></pre>
<br>

<p>마찬가지로 생성한 인덱스를 사용해 <strong>Index Scan</strong>을 하고 있기는 하나, condition 부분이 조금 다른 것을 확인할 수 있습니다.</p>
<pre><code class="language-sql">Index Scan using user_step_progress_user_id_completed_at_index on user_step_progress usp1_0  (cost=0.28..20.69 rows=8 width=56) (actual time=0.553..0.557 rows=2 loops=1)
  Index Cond: ((user_id = 3) AND (completed_at IS NOT NULL))
Planning Time: 1.645 ms
Execution Time: 0.588 ms</code></pre>
<p>또한 기존 실행 시간보다 <strong>약 2.3배</strong> 성능이 개선되었으나, partial index를 이용한 것의 결과에 비하면 약 8배 정도 성능이 더딘 것을 알 수 있습니다.</p>
<p><br><br></p>
<p>확연히 일반 인덱스에 비해 partial index를 잘 이용하면 성능을 엄청나게 향상시킬 수 있습니다. 다만, 과도하고 적절하지 않은 인덱스 이용은 오히려 성능 저하를 불러올 수도 있으니, 상황에 따라 지표를 확인해가며 적절한 인덱스 전략을 이용하는 것이 좋습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GLFW error 65542 해결하기]]></title>
            <link>https://velog.io/@dooo_it_ly/GLFW-error-65542-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dooo_it_ly/GLFW-error-65542-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 07 Jul 2025 06:56:04 GMT</pubDate>
            <description><![CDATA[<p>신나게 마인크래프트를 하고 있던 어느 날 ..
갑자기 화면이 멈추고 컴퓨터가 다운되어 버렸습니다. 컴퓨터를 오래 썼기 때문일까요 ..</p>
<p>다시 전원을 켜 마인크래프트를 켜 접속해보니, <strong>GLFW error 65542</strong> 라는 Alert창이 나타나며 게임을 실행할 수 없었습니다.</p>
<p>Window 환경에서 해당 문제를 해결한 절차를 간략히 기록하고자 합니다.</p>
<br>

<h2 id="1-문제-원인-파악">1. 문제 원인 파악</h2>
<p>발생한 예외는 그래픽 구동 오류의 일종입니다. 먼저, 그래픽 관련 시스템에 문제가 있는지 확인해봅니다.</p>
<p><code>window + x</code>를 누르면 다음과 같은 창이 뜹니다. <strong>장치 관리자(M)</strong> 탭으로 들어갑니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/a30604e7-5ce7-48c2-9844-dd39417e024f/image.jpeg" alt=""></p>
<br>

<p>장치관리자의 <strong>디스플레이 어댑터</strong> 항목을 보니, AMD Radeon 장치에 경고 표시가 떠있습니다.
<img src="https://velog.velcdn.com/images/dooo_it_ly/post/7230b38d-cf9a-4585-8a10-e1cf6ed02088/image.PNG" alt=""></p>
<p>문구를 확인해보니, 해당 디스플레이 드라이버를 window에서 정상적으로 로드하지 못해 문제가 발생한 것으로 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/21de2bb6-1592-465a-b3fc-8864376c0aff/image.PNG" alt=""></p>
<br>

<h2 id="2-해결-방법">2. 해결 방법</h2>
<p>해당 장치를 올바르게 다시 쉽고 빠르게 로드하기 위해선 두 가지 방법을 실행할 수 있습니다.
<strong>1. 디스플레이 드라이버 제거 후 재설치
2. 명령어를 이용한 파일시스템 점검</strong></p>
<p>1번 방법의 경우, 위의 창에서 해당 장치를 제거한 이후, 드라이버 제조사 사이트에서 알맞은 버전을 다운받으면 됩니다.</p>
<p>저는 시간 상의 제약과 맥북 유저이기 때문에 윈도우에 대해 무지하다는 점을 고려해 명령어를 이용해 파일시스템을 점검하는 방법을 선택했습니다.</p>
<br>

<p>우선, 관리자 모드로 cmd 창을 실행하고 아래 명령어를 실행합니다.</p>
<pre><code class="language-bash">sfc /scannow</code></pre>
<p>명령어를 실행하면, 아래와 같은 창이 뜨며 내부에서 파일시스템을 검사하고 손상된 부분을 복구합니다.</p>
<p><img src="https://velog.velcdn.com/images/dooo_it_ly/post/50188149-fd33-4caf-bde4-33f8b20d8207/image.PNG" alt=""></p>
<p>복구하는데에 시간이 소요되며, 복구가 완료되면 시스템을 재시작합니다.
장치 관리자 창에 다시 들어가 그래픽 드라이버의 연결 상태를 확인해보면, 오류 문구가 사라지고 손상된 부분이 복구된 것을 확인할 수 있습니다.</p>
<br>

<p>혹여나 해당 방법으로도 문제가 해결되지 않는다면, 드라이버 재설치 방법을 권장드립니다! </p>
]]></description>
        </item>
    </channel>
</rss>