<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>standard-chan.log</title>
        <link>https://velog.io/</link>
        <description>호기심 많은 개발자</description>
        <lastBuildDate>Sun, 29 Mar 2026 08:55:28 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>standard-chan.log</title>
            <url>https://velog.velcdn.com/images/standard-chan/profile/ecd9c058-7708-49b3-910d-e37c69561fc7/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. standard-chan.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/standard-chan" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[storage - 9] 우선순위 큐 및 스케쥴러 도입하여 업로드 작업 처리하기]]></title>
            <link>https://velog.io/@standard-chan/storage-9-%EC%9A%B0%EC%84%A0%EC%88%9C%EC%9C%84-%ED%81%90-%EB%B0%8F-%EC%8A%A4%EC%BC%80%EC%A5%B4%EB%9F%AC-%EB%8F%84%EC%9E%85%ED%95%98%EC%97%AC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%9E%91%EC%97%85-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/storage-9-%EC%9A%B0%EC%84%A0%EC%88%9C%EC%9C%84-%ED%81%90-%EB%B0%8F-%EC%8A%A4%EC%BC%80%EC%A5%B4%EB%9F%AC-%EB%8F%84%EC%9E%85%ED%95%98%EC%97%AC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%9E%91%EC%97%85-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 29 Mar 2026 08:55:28 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<blockquote>
</blockquote>
<p>**
작은 파일에게 더 많은 대역폭을 우선적으로 주는 방법은 없을까?
&amp;&amp;
동시 업로드 요청 수를 어떻게 더 많이 수용할 수 있을까?
**</p>
<blockquote>
</blockquote>
<h2 id="0-문제-인식">0. 문제 인식</h2>
<h3 id="1-업로드-대역폭을-균등-분배하는-구조">1. <strong>업로드 대역폭을 균등 분배하는 구조</strong></h3>
<p>현재 시스템이 <strong>동시 업로드 요청을 많이 수용하지 못하는 원인</strong> 중 하나는, 모든 요청에 대역폭을 균등하게 분배하는 구조 때문이다. (근본 원인은 업로드 속도가 한정되어있기 때문이지만)</p>
<p>요청이 몰릴 경우 요청당 업로드 속도가 250KB 수준으로 떨어질 수 있으며, 이로 인해 저용량 파일 업로드가 대용량 요청에 밀려 지연되고 평균 처리 시간이 늘어나는 문제가 생긴다.
사용자 입장에서 5MB짜리 파일을 올리는 데 50초 이상이 걸린다면 분명 불쾌한 경험이 될 것이다.</p>
<p>그렇다면 <strong>요청별로 업로드 속도를 제어할 수 있다면 어떨까?</strong> 대용량 파일에 할당된 대역폭 일부를 저용량 파일 쪽으로 돌려, 작은 파일을 우선적으로 빠르게 처리하는 방식이다.</p>
<p>대용량 파일 업로드 속도가 느려지는 것에 대한 거부감도 있을 수 있지만, 1GB 파일 업로드에 시간이 걸린다는 사실은 사용자도 충분히 인지하고 있기 때문에, 이는 납득 가능한 트레이드오프라고 판단했다.</p>
<h3 id="동시-처리-제한을-초과한-요청을-즉시-거절하는-구조"><strong>동시 처리 제한을 초과한 요청을 즉시 거절하는 구조</strong></h3>
<p>현재는 약 35개의 요청이 동시에 들어올 경우, <strong>초과 요청을 즉시 거절하는 방식을 택하고 있다</strong>.</p>
<p>이 제한을 둔 이유는 서버 안정성 때문이 아니다. 동시 요청 수가 일정 수준을 넘으면 요청당 업로드 속도가 저하되는데, 먼저 들어온 요청이 나중 요청으로 인해 느려지는 것은 공정하지 않다고 판단했기 때문이다.</p>
<p>그러나 사용자 입장에서는 요청이 거절될 때마다 반복적으로 재시도해야 하는 상황 자체가 불편할 수 있다.
<strong>차라리 업로드 요청을 일단 대기열에 받아두고, 처리 가능한 시점에 순서대로 진행하는 편이 낫지 않을까?</strong> 업로드에 시간이 걸리더라도, 요청을 걸어두고 다른 작업을 이어가는 쪽이 사용자 경험 측면에서 훨씬 좋을 것 같다고 생각했다.</p>
<p>사용자 입장에서는 업로드 대기가 걸려도 괜찮으니, 우선 업로드 해놓고 다른일 하는게 더 좋지 않을까?</p>
<hr>
<h1 id="1-해결-방안-찾기">1. 해결 방안 찾기</h1>
<p>우연히 식사를 하면서 이런저런 이야기를 하다가 CPU 이야기가 나오게되었고, 그러다가 이러한 CPU 스케쥴링 방식을 현재 나의 서비스에 도입해보면 어떨지 고민해보게되었다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/3404b3a3-a9aa-40d3-b755-5d51ef30fd4d/image.png" alt="CPU 아이콘"></p>
<p>이걸 도입해보는 것은 어떨까?</p>
<p>CPU는 작업의 우선순위와 대기시간 등을 고려해서, 작업을 할당한다. 설정한 CPU 스케줄링 방식에 따라서 작업을 길게 할당할수도 있고, 짧게 사용하게 만들 수도 있다. </p>
<p>이를 이용한다면, <strong>작은 파일에게 더 많은 대역폭을 우선적으로 줄 수 있을 것</strong>이라고 생각했다.</p>
<ul>
<li>용량이 작은 파일에 우선순위를 높게 하여, CPU 연산 할당을 더 많이 받을 수 있도록 한다.</li>
</ul>
<p>뿐만아니라 많은 작업들을 동시에 받아도, 작업을 할당 하지 않으면 되므로, <strong>사용자의 업로드 요청을 대기시키는 것도 가능</strong>할 것이라 생각했다.</p>
<p><strong>즉, 2가지 요구사항</strong></p>
<ul>
<li>업로드 대역폭을 요청에 따라 다르게 분배</li>
<li>사용자가 즉시 거절받지 않고, 요청을 대기시키는 것</li>
</ul>
<p>모두 충족할 수 있는 좋은 방법이라 생각하여 CPU 스케쥴링의 방식을 한번 도입해보기로 하였다.</p>
<hr>
<h1 id="2-cpu-스케쥴링-도입하기">2. CPU 스케쥴링 도입하기</h1>
<h2 id="21-어려웠던-점">2.1 어려웠던 점</h2>
<blockquote>
<p><strong>CPU 스케쥴링을 그대로 도입하기는 어려웠다.</strong></p>
</blockquote>
<p>CPU는 스레드 기반으로 작업을 할당하는데, <strong>현재 프로젝트는 JS기반으로 구현</strong>했기 때문이다. JS는 JAVA와 같이 스레드를 사용하는 방식이 아니라, event loop로 작업을 등록하기 때문에, 작업에 우선순위를 설정하기가 어렵다.</p>
<p>따라서 스레드 자체를 제어하기 보다는 다른 방식으로 제어해야했다.</p>
<h2 id="22-application-단에서-제어하기">2.2 Application 단에서 제어하기</h2>
<p>직접 low level로 들어가기가 어려우므로, application 수준에서 작업의 할당을 제어하기로 하였다.</p>
<p>현재 업로드 요청들이 들어오면, 각 작업들이 chunk 단위로 병렬적으로 처리된다. event loop 기반으로 순서대로 a → b→ c → d→ … 로 DISK 쓰기가 처리되고, 도중에 멈춘다면 다시 event loop에 등록이 완료되는 순서대로 a → b→ c→ d→… 순서로 처리된다. </p>
<h3 id="방법-1--event-loop-등록-queue-방식">방법 1 : Event loop 등록 queue 방식</h3>
<blockquote>
<p><strong>우선순위가 높은 작업의 실행 횟수를 늘려보자</strong></p>
</blockquote>
<p>CPU가 ‘우선순위가 높은 작업에 시간을 더 많이 할당한다’는 특성을 고려해서, <strong>우선순위가 높은 작업이 더 할당이 많이 되도록</strong> 만들어보려고 하였다. 즉, a라는 작업의 우선순위를 둬서, <code>a → b → a → c → a → d → …</code> 로 처리하도록 만드는 것이다. </p>
<p>이렇게 하려면, <strong>event loop에 등록되는 순서를 조절하는 큐</strong>를 만들면 될 것 같았다.</p>
<p>현재 업로드 흐름을 다음과 같다.</p>
<p>socket buffer → stream → writable stream → disk cache 순서로 DISK 쓰기 작업이 진행되고,</p>
<p>writable stream이 가득 차게되면(DISK IO 병목 등의 원인), pause가 되었다가 이후 재실행할 때, event loop에 재실행을 등록하여 실행하는 구조이다.</p>
<p>나도 공부를 더 많이 해봐야겠지만, 내가 의도한 방향은 재실행할 때, 사용되는 함수 <code>drain()</code>을  <strong>우선순위 큐 → event loop</strong> 등록을 한다면, 우선순위가 높은 작업들이 event loop에 먼저 들어가게 될 것이고 따라서 먼저 disk 쓰기가 될 것이라고 생각했다.</p>
<p>하지만 이러한 drain은 libuv에서 관리를 하기 때문에, 내가 그 사이에 우선순위 큐를 넣어서 직접 작업을 관리하기가 어렵다고 한다.</p>
<p>사실 drain() 부분을 잘 모르기도 하고, 모르기 때문에 어떻게 해야할지를 모르겠어서, 우선은 다른 방법이 있는지 고민을 더 해보았다. 다른 방법도 없다면, 그때 더 깊게 공부를 해야겠다고 생각했다.</p>
<h3 id="방법2--각-요청의-처리량-제어">방법2 : 각 요청의 처리량 제어</h3>
<blockquote>
<p><strong>우선순위가 높은 작업의 처리량을 늘려보자</strong></p>
</blockquote>
<p>이전에는 <strong>작업 시간</strong>을 더 많이 할당했다면, 조금 더 근본적으로 다가가서 <strong>작업의 처리량</strong>을 더 많이 할당해보려고 했다.</p>
<p>즉, <strong>stream을 통해 지나가는 데이터의 양을 제한</strong>하는 것이다. 그리고 <strong>그 데이터의 총량을 스케쥴링을 통해 분배</strong>하는 것이다. 다시말해서, <strong>각 요청의 업로드 속도를 스케줄링을 통해 제어하는 방법이다.</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/4c077466-80c1-493c-b012-c4e61f561771/image.png" alt="작업 할당"></p>
<p>만약, 100MB/s 속도의 제한이 있다면, 우선순위가 높은 작업에는 70MB/s, 낮은 작업에는 30MB/s를 설정하는 것이다.CPU 스케쥴링처럼 새로운 작업이 들어오면, 그 상황에 맞게 다시 할당하는 방식을 반복하여 각 작업의 우선순위를 보장하는 방법이다.</p>
<p>이론적으로나 구현상으로나 크게 걱정될 부분이 없다고 생각하여, 더 구체적으로 들어가보았다.</p>
<h3 id="업로드의-속도-제어는-어떻게-할것인가">업로드의 속도 제어는 어떻게 할것인가?</h3>
<p>Rate Limit 을 하는 다양한 알고리즘이 있다.</p>
<p>그 중에, <strong>Token Bucket 알고리즘</strong>을 사용하면, 안정적이고 낮은 메모리 사용으로 속도 제한 조건을 만들어낼 수 있다고 생각했다. </p>
<p>각 요청에 token을 할당하고, 해당 token을 모두 사용하면 pause를 걸어서 추가적인 데이터의 이동을 막아내는 방식이다. 구현도 크게 어렵지 않고, 데이터의 손실도 막을 수 있으므로 적당한 방법이라고 생각했다.</p>
<p>그리고 주기적으로 token을 채워지게 해서, 속도를 제한시키는 것이다.</p>
<p>token bucket은 일종의 게임 피로도라고 생각하면 될 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/40544309-896e-474b-a99e-96f3f8bde6d2/image.png" alt="게임 피로도"></p>
<p>메이플스토리의 전문기술 피로도가 해당 token bucket 알고리즘이라고 생각하면 된다. 하루에 100이 회복되고, 100을 모두 사용하면 더 이상 사용하지 못하는 구조와 동일하다.</p>
<h3 id="작업의-제한-속도는-분배는-어떻게-할-것인가">작업의 제한 속도는 분배는 어떻게 할 것인가?</h3>
<p>새로운 요청이 들어올 때마다 진행 중인 각 요청의 업로드 속도를 재할당해야 한다. 즉, 토큰 발급 속도를 실시간으로 조정해야 한다. 이를 위해 <strong>매 순간 점수를 산정하고, 그 점수에 따라 토큰을 발급하는 방식</strong>을 고려했다.</p>
<p>현재 내가 사용하고 있는 DISK의 IO가 100MB/s이니, 점수의 합을 70정도로 놓고, 70내부에서 IO를 분배하는 방식을 사용하는 것이다.</p>
<p>다만, <strong>점수 산정 방식</strong>은 <strong>언제든지 변경할 수 있도록 유연하게 설계해야 한다</strong>. 현재는 용량을 기준으로 고정하되, 향후 상황에 따라 기준을 바꿀 수 있는 구조로 만들어야 한다고 판단했다.</p>
<hr>
<h1 id="3-작업-대기큐-만들기">3. 작업 대기큐 만들기</h1>
<blockquote>
<p><strong>두 번째 목표는 거절하는 요청을 최대한 적게 만드는 것이다.</strong></p>
</blockquote>
<p>사용자 입장에서, 거절받아서 재시도를 하는 것보다, 시간이 조금 소요되더라도 승인받고 기다리는게 더 좋다고 판단했다. 그래서 <strong>요청이 대기할 수 있는 대기큐를 만들어서, 최대한 많은 요청을 받게 만들어야겠다</strong>고 생각했다.</p>
<p>처음에는 단순하게 모든 요청을 받고, 우선순위에 따라서 업로드 속도를 0에 가까이 제한하는 방식을 생각했다. 하지만 이렇게 하면, 많은 요청으로 서버가 불안정해지고, 사용자는 지나치게 많은 시간을 대기해야하는 문제가 생길 수 있다. </p>
<p>따라서</p>
<ul>
<li>요청은 최대한 많이 받되,</li>
<li>안될 요청들을 빨리 거절하기</li>
</ul>
<p>의 방향으로 <strong>앞단에 대기큐를 놓는 것이 좋다고 생각</strong>했다.</p>
<p>따라서 일정 수의 요청만을 처리하되 나머지는 대기할 수 있도록 만드는 것이다.</p>
<p>그래서 우선순위 큐를 앞 단에 두어서, 1차 통로를 만들고, 이를 거쳐서 들어오도록 만들 생각이다. <strong>1차 통로에서는 타임아웃 시간을 짧게 설정하여, 안된다 싶으면 빨리 거절하는 방식</strong>을 채택하려고 하였다.</p>
<h3 id="작업-대기큐-흐름">작업 대기큐 흐름</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/40b6f570-fa90-4ab2-90ef-1b80f0aafece/image.png" alt="대기큐 흐름"></p>
<p>우측의 작업이 모두 차게되면, 앞단의 우선순위 대기큐에서 작업을 대기시킨다. 이후 작업이 완료되어 빈 공간이 생기면, 우선순위에 따라서 작업을 할당한다. <code>적은 용량의 파일</code> + <code>오래 대기한 요청</code>을 높은 우선순위로 두어서, 해당 요청이 먼저 업로드될 수 있도록 만들어야겠다고 생각했다. </p>
<p>이를 통해 사용자들은 즉시 거절을 받고나서, 재시도를 반복하는 문제에서 벗어날 수 있다고 생각했다.</p>
<hr>
<h1 id="4-설계-및-구현하기">4. 설계 및 구현하기</h1>
<p>구현을 하고 설계를 하는데 있어서, 난이도가 굉장히 어려웠다.</p>
<p>우선순위 큐를 만들거나, 속도를 제어하는 로직을 구현하는건 어렵지 않겠지만 이게 스케줄링과 얽히고 섥히다보니 설계부터가 정말 막막했다.</p>
<p>그래서 <strong>AI의 도움을 받아서 설계를 진행해나갔다. AI가 제시한 방향과, 내가 생각하는 방향을 비교하면서, 나의 의도대로 만들어내려고 하였다.</strong></p>
<p>그러다보니, 설계 문서만 해도, 18개정도가 나왔다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/8f2fde1c-b340-422e-b5ec-56f966f30a3e/image.png" alt="AI 문서"></p>
<p>AI의 사용은 다음처럼 진행했다.</p>
<p>md 문서 작성 → 읽으면서 의도와 다르거나, 이해가 안가는 부분에 대해 comment → 버전 올려서 다시 md 문서 작성 → 다시 comment → … 의도와 맞을 때까지 무한 반복 → 구현 요청</p>
<p>구체적인 구현을 모두 작성하는 것은 너무 길어서, 위에서 작성한 설계방향대로 작성을 하였다. 설계나 구현관련 내용은 분량이 너무 많아, 현 포스팅에서는 결과만 작성하였습니다.</p>
<hr>
<h1 id="5-적용-결과-확인하기">5. 적용 결과 확인하기</h1>
<p>명확한 비교를 위해 다음 2가지에 대해서, 이전과의 수치를 비교할 필요가 있었다.</p>
<h2 id="비교할-지표">비교할 지표</h2>
<h3 id="1-한번에-수용가능한-최대-요청-수-확인">1. <strong>한번에 수용가능한 최대 요청 수 확인</strong></h3>
<p>대기큐를 넣어두었기 때문에, 한번에 수용할 수 있는 요청이 많아져야하고, 이로인해 성공률이 높아져야한다. </p>
<p>10MB 파일 업로드 기준으로, 기존 VUs=50으로 늘려서 테스트를 진행해보았다. (기존 서비스는 VUs를 최대 35까지 허용한다)</p>
<table>
<thead>
<tr>
<th><strong>항목</strong></th>
<th><strong>대기큐 도입 전</strong></th>
<th><strong>대기큐 도입 후</strong></th>
<th><strong>변화 및 분석</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>파일 업로드 성공률</strong></td>
<td><strong>7%</strong> (93/1191)</td>
<td><strong>63%</strong> (195/114)</td>
<td><strong>성공률 약 9배 상승</strong></td>
</tr>
<tr>
<td><strong>평균 업로드 시간</strong></td>
<td>5.77s</td>
<td><strong>29.06s</strong></td>
<td>대기 시간으로 인해 지연시간 증가</td>
</tr>
</tbody></table>
<p>업로드 성공률이 아닌, 성공한 업로드 개수를 보면 100개 가량 늘어난 것을 확인할 수 있었다.</p>
<p>현 개선을 통해, 뒤늦게 들어오는 요청은 사용자가 계속해서 재시도를 반복할 필요없이, 대기를 걸어놓도록 하여 UX를 개선할 수 있었다.</p>
<h3 id="2-파일-용량별-업로드-속도-확인">2. <strong>파일 용량별 업로드 속도 확인</strong></h3>
<p>파일 용량별 업로드 속도를 비교하여, <strong>우선순위가 높은 작업(파일 용량이 작은 요청)들의 업로드 속도가 높음을 확인할 필요가 있다.</strong></p>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>이전 - 평균 처리 속도</strong></th>
<th><strong>도입 이후 - 평균 처리 속도</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>10 MB</strong></td>
<td><strong>1.45 MB/s</strong></td>
<td><strong>4.82 MB/s</strong></td>
</tr>
<tr>
<td><strong>50 MB</strong></td>
<td><strong>1.42 MB/s</strong></td>
<td><strong>3.16 MB/s</strong></td>
</tr>
<tr>
<td><strong>100 MB</strong></td>
<td><strong>1.62 MB/s</strong></td>
<td><strong>1.09 MB/s</strong></td>
</tr>
</tbody></table>
<p>용량이 큰 파일의 업로드 속도는 느려졌지만, 작은 파일들은 속도가 높은 것을 확인할 수 있었다.</p>
<p>물론, 큐에서 대기하는 시간은 고려하지 않았지만, 이를 고려하더라도 높았기 때문에, 분리하여 측정하지는 않았다.</p>
<h1 id="6-현재-코드의-문제점-수정하기">6. 현재 코드의 문제점 수정하기</h1>
<h2 id="61-단일-파일-업로드-시의-문제">6.1. 단일 파일 업로드 시의 문제</h2>
<blockquote>
<p><strong>업로드 속도가 4배 느려진 문제</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/ae749fa3-ba67-492c-ba7b-a75700b44f0e/image.png" alt="문제"></p>
<p>10MB 단일 파일 업로드시에, 약 4.4s 가 소요되는 문제가 발생했다.</p>
<p>현 시스템 도입 전, 약 1s 가 소요된 것에 비해 많은 속도 저하가 발생한 것 같아, 디버깅을 통해 속도를 확인해보기로 하였다.</p>
<p>일일이 멈춰가면서, 속도 제한값인 <code>previousAllocatedRateBps</code>를 비교해보았따.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/e59d3d70-ff1a-4ef6-a264-89c6a5c6f4a4/image.png" alt="속도 1">
<img src="https://velog.velcdn.com/images/standard-chan/post/758634df-21e2-4ed7-b085-6a1453296e38/image.png" alt="속도 2"></p>
<p>업로드 속도 <code>Byte/s</code> 값을 보면, 처음 속도(첫 사진)은 <code>262144</code>, <strong>1tick(250ms)</strong> 이후의 속도(우측 사진은)는 <code>524288</code>이다. 1tick에 256KB가 증가한 것을 확인할 수 있었다.</p>
<p>1tick에 256KB이므로 1s에 최대 1MB만 증가할 수 있었고, 이러한 속도 제한때문에 업로드에 오랜 시간이 걸렸다고 판단했다.</p>
<p>** 업로드 제한 속도가 급격하게 변동하는 것을 막기 위해서 1tick(0.25ms)에 속도 증가량이 256KB 정도로 제한을 시켰뒀지만, 이 폭이 너무 낮게 설정되어있던 것이다.** </p>
<p>slow start 방식을 사용했기 때문에, <strong>처음에는 항상 0.2MB/s 속도로 시작하여</strong>, 1초에 1MB씩 증가하여 50초가 지나서야 비로소 50MB/s 의 값을 가질 수 있게된다. 이는 업로드 UX에 큰 불편함을 일으킨다고 생각하여 개선할 필요성을 느꼈다.</p>
<h3 id="611-실제로-맞는지-확인">6.1.1 실제로 맞는지 확인</h3>
<p>실제 해당 이유 때문인지 검증하기 위해, 제한을 없애고 테스트를 진행해 보았다.</p>
<pre><code class="language-tsx">  /**
   * 이전 tick 대비 rate 변화량 제한
   * - step up/down 범위 내로 clamp
   * - minRate는 항상 우선 적용
   */
  private applyRateStepLimit(
    runningJobs: RateAllocationJob[],
    targetByJobId: Map&lt;string, number&gt;,
  ): Map&lt;string, number&gt; {
    const limited = new Map&lt;string, number&gt;();

    for (const job of runningJobs) {
      const targetRate =
        targetByJobId.get(job.jobId) ?? this.config.minRatePerJobBps;
      const previous = Math.max(0, job.previousAllocatedRateBps || 0);

      if (previous &lt;= 0) {
        limited.set(
          job.jobId,
          Math.max(this.config.minRatePerJobBps, targetRate),
        );
        continue;
      }

      /** 최대 폭 제한 해제**/
      // const minByStep = Math.max(0, previous - this.config.rateStepDownBps); // 감소
      // const maxByStep = previous + this.config.rateStepUpBps;  // 증가
      // const clampedByStep = Math.max(
      //   minByStep,
      //   Math.min(targetRate, maxByStep),
      // );

      // minRate 우선순위가 step limit보다 높다.
      limited.set(
        job.jobId,
        targetRate,
        // Math.max(this.config.minRatePerJobBps, clampedByStep),
      );
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/7f3df741-20a4-4f7f-8638-1bb14e1ec13b/image.png" alt="제한을 없앤 결과"></p>
<p>동일한 파일 기준, 응답 속도가 1s 근처로 형성된 것을 확인할 수 있었고, 업로드 증가폭 제한이 원인임을 확신할 수 있었다.</p>
<h3 id="612-해결-방안">6.1.2 해결 방안</h3>
<p>해결 방안은 2가지가 떠올랐다.</p>
<ol>
<li>*<em>처음부터 높은 업로드 속도 대역폭을 할당한다. *</em></li>
<li><strong>최소 증가/감소폭을 늘린다.</strong></li>
</ol>
<p>1번 방식 - <strong>처음부터 높은 대역폭을 할당하기는 어렵다고 생각했다.</strong> 처음에 Slow Start로 할당한 이유는, 업로드 처리의 갑작스러운 속도 변경을 막기 위해서였기 때문이다.</p>
<p>새로운 업로드 요청이 들어왔을 때, 해당 요청이 처음부터 높은 대역폭을 차지해버린다면, 기존 업로드 요청들은 갑작스러운 대역폭 변화를 겪게된다. 따라서 1번 방식인 처음부터 높은 속도를 할당하기는 무리라고 판단했다. </p>
<p>그래서 <strong>2번 방법을 선택하기로 하였</strong>고, 현재 변화량 제한 상한선 256KB에서 2MB/s 로 변경하여, 1s에 최대 8MB/s 까지만 변동시킬 수 있도록 제한을 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/f9a6959e-9de5-45ef-a8f4-973300592906/image.png" alt="제한을 설정"></p>
<p>제한이 없을 때(0.8s)보단, 1.7s로 느리지만 안정성을 보장할 수 있을 것라고 생각한다.</p>
<h2 id="62-대용량파일-업로드가-도중에-끊기는-문제---데드락">6.2. 대용량파일 업로드가 도중에 끊기는 문제 - 데드락</h2>
<p>&quot; 업로드가 되다 말아버리는 문제가 발생했다.</p>
<p>예를 들어 500MB 파일을 업로드 했는데, 다음과 같이 도중에 멈춰버린다.
<img src="https://velog.velcdn.com/images/standard-chan/post/58b5fc2c-b3c3-480a-80b0-74f4b7e1f281/image.png" alt="멈춘 거"></p>
<p>혹시 업로드 속도 할당이 안되어있는지 확인하기 위해, 로그를 확인해봤다.
<img src="https://velog.velcdn.com/images/standard-chan/post/166f2438-821b-4b24-abbd-d2c72b060823/image.png" alt="업로드 속도 로그"></p>
<p>속도 할당은 시간 순서대로 잘 된 것을 볼 수 있었다. 뭐가 문제일까?</p>
<p><strong>디버깅을 통해 뭐가 문제인지를 확인해보았다.</strong></p>
<h3 id="621-디버깅">6.2.1 디버깅</h3>
<p>500MB 파일 업로드 하는 도중 랜덤 시기에 멈추니까, 업로드가 멈추는 시점에 브레이크포인트를 찍어 확인해보기로 하였다.</p>
<p>예상컨데, 토큰할당이 제대로 안됐거나, stream이 pause 상태에서 재시작을 안했거나 여러 이유가 있을 것 같다.</p>
<p><code>RateConrolleredTransform</code> 이라는 class가 있는데, stream의 전송속도를 제한하는데 사용하는 Transfrom이다.</p>
<p>업로드가 중지된 순간, 모든 메서드에 break point를 찍어보았지만, 멈추지 않았고, 이를 통해 stream으로 이동하는 데이터가 없다는 것을 확인할 수 있었다.</p>
<p>AI 도움을 받아, Transform class 의 메서드 곳곳에 임시 로그를 찍어보았고, </p>
<pre><code>[tryFlushPending] Push result canContinue: false
[tryFlushPending] Downstream backpressure - breaking
[tryFlushPending] Loop ended - pending left: 1, bytesOut total: 1113543
{&quot;level&quot;:40,&quot;time&quot;:1774953777924,&quot;pid&quot;:25740,&quot;hostname&quot;:&quot;DESKTOP-KAA786S&quot;,&quot;secondaryNodeIp&quot;:&quot;http://secondary-node:3000&quot;,&quot;msg&quot;:&quot;Secondary ...&quot;}
{&quot;level&quot;:40,&quot;time&quot;:1774953777924,&quot;pid&quot;:25740,&quot;hostname&quot;:&quot;DESKTOP-KAA786S&quot;,&quot;msg&quot;:&quot;Secondary ...&quot;}
}
&quot;}</code></pre><p>해당 로그 이후로, 더이상의 업로드가 발생하지 않았다. (요청은 그대로 연결되어있는 상태에서 데이터만 disk에 쓰여지지 않는 상태)</p>
<p>아마 쓰기위한 <code>token</code> 발급이 제대로 되지 않아서 Transform -&gt; Writable Stream으로 쓰지 못하는 걸까? 로그를 찍어보았다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/8176788d-1740-4374-86e2-97502df101ff/image.png" alt="토큰 로그"></p>
<p>쓰기 위한 token도 남아있었고, <code>pendingQueue</code> 라고 해서, Writable Stream에서 backpressure가 발생했는데, 대기하는 장소가 있는데 해당 size=0 인걸 보니 writable stream이 막힌것도 아니다.</p>
<p>왜 Readble Stream에서 못 간걸까?
더 공부를 해서 해결해보아야겠다고 느꼈다.</p>
<h3 id="622-stream과-transform에-대해-알아보기">6.2.2 Stream과 Transform에 대해 알아보기</h3>
<p>작동 흐름은 다음과 같다.</p>
<p>streamA → streamB → streamC 일 때, B 기준으로 보면,</p>
<p>_read() 로 A 데이터 읽기 → push(Adata) → C 전달 → _read() → _push() → … 반복</p>
<p>만약, C buffer가 가득 차면, push()는 false를 반환하고, 멈춘다. == backpressure 상태</p>
<ul>
<li><p><code>_read()</code> : 데이터를 가져와서 push 하는 함수</p>
</li>
<li><p><code>push()</code> : 데이터를 다음 큐에 넣는 함수</p>
<p><a href="https://nodejs.org/api/stream.html#readable-readsize">https://nodejs.org/api/stream.html#readable-readsize</a></p>
</li>
</ul>
<p><strong>backpressure 없을 때</strong></p>
<p><code>_read() → push(chunk)</code> </p>
<p><code>→ _transform(chunk, encoding, callback) → push(transformedChunk) → callback()</code></p>
<p><code>→ write(chunk) → _write(chunk, encoding, callback) → callback</code></p>
<p>``→ 무한 반복`</p>
<p><strong>backpressure 있을 때 = Writable 의 buffer가 full</strong></p>
<p><code>write(chunk) → false 반환 → upstream = Transform push와 Readable _read() 중단</code></p>
<p><code>→ emit drain 이벤트 (다시 써도 된다) → 다시 Transform push 시작 → Readable _read() 호출</code></p>
<p>현재 작동의 문제는 drain() 이벤트가 발생했을 때, 다시 Transform에서 push를 재개하지 않는다는 것이라고 생각했다.</p>
<p>그래서 on drain() 시에, 멈춤을 걸고, 해당 시점 이후로 데이터가 들어오는지 확인했다.</p>
<pre><code class="language-tsx">export class RateControlledTransform extends Transform {
...

  pipe&lt;T extends NodeJS.WritableStream&gt;(
    dest: T,
    options?: { end?: boolean },
  ): T {
    dest.on(&quot;drain&quot;, () =&gt; {
      console.info(`[DRAIN] drain 이벤트 발생`); // debuging point
    });

    return super.pipe(dest, options);
  }

...
}</code></pre>
<p>drain() 발동 시점은 64KB 이다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d0013128-8dd8-450b-abeb-e1002f0f7614/image.png" alt="64"></p>
<p>브레이크포인트를 해제한 후에도 버퍼가 동일하게 64KB를 유지한다면, drain 이후에도 쓰기가 재개되지 않는 것이므로 drain 이벤트에 반응하지 못하고 있다고 추측할 수 있다.
실제로 확인 결과 64KB에서 변화가 없었고, drain 발생 이후에도 스트림이 재개되지 않고 있음을 확인했다.</p>
<blockquote>
<p>스트림이 재개되지 않으면 어떤 문제가 발생할까?</p>
</blockquote>
<p>브레이크포인트를 하나씩 찍어가며 멈추는 지점을 추적한 결과, pending queue(데이터 정보를 모아두는 변수)에 데이터가 유입되지 않는 것을 발견했다.</p>
<p>pending queue로 데이터를 주입하는 로직은 <strong>_transform()인데, 이 함수가 정상적으로 호출되지 않고 있었다</strong></p>
<blockquote>
<p>왜 <code>_transform()</code> 이 호출되지 않았을까? <strong>읽을 데이터가 없었을까?</strong></p>
</blockquote>
<p>bodyStream → Transform → wriatble Stream 의 데이터 흐름이고, bodyStream의 데이터를 확인해봤으나, <code>request.raw.readableLength</code> = 65535 로 데이터가 존재했다. (raw.readableLength가 bodyStream이다)</p>
<p>그렇다면, <strong>데이터는 존재하지만, 데이터를 읽어오는 로직이 실행되지 않고 있다고 추측했다</strong>. 즉, Transform에서 bodyStream으로 데이터를 읽어오는 _read()가 막혀 실행되고 있지 않았던 것이다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/5dd4118b-e312-4dca-9ff0-1b81f5a061e0/image.png" alt="막힌 부분"></p>
<p>디버깅을 찍어서 확인해봤을 때, bodyStream의 <code>flowing</code>, <code>paused</code> 값(좌측)을 보면, 멈춰있는 것을 확인할 수 있다. 우측은 Transform의 Readable Stream인데, flowing = true로 대기하고 있는 것을 확인했다.</p>
<p>즉, <code>Transform</code>은 문제없이 기다리고 있는데, upstream인 <code>bodyStream</code>이 <strong>paused 되어 복구되지 않았던 것.</strong></p>
<blockquote>
<p>따라서 <strong>upstream의 request.raw가 왜 paused 되었는지, 그리고 그것을 어떻게 뚫을 것인지 초점</strong>을 맞추었다.</p>
</blockquote>
<pre><code class="language-tsx">[bodyStream] - ★ 문제의 지점 → [Transform] → [wriatble Stream]</code></pre>
<h3 id="623-해결">6.2.3 해결</h3>
<p><strong>기존 코드</strong></p>
<pre><code class="language-tsx">export class RateControlledTransform extends Transform {
    ...

  _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
    this.stats.bytesIn += chunk.length;
    this.pendingQueue.push({
      buffer: chunk,
      offset: 0,
      done: callback,
    });

    this.tryFlushPending();
  }

    private tryFlushPending(): void {
      // 이미 flush 중이거나 에러로 destroy된 상태면 실행하지 않음 (중복 실행 방지)
      if (this.flushing || this.destroyedByError) {
        return;
      }

      // flush 시작 플래그 (재진입 방지)
      this.flushing = true;

      try {
        // pendingQueue에 남아있는 데이터들을 순차적으로 처리
        while (this.pendingQueue.length &gt; 0) {
          const head = this.pendingQueue[0]; // 현재 처리할 청크
          const remaining = head.buffer.length - head.offset; // 아직 쓰지 않은 데이터 크기

          // 이미 다 쓴 청크라면 큐에서 제거하고 callback 호출
          if (remaining &lt;= 0) {
            this.pendingQueue.shift();
            head.done(); // _transform callback 호출 → 다음 chunk 처리 가능 상태로 만듦
            continue;
          }

          // 현재 사용 가능한 토큰 (보낼 수 있는 바이트 수)
          const spendable = this.bucket.spendableBytes();

          // 토큰이 없으면 전송 중단 (throttling 시작)
          if (spendable &lt;= 0) {
            this.markThrottledStartIfNeeded();
            break;
          }

          // 이번에 실제로 쓸 바이트 수 결정
          const writeBytes = Math.min(remaining, spendable);

          // 해당 범위만큼 잘라서 push
          const piece = head.buffer.subarray(head.offset, head.offset + writeBytes);

          // downstream으로 push (false면 backpressure 상태)
          const canContinue = this.push(piece);

          // 사용한 토큰 차감
          this.bucket.consume(writeBytes);

          // offset 이동 (얼마나 썼는지 기록)
          head.offset += writeBytes;
          this.stats.bytesOut += writeBytes;

          // 전체 청크를 다 못 쓴 경우 (partial write)
          if (writeBytes &lt; remaining) {
            this.stats.partialWriteCount += 1;
          }

          // 청크를 모두 소비했으면 큐에서 제거하고 callback 호출
          if (head.offset &gt;= head.buffer.length) {
            this.pendingQueue.shift();
            head.done(); // 다음 _transform 실행 트리거
          }

          // downstream buffer가 가득 찬 경우 → backpressure
          if (!canContinue) {
            // 이후 drain 이벤트까지 대기해야 함
            break;
          }
        }

        // 모든 pending 데이터가 처리된 경우 throttling 종료 처리
        if (this.pendingQueue.length === 0) {
          this.markThrottledEndIfNeeded();
        }
      } catch (error) {
        // flush 중 에러 발생 시 모든 pending 작업 실패 처리 + stream destroy
        const flushError = error instanceof Error ? error : new Error(String(error));
        this.failAllPending(flushError);
        this.destroy(flushError);
      } finally {
        // flush 종료 (다음 flush 가능 상태로 복구)
        this.flushing = false;
      }
    }
}</code></pre>
<p><code>_transform</code>에서 callback 함수는 해당 chunk 작업이 끝났음을 알리는 역할을 한다. 따라서 callback이 호출되지 않는다면 작업이 종료되지 않았다고 판단하여, upStream에서 데이터를 읽지 않는다.</p>
<p>그리고 이런 callback을 <code>tryFlushPending</code> 함수의 2개의 부분에서 실행하고 있다. 일반적인 경우라면 정상적으로 callback을 호출하고 종료하게 된다.</p>
<p>하지만 다음 2가지로 인해 데드락이 발생한 것이다.</p>
<ul>
<li>token이 없는 경우 → break</li>
<li>backpressure → break</li>
</ul>
<p>위 2가지 경우에는 callback을 호출하지 않고 종료시키기 때문에, 현재 처리중인 작업이 완료되지 않은 상태로 남아있게 된다. 즉, drain()이 발동하여도 해당 작업이 ‘진행중’이라고 판단하여 _transform을 호출하지 않게 되는 것.</p>
<p>따라서 callback을 작업이 끝난 시점이 아닌, _transform을 호출하는 시점으로 옮기면, 작업이 처리되었다고 판단할 수 있게 되는 방식으로 수정하였다.</p>
<pre><code class="language-tsx">export class RateControlledTransform extends Transform {
    ...

  _transform(
    chunk: Buffer,
    _encoding: BufferEncoding,
    callback: TransformCallback,
  ): void {
    this.stats.bytesIn += chunk.length;
    this.pendingQueue.push({
      buffer: chunk,
      offset: 0
    });

    // 여기에서 바로 callback을 호출하도록 변경
    callback();

    this.tryFlushPending();
  }

    ...

    private tryFlushPending(): void {
    if (this.flushing || this.destroyedByError) {
      return;
    }

    this.flushing = true;

    try {
      let hadSuccessfulWrite = false;

      while (this.pendingQueue.length &gt; 0) {
        const head = this.pendingQueue[0];
        const remaining = head.buffer.length - head.offset;

        if (remaining &lt;= 0) {
          // chunk가 완전히 flush됨 → queue에서 제거
          this.pendingQueue.shift();
          // callback은 이미 _transform에서 호출됨 (이중 호출 방지)
          continue;
        }

        const spendable = this.bucket.spendableBytes();
        if (spendable &lt;= 0) {
          this.markThrottledStartIfNeeded();
          break;
        }

        const writeBytes = Math.min(remaining, spendable);
        const piece = head.buffer.subarray(
          head.offset,
          head.offset + writeBytes,
        );

        const canContinue = this.push(piece);
        this.bucket.consume(writeBytes);

        head.offset += writeBytes;
        this.stats.bytesOut += writeBytes;
        hadSuccessfulWrite = true;

        if (writeBytes &lt; remaining) {
          this.stats.partialWriteCount += 1;
        }

        if (head.offset &gt;= head.buffer.length) {
          this.pendingQueue.shift();
          // callback은 이미 _transform에서 호출됨
        }

        if (!canContinue) {
          break;
        }
      }

      if (this.pendingQueue.length === 0) {
        this.markThrottledEndIfNeeded();
      }

      // Token 충분 + pending queue 비움 → upstream resume 신호
      // (표준 stream의 _read() 호출로 충분)
      if (hadSuccessfulWrite &amp;&amp; this.pendingQueue.length === 0) {
        // 데이터가 downstream으로 성공 전송됨
      }
    } catch (error) {
      const flushError =
        error instanceof Error ? error : new Error(String(error));
      this.failAllPending(flushError);
      this.destroy(flushError);
    } finally {
      this.flushing = false;
    }
  }
}</code></pre>
<p>AI의 도움을 받아 수정하였지만, 이 방식이 맞는지 의문이 든다. 작업 시적 전에 callback을 호출하면, buffer를 비움과 동시에 _read를 하게 되는거 아닐까?</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/a7b88497-3bc6-4dad-a142-b5133c140d41/image.png" alt="성공 결과"></p>
<p>결과적으로는 성공하였으나, 제대로 이해하지 못하고 사용하고 있는 기분이 든다.</p>
<h2 id="63-오래-소요되는-대용량-파일-업로드">6.3 오래 소요되는 대용량 파일 업로드</h2>
<p>최소 업로드 속도를 1MB/s로 잡다 보니, 우선순위가 낮은 100MB 파일 업로드 성공 시점이 대략 100s 위치에서 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/671ac84f-c396-4b59-ab13-7062df8bf3f9/image.png" alt="업로드 성공 시점"></p>
<p>(1m50s 지점에서 최초로 100MB 업로드 성공 로그가 출력되었다.)</p>
<p>이렇게 <strong>작은 파일에 우선권을 주는 score 정책을 설정한 이유</strong>는 <strong>사용자가 저장하려는 대부분의 파일이 1~10MB (사진, pdf 등)이라고 생각했고, 이런 업로드가 불편하지 않도록 우선권을 주려고 하였다</strong>. </p>
<p>또한 용량이 작으므로, <strong>빠르게 처리하여 용량이 큰 파일들의 트래픽을 확보하기 위함</strong>이었다. </p>
<p>하지만 <strong>트래픽이 지속적으로 몰리게 된다면, 100MB는 물론 1GB 파일을 업로드하는데 1000s가 소요되는 문제</strong>가 발생한다.</p>
<blockquote>
<p>Score 정책을 변경할 필요가 있다. </p>
</blockquote>
<p><strong>CPU의 HRN 스케줄링 기법</strong>처럼 <strong>장시간 대기하게 되는 경우에 추가 score를 부여</strong>하여 속도를 변환시키는 것이 좋은 방법이라고 생각했다</p>
<h3 id="631-작업-소요-시간에-따른-가중치-함수-설정">6.3.1 작업 소요 시간에 따른 가중치 함수 설정</h3>
<p>현재 score를 기준으로 우선순위를 책정하고 있다. 따라서 score를 시간에 따라서 어떻게 올릴지를 고민해보았다.</p>
<blockquote>
<p>→ *<em>어떤 함수를 사용해서 time에 따른 score를 할당할까?
*</em></p>
</blockquote>
<h3 id="선택-1-로그함수">선택 1: <strong>로그함수</strong></h3>
<p>처음에는 <code>로그함수</code>를 떠올렸다. 가중치 우선순위는 확실하게 유지하면서, 시간에 따라 우선순위를 천천히 올리는 방식이다.</p>
<p>실제 도입해서 테스트를 해보았으나, 있으나 마나한 결과가 나왔다.</p>
<blockquote>
<p>&quot;처음에는 천천히 올라가다가, 나중에 수렴하는 함수가 있을까?&quot;</p>
</blockquote>
<p><code>지수함수</code>가 초반에는 천천히 올라간다는 면에서는 유리한데, 시간이 지나면 폭발적으로 증가한다. 이런 폭발적 증가를 없애고 싶었다.
**
<code>초반에 천천히 -&gt; 적당히 증가 -&gt; 천천히 수렴</code> 의 구조를 원했다.**</p>
<h3 id="선택2--sigmoid-함수">선택2 : sigmoid 함수</h3>
<p>그러다가 발견한게 <code>Sigmoid</code> 함수이다. 예전에 AI 수업을 들을 때, 배웠던 함수인데, 내가 원하던 방향의 함수이다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/fa8afb09-8525-4926-b64d-c46bfbe41f5a/image.png" alt="sigmoid"></p>
<p>다만, 문제는 연산량이 많다는 것이다. 연산량은 적은데, 이와 유사한 함수를 찾고 싶었다.</p>
<h3 id="선택3--ttt-함수t-함수">선택3 : t/t+T 함수T 함수</h3>
<p>시그모이드와 유사한데, 연산이 적은 함수를 AI에게 물어봤고 위 함수를 추천해줬다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/885b5776-1aee-433b-9d1f-9b0083c2c78f/image.png" alt="포화함수"></p>
<p>그래프를 보면, 절대 1을 넘을 수 없다. 따라서 수렴에도 문제가 없고, 증가폭에서도 잔잔히 증가하기에 문제가 없다. 연산은 당연히 복잡하지 않다.</p>
<p>따라서 해당 함수를 사용하여 score 할당을 진행하였다. 3분에 도달하는 시점에 최대 score를 할당하도록 설계하였다.</p>
<h3 id="632-개선-여부-테스트">6.3.2 개선 여부 테스트</h3>
<p>500MB파일 업로드를 기준으로 테스트를 진행하였다.</p>
<p><code>테스트 목적</code>은 <strong>대용량 파일 업로드 시, 시간 가중치가 잘 반영되어 빠르게 업로드 되느냐</strong>이다.</p>
<p>테스트는 VUs=20 명이 동시에 20MB파일을 업로드하고 있는 상황에서 500MB파일 업로드 시간을 측정하였다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/10d286d4-5f65-421a-975f-1d498807c587/image.png" alt="개선전"></p>
<p>개선하기 전이다. 정확히는 time에 따른 증가폭을 0에 수렴시킨 결과이다.</p>
<p>TIMEOUT 제한으르 300s으로 잡아둬서 실패가 떴는데, 데이터 512MB는 모두 전송되어서, 약 300s이상이 소요될 듯 하다.</p>
<p>개선 후이다.
<img src="https://velog.velcdn.com/images/standard-chan/post/1118cae2-10b5-4898-af40-ed5b65dbba17/image.png" alt="개선후 ">
약 3.3MB/s가 소요되었다.</p>
<p>이밖에도 여러번 테스트를 진행하였고 모두 유사한 결과가 나왔다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>소요 시간</th>
<th>속도</th>
</tr>
</thead>
<tbody><tr>
<td>단일 업로드 (VUs=0)</td>
<td>약 32s</td>
<td>17MB/s</td>
</tr>
<tr>
<td>개전 선</td>
<td>약 300s</td>
<td>1.7MB/s</td>
</tr>
<tr>
<td>개선 후</td>
<td>약 160s</td>
<td>3.1 - 3.3MB/s</td>
</tr>
</tbody></table>
<p>성공적으로 개선했다.</p>
<hr>
<h1 id="7-아쉬운-점">7. 아쉬운 점</h1>
<h2 id="71-연산량">7.1 연산량</h2>
<p>우선순위 큐의 대기 작업을 관리하면서, 매 tick 마다 <strong>‘타임아웃된 요청’을 주기적으로 제거하는 로직</strong>을 사용하고 있다. 뿐만 아니라, </p>
<p>문제는 타임아웃된 요청을 찾기 위해서는 큐에 있는 모든 요소를 모두 읽어야한다는 점이다. O(n)만큼의 시간 복잡도가 들어간다. 이유는 현재 정렬이 (score, 시간, 파일 크기) 순서로 되어있기 때문이다. </p>
<p>score도 마찬가지이다. 오래 대기할수록 우선순위를 높여야만 하다 보니, 짧은 주기마다 score를 다시 연산하고 이를 다시 정렬시키는 로직을 사용하고 있다. </p>
<p>물론 최대 대기 작업 수가 50개를 넘지 않으므로, 큰 무리는 없다고 생각한다. CPU 연산에 부하가 발생한다면 가장 먼저 개선해야할 부분이라고 생각한다.</p>
<hr>
<h1 id="8-교훈-및-느낀점">8. 교훈 및 느낀점</h1>
<h2 id="81-ai-코드-작성에-대해서">8.1 AI 코드 작성에 대해서</h2>
<p>AI를 통해서 코드를 설계하고 작성하였으나, 이전 대용량파일 업로드 시에, 데드락 문제(6.2)가 발생했다.</p>
<p>제대로 검증을 했다고 생각했으나, 결국 버그가 있었고 AI가 아직은 부족하다고 느끼게 된 계기가 된 것 같다. (물론 대부분은 작동한다)</p>
<h3 id="811-ai-코드를-어떻게-검증해야할까">8.1.1 AI 코드를 어떻게 검증해야할까?</h3>
<p><strong>현재 발생한 버그의 경우에는, 코드를 읽어서 검증하는 것으로는 찾아내기 어렵다.</strong> 전체 코드의 량이 1000줄이 넘어가는데 모든 if 조건, loop 하나하나 다 머릿속으로 돌려볼 수는 없지 않는가...</p>
<p>이번 경험을 통해 느낀건, <strong>최대한 다양한 테스트를 돌려보고 발생하는 버그들을 찾아내고, 이를 추적해서 해결하는 방법</strong>이 가장 좋은 방법이라고 생각한다. 코드를 일일이 읽어서 이런 버그를 막아내는 것은 비효율적인것 같다. (물론 읽다가 보이면 해결하는게 가장 좋다고 생각한다. 여기에서 말하고 싶은 것은 굳이 버그를 찾아내겠다고 모든 조건문을 시뮬레이션할 필요성을 못느끼겠다는 것이다.)</p>
<p>이번 경우에도, 대용량 파일을 업로드를 테스트하다 발견한 문제고, 디버깅툴과 AI를 활용해서 문제를 찾아 해결하였다.</p>
<h2 id="82-중요하다고-생각되는-능력">8.2 중요하다고 생각되는 능력</h2>
<p>이러한 버그를 해결하는데 중요하다고 느껴졌던게 있다.</p>
<ol>
<li>무엇이 문제의 원인인지 추적하는 능력</li>
<li>해당 원인을 해결하는 능력</li>
</ol>
<h3 id="821-무엇이-문제의-원인인지-추적하는-능력">8.2.1 무엇이 문제의 원인인지 추적하는 능력</h3>
<blockquote>
<p><strong>이게 시간도 오래걸리고 가장 어려웠다.</strong> </p>
</blockquote>
<p>2가지가 필요하다고 생각된다.</p>
<ul>
<li>문제에 대한 지식</li>
<li>디버깅 등의 툴을 통해 추적하는 능력</li>
</ul>
<h4 id="822-문제에-대한-지식">8.2.2 문제에 대한 지식</h4>
<p><strong>AI에게 최대한 구체적으로 알려준다 한들 틀리는 경우도 많고, 맞다고 한들 내가 해당 부분에 대한 지식이 없으면 무심코 지나가기 때문이다.</strong></p>
<p>이 버그에서는 <code>Stream</code>, <code>Transform</code>에 대한 깊이있는 지식을 몰라서 찾아내기 어려웠다.
처음부터 _transform()의 callback만 공부하면 된다는 걸 알 수 있으면 좋겠지만, 그러기 쉽지 않다. Stream의 _read(), _write(), this.push(), emit drain... 굉장히 많은 것을 알고있어야했고 깊게 알고있어야했다. 그리고 _transform()은 그중 하나의 함수일 뿐이었다.</p>
<p>하지만 문제의 원인을 찾아내기 위해서는 반드시 알아야할 내용들이었고, 몰랐으면 못풀었을 것이다.</p>
<h4 id="823-추적하는-능력">8.2.3 추적하는 능력</h4>
<p>이번에는 vscode의 디버거를 적극적으로 활용했다. <code>break point</code>를 찍어서 문제 시점에 어떤 함수가 호출되고 호출되지 않는지, 그리고 그때의 각 변수의 값은 어떠한지를 명확하게 알 수 있었다.</p>
<p>하면서 얻은 한가지 팁이 있다면, <code>await</code> 의 경우에는 추적하기가 어려운데 추적할 수 있는 팁? 이 생겼다.</p>
<pre><code class="language-ts">export async function saveStreamToStorage(...
): Promise&lt;string&gt; {

  const filePath = path.join(process.cwd(), &#39;uploads&#39;, bucket, objectKey);
  const fileDir = path.dirname(filePath);
    ...

  const writeStream = fs.createWriteStream(filePath);
  ...

  // 여기 부분
  await pipeline(stream, writeStream);

  return filePath;
}</code></pre>
<p>위의 await pipeline 내부에 있는 <code>writeStream</code>의 작동흐름을 추적하고 싶었는데, writeStream이 Node.js 자체에서 지원하는 인터페이스처럼 되어있어서 <code>break point</code> 찍는게 어려웠다.</p>
<pre><code class="language-ts">    export class WriteStream extends stream.Writable {
        /**
         * Closes `writeStream`. Optionally accepts a
         * callback that will be executed once the `writeStream`is closed.
         * @since v0.9.4
         */
        close(callback?: (err?: NodeJS.ErrnoException | null) =&gt; void): void;
        /**
         * The number of bytes written so far. Does not include data that is still queued
         * for writing.
         * @since v0.4.7
         */
        bytesWritten: number;</code></pre>
<p>요로코롬 되어있어서 break point 찍어도 안멈추더라.</p>
<p>그래서 어떻게 할지 고민하다가, setInterval로 주기적으로 강제 호출시켰다.</p>
<pre><code class="language-ts">export async function saveStreamToStorage(...
): Promise&lt;string&gt; {

  const filePath = path.join(process.cwd(), &#39;uploads&#39;, bucket, objectKey);
  const fileDir = path.dirname(filePath);
    ...

  const writeStream = fs.createWriteStream(filePath);
  ...

  // setIntever() 호출해서 주기적으로 해당 함수 스택으로 들어오도록 설정함
  // 그리고 해당 부분에 break point 걸어둠

  // 여기 부분
  await pipeline(stream, writeStream);

  return filePath;
}</code></pre>
<p>이러면 해당 함수 스택에서 멈춰서 주기적으로 <code>writeStream</code> 의 상태 및 값을 쉽게 확인할 수 있다.</p>
<p>물론 로그 찍어서 확인해도 된다. </p>
<h3 id="824-앞으로-ai-사용-방향-및-길러야할-능력">8.2.4 앞으로 AI 사용 방향 및 길러야할 능력</h3>
<p>AI가 나와서 구현이 많이 편해진 것은 사실이다.
하지만 이번 디버깅하면서 원인을 찾고 해결하는 과정에서 AI가 직접 찾아주었느냐 묻는다면, 그렇지 않다고 답할 것 같다.</p>
<p>구현은 나보다 훨씬 잘하니까, 앞으로도 AI를 많이 쓸 것 같다. 하지만 검증이나 디버깅, 버그찾기 등은 AI가 아직은 부족한 것 같으니, 이런 부분들에 대한 역량을 길러야겠다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage - 8] DISK가 가득 차면 어떻게하지? → 확장성 있는 구조 설계하기]]></title>
            <link>https://velog.io/@standard-chan/storage-8-DISK%EA%B0%80-%EA%B0%80%EB%93%9D-%EC%B0%A8%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C%ED%95%98%EC%A7%80-%ED%99%95%EC%9E%A5%EC%84%B1-%EC%9E%88%EB%8A%94-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/storage-8-DISK%EA%B0%80-%EA%B0%80%EB%93%9D-%EC%B0%A8%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C%ED%95%98%EC%A7%80-%ED%99%95%EC%9E%A5%EC%84%B1-%EC%9E%88%EB%8A%94-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 25 Mar 2026 07:54:42 GMT</pubDate>
            <description><![CDATA[<p>파일 저장소이다보니, 시간이 흐를수록 DISK 공간이 차게되고, 결국 DISK 확장을 해야하는 순간이 마주하게 됩니다. </p>
<p>수직적으로 확장한다면 당장의 문제를 해결할 수 있겠지만 언젠가는 수평 확장을 해야합니다. 이를 위해서 수평확장이 용이한 구조를 만들려고 합니다.</p>
<hr>
<h1 id="1-초기-구조와-확장-구조-설계">1. 초기 구조와 확장 구조 설계</h1>
<h2 id="11-초기-구조">1.1 초기 구조</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/b49e0521-6a1e-4bb8-b6c7-6c039d51f8bc/image.png" alt="초기 구조"></p>
<p>초기 구조에서는 Presigned URL 발급 시, <strong>고정된 Node IP 주소</strong>를 반환했습니다.</p>
<p>운영 서버는 원본 + 복제, 총 2대였고 복제 서버는 복제만 담당했기 때문에, 업로드는 단 1개의 서버로만 처리했습니다.</p>
<p>이 구조의 문제는 명확합니다. DISK가 가득 찼을 때 <strong>수직 확장 외에 선택지가 없다</strong>는 것입니다. 따라서 DISK를 수평으로 확장할 수 있는 구조가 필요했습니다.</p>
<h2 id="12-확장성-높은-구조로-바꾸기-위해-필요한-것">1.2 확장성 높은 구조로 바꾸기 위해 필요한 것</h2>
<p>확장 가능한 구조를 만들기 위해 다음 3가지를 핵심 기준으로 삼았습니다.</p>
<ol>
<li><strong>쓰기</strong> — 어떤 Node의 DISK에 파일을 저장할 것인가?</li>
<li><strong>읽기</strong> — 파일을 읽을 때, 어떤 Node에 저장되어 있는지 알아낼 수 있는가?</li>
<li><strong>간편성</strong> — 확장을 쉽게 하려면 무엇이 필요한가?</li>
</ol>
<p>이 3가지를 중심으로 설계를 진행했습니다.</p>
<hr>
<h1 id="2-확장성-있는-구조-설계하기">2. 확장성 있는 구조 설계하기</h1>
<h2 id="21-쓰기-구조">2.1 쓰기 구조</h2>
<blockquote>
<p><strong>어떤 Node의 DISK에 파일을 저장할 것인가?</strong></p>
</blockquote>
<p>크게 2가지를 기준으로 저장할 node를 판단해야겠다고 생각하였습니다.</p>
<ol>
<li>남아있는 DISK 용량이 많은 node</li>
<li>현재 처리 중인 요청의 개수가 적은 node</li>
</ol>
<p>첫 번째는 모든 DISK에 균등하게 파일을 분배하기 위함이고, 두 번째는 하나의 node에 작업이 몰리지 않도록 하기 위함입니다.</p>
<p>우선순위가 1번이 높기 때문에, 우선 <strong>1번만을 확실하게 구현</strong>하고 2번은 추후 트래픽 분산을 처리할 때 구현하기로 하였습니다.</p>
<p>해당 트래픽 분산은 쓰기 뿐만 아니라, 읽기 요청에서도 고려해야할 부분이기에 추후 별도로 생각하는 것으로 하고, 확장성 구조 자체에만 집중하는 것이 좋다고 판단하였습니다.</p>
<h2 id="22-읽기-구조">2.2 읽기 구조</h2>
<blockquote>
<p><strong>파일이 어디 node에 저장되어있는지를 알아낼 수 있을까?</strong></p>
</blockquote>
<p>요청 시, 사용되는 정보는 3가지 (user, bucket, path)입니다. (여기서 path는 node 번호가 아닌, 디렉토리 경로라고 생각하면 됩니다.) 즉, <strong>사용자는 어디 node에 저장되어있는 지를 모르기 때문에, 서버에서 이를 확인하여 찾아줘야할 책임</strong>이 있습니다.</p>
<h3 id="221-파일-위치-찾기">2.2.1 파일 위치 찾기</h3>
<p><code>control plane</code>이 <code>node</code> 에 직접 물어보는 방법도 있을 것이고, <code>control plane</code> 자체에 파일 정보를 저장하는 방법도 있을 것 같습니다.</p>
<p><strong>node 에 일일이 물어보는 방법</strong></p>
<p>정확성은 높을지 몰라도, presigned url을 발급받는 시점마다 모든 node에 요청을 보내게 되면, 두 가지 문제가 생깁니다.</p>
<ol>
<li>불필요한 API 요청 - node의 개수가 늘어날수록 비효율적</li>
<li>사용자 대기 시간 증가 - 파일을 가지고 있는 node를 찾을 때까지 대기</li>
</ol>
<p><strong>control plane 자체에 저장하는 방법</strong></p>
<p>파일 정보를 저장해야한다는 번거로움이 있을 수는 있겠지만, 사용자에게 빠른 응답을 보낼 수 있다는 장점이 있습니다.</p>
<p>다만, 파일 정보 업데이트를 실패하는 경우 정합성이 깨지는 문제가 있을 수 있습니다.</p>
<p>정합성 문제를 고려하더라도 두 번째 방법이 적합하다고 생각하여, <strong><code>control plane</code>에 저장하는 방법</strong>을 선택하기로 하였습니다.</p>
<h3 id="222-파일-메타-정보-저장하기">2.2.2 파일 메타 정보 저장하기</h3>
<p><strong>파일이 업로드가 완료되었을 때, 파일 정보들(위치 등)을 <code>control plane</code>에 저장해야 합니다.</strong> 따라서 업로드 성공 시 파일 정보 저장용 필드와 API를 추가해야합니다.</p>
<p>업로드 전에 presigned url을 발급하는 순간에 미리 저장하는 방법도 있을 수 있습니다.</p>
<p>하지만 업로드 전에 저장을 하게 되면 실제 파일이 저장되지 않았는데 메타정보에는 저장되는 경우가 생길 수 있습니다. 따라서 반드시 파일 업로드가 완료된 이후에 메타정보를 저장해야합니다.</p>
<pre><code class="language-java">public class StoredObject {

    @Id
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;bucket_id&quot;, nullable = false)
    private Bucket bucket;

    @Column(nullable = false, length = 1024)
    private String storagePath;

        ...

    @Column(length = 255, nullable = false)
    private String primaryNodeIp;

    @Column(length = 255)
    private String secondaryNodeIp;</code></pre>
<p>아래의 파일 위치를 저장하는 2가지 필드를 추가하였습니다.</p>
<p>다만 이렇게 추가하게 될 경우, 추후 IP 주소가 바뀌게 되었을 때 데이터를 전부 바꿔야하는 문제가 있습니다. 따라서 enum이나 문자열로 맵핑시키는 방법으로 수정할 필요가 있습니다.</p>
<h2 id="23-간편한-확장">2.3 간편한 확장</h2>
<p><strong>환경 변수</strong>에 <strong>새로운 node의 ip만 추가하면, 자동으로 처리</strong>를 진행시키고 싶었습니다. <code>control plane</code>과 <code>node</code>가 강하게 묶여있는 게 아니기 때문에 충분히 가능하다고 생각하였습니다.</p>
<hr>
<h1 id="3-구현">3. 구현</h1>
<h2 id="31-쓰기-구조">3.1 쓰기 구조</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/c1b212a4-cd16-44ad-81e4-f4c4fe2d0abd/image.png" alt="쓰기 흐름"></p>
<p>흐름은 다음과 같습니다.</p>
<ol>
<li>Control plane에 각각의 node ip 주소를 저장한다.</li>
<li>모든 node ip 주소로 DISK 사용 용량을 조회한다.</li>
<li>가장 여유 공간이 많은 node를 선택한다.</li>
<li>해당 node를 presigned url 로 발급한다.</li>
<li>사용자는 해당 node에 파일을 업로드한다.</li>
</ol>
<h3 id="312-코드">3.1.2 코드</h3>
<p>흐름은 위와 유사합니다. </p>
<p>Presigned URL 요청 → disk 사용량 조회 → 선택 → presigned URL 발급입니다.</p>
<p>아래는 presigned url 발급 코드입니다.</p>
<pre><code class="language-java">public class PresignedUrlService {
    ...

  public String generateUploadPresignedUrl(String bucket, String objectKey, long fileSize) {
      log.info(&quot;Upload Presigned URL 생성 요청 - bucket: {}, objectKey: {}, fileSize: {}&quot;, bucket, objectKey, fileSize);

      StorageNodeDiskInfo selectedNode = storageNodeDiskService.selectOptimalNodeForUpload(fileSize);

      log.info(&quot;Selected storage node - ip: {}, availableSpace: {} Bytes&quot;, selectedNode.getNodeIp(), selectedNode.getAvailableSpace());

      return generatePresignedUrl(DIRECT_PATH, bucket, objectKey, fileSize, &quot;PUT&quot;, selectedNode.getNodeIp(), STORAGE_NODE_PORT);
  }

  ...
}</code></pre>
<p>아래는 disk 공간 조회 코드입니다.</p>
<pre><code class="language-java">public class StorageNodeDiskService {

    private static final Logger log = LoggerFactory.getLogger(StorageNodeDiskService.class);

    @Value(&quot;${storage.node.ips:}&quot;)
    private String storageNodeIpsString;

    @Value(&quot;${storage.node.port:3000}&quot;)
    private Integer storageNodePort;

    @Value(&quot;${storage.node.disk-query-timeout-ms:5000}&quot;)
    private Long diskQueryTimeoutMs;

    private final RestTemplate restTemplate;

    public StorageNodeDiskService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

  /**
   * 업로드를 위한 최적의 노드 선택
  */
  public StorageNodeDiskInfo selectOptimalNodeForUpload(long uploadFileSize) {
        List&lt;StorageNodeDiskInfo&gt; nodeDiskInfos = getAllStorageNodesDiskUsage();

        return nodeDiskInfos.stream()
            .filter(nodeDisk -&gt; nodeDisk.getAvailableSpace() &gt;= uploadFileSize)
            .max(Comparator.comparing(StorageNodeDiskInfo::getAvailableSpace))
            .orElseThrow(() -&gt; {
                log.error(&quot;[Storage node] 적절한 Storage Node를 찾을 수 없습니다. (요청 크기: {} Byte)&quot;, fileSize);
                return new RuntimeException(&quot;가용한 저장 공간이 부족하거나 활성화된 노드가 없습니다.&quot;);
        });
  }


    /**
   * 등록된 모든 Storage Node의 디스크 용량 조회
   *
   * @return Storage Node 디스크 정보 리스트
   */
  private List&lt;StorageNodeDiskInfo&gt; getAllStorageNodesDiskUsage() {
      List&lt;String&gt; nodeIpList = this.getValidNodeIps();

      log.info(&quot;[Storage node] 등록된 node ip 개수 : {} &quot;, nodeIpList.size());

      List&lt;CompletableFuture&lt;StorageNodeDiskInfo&gt;&gt; futures = nodeIpList.stream()
          .map(nodeIp -&gt; CompletableFuture.supplyAsync(() -&gt; queryDiskUsageToStorageNode(nodeIp.trim())))
          .toList();

      return futures.stream().map(future -&gt; {
          try {
              return future.orTimeout(diskQueryTimeoutMs, TimeUnit.MILLISECONDS).join();
          } catch (Exception e) {
              log.warn(&quot;[Storage node] DISK 사용량 조회 타임아웃&quot;, e);
              return null;
          }
      }).filter(Objects::nonNull).collect(Collectors.toList());
  }

    /**
   * 환경 변수에 등록된 node ip 들을 반환
   */
  private List&lt;String&gt; getValidNodeIps() {
      if (storageNodeIpsString == null || storageNodeIpsString.isBlank()) {
          throw new IllegalStateException(&quot;Storage Node IP가 설정되지 않았습니다&quot;);
      }

      List&lt;String&gt; nodeIps = Arrays.stream(storageNodeIpsString.split(&quot;,&quot;))
          .map(String::trim)
          .filter(ip -&gt; !ip.isEmpty())
          .toList();

      if (nodeIps.isEmpty()) {
          throw new IllegalStateException(&quot;유효한 Storage Node IP가 없습니다&quot;);
      }

      return nodeIps;
  }

    /**
   * 단일 Storage Node의 디스크 사용량 조회
   */
  private StorageNodeDiskInfo queryDiskUsageToStorageNode(String nodeIp) {
      try {
          String url = String.format(&quot;http://%s:%d/%s&quot;, nodeIp, storageNodePort, DISK_USAGE_API_ENDPOINT);

          StorageNodeDiskInfo diskInfo = restTemplate.getForObject(url, StorageNodeDiskInfo.class);

          if (diskInfo != null) {
              log.debug(&quot;[Storage node] DISK &#39;{}&#39; IP의, - 사용 가능 DISK 용량: {} MB&quot;, nodeIp, diskInfo.getAvailableSpace());
          }

          return diskInfo;
      } catch (Exception e) {
          log.warn(&quot;[Storage node] disk 용량을 불러오는데 실패하였습니다 - ip: {}&quot;, nodeIp, e);
          return null;
      }
  }
}</code></pre>
<h3 id="보완할-점">보완할 점</h3>
<ul>
<li>현재는 DISK 공간만을 조회하여, 비어있는 DISK 용량의 최대 node만을 찾습니다. 트래픽도 고려하면 좋을 것 같습니다.</li>
</ul>
<h3 id="313-업로드-성공-시-메타-정보-저장">3.1.3 업로드 성공 시, 메타 정보 저장</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/f462bafd-9de6-4aa1-a1d1-7c3fc60beca3/image.png" alt="메타 정보 저장 흐름"></p>
<p>파일 업로드가 완료되면, 어떤 DISK에 저장되었는지를 알 수 있게 메타정보를 <code>control plane</code>으로 전달해주어야합니다.</p>
<h3 id="331-node-메타-정보-저장-요청-로직">3.3.1 node 메타 정보 저장 요청 로직</h3>
<p>(로그나 불필요한 코드 제외)</p>
<pre><code class="language-tsx">export async function uploadFile(
  request: FastifyRequest&lt;{ Querystring: PresignedQuery }&gt;,
  replicationQueue: ReplicationQueueRepository,
): Promise&lt;FileInfo&gt; {
  const { bucket, objectKey } = request.query;
  const mimetype = request.headers[&quot;content-type&quot;] ?? DEFAULT_CONTENT_TYPE;
  const bodyStream = request.body;

    // 업로드 전, 유효성 검사
  validatePresignedUrlRequest(request.query, &quot;PUT&quot;);
  validateReplicationBodyStream(bodyStream);

    // 파일 업로드
  const filePath = await saveStreamToStorage(bucket, objectKey, bodyStream, request.log);
  const fileInfo = await collectStreamFileInfo(bucket, objectKey, filePath, mimetype)

    // 파일 다중화 로직 (바로 복제하는 것이 아니라, 저장해뒀다가 하나씩 복제함)
  replicationQueue.registerReplicationTask(bucket, objectKey);

  // ** 파일 업로드 완료 시에, control plane으로 메타 정보를 전달하는 로직 **
  notifyUploadComplete(
    {
      bucket,
      objectKey,
      fileSize: fileInfo.size,
      etag: fileInfo.etag ?? &quot;&quot;,
      storagePath: fileInfo.storagePath,
      primaryNodeIp: NodeIpDetector.getCurrentNodeIp(),
    },
    request.log,
  );

  return fileInfo;
}</code></pre>
<pre><code class="language-tsx">async function notifyUploadComplete(
  uploadInfo: {
    bucket: string;
    objectKey: string;
    fileSize: number;
    etag: string;
    storagePath: string;
    primaryNodeIp: string;
  },
  log: FastifyBaseLogger,
) {
  try {
    const controlPlaneUrl = process.env.CONTROL_PLANE_URL;
    if (!controlPlaneUrl) {
      throw new Error(&quot;CONTROL_PLANE_URL 값이 설정되지 않았습니다.&quot;);
    }

    const response = await fetch(
      `${controlPlaneUrl}/api/stored-objects/upload-complete`,
      {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify(uploadInfo),
      },
    );

    if (!response.ok) {
      throw new Error(
        `Upload complete failed: ${response.status} ${response.statusText}`,
      );
    }
  } catch (error: unknown) {
    // TODO : 실패한 메타데이터 전송 요청을 별도로 저장하고, 이를 재시도하는 로직 필요

    log.error(
      error instanceof Error
        ? {
            message: error.message,
            stack: error.stack,
            name: error.name,
          }
        : { error },
      &quot;[upload complete] control plane 호출 실패&quot;,
    );

    throw error;
  }</code></pre>
<h3 id="보완해야할-점"><strong>보완해야할 점</strong></h3>
<ul>
<li><strong>메타데이터 전송이 실패하는 경우</strong>, control plane에 파일 메타 정보가 없어서, <strong>조회가 불가능하게 됩니다. 이에 대한 대비책이 필요합니다.</strong></li>
<li><strong>다중화(복제)된 node ip 정보 또한, control plane으로 전달해야합니다.</strong> 이를 통해 원본이 깨졌을 때, secondary로 요청을 할 수 있습니다.</li>
</ul>
<h2 id="32-읽기-구조">3.2 읽기 구조</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/cad584ed-1ae1-492b-ae6a-3fd2a0645c1e/image.png" alt="읽기 흐름"></p>
<p>코드는 primary node ip를 조회해서 요청하는 방식입니다.</p>
<pre><code class="language-java">public class PresignedUrlService {
    public String generateGetPresignedUrl(String bucket, String objectKey, long fileSize) {
        log.info(&quot;업로드 Presigned URL 생성 요청 - bucket: {}, objectKey: {}&quot;, bucket, objectKey);

        StoredObject storedObject = storedObjectService.getObject(bucket, objectKey);

        String presignedUrl = generatePresignedUrl(DIRECT_PATH, bucket, objectKey, fileSize, &quot;GET&quot;,
            storedObject.getPrimaryNodeIp(), STORAGE_NODE_PORT);

        log.info(&quot;업로드 Presigned URL 생성 - url: {}&quot;, presignedUrl);

        return presignedUrl;
</code></pre>
<p>메타정보만 잘 저장되어 있고 읽을 수 있다면, 파일 조회는 어렵지 않습니다.</p>
<h3 id="보완해야할-점-1">보완해야할 점</h3>
<ul>
<li><strong><code>primary node</code>에 파일이 존재하지 않을 수도 있습니다</strong>. 이때는 <strong><code>secondary node</code>의 IP로 presigned url을 발급해야합니다.</strong> 따라서 해당 파일이 존재하는지 확인하고 presigned url을 발급할 필요가 있습니다.</li>
<li><strong>원본 파일이 깨진 경우, secondary 로 재요청을 보낼 수 있어야합니다.</strong></li>
</ul>
<h2 id="33-확장성">3.3 확장성</h2>
<p>제가 의도한 확장은 이것저것 추가하는 것 없이, 다음 2개의 절차로 간단하게 확장시키고 싶었습니다.</p>
<ol>
<li>docker로 VM에 storage node 서버 띄우기</li>
<li>control plane의 .env에 IP 등록하기</li>
</ol>
<p>현재 .env 파일에 ip를 추가하면 자동으로 요청이 가도록 설정하였습니다.</p>
<pre><code># Control Plane .env 파일

# storage node
STORAGE_NODE_IPS=20.196.137.76,20.200.201.246
STORAGE_NODE_PORT=3000
STORAGE_NODE_DISK_QUERY_TIMEOUT_MS=5000</code></pre><p><code>STORAGE_NODE_IPS</code> 에 ,를 기준으로 각각의 IP를 등록합니다.</p>
<p>Storage node에도 다음 2가지 정보를 추가하였습니다</p>
<pre><code># Storage Node .env 파일

NODE_IP=storage node의 IP 정보
CONTROL_PLANE_URL= 20.194.156.789:8080</code></pre><p><code>NODE_IP</code>는 파일 메타 정보를 보낼 때, 파일의 위치를 알려주기 위한 정보입니다.</p>
<p><code>CONTROL_PLANE_URL</code> 은 파일 메타정보를 보낼 서버 주소입니다.</p>
<p>위 정보만을 수정하고, docker를 통해 서버를 띄우면 간단하게 확장할 수 있습니다.</p>
<hr>
<h1 id="4-결과-테스트">4. 결과 테스트</h1>
<h2 id="41-쓰기-테스트">4.1 쓰기 테스트</h2>
<p>2개의 node 서버를 대상으로 테스트를 하였고, DISK 공간을 모두 유사하게 맞춰두었습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/852890de-a6ed-4b1d-9644-bef79715ad49/image.png" alt="테스트"></p>
<p>총 <strong>493개의 파일 업로드 요청</strong>을 했고 그 결과를 확인해보았습니다. </p>
<pre><code class="language-sql">SELECT t.primary_node_ip, COUNT(*) AS cnt
FROM tb_objects t
GROUP BY t.primary_node_ip;</code></pre>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/021ef72c-b9d8-4db5-b1cb-ce7767da30de/image.png" alt="DB 메타 데이터 저장 정보"></p>
<p>결과를 확인하면 상당히 고르게 분배된 것을 볼 수 있었습니다.</p>
<p><strong>트래픽 분산 로그</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/79360cf3-f6a0-42cf-91df-2695fc61629e/image.png" alt="로그"></p>
<p>2개의 서버를 등록해둬서, 2개로 나눠진 걸 볼 수 있습니다.</p>
<hr>
<h1 id="5-마무리">5. 마무리</h1>
<h2 id="51-시간이-많이-걸렸던-부분">5.1 시간이 많이 걸렸던 부분?</h2>
<p>사실 힘들다기보다는, 시간이 많이 걸렸던 부분이 있다.</p>
<p>controller 등록을 했는데, 빌드 후에 API가 작동을 안해서 어떤 문제인지 찾기가 어려웠다. 금방 끝날 것 같아서, VM 내부의 vim 으로 작업을 진행하고 테스트를 했는데 오히려 이게 발목을 더 잡은 듯 하다.</p>
<p>문제의 원인은 /gradlew build 시에, clean 을 하지 않아서 controller에서 API 인식을 못했던 것이다. clean 하고 하니 됐는데, 기쁘기도 했지만, 전혀 예상치 못한 곳에서 해결이 되어서 당황스럽기도 했다. </p>
<p>두 번째는 bucket 의 생성 여부인데, bucket 을 생성하지 않고 저장하다 보니, Control plane에서 에러가 발생하였고, 그 에러 메시지가 storage node의 로그에 제대로 출력이 안되었다.</p>
<ul>
<li><strong>로그를 제대로 명시하자</strong></li>
</ul>
<p>이전부터 로그 처리와 응답의 에러 포맷을 통일 시키는 것을 미루고 있었다. 이로 인해서 로그에 상태코드만 나왔을 뿐, 메세지가 안나왔었는데 이 문제로 인해 근본적인 원인 파악이 어려웠던 것 같다. </p>
<ul>
<li><strong>응답 및 로그 포맷, http 글로벌 응답 처리를 하자</strong></li>
</ul>
<p>클라이언트 → 서버 통신의 경우에는 어떤 응답이 보이는지 바로 보이지만, 서버내부 → 서버 통신간에는 예외처리나 로그를 제대로 찍지 않는 이상 어떤 오류나 응답이 왔는지 명확하게 보이지 않는다. 명확하게 보기 위해서는 로그, 포맷 등이 필수인 것 같다.</p>
<ul>
<li><strong>상수화 및 리팩토링</strong></li>
</ul>
<p>급하게 테스트를 돌려보고 싶다는 마음에, port 번호나 엔드포인트 등을 문자열로 하드코딩으로 박아버렸다. 이로 인해서 어떤 코드는 포트가 있고, 어떤 코드는 포트가 없다. 그 기준이 모호하니 상수화로 일관적으로 관리할 수 있도록 하자.</p>
<p>기능을 더 추가할 게 많지만서도, 리팩토링도 해야하고 정말 할게 많다.</p>
<h2 id="52-ai-사용-관련-느낀점">5.2 AI 사용 관련 느낀점</h2>
<p>이번에 AI를 사용해서 코딩을 했는데, 몇 가지 느낀점이 있어 작성한다.</p>
<ul>
<li><p><strong>“아직 구현하지마” 를 명시적으로 달아둘 것</strong></p>
<p>  해당 텍스트가 없으면, 자기 마음대로 설계가 완료되었다고 구현을 시작해버린다. 위 텍스트를 명시해서 설계에만 집중하도록 하자.</p>
</li>
<li><p><strong>한번 잘못 구현을 했다면, 대화창을 새로 파서 시작하자</strong></p>
<p>  이전 맥락을 과하게 기억하는듯 하다. 마음에 안들어서 지웠던 코드들이 다시 생성되어있는 모습을 많이 발견한다. 이런 부분들을 명시적으로 요청해도, 이 부분과 연관된 다른 부분들이 문제인지, 수정을 원치 않은 다른 부분들도 AI가 마음대로 수정한다.</p>
<p>  따라서 한번 잘못되었으면, 다시 대화창을 새로 파서 시작하자. 정정하는 것보다 새로하는게 더 빠르다.</p>
</li>
<li><p><strong>세션에서 읽기보다는 md 파일로 만들어 달라고 하자</strong></p>
<p>  원래는 세션으로만 읽고 요청을 했는데, 어느 부분을 특정지어서 말하는 게 너무 불편하다. ‘1의 storoedObject 엔티티의 A 필드를 ~~’ 이라고 하는 것보다, 그냥 md 문서를 내가 수정하는게 효율이 좋다. 뿐만아니라, 세션에서 하게 되면 다른 AI를 사용하는 게 힘든데, md 문서는 그대로 들고가면 되니 얼마나 효율적인가.</p>
</li>
</ul>
<h2 id="53-남아있는-과제">5.3 남아있는 과제</h2>
<h3 id="트래픽을-효과적으로-분산시키기">트래픽을 효과적으로 분산시키기</h3>
<p>현재의 방식은, 트래픽을 분산하는 것이 아니라, <strong>DISK 용량을 분산시키기 위한 전략</strong>이라서 <strong>트래픽 자체를 효과적으로 막을 수 있는 방법</strong>은 아니다. </p>
<p>따라서 <strong>트래픽 또한 분산할 수 있도록 좋을 것 같다.</strong></p>
<p>DISK 용량이 이상적으로 동일하다면, 트래픽이 잘 분산되지만, 그런 상황은 잘 발생하지 않는다. </p>
<p>이상적인 상황일땐 어떨 지 궁금해서 재미로 실제 성능을 한번 테스트를 진행해보았다.</p>
<p>이전과 마찬가지로 VUs=35로 진행해보았다. 디스크를 비우고 시작했으니, 이론상으로는 트래픽이 거의 균등하게 분배된다. 따라서 <strong>이전에 비해 2배 빨라져야한다.</strong> </p>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>이전 전송 속도 (MB/s)</strong></th>
<th><strong>현재 초당 전송 속도 (MB/s)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1MB</strong></td>
<td>0.54 MB/s</td>
<td>1.61 MB/s</td>
</tr>
<tr>
<td><strong>10MB</strong></td>
<td>1.71 MB/s</td>
<td>3.22 MB/s</td>
</tr>
<tr>
<td><strong>50MB</strong></td>
<td>1.79 MB/s</td>
<td>3.77 MB/s</td>
</tr>
<tr>
<td><strong>100MB</strong></td>
<td>1.95 MB/s</td>
<td>4.39 MB/s</td>
</tr>
</tbody></table>
<p>약 2배 늘어난 것을 확인할 수 있었다. 이상적인 수치이다.</p>
<h3 id="이밖에">이밖에</h3>
<ul>
<li>메타데이터 전송이 실패하는 경우에 대한 대비책</li>
<li>다중화(복제)된 파일 위치 정보 전송</li>
<li><code>primary node</code>에 파일이 존재하지 않는 경우에 대한 대비책</li>
<li>원본 파일이 깨진 경우, secondary 로 재요청</li>
</ul>
<p>뿐만아니라 내가 못 본 여러가지 사항이 있을 것 같다.</p>
<h2 id="54-하면서-느낀-점">5.4 하면서 느낀 점</h2>
<p>어떤 문제를 해결하다 보면, 해결 과정에서 새로운 문제들이 보이게 된다. 이번 경험에도 <strong>확장성만 높이려고 했지만,</strong> 이로 인한 안정성, 트래픽 분산 등 정말 많은 새로운 문제들이 나왔다. </p>
<p>특히 트래픽 분산 부분은, 하면서 해결하면 좋을 것 같아서 유혹을 많이 받았다. (그만큼 해보고 싶은 매력적인 기능이었다)</p>
<p>하지만 이전 경험으로부터 다른 길로 새는게 좋지 않다는 걸 알고있다. 그리고 최근에 읽었던 toss의 핵심 가치에서도 이를 피하는 것을 추천하고 있던게 기억이 났다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/59a8a9ac-36b1-47fd-bce0-8db88d05812f/image.png" alt="toss 핵심 가치"></p>
<p>toss는 뛰어난 개발자가 많은 집단이다 보니, 그들에게서 어떤 시행착오가 있었고 어떤 행동 지침이 있었는지를 참고하면 많은 도움이 된다고 생각한다.</p>
<p>아마 이전의 나였다면 핵심이 아닌 다른 일들에 매몰되느라, 가장 중요한 확장성을 놓치지 않았을까 싶다.</p>
<p>개발 공부도 중요하지만 종종 이러한 좋은 마인드셋들을 읽어보면서, 나에게 적용해보는 것 또한 중요하다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-7] 서버는 안 터지는데, 업로드가 느려졌다]]></title>
            <link>https://velog.io/@standard-chan/storage-7-%ED%8C%8C%EC%9D%BC-upload-%EC%86%8D%EB%8F%84-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/storage-7-%ED%8C%8C%EC%9D%BC-upload-%EC%86%8D%EB%8F%84-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 20 Mar 2026 14:28:18 GMT</pubDate>
            <description><![CDATA[<h1 id="쓰기-제어의-필요성">쓰기 제어의 필요성</h1>
<blockquote>
<p><strong>업로드 요청의 쓰기 속도 보장</strong></p>
</blockquote>
<p>이전 서버 최적화 작업으로 굉장히 <strong>많은 수의 업로드 요청을 한번에 처리할 수 있게</strong> 만들었습니다. 이제 5MB 파일의 경우 100명을, 100MB파일의 경우에는 60명도 서버 종료 없이, 안정적으로 처리할 수 있습니다.</p>
<p>(이전 기록 <a href="https://velog.io/@standard-chan/storage-5-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A1%8C%EB%8B%A4-%EC%9B%90%EC%9D%B8-%EB%B6%84%EC%84%9D%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EB%B0%8F-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D">https://velog.io/@standard-chan/storage-5-부하-테스트-중-서버가-터졌다-원인-분석과-해결-및-삽질-기록</a>)</p>
<p>하지만, <strong>많은 요청을 한번에 받으면, <code>업로드 속도</code>에서 문제가 발생하게 됩니다</strong>. 실제로 5MB파일을 100명이 올리게 될 경우, 60초가 넘어가는 경우가 많아서 <strong>TIMEOUT</strong>이 많이 발생하게 됩니다.</p>
<p>아래는 <strong>과도하게 동시 업로드 시</strong>를 진행하였을때, <strong>DISK의 I/O 그래프</strong>입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/395709bd-8fa9-4d61-a306-7bca5492742d/image.png" alt="DISK IO"></p>
<p><strong>DISK I/O 속도의 최대값이 약 140MB/s 넘지 못하는 것</strong>을 확인할 수 있습니다 (실제 Azure의 DISK 제한은 100MB/s인데, 40정도 더 나온 것 같습니다). 이로 인해서 <strong>DISK 대기큐도 많이 증가</strong>하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/df7176eb-cc96-43f0-bc82-1f7bd5c7fa40/image.png" alt="DISK QUEUE"></p>
<p>따라서 <strong>이러한 속도 저하 문제를 방지하기 위해서, 쓰기를 제한하는 정책이 필요</strong>하다고 생각하였습니다.</p>
<blockquote>
<p><strong>쓰기를 제어하여 업로드 속도를 보장하는 방법을 찾아보고, 이를 적용하는 것을 목표로 하였습니다.</strong></p>
</blockquote>
<hr>
<h1 id="2-어떻게-쓰기를-제어할까">2. 어떻게 쓰기를 제어할까?</h1>
<p>가장 무난한 방법으로는 <strong>업로드 요청의 개수를 기준으로 잘라내는 방법</strong>이 있습니다.
이보다 더 정확하게 제어하려면, <strong>업로드 데이터 흐름의 속도를 기준으로 판단</strong>하는 방법도 있을 수 있습니다.</p>
<h2 id="21-업로드-요청-수로-제한하기">2.1 업로드 요청 수로 제한하기</h2>
<p>이 방법은 한계 점이 분명합니다. </p>
<blockquote>
<p><strong>효율적이지 못하다</strong></p>
</blockquote>
<p>1MB 업로드 10개 요청과, 100MB 업로드 10개 요청은 다릅니다. 1MB의 경우 100개를 받아도 빠르게 처리할 수 있는데, 10개로 제한해버리면 90개의 요청을 처리하지 못한다는 문제가 있습니다.</p>
<p>이 방식으로 구현한다면 정말 간단하겠지만, <strong>서버의 자원을 효율적으로 사용할 수는 없을 것</strong> 같습니다. 그래서 다른 방법을 고민해보았습니다.</p>
<h2 id="22-데이터-흐름의-속도로-제한하기">2.2 데이터 흐름의 속도로 제한하기</h2>
<p><strong>데이터가 DISK까지 흘러가는 속도를 기준으로 제한</strong>한다면, 1MB같은 작은 요청은 더 많이 수용할 수 있을 것이고, 큰 요청은 적게 수용할 수 있을 것입니다. </p>
<p>예를 들어 각 요청당 5MB/s의 하한을 두고, 모든 요청이 해당 속도 이하가 되어버리면, 더 이상 요청을 받지 않는 방법이 있을 것 같습니다. </p>
<p><strong>이렇게 되면 자원을 효율적으로 사용할 수 있습니다.</strong> </p>
<p>하지만 한가지 어려움이 있었습니다.</p>
<blockquote>
<p><strong>어떤 수치를 기준으로 흐름을 판단해야할지 설정하기가 어렵다.</strong></p>
</blockquote>
<p><strong>데이터들의 흐름 속도가 DISK 병목으로 느려진 것인지, 네트워크가 느려서 느려진 건지 판단하기가 어렵습니다.</strong></p>
<p>더구나 이렇게 측정한 수치 측정 시간의 기준을 1s 로 할건지, 2s로 할건지의 시간문제부터, 그 값의 평균으로 할지 등의 계산 방식의 문제도 고려가 필요할 것 같습니다. 이러한 값을 추측하기 어려울 뿐더라 구현 외적으로 복잡도가 많이 크다고 생각하여 다른 방법을 생각해보았습니다.</p>
<h2 id="23-업로드-요청-수--요청-파일-크기로-제한하기">2.3 업로드 요청 수 + 요청 파일 크기로 제한하기</h2>
<blockquote>
<p><strong>모든 요청이 동일하지 않다면, 요청 별로 제한을 다르게 하자!</strong></p>
</blockquote>
<p><strong>업로드 요청에 들어가는 파일의 크기를 바탕으로 가중치를 부여</strong>하고, <strong>이를 바탕으로 요청 수를 제한</strong>하는 방법입니다.</p>
<p>예를 들어서, 다음 처럼 각 파일에 가중치를 둬서, 100을 넘어가면 더이상 요청을 받지 않는 방법입니다.</p>
<ul>
<li>5MB 미만인 파일 : 1 가중치</li>
<li>5MB 이상인 파일 : 5 가중치</li>
</ul>
<p>만약 3MB 파일 20개, 5MB 파일 5개라고 한다면, 총 45가 되는 것이고, 5MB파일 20개를 처리중이라고 하면 100으로 더 이상 요청을 받지 않는 것입니다.</p>
<p>이렇게 한다면 <strong>업로드 시, 서버 자원도 효율적으로 사용하고, 쓰기 속도도 보장할 수 있겠다고 판단</strong>하였습니다. </p>
<hr>
<h1 id="3-가중치-및-속도-설계하기">3. 가중치 및 속도 설계하기</h1>
<p>위 5MB의 가중치 기준은 예시일 뿐이고, 어떻게 가중치를 둬서, 어떻게 속도를 보장할 지를 고민해보았습니다.</p>
<h2 id="31-속도-측정하기">3.1 속도 측정하기</h2>
<p>현재 Azure의 DISK I/O 속도가 100MB/s 로 되어있습니다. <strong>실제로 이 수치가 나오는지 확인을 해야겠다</strong>고 생각을 하였습니다. 위 수치는 이론상 수치일 뿐이고 실제로는 모르기 때문입니다.</p>
<p>이 속도를 측정한 이후로, 몇 개의 요청을 받을지를 추측하면 될 것이라고 생각했습니다.</p>
<h3 id="311-세션-업로드-속도-측정하기">3.1.1 세션 업로드 속도 측정하기</h3>
<p><strong>세션 1개의 업로드 속도가 어느정도 나오는지를 측정</strong>했습니다. 이론상 DISK I/O가 100MB/s이니, 그와 비슷하게 나올 것 같습니다.</p>
<p><code>dirty page</code> 메모리 때문에, DISK I/O  속도 100MB/s 보다 빠른 수치가 나올 수 있습니다. 따라서 <strong>3m + dirty 메모리 128MB로 설정</strong>하여 진행하였습니다.</p>
<pre><code class="language-bash"># dirty page 메모리 설정하기
sudo sysctl -w vm.dirty_bytes=134217728</code></pre>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/40aa0117-9bec-4a26-891c-3b53ef82e054/image.png" alt="dirty page 메모리"></p>
<p>DISK 쓰기가 100MB/s이므로 dirty 제한을 128MB로 설정하였습니다.</p>
<table>
<thead>
<tr>
<th>파일 크기</th>
<th>동시 업로드 수</th>
<th>요청 당 업로드 속도</th>
<th><strong>총 업로드 속도</strong></th>
</tr>
</thead>
<tbody><tr>
<td>10MB</td>
<td>10개</td>
<td>11MB/s</td>
<td><strong>110MB/s</strong></td>
</tr>
<tr>
<td>100MB</td>
<td>1개</td>
<td><strong>71MB/s</strong></td>
<td><strong>71MB/s</strong></td>
</tr>
<tr>
<td>100MB</td>
<td>10개</td>
<td>8.1MB/s</td>
<td><strong>81MB/s</strong></td>
</tr>
<tr>
<td>500MB</td>
<td>1개</td>
<td>75MB/s</td>
<td><strong>75MB/s</strong></td>
</tr>
<tr>
<td>500MB</td>
<td>2개</td>
<td>48MB/s</td>
<td><strong>96MB/s</strong></td>
</tr>
<tr>
<td>1GB</td>
<td>1개</td>
<td><strong>78MB/s</strong></td>
<td><strong>78MB/s</strong></td>
</tr>
</tbody></table>
<h3 id="313-결과-분석하기">3.1.3 결과 분석하기</h3>
<p>10MB의 업로드 속도가 유독 빠른 이유는 <code>page cache</code> 크기가 파일 크기보다 크기 때문에 그런 것 같습니다.</p>
<p>해당 수치를 제외하고 보면, <strong>업로드 속도가 100MB/s에 수렴</strong>하는 것 같습니다. <strong>이를 통해 대충 90MB/s에 근접한 속도를 갖는다</strong>는 것을 확인할 수 있었습니다. </p>
<p>(여담이지만, 동시 업로드 수가 많아질수록 총 업로드 속도가 높아지는 것을 확인할 수 있는데, buffer에 데이터를 미리 받아오기 때문이 아닐까… 라고 생각해봅니다.)</p>
<h2 id="32-가중치-설정하기">3.2 가중치 설정하기</h2>
<p><strong>업로드 속도의 하한선은 5MB/s</strong>로 설정한다고 하겠습니다. </p>
<p>90MB/s를 이상적으로 나눈다고 하면, 18개의 요청으로 제한해야합니다. 다만, 모든 요청이 고르게 분배되지 않을 수도 있습니다. 예를 들어, 1MB 파일의 경우에는 5MB/s를 모두 사용하지 않기 때문에, 1MB파일 17개와 100MB 파일 1개의 요청이 들어온다면 고르게 분배되지 않을 것입니다.</p>
<p>따라서 <strong>5MB를 기준으로 5MB 이하의 파일은 파일 크기의 정수값으로 설정</strong>하고, <strong>5MB 이상의 파일들은 가중치 6로 설정</strong>하는게 좋겠다고 판단하였습니다.</p>
<p>또한 100MB/s 를 파일 업로드 작업 뿐만 아니라, DB 쓰기, 파일 읽기 등에도 사용되어야하기 때문에 여유롭게, <strong>제한을 70으로 설정하는 것</strong>이 좋다고 생각하였습니다. 그래야 다른 작업들이 들어와도 5MB/s를 보장할 수 있다고 생각했기 때문입니다.</p>
<h2 id="33-테스트를-통해-업로드-속도-직접-확인하기">3.3 테스트를 통해, 업로드 속도 직접 확인하기</h2>
<p>일반적인 상황을 가정하기 위해서, 1MB ~ 100MB 까지의 파일을 바탕으로 테스트를 진행하였습니다. 동시 업로드의 수(VUs)는 20과 30으로 진행하였습니다.</p>
<h3 id="vus-20명-기준-업로드-속도-비교">VUs 20명 기준: 업로드 속도 비교</h3>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>개선 전 속도</strong></th>
<th><strong>개선 후 속도</strong></th>
<th><strong>향상률</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1 MB</strong></td>
<td>0.55 MB/s</td>
<td>0.50 MB/s</td>
<td>(소폭 감소)</td>
</tr>
<tr>
<td><strong>10 MB</strong></td>
<td>2.75 MB/s</td>
<td><strong>3.05 MB/s</strong></td>
<td>약 11% ↑</td>
</tr>
<tr>
<td><strong>50 MB</strong></td>
<td>3.41 MB/s</td>
<td><strong>4.59 MB/s</strong></td>
<td>약 35% ↑</td>
</tr>
<tr>
<td><strong>100 MB</strong></td>
<td>3.66 MB/s</td>
<td><strong>5.04 MB/s</strong></td>
<td><strong>약 38% ↑</strong></td>
</tr>
<tr>
<td>성공률</td>
<td>100%</td>
<td>46%</td>
<td>약 46% <strong>↓</strong></td>
</tr>
</tbody></table>
<h3 id="vus-30명-기준--업로드-속도-비교">VUs 30명 기준 : 업로드 속도 비교</h3>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>개선 전 속도</strong></th>
<th><strong>개선 후 속도</strong></th>
<th><strong>향상률</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1 MB</strong></td>
<td>0.41 MB/s</td>
<td><strong>0.58 MB/s</strong></td>
<td>약 41% ↑</td>
</tr>
<tr>
<td><strong>10 MB</strong></td>
<td>1.68 MB/s</td>
<td><strong>3.01 MB/s</strong></td>
<td>약 79% ↑</td>
</tr>
<tr>
<td><strong>50 MB</strong></td>
<td>1.89 MB/s</td>
<td><strong>4.69 MB/s</strong></td>
<td>약 148% ↑</td>
</tr>
<tr>
<td><strong>100 MB</strong></td>
<td>2.11 MB/s</td>
<td><strong>5.19 MB/s</strong></td>
<td><strong>약 146% ↑</strong></td>
</tr>
<tr>
<td>성공률</td>
<td>99%</td>
<td>39%</td>
<td>약 61% <strong>↓</strong></td>
</tr>
</tbody></table>
<hr>
<h1 id="4-결과-분석">4. 결과 분석</h1>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>VUs = 20</strong></th>
<th><strong>VUs = 30</strong></th>
<th><strong>속도 차이</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1 MB</strong></td>
<td>0.50 MB/s</td>
<td><strong>0.58 MB/s</strong></td>
<td><strong>약 16% ↑</strong></td>
</tr>
<tr>
<td><strong>10 MB</strong></td>
<td><strong>3.05 MB/s</strong></td>
<td>3.01 MB/s</td>
<td>약 1% ↓ (동등 수준)</td>
</tr>
<tr>
<td><strong>50 MB</strong></td>
<td>4.59 MB/s</td>
<td><strong>4.69 MB/s</strong></td>
<td><strong>약 2.2% ↑</strong></td>
</tr>
<tr>
<td><strong>100 MB</strong></td>
<td>5.04 MB/s</td>
<td><strong>5.19 MB/s</strong></td>
<td><strong>약 3% ↑</strong></td>
</tr>
</tbody></table>
<p>VUs가 20명일 때와 30명일 때, 큰 차이가 없는 것을 확인할 수 있었습니다. </p>
<p>이는 랜덤 파일용량 업로드라는 점을 고려한다면, 큰 차이가 없다고 생각하였습니다. 따라서 <strong>동시 업로드 수가 아무리 많아도 위 속도를 유지할 수 있다고 생각하였습니다.</strong></p>
<p>혹시나 해서, <strong>VUs = 50</strong> 의 경우에도 테스트를 해보았습니다.</p>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>VUs=50 속도 (MB/s)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1 MB</strong></td>
<td><strong>약 0.59 MB/s</strong></td>
</tr>
<tr>
<td><strong>10 MB</strong></td>
<td><strong>약 3.39 MB/s</strong></td>
</tr>
<tr>
<td><strong>50 MB</strong></td>
<td><strong>약 4.38 MB/s</strong></td>
</tr>
<tr>
<td><strong>100 MB</strong></td>
<td><strong>약 5.07 MB/s</strong></td>
</tr>
</tbody></table>
<p>이 역시 VUs 20, 30과 큰 차이가 없음을 확인할 수 있었습니다.</p>
<p>결과적으로는 요청이 아무리 많아도, 항상 유사한 속도를 보장할 수 있다는 걸 확인할 수 있었습니다.이로써 초기 목표였던, <strong>과도한 동시 요청 시, 속도가 저하되는 문제를 막을 수 있게 되었습니다</strong>. 속도를 항상 5MB/s와 유사하게 보장할 수 있도록 구조를 만들었습니다.</p>
<p>다만 개수제한으로 거절을 하는 방식이다보니, 성공률 자체는 낮다는 아쉬움이 있습니다.</p>
<hr>
<h1 id="5-아쉬운-점">5. 아쉬운 점</h1>
<p>아직 <strong>성공률에 있어서 한계가 많습니다</strong>. 즉, <strong>업로드를 보냈는데 바로 거절 당해버리는 경우</strong>가 많습니다.</p>
<p>물론 업로드 요청이 연결만 되면, 빠르게 처리할 수 있지만, <strong>업로드 요청이 많은 상황이라면 업로드 연결을 하는 것 자체가 어렵다는 문제가 있습니다.</strong> 
뿐만 아니라, 만약 <strong>가중치가 높은 업로드 요청들이 네트워크 이슈로 세션을 계속 차지하고 있다면, 다른 업로드 요청들이 진입을 못하는 문제</strong>도 상상할 수 있겠습니다. </p>
<p>따라서 <strong>이 부분에 있어서 개선이 필요할 것 같습니다</strong>. 지금 당장 떠오르는 해결방안으로는</p>
<p>과하게 오랜 시간 동안 업로드를 하는 경우에는 동적으로 가중치 조절하거나, 연결 끊는 방법을 생각해볼 수 있습니다.</p>
<hr>
<h1 id="6-롤백---추가-26324">6. 롤백..? - 추가 (26.3.24)</h1>
<p>실제로 사용을 해보면서, 문득 이런 의문이 생겼습니다.</p>
<blockquote>
<p><strong>내가 사용자라면 아예 거절당하는 것보다, 속도가 느리더라도 업로드 요청이 전달되기를 바랄 것 같은데…</strong></p>
</blockquote>
<p>지금 방식이, 먼저 업로드한 사용자의 속도를 유지하고, DISK 부하를 줄이겠다는 취지에서는 좋은데, <strong>이게 과연 사용자 전체의 관점에서 봤을 땐 좋은 선택이었을까 하는 의문</strong>입니다.</p>
<p>반대로 먼저 업로드한 사용자의 관점에서는 어떨까 생각해봤습니다.</p>
<p>‘내가 먼저 업로드 했으니! 5MB/s 업로드 속도가 나오는게 맞아!’ 라고 생각할까? </p>
<p><strong>물론 너무 길게 소요된다면 불편함이 있을 것 같지만,</strong> 5MB/s든 3MB/s든 크게 신경 안쓸 것 같다고 생각합니다. 그저 업로드만 완료하면 되니까. (<del>물론 당연히 빨리되면 좋다</del>)</p>
<blockquote>
<p>즉, <strong>사용자의 관점에서는 ‘빠른 업로드의 속도가 보장되면 좋지만, 업로드가 되고 있느냐 아니냐’가 중요</strong>할 것 같다고 생각한다.</p>
</blockquote>
<h2 id="그럼-최선은-어떻게">그럼 최선은 어떻게…?</h2>
<blockquote>
<p>롤백…? 보다는 <strong>한계를 느슨하게</strong></p>
</blockquote>
<p>아무리 업로드가 되고 있는게 중요하다고 한들, 업로드 속도가 1~2MB/s 미만이 나오게 된다면, 업로드 경험이 썩 좋지는 못할 것이다. (1GB 파일을 올리는데만 15분 이상이 걸리면, 사용을 하더라도 불쾌할 것 같다.) 즉, 제한을 아예 없애는 것은 좋은 방법이 아니라고 생각한다.</p>
<p>그래서 한계를 느슨하게 잡으려고 한다. <strong>가능한 최대로 수용하되, 과하면 차단</strong>하려고 한다</p>
<h2 id="가중치-수정하기">가중치 수정하기</h2>
<p>대충 90MB/s IO 속도를 갖으므로, 업로드 외의 IO작업을 생각하면, 60-70개의 요청 개수로 한계를 설정하면 1~2MB/s로 여유롭게 처리할 수 있을 것 같다.</p>
<p>그러면 2MB 이상인 파일에 대해서 가중치를 2로 설정하고, 그 외의 파일은 1로 설정하기로 하였다. 그리고 상한선은 70으로 하자.</p>
<p>이론적으로는 최저 35개 ~ 최대 70개의 동시 요청까지 수용할 수 있다.
VUs=35로 테스트를 진행하였고, 총 301개의 <strong>요청을 100%로 처리</strong>할 수 있었다. 속도도 예상치와 유사하게 나왔다.</p>
<table>
<thead>
<tr>
<th><strong>파일 크기</strong></th>
<th><strong>평균 소요 시간 (avg)</strong></th>
<th><strong>초당 전송 속도 (Mbps)</strong></th>
<th><strong>초당 전송 속도 (MB/s)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1MB</strong></td>
<td>1,835.89ms</td>
<td><strong>4.36 Mbps</strong></td>
<td>0.54 MB/s</td>
</tr>
<tr>
<td><strong>10MB</strong></td>
<td>5,853.85ms</td>
<td><strong>13.67 Mbps</strong></td>
<td>1.71 MB/s</td>
</tr>
<tr>
<td><strong>50MB</strong></td>
<td>27,915.33ms</td>
<td><strong>14.33 Mbps</strong></td>
<td>1.79 MB/s</td>
</tr>
<tr>
<td><strong>100MB</strong></td>
<td>51,282.62ms</td>
<td><strong>15.60 Mbps</strong></td>
<td>1.95 MB/s</td>
</tr>
</tbody></table>
<h2 id="아쉬운-점">아쉬운 점</h2>
<p>처리 요청 수를 늘리면, 결국 속도가 느려진다. 즉, 두 요소 모두 확보하기가 불가능에 가깝다.</p>
<p>결국 이 방식의 근본적인 한계는 모든 요청이 동일한 대역폭을 나눠 갖는다는 점이다. 10MB짜리 파일은 사실 금방 끝날 수 있는데, 거의 5초가량 대기해야한다.
1MB ~ 10MB 파일(카메라 이미지, pdf파일 등)이 보통 가장 많이 업로드되기 때문에, 이러한 파일에 우선권을 주는게 좋다고 생각한다.</p>
<p>그렇다면 *<em>처리 요청 수를 최대한 많이 유지하면서도, 작은 파일에게 더 많은 대역폭을 우선적으로 주는 방법은 없을까? *</em></p>
<p>이 부분에 대해서 고민을 해봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-6] 서버 메모리 부하로 인한 종료 - 원인 분석과 해결 및 삽질 기록]]></title>
            <link>https://velog.io/@standard-chan/storage-5-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A1%8C%EB%8B%A4-%EC%9B%90%EC%9D%B8-%EB%B6%84%EC%84%9D%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EB%B0%8F-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@standard-chan/storage-5-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A1%8C%EB%8B%A4-%EC%9B%90%EC%9D%B8-%EB%B6%84%EC%84%9D%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EB%B0%8F-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Sun, 15 Mar 2026 13:08:34 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>모니터링 지표를 보고, 잘못된 길로 빠져버렸다.</strong></p>
</blockquote>
<hr>
<p>네이버 부스트캠프 당시에 최종 프로젝트 발표가 기억이 난다. 발표 도중에 우리 서버가 트래픽 부하로 인해 터져버렸었다. 잊으려 해도 잊을 수 없는 경험이다...</p>
<p>이러한 끔찍한 기억을 살려서, <strong>무수히 많은 요청에도 버틸 수 있는 서버를 구축해보고 싶었다.</strong> <strong>서버 부하가 많은 들어가는 업로드로 부하 테스트를 진행하여 안정적으로 버틸 수 있는 서버를 만들어보자!</strong></p>
<blockquote>
<p>이 글은 파일 업로드를 처리하는 스토리지 서버의 문제를 발견하고 해결하는 과정을 다뤘습니다.</p>
</blockquote>
<hr>
<h1 id="1-개요">1. 개요</h1>
<p>100MB 파일 업로드를 기준으로 부하테스트를 실시하던 중, <strong>동시 업로드 수가 늘어날수록 서버가 불안정해지다가 결국 터져버렸다.</strong> </p>
<p>내 예상으로는 속도만 느려져야하는데, 그게 아니라 서버가 터져버리는 문제가 발생했다.</p>
<h2 id="11-한계-지점-발견">1.1 한계 지점 발견</h2>
<p>동시 업로더 수를 점진적으로 늘려가며 테스트해보니, <strong>40명이 동시 업로드</strong>할 때, <strong>VM이 중지되는 문제</strong>가 발생했다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/5c67267d-7c5d-4dcb-b13f-63b414010aad/image.png" alt=""></p>
<p>메모리상으로 봤을 때, full 로 차있는 부분에서 VM 중지가 발생했다.</p>
<p>약 <strong><code>2.5</code>~<code>2.7</code>GiB를 사용할 때 VM이 종료되었다.</strong> 이를 바탕으로 <strong>한계 메모리 지점을 2.5GB</strong> 라고 판단하였다. (VM 메모리는 4GB이다)</p>
<h2 id="12-1차-안정-장치---docker-container-메모리-제한-설정">1.2 1차 안정 장치 - docker container 메모리 제한 설정</h2>
<p>한계지점까지 도달하면 VM 자체에 문제가 발생해버린다. 그리고 <strong>VM이 통째로 내려가면 재시작까지 최소 5분이 소요된다</strong>. </p>
<p>따라서 서비스 중단 시간을 최소화하기 위해, 우선 <code>docker container</code>의 메모리 상한을 <strong><code>2GB</code></strong>로 설정하여 container만 종료시키는 로직을 추가하였다. <strong>VM이 중지되는 것보다, container만 재시작하는게 복구가 쉽기 때문이다.</strong> </p>
<pre><code># docker-compose.yml
services:
  storage-node:
    mem_limit: 2g</code></pre><h2 id="13-정밀-한계-지점-측정">1.3 정밀 한계 지점 측정</h2>
<p>메모리 제한을 설정한 뒤, 2GB 메모리에서 <strong>정확한 동시 요청 수의 <code>한계지점</code>을 측정</strong>하기 위해 부하 테스트를 다시 진행하였다. </p>
<p>동시 요청 수를 30 → 25 → 20으로 낮춰가며 테스트한 결과, 20은 성공했지만, 30과 25에서 강제 종료되었다.</p>
<pre><code class="language-tsx">dmesg -T | grep -i &quot;oom-kill&quot;</code></pre>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/70ecaa9c-c39c-4562-bcc1-8a59e09410bc/image.png" alt="한계"></p>
<p>이후 20부터 한 단계씩 올려 측정한 결과, <strong>22개까지는 정상 처리</strong>되고 <strong>23개부터 강제 종료</strong>됨을 확인할 수 있었다.</p>
<p>이 수치를 기준점으로 삼아, 다음 목표를 설정했다.</p>
<blockquote>
<p><strong>동시 요청이 23개 이상 발생하더라도, 메모리 사용을 안정적으로 유지하여 서버의 비정상적인 종료를 방지한다. 최소 50개의 동시 요청은 버틸 수 있는 서버를 만들자</strong></p>
</blockquote>
<hr>
<h1 id="2-해결방안---요청-수-제한하기">2. 해결방안 - 요청 수 제한하기</h1>
<p>가장 단순한 해결책은 동시 처리 요청 수를 22개 이하로 제한하는 것이라고 생각했다. 실제로 안전 하게 <strong>17개 정도로 상한을 설정</strong>하면 서버의 안정성은 확실하게 보장할 수 있다.</p>
<p>하지만 요청 수를 기준으로 제한할 경우, 한 가지 우려되는 부분이 있다.</p>
<h2 id="문제점">문제점</h2>
<p>부하가 크지 않는 업로드 요청도 제한이 걸린다는 점이다.</p>
<p>아래는 5MB 파일 업로드로 진행한 부하테스트 결과이다.</p>
<table>
<thead>
<tr>
<th>VUs</th>
<th>req/s</th>
<th>data_sent</th>
<th>avg duration</th>
<th>p95 duration</th>
<th>iteration</th>
</tr>
</thead>
<tbody><tr>
<td><strong>20</strong></td>
<td><strong>13.1 req/s</strong></td>
<td><strong>34 MB/s</strong></td>
<td><strong>510 ms</strong></td>
<td><strong>2.58 s</strong></td>
<td><strong>3.02 s</strong></td>
</tr>
<tr>
<td>30</td>
<td><strong>17 req/s</strong></td>
<td>45 MB/s</td>
<td>749 ms</td>
<td><strong>2.75 s</strong></td>
<td>3.48 s</td>
</tr>
<tr>
<td>50</td>
<td><strong>18.2 req/s</strong></td>
<td>48 MB/s</td>
<td>1.69 s</td>
<td><strong>5.91 s</strong></td>
<td>5.38 s</td>
</tr>
<tr>
<td>70</td>
<td><strong>19.7 req/s</strong></td>
<td>50 MB/s</td>
<td>2.57 s</td>
<td><strong>8.04 s</strong></td>
<td>7.17 s</td>
</tr>
</tbody></table>
<p>5MB파일의 경우, 70개의 동시 업로드도 (속도는 느리지만) 문제없이 처리할 수 있다. 따라서 요청의 수를 17개로 제한하면, 서버에 부담이 없는 요청까지 불필요하게 막히게 된다. 그래서 서버의 자원을 제대로 사용하지 못하는 비효율적인 결과를 낳을 수 있을 것이라고 판단했다.</p>
<p><strong>그래서 요청 수를 줄이기보다, 메모리 사용량 자체를 줄이는 방향</strong>으로 접근하기로 했다. 이를 위해서, 모니터링을 분석하고 메모리를 왜 많이 사용하는지를 파악했다.</p>
<hr>
<h1 id="3-원인-분석하기">3. 원인 분석하기</h1>
<p>메모리 사용량이 2GB를 넘어서 서버가 종료되는 것이므로, 어느 부분에서 <strong>메모리를 과도하게 사용하는 지</strong>를 먼저 확인해보았다.</p>
<h2 id="31-메모리-사용-지점-분석">3.1 메모리 사용 지점 분석</h2>
<p>다음 2개의 지점을 의심해봤다.</p>
<ol>
<li><strong>DISK 캐시에서 dirty page memory</strong></li>
<li><strong>애플리케이션 코드 자체에서 메모리 사용</strong></li>
</ol>
<p>결론부터 말하면 <strong>2번이 원인</strong>이었다. 하지만 처음에는 모니터링 수치에 이끌려 1번을 먼저 분석했고, 이를 바탕으로 해결하려고 하였다. 결과적으로는 삽질을 많이 하게 되었지만, 그 과정에서 얻었던 것들도 많아 기록 하게되었다.</p>
<h2 id="32-첫-번째-가설---disk-캐시">3.2 첫 번째 가설 - DISK 캐시</h2>
<p>결과적으로 삽질이 된 경험이지만…</p>
<h3 id="31-왜-나는-disk-캐시를-먼저-분석하였는가">3.1 왜 나는 DISK 캐시를 먼저 분석하였는가?</h3>
<blockquote>
<p><strong>분석하기 위해 보았던, 모든 모니터링 지표가 DISK 병목을 말하고 있었다!!</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/40880841-766f-4088-9e37-3942c5821777/image.png" alt="처리"></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/23cd728f-f321-4887-84fa-1e7ae4444a48/image.png" alt="디스크 큐"></p>
<p>DISK Queue, CPU I/O… 여기 올린 그래프 뿐만아니라 <strong>모든 지표가 DISK만을 향해있었다</strong></p>
<p>모니터링을 잘 다루지 못했던 나는, &#39;메모리 적재의 원인이 DISK&#39;라고 생각하며 DISK만을 몰입했다</p>
<p>그러던 중, DISK에 더 몰입하게 된 결정적 계기가 된, 메모리 지표를 보게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/fa43ce22-c345-4bc4-a69c-cdb36a16518b/image.png" alt="메모리 지표"></p>
<p>특히 수치 중에서 DISK와 관련이 있는 수치가 있었다. <strong><code>Cache - Parked file data (file content) cachce</code></strong> 에 계속해서 눈길이 갔다… DISK와 밀접한 관련이 있을 뿐만 아니라, 심지어 평균이 <code>1.31GB</code>로 상당히 많은 공간을 차지하고 있었다. (눈이 안갈수가 없다)</p>
<p>그래서 메모리 중에서도 DISK와 연결점이 있는 <strong><code>DISK Cache</code></strong>를 먼저 보는 것이 맞다고 생각하였고, *<em>이것이 원인이라는 가설을 세우게 되었다. *</em></p>
<h3 id="32-처리-흐름과-disk-캐시-관계-파악하기">3.2 처리 흐름과 DISK 캐시 관계 파악하기</h3>
<p>DISK 캐시가 어떻게 쌓이는지를 확인하기 위해, <strong>업로드 로직의 처리의 흐름을 분석</strong>해보았다. </p>
<pre><code>NIC
↓
kernel socket buffer
↓
Node stream
↓
write()
↓
kernel page cache ← 디스크 캐시 (여기가 문제라고 판단)
↓
disk flush</code></pre><p>disk flush, 즉, DISK I/O 속도의 병목으로 인해서, kernel page cache 메모리에 적재되었다고 판단했다.</p>
<p>현재 disk I/O의 속도가 100MB/s 인데, <strong>DISK 캐시(kernel page cache)에 100MB/s 이상의 속도로 데이터가 들어온다면</strong>, 계속해서 쌓이는 문제가 발생한다. 결국 시간이 흐르면 <strong>메모리가 가득차게되는 문제가 발생하고, 이러한 문제가 VM 자체에 영향을 주어서 비정상적인 종료가 일어난 것이라고 판단</strong>했다.</p>
<h3 id="33-해결-시도">3.3 해결 시도</h3>
<p>그래서 해당 DISK cache 부하를 막기 위해서, Disk Memory의 <strong>Dirty Page 크기가 임계점을 넘어갈 경우, write()를 대기하고, 다시 미만이 될 경우 write()를 실행하는 로직을 구현</strong>하였다.</p>
<p> <code>/proc/meminfo</code> 를 통해 memory 값들을 확인할 수 있다. 이 명령어를 통해 <strong><code>Dirty page</code> 크기를 조회</strong>해서 <strong>Stream을 write() 하기 전에 가득 차있으면, 대기하는 로직</strong>을 넣었다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/70d2b429-576f-4ff3-b7e0-72619446e281/image.png" alt="메모리meminfo"></p>
<p>(proc/meminfo 를 조회하면, Dirty 값을 확인할 수 있다)</p>
<pre><code class="language-tsx">// 대기 로직
export async function throttleDirtyCachce(log: FastifyBaseLogger): Promise&lt;void&gt; {

    // dirty page cache 크기 조회
  const currentDirty = await getDirtyPageSizeCached();

  ...


  if (currentDirty &gt;= DIRTY_PAGE_LIMIT) { // dirty cache가 크면
    ...

        // 대기 
    await waitForDirtyPageDecrease(log);
  }
}

// 대기 및 공간 확인 로직
async function waitForDirtyPageDecrease(log: FastifyBaseLogger): Promise&lt;void&gt; {
  let attempts = 0;
  const maxAttempts = 60000 / WAIT_POLL_INTERVAL_MS; // 60초 타임아웃

  while (attempts &lt; maxAttempts) {
    const currentDirty = await readDirtyPageSize();

    if (currentDirty &lt; DIRTY_PAGE_LIMIT) { // 여유가 되면, pass
        ... 
      break;
    }

    // 300ms 대기 후 재시도
    await new Promise((resolve) =&gt; setTimeout(resolve, WAIT_POLL_INTERVAL_MS));
    attempts++;
  }
}</code></pre>
<p>뭐… 이런 시도를 했지만, 부하 테스트 결과, VM이 동일하게 중지되었다. 즉 <strong>DISK 캐시는 원인이 아니었다.</strong> 뭔가 허무하기도 하고… 당연한 결과 아닐까 싶기도 하고 (Ubuntu가 얼마나 많이 최적화를 시켜뒀겠어…)</p>
<h3 id="추후에-알게된-사실">추후에 알게된 사실</h3>
<blockquote>
<p><strong>application 로직으로 제어를 했는데, 굳이 이럴 필요는 없었다.</strong></p>
</blockquote>
<p>linux의 <code>/proc/sys/vm/</code> 경로에는 커널의 메모리 관리 정책을 설정하는 파일들이 저장되어있다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/1d72fd38-a498-4bd2-8b1c-747e092095ab/image.png" alt="커널 설정"></p>
<p>이중에 <code>dirty_bytes</code> 라는 파일이 있는데, 이 파일을 수정하면 dirty page 크기를 커널 수준에서 조절할 수 있다.</p>
<ul>
<li><code>dirty_bytes</code> 는 해당 bytes 이상으로는 write를 하지 못하도록 block하는 bytes를 설정</li>
<li><code>dirty_background_bytes</code> 는 flush 를 시작하는 bytes 값</li>
</ul>
<p>위에서는 application 수준에서 조절하도록 로직을 작성하였는데, 굳이 그럴 필요가 없고 위 파일 내용을 바꾸는 것만으로 간단하게 제한을 시킬 수 있다.</p>
<h2 id="32-두-번째-가설---애플리케이션-코드">3.2 두 번째 가설 - 애플리케이션 코드</h2>
<p>첫 번째 가설을 검증하는데 생각보다 오랜 시간이 걸렸다… 별거 아니고 이론을 먼저 공부했다면 직접 구현하는 것 없이 넘어갈수도 있었을 거라고 생각이 든다. </p>
<p>아무튼 해당 가설이 틀렸음을 확인하고, <strong>애플리케이션 코드에서의 메모리 사용 지점을 찾아</strong>보았다.</p>
<h3 id="321-etag-생성">3.2.1 ETag 생성</h3>
<p>여러 지점이 있었지만 <strong>눈에 띄는 지점은 <code>ETag</code> 생성 지점</strong>이었다. </p>
<p><code>ETag</code>는 파일이 올바르게 저장 되었는지를 확인하는 hash 값인데, 기존 코드에서는 파일 전체를 memory에 올려서 처리를 진행하고 있다.</p>
<pre><code class="language-tsx">export async function generateETag(filePath: string): Promise&lt;string&gt; {
    // 파일 전체 읽어서, 메모리에 올리기
  const fileBuffer = await fsPromises.readFile(filePath);
  const hash = crypto.createHash(&#39;sha256&#39;);
  hash.update(fileBuffer);
  return hash.digest(&#39;hex&#39;);
}</code></pre>
<p>지금 이 로직을 설명하자면, 100MB 파일을 업로드하게 될 경우, 100MB 전체를 메모리에 올려 ETag를 생성한다. 만약, 22개의 파일을 동시에 업로드하게 되면, ETag를 생성하는데에만 2.2GB 메모리가 필요하게 된다. 즉, <strong>메모리에 큰 부하를 주는 로직이다.</strong></p>
<h3 id="322-왜-이런-코드를-작성하였는가">3.2.2 왜 이런 코드를 작성하였는가?</h3>
<p>처음에 해당 로직을 작성할 때에는, 크게 문제로 생각하지 않고 있었다. 왜냐하면 <strong>해시값을 생성하기 위해서는 당연히 전체 내용이 필요하다고 생각했기 때문</strong>이다.</p>
<h3 id="323-streaming-으로-전환">3.2.3 Streaming 으로 전환</h3>
<p>해결 방안으로 여러가지를 생각해봤다. <strong>첫 번째 방법</strong>으로는 multipart 업로드로 각 part 별 Etag를 생성한다면, 메모리에 부하를 줄일 수 있을 것 같다.</p>
<p>이 방법뿐이라고 생각을 했는데, 두 번째 방법으로 <strong>Hash 생성시에 Streaming 방식으로 올려서 Hash처리를 할 수 있다</strong>고 한다.</p>
<pre><code class="language-tsx">export async function generateETag(filePath: string): Promise&lt;string&gt; {
  const hash = crypto.createHash(&#39;sha256&#39;);

  await new Promise&lt;void&gt;((resolve, reject) =&gt; {
    const readStream = fs.createReadStream(filePath);
    readStream.on(&#39;data&#39;, (chunk) =&gt; hash.update(chunk));
    readStream.on(&#39;end&#39;, resolve);
    readStream.on(&#39;error&#39;, reject);
  });

  return hash.digest(&#39;hex&#39;);
}</code></pre>
<p>최근 대부분의 해시 알고리즘이 <strong>chunk 단위의 업데이트를 지원한다고</strong> 한다. chunk 64KB 단위로 메모리에 올리고 사용하여, 메모리 사용을 줄일 수 있다.</p>
<h3 id="324-간단하게-찾아본-원리">3.2.4 간단하게 찾아본 원리</h3>
<p><strong>궁금해서 원리를 찾아봤는데</strong>, chunk 단위로 데이터를 처리한다고 한다.</p>
<p>예를 들어서, “123456789” 데이터가 있고, chunk를 3개라고 하자. 그러면 다음으로 처리된다.</p>
<p>hash(”123456789”) = hash(”123”) + hash(”456”) + hash(”789”) = hash(”123” + “456” + “789”) 이런 식.</p>
<p>즉, 모든 데이터를 청크 단위로 처리해서 개별적으로 하나, 한번에 하나 동일한 결과가 나오게 된다.</p>
<hr>
<h1 id="4-개선-결과">4. 개선 결과</h1>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/44d14900-689d-4c87-9de9-5c4c7c3023d6/image.png" alt="메모리 사용량"></p>
<p>이를 통해 메모리의 사용을 <strong><code>2GB</code></strong>에서 <strong><code>160MB</code> 수준</strong>으로 개선할 수 있었다. (생각보다 심각한 문제였다)</p>
<table>
<thead>
<tr>
<th></th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>최대 동시 요청 수</td>
<td>22</td>
<td>50 이상</td>
</tr>
<tr>
<td>메모리 사용량</td>
<td>2GB</td>
<td>130MB</td>
</tr>
</tbody></table>
<p>그래서 동시 유저 수 30 뿐만아니라 50명에서도 충분히 버틸 수 있는 안정성 높은 서버를 만들 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/0b20c509-f4fe-40da-8dc6-e0ace61334fc/image.png" alt="50 부하테스트"></p>
<p> 50명이 동시 업로드하는 부하 테스트에서도 OOM 없이 안정적으로 동작하는 것을 확인할 수 있었다. 테스트 서버 자체의 부담으로 인해서, 100명까지는 해보지 않았지만, 충분히 가능할 것 같다.</p>
<hr>
<h1 id="5-개선할-점">5. 개선할 점</h1>
<p>이 개선으로 50명이 동시에 업로드해도 문제없고, 100명도 가능할 것 같다. </p>
<p>근데 <strong>한가지 아쉬운 점</strong>이 있다.</p>
<p><strong>동시 요청 수가 늘어날수록, 각 요청에 대한 처리 속도가 느려진다는 점</strong>이다. 많은 요청이 들어오면 서버는 꺼지지 않겠지만, 파일을 올리는 UX는 내려갈 수 밖에 없다.</p>
<p>결국에 이걸 해결하려면, <strong>요청 수 제한을 도입해야하는데 이 요청 수를 설정하는게 정말 어려운 것 같다</strong>. </p>
<p>뭐.. 기존에는 23개 동시 업로드부터 서버가 터졌으니까, 명확한 기준이라도 있었지만, 이제는 그 기준조차 없다. 요청수를 30으로 제한할지 50으로 할지… </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/c5b80df3-b4c6-47b5-9de8-5bcc32a4262c/image.png" alt="모래알"></p>
<blockquote>
<p>모래알 몇개부터 모래알  더미라고 해야하는가?</p>
</blockquote>
<p>가장 이상적인 방법은 현재 처리하고 있는 데이터의 크기를 바탕으로 동적으로 조절하면 좋을 것 같다.  DISK I/O 속도가 100MB/s 이니까, 이거 맞춰서 제한하면 효율적일 뿐만 아니라, 명확한 숫자를 찾아낼 수 있을 것 같다.</p>
<p>다음번엔 이 부분에 대해서 고민해볼 예정이다.</p>
<hr>
<h1 id="6-배운점">6. 배운점</h1>
<h3 id="linux-커널에-대한-이해">linux 커널에 대한 이해</h3>
<blockquote>
<p>application 로직 구현 전, 커널단위에서 제공하는 기능이 있는지 먼저 확인하자 </p>
</blockquote>
<p>아쉬웠던 점이 하나 있는데, dirty page의 크기를 OS 설정에서 제한할 수 있다는 것이다. 
이 사실을 뒤늦게 알게되어서, 위의 첫번째 가설 검증에서 사용하지 못했다는 아쉬움이 있다. 만약 이를 사용했다면 더욱 빠르게 가설이 틀렸음을 검증할 수 있었을 것이다.</p>
<p>이러한 메모리 조절이나 네트워크 소켓 버퍼 등과 같이 OS에서 담당하는 기능들은 설정이 존재할 가능성이 있으니, 앞으로는 이를 먼저 확인하여 검증할 수 있는지 확인하자.</p>
<h3 id="가설-검증-순서">가설 검증 순서</h3>
<blockquote>
<p>문제 발생 시점의 데이터만을 고려하자.</p>
</blockquote>
<p>OOM → 메모리 문제
DISK 병목이 심함 -&gt; 메모리 문제의 원인?
이라고 생각하여 DISK를 먼저 검증했다.</p>
<p>하지만 서버가 종료되는 시점에 OOM이 작동한 것이라면, <strong>종료되는 시점의 데이터를 우선하여 확인할 필요가 있다.</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/ec4149cd-a59c-463d-b76b-74cf5f0e8637/image.png" alt="종료 시점의 메모리 사용량"></p>
<p>종료되는 시점에 초록색(application mem)이 폭등한 것을 확인할 수 있다. 따라서 application 을 먼저 검증하는 것이 더 합리적일 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage - 5] 테스트 환경 격리하기]]></title>
            <link>https://velog.io/@standard-chan/%EB%8F%84%EB%9E%80%EB%8F%84%EB%9E%80-%EC%9D%B4%EC%95%BC%EA%B8%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%EA%B2%A9%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/%EB%8F%84%EB%9E%80%EB%8F%84%EB%9E%80-%EC%9D%B4%EC%95%BC%EA%B8%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%EA%B2%A9%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 13 Mar 2026 05:33:14 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/standard-chan/post/ca4a6bd1-6257-47c1-90bd-7ff7abca54b1/image.jpg" alt="격리"></p>
<p>테스트 환경 좀 격리하자</p>
<p>매번 로컬 -&gt; 배포 서버에 부하를 걸어서 테스트를 했는데, 이게 너무 불편해서 테스트 전용으로 격리된 VM을 띄웠다. 그 과정에 대한 이야기.</p>
<blockquote>
<p>기술적인 이야기는 아니고, 하면서 느꼈던 감정이나 생각을 막 적은 글</p>
</blockquote>
<h2 id="구축-계기">구축 계기</h2>
<p><strong>실제 배포 서버를 대상으로 하고 싶었는데,</strong> 로컬에서 실제 배포 서버로 부하테스트를 진행하기 어려웠다. 왜 어려웠는가 하면…</p>
<h3 id="1-인터넷-속도"><strong>1. 인터넷 속도</strong></h3>
<p>보통 학교나 집에서 개발을 많이 하는데, wifi 가 너무 제한되어있다… </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/8d7e0e0d-1d81-4c8b-82a4-9a8fc991b347/image.png" alt="인터넷"></p>
<p>테스트 내용이 업로드/다운로드인데… <strong>업로드 대역폭이 20 Mbps 밖에 안된다</strong>(심지어 대학교 도서관 wifi 업로드 대역폭이 이렇다 ㅜㅜ → <del>정말 좀 투자좀 해서 늘려줘라</del>). 이 수치는 20Mbps = <strong>2.5MB/s</strong> 인데, 초당 2MB정도면 부하 테스트를 서버에 전달하기도 전에 네트워크에서 병목이 발생해버린다.</p>
<p>우리 집 인터넷도 업로드 속도가 빠른 편은 아니다. 9대충 35Mbps 정도 나온다. 심지어 산에 있어서 가끔 가다가 끊기기도 한다… → 서울인데 이게 말인가…)</p>
<p>그래서 테스트를 하려면 대형 카페나 인터넷이 빵빵 터지는 곳에 가서 해야하는데, <strong>매번 가서 테스트하기가 불편하다. (</strong>돈도 들고… ****특히 카페는 너무 오래 앉아있기도 불편해서..)</p>
<h3 id="2-변인-통제"><strong>2 변인 통제</strong></h3>
<p>로컬에서 부하테스트를 많이 했다. (로컬→로컬) 하지만 할때마다 수치가 다르게 나오는 경우가 빈번했다. Latency의 p(95)가 10s까지도 차이가 났다.</p>
<p>아무래도 window 기반 docker desktop 위에 올려 사용하다 보니, 때때로 docker desktop이 이상하게 작동할 때도 있고, 테스트 하는 순간에 호스트 환경이 다를 수 있기 때문에 테스트가 다르게 나왔던 순간도 많았다.</p>
<p>매번 동일한 환경을 세팅해줘야하는데, 로컬에서 그런 부분까지 신경쓰기가 어려웠다.</p>
<h3 id="3-무튼-결론적으로">3 무튼 결론적으로</h3>
<p>무튼 이런 2가지 문제 때문에, Azure VM위에 테스트 용도의 독립된 환경을 구축하려고 한다.</p>
<p>그림으로 그려보면 아래 느낌이겠다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/001a5888-3610-47ef-abe5-153f2db3c18a/image.png" alt="아키텍처"></p>
<p>테스트용 VM을 다른 VPC로 분리해서, 실제 네트워크 대역폭을 적용해볼까도 생각해봤다. 그런데 순수 서버의 성능을 측정하려면 같은 VPC 에 담아서 측정하는 것이 맞다고 생각했다. 네트워크 대역폭에 막혀서 서버의 최대 성능을 확인할 수 없을 수도 있으니 말이다.</p>
<hr>
<h1 id="테스트용-vm-선택하기">테스트용 VM 선택하기</h1>
<p>그냥 무난하게 사용하기 좋은 VM을 GPT를 통해 찾아보았다.</p>
<h3 id="standard_b2s--b2ms"><strong>Standard_B2s / B2ms</strong></h3>
<p>예시</p>
<ul>
<li>vCPU: 2</li>
<li>RAM: 4~8GB</li>
</ul>
<p>음… 근데 Azure Free Tier 사용중이라서, 한국 리전 제한이 걸렸다. (이미 다른 서버 2대를 실행중이라서, 같은 리전에 추가 설치를 못한다)</p>
<p>그래서 가장 가까운 Japan West로 설정했고, RTT를 보니 20-40ms 정도밖에 안되어서, 리전 일본으로 결정해서 진행하였다. 뭐 테스트 할 때, 요청이 여러번 왔다갔다 하는게 아니니 괜찮다고 판단했다.</p>
<p><del>비용도 크게 걱정이 안되었다. 테스트할 때 잠깐 쓰고 종료시킬 거기도 하고, IP도 public으로 할필요 없다. 어떻게 보면 인터넷 좋은 카페에 가서 테스트하는 것보다 훨씬 저렴할 것 같다. good</del></p>
<hr>
<h3 id="비용이-생각보다-많이-나왔다">비용이 생각보다 많이 나왔다</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/80a5ee06-8ce9-4adb-aa77-8cf6506756c2/image.png" alt="비용"></p>
<p>여기 적혀있는 분홍색 <code>load-test-vm</code>...</p>
<p>5630원이나 나왔는데, 이게 하루치 비용이다. 체감상 한 5시간 안되게 썼던 것 같은데, 생각보다 비용이 많이 나왔다. ㅜㅜ</p>
<hr>
<h2 id="테스트-해보기">테스트 해보기</h2>
<p>k6로 테스트를 해보았다. (TEST용도의 VM → 배포 서버)</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/85b414c4-b698-4f22-8e6c-bc75aff93530/image.png" alt="테스트 성공"></p>
<p>너무 잘된다 야호 ^^</p>
<h2 id="그냥-하면서-불편했던-점과-도란도란-이야기">그냥 하면서 불편했던 점과 (도란도란 이야기)</h2>
<h3 id="불편했던-점은">불편했던 점은..</h3>
<p><strong>학교 도서관</strong>에서 개발을 자주 한다. (모니터 제공을 해줘서 ㅎㅎ)</p>
<p>그런데 최근 학교 도서관이 리뉴얼을 하면서, <strong>wifi의 SSH 아웃바운드를 막아버렸다</strong>. 그래서 로컬로 SSH 접속을 못하고, 테스트 할때마다 Azure 웹사이트 터미널로 들어가야한다. 디자인도 조금 구리고… 불편하고…</p>
<p>인터넷 대역폭도 그렇고, 아웃바운드도 그렇고 개발하면서 불편한 사항이 많다.</p>
<h3 id="그래도-재미있었다">그래도 재미있었다</h3>
<p><strong>하지만 이참에 여러가지 우회하는 방법들을 시도해보면서, 신기한 경험들을 많이 했던 것 같다. *<em>SSH로 파일 이동이 불가능해서, google drive link로 옮겼다거나, 500MB 이상 파일은 또 google drive 다운로드가 안되어서(보안 문제), Azure 베스천을 쓴다거나… 그냥 *</em>여러모로 신기한 경험을 많이 한 것 같다.</strong></p>
<p>이렇게 예상치 못한 일로 막히거나 지체되는 일이 많아서, 답답함도 느끼고 그랬지만, 막상 지나고 나면 얻어가는게 있다. 그러니 <strong>어떤 일을 할 때에도 이런 자세로 하길 스스로에게 바란다</strong>.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-4] offset append 방식으로 대용량 파일 Resumable Upload 기능 구현하기]]></title>
            <link>https://velog.io/@standard-chan/storage-4-%EB%8C%80%EC%9A%A9%EB%9F%89-%ED%8C%8C%EC%9D%BC-Resumable-Upload-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-277lp3og</link>
            <guid>https://velog.io/@standard-chan/storage-4-%EB%8C%80%EC%9A%A9%EB%9F%89-%ED%8C%8C%EC%9D%BC-Resumable-Upload-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-277lp3og</guid>
            <pubDate>Thu, 05 Mar 2026 04:52:50 GMT</pubDate>
            <description><![CDATA[<h3 id="기능을-구현하게-된-계기">기능을 구현하게 된 계기</h3>
<p><strong>대용량 파일을 업로드하다 보면, 중간에 네트워크 문제가 발생하거나 새로고침으로 인해서 업로드가 끊기는 경우가 발생</strong>할 수 있다. 10MB와 같이 작은 파일의 경우, 문제가 없겠지만, 5GB, 10GB 파일들은 업로드하는데 시간이 굉장히 오래 걸린다..!
(이런 파일을 업로드하다가 끊기면, 아마 화가 날 수도 있을 것 같다...) </p>
<p>이러한 불편함을 개선하기 위해서 <strong>업로드 재개</strong> 기능을 한번 도입해보려고한다.</p>
<blockquote>
<p>이번에는 <strong>업로드/다운로드가 끊긴 지점부터 다시 업로드 하는 기능을 구현</strong>하는 과정을 담았습니다.</p>
</blockquote>
<h1 id="1-이어서-업로드-하는-2가지-방법">1. 이어서 업로드 하는 2가지 방법</h1>
<p>업로드를 이어서 하는 2가지 방법이 있습니다.</p>
<ol>
<li><strong>Multi-part Upload</strong> : 하나의 파일을 여러 파일로 나누어서 업로드하고, 이후 하나의 파일로 합치는 방법 </li>
<li><strong>Offset 기반 append 방식의 업로드</strong> : 하나의 파일에서 offset을 기반으로 이어서 write 하는 방법</li>
</ol>
<p>기타 방법 (웹소켓으로 업로드를 한다거나 하는 방법)이 있겠지만, 현 storage 서비스와는 맞지 않아 제외하였습니다.</p>
<p>어떤 방법을 사용할지 고민해보았습니다.</p>
<hr>
<h2 id="21-방법-1---multipart-upload-방식">2.1 방법 1 - multipart Upload 방식</h2>
<blockquote>
<p><strong>여러 파일로 나누어서 업로드하고, 이후 하나의 파일로 합치는 방법</strong></p>
</blockquote>
<h3 id="211-작동-방식-이해하기">2.1.1 작동 방식 이해하기</h3>
<p>실제 <strong>AWS S3</strong>에서 해당 <strong><code>multi-part Upload</code></strong> 방식을 사용할 수 있고, 권장하고 있습니다.</p>
<blockquote>
<p><a href="https://aws.amazon.com/ko/blogs/big-data/using-aws-for-multi-instance-multi-part-uploads/">https://aws.amazon.com/ko/blogs/big-data/using-aws-for-multi-instance-multi-part-uploads/</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/babc18e4-7e5a-43cd-8278-bb6b68fcb355/image.png" alt="AWS-multipart upload"></p>
<p>위 이미지처럼 <strong>하나의 큰 파일을 part A, B, C, D 4개의 파일로 나누어서</strong> <strong>각각을 독립적으로 Upload하는 방식</strong>입니다. 각각을 병렬적으로 업로드하기에 (네트워크 대역폭이 충분하다는 가정하에) 더 빠르게 업로드를 진행할 수 있습니다.</p>
<h3 id="212-장점">2.1.2 장점</h3>
<p>하나의 큰 파일을 병렬적으로 받을 수 있어서, 업로드 속도가 빠르다는 장점이 있습니다.</p>
<p>즉, 1GB파일을 100MB씩 10개의 파일로 나누어서 병렬적으로 받게되면, 업로드에 100초가 걸릴 작업을 대략 10초 정도에 할 수 있게됩니다.</p>
<p>따라서 대용량 파일을 자주 업로드하거나, 속도가 중요하다면 이 방법이 좋은 선택지가 될 수 있을 것 같습니다.  </p>
<h3 id="213-문제점1--다른-업로드-요청-속도-감소">2.1.3 문제점1 : 다른 업로드 요청 속도 감소</h3>
<blockquote>
<p>업로드 세션이 많아지면, 다른 업로드 세션 속도가 줄어든다</p>
</blockquote>
<p>현재 제 프로젝트에서는 <strong>병렬 기반의 업로드</strong>를 사용하고 있습니다. 즉, 여러 사용자들의 업로드 요청들을 병렬적으로 처리하고 있습니다.</p>
<p>하지만 <code>multipart</code> 업로드로 인해, 업로드 요청이 많아지게 된다면 모두가 공정한 업로드 속도를 갖는것이 아니라, multipart 의 part 별 세션이 모두 자원을 가져가게 됩니다. <strong>공정하지 않게 되는 것입니다!</strong></p>
<p>아래는 예시를 작성해보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/8db93b65-8fad-491f-93d7-798e638e8a51/image.png" alt="병렬처리"></p>
<p>A, B, C, D 4개의 요청이 동시에 들어오면, 이들을 각각 병렬적으로 처리하고 있습니다. 이 과정에서 만약 1개의 추가적인 요청 E가 더 들어온다면, E를 포함해서 다시 병렬적으로 처리가 진행됩니다. (이러한 구조를 사용한 데에는 작은 파일을 빠르게 올리기 위함이었습니다.)</p>
<p>이러한 상황에서 <strong>multipart 업로드 방식을 사용하면 다른 사용자들의 요청이 느려지는 문제가 발생할 수 있습니다</strong>.</p>
<p>예를 들어서 [A, B, C, D] 4개의 요청이 존재하고, [A, B, C]는 각각 10MB, [D] 파일은 10GB라고 가정해보겠습니다. 그리고 초당 처리 속도가 10MB 라고 가정하겠습니다.</p>
<p><strong>10GB를 1개의 요청으로 받게 된다면</strong>, ABCD는 트래픽을 고르게 나누어 가져서, <strong>각각 초당 2.5MB를 처리</strong>할 수 있습니다. 즉, ABC는 약 4초만에 쓰기를 완료할 수 있게됩니다.</p>
<p>하지만 <strong>multipart 방식을 사용</strong>하여, 10GB 파일을 100개의 요청으로 분할해서 업로드를 한다면, 요청의 수가 A,B,C,D1,D2,…,D100 가 됩니다. 따라서 <strong>각각 초당 10/103 MB/s = 약 90KB/s 의 처리 속도</strong>를 갖게 됩니다. A,B,C 파일을 쓰는데에 약 25초가 넘게 소요되게 됩니다. <strong>즉, 1개의 대용량 파일을 업로드하게 되면, 다른 사용자의 업로드 속도를 저하시킬 수 있습니다.</strong></p>
<h3 id="214-문제점2-재복사">2.1.4 문제점2: 재복사</h3>
<blockquote>
<p><strong>중복된 DISK 쓰기 작업</strong></p>
</blockquote>
<p>또 다른 문제점으로 merge 작업이 있습니다. </p>
<p><strong><code>multipart</code></strong>로 나누어서 업로드를 하게 될 경우에는, <strong>나누어진 part 파일을 병합하는 과정이 필요</strong>합니다.</p>
<p>A 파일을 a1, a2, a3, a4로 나누어서 받는다고 하면, 실제 DISK에는 a1, a2, a3, a4 파일이 업로드 됩니다. A라는 파일을 만들기 위해서는 받은 각각의 파일들을 병합하는 과정이 필요합니다. (a1 + a2 + a3 + a4 ⇒ A)</p>
<p><strong>즉, 1GB파일을 받는다고 하면, 각 part 별로 총 1GB 를 쓰고, 이를 병합하는데 1GB, 총 <code>2GB</code>를 써야합니다.</strong></p>
<p>현재 <strong>핵심 병목이 DISK 에 있기</strong> 때문에, 가능하면 <strong>중복하여 2번 쓰지 않는 것이 좋아보였습니다</strong>.</p>
<p>따라서 DISK 쓰기 비용을 조금이라도 줄이기 위해서는 위 과정은 가능하면 피하는 것이 좋다고 판단하였습니다.</p>
<hr>
<h2 id="22-방법-2---파일-자체에서-append를-통해-이어쓰기">2.2 방법 2 - 파일 자체에서 append를 통해 이어쓰기</h2>
<p>두 번째 방법으로는 파일을 이전에 썼던 지점부터 다시 이어 쓰는 방법이 있습니다. </p>
<h3 id="221-작동-방식-이해하기">2.2.1 작동 방식 이해하기</h3>
<p>다음 1GB 파일을 올린다고 가정하겠습니다. 각 라인은 100MB를 담고 있습니다.</p>
<pre><code>// 총 1GB 파일
aaaaa...aaaa // 100MB
bbbbb...bbbb // 100MB
ccccc...cccc // 100MB
ddddd...dddd // 100MB
eeeee...eeee // 100MB
...</code></pre><p>그런데 중간에 네트워크 문제로 인해서 업로드가 끊겼습니다</p>
<pre><code>aaaaa...aaaa // 100MB
bbbbb // 여기까지 쓰다가 연결 종료</code></pre><p>원래라면 다시 처음부터 aaaa…aaa 를 작성해야하지만, append를 통해서 이어나가면 bbb 부터 작성할 수 있게 됩니다. 즉, 다시 쓰기를 시작할 지점의 offset을 기록해서 해당 offset 부터 이어쓰는 것입니다.</p>
<pre><code>aaaaa...aaaa // 100MB
bbbbb | **여기에서 바로 이어쓰기 (append)**</code></pre><h3 id="222-장점과-단점">2.2.2 장점과 단점</h3>
<p>append 하는 방식은 별도로 파일들을 병합하는 추가 작업이 필요 없다는 장점이있습니다. </p>
<p>하지만 하나의 세션으로 업로드를 진행하다보니, multipart에 비해서 업로드 속도가 느리다는 단점이 있습니다. </p>
<p>하지만 위 단점은 현재 프로젝트 처리 방식과의 충돌로 인해서 피하는 것이 낫다고 판단하였고, DISK 쓰기를 조금이라도 줄이기 위해 <strong>append 방식으로 resumable Upload를 구현</strong>하기로 결정하였습니다</p>
<hr>
<h1 id="3-append-로-resumable-upload-구현하기">3. append 로 Resumable Upload 구현하기</h1>
<h2 id="31-구현-방식">3.1 구현 방식</h2>
<p>구현은 두가지 방식이 있습니다.</p>
<ul>
<li><p>직접 구현하는 방식</p>
</li>
<li><p>라이브러리를 사용하는 방식</p>
<p>  파일을 그대로 이어붙여주는 <strong><code>tus-node-server</code></strong> 패키지가 있습니다.</p>
</li>
</ul>
<p>직접 구현하는 방법보다는 라이브러리를 사용해서 구현하는 것이 시간상으로도, 완성도 측면에서도 도움이 될 것이라고 생각하였습니다.</p>
<p>라이브러리는 tus, Uppy, Resumable.js 가 있었고, 프론트와 서버 모두 지원하는 <code>tus-node-server</code>를 사용하여 구현하였습니다. </p>
<p><a href="https://github.com/tus/tus-node-server">https://github.com/tus/tus-node-server</a></p>
<h2 id="32-tus-libary">3.2 tus Libary</h2>
<p>해당 라이브러리를 가져와서, 현 프로젝트 흐름에 맞추어야하였기에, 어떤 방식으로 동작하는지를 학습하고 현 프로젝트에 어떻게 도입하여야할지를 고민하였습니다.</p>
<h3 id="321-동작-방식">3.2.1 동작 방식</h3>
<p>동작 방식은 다음 공식 문서를 참고하였습니다. </p>
<blockquote>
<p><a href="https://tus.io/faq#how-does-tus-work">https://tus.io/faq#how-does-tus-work</a></p>
</blockquote>
<ol>
<li>POST를 통해 서버에 Metadata를 보내고, <strong>파일을 업로드할 수 있는 URL을 받기</strong></li>
<li><strong>PATCH URL 을 통해서 파일을 업로드</strong><ul>
<li>Upload-Offset 헤더 포함 : 파일 쓰기를 시작할 byte 위치</li>
<li>request 성공 시 = 업로드 완료</li>
</ul>
</li>
<li>업로드 도중에 실패했을 경우<ul>
<li>사용자는 resume the upload 로 이어서 업로드를 재개 수 있다.</li>
<li>해당 URL로 HEAD 요청을 통해서, Upload-Offset header를 받을 수 있다. 이를 바탕으로 이어쓰기를 한다.</li>
</ul>
</li>
<li>사용자가 해당 업로드를 삭제하고 싶은 경우<ul>
<li>DELETE 요청을 통해 upload URL을 삭제할 수 있다.</li>
<li>이 경우에는 이어쓰기(resuming the upload) 가 더 이상 불가능하다</li>
</ul>
</li>
</ol>
<h3 id="322-현-프로젝트에-도입-시-수정-및-override가-필요한-부분들">3.2.2 현 프로젝트에 도입 시, 수정 및 override가 필요한 부분들</h3>
<p>동작하는 방식에 있어서, 현 프로젝트와 크게 충돌하는 부분이 없다고 생각했고, 현재 방식과 다른 부분들은 override나 추가 구현을 통해 해결할 수 있다고 판단하였습니다.</p>
<p>기존 방식과 다른 부분은 아래와 같습니다.</p>
<p><strong>URL 발급 및 검증 과정</strong></p>
<p>PATCH URL을 받아 업로드를 진행하게 됩니다. 하지만 보안을 위해서 해당 업로드 URL이 올바른 업로드인지, 만료되지는 않았는지를 검증하는 과정이 필요합니다. 따라서 동작 방식의 1, 2 부분을 보완할 필요가 있습니다.</p>
<p><strong>메타데이터 저장 위치</strong></p>
<p>파일 업로드에 필요한 메타데이터를 기본적으로 memory에 저장하는 기본 코드를 지원하고 있습니다. (샘플이긴 합니다) 메모리에 저장하면, 서버 재시작 시 메타데이터가 날라갈 수 있으므로, 이 부분도 DISK에 저장하도록 수정이 필요할 것 같습니다. </p>
<h3 id="323-테스트해보기">3.2.3 테스트해보기</h3>
<p>간단하게 실제로 잘 돌아가는지 확인 겸 흐름을 확실하게 이해하기 위해서 겸사겸사 테스트를 해보았습니다. 1GB로 테스트를 했더니 잘 작동하고, 중간에 끊어도 offset을 이어 받아서 잘 작성하는 걸 확인할 수 있었습니다.</p>
<p>아래는 그냥 <strong>offset을 어디에서 어떻게 가져오는지 궁금해서</strong> <strong>직접 구현 코드를 뜯어서 offset을 어디에 저장하고 어디에서 가져오는지 확인해본 내용</strong>입니다.</p>
<blockquote>
<p><a href="https://www.notion.so/TUS-31afc6c66701808da996eefd70b85c0a?source=copy_link">https://www.notion.so/TUS-31afc6c66701808da996eefd70b85c0a?source=copy_link</a></p>
</blockquote>
<hr>
<h1 id="4-처리-흐름-설계하기">4. 처리 흐름 설계하기</h1>
<p>흐름을 설계하고, 발생할 수 있는 문제점들을 검토하며 보완하는 과정을 작성하였습니다.</p>
<h2 id="41-흐름">4.1. 흐름</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/68425fd9-db1a-4e2b-b71a-17fdc2061a96/image.png" alt="append 방식 처리 흐름"></p>
<p><code>tus-server</code> 코드를 최대한 유지한 방향으로 설계를 진행하였습니다. 이로인해, SpringBoot에서 <code>Presigned URL</code>을 받고, 또다시 <strong><code>PATCH 용도의 URL</code>을 재발급 받아야한다는 불편함</strong>이 있습니다.</p>
<p>정확한 흐름은 다음과 같습니다.</p>
<pre><code>1. Control Plane에서 Presigned URL 발급
2. POST 요청을 통해 Primary Node 에서 업로드용 URL 발급 및 Primary Node에 저장할 파일 생성
3. &#39;HEAD : 업로드용 URL**&#39;** 을 통해 OFFSET 확인
4. &#39;PATCH : 업로드용 URL&#39; 을 통해 OFFSET 지점부터 파일 업로드
5. 성공 응답 반환</code></pre><p>물론 Control Plane에서 Primary Node로 POST 요청을 보내어, 업로드용 URL을 바로 발급 받을 수도 있습니다. 하지만 TUS 로직 상, POST 요청을 보내는 순간, 저장할 파일이 생성되기 때문에 사용자가 URL을 사용하지 않는다면, 더미 파일이 생겨버릴 수 있습니다.</p>
<p>따라서 조금 번거로울 지라도, <strong>2번의 걸쳐서 요청을 보내는 방식을 선택</strong>하였습니다.</p>
<h2 id="42-첫-파일-업로드-시-발생하는-문제점---불필요한-head-요청">4.2 첫 파일 업로드 시 발생하는 문제점 - 불필요한 HEAD 요청</h2>
<p><strong>파일을 처음으로 업로드하는 경우</strong>에서 발생할 수 있는 <strong>문제점</strong>이 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/e943bd15-d35c-49de-a8b8-e473a1188de8/image.png" alt="불필요한 HEAD 요청"></p>
<p>파일을 처음으로 업로드하는 경우에는 위와 같은 <strong><code>HEAD 요청</code>이 불필요</strong>합니다. 왜냐하면 항상 파일의 처음 위치부터 업로드하기 때문입니다. 따라서 <strong>이러한 불필요한 요청을 어떻게 줄일 수 있을지</strong>를 고민하였습니다.</p>
<h3 id="431-해결-방안-1---presigned-url-발급-시-offset-정보-보내기">4.3.1 해결 방안 1 - Presigned URL 발급 시, OFFSET 정보 보내기</h3>
<p>먼저, Presigned URL을 발급 받을 때, 데이터의 OFFSET 을 같이 전달하는 방법이 있습니다. </p>
<p>이렇게 된다면, Primary Node에서 파일을 업로드하는 시점과 완료한 시점에 Control Plane에 요청을 보내주는 로직이 추가되어야합니다.</p>
<h3 id="432-해결-방안-2---대용량-파일일-경우에만-head-요청을-전송하기">4.3.2 해결 방안 2 - 대용량 파일일 경우에만 HEAD 요청을 전송하기</h3>
<p>두 번째 방법은 <strong>HEAD 요청의 횟수 자체를 줄이는 방법</strong>입니다. 100KB, 1MB와 같은 작은 파일에는 HEAD 요청을 전송하지 않고, 바로 업로드를 진행하도록 처리할 수 있습니다. 도중에 끊겼을 경우에도 처음부터 덮어 쓰기 방식으로 진행하는 방향입니다.</p>
<p>이렇게 되면 Frontend 코드에서 용량을 먼저 확인하고 HEAD를 보낼지 말지를 처리하게 될 것 같습니다. </p>
<p>대부분의 요청은 용량이 크지 않은 파일이고, 해결 방안 2를 도입하게 되면, HEAD 요청의 수가 상당히 감소하게 됩니다. HEAD 요청이 불필요하게 많이 생기지는 않을 것이라고 판단하여, 우선은 <strong>해결 방안 2를 선택하여 도입하기로 결정하였습니다.</strong></p>
<p>(추후에는 방안 1 도입도 고려해봐야겠습니다)</p>
<hr>
<h1 id="5-구현하기">5. 구현하기</h1>
<h2 id="51-api-엔드포인트-분리">5.1 API 엔드포인트 분리</h2>
<p>4.3.2의 방식을 적용하기 위해 <strong>대용량 파일</strong>과 <strong>저용량 일반 파일 업로드</strong>의 API endpoint를 다르게 설정하였습니다. 하나의 엔드포인트에서 처리할 수도 있지만, <strong>업로드의 방식이 다르고 resumable 방식에는 header값들이 많이 추가되기 때문에</strong>, 동일한 URL로 처리하기 보다는 <strong>분리해서 처리하는 것이 좋다고 판단</strong>하였습니다.</p>
<p><strong>다음과 같이 URL을 발급받도록 구현하였습니다.</strong></p>
<ul>
<li><strong>100MB 보다 작은</strong> 파일 업로드 요청 ⇒ 일반 업로드 URL = uploads/direct</li>
<li><strong>100MB 보다 큰</strong> 파일 업로드 요청 ⇒ <strong>resumable 업로드</strong> URL = uploads/resumable</li>
</ul>
<h2 id="52-tus-filestore-override">5.2 tus FileStore override</h2>
<p><strong>tus에서는 기본적으로 <code>file_id</code> 값을 사용해서 하나의 디렉토리에 모든 파일을 저장</strong>합니다. 즉, 파일 이름과 위치를 <code>file_id</code>로 저장하고 메타데이터도 <code>file_id</code>로 얻습니다. </p>
<p>하지만 현 서비스에서는 <strong>디렉토리 기반의 파일 경로 기반 구조</strong>를 사용하고 있기 때문에, 경로가 없는 경우 자동으로 생성되는 로직을 추가하여 구현하였습니다.</p>
<p>create()의 생성로직만 바뀌고 나머지는 동일하기 때문에, <code>create()</code> 만 override하였습니다.</p>
<pre><code class="language-tsx">class CustomFileStore extends FileStore {
  private readonly _directory: string;

  constructor(options: FileStoreOptions) {
    super(options);
    this._directory = options.directory;
  }

  create(file: TusFile): Promise&lt;TusIFile&gt; {
    return new Promise((resolve, reject) =&gt; {
      const filePath = path.join(this._directory, (file as unknown as { id: string }).id);
      const dirPath = path.dirname(filePath);

            // 디렉토리가 없는 경우 생성
      fs.mkdir(dirPath, { recursive: true }, (mkdirErr) =&gt; {
        if (mkdirErr) {
          return reject(mkdirErr);
        }

        return super.create(file).then(resolve).catch(reject);
      });
    });
  }
}</code></pre>
<h2 id="53-configstore-메모리-조회-→-disk-조회">5.3 Configstore 메모리 조회 → DISK 조회</h2>
<p><code>Configstore</code>의 역할은 업로드하는 <strong>파일의 메타데이터</strong>를 <strong>저장</strong>하는 공간입니다. <strong>해당 파일의 총 크기를 바탕으로 업로드 요청의 재개 여부를 판단합니다. (</strong>즉, 파일 총 크기보다 현재 저장된 파일이 작다면, 업로드를 재개해야하는데, 이러한 파일의 총 크기 정보를 저장합니다)</p>
<p>기본 tus 라이브러리에서 제공하는 방식은 <strong><code>메모리</code></strong>에 <strong>메타데이터를 저장/조회</strong>하는 방식입니다.</p>
<pre><code class="language-tsx">// 기존 제공
class MemoryConfigstore {
    constructor() {
        this.data = new Map();
    }

    async get(key) {
        let value = this.data.get(key);
        if (value !== undefined) {
            value = JSON.parse(value);
        }
        return value;
    }

    async set(key, value) {
        this.data.set(key, JSON.stringify(value));
    }

    async delete(key) {
        return this.data.delete(key);
    }
}

module.exports = MemoryConfigstore;
</code></pre>
<p>메모리는 속도가 빠르다는 장점이 있지만, <strong>서버가 예상치 못하게 종료되면 파일을 처음부터 다시 올려야하는 문제</strong>가 있습니다. 따라서 해당 방식을 메모리 방식에서 DISK에 저장하는 방식으로 수정하였습니다.</p>
<p><strong>속도보다는 안정성이 더 중요하다고 판단하여, DISK에 저장하는 방식을 선택</strong>하였습니다.</p>
<h3 id="531-필요-기능">5.3.1 필요 기능</h3>
<p>다음 기능을 지원할 수 있어야합니다.</p>
<ul>
<li><strong><code>file_id(파일경로)</code></strong>를 통해서, 메타데이터를 조회/쓰기가 가능해야합니다.</li>
<li>일정 시간이 지난, 만료된 데이터를 삭제할 수 있어야합니다.</li>
<li>상태를 조회할 수 있어야합니다.</li>
</ul>
<p>파일 여러개의 메타데이터를 동시에 쓸 수도 있으므로, 충돌을 막기 위해서 일반 파일을 사용하는 것은 좋지 않을 수 있겠다고 생각하였습니다. 또한 많은 데이터가 저장되는 것이 아니기에, 별도 DB 서버를 운용할 필요가 없습니다.</p>
<p>따라서 embedded DB 중, <code>RocksDB</code>, <code>SQLite</code> 중 고민을 하였고, <strong>위 상황에 <code>SQLite</code>가 적합하다고 판단</strong>하였습니다. RocksDB는 Key Value의 형태로 저장하기 때문에, 만료 데이터 조회나 상태 조회가 어렵기 때문입니다. 더구나 기존에 서버에서 사용하던 SQLite가 있어, 그대로 가져가서 사용하면 되기 때문입니다!</p>
<p><strong>SQLite를 기</strong>반으로 메타데이터를 관리하는 <strong><code>Configstore</code>를 추가</strong>하였습니다.</p>
<pre><code class="language-tsx">export default class SqliteConfigstore {
  private readonly getStmt: Statement;
  private readonly setStmt: Statement;
  private readonly deleteStmt: Statement;

  constructor(db: InstanceType&lt;typeof Database&gt;) {
    this.getStmt = db.prepare(QUERIES.GET);
    this.setStmt = db.prepare(QUERIES.SET);
    this.deleteStmt = db.prepare(QUERIES.DELETE);
  }

  async get(key: string): Promise&lt;IFile | undefined&gt; {
    const row = this.getStmt.get(key) as TusFile | undefined;
    if (row === undefined) return undefined;

    const file = new TusFile(
      row.id,
      row.upload_length ?? &quot;&quot;,
      row.upload_defer_length ?? &quot;&quot;,
      row.upload_metadata ?? &quot;&quot;,
    );
    return file as IFile;
  }

  async set(key: string, value: IFile): Promise&lt;void&gt; {
    const { upload_length, upload_defer_length, upload_metadata } = value;
    this.setStmt.run(
      key,
      upload_length || null,
      upload_defer_length || null,
      upload_metadata || null,
    );
  }

  async delete(key: string): Promise&lt;boolean&gt; {
    const result = this.deleteStmt.run(key);
    return result.changes &gt; 0;
  }
}</code></pre>
<p>이제 <strong>메타데이터 조회, 저장, 삭제 시 SQLite를 통해 진행됩니다.</strong></p>
<h2 id="54-patch-url-검증">5.4 PATCH URL 검증</h2>
<h3 id="541-검증의-필요성">5.4.1 검증의 필요성</h3>
<p>tus는 기본적으로 다음 <code>resumable 업로드 URL</code>을 반환해줍니다. </p>
<pre><code class="language-tsx">http://localhost:3000/{tus api path}/test_bucket/abc/test(1).jpg</code></pre>
<p>이를 그대로 사용할수도 있겠지만, 어떤 사용자가 악의적으로 요청을 수정하진 않았는지, <strong>해당 URL이 만료되었는지</strong> 등을 <strong>검증하기가 어렵습니다</strong>. 따라서 <strong>이를 검증하는 로직이 필요</strong>하다고 생각하였습니다.</p>
<h3 id="542-시도-1---tus-라이브러리-코드를-수정하기">5.4.2 시도 1 - TUS 라이브러리 코드를 수정하기</h3>
<p>우선 <strong>코드를 수정하여 URL에 <code>signature</code>를 넣어, 검증 할 수 있도록</strong> 하려고 하였습니다.</p>
<p>그래서 URL을 생성하는 로직을 살펴보았습니다.  TUS의 <code>BaseHandler</code> - <code>generateUrl</code> 을 통해서 생성이 됩니다. (Handler는 응답을 처리하는 class 입니다)</p>
<pre><code class="language-tsx">class BaseHandler extends EventEmitter {
    generateUrl(req, file_id) {
        return this.options.relativeLocation ? `${req.baseUrl || &#39;&#39;}${this.options.path}/${file_id}` 
        : `//${req.headers.host}${req.baseUrl || &#39;&#39;}${this.options.path}/${file_id}`;
    }
    ...   
}</code></pre>
<p>그래서 <strong>BaseHandler를 override하여 수정하려고 했습니다</strong>. </p>
<p>하지만 문제는 기존 클래스들이 모두 <code>BaseHandler</code>를 상속하기 때문에 수정해서 <strong>적용이 불가능</strong>합니다.</p>
<pre><code class="language-tsx">class PostHandler extends BaseHandler {...}</code></pre>
<p>그래서 <code>BaseHandler</code>로는 수정을 할 수 없어, 다른 방법을 찾아야 했습니다. </p>
<h3 id="543-시도-2---sqlite에-세션-정보-저장시키기">5.4.3 시도 2 - SQLite에 세션 정보 저장시키기</h3>
<p>현재 <strong>메타데이터 정보를 <code>SQLite</code>에 저장하고 있습니다</strong>. 즉, 상태를 이미 저장하고 있고</p>
<ul>
<li>서버 재시작 시, 업로드 유지</li>
<li>세션 검증 데이터와 TUS 상태 데이터 생명 주기가 동일하다는 것을 바탕으로</li>
</ul>
<p><strong><code>SQLite</code>에 저장하여 URL을 검증하고 만료를 확인하는 방법을 선택</strong>하였습니다. </p>
<p>즉, URL 요청이 들어오면, 해당 URL이 만료되었는지 여부를 DB에서 판단하고 만료되지 않았다면 업로드를 시작하는 방식입니다. </p>
<p>파일의 크기 검증은 기본적으로 TUS가 지원을 해주기 때문에 URL의 <strong>유효기간만을 검증</strong>하도록 구현하였습니다.</p>
<p><strong>만료된 데이터 삭제하기</strong></p>
<p>SQLite는 별다른 조치를 하지 않으면 영속적으로 저장되기때문에, 주기적인 삭제 작업이 필요합니다. 따라서 서버 시작 및 1일 주기로 삭제 처리를 하도록 설정하였습니다. </p>
<hr>
<h1 id="6-대용량-파일의-기준-설정하기">6. 대용량 파일의 기준 설정하기</h1>
<p>테스트를 통해서 대용량 파일의 기준을 설정하는 단계입니다. 다른말로 표현하자면, <code>resume upload</code> 를 적용할 파일의 크기를 설정하는 단계입니다.</p>
<p><strong>작은 파일인 경우에는 굳이 resume upload를 할 필요 없이, 다시 upload 하는게 성능상 더 좋으니 기준선을 잘 찾을 필요가 있습니다.</strong></p>
<h2 id="61-기준을-설정할-때-고려한-점">6.1 기준을 설정할 때 고려한 점</h2>
<p>어느정도를 기준으로 잡아야할지 최적의 값을 찾는 것은 꽤나 어려운 일입니다. 그래서 AWS S3를 찾아보았습니다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/70dc0304-2d71-46b2-8e7c-b98022ebd66c/image.png" alt="AWS multipart 권장 대용량 파일 기준"></p>
<p><strong>S3의 경우, <code>100MB</code> 부터 multipart 도입을 권장하고 있습니다.</strong></p>
<blockquote>
<p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html?utm_source=chatgpt.com">https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html?utm_source=chatgpt.com</a></p>
</blockquote>
<p>물론 <strong>S3의 경우</strong> <code>multipart upload</code> 이기 때문에, <strong>업로드 속도를 빠르게 하기 위해서 100MB부터 도입한다는 측면</strong>도 있습니다. 하지만 <strong>제 프로젝트에서는 append 방식의 upload 이기에, 속도는 그대로</strong>입니다.</p>
<p>하지만 <strong>세계적으로 많이 사용되는 서비스에서 100MB를 기준으로 권장</strong>을 하고 있기 때문에, 이 수치를 사용해도 괜찮겠다고 판단하였습니다. </p>
<p>또한 현재 제가 대여하고 있는 <strong>Azure 서버 컴퓨터의 최대 네트워크 대역폭</strong>은 6250 Mbps <strong>≈ 781.25 MB/s 으로 큰 문제가 없고, 일상 환경에서</strong> 저비용 인터넷 <strong>업로드 대역폭이 40Mbps</strong>인 듯 하여, 40Mbps 기준으로 생각을 해보았습니다. </p>
<ul>
<li>학교 인터넷 : 20Mbps</li>
<li>일반 가정(우리집) : 40Mbps</li>
<li>LTE : 40Mbps</li>
</ul>
<p>일상에서 100MB 파일을 업로드하는데 보통 20-30초 정도가 소요됩니다. 20초는 사용자가 느끼기에 상당히 긴 시간으로 느껴질 것 같아, 50MB 파일을 기준으로 50MB 이상의 파일은 <code>resumable Upload</code>를 적용하고, 그 미만의 파일은 단순 업로드를 하도록 구현하였습니다.</p>
<h1 id="6-성능-테스트-결과">6. 성능 테스트 결과</h1>
<h3 id="1-테스트-조건-요약">1. 테스트 조건 요약</h3>
<ul>
<li>(네트워크 대역폭 제한 없음 - 로컬에서 테스트)</li>
<li><strong>파일 크기:</strong> 1.00 GB (1024 MB)</li>
<li><strong>청크 크기:</strong> 5 MB</li>
<li><strong>중단 시점:</strong> 500 MB 전송 후 중단</li>
</ul>
<table>
<thead>
<tr>
<th><strong>항목</strong></th>
<th><strong>기존 방식 - Restart</strong></th>
<th><strong>업로드 재개 방식 - Resume</strong></th>
<th><strong>차이 (절감 효과)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>복구 소요 시간</strong></td>
<td>20.7 초</td>
<td><strong>10.9 초</strong></td>
<td><strong>9.8 초 (47.3% ↓)</strong></td>
</tr>
<tr>
<td><strong>전체 소요 시간</strong></td>
<td>32.2 초</td>
<td><strong>21.6 초</strong></td>
<td><strong>10.6 초 (32.9% ↓)</strong></td>
</tr>
<tr>
<td><strong>총 전송량</strong></td>
<td>1524 MB</td>
<td><strong>1024 MB</strong></td>
<td><strong>500 MB (32.8% ↓)</strong></td>
</tr>
<tr>
<td><strong>낭비 데이터</strong></td>
<td>500 MB</td>
<td><strong>0 MB</strong></td>
<td><strong>100% 제거</strong></td>
</tr>
</tbody></table>
<p>간단하게 1GB를 기준으로 테스트를 진행하였습니다. 만약 데이터가 더욱 커진다면, 더 큰 효과를 발휘할 수 있을 것 같습니다. </p>
<hr>
<h1 id="7-배운점">7. 배운점</h1>
<blockquote>
<p><strong>확장성과 유지보수를 고려하여 코드를 작성하는 태도를 기르자.</strong></p>
</blockquote>
<h3 id="71-오픈소스를-읽은-계기">7.1 오픈소스를 읽은 계기</h3>
<p>이번에 tus라는 오픈소스를 직접 뜯어서 디버깅도 해보고, override도 해보는 과정을 가지게 되었습니다.</p>
<p>사실 AI를 사용해서 간단하게 처리하려고 했지만, AI가 짜준 코드가 작동을 안하는 경우가 굉장히 많았습니다. 뿐만아니라, 왜 작동을 안하는지 확인을 하기 위해서도 직접 로직을 살펴보아야하는 경우가 많았습니다.</p>
<p>그래서 오픈소스 파일을 직접 읽고 디버깅도 하면서 흐름을 파악하는 과정에 시간을 많이 들였습니다. </p>
<h3 id="72-오픈소스를-보면서-느낀점">7.2 오픈소스를 보면서 느낀점</h3>
<p>코드를 보면서, 추상화가 굉장히 잘 되어있다는 것을 느꼈습니다. 정말 interface만 딱 바꾸면, 다른 기능으로 사용할 수 있게 되어있습니다.</p>
<p>(<code>Store</code>와 <code>Configstores</code>는 손쉽게 갈아 끼울 수 있습니다.)</p>
<p>이번 프로젝트를 하면서, 추상화를 전혀 고려하고 있지 않았습니다. 기능이 안정적으로 돌아가도록 하는게 최우선의 목표였고 하루빨리 잘 돌아가는 걸 보고싶어서 코드 퀄리티를 많이 고려하지 않았던 것 같습니다.</p>
<p>그래서 제가 해야할 다음 과제는 리팩토링. 그 중에서도 추상화를 할 필요가 있는 부분들을 찾아서 수정해야겠습니다.</p>
<p>현재 디렉토리 구조의 파일구조로 파일을 저장하고 있는데, 추후에 확장이 될 경우 BLOB Storage(블록 저장소) 로 바꿀 필요가 있습니다. 메타데이터라던가, 작은 크기의 파일들을 효율적으로 저장하는 방식이기 때문입니다.</p>
<p>따라서 다음번에는 파일 저장 기능들을 interface로 바꾸어볼 생각입니다.</p>
<hr>
<h1 id="고민해봐야할-문제">고민해봐야할 문제</h1>
<p>결국에 <code>resumable upload</code> 방식은, 하나의 요청으로 업로드를 처리하는 합니다. 따라서 본질적으로 <code>속도 문제</code>에 있어서 아쉬움이 있습니다.</p>
<p>10GB파일... 더 나아가 100GB파일을 올린다면, 요청 1개로는 몇시간이 걸릴지도 모르겠네요. 따라서 이러한 속도 문제를 해결하기 위해서 multipart를 도입할 필요가 있는데, 문제는 disk 병목인 것 같습니다. 더 나아가 다른 사용자의 업로드 속도에 영향을 미친다는 점. (이것도 DISK 병목이 원인입니다)</p>
<p>물론 DISK를 프리미엄으로 업그레드를 한다거나 하는 방법이 있을 수 있겠지만... 이런 비용적인 문제는 가능하면 피하고 싶었습니다.</p>
<p>이러한 문제를 어떻게 해결할지 고민을 해봐야겠습니다.</p>
<p>지금 당장 떠오르는 방법으로는...</p>
<blockquote>
<p>*<em>multipart가 다른 사용자의 요청을 가로막아서 문제라면, 가로막음을 최소화시키면 되지 않을까? *</em> 동시 세션 수를 동적으로 줄인다거나 해서!</p>
</blockquote>
<blockquote>
<p><strong>merge 작업으로 DISK 병목이 생긴다면, merge를 안하면 되지않을까?</strong> part 데이터를 SQLite에 저장하던가 해서...!</p>
</blockquote>
<p>여러가지 방법이 있을 것 같습니다. 이러한 부분은 다음번에 알아보도록 하겠습니다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-3] 서버 트래픽에 따른 재복제 요청 전송하기]]></title>
            <link>https://velog.io/@standard-chan/storage-3-%EC%84%9C%EB%B2%84-%EC%83%81%ED%99%A9%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%9E%AC%EB%B3%B5%EC%A0%9C-%EC%9A%94%EC%B2%AD-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/storage-3-%EC%84%9C%EB%B2%84-%EC%83%81%ED%99%A9%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%9E%AC%EB%B3%B5%EC%A0%9C-%EC%9A%94%EC%B2%AD-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 01 Mar 2026 08:58:15 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요">1. 개요</h1>
<h2 id="11-이-글의-목적">1.1 이 글의 목적</h2>
<p>이 글은 Secondary Node 복제 재시도 로직 개선 시리즈의 2편입니다. 1편에서는 복제 실패 시 사용자에게 재업로드를 요구하던 기존 방식의 문제를 살펴보고, 실패 로그를 SQLite에 저장하여 주기적으로 재복제를 시도하는 방식을 도입하는 과정을 다뤘습니다.</p>
<p>하지만 1편에서 해결하지 못한 <strong>‘트래픽이 몰리는 시점에 재복제 로직을 보낼 수 있다는 문제’</strong>가 남아있습니다.</p>
<p>이를 해결하기 위해 <strong>재복제 요청을 보내기 전에 Secondary Node의 현재 작업량을 확인하고, 여유로울 때만 재복제 요청을 전송하는 방식</strong>을 도입하였습니다. </p>
<p><strong>이번 글에서는 그 설계와 구현 과정을 담았습니다.</strong></p>
<p>먼저 트래픽을 확인할 수 있는 여러 지표들을 알아보고, Node js에서 작업 큐로 작업의 수를 만드는 과정에서 직면한 한계를 작성하였습니다. 이후 Stream 수를 기반으로 작업량을 판단하는 방식을 도입하였습니다. 마지막으로는 부하 테스트를  통해 적정 트래픽 기준을 설정하는 과정을 작성하였습니다.</p>
<hr>
<h1 id="2-서버-트래픽-확인하기">2. 서버 트래픽 확인하기</h1>
<h2 id="21-서버-트래픽-확인-종류">2.1 서버 트래픽 확인 종류</h2>
<p>서버의 트래픽을 어떤 값으로 판단할지 고민해보았습니다.</p>
<ul>
<li><strong>DISK 사용량</strong> : 복제 요청은 DISK 쓰기 작업입니다. 따라서 DISK 사용량을 바탕으로 트래픽을 판단할 수 있습니다.</li>
<li><strong>최근 N 초간 요청 수</strong> : 최근 N시간 동안 서버가 받은 요청 수를 기준으로 판단하는 방법입니다.</li>
<li><strong>작업큐</strong> : <code>secondary Node</code>에 얼마나 많은 요청 작업들이 작업 중 인지를 바탕으로 확인하는 방법이 있습니다.</li>
</ul>
<h2 id="22-disk-사용량">2.2 DISK 사용량</h2>
<p>DISK 사용량을 바탕으로 트래픽을 확인하는 방법은 <strong>구현이 간단합니다</strong>. OS 자체에서 지원해주는 DISK 사용량, 대기 큐 등을 사용하여 쉽게 구현할 수 있습니다. </p>
<p>하지만 문제가 있는데, “DISK 사용량은 낮지만, 여러 작업이 대기 중인 경우” 는 판단하기 어렵습니다. 이를 보완하기 위해 CPU, 메모리 사용량을 같이 볼 수 있겠지만, 이들을 종합하여 병목으로 볼 기준점을 찾기가 어렵다고 생각합니다. </p>
<p>결론적으로 DISK 사용량만으로는 <strong>서버에 트래픽이 몰려있는지를 확인할 서브 자료로 사용할 수는 있지만, 이것만으로 분명히 확인할 수는 없다</strong>고 ****판단하였습니다.</p>
<h2 id="23-작업큐">2.3 작업큐</h2>
<p>보통 서버 병목은 처리해야 할 작업의 수가 많아질 때 발생합니다. 따라서 현재 얼마나 많은 작업을 진행/대기 중 인지를 추적한다면, 서버의 부하 상태를 파악할 수 있다고 판단했습니다.</p>
<p>물론 트래픽이 몰리지 않았어도 DISK 병목이 생길 수도 있습니다. 다른 Application을 하나의 컴퓨터에 동시에 띄운다면 그럴 수 있겠습니다. 하지만 Secondary Node 서버에는 단 1개의 프로세스(모니터링이 있지만 부하가 크지 않다고 판단하였습니다)만이 동작하고, 이 서버에서 처리하는 대부분의 작업들은 DISK 읽기/쓰기입니다. 그리고 복제 요청 역시 DISK 쓰기 작업에 해당합니다.</p>
<p>따라서 <strong>Secondary Node 서버에서 발생하는 DISK 병목의 원인은 사실상 작업 대기큐에 쌓인 작업 수와 관련이 있으므로,</strong> 작업 대기큐의 크기를 확인하는 것만으로도 현재 DISK에 얼마나 부하가 걸려 있는지를 충분히 예측할 수 있다고 판단하였습니다.</p>
<hr>
<h1 id="3-작업-대기큐-설계하기">3. 작업 대기큐 설계하기</h1>
<h2 id="31-작업-대기큐를-설계하는데-직면한-문제">3.1 작업 대기큐를 설계하는데 직면한 문제</h2>
<p>Node JS 언어 자체의 특징 때문에 작업 대기큐를 <strong>설계하기가 많이 어려웠습니다</strong>. 그 이유는 <strong>단순하게 작업들을 대기큐에 넣어, 한번에 하나의 작업을 빼서 처리하기 어려운 구조</strong>였기 때문입니다. 현재 Fastify 서버에서 DISK 쓰기는 하나의 작업을 모두 완료한 뒤에 다음 작업을 처리하는 것이 아닌, 병렬적으로 나누어서 진행됩니다.</p>
<p>즉, n개의 파일이 동시에 들어오면, 1→2→3→…→n 순서로 파일을 업로드 하는 것이 아니라, 청크단위로 [1<del>n까지 첫 번째 청크 업로드] → [1</del>n까지의 두 번째 청크 업로드]로 진행되게 됩니다. 모든 요청들을 조금씩 병렬적으로 처리하는 것 입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/97bfcc22-1994-48d2-9cbc-c05d6ec2eb1d/image.png" alt="병렬 처리 예시 이미지"></p>
<p>예시를 들어 설명하겠습니다. <strong>A, B, C, D 작업</strong>이 있다고 하고 각 파일을 a, b, c, d 라고 해보겠습니다. 그리고 청크 번호를 n 이라고 해서 <code>[파일이름]n</code>이라고 하겠습니다. 그러면 <strong>4개 요청이 동시에 들어왔을 때, 위 이미지의 쓰기 처리과정</strong>을 거칩니다.</p>
<p><strong>(스레드 4개 기준)</strong></p>
<ul>
<li>좌측 위가 event Loop 에 등록되는 작업입니다. </li>
<li>하단의 작업이 DISK 쓰기 작업입니다.</li>
</ul>
<p><code>[a1, b1, c1, d1]</code>가 약간의 텀이 있지만, 거의 동시에 진행 (event loop에 등록되는 시기 때문)</p>
<blockquote>
<p><strong>그러면 큐에서 작업을 뺄 때, 1개가 아닌 4개로 빼면 되는 것이 아닌가요?</strong></p>
</blockquote>
<p>라고 물어볼 수도 있습니다. 하지만 그렇게 할 수 없는데, 그 이유는 5개 이상의 작업이 들어왔을 때 역시 직렬적으로 처리되는 것이 아니라 병렬적으로 처리되기 때문입니다.</p>
<p>이전 예시에 E 작업만 추가하겠습니다. <strong>5개의 작업이 들어오면, 다음처럼 처리됩니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d3a6c6fe-8709-497f-8335-9a2c2d29d7d0/image.png" alt="병렬 처리 5개"></p>
<p>먼저 받은 4개의 작업이 완료된 뒤에, 뒤에 받은 E 작업을 진행하는 것이 아니라, <strong>A, B, C, D, E 개의 작업이 차례대로 진행하게 됩니다</strong>.</p>
<p>따라서 4개의 작업 단위로 큐에서 뺀다고 하더라도, 크게 의미가 없습니다. 이를 정말 큐로 막기 위해서는, Kafka와 같은 MQ를 도입하여, 별도 서버에서 받아서 앞단에서 처리해줄 필요가 있을 것 같습니다.</p>
<h2 id="32-실제로-측정하여-가설-검증하기">3.2 실제로 측정하여 가설 검증하기</h2>
<p><strong>위에서 이론적으로 예측한 내용이 실제 결과와 다를 수 있습니다</strong>. 왜냐하면 제가 JS, libuv 전문가도 아니고, 이전에 실제 검증 결과가 예상과 달랐던 상황 때문에 삽질을 많이 한 경험이 있습니다. 그 아픈 경험을 다시 하지 않으려면 <strong>반드시 실제 측정을 바탕으로 가설을 검증해야한다고 생각합니다.</strong></p>
<h3 id="321-실제-측정-방식">3.2.1 실제 측정 방식</h3>
<p>측정할때에는 다음 과정으로 이론을 검증 해보았습니다.</p>
<p><strong>4개의 10MB 파일과 2개의 1MB 업로드 요청을 진행</strong>합니다. 각각을 순서대로 [A, B, C, D], [E, F] 라고 하겠습니다. 이론이 맞다면, ABCD가 먼저 서버 작업에 들어갔음에도 불구하고 E, F가 우선적으로 작업 완료처리가 되어야합니다. 즉, <strong>작업 완료의 순서가 E, F, A, B, C, D 가 되어야합니다</strong>. 청크단위로 병렬처리가 되기 때문에 1MB 파일이 우선적으로 처리되어야하고 10MB 파일은 더 이후에 처리되기 때문입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/5c8eecfa-4f5c-4c5c-813d-e065394fd7ce/image.png" alt="로그1"></p>
<p>직접 측정을 해본 결과로, 가설을 검증할 수 있었습니다. 다음건 재미삼아서 다르게 한번 더 해보았습니다. </p>
<p>[ABCDE] [FGHI] 이렇게 10MB, 1MB 파일로 테스트를 진행해봤습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/69a60e27-9419-43cb-9aef-eb285df8884f/image.png" alt="로그2"></p>
<p>마찬가지로 1MB 파일이 먼저 업로드되고, 10MB 파일이 나중에 업로드 되는 것을 볼 수 있습니다.</p>
<p><strong>이를 통해 이론 가설을 검증했습니다</strong>. 이제 <strong>이 병렬 처리 구조에서 작업 수를 어떻게 파악할 수 있을지</strong> 고민해보았습니다.</p>
<h2 id="33-작업큐를-직렬적으로-변경">3.3 작업큐를 직렬적으로 변경</h2>
<h3 id="331-작업큐를-직렬적으로-변경">3.3.1 작업큐를 직렬적으로 변경</h3>
<p>대기큐에서 작업을 1개씩 빼낼 수는 없습니다. n개 단위로 뺄 수도 없는 상황입니다. 그리고 이 문제의 원인은 작업이 병렬적으로 처리가 되기 때문입니다. 따라서 <strong>해결책</strong>은 이러한 <strong>처리를 직렬적으로 바꾸어 1개씩 빼내게 하는 방법</strong>이 있습니다.</p>
<p><strong>1번에 1개씩 처리하면, 속도가 느려진다</strong></p>
<p>하지만 1번에 1개씩만 처리하게 된다면, 속도가 느려진다는 우려도 있습니다. 현재 병렬 처리에서는 DISK에 쓰기작업을 진행하는 동안, 다른 요청들의 socket buffer에서 readable Stream으로 미리 옮겨놓는 작업을 진행하고 있습니다. 하지만 1번에 1개의 작업만 처리한다면, DISK에 쓰는 작업 도중에 js가 놀게되는 시간이 생기게 될 것 같습니다. (검증하지는 않았지만, 제 추측입니다)</p>
<p><strong>1번에 4개씩 처리하면?</strong></p>
<p>속도 저하가 우려된다면, libuv 스레드 단위에 맞춰서 4개씩 빼는 방법은 어떨까요? 생각보다 괜찮을 방법일지 모르겠습니다. DISK에 쓰는 동안 socket buffer에서 데이터를 읽는 작업을 진행할테니 말이죠!</p>
<h3 id="332-직렬-처리의-문제점">3.3.2 직렬 처리의 문제점</h3>
<p>하지만 직렬 처리에서의 근본적인 문제점이 하나 있습니다. <strong>직렬적으로 바꾸게된다면, 데이터 처리가 느려지는 문제</strong>가 발생합니다. 정확히 말하자면 데이터 처리가 느려진다기 보다는 요청 처리 순서로 인해서, 불편함을 유발한다고 보는게 더 정확할 것 같습니다. 마치 HTTP/1.1 의 <strong>HOL(Head-of-Line) Blocking</strong> 문제처럼 말입니다.</p>
<p> <strong>HOL(Head-of-Line) Blocking</strong> 문제란, HTTP 전송에서 앞에있는 요청 데이터가 큰 경우 뒤에 작은 데이터가 오더라도 앞의 요청을 모두 처리한 후에 처리되는 문제를 말합니다. 구체적인 예시를 들어 설명하자면 1GB파일 → 1KB 파일순으로 HTTP 요청을 보낸다면, 1KB 파일은 매우 작지만, 1GB 파일을 모두 받은 이후에야 받을 수 있는 문제입니다.</p>
<p>직렬 처리로 바꾸게 된다면, 사용자 입장에서 ‘<strong>용량이 1KB 파일인데 왜이렇게 업로드/다운로드가 느려?</strong>’ 라는 불편함을 호소할 수도 있습니다. 즉, UX를 크게 감소시키게 될 수 있다고 판단하였습니다. 더군다나 현재 서비스는 write 만 하는 것이 아니라, read 요청도 많이 처리를 해야하기 때문에 더더욱 직렬화로 바꿀 수 없었습니다.</p>
<p><code>3.3.1</code>의 한번에 4개씩 처리한다면 위 문제를 예방할 수도 있을 것 같습니다. 다만, 1GB 파일이 4개가 동시에 들어온다면 다시 문제가 터질 수는 있겠습니다.(물론 확률은 낮을 듯 합니다)</p>
<h2 id="34-결론">3.4 결론</h2>
<p>결국 작업 수를 파악하기 위해 처리 구조를 바꾸는 대신, <strong>현재 병렬 처리 구조를 그대로 유지하면서, 다른 방법으로 작업 수를 관찰하는 방향</strong>으로 진행하고자 하였습니다.</p>
<hr>
<h1 id="4-작업-수-측정하기">4. 작업 수 측정하기</h1>
<p>결국에 저희가 알고 싶어 하는 정보는, ‘<strong>현재 서버가 몇 개의 작업을 처리하고 있느냐?</strong>’ 입니다. 그리고 조금 더 나아가서 재복제 요청에 영향을 줄 수 있는 작업인지까지 확인하면 좋을 것 같습니다.</p>
<h2 id="41-재복제에-영향을-줄-수-있는-작업-파악하기">4.1 재복제에 영향을 줄 수 있는 작업 파악하기</h2>
<p><strong>재복제 작업은 순수 DISK 쓰기 작업</strong>입니다. 따라서 <strong>DISK 작업에 병목이 없어야합니다</strong>. 그래서 DISK 작업의 병목에 초점을 두어 테스트를 진행하였습니다.</p>
<h3 id="411-쓰기-요청-병목-테스트">4.1.1 쓰기 요청 병목 테스트</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/53a7e6e0-5b35-47e5-b07b-922b24e2f9fb/image.png" alt="쓰기 병목 테스트 로그"></p>
<p>k6로 부하테스트를 진행했을 때의 일부 로그를 가져왔습니다. DISK 쓰기 작업에서 시간이 소요되는 것을 확인할 수 있었습니다.</p>
<h3 id="412-읽기-요청-병목-테스트">4.1.2 읽기 요청 병목 테스트</h3>
<table>
<thead>
<tr>
<th><strong>항목</strong></th>
<th><strong>수치</strong></th>
<th><strong>비고</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Checks (성공률)</strong></td>
<td><strong>100.00%</strong></td>
<td>총 9,156건 성공 / 0건 실패</td>
</tr>
<tr>
<td><strong>Data Received</strong></td>
<td>2.0 GB</td>
<td>초당 평균 약 63 MB/s</td>
</tr>
<tr>
<td><strong>Data Sent</strong></td>
<td>1.2 MB</td>
<td>초당 평균 약 36 kB/s</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th><strong>구분</strong></th>
<th><strong>응답 시간 (Latency)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Avg (평균)</strong></td>
<td><strong>1.22s</strong></td>
</tr>
<tr>
<td><strong>p(90)</strong></td>
<td>1.7s</td>
</tr>
<tr>
<td><strong>p(95)</strong></td>
<td>2.1s</td>
</tr>
<tr>
<td><strong>p(99)</strong></td>
<td><strong>11.1s</strong></td>
</tr>
</tbody></table>
<p>위는 30초 동안 <strong>9156건</strong>의 서로 다른 파일(100KB, 1MB, 10MB) <strong>읽기 요청에 대한 테스트 결과</strong>입니다. 읽기 또한 DISK에 병목 영향을 끼칠 수 있다고 판단하여 읽기의 작업 수도 고려해야한다고 생각하였습니다.</p>
<p>다만 쓰기보다는 병목이 작아서, 쓰기를 중심적으로 고려할 필요가 있습니다.</p>
<h2 id="42-작업-수-판단-코드">4.2 작업 수 판단 코드</h2>
<h3 id="421-stream-수와-작업-수의-관계">4.2.1 Stream 수와 작업 수의 관계</h3>
<pre><code class="language-tsx">┌─────────────────────────── Client ─────────────────────────────────┐
│                                                                    │
│   요청 1                                      요청 2               │
│     │                                          │                   │
└─────┼──────────────────────────────────────────┼───────────────────┘
      ▼                                          ▼

┌──────────────────────── Kernel (Network) ──────────────────────────┐
│                                                                    │
│   [ Socket Buffer 1 ]                [ Socket Buffer 2 ]           │
│                                                                    │
└──────────────┬───────────────────────────────┬─────────────────────┘
               ▼                               ▼

┌──────────────────────── Node.js (User Space) ──────────────────────┐
│                                                                    │
│   요청 1 흐름                         요청 2 흐름                   │
│                                                                    │
│   [ Readable Stream ]               [ Readable Stream ]            │
│           │                                │                       │
│           ▼                                ▼                       │
│   [ Writable Stream ]               [ Writable Stream ]            │
│                                                                    │
└──────────────┬───────────────────────────────┬─────────────────────┘
               ▼                               ▼

┌──────────────────────── Kernel (File System) ──────────────────────┐
│                                                                    │
│                [ Page Cache ]  (커널에서 공유됨)                    │
│                                                                    │
└──────────────────────────────┬─────────────────────────────────────┘
                               ▼
                            [ DISK ]</code></pre>
<p>즉 쓰기 작업의 개수는 현재 열려 있는 Readable Stream 수와 비례한다는 것을 알 수 있습니다. 읽기 작업도 위 구조와 비슷한 구조를 갖습니다.</p>
<p>따라서 <strong>DISK IO에 사용되는</strong> <strong>Stream의 개수를 바탕으로 병목이 될 수 있는 작업 수를 판단</strong>하기로 하였습니다.</p>
<h3 id="422-디스크-쓰기읽기-작업-수">4.2.2 디스크 쓰기/읽기 작업 수</h3>
<p>DISK에 저장하는 메서드 내부에 전역 변수를 별도로 두어서, 작업 시작 시 +1, 종료 시에 -1 을 하는 방식으로 구현하였습니다.</p>
<pre><code class="language-tsx">/**
 * 파일을 로컬 파일시스템에 저장
 */
export async function saveFileToStorage(
  bucket: string,
  objectKey: string,
  fileData: MultipartFile
): Promise&lt;string&gt; {
  ...

  // 파일 저장
  const writeStream = fs.createWriteStream(filePath)

  // stream이 닫히면 자동으로 &#39;쓰기 작업 개수 -1&#39;
  writeStream.once(&#39;close&#39;, () =&gt; { _activeDiskWrites-- })

  // 작업 수 +1
  _activeDiskWrites++

  await pipeline(stream, writeStream)

  return filePath
}</code></pre>
<p>Stream이 닫히면 -1 하도록 구현하였습니다.</p>
<p>읽기도 마찬가지입니다</p>
<pre><code class="language-tsx">/* 파일 읽기 스트림 생성 */
export function getFileStream(bucket: string, objectKey: string): fs.ReadStream {
  ...

  const stream = fs.createReadStream(filePath)
  stream.once(&#39;close&#39;, () =&gt; { _activeDiskReads-- })
  _activeDiskReads++

  return stream
}</code></pre>
<p>이로써, 현재 진행 중인 DISK I/O 작업의 수를 API를 통해 얻을 수 있는 로직을 구현하였습니다.</p>
<hr>
<h1 id="5-트래픽-판단-기준">5. 트래픽 판단 기준</h1>
<p> <strong>과도한 트래픽의 적절한 기준</strong>을 잡아야했습니다. 트래픽 기준을 작게 잡는다면, 복제 로직 시도를 못할 것이고, 높게 잡는다면 부하가 있을 수 있기 때문입니다.</p>
<p><strong>트래픽 판단의 기준을 잡기 정말 어려웠습니다.</strong> 보통 서비스를 사용한다면 카메라 이미지를 많이 올릴 것 같은데, 카메라 이미지(약 4MB)로 테스트를 한다고 하더라도, 몇 초를 기준으로 잡아야할지 어려웠기 때문입니다.</p>
<h3 id="기준-찾아보기">기준 찾아보기</h3>
<p>기준 설정을 위해 <code>Notion</code>, <code>Google Drive</code>, <code>Naver-MyBox</code> 등 일상에서 많이 사용하는 서비스에서 실제 파일 업로드 시간을 측정해보았습니다. </p>
<p><strong>1MB 파일을 올리는데 소요된 시간</strong>입니다. (업로드 대역폭 25Mbps (3MB/s) 환경에서 테스트)</p>
<p>(오차가 있을 수 있습니다.)</p>
<table>
<thead>
<tr>
<th>서비스</th>
<th>1MB 파일 업로드 소요 시간</th>
<th>5MB 파일 업로드 소요 시간</th>
<th>5MB 파일 다운로드 소요시간</th>
</tr>
</thead>
<tbody><tr>
<td>Notion (재미용)</td>
<td>약 2.5초</td>
<td>10초 이상</td>
<td>-</td>
</tr>
<tr>
<td>Google Drive</td>
<td>-</td>
<td>약 3초</td>
<td>1.5초 이내</td>
</tr>
<tr>
<td>Naver MyBOX</td>
<td>-</td>
<td>약 1.5초 (최대)</td>
<td>1초 이내</td>
</tr>
</tbody></table>
<p>많은 사용자가 사용하는 대규모 서비스에서 응답 속도에 불편함을 느끼지 않는다는 점에서 이 수치를 트래픽의 부하 기준으로 정해도 괜찮겠다는 생각을 하였습니다.</p>
<p>파일 저장소인, <strong><code>google drive</code></strong>와 <strong><code>Naver My box</code></strong>를 기준으로, 5MB 파일의 <strong><code>업로드</code> 시간은 중간 지점인</strong> <strong><code>2.5초</code></strong> 기준으로 정하는 것이 괜찮겠다고 생각하였습니다. <strong><code>읽기</code> 소요시간은 넉넉하게 <code>1.5초</code> 이내</strong>로 잡았습니다.</p>
<h2 id="51-테스트-결과">5.1 테스트 결과</h2>
<blockquote>
<p><strong>K6로 5MB 파일로 5m간 테스트를 진행하여, 요청 인원수별로 적정 트래픽을 찾아보았습니다.</strong></p>
</blockquote>
<h3 id="511-읽기">5.1.1 읽기</h3>
<table>
<thead>
<tr>
<th>동시 요청 최대 유저 수</th>
<th><strong>20</strong></th>
<th>30</th>
</tr>
</thead>
<tbody><tr>
<td>p(95)</td>
<td><strong>1.14s</strong></td>
<td>5.41s</td>
</tr>
<tr>
<td>p(99)</td>
<td><strong>1.35s</strong></td>
<td>6.93s</td>
</tr>
</tbody></table>
<p>읽기 작업의 경우 <strong>20명의</strong> 유저가 지속적인 다운로드 요청을 보냈을 때, <strong>p(95)=1.14s</strong> 가 나왔습니다. 따라서 <strong><code>20개의 작업</code>을 적정 기준</strong>으로 설정하였습니다.</p>
<p>(30명이 되니 급수적으로 올라갔는데, 아마 추측하기에는 병렬적으로 요청을 처리하고 있기 때문이 아닐까 싶습니다.)</p>
<h3 id="512-쓰기">5.1.2 쓰기</h3>
<table>
<thead>
<tr>
<th>동시 요청 최대 유저 수</th>
<th>10 명</th>
<th><strong>15 명</strong></th>
<th>20 명</th>
</tr>
</thead>
<tbody><tr>
<td>p(95)</td>
<td>744.07ms</td>
<td><strong>810.16ms</strong></td>
<td>3.23s</td>
</tr>
<tr>
<td>p(99)</td>
<td>7.01s</td>
<td><strong>11.4s</strong></td>
<td>10.14s</td>
</tr>
</tbody></table>
<p>아무튼 쓰기도 마찬가지의 이유로 <strong><code>15개의 DISK 쓰기 작업</code>을 적정선으로</strong> 설정하였습니다.</p>
<p>(읽기와 마찬가지로 병렬 처리로 인해서 15 → 20 명으로 올라가면 갑작스럽게 p(95)가 올라가는 것 같습니다.)</p>
<h2 id="52-기준-설정하기">5.2 기준 설정하기</h2>
<p>우선은 <strong>쓰기가 15개 이상이거나, 읽기가 20개 이상인 경우를 트래픽 부하로 설정</strong>하여, <strong>재복제 요청을 보내지 않도록 처리하였습니다</strong>.</p>
<p>그리고 병렬 작업으로 인해, 요청 딜레이가 늘어나는 지점이 20개 근처인 것으로 보여서, <strong><code>쓰기 작업 + 읽기 작업 &lt; 20</code></strong> 인 조건도 추가하였습니다. </p>
<p>(<strong>적정선을 더 정확하게 정하려면</strong>, 쓰기 작업 1개당, 몇개의 읽기 작업에 해당하는지를 측정하여 적용할 수 있을 것 같지만, 이정도의 트래픽에서는 오차도 크고 큰 의미도 없을 것 같아 따로 계산하지는 않았습니다.. )</p>
<hr>
<h1 id="6-구현-및-흐름">6. 구현 및 흐름</h1>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/51bd980d-d378-429f-aa23-44c9b3737dc1/image.png" alt="구현 최종 흐름"></p>
<p>위 흐름을 구현한 코드입니다.</p>
<pre><code class="language-tsx">async function retryFailedReplications(
  replicationQueue: ReplicationQueueRepository,
  log: FastifyBaseLogger,
): Promise&lt;void&gt; {
  // 1. Secondary 노드의 현재 작업량을 확인하여 복제 가능 여부 판단
  const idle = await isSecondaryNodeIdle(log);

  // 2. 작업 가능 여부 확인
  if (!idle) return;

    // 3. 복제 실패한 정보들 sqlite에서 가져오기
  const replicationObjects = replicationQueue.fetchRetryBatch(RETRY_WORKER_BATCH_SIZE);
  if (replicationObjects.length === 0) return;

    // 4. 재복제 요청 전달
  for (const row of replicationObjects) {
    const { bucket, objectKey } = row;

    try {
      await replicateToSecondary(bucket, objectKey, log);
    } catch (err) {
        ...

        // 복제 실패 시, 복제 실패 정보를 업데이트
      replicationQueue.updateOnRetryFailure(
        bucket,
        objectKey,
        errorType,
        errorMessage,
      );

      ...
      continue;
    }
    // 복제 성공 시, 데이터 삭제
    replicationQueue.deleteOnSuccess(bucket, objectKey);
  }
}</code></pre>
<p>핵심은 1번 단계입니다. 재복제를 요청을 하기 전에, <code>isSecondaryNodeIdle</code>을 호출하여 현재 작업량을 확인하고, Secondary 서버의 작업량이 기준치를 초과하지 않은 경우에만 재복제를 하여, 트래픽이 몰린 시점의 추가 부하를 방지합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-2] 데이터 업로드/복제 분리로 응답속도 개선하기]]></title>
            <link>https://velog.io/@standard-chan/storage-2-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%B5%EC%A0%9C-%EC%8B%A4%ED%8C%A8-%EC%8B%9C-%EC%9E%AC%EC%8B%9C%EB%8F%84-%EB%A1%9C%EC%A7%81-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@standard-chan/storage-2-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%B5%EC%A0%9C-%EC%8B%A4%ED%8C%A8-%EC%8B%9C-%EC%9E%AC%EC%8B%9C%EB%8F%84-%EB%A1%9C%EC%A7%81-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Sun, 01 Mar 2026 08:41:30 GMT</pubDate>
            <description><![CDATA[<p>파일을 업로드 하다가 아쉬운 점이 있었습니다. 업로드가 잘 되어도, 복제가 실패하면 결국에 실패한다는 것 입니다. 그래서 의문이 들었습니다.</p>
<p><strong>“원본엔 잘 저장됐는데, 복제가 실패했다고 사용자한테 실패를 돌리는 게 맞나?”</strong></p>
<p>거기다 복제가 끝날 때까지 응답을 무작정 기다리게 하는 것도 조금 찜찜했습니다. </p>
<blockquote>
<p>이번 글에서는 기존 업로드 흐름의 문제점을 짚어보고, 속도와 실패율을 줄이는 것을 목표로 개선해나가는 과정을 담았습니다.</p>
</blockquote>
<hr>
<h1 id="1-기존-방식의-흐름과-문제점">1. 기존 방식의 흐름과 문제점</h1>
<h2 id="11-기존-방식의-작동-흐름">1.1 기존 방식의 작동 흐름</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/ceeb7700-4555-4d1a-b30f-168c0b49a1c5/image.png" alt="기존 흐름"></p>
<p>현재 파일을 업로드하는 전반적인 흐름은 위와 같습니다.</p>
<pre><code class="language-tsx">1. 사용자는 URL로 파일을 업로드한다.
2. Primary Node에서 요청을 받아 DISK에 파일을 저장한다.
3. Secondary Node 에게 복제를 요청한다
4. Secondary Node 가 이를 받아 DISK에 파일을 저장한다
5. 복제 성공 응답을 Primary Node에게 전달한다
6. Primary Node가 이를 받아 Client 에게 성공 응답을 전달한다</code></pre>
<p>즉, <strong>2개의 DISK 모두 저장이 완료되었을 때에만 성공 응답을 반환하게 됩니다.</strong></p>
<pre><code class="language-tsx">export async function uploadFileWithConcurrentReplication(
  request: FastifyRequest&lt;{ Querystring: PresignedQuery }&gt;,
  replicationQueue: ReplicationQueueRepository,
): Promise&lt;FileInfo&gt; {
  const { bucket, objectKey } = request.query;
  const bodyStream = request.body;

  request.log.info({ objectKey }, &quot;PUT request received&quot;);

    // 유효성 검사
  validatePresignedUrlRequest(request.query, &quot;PUT&quot;);
  validateReplicationBodyStream(bodyStream);

  // stream 분기 처리
  const storageStream = new PassThrough();
  const replicationStream = new PassThrough();
  bodyStream.pipe(storageStream);
  bodyStream.pipe(replicationStream);

  // 복제 요청
  const replicationPromise = replicateToSecondary(
    bucket,
    objectKey,
    replicationStream,
    replicationQueue,
    request.log,
  );

    // DISK 쓰기
  const filePath = await saveStreamToStorage(bucket, objectKey, storageStream);
  const fileInfo = await collectStreamFileInfo(
    bucket,
    objectKey,
    filePath,
    mimetype,
  );

    // 복제 완료 대기
  await replicationPromise;

  request.log.info({ fileInfo }, &quot;파일 업로드 성공&quot;);

  return fileInfo;
}</code></pre>
<p>위 코드는 <strong>두 저장 작업을 모두 완료한 후</strong>에만 <strong>성공 응답</strong>을 반환하도록 설계하였습니다. 이는 <strong><code>Primary</code>와 <code>Secondary</code> 2개의 DISK에 파일을 모두 저장하여 안정성을 보장</strong>하려는 의도였습니다. </p>
<h2 id="12-현재-로직-개선-포인트-분석">1.2 현재 로직 개선 포인트 분석</h2>
<h3 id="121-분석-1-응답-시점에-따른-소요시간-개선-포인트">1.2.1 분석 1: 응답 시점에 따른 소요시간 개선 포인트</h3>
<p>현재 처리 방식을 <strong>시간 그래프</strong>로 나타내면 아래 그림과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/3bc1f258-b57b-406c-96ac-5aab0a29602b/image.png" alt="시간 그래프"></p>
<p>2개의 DISK에 모두 쓰기가 완료되는 시점에 성공 응답이 반환됩니다. 비동기적으로 쓰기 처리를 하지만, <strong>복제가 완료될 때까지 <code>대기</code> 해야한다는 문제</strong>가 있습니다.</p>
<p>이러한 <strong><code>대기 시간</code>을 줄인다면</strong>, 사용자가 받을 응답 속도를 더 높일 수 있을 것 같습니다.</p>
<h3 id="122-분석-2-복제-실패-시-재업로드-개선-포인트">1.2.2 분석 2: 복제 실패 시, 재업로드 개선 포인트</h3>
<p>현재 2개의 DISK에 저장하게 되는데, 흐름 상 <strong>저장이 실패할 수 있는 지점은 2가지</strong>입니다. </p>
<ol>
<li>Primary Node에 저장 실패 (원본)</li>
<li>Secondardy Node에 저장 실패 (복제본)</li>
</ol>
<p>데이터가 원본에 저장되지 않았다면 실패 응답을 받는 것이 납득이 됩니다. 아예 저장되지 않았으니 사용자는 다시 업로드를 해야하기 때문입니다.</p>
<p>하지만 <strong>원본에 데이터가 잘 저장되었는데, 사용자가 실패로 처리해야하는 부분</strong>에 아쉬움을 느꼈습니다.</p>
<blockquote>
<p>‘원본은 잘 저장되었으니, 원본을 복제해서 다시 시도하면 되는거 아니야?&#39; 
&#39;굳이 사용자 불편하게 처음부터 업로드를 시켜야해?’</p>
</blockquote>
<p>이러한 문제를 <strong>서버에 저장되어있는 ‘원본’을 이용하여 해결하고 싶다는 생각</strong>이 들었습니다.</p>
<h2 id="13-결론">1.3 결론</h2>
<p>결론적으로 현재 문제와 해결하고싶은 사항을 요약하자면 다음과 같습니다.</p>
<ul>
<li><strong>복제 대기시간을 줄여, 사용자가 받는 응답 속도 향상시키기</strong></li>
<li><strong>복제만 실패할 경우, 사용자가 재업로드를 해야하는 불편함 개선하기</strong></li>
</ul>
<hr>
<h1 id="2-해결-방안">2. 해결 방안</h1>
<h2 id="21-방안-1-즉각-retry">2.1 방안 1: 즉각 retry</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/28814bf5-762e-4577-842e-fd649eceb14d/image.png" alt="retry"></p>
<p>가장 무난하게 떠올릴 수 있었던 방법은 <strong>복제 요청이 실패했을 경우</strong>, <strong>즉각적으로 복제 요청을 시도</strong>하는 방식입니다. <strong>구현이 간단하여 쉽게 도입할 수 있다는 장점</strong>이 있습니다.</p>
<p>하지만 <strong>2가지 우려사항</strong>이 있습니다.</p>
<ul>
<li><strong>N번 재시도 후 모두 실패하면 그 이후에는 재시도를 하지 않는다는 점</strong> : 즉, 데이터가 Secondary에 저장되지 않은 상태로 남겨질 수 있어 완전한 복제 보장이 불가능합니다.</li>
<li><strong>사용자가 받을 응답 시간이 느려짐</strong> : 복제를 재시도하는 동안 사용자는 응답을 하염없이 기다려야합니다.</li>
</ul>
<p>따라서 현재 문제의 해결책으로는 부족하다고 판단했습니다.</p>
<h2 id="22-방안-2-업로드와-복제-처리-분리하기">2.2 방안 2: 업로드와 복제 처리 분리하기</h2>
<blockquote>
<p><strong>업로드와 복제 처리를 분리하기</strong></p>
</blockquote>
<p>현재 업로드의 <strong>문제점</strong>은 <strong>복제 요청이 업로드 요청에 포함되어있기 때문에 발생</strong>한 것입니다. 따라서 복제가 실패하면 업로드 요청도 실패하고, 복제가 완료되지 않으면 업로드 요청도 완료되지 않습니다. 그래서 <strong>업로드와 복제를 분리해서 처리하는 방법을 생각</strong>했습니다.</p>
<p>사용자가 <strong><code>업로드 요청</code>을 보내면, 복제 요청 정보를 별도로 저장하는 것 까지 처리하고, <code>복제</code>처리는 업로드 요청과 별도로 처리</strong>하는 것입니다.</p>
<p>이렇게 된다면, 복제 성공/실패 여부에 상관없이, <strong>원본이 저장된다면 사용자는 성공 응답을 받을 수 있습니다</strong>.</p>
<h3 id="응답-시간-개선">응답 시간 개선</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/5c0ecbe7-f0b2-4545-990c-b0709ac5adb2/image.png" alt="시간 개선"></p>
<p>기존에는 복제가 끝날 때까지 응답을 대기시켜뒀는데, 이 방식에서는 <strong>복제를 기다리지 않고 바로 성공 응답을 반환하여, 사용자의 응답속도도 향상</strong>시킬 수 있습니다.</p>
<h3 id="복제가-실패하는-경우에는">복제가 실패하는 경우에는?</h3>
<p>위 방식에서는 복제가 실패하는 경우, 사용자는 다시 업로드를 하지 않습니다. 따라서 완전한 복제를 보장하기 위한 방법이 필요합니다.</p>
<p>이를 위해 복제 정보를 저장하여, 만약 복제가 실패하더라도 언제든 다시 시도할 수 있게 만들어 보장할 수 있습니다. 그렇게되면 언젠가는 반드시 복제가 완료되고, <strong>복제 실패로 인한 데이터 유실을 방지</strong>할 수 있습니다.</p>
<p>결과적으로 앞서 언급했던 두 가지 문제, <strong>복제 실패 보장</strong>과 <strong>응답 속도 개선</strong>을 동시에 해결할 수 있는 방법입니다.</p>
<hr>
<h1 id="3-복제-정보를-통한-복제-요청-구조-설계하기">3. 복제 정보를 통한 복제 요청 구조 설계하기</h1>
<p>이를 위해 다음 조건이 필요합니다.</p>
<ol>
<li><strong>복제 요청 정보들을 별도로 저장해야한다</strong></li>
<li><strong>주기적으로 복제를 호출할 수 있어야 한다</strong></li>
</ol>
<h2 id="31-처리-흐름">3.1 처리 흐름</h2>
<p>그림으로 표현하면 다음 처리 흐름을 갖겠습니다.</p>
<h3 id="복제-요청---초기-시도">복제 요청 - 초기 시도</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/19e51291-18df-48e2-89d3-80903951645c/image.png" alt="초기 복제"></p>
<p>처리 흐름</p>
<pre><code class="language-tsx">1. Primary Node가 복제 실패 응답을 받는다
2. 실패한 요청에 대한 정보를 별도로 저장한다
3. 이후 Secondary Node 서버에 복제를 다시 요청한다</code></pre>
<h3 id="복제-요청---실패-시-처리">복제 요청 - 실패 시 처리</h3>
<p>만약 복제가 실패하더라도, 다음 방식으로 반복하여 복제 시도가 가능합니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/904a1cc6-aa1c-4345-93ed-c202f371723d/image.png" alt="실패 시 재시도"></p>
<pre><code class="language-tsx">1. 복제 요청 정보를 통해 재복제 요청을 전달한다.
2. 재복제 실패
3. 실패한 복제 요청에 대해 정보를 갱신한다</code></pre>
<h2 id="32-복제-요청-정보-저장">3.2 복제 요청 정보 저장</h2>
<p>재복제 요청을 다시 전송하기 위해 <strong>필요한 데이터들을 저장</strong>해야합니다.</p>
<h3 id="321-disk에-저장하기"><strong>3.2.1 DISK에 저장하기</strong></h3>
<p>이를 DISK에 저장할지, Memory에 저장할지 고민했습니다. 메모리의 경우 서버가 종료되면 같이 종료되기에, 안정성에 문제가 있다고 판단하여, <strong>확실한 복제 보장을 위해 DISK에 저장</strong>하기로 하였습니다.</p>
<h3 id="322-저장-방식-선택하기---sqlite"><strong>3.2.2 저장 방식 선택하기 - SQLite</strong></h3>
<p>다음으로 JSON과 같은 파일로 저장할지, DB를 사용하여 저장할지를 고민하였습니다.</p>
<p>JSON으로 저장한다면 간단히 구현할 수 있다는 장점이 있지만, 동시성 충돌을 제어하기가 어렵다고 판단했습니다. 해당 로컬에서만 사용하는 정보이므로, <code>embedded DB</code>를 사용하려고 했습니다. </p>
<p>주기적으로 재복제를 호출해야하므로, 시간 정보 또한 담을 필요가 있는데, <code>RocksDB</code>의 경우에는 시간순 조회 및 값 갱신에 적합하지 않다고 판단하여 <code>sqlite</code>을 선택하였습니다.</p>
<h3 id="323-저장-필드"><strong>3.2.3 저장 필드</strong></h3>
<ul>
<li>bucket, objectKey : 식별하는데 필요한 값 (파일 이름)</li>
<li>retryCount : 복제 요청 시도 횟수</li>
<li>nextRetryAt : 다음 복제를 시도할 시간</li>
<li>status : 재시도 여부 상태</li>
<li>이 밖의 로그 및 에러 확인 용도의 필드</li>
</ul>
<p>위 내용들을 바탕으로 <strong>초기 요청 실패 시</strong>, sqlite를 사용하여 <strong>데이터를 저장하는 코드</strong>를 구현하였습니다.</p>
<pre><code class="language-tsx">export async function uploadFile(
  request: FastifyRequest&lt;{ Querystring: PresignedQuery }&gt;,
  replicationQueue: ReplicationQueueRepository,
): Promise&lt;FileInfo&gt; {

  const { bucket, objectKey } = request.query;
  const bodyStream = request.body;

  request.log.info({ objectKey }, &quot;PUT request received&quot;);

  validatePresignedUrlRequest(request.query, &quot;PUT&quot;);
  validateReplicationBodyStream(bodyStream);

  const filePath = await saveStreamToStorage(bucket, objectKey, bodyStream);
  const fileInfo = await collectStreamFileInfo(
    bucket,
    objectKey,
    filePath,
    mimetype,
  );
  request.log.info({ fileInfo }, &quot;[FileUPload] 파일 업로드 성공&quot;);

  // 복제할 정보를 저장하기

  return fileInfo;
}</code></pre>
<p><strong>복제 정보를 SQLite에, 원본을 DISK에 성공적으로 저장하면, 사용자에게 성공 응답을 반환</strong>합니다.</p>
<p>Primary에 파일이 저장된 상태이므로, Secondary 복제는 서버가 자동으로 보장하는 방식입니다. <strong>다만 재복제가 완료되기 전까지는 Primary 장애 시 데이터가 유실될 수 있다는 위험은 남아있습니다</strong>. </p>
<h2 id="33-주기적으로-재요청을-보내는-방법">3.3 주기적으로 재요청을 보내는 방법</h2>
<p>한번에 재복제 요청이 몰리게 되면, secondary Node 서버에 무리가 갈 수 있습니다. 따라서 <strong>한번에 BATCH_SIZE 만큼만 재복제 요청을 전달</strong>합니다. BATCH_SIZE는 10로 설정하였습니다.</p>
<p>또한 각 실패한 복제 요청 처리의 주기를 <code>10*2^시도횟수</code> 로 설정하고, 최대 대기 시간을 <code>1시간+a</code>으로 설정하였습니다. <strong>요청이 몰렸을 때, 겹치지 않게 하기 위해서 서로 다른 주기로 설정</strong> 하였습니다.</p>
<p>만약 Secondary Node가 장시간 복구되지 않는 경우에는 재복제 요청이 무한히 반복할 수 있습니다. 따라서 <strong>최대 시도 횟수(retryCount)를 두어 재시도의 제한을 두도록 처리</strong>했습니다. 최대 시도 횟수는 15로 설정하였습니다.</p>
<pre><code class="language-tsx">async function tryReplications(
  replicationQueue: ReplicationQueueRepository,
  log: FastifyBaseLogger,
): Promise&lt;void&gt; {
    // secondary node health 체크
    const idle = await isSecondaryNodeIdle(log);
  if (!idle) return;

    // 복제 정보 가져오기
  const replicationObjects = replicationQueue.fetchRetryBatch(BATCH_SIZE);

  // 가져온 정보를 바탕으로 복제 시도
  for (const row of replicationObjects) {
    const { bucket, objectKey } = row;

    try {
      await replicateToSecondary(bucket, objectKey, log);
    } catch (err) {
        // 재복제 실패 시, 다시 등록
      replicationQueue.updateOnRetryFailure(
        bucket,
        objectKey,
        errorType,
        errorMessage,
      );

      // 로그

      continue;
    }
    // 복제 성공 시, 복제 정보 삭제 
    replicationQueue.deleteOnSuccess(bucket, objectKey);
  }
}</code></pre>
<p>일정 주기마다 위의 <code>retryFailedReplications</code> 함수를 호출하여, 데이터 복제 로직을 실행합니다.</p>
<h2 id="34-적용-결과">3.4 적용 결과</h2>
<h3 id="초기-업로드-시">초기 업로드 시</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/a236b2a7-2d7d-47e0-89e9-0d0d1af8f034/image.png" alt="초기 업로드1">
<img src="https://velog.velcdn.com/images/standard-chan/post/393244c7-fedf-47f2-b32d-54fd1c20e38d/image.png" alt="초기 업로드2"></p>
<p>주기적으로 호출하는 retryWorker에서 복제를 처리합니다</p>
<h3 id="복제-실패-시">복제 실패 시</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/717c47fc-6890-48e0-a5a5-4a4861dc01b1/image.png" alt=""></p>
<p>복제가 실패했을 때, 주기적으로 재복제 로직이 자동으로 실행되도록 하였습니다.</p>
<p><strong>서버가 꺼졌다 켜지는 경우</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d59c2aca-1989-4046-986b-97788bb00323/image.png" alt=""></p>
<p>실패 정보를 메모리가 아닌 DISK의 SQLite에 저장했기 때문에, 서버가 꺼졌다 켜지는 경우에도 동일하게 재복제 로직이 실행됩니다.</p>
<hr>
<h1 id="5-결과-비교">5. 결과 비교</h1>
<h2 id="51-속도-비교">5.1 속도 비교</h2>
<p>20개의 5MB 파일 업로드를 기준으로 속도를 측정해봤을 때, 소요되는 시간입니다. (로컬 I/O속도 100MB/s 이상 환경에서 실시하였기 때문에 DISK 병목은 없습니다)</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/80da498a-673a-4130-abee-279fa2e20271/image.png" alt="2"></p>
<ul>
<li><strong>기존 방식</strong> : 모두 저장 후 응답을 하는 경우, p(95) = <strong>2670.2 ms</strong> 가 소요됩니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/a55902ee-d7d0-465d-8a66-c2deec41ac1d/image.png" alt="1"></p>
<ul>
<li><strong>개선 방식</strong> : 즉시 응답을 하는 경우, p(95) = <strong>2224.9 ms</strong></li>
</ul>
<p><strong>20개 파일 기준으로 0.4s 를 개선하였습니다.</strong></p>
<hr>
<h1 id="6-보완해야할-점">6. 보완해야할 점</h1>
<p>이렇게 로그를 통한 재시도 로직을 구현하였습니다. 사용자의 응답 속도는 높아졌지만, 도입으로 인한 2가지 우려사항이 생겼습니다.</p>
<h3 id="문제점-1---데이터-동기화">문제점 1 - 데이터 동기화</h3>
<p>만약 복제 처리가 되지 않았는데, 파일 읽기요청이 <code>secondary</code> 서버로 들어온다면 제대로된 파일을 반환할 수 없을 것입니다. 이를 막으려면 <code>primary</code> 서버로만 읽기 요청을 전달하거나, 복제 완료 여부를 확인할 수 있는 API를 별도로 두어야할 것 같습니다. (현재는 모든 요청을 primary에 전달하는 구조라 상관이 없지만, 로드밸런싱을 시키게 된다면 개선할 필요가 있을 것 같습니다)</p>
<p>사용자 응답 속도는 개선했지만, 추가적인 문제가 발생하여서 이러한 부분도 추후 구현 시 고려하여야 할 것 같습니다.</p>
<h3 id="문제점-2---서버-부하">문제점 2 - 서버 부하</h3>
<p><code>secondary</code> 서버에 부하가 발생했는데, 계속해서 재시도 로직이 동작한다면, <code>secondary</code> 서버에 문제가 발생할 수 있을 것 같습니다.</p>
<p>이러한 부하를 막기 위해서, 서로 다른 주기로 요청이 전송되도록 설정했고, 한번에 10개의 복제로직만 수행되도록 만들었습니다. 하지만 <strong>이런 로직들은 예방의 측면이 강할 뿐이지, 트래픽이 몰렸을 때를 회피하기엔 부족하다고</strong> 생각합니다. 추후에 이 부분을 개선해볼 필요성이 있을 것 같습니다</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[storage-1] 데이터 내구성 99.99..% 아키텍처 설계해보자]]></title>
            <link>https://velog.io/@standard-chan/%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%84%A4%EA%B3%84-%EB%82%B4%EA%B5%AC%EC%84%B1-99.99..-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@standard-chan/%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%84%A4%EA%B3%84-%EB%82%B4%EA%B5%AC%EC%84%B1-99.99..-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 25 Feb 2026 09:21:56 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요">1. 개요</h1>
<h2 id="11-들어가기에-앞서-도란도란">1.1 들어가기에 앞서 도란도란</h2>
<p>개인적으로 사용할 스토리지 서비스를 만들고 있습니다. 사실 외부 스토리지 서비스를 구독해서 쓴다면 훨씬 저렴하게 쓸 수 있지만... 거기에 돈쓰기는 싫고 ㅜ, 지인들이나 가족에게 무료로 서비스를 풀어보고 싶어서 직접 스토리지 서비스를 만들고 있습니다.</p>
<p>다만, 언제나 문제가 되는 지점은 비용입니다. 그래서 비용을 최소한으로 설계하려고 했습니다. </p>
<h2 id="12-중요한-건-데이터가-소실되지-않는-것">1.2 중요한 건, 데이터가 소실되지 않는 것</h2>
<p>스토리지 서비스를 구축할 때, 가장 중요하게 생각했던 부분은 <strong>데이터의 내구성을 보장하는 것</strong>이었습니다. 다시말해서 데이터가 갑자기 없어져버리는 불상사를 막는 것입니다! 사용자가 저장한 데이터가 DISK 고장 등의 이유로 없어진다면, 다시는 서비스를 사용하지 않을 것 같습니다. (상상만해도 끔찍하네요.) 이러한 이유로 내구성을 위해 <strong>분산 스토리지</strong>를 도입하기로 하였고,  설계의 과정을 글로 정리하였습니다.</p>
<blockquote>
<p>이 글에서는 디스크 개수와 Node 개수를 어떻게 결정했는지, 그리고 비용적인 부분과 안정성 사이에서 어떤 판단을 내렸는 지를 작성하였습니다</p>
</blockquote>
<hr>
<h1 id="2-내구성-보장--disk-개수-선택">2. 내구성 보장 : DISK 개수 선택</h1>
<p>현재 <strong><code>Azure</code></strong>를 사용하여 서비스를 구축하고 있습니다. Azure를 선택한 가장 큰 이유는 비용이었고, Azure에 무료 크레딧이 남아있어 Azure를 선택하였습니다.</p>
<p>그래서 Azure의 자료를 바탕으로 DISK 고장 상황을 예상하여 개수를 선택하는 과정을 작성하였습니다.</p>
<h2 id="21-단일-disk-고장으로-인한-데이터-유실">2.1 단일 DISK 고장으로 인한 데이터 유실</h2>
<p><strong>하나의 DISK에 데이터를 저장</strong>하면, DISK가 고장났을 때, <strong>데이터를 복구할 수 없습니다</strong>. 그래서 DISK의 고장 확률이 중요합니다. Azure에는 <strong><code>Managed DISK</code></strong>를 제공하는데, 해당 <strong>DISK는 99.999…% (11 nines) 내구성</strong>을 보장합니다. (3개의 복제본을 운용한다고 합니다.) 사실 저렴한걸 쓰고 싶어서 복제본이 없는 DISK를 사용하고 싶었는데, 그런건 제공하지 않는다고 합니다. (어차피 복제본 없는걸 써도 안정성을 위해 3개는 쓸 생각이었어서 별 차이는 없었겠지만요 ^^;)</p>
<blockquote>
<p><strong>Azure Managed DISK의 내구성</strong>
<a href="https://learn.microsoft.com/ko-kr/azure/virtual-machines/managed-disks-overview">https://learn.microsoft.com/ko-kr/azure/virtual-machines/managed-disks-overview</a></p>
</blockquote>
<p>따라서 <strong>Managed DISK를 1개만 사용하더라도, 데이터의 내구성을 충분히 보장</strong> 할 수 있다고 생각하였습니다.
그러면 &#39;DISK 1개만 쓰면 되느냐?&#39; 그건 아니었고, 아래 상황도 고려했습니다.</p>
<h2 id="22-재해-복구로-인한-데이터-유실">2.2 재해 복구로 인한 데이터 유실</h2>
<p>하지만 DISK 고장이 아닌, <strong>정전, 화재로 인한 DISK 데이터 손실</strong>은 Manged DISK 만으로 <strong>내구성을 보장할 수 없습니다.</strong> 실제 1년간 재해 확률은 약 0.1% ~ 1%라고 합니다. (사실 재해확률은 예측할 수가 없습니다) 화재로 인한 데이터 손실이나, 정전으로 인해 서비스가 내려가면 큰일이기에 DISK를 1개 더 두어, 안전하게 99.99% ~ 99.9999% 를 갖는 내구성 서비스를 구축하기로 하였습니다.</p>
<p>여타 다른 서비스처럼 3개를 둔다면 좋겠지만, <strong>클라우드 서비스 대여 비용 문제</strong>와 AZ가 <strong>재해로 인해 데이터가 모두 소실되는 경우는 극히 드물</strong>다고 판단하여 <strong>2개로 설계를 진행</strong>하였습니다.</p>
<hr>
<h1 id="3-아키텍처-구조-설계">3. 아키텍처 구조 설계</h1>
<h2 id="31-구조-설계">3.1. 구조 설계</h2>
<h3 id="311-초기-설계">3.1.1 초기 설계</h3>
<p>처음에는 1개의 인스턴스 위에 <code>Node server</code>를 띄우고, 각 DISK에 연결하는 방식으로 구상 하였습니다. (상황에 따라, 서버를 1개를 띄울 수도 있고 여러개를 띄울 수도 있을 것 같습니다)</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/52952138-7943-4cdb-ad94-9a3429e55b0e/image.png" alt="초기 설계안"></p>
<p>위와 같은 구조를 구상한 가장 큰 이유는 <strong><code>인스턴스 대여 비용</code></strong> 때문입니다. 이를 줄이기 위해 1개의 컴퓨터에서 여러 서버를 사용하여 해결하려고 하였습니다. 하지만 이 방식은 몇가지 문제가 있었습니다.</p>
<ol>
<li><strong>DISK는 인스턴스와 동일한 AZ에 두어야한다는 제약</strong></li>
<li>인스턴스가 예상치 못한 상황으로 종료되는 경우에 ** 서비스 전체가 마비되는 문제**</li>
</ol>
<h3 id="312-수정안">3.1.2 수정안</h3>
<p>따라서 아래와 같이 인스턴스 서버를 1개 더 추가하여 설계하였습니다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/811716cc-70f3-48a9-aa94-67f5e7964a2b/image.png" alt="수정안"></p>
<p>하지만 이렇게 2개를 쓸 경우, 인스턴스 비용이 많이 발생합니다. </p>
<h2 id="32-비용-문제-해결-방안">3.2 비용 문제 해결 방안</h2>
<p>그래서 <strong>최대한 낮은 가격의 인스턴스를 사용</strong>하려고 합니다. 이러한 인스턴스에서 돌리기 위해, Java Spring 처럼 무거운 서버가 아닌, FAST API나 Fastify 처럼 가벼운 프레임워크를 사용하는 방향으로 진행하려고 합니다.</p>
<hr>
<h2 id="4-결론">4. 결론</h2>
<p>이런저런 이야기를 길게 적었지만, 핵심은 <strong>데이터 내구성</strong>입니다. 몇 년 전에 카카오에서 불이 난것도 그렇고, 데이터센터에 화재가 난 것도 그렇고 재해 발생을 고려할 수밖에 없었습니다.</p>
<p>그래서 2개의 DISK를 사용하여 다음 내구성을 보장하도록 설계하였습니다.</p>
<ul>
<li>DISK 데이터 내구성 : 99.999… % (22nines)</li>
<li>재해 발생 내구성 : 99.99% (4nines)</li>
</ul>
<p><code>Azure Managed DISK</code>가 기본적으로 높은 내구성을 제공하지만, 디스크 고장 외에도 인스턴스 장애나 AZ 단위 문제까지 고려해서 판단하였고, 이에 따라 <strong>인스턴스 2개</strong>와 <strong>DISK 2개 구조</strong>로 설계하였습니다. 현재의 비용 제약 안에서 안정성을 최대한 확보하려고 하였습니다.</p>
<h3 id="여담">여담</h3>
<p>VM이 약 <code>$30/월</code> 이고, SSD DISK가 <code>$6/월</code> 이어서, 월에 36 * 2 = $72 정도 들어갈 듯 합니다. 생각보다 너무 비싸서, 라즈베리 파이를 사용하는 것도 고려해보아야겠습니다. (사실 이미 있지만, 네트워크 대역폭도 문제고 VPC도 안되서 고민입니다.)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next 캐시를 활용해 불필요한 DB 요청을 줄이기]]></title>
            <link>https://velog.io/@standard-chan/Next%EC%97%90%EC%84%9C-%EC%BA%90%EC%8B%9C%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%B4-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-DB-%EC%9A%94%EC%B2%AD%EC%9D%84-%EC%A4%84%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/Next%EC%97%90%EC%84%9C-%EC%BA%90%EC%8B%9C%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%B4-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-DB-%EC%9A%94%EC%B2%AD%EC%9D%84-%EC%A4%84%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Sun, 18 Jan 2026 03:01:26 GMT</pubDate>
            <description><![CDATA[<h1 id="1-구조-개선-고민을-하게된-계기"><strong>1. 구조 개선 고민을 하게된 계기</strong></h1>
<p>현재 서비스는 <strong>사용자가 화면에서 조건을 바꿀 때</strong>마다, <strong>서버가 같은 정보를 다시 가져오는 방식</strong>으로 동작하고 있습니다.</p>
<p>기능적으로는 문제가 없지만, 불필요한 반복 작업이 발생할 수 있습니다. </p>
<p><strong>이로 인해 다음과 같은 의문이 생겼습니다.</strong></p>
<blockquote>
<p>“사용자가 많아지면 이 구조가 그대로 버틸 수 있을까?”</p>
</blockquote>
<p>이 의문을 시작으로, 자주 바뀌지 않는 데이터를 Next 서버에 보관해두고 그 안에서 처리하는 구조를 생각해보았습니다.</p>
<p><em><strong>이 글은 그 과정에서 해당 기술의 필요성을 검토하고, 현재 서비스에 가장 적합한 방식을 어떻게 판단했는지를 정리한 기록입니다.</strong></em></p>
<hr>
<h1 id="2-현재-데이터-로딩-방식">2. 현재 데이터 로딩 방식</h1>
<h2 id="21-어떤-상황에서-필터링이-발생하는가">2.1 어떤 상황에서 필터링이 발생하는가?</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/98960283-562a-4e76-b8b7-47c5ce289fb8/image.png" alt="퀴즈 필터링 페이지"></p>
<p>현재 서비스의 UI입니다. 사용자는 <strong>난이도와 분야를 선택하여 퀴즈를 필터링</strong>할 수 있습니다. </p>
<p>아무래도 첫 페이지이다보니, 사용자가 이것저것 간편하게 눌러볼 수 있으며 이에 따라 서비스 전체에서 가장 많이 발생하는 <strong>요청/응답</strong>이라고 생각이 듭니다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/12630ca2-1967-4d72-83ec-3db5a7e30edb/image.png" alt="조회 로그"></p>
<p>실제로 서버 로그를 확인해 보면, <strong><code>GET /api/quizzes</code></strong> 가 유독 많은 것을 볼 수 있는데요, 사용자에게 전달되는 정보의 대부분이 비슷하거나 필터값만 바뀐 동일한 내용이었습니다.</p>
<h2 id="22-필터링-버튼을-클릭-할때마다-어떻게-동작하는가">2.2 필터링 버튼을 클릭 할때마다 어떻게 동작하는가?</h2>
<p>현재 방식은 Next의 <strong>CSR + SSR 방식으로 구현되어 있어</strong>, Next에서 데이터를 직접 요청하는 구조를 갖고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d5a1b99b-9736-4b12-b0dc-f91ae36cc676/image.jpg" alt="SSR 기존 방식"></p>
<p><strong>첫 페이지만 Next에서 받고, 이후의 필터링 시에는 SSR로 처리가 됩니다.</strong></p>
<p>전반적인 흐름을 아래와 같습니다.</p>
<pre><code class="language-tsx">1. 사용자가 페이지에 접근하거나 필터 조건을 변경합니다.
2. 클라이언트는 Next 서버에 요청을 보냅니다.
3. Next 서버는 API 서버에 퀴즈 데이터를 요청합니다.
4. API 서버는 DB에서 조건에 맞는 퀴즈 데이터를 조회합니다.
5. 조회된 데이터를 Next 서버로 반환합니다.
6. SSR 결과를 클라이언트에 전달합니다.</code></pre>
<hr>
<h1 id="3-이-구조에서-불편함을-느낀-지점">3. 이 구조에서 불편함을 느낀 지점</h1>
<p>사용자가 필터링 조회를 할 때마다, DB 서버로 요청을 보내는 것이 불필요하다고 생각하였습니다.</p>
<h2 id="31-문제점--지나치게-많이-반복되는-요청">3.1 문제점 : 지나치게 많이 반복되는 요청</h2>
<p>우선 <strong>필터링이 발생할 때마다 반복되는 서버 요청</strong>이 발생합니다.</p>
<p>사용자가 난이도나 분야를 바꿀 때마다, 클라이언트 → Next 서버 → API 서버 → DB로 이어지는 <strong>동일한 요청이 계속 발생</strong>하고 있습니다. 그리고 이는 API와 DB 서버에 부하를 줄 수 있습니다.</p>
<p>요청/응답 자체가 빠르게 많은 비용이 들어가는 연산은 아니지만, 서비스에서 가장 많이 발생하는 요청이므로 이를 줄여야 겠다고 생각하였습니다.</p>
<h2 id="32-문제점--필요하지-않은-실시간성">3.2 문제점 : 필요하지 않은 실시간성</h2>
<p><strong>현재</strong> 구조는 <strong><code>실시간</code>이라는 장점</strong>을 가지고 있습니다. 하지만, 이로 인해 <strong>갱신이 필요 없는 데이터도 다시 요청하는 단점</strong> 또한 함께 가지고 있습니다.</p>
<p><strong>퀴즈 데이터는 자주 변경되지 않고, 실시간이 크게 중요하지 않은 데이터</strong>이기 때문에 갱신 요청을 자주 할 필요가 없습니다.
즉, <strong><code>불필요한 장점</code></strong>입니다.</p>
<hr>
<h1 id="4-아이디어">4. 아이디어</h1>
<blockquote>
<p>현재 문제는 “<strong>필터링을 할 때마다 동일한 데이터를 다시 요청하고 있다는 구조</strong>”입니다.</p>
</blockquote>
<p>그렇다면 <strong>그 동일한 데이터를 다시 요청하지 않고, 서버에 캐시로 저장하는 것이 어떨까요?</strong></p>
<h2 id="41-캐싱-저장-장소-고민">4.1 캐싱 저장 장소 고민</h2>
<p>우선 캐싱을 저장할 장소를 고민하였습니다. 
현재 API 서버, Next 서버를 사용하고 있었고 (조금 더 고려한다면 Redis까지), 그 중에 <strong><code>Next 서버</code>를 캐싱 장소로 선택</strong>하였습니다. 그 이유는 필터링 시 마다 발생하던 API 요청 뿐만 아니라, 초기 SSR 페이지를 만드는 과정에서도 필요한 퀴즈 데이터 요청까지 생략할 수 있기 때문입니다.</p>
<h2 id="42-개선한-구조">4.2 개선한 구조</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/6ed48e61-a02e-4b20-bcdc-719afc7641d8/image.png" alt="개선한 구조 흐름"></p>
<p>개선한 구조의 흐름은 위와 같습니다.</p>
<ol>
<li>현재 DB에 존재하는 모든 퀴즈 데이터를 Next 서버에 캐시로 올립니다.</li>
<li>사용자가 요청을 하게 될 때, API 서버에 접속하는 일 없이, Next 서버에서 퀴즈를 필터링합니다.</li>
<li>이후 컴포넌트를 만들어 Client에게 전달해줍니다.</li>
</ol>
<hr>
<h1 id="5-바로-도입하지-않고-먼저-따져본-것들">5. 바로 도입하지 않고, 먼저 따져본 것들</h1>
<blockquote>
<p>해당 구현을 바로 적용하기보다, 캐시를 <strong>도입하였을 때 발생할 문제점</strong>에 대해 먼저 검토해보았습니다.</p>
</blockquote>
<ol>
<li>퀴즈 데이터를 캐시에 저장하기에 충분히 작은 크기일까?
추후 퀴즈 데이터를 많이 추가하더라도 Next 서버에 부담이 되지는 않을지</li>
<li>퀴즈 데이터가 실시간으로 추가되었을 때, 이를 즉시 반영할 필요가 없을까?</li>
<li>Next 서버에서 필터링 로직을 수행하는 것이 서버에 부하를 주지 않을까?</li>
<li>Next 서버에서 컴포넌트를 만드는 것이 부하를 주지 않을까?</li>
</ol>
<h2 id="51-캐시에-저장되기에-충분히-작은-크기인가">5.1 캐시에 저장되기에 충분히 작은 크기인가?</h2>
<p>퀴즈 데이터가 정말 캐시에 올려도 될 만큼 작은지, 추후 데이터가 늘어났을 때 문제가 되지는 않는지 확인이 필요했습니다.</p>
<p>아래는 저장할 퀴즈 데이터의 정보입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/f9cc8ab0-23bb-4cf7-be9c-0b48714aff2e/image.png" alt="퀴즈 데이터 ERD"></p>
<p>DTO 객체 형태로 캐시에 저장한다고 하면, 개당 수백 바이트 수준이며, 수천 개여도 1MB 미만입니다.</p>
<p>따라서 추후에 개수를 확장하더라도, 크게 부담이 될 크기는 아니라고 판단했습니다.</p>
<h2 id="52-캐시-갱신-시점">5.2 캐시 갱신 시점</h2>
<p>다음으로 고민한 부분은 <strong>“캐시를 언제, 어떻게 갱신할 것인가”</strong>였습니다.</p>
<p>캐시를 사용한다는 것은 곧 <strong>일정 시점의 데이터만 사용자에게 제공</strong>한다는 의미이므로, 실시간성이 중요한 경우에는 적합하지 않을 수 있습니다. </p>
<h3 id="퀴즈-데이터는-실시간이어야하는가">퀴즈 데이터는 실시간이어야하는가?</h3>
<p>퀴즈 데이터는 관리자가 여러 개의 퀴즈를 <strong>batch 형태로 추가</strong>하는 구조입니다. 또한 <strong>퀴즈가 추가되는 빈도도 높지 않으며</strong>, 새로운 퀴즈가 등록되더라도 사용자가 <strong>즉시 확인해야 할 필요도 크지 않습니다.</strong></p>
<p>즉, 퀴즈 데이터는 다음과 같은 특성을 갖습니다.</p>
<ul>
<li>자주 변경되지 않으며</li>
<li>실시간성이 크게 요구되지 않고</li>
<li>지연을 허용할 수 있는 데이터입니다.</li>
</ul>
<p>따라서 주기적으로 데이터를 저장하여 캐싱 값으로 처리하는 것이 적합하다고 생각했습니다.</p>
<h2 id="53-필터링-로직이-next-서버에-부하를-주는지-여부">5.3 필터링 로직이 Next 서버에 부하를 주는지 여부</h2>
<p>마지막으로, <strong>필터링 로직을 Next 서버에서 처리하는 것이 과연 부담이 되는지</strong>를 검토했습니다.</p>
<p><strong>현재 퀴즈의 데이터는 100개</strong>입니다. 추후 퀴즈 추가로 몇천개가 된다고 하더라도 <strong>필터링 로직은 O(n) 시간 복잡도</strong>로 수행할 수 있으므로 서버에 큰 부담을 주지 않다고 판단하였습니다.</p>
<p>이 정도 규모라면, 필터링 비용은 Next 서버 입장에서 부담이 없는 수준이라고 판단하였습니다.</p>
<h2 id="54-컴포넌트-생성-비용">5.4 컴포넌트 생성 비용</h2>
<p>컴포넌트를 Next 서버에서 일일이 생성하는데 비용이 들어갈 수도 있습니다. 특히, 한번에 많은 생성 요청이 몰렸을 때에는 해당 부분이 병목이 될 수도 있을 것 같습니다.</p>
<p>그래서 퀴즈 데이터의 수에 따른 테스트를 진행해보았습니다.</p>
<p>아래는 퀴즈 데이터가 1000개일 때, SSR 처리 속도입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/7b52612b-73b3-4a8c-8aff-82a85c2630ad/image.png" alt="컴포넌트 생성 비용 - 이전"></p>
<p><code>Fetch Duration</code>은 데이터를 가져오는데 걸리는 시간입니다. 해당 시간을 제외한 2ms 정도가 컴포넌트를 생성하는데 소요된 시간입니다. </p>
<p>즉, 컴포넌트 생성 속도 면에서 큰 문제가 없는 것을  확인할 수 있습니다. <strong>따라서 컴포넌트 생성 비용이 크지 않다고 판단</strong>하였습니다.</p>
<h2 id="55-결론">5.5 결론</h2>
<p>Next의 캐시로 옮기는 것이, 단점보다 <strong>장점이 더 많다고 판단하여 옮기게 되었습니다.</strong></p>
<hr>
<h1 id="6-개선한-구조">6. 개선한 구조</h1>
<h2 id="61-개선-구조-비교">6.1 개선 구조 비교</h2>
<p>이번 구조 개선에서는 <strong>퀴즈 데이터를 어디에서 관리하고, 어디에서 필터링할 것인가</strong>를 변경했습니다.</p>
<p><strong>기존 구조</strong>에서는 사용자의 <strong>모든 필터링 요청이 API 서버와 DB까지 전달</strong>되었습니다.
반면 <strong>개선된 구조</strong>에서는 <strong>퀴즈 데이터를 Next 서버에서 캐시로 관리</strong>하고, 필터링 로직을 서버 내부에서 처리하도록 변경했습니다.</p>
<h3 id="변경-전-구조">변경 전 구조</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d5a1b99b-9736-4b12-b0dc-f91ae36cc676/image.jpg" alt="SSR 기존 방식"></p>
<ul>
<li>사용자가 필터를 변경할 때마다 요청이 발생합니다.</li>
<li>Next 서버 → API 서버 → DB로 요청이 전달됩니다.</li>
<li>DB에서 필터링된 결과를 반환합니다.</li>
<li>동일한 데이터에 대해 반복적인 조회가 발생합니다.</li>
</ul>
<h3 id="변경-후-구조">변경 후 구조</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/360ac5eb-f2ef-4fbb-9881-5c51d065330b/image.png" alt="변경 후 구조"></p>
<ul>
<li>최초 요청 시, API 서버에서 전체 퀴즈 데이터를 한 번 조회합니다.</li>
<li>Next 서버에 퀴즈 데이터를 캐시로 저장합니다.</li>
<li>이후 필터링 요청은 <strong>캐시된 데이터를 기준으로 처리</strong>합니다.</li>
<li>DB 접근 없이 필터링된 결과를 반환합니다.</li>
</ul>
<h2 id="62-개선-결과---성능-비교">6.2 개선 결과 - 성능 비교</h2>
<p>퀴즈 데이터 1000개일 때를 기준으로 측정한 속도 측정 결과입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/825b4b77-8ca1-4e45-8842-665dd4c6b23e/image.png" alt="퀴즈 데이터 이전 SSR 속도"></p>
<p>위는 이전 방식에서 초기 페이지 SSR 속도입니다. 총 113ms가 소요된 것을 볼 수 있습니다.</p>
<p>아래는 캐시를 사용하는 경우의 SSR 생성 속도입니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/9111bf48-f522-4bea-9ae9-aa5d40b83ffd/image.png" alt="개선 이후 SSR 속도"></p>
<p><code>Logic Duration</code>은 필터링 처리를 하는데 소요되는 시간이고 약 7ms가 소요되는 것을 확인할 수 있습니다.</p>
<h2 id="63-정리">6.3 정리</h2>
<p>이번 구조 개선에서는 API 및 DB 서버의 부하를 줄이기 위해서, <strong>퀴즈 데이터를 Next에서 관리하고, 필터링</strong>하도록 변경하였습니다. 이를 통해 다음 결과를 얻을 수 있었습니다.</p>
<ul>
<li>API 서버와 DB에 대한 불필요한 요청을 줄였으며</li>
<li>필터링 응답 속도를 개선했습니다.</li>
</ul>
<hr>
<h1 id="7-얻은-인사이트">7. 얻은 인사이트</h1>
<p>이번 개선을 통해 가장 크게 느낀 점은 <strong>장점이 있으면 항상 단점도 존재한다는 점</strong>입니다.</p>
<p>기존 구조의 <strong>실시간성</strong>이라는 장점을 보면서, 그로 인해 발생하는 <strong>불필요한 요청과 부하</strong>를 인식하게 되었습니다.</p>
<p>기술의 장단점을 단순히 외우는 것이 아니라, <strong>장점에서 출발해 단점을 도출하는 사고 흐름</strong>이 중요하다는 점을 깨달았습니다.</p>
<p>예를 들어 RDB를 사용하면 구조화와 관계 정의가 편리하지만, 반대로 비정형 데이터에는 제약이 생깁니다.
Docker 역시 환경 재현성이라는 장점이 있지만, 저사양 환경에서는 오히려 제약이 됩니다.</p>
<p>이번 캐싱 도입에서도, 실시간성이라는 장점을 포기하는 대신 요청 부하라는 단점을 해결할 수 있었습니다</p>
<hr>
<blockquote>
<h3 id="도입-이후-추가한-내용">도입 이후 추가한 내용</h3>
</blockquote>
<h1 id="8-이후-발생한-한계점과-보완">8. 이후 발생한 한계점과 보완</h1>
<p>이후에 개발을 진행하다가 <strong>새로운 문제 상황</strong>을 마주쳤습니다.
바로 <strong>퀴즈 데이터가 많아짐에 따라서, SSR 방식이 UX를 해칠 수 있다는 사실</strong>입니다.</p>
<h2 id="81-무한-스크롤-도입의-필요성">8.1 무한 스크롤 도입의 필요성</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/9b12813c-333a-4456-a8f5-d0fe305a2a30/image.png" alt="작은 스크롤바"></p>
<p>SSR 방식으로 다수의 퀴즈 데이터를 한번에 전달하면, 위처럼 스크롤바가 정말 작아지게 됩니다. 즉, <strong>하나의 페이지에 1000개의 퀴즈 카드들이 존재하게됩니다.</strong> (스크롤을 언제 다 내릴까요… 정말 무시무시할 것 같습니다)</p>
<p>이 부분이 <strong>사용자의 UX를 크게 해칠 수 있다</strong>고 생각을 하게 되어, <strong>무한스크롤</strong>을 도입해야겠다고 생각했습니다.</p>
<h3 id="811-왜-기존에는-이-부분을-고려하지-않았나">8.1.1 왜 기존에는 이 부분을 고려하지 않았나?</h3>
<p>수백 개의 퀴즈를 한번에 <strong>렌더링</strong> 하는 경우 페이지가 어떻게 보여질지를 상상하지 못했던 것 같습니다. 뒤늦게 퀴즈 데이터가 많이 추가된 뒤에, 이 문제를 인지하게 되었습니다. </p>
<p>(서비스를 사용해보며 불편함을 몸소 느끼면서 체감을 많이 했습니다.)</p>
<h2 id="82-무한-스크롤-방식">8.2 무한 스크롤 방식</h2>
<p>무한 스크롤은 크게 2가지가 있습니다. </p>
<ul>
<li><strong>CSR로 데이터를 서버에 요청해서 렌더링하는 방식</strong></li>
<li>새로 추가되는 컴포넌트를 포함하여 <strong>Next 서버에서 다시 컴포넌트를 만들어주는 RSC 방식</strong>이 있습니다.</li>
</ul>
<p>이전 성능 속도의 결과를 보면, 필터 로직처리하고 컴포넌트를 생성하는데에는 시간이 별로 걸리지 않음을 확인할 수 있습니다. 그래서 <strong>Next 서버에서 컴포넌트를 만들어주는 RSC 방식을 선택해서 진행</strong>하였습니다.
정확히는 RSC 프로토콜을 사용하고, 렌더링은 클라이언트에서 처리하도록 구현하였습니다.</p>
<h2 id="83-수정-결과-및-성능">8.3 수정 결과 및 성능</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/ed2fdad9-f97c-4479-949b-06884f811804/image.png" alt="크게 바뀐 스크롤바">
무한 스크롤로 바꾸면서, 스크롤바 문제를 해결하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/07a0ce4d-2bb9-4dc2-ba4d-5c2166eac418/image.png" alt="무한 스크롤 퀴즈 목록"></p>
<p>또한 무한 스크롤을 잘 적용한 모습을 볼 수 있습니다.</p>
<h3 id="831-성능">8.3.1 성능</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/f0ea12a0-2c16-423e-9688-2c65331757ab/image.png" alt="무한스크롤 성능"></p>
<p>추가되는 컴포넌트를 생성하는데에도** 큰 무리가 없는 모습**을 확인할 수 있습니다. 이상할정도로 빠른 속도를 보여주고 있습니다.</p>
<hr>
<h1 id="9-여담---마무리하며">9. 여담 - 마무리하며</h1>
<p>최근에 <strong><code>OpenAI</code></strong>에서 <strong>PostgreSQL 서버 1개</strong>로 모든 처리를 진행했다는 포스팅을 읽었습니다.</p>
<blockquote>
<p><a href="https://openai.com/ko-KR/index/scaling-postgresql/">https://openai.com/ko-KR/index/scaling-postgresql/</a></p>
</blockquote>
<p>요약하자면, PostgreSQL 1개의 서버로 들어오는 모든 워크로드들을 다른 서버로 이전했다는 내용입니다.</p>
<p>여기에서 인상적이었던 내용은, <code>JOIN 로직</code>을 DB에서 처리하는게 아니라, <strong>Application으로 옮겨서 처리했다는 점</strong>이었습니다. 이 내용이 이번에 구현했던 <code>Filter 및 페이지네이션 로직</code>을 DB에서 하는 것이 아니라, <strong>Next 서버에서 하는 것과 무척이나 닮아</strong>있었기 때문에 특히 인상적이었습니다.</p>
<p>위 작업을 하면서, &#39;<strong>내가 DB에서 하면 편할 일을, 내가 불필요하게 Next로 옮긴게 아닐까?</strong>&#39; 라는 생각을 많이 했었는데, <strong>OpenAI 블로그를 보면서 DB작업이든, Application 작업이든 상황에 따라서 언제든 달라질 수 있다는 것도 확신할 수 있게 되었던 경험</strong>이었습니다.</p>
<p>물론 그 과정에서 미리 고려하지 못했던 부분들도 있었습니다. 앞으로는 최대한 고려해볼 생각이지만 <strong>미처 고려하지 못하더라도 지금처럼 돌파구가 있다고 생각하고, 문제를 해결해나갈 생각</strong>입니다. </p>
<p>그리고 생각보다 이런 문제를 해결하는 과정이 재미있다고 생각하기에, 미처 고려하지 못해서 마주친 문제들도 재미있게 풀어나가고 싶다는 생각이 듭니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Slack] GitHub PR 자동 커스텀 알림 보내기]]></title>
            <link>https://velog.io/@standard-chan/Slack-GitHub-PR-%EC%9E%90%EB%8F%99-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/Slack-GitHub-PR-%EC%9E%90%EB%8F%99-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Sat, 06 Dec 2025 14:31:54 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Slack에서 GitHub PR에 대한 알림을 팀원에게 자동으로 공유하기 위한 설정 방법</p>
</blockquote>
<h2 id="문제-상황">문제 상황</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/4c290b47-385f-4460-a022-b73f31cf96d8/image.png" alt="문제 상황"></p>
<p>협업하는 과정 중에 PR을 올리면, 코드리뷰와 승인 요청을 위해 slack에서 해당 PR을 공유하고 있습니다. 하지만 <strong>PR이 생길때마다 매번 이를 직접 공유</strong>하려고 하니, 번거로운 문제가 아닐 수 없었습니다. 저는 이를 자동화해서 귀찮음을 줄이고 싶었습니다.</p>
<h3 id="자동화-목표">자동화 목표</h3>
<blockquote>
<p><strong>PR을 올릴 때마다, Slack에 PR 메시지 자동 전송시키기!</strong></p>
</blockquote>
<h1 id="1-slack-github-bot으로-진행하기">1. Slack Github bot으로 진행하기</h1>
<p>Slack에서 제공하는 Github bot 이라는게 있습니다. 기본적으로 제공하는 기능은 간단하게 사용할 수 있을 것 같아서  이걸을 사용해보기로 하였어요.</p>
<h3 id="1-slakc에-github-설치하기">1. slakc에 Github 설치하기</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/b5e9b1be-7937-4f0b-ac8a-9a7a53ae2e60/image.png" alt="순서1"></p>
<p>좌측에 <code>더보기 &gt; 도구</code> 에 들어갑니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/576a5a6c-7f59-49b8-8756-a78f31097adb/image.png" alt="순서2"></p>
<p> 좌측 도구&gt;앱 에서 GitHub App을 클릭하여 설치를 진행합니다.</p>
<h3 id="2-인증">2. 인증</h3>
<p>현재 Slack과 자신의 GitHub 연동을 위해 인증 절차를 거쳐야해요.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d64a64d3-c8c2-4c65-86e5-4e2dd4db776b/image.png" alt="인증1"></p>
<p><code>Connect GitHub account</code>를 진행하게 되면, 인증코드를 받을 수 있어요. 이후 <code>Enter verification code</code> 를 눌러 해당 인증코드로 인증을 진행해요</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/813a7140-f039-4145-bf4b-39dcb1cee955/image.png" alt="인증2"></p>
<p>그러면 Slack에 해당 github 계정 등록이 완료돼요.</p>
<p><code>/github help</code> 를 사용하면 사용 가능한 명령어를 볼 수 있어요.</p>
<h3 id="3-github-repo-구독">3. github Repo 구독</h3>
<p>다음 명령어를 사용하면 github의 해당 repo를 구독할 수 있어요. 구독을 하면 issue, PR 등의 알림이 자동으로 slack에 전송돼요.</p>
<pre><code class="language-bash">/github subscribe [repo주소]</code></pre>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/2fc7b8e8-9b06-46cb-a21c-01fd210f3e4e/image.png" alt="구독1"></p>
<p>하지만 기본적으로 제공하는 Slack 봇을 사용하려면, <strong>해당 Repo owner의 승인이 필요</strong>하더군요...</p>
<p><strong>제가 해당 권한을 가지고 있지 않고, 승인을 받기 어려울 것 같아서 다른 방법을 모색</strong>해보았습니다.</p>
<hr>
<h1 id="2-github-action--slack-bot-으로-자동화하기">2. GitHub Action &amp; Slack Bot 으로 자동화하기</h1>
<p>구글에 검색을 해보니, <code>Github action</code>을 이용해서 <code>Slack bot</code>과 연동시킨 후 자동화할 수 있는 것 같더군요. 그래서 <strong>GitHub bot을 사용하는게 아닌 새로운 Slack bot을 만들어서 진행</strong>할 생각입니다.</p>
<h2 id="31-bot-만들기">3.1 Bot 만들기</h2>
<p><a href="https://api.slack.com/apps">https://api.slack.com/apps</a> 링크에 들어갑니다</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/c4914d5d-94cd-456d-9dc1-eb93e4f8e082/image.png" alt="bot생성1"></p>
<p>우측에 있는 Create New App을 클릭합니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/a5857c7a-d82a-467d-b37d-4abbae50cd5a/image.png" alt="bot생성2"></p>
<p><code>From Scratch</code>를 선택합니다.</p>
<p>이후 App 이름과 사용할 Slack 채널을 선택해서 Bot을 생성합니다.</p>
<p>잘 생성되면 아래 페이지로 <strong>redirect</strong> 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/176d6b92-7360-4c05-b3e5-ad33ee51d85f/image.png" alt="bot생성3"></p>
<h2 id="32-webhook-등록하기">3.2 Webhook 등록하기</h2>
<blockquote>
<p><strong><code>webHook</code></strong>이란, <strong>특정 이벤트가 발생했을 때, 서버가 다른 서버나 서비스로 데이터를 실시간으로 자동 전송하는 방식</strong>을 의미합니다. </p>
</blockquote>
<p>다시말해서, <strong>데이터가 변경되었을 때 실시간으로 알림을 전송하는 것</strong>이라고 생각하면 됩니다!</p>
<p>위 페이지에서 좌측에 있는 <code>Incoming Webooks</code>로 들어갑니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/d85234ae-c03b-4b6a-a5d9-4dafee5e1cff/image.png" alt="webhook등록1"></p>
<p>기본값으로 <code>OFF</code> 로 지정 되어있는데, <strong>이를 <code>ON</code> 으로 변경</strong>합니다.</p>
<p>그러면 아래에 페이지가 추가로 생성됩니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/094c88f2-aa4f-4ca6-9fc8-6e76bfaee0ba/image.png" alt="webhook등록2"></p>
<p>저희는 Webhook을 등록해야하니까, <code>Add New Webhook</code>을 클릭합니다.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/25c9ae27-faaa-41e3-b861-d49e74e0fb15/image.png" alt="webhook등록3"></p>
<p>여기에서 사용할 workspce와 채널을 지정해주고, 허용을 눌러줍니다.</p>
<h3 id="등록-완료">등록 완료</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/c1eab2e2-2db0-4cf8-a55a-49262c322484/image.png" alt="webhook등록4"></p>
<p>위와 같은 메시지가 뜨면서 등록이 완료됩니다!</p>
<h3 id="등록-확인하기">등록 확인하기</h3>
<p>curl 을 터미널이나 bash로 보내봅니다</p>
<pre><code class="language-bash">curl -X POST -H &#39;Content-type: application/json&#39; --data &#39;{&quot;text&quot;:&quot;Hello, World!&quot;}&#39; https://hooks.slack.com/services/주소</code></pre>
<p>슬랙에 아래와 같이 입력이 된다면 등록이 성공한 것입니다!</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/a784014a-1f4e-4618-8a8a-d2cb2365a937/image.png" alt="webhook등록5"></p>
<h2 id="pr-메시지-자동화-하기">PR 메시지 자동화 하기</h2>
<p><strong><code>webhooks</code></strong>을 등록하였으니, Github Action으로 PR이 올라오는 순간을 감지해서 Slack에 메시지를 보내주면 됩니다!</p>
<h3 id="github-action에-등록하기">Github Action에 등록하기</h3>
<p>PR이 등록되면 Slack에 메시지를 보내야해요. 따라서 PR 등록 시점을 인지할 수 있는 <code>Github Action</code>을 사용해요.</p>
<p>아래는 yml 파일이에요.</p>
<pre><code class="language-bash"># PR 등록시, slack에 자동 알림
name: Notify PR to Slack

on:
  pull_request:
    types: [opened]   # PR 생성될 때만 실행

jobs:
  notify:
    runs-on: ubuntu-latest

    steps:
      - name: Send PR info to Slack
        run: |
          PR_TITLE=&quot;${{ github.event.pull_request.title }}&quot;
          PR_URL=&quot;${{ github.event.pull_request.html_url }}&quot;
          PR_CREATOR=&quot;${{ github.event.pull_request.user.login }}&quot;

          curl -X POST \
            -H &#39;Content-type: application/json&#39; \
            --data &quot;{\&quot;text\&quot;:\&quot;${PR_CREATOR}님의 PR이 생성되었습니다.\n제목: ${PR_TITLE}\n링크: ${PR_URL}\&quot;}&quot; \
            https://hooks.slack.com/services/주소</code></pre>
<p>yml 작성시에 사용되는 pull_request 변수들은 다음 문서를 참고하면 쉽게 커스텀 할 수 있어요.</p>
<blockquote>
<p><a href="https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request">https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request</a></p>
</blockquote>
<p>이제 위의 yml을 <code>main branch</code>에 등록시키면, 앞으로 생성되는 모든 PR에 대해서 Slack에 알림이 오게 돼요.</p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/619c3a6b-51eb-4a57-973b-f07ef901c712/image.png" alt="결과 확인"></p>
<hr>
<h2 id="느낀점">느낀점</h2>
<p>최근 협업을 진행하면서 <strong>자동화에 많은 관심이 생겼습니다</strong>.</p>
<p>회의하랴, 코드 구현하랴… 안그래도 시간이 부족한데 이런 <strong>번거로운 일들까지 하려고 하니 잡다한 시간들이 낭비되는 것</strong> 같더라구요. 많이 귀찮기도 하고…</p>
<p>사실 전까지는 그냥 불편함을 감수하고 사용하면서 살았는데, <strong>최근에 자동화에 꽂히게 되어서</strong> 일상이나 협업에서 <strong>불편했던 부분들을 하나씩 자동화 시켜나가려고 해요</strong>. </p>
<p>또 최근에 크게 불편함을 느꼈던 부분이 문서화 작업이에요. (귀찮음의 끝판왕이라고 생각합니다ㅎㅎ) 문서화 작업은 AI를 사용해서 자동화를 하여 시간을 많이 단축시키고 싶다는 생각을 전부터 했었는데, 다음번에는 문서화 작업을 AI를 활용하여 자동화를 해보아야겠습니다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[HTTP 0.9에서 1.0으로의 발전]]></title>
            <link>https://velog.io/@standard-chan/HTTP-0.9%EC%97%90%EC%84%9C-1.0%EC%9C%BC%EB%A1%9C%EC%9D%98-%EB%B0%9C%EC%A0%84</link>
            <guid>https://velog.io/@standard-chan/HTTP-0.9%EC%97%90%EC%84%9C-1.0%EC%9C%BC%EB%A1%9C%EC%9D%98-%EB%B0%9C%EC%A0%84</guid>
            <pubDate>Sun, 21 Sep 2025 01:48:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/standard-chan/post/2fcbb8af-279c-41d3-9d5c-db08d54f821b/image.png" alt="리얼월드 HTTP"></p>
<blockquote>
<p>리얼월드 HTTP 를 학습하고 정리한 글입니다.</p>
</blockquote>
<h1 id="http-10-syntax">HTTP 1.0 Syntax</h1>
<p>초창기 HTTP의 발전 과정에서 중요한 것은 다음 4가지이다.</p>
<ul>
<li>메서드와 경로</li>
<li>헤더</li>
<li>바디</li>
<li>status code</li>
</ul>
<p><code>HTTP 0.9 -&gt; 1.0 -&gt; 1.1</code> 의 과정은 위의 차이에서 기인하니, 이를 중심으로 발전을 이해해보자.</p>
<h2 id="1-초기-http">1. 초기 HTTP</h2>
<h3 id="11-http-의-시작">1.1 HTTP 의 시작</h3>
<p>유럽입자 물리학 연구소 (CERN)의 팀 버너스리가 최초의 웹 서버를 구현(1990)하였다.</p>
<p>이를 바탕으로 발전 시킨 것이 <code>HTTP/0.9</code> 이다.</p>
<h3 id="12-http09">1.2. HTTP/0.9</h3>
<p><strong>HTTP/0.9의 목적</strong></p>
<ul>
<li>“브라우저가 문서를 요청하면, 서버가 그에 맞는 문서를 반환”</li>
<li>그래서 추가적인 정보 없이, 데이터 그 자체만을 전달</li>
</ul>
<p><strong>HTTP/0.9 구조</strong></p>
<ul>
<li>요청 : header 없이, URL만을 이용하여 GET 요청을 보낸다.</li>
<li>응답 : header 없이, 문서 데이터만 반환해준다.</li>
<li>검색 기능 또한 제공하였는데, 이때에는 BODY가 없었으므로, URL에 Param으로 필요한 데이터를 같이 전달하는 방식으로 하였다.</li>
</ul>
<p><strong>한계</strong> </p>
<ul>
<li>하나의 문서만 응답으로 전달 가능하다</li>
<li>모든 문서를 HTML로 취급하여, 이미지 등의 파일 정보를 서버에서 전달하기 어렵다</li>
<li>클라이언트 측에서 검색 이외의 정보 요청이 불가능하다</li>
<li>서버의 응답이 성공/실패 하였는지 확인 할 수 없었다.</li>
</ul>
<hr>
<h2 id="2-http10">2. HTTP/1.0</h2>
<h3 id="21-http10">2.1 HTTP/1.0</h3>
<p>HTTP/1.0 에서는 HTTP/0.9와 달리 새롭게 추가된 것들이 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/2f02dc17-14e1-4ff4-9e48-c3d122365710/image.png" alt="curl HTTP1.0"></p>
<p>여러 헤더들 (Host, User-Agent, Accept) 들이 추가된 것이 보입니다.</p>
<ul>
<li><code>&gt;</code> 는 클라이언트 → 서버 전달하는 요청</li>
<li><code>&lt;</code> 는 서버→클라이언트 로 전달받는 응답</li>
</ul>
<p><code>&gt;</code> 를 보게되면 다음이 추가된 것을 볼 수 있습니다.</p>
<ul>
<li>GET / HTTP/1.0</li>
<li>Host</li>
<li>User-Agent</li>
<li>Accept</li>
<li>Content-Type</li>
<li>Date</li>
</ul>
<p>이들을 <code>헤더</code> 라고 하는데, 이를 중심으로 큰 차이를 먼저 확인하겠습니다.</p>
<details>
<summary>
  서버 JS 코드 (실행 시 참고)
</summary>


<pre><code class="language-js">    // echo-server.js
    const http = require(&quot;http&quot;);

    const PORT = 3000;

    const server = http.createServer((req, res) =&gt; {
      let body = [];

      req.on(&quot;data&quot;, (chunk) =&gt; {
        body.push(chunk);
      });

      req.on(&quot;end&quot;, () =&gt; {
        body = Buffer.concat(body).toString();

        // 요청 전체 dump 비슷하게 출력
        console.log(&quot;===== Request Dump =====&quot;);
        console.log(`${req.method} ${req.url} HTTP/${req.httpVersion}`);
        for (const [key, value] of Object.entries(req.headers)) {
          console.log(`${key}: ${value}`);
        }
        console.log(&quot;&quot;); // 헤더 끝
        console.log(body); // body 출력
        console.log(&quot;========================&quot;);

        // 응답: hello! html
        res.writeHead(200, { &quot;Content-Type&quot;: &quot;text/html&quot; });
        res.end(&quot;&lt;html&gt;&lt;body&gt;hello!&lt;/body&gt;&lt;/html&gt;\n&quot;);
      });

      req.on(&quot;error&quot;, (err) =&gt; {
        console.error(&quot;Request Error:&quot;, err);
        res.writeHead(500, { &quot;Content-Type&quot;: &quot;text/plain&quot; });
        res.end(&quot;Internal Server Error&quot;);
      });
    });
    server.listen(PORT, () =&gt; {
      console.log(`✅ Echo server running at http://localhost:${PORT}`);
    });
</code></pre>
</details>


<h3 id="22-http10-헤더">2.2 HTTP/1.0 헤더</h3>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/89b40a51-a6a5-4851-a85c-d32ed26f667d/image.png" alt="헤더"></p>
<p>위의 이미지의 <code>&gt;, &lt;</code> 로 출력되어있는 라인들이 Body외의 HTTP에서 추가적으로 전달되는 정보입니다.</p>
<p>HTTP/0.9 에서 어떤 파일을 전송하는지, 목적지가 어디인지 등을 같이 전달하기 위해서 헤더를 추가하였는데요,
자주 사용되는 헤더에 대해 알아보겠습니다.</p>
<p><strong>요청 헤더 (client → server)</strong></p>
<ul>
<li>User-Agent : 클라이언트가 자신의 App 이름을 넣는 곳</li>
<li>Referer : 서버에서 참고하는 추가 정보 (클라이언트가 요청을 보낸 page url)</li>
<li>Authorization : 인증 정보</li>
</ul>
<p><strong>응답 헤더 (server → client)</strong></p>
<ul>
<li>Content-Type : 파일 종류 지정 (MIME 문자열로 전달)</li>
<li>Content-Length : Body 크기 지정 (압축된 경우 압축 후의 길이)</li>
<li>Content-Encoding : 압축 형식</li>
<li>Date : 문서 날짜</li>
<li><code>X-</code> 로 시작하는 헤더 : 사용자가 자유롭게 붙여서 사용할 수 있는 헤더</li>
</ul>
<h3 id="23-content-type과-보안">2.3 Content-Type과 보안</h3>
<p>Content-Type은 브라우저에서 받은 파일을 렌더링할때 중요한 역할을 합니다. 만약에 image인데, test/plain으로 전달을 해버리면 브라우저는 text라고 판단하여, 이미지를 text로 변환한 값을 화면에 그립니다.</p>
<img src="https://velog.velcdn.com/images/standard-chan/post/e9c681b7-913f-4c35-9d93-310da1c218ed/image.png" width="30%" height="30%">

<p>저희가 예전에 사용하던 인터넷 익스플로러는 조금 특이하게도 <code>Content-Type</code> 의 MIME 타입을 확인하지 않았습니다. 위와 같은 문제가 발생할 수도 있다고 생각하여 전달되는 내용을 보고 파일 형식을 추측했어요. 이를 <strong>Content sniffing</strong> 이라고 합니다.</p>
<p>하지만 위 방식의 단점은 직접 파일을 추측해야한다는 것과 이 과정에서 잘못된 추측이 발생할 수 있다는 것이었습니다. <code>text/plain</code> 의 HTML + js 인데, 이를 멋대로 해석해서 <code>HTML/JS</code> 로 판단하는 예시가 있겠네요.</p>
<h3 id="24-전자메일---header-body">2.4 전자메일 - header, Body</h3>
<blockquote>
<p>Header, Body의 시초</p>
</blockquote>
<p>HTTP/1.0 이전에 <strong>전자메일</strong>을 네트워크 상으로 전달하곤 했었습니다. 전자메일은 수신자와 같은 <strong>관련 정보</strong>와 메일 내용을 담는 <strong>body</strong>로 구성되어 전달되어있었습니다. 이에 착안하여 HTTP에 전자메일과 비슷한 <strong>Header</strong>와 <strong>Body</strong>를 추가하게 되었습니다.</p>
<pre><code class="language-text">// 전자메일
From: alice@example.com
To: bob@example.com
Subject: Meeting Tomorrow
Date: Sun, 21 Sep 2025 10:00:00 +0900
Content-Type: text/plain; charset=&quot;utf-8&quot;

Hi Bob,
Let&#39;s meet tomorrow at 2 PM.
Thanks,
Alice</code></pre>
<pre><code class="language-text">// HTTP 요청
POST /login HTTP/1.0
Host: www.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 32

username=seokchan&amp;password=1234</code></pre>
<p>전자메일의 From:, To: 등의 값들을 헤더로 변환하였고, 아래의 개행문자와 함께 분리된 메일 내용을 Body로 변환한 것입니다. 그래서 HTTP와 전자메일의 구조자체는 비슷해요. </p>
<p>다만 <strong>2가지 큰 차이</strong>가 있습니다.</p>
<ul>
<li>메서드 + 경로</li>
<li>status code</li>
</ul>
<p>위 2가지 사항을 제외하면 HTTP는 고속으로 전자메일을 왕복시키는 것이라고 표현해도 무방할 정도로 닮아있습다.</p>
<h3 id="25-뉴스-그룹---메서드-status-code">2.5 뉴스 그룹 - 메서드, status code</h3>
<p>HTTP의 메서드와 상태 코드는 <code>뉴스 그룹</code> 에서 착안해 온 것입니다. 뉴스 그룹의 메서드로는 LIST, HEAD, BODY, POST 등이 사용되었습니다. LIST는 서버에 있는 모든 뉴스 그룹 목록을 가져오는데, HTTP의 GET과 비슷한 역할을 했습니다. </p>
<p>이에 착안하여 HTTP에 수많은 메서드가 제안되었고, 다음 세 가지가 <code>POST, GET, HEAD</code> 가 흔히 사용되는 메서드로 사용됩니다. 이후에 HTTP 1.1에 가서야 <code>PUT, DELETE</code> 등이 추가로 지원되었습니다.</p>
<p><code>HTML Form</code>으로 사용하는 <code>&lt;Form&gt;</code>은 POST, GET만 지원합니다.</p>
<p><strong>상태 코드</strong></p>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/26c733a1-2aca-4ecc-a969-00ca8c98ec52/image.png" alt=""></p>
<p>여기에서 300번대의 상태코드가 조금 특이한데, 300번대는 서버가 브라우저에게 리다이렉트하도록 지시하는 status 코드를 의미한다.</p>
<h3 id="26-url">2.6 URL</h3>
<p><strong>URL vs URI</strong></p>
<p>URL과 URI는 URI가 URL을 포함하는 관계로 조금 다릅니다. URI = URL+URN(명명 규칙) 인데, 웹에서는 URN을 잘 안써서 URL과 URI가 비슷한 의미를 갖는다고 합니다.</p>
<p>URL 구조는 다음과 같습니다.</p>
<pre><code class="language-js">스키마://호스트명/경로

조금더 구체적으로는 아래와 같다.

스키마://사용자:패스워드@호스트명:포트/경로#fragment?Query
https://jeong:1234@localhost:8080/books#fragment?query=1</code></pre>
<p>각각의 기능을 아래와 같아요.</p>
<ul>
<li><p><strong>스키마</strong> 
http, https, file 등으로 브라우저가 이 스키마를 해석하여 적절한 접속 방법을 선택합니다. 
저희가 <code>Chrome</code>으로 <code>로컬의 pdf</code>를 열면 <a href="file://">file://</a> 경로로 뜨는데요, 이는 크롬이 스키마를 읽고 file 방식으로 해당 파일에 접속했기 때문입니다.</p>
</li>
<li><p><strong>호스트명</strong>
통신 대상이 되는 서버 주소를 의미합니다. 포트가 생략되면 스키마별 기본 포트를 사용해요.</p>
</li>
<li><p>사용자, 패스워드 : 보안 문제 때문에 웹에서는 해당 방식으로 사용하지 않음</p>
</li>
<li><p>프레드먼트 : 앵커 저장</p>
</li>
</ul>
<h3 id="27-body">2.7 Body</h3>
<p><code>HTTP/0.9</code> 에서는 요청 시에, 서버로 데이터를 전달하기 어려웠다. 전달하는 유일한 방법은 URL에 Query를 실어서 전달하는 방법 뿐이었기에 파일을 전달할 수 없었습다.</p>
<p>HTTP/1.0에서는 Body에 데이터를 넣어서 전달할 수 있다. 헤더 끝에 빈 줄을 넣으면 그 아래부터는 Body로 인식한다.</p>
<pre><code class="language-jsx">헤더1: 헤더 값1
헤더2: 헤더 값2
Content_Length: 바디의 길이

지정된 바이트 수많큼의 바디 데이터</code></pre>
<p>curl로 body를 같이 서버에 전송하고 싶으면 <code>-d</code> 를 사용하면 된다.</p>
<pre><code class="language-jsx">$ curl -d &quot;{\&quot;name\&quot;:\&quot;Jeong\&quot;} -H &quot;Content-Type: application/json&quot;
  http://localhost:8080</code></pre>
<h3 id="28-get에-body-실어서-요청하기">2.8 GET에 Body 실어서 요청하기</h3>
<p>사실 GET 요청 시에도 Body에 값을 넣어서 전달할 수 있습니다. 전달하는 데이터 헤더 끝에 빈줄을 넣고 Body를 실어서 전달하면 GET 요청에도 Body를 전달할 수 있습니다.</p>
<pre><code>GET /search?q=hello HTTP/1.0
Host: www.example.com
Content-Type: application/json
Content-Length: 27

{
  &quot;filter&quot;: &quot;recent&quot;,
  &quot;limit&quot;: 10
}</code></pre><p>하지만 <code>RFC 7231(HTTP/1.1) (HTTP 사용 지침)</code>에 따르면 이 방식은 추천하지 않고, 서버에 따라서 GET 요청인 경우 Body를 <strong>무시</strong>하는 경우도 있으니 자제하라고 합니다.</p>
<p>여기에서 말하고 싶은 것은 어떤 HTTP 요청이라도 body 메세지를 추가해서 전달할 수 있다는 것입니다. <strong>HTTP 메서드에 따라서 형식이 달라지는 것은 아니라는 것</strong>입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] JOIN Query에서 INDEX로 성능 최적화하기]]></title>
            <link>https://velog.io/@standard-chan/MySQL-JOIN-Query%EC%97%90%EC%84%9C-INDEX%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@standard-chan/MySQL-JOIN-Query%EC%97%90%EC%84%9C-INDEX%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 01 Jul 2025 15:51:13 GMT</pubDate>
            <description><![CDATA[<p>JOIN Query에서 성능을 최적화 하는 방법을 알아보겠습니다.</p>
<p>Join Query가 MySQL에서 어떻게 동작 하는지를 이해해야 어떻게 INDEX를 만들어야할지를 알 수 있으니 우선 깊게 이해해보자.</p>
<h1 id="테스트-데이터-생성하기">테스트 데이터 생성하기</h1>
<h3 id="테이블-생성">테이블 생성</h3>
<p>관광지와 방문한 장소 테이블을 만들어보자.</p>
<pre><code class="language-sql">-- 관광지 테이블
CREATE TABLE tourist_spot (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    category VARCHAR(100),
    address TEXT,
    lat DOUBLE NOT NULL,
    lng DOUBLE NOT NULL
);

-- 방문한 관광지 
CREATE TABLE visited_place (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tourist_spot_id BIGINT NOT NULL,
    visited_at DATETIME NOT NULL,
    description TEXT,
    photo_url VARCHAR(512),

    CONSTRAINT fk_tourist_spot
        FOREIGN KEY (tourist_spot_id)
        REFERENCES tourist_spot(id)
        ON DELETE SET NULL
);</code></pre>
<p><code>내가 방문한 관광지</code> 와 <code>관광지</code> 가 1:N의 관계로 이루어져 있다.</p>
<p>데이터예시</p>
<p><strong>관광지 데이터</strong></p>
<pre><code class="language-sql">INSERT INTO tourist_spot (name, category, address, lat, lng) VALUES 
(&#39;남산타워&#39;, &#39;전망대&#39;, &#39;서울특별시 용산구 남산공원길 105&#39;, 37.5512, 126.9882),
(&#39;해운대 해수욕장&#39;, &#39;해변&#39;, &#39;부산광역시 해운대구 우동&#39;, 35.1587, 129.1604),
(&#39;경복궁&#39;, &#39;고궁&#39;, &#39;서울특별시 종로구 사직로 161&#39;, 37.5796, 126.9770);</code></pre>
<p><strong>방문한 관광지 데이터</strong></p>
<pre><code class="language-sql">INSERT INTO visited_place (tourist_spot_id, visited_at, description, photo_url) VALUES 
(1, &#39;2025-06-01 18:30:00&#39;, &#39;서울 야경 끝내줬다. 케이블카도 탔다.&#39;, &#39;https://example.com/photo1.jpg&#39;),
(2, &#39;2025-06-15 13:20:00&#39;, &#39;여름에 해운대는 역시 미쳤다. 사람 진짜 많음.&#39;, &#39;https://example.com/photo2.jpg&#39;),
(NULL, &#39;2025-06-20 14:00:00&#39;, &#39;지나가다 발견한 뷰 좋은 루프탑 카페. 지도에도 안 나옴.&#39;, &#39;https://example.com/photo3.jpg&#39;),
(3, &#39;2025-06-25 10:00:00&#39;, &#39;경복궁에서 한복 입고 찍은 날! 외국인도 많았다.&#39;, &#39;https://example.com/photo4.jpg&#39;);
</code></pre>
<h3 id="더미-데이터-생성">더미 데이터 생성</h3>
<p>성능 측정을 위한 더미데이터 100만개를 생성해보자.</p>
<p>WITH RECURSIVE 구문은 SQL버전 반복문이라고 생각하면 된다.</p>
<pre><code class="language-sql">WITH RECURSIVE [TABLE] AS (
        SELECT 1 AS n # Anchor member  ( 재귀 첫 루프에만 실행)  
        UNION ALL   # 빠른 생성을 위해 UNION ALL
        SELECT n + 1 FROM [TABLE]  # 반복적으로 실행시킬 부분 (재귀)
        WHERE n &lt; 3  # 종료 조건
)
# 이러면 n [1,2,3] 이 생성된다.</code></pre>
<pre><code class="language-sql"># 최대 재귀 깊이 설정
SET SESSION cte_max_recursion_depth = 1000000;

# 관광지 데이터
INSERT INTO tourist_spot (name, category, address, lat, lng)
WITH RECURSIVE numbers AS (
    SELECT 1 as n
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n &lt; 100000
)
SELECT
    CONCAT(&quot;관광지&quot;, LPAD(n,7,&#39;0&#39;)),
    CONCAT(&quot;카테고리&quot;, LPAD(n,7,&#39;0&#39;)),
    CONCAT(&quot;주소&quot;, LPAD(n,7,&#39;0&#39;)),
    33 + RAND() * 10,  --  한국 위도
    124 + RAND() * 8   --  한국 경도
FROM numbers;

# 방문한 광광지 더미 데이터
INSERT INTO visited_place (tourist_spot_id, visited_at, description, photo_url)
WITH RECURSIVE number AS (
        SELECT 1 as n
        UNION ALL
        SELECT n + 1 FROM number WHERE n &lt; 1000000
)
SELECT
    (n % 100000) + 1 AS tourist_spot_id,  -- tourist_spot_id는 1~100000 사이
    DATE_ADD(&#39;2025-01-01&#39;, INTERVAL (n % 365) DAY) AS visited_at,  # 일 단위로 증가
    CONCAT(&#39;자동 생성 방문기록 &#39;, n) AS description,
    CONCAT(&#39;https://example.com/photo&#39;, n, &#39;.jpg&#39;) AS photo_url
FROM number;

</code></pre>
<hr>
<h1 id="성능-측정-및-개선">성능 측정 및 개선</h1>
<p>다양한 INDEX를 사용하면서 최적의 쿼리를 찾아보겠습니다.</p>
<h3 id="성능-확인">성능 확인</h3>
<p>성능 확인을 위한 세팅</p>
<pre><code class="language-sql">SET profiling=1;
SHOW PROFILES; # duration (쿼리 실행 시간 확인)</code></pre>
<h3 id="성능-측정할-쿼리">성능 측정할 쿼리</h3>
<pre><code class="language-sql">SELECT *
        FROM visited_place v
        JOIN tourist_spot t ON v.tourist_spot_id = t.id
        WHERE v.vistied_at = &#39;2025-07-01&#39;
        AND t.lat BETWEEN 34 AND 34.5
        AND t.lng BETWEEN 127 AND 128;</code></pre>
<p>이제 좌표 범위 <code>lat</code> : 34~34.5, <code>lng</code> : 127 ~ 128  이고 </p>
<p><code>날짜</code>가 2025-07-01’ 인 데이터를 검색해보겠습니다.</p>
<h2 id="1-기본-인덱스만-존재할-경우">1. 기본 인덱스만 존재할 경우</h2>
<p>현재 인덱스</p>
<ul>
<li>tourist_spots<ul>
<li>PRIMARY (id)</li>
</ul>
</li>
<li>visited_place<ul>
<li>PRIMARY (id)</li>
<li>fk_tourist_spot (외래키)</li>
</ul>
</li>
</ul>
<h3 id="어떻게-동작-하는가">어떻게 동작 하는가?</h3>
<p>위와 같은 쿼리를 실행시켰을 때, 최적의 실행 경로를 예측해보면</p>
<p>JOIN 할 때, fk_tourist_spot과 PRIMARY를 사용할 수 있겠다. 이러면 해당 ID를 가지고 곧바로 <code>관광지</code> 데이터를 찾아낼 수 있으므로 굉장히 빠른 속도로 JOIN을 할 수 있을 것이다.</p>
<p>하지만, 2가지 조건의 경우에는 추출한 테이블을 <code>FULL SCAN</code> 하면서 데이터을 찾아야한다. </p>
<ul>
<li>날짜가 <code>2025-07-01</code> 인 데이터</li>
<li>좌표 범위가 <code>lat</code> : 34~34.5, <code>lng</code> : 127 ~ 128  인 데이터</li>
</ul>
<h3 id="explain-analyz-한-코드">EXPLAIN ANALYZ 한 코드</h3>
<pre><code class="language-sql"></code></pre>
<p>-&gt; Nested loop inner join  (cost=16429 rows=1121) (actual time=5.85..1684 rows=47 loops=1)\n   </p>
<ol>
<li><p>-&gt; Filter: ((t.lat between 34 and 34.5) and (t.lng between 127 and 130))  (cost=10290 rows=1228) (actual time=0.716..274 rows=1976 loops=1)\n        </p>
<p>-&gt; Table scan on t  (cost=10290 rows=99520) (actual time=0.694..239 rows=100000 loops=1)\n   </p>
</li>
</ol>
<ol>
<li><p>-&gt; Filter: (v.visited_at = TIMESTAMP&#39;2025-07-01 00:00:00&#39;)  (cost=4.09 rows=0.912) (actual time=0.707..0.712 rows=0.0238 loops=1976)\n        </p>
<p>-&gt; Index lookup on v using fk_tourist_spot (tourist_spot_id=<a href="http://t.id/">t.id</a>)  (cost=4.09 rows=9.12) (actual time=0.188..0.707 rows=10 loops=1976)\n</p>
</li>
</ol>
<p>순서를 설명하면 </p>
<ol>
<li><code>tourist_spot</code> 의 <strong>FULLSCAN</strong> 으로 좌표 범위 필터링</li>
<li><code>tourist_spot</code>을 훑으면서 id를 읽고, 해당 id에 맞는 <code>visited_place</code>를 <code>idx_tourist_spot</code> 인덱스를 통해 필터링</li>
<li>필터링된 데이터를 <code>날짜</code> 기준으로 추가 필터링</li>
<li>최종 데이터 JOIN</li>
</ol>
<p><strong>참고용 쿼리플랜</strong></p>
<table>
<thead>
<tr>
<th>table</th>
<th>type</th>
<th>key</th>
<th>ref</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>t</td>
<td>ALL</td>
<td>(null)</td>
<td>(null)</td>
<td>99520</td>
<td>1.23</td>
<td>Using where</td>
</tr>
<tr>
<td>v</td>
<td>ref</td>
<td>fk_tourist_spot</td>
<td>idx_test.t.id</td>
<td>9</td>
<td>10.00</td>
<td>Using where</td>
</tr>
</tbody></table>
<p>FULLSCAN이 있기때문에 성능이 굉장히 구립니다. 똥이에요. </p>
<p><img src="attachment:63acff21-b2bd-4f74-b11d-6a4c41819b35:image.png" alt="image.png"></p>
<p>실제 실행 속도 약 1700 ms 정도로 나오는 것 같죠?</p>
<hr>
<h2 id="2-range-타입으로-변경하기">2. Range 타입으로 변경하기</h2>
<p>좌표 데이터를 필터링하는데 FULL SCAN을 하였으니, 이를 개선해보겠습니다.</p>
<p>좌표를 빠르게 찾을 수 있게 lat, lng 전용 INDEX를 추가해보도록 하겠습니다.</p>
<pre><code class="language-sql">CRAETE INDEX idx_lat_lng ON tourist_spot (lat asc, lng asc);</code></pre>
<pre><code class="language-sql">SELECT *
        FROM visited_place v
        JOIN tourist_spot t FORCE INDEX (idx_lat_lng)
        ON v.tourist_spot_id = t.id
        WHERE v.visited_at = &#39;2025-07-01&#39;
        AND t.lat BETWEEN 34 AND 34.5
        AND t.lng BETWEEN 127 AND 130;</code></pre>
<h3 id="예측해보자">예측해보자</h3>
<p>이전 방법에서 좌표를 필터링할때, FULL SCAN 대신 INDEX를 통한 RANGE로 개선하면 되겠죠?</p>
<pre><code class="language-sql">&#39;-&gt; Nested loop inner join  (cost=9183 rows=4546) (actual time=32.8..1301 rows=47 loops=1)\n    -&gt; Index range scan on t using idx_lat_lng over (34 &lt;= lat &lt;= 34.5 AND 127 &lt;= lng &lt;= 130), with index condition: ((t.lat between 34 and 34.5) and (t.lng between 127 and 130))  (cost=2808 rows=4983) (actual time=0.0444..76 rows=1976 loops=1)\n    -&gt; Filter: (v.visited_at = TIMESTAMP\&#39;2025-07-01 00:00:00\&#39;)  (cost=3.31 rows=0.912) (actual time=0.616..0.619 rows=0.0238 loops=1976)\n        -&gt; Index lookup on v using fk_tourist_spot (tourist_spot_id=t.id)  (cost=3.31 rows=9.12) (actual time=0.187..0.614 rows=10 loops=1976)\n&#39;
# 전부 펼치기 귀찮아서 참고용으로 읽어보세요</code></pre>
<p>순서를 요약하면 다음과 같아요.</p>
<ol>
<li><code>tourist_spot</code> 의 INDEX를 통한 RANGE SCAN으로 좌표 범위 필터링</li>
<li><code>tourist_spot</code>을 훑으면서 id를 읽고, 해당 id에 맞는 <code>visited_place</code>를 <code>idx_tourist_spot</code> 인덱스를 통해 필터링</li>
<li>필터링된 데이터를 <code>날짜</code> 기준으로 추가 필터링</li>
<li>최종 데이터 JOIN</li>
</ol>
<p><strong>EXPLAN</strong></p>
<table>
<thead>
<tr>
<th>table</th>
<th>type</th>
<th>key</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>t</td>
<td>range</td>
<td>idx_lat_lng</td>
<td>4983</td>
<td>11.11</td>
<td>Using index condition</td>
</tr>
<tr>
<td>v</td>
<td>ref</td>
<td>fk_tourist_spot</td>
<td>9</td>
<td>10.00</td>
<td>Using where</td>
</tr>
</tbody></table>
<p>range 타입으로 개선하였지만, 실제 속도는 여전히 구립니다…</p>
<p><img src="attachment:7b9bb8d2-5d46-40da-a043-2fda5f834be7:image.png" alt="image.png"></p>
<p>한 대충 1500ms 정도 나오겠네요. 200ms 개선하긴 했네요.</p>
<p>근데 DB에서만 1.5초면 실제 서비스에서 사용자가 이미 웹사이트를 나가기 충분한 시간인듯 합니다.</p>
<hr>
<h2 id="3-복합-인덱스---join할-때-날짜-필터링까지-동시에-해보자">3. 복합 인덱스 - JOIN할 때, 날짜 필터링까지 동시에 해보자.</h2>
<p>이전 과정에서 더 개선할 점이 뭐가 있을까요? 다음은 이전 순서입니다.</p>
<ol>
<li><code>tourist_spot</code> 의 INDEX를 통한 RANGE SCAN으로 좌표 범위 필터링</li>
<li><code>tourist_spot</code>을 훑으면서 id를 읽고, 해당 id에 맞는 <code>visited_place</code>를 <code>idx_tourist_spot</code> 인덱스를 통해 필터링</li>
<li>필터링된 데이터를 <code>날짜</code> 기준으로 추가 필터링</li>
<li>최종 데이터 JOIN</li>
</ol>
<p>여기 보면, 필터링된 데이터를 다시 날짜 기준으로 추가적으로 필터링 시킵니다. 필터링된 데이터를 전부 FULLSCAN 하면서 다시 필터링 해야합니다.</p>
<pre><code class="language-sql">CREATE INDEX idx_touristspot_visitedat ON visited_place (tourist_spot_id, visited_at asc);</code></pre>
<pre><code class="language-sql">SELECT *
        FROM visited_place v FORCE INDEX (idx_touristspot_visitedat)
        JOIN tourist_spot t FORCE INDEX (idx_lat_lng)
        ON v.tourist_spot_id = t.id
        WHERE v.visited_at = &#39;2025-07-01&#39;
        AND t.lat BETWEEN 34 AND 34.5
        AND t.lng BETWEEN 127 AND 130;</code></pre>
<p>잡다한거 생략하고 바로 순서로 넘어가겠습니다.</p>
<pre><code class="language-sql">-&gt; Nested loop inner join  (cost=3259 rows=4983) (actual time=0.747..15.9 rows=47 loops=1)\n    -&gt; Index range scan on t using idx_lat_lng over (34 &lt;= lat &lt;= 34.5 AND 127 &lt;= lng &lt;= 130), with index condition: ((t.lat between 34 and 34.5) and (t.lng between 127 and 130))  (cost=2453 rows=4983) (actual time=0.0622..6.97 rows=1976 loops=1)\n    -&gt; Index lookup on v using idx_touristspot_visitedat (tourist_spot_id=t.id, visited_at=TIMESTAMP\&#39;2025-07-01 00:00:00\&#39;)  (cost=0.556 rows=1) (actual time=0.00437..0.00439 rows=0.0238 loops=1976)\n
</code></pre>
<p><strong>작동 순서</strong></p>
<ol>
<li><code>tourist_spot</code> 의 INDEX를 통한 RANGE SCAN으로 좌표 범위 필터링</li>
<li><code>tourist_spot</code>을 훑으면서 id를 읽고, 해당 id에 맞는 <code>visited_place</code>를 인덱스를 읽으면서 <code>날짜</code> 를 동시에 필터링</li>
<li>최종 데이터 JOIN</li>
</ol>
<h3 id="성능">성능</h3>
<p><img src="attachment:cfed37d9-f2c9-4663-ab75-36a191efebb8:image.png" alt="image.png"></p>
<p>약 17 ms 정도 소요되었습니다.</p>
<p>성능이 비트코인마냥 증가했는데, 이 원리를 이해하려면 MySQL에서 InnoDB가 어떻게 INDEX를 타고 데이터를 가져오는 지를 이해해야 합니다.</p>
<p>이전 방식(1,2번) 은 visited_place 데이터를 가져올 때, </p>
<p><code>FK_INDEX</code> → <code>PRIMARY_INDEX</code> → 날짜 필터링 → JOIN </p>
<p>이렇게 2번에 걸쳐서 INDEX를 타고 실제 데이터를 가져와야 했습니다.</p>
<p>하지만 현재 방식은 </p>
<p><code>idx_touristspot_visitedat</code> → JOIN 으로 바로 가져올 수 있는 것입니다.</p>
<hr>
<h2 id="4-서브쿼리-조인">4. 서브쿼리 조인</h2>
<p>여기에서 더 줄일 수 있습니다. </p>
<p>바로 서브쿼리 조인을 이용하는 것입니다.</p>
<pre><code class="language-sql">SELECT  *
        FROM visited_place v FORCE INDEX (idx_touristspot_visitedat)
        JOIN (
            SELECT *
            FROM tourist_spot FORCE INDEX (idx_lat_lng)
            WHERE lat BETWEEN 34 AND 34.5
            AND lng BETWEEN 127 AND 130
        ) AS t
        ON v.tourist_spot_id = t.id
        WHERE v.visited_at = &#39;2025-07-01&#39;;</code></pre>
<pre><code class="language-sql">-&gt; Nested loop inner join  (cost=3259 rows=4983) (actual time=0.426..13.9 rows=47 loops=1)\n    -&gt; Index range scan on tourist_spot using idx_lat_lng over (34 &lt;= lat &lt;= 34.5 AND 127 &lt;= lng &lt;= 130), with index condition: ((tourist_spot.lat between 34 and 34.5) and (tourist_spot.lng between 127 and 130))  (cost=2453 rows=4983) (actual time=0.0281..6.26 rows=1976 loops=1)\n    -&gt; Index lookup on v using idx_touristspot_visitedat (tourist_spot_id=tourist_spot.id, visited_at=TIMESTAMP\&#39;2025-07-01 00:00:00\&#39;)  (cost=0.556 rows=1) (actual tie=0.00368..0.0037 rows=0.0238 loops=1976)\n
</code></pre>
<h3 id="성능-1">성능</h3>
<p><img src="attachment:6e17fafd-1c09-4628-bd50-1b73669102ec:image.png" alt="image.png"></p>
<p>약 13ms 의 시간이 소요됩니다.</p>
<p>이렇게 까지 해야하나 싶을 정도로 짧을 시간입니다.</p>
<h3 id="왜-이럴까">왜 이럴까?</h3>
<p>옵티마이저가 쿼리를 어떤 과정으로 실행해야할지를 생각하지 않아도 되기 때문입니다. </p>
<ul>
<li><p>3번 쿼리는 옵티마이저 입장에서 “어떤 테이블을 먼저 스캔할까”, “lat/lng 조건은 어디서 적용해야 할까?” 등 여러 판단을 해야합니다.</p>
<p>  → 이 과정에서 비용 소모</p>
</li>
<li><p><strong>하지만 쿼리 B</strong>는 <code>tourist_spot</code>을 먼저 필터링한 서브쿼리 형태라서 옵티마이저가 “이걸 먼저 돌리면 돼”라고 <strong>확정된 상태로 시작</strong>합니다.</p>
<p>  → <strong>플랜 계산 시간이 줄고</strong>, <strong>실행 계획이 단순</strong></p>
</li>
</ul>
<p>그래서 큰 시간적인 차이는 발생하지 않습니다.</p>
<hr>
<h2 id="5-커버링-인덱스">5. 커버링 인덱스</h2>
<p>여기에서 더 읽기 속도를 향상 시킬 수 있습니다. </p>
<p>읽기 속도가 중요할 때 많이 사용하지만, 쓰기 속도가 느려지기 때문에 신중한 사용이 필요합니다.</p>
<pre><code class="language-sql">CREATE INDEX idx_lat_lng_id ON tourist_spot(lat asc, lng asc, id);</code></pre>
<pre><code class="language-sql">SELECT  *
        FROM visited_place v FORCE INDEX (idx_touristspot_visitedat)
        JOIN (
            SELECT id, lat, lng
            FROM tourist_spot FORCE INDEX (idx_lat_lng_id)
            WHERE lat BETWEEN 34 AND 34.5
            AND lng BETWEEN 127 AND 130
        ) AS t
        ON v.tourist_spot_id = t.id
        WHERE v.visited_at = &#39;2025-07-01&#39;;</code></pre>
<h3 id="성능-2">성능</h3>
<p><img src="attachment:5e83d328-112e-40e7-8c66-451f57e188a5:image.png" alt="image.png"></p>
<p>소요시간이 약 10ms로 더욱 개선된 것을 볼 수 있습니다. </p>
<p>여기에서는 이전과 크게 차이가 없지만, 데이터가 더욱 많아지고 조건이 복잡해지면 차이가 많이 생깁니다.</p>
<h3 id="이유">이유</h3>
<p>MySQL의 InnoDB에서는 생성한 INDEX에 데이터의 주소를 저장하지 않고 해당 데이터의 PRIMARY ID를 저장해놓습니다. 따라서 실제 데이터를 가져오려면 PRIMARY INDEX 의 leaf 노드에 있는 데이터의 저장 위치를 읽고, DISK에서 해당 위치의 데이터를 가져와야합니다. 즉, INDEX를 2번 읽어야 하는 것입니다.</p>
<p>하지만 커버링 인덱스를 사용하면, INDEX의 leaf 노드에 필요한 모든 데이터가 저장되어있기 때문에 PRIAMRY INDEX 를 찾아가 검색할 필요가 없습니다. INDEX를 1번만 타도 되는 것입니다.</p>
<p>---<img src="https://velog.velcdn.com/images/standard-chan/post/74f32e0b-2dd1-47ac-a62d-4f55380777bc/image.png" alt=""></p>
<h2 id="마무리">마무리</h2>
<p>이렇게 쿼리 읽기 성능을 개선하는 방법을 알아보았습니다.</p>
<p>읽기 속도가 빠르다고 항상 좋은 것은 아닙니다.</p>
<p>쓰기 속도가 느려진다는 점, 용량이 커진다는 점 등의 문제가 발생합니다. 여기서 더 나아가면 JAVA 등의 실제 코드로 옮길 때, 유지보수 및 가독성이 좋냐? 도 생각할 필요가 있습니다.</p>
<p>따라서 얼마나 많이 읽느냐, 얼마나 많이 쓰느냐 등을 모두 고려하여 적절한 쿼리를 선택합시다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[intellij] Gradle Test Executor 1 에러]]></title>
            <link>https://velog.io/@standard-chan/intellij-Gradle-Test-Executor-1-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@standard-chan/intellij-Gradle-Test-Executor-1-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Wed, 28 May 2025 12:58:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/standard-chan/post/1a25b9e3-722b-44db-9641-1c98ee81e761/image.png" alt=""></p>
<h3 id="위-아이콘을-눌렀을-때에-발생하는-오류">위 아이콘을 눌렀을 때에 발생하는 오류.</h3>
<p>Intellij에서 이상한 gradle 에러가 발생하면서 test가 실행이 안되는 문제가 발생하는 경우가 있다.</p>
<pre><code>Execution failed for task &#39;:test&#39;.
&gt; Process &#39;Gradle Test Executor 1&#39; finished with non-zero exit value 1</code></pre><h4 id="우선-test-코드가-잘-작동하는-지를-먼저-테스트-해보자">우선 test 코드가 잘 작동하는 지를 먼저 테스트 해보자</h4>
<pre><code>./gradlew test --tests &quot;패키지명.클래스명.메서드명&quot;</code></pre><p>=&gt; 여기서 테스트가 실행이 안된다면, 테스트 코드 문제일 것이다.</p>
<hr>
<h4 id="잘-작동-한다면-즉-build-success가-뜬다면-빌드-도구의-문제가-생긴-것이다">잘 작동 한다면, 즉 build success가 뜬다면 빌드 도구의 문제가 생긴 것이다.</h4>
<p>다음 수정을 통해 test를 실행하는 툴을 바꿔보도록 하자.</p>
<p>&#39;Ctrl + Alt + s&#39; 를 눌러 설정 창을 열자.
<img src="https://velog.velcdn.com/images/standard-chan/post/2628149e-61e8-499c-955c-7433cc6e29f0/image.png" alt="">
아래의 테스트 실행을 Grdle(default) 에서 Intellij IDEA로 바꾼다.</p>
<p>적용누르고 확인</p>
<p>좌측에 실행버튼 누르면 잘 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Entity의 배열 자료구조는 1NF를 위반할까?]]></title>
            <link>https://velog.io/@standard-chan/Entity%EC%9D%98-%EB%B0%B0%EC%97%B4-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0%EB%8A%94-1NF%EB%A5%BC-%EC%9C%84%EB%B0%98%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@standard-chan/Entity%EC%9D%98-%EB%B0%B0%EC%97%B4-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0%EB%8A%94-1NF%EB%A5%BC-%EC%9C%84%EB%B0%98%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 08 May 2025 02:59:39 GMT</pubDate>
            <description><![CDATA[<p>아래의 User Entity는 Set&lt;&gt; 자료구조가 필드에 존재한다. 그렇다면 User는 제 1 정규형(원자성)을 위반할까?</p>
<pre><code class="language-java">@Entity
public class User {
    @Id
    private Long id;

    @OneToMany(mappedBy = &quot;user&quot;)
    private Set&lt;Friend&gt; friends;
}

@Entity
public class Friend {
    @Id
    private Long id;

    @ManyToOne
    private User user;

    @ManyToOne
    private User friend;
}</code></pre>
<blockquote>
<p>결과부터 말하자면, DB 테이블 차원에서 위는 1NF를 만족한다.</p>
</blockquote>
<p>왜냐하면 JPA에서는 내부적으로 다대일(N:1) 또는 일대다(1:N) 관계를 <strong>별도의 테이블</strong>(또는 외래 키)을 통해 <strong>정규화된 방식</strong>으로 저장하기 때문이다.</p>
<p>실제로 DB에는 아래와 같이 저장된다.</p>
<pre><code class="language-java">user(id)

friend(id, user_id, friend_id, level)</code></pre>
<h3 id="그렇다면-user-데이터를-불러왔을때-fiend-데이터는-포함되지-않는-것일까">그렇다면 User 데이터를 불러왔을때, fiend 데이터는 포함되지 않는 것일까?</h3>
<p>그렇다. <code>User</code> 테이블에는 <code>friends</code> 정보가 직접적으로 저장되어 있지 않기 때문에, <strong>기본적으로는 JPA가 자동으로 친구들을 가져오지 않는다.</strong></p>
<p>위와같은 방식으로 데이터를 로딩하는 것을 <strong>지연 로딩(LAZY)</strong>이라고 한다.</p>
<h2 id="지연로딩lazy">지연로딩(Lazy)</h2>
<p>지연로딩이란, 해당 데이터를 미리 가져오지 않고, 해당 데이터가 필요한 순간에 데이터를 가져오는 것을 말한다.</p>
<p>위의 User, friend를 예시로 설명하자면, User 정보를 가져올때, friend 정보를 같이 가져오는 것이 아니라, friend 정보가 필요할때, user_id를 통해 검색하여 friend 정보를 가져오는 것을 말한다.</p>
<p>지연로딩을 사용하게 되면, 불필요한 데이터를 가져오지 않아도 되니 데이터 로딩 비용이 적게 들고, 속도가 빠르다는 장점이 있다.</p>
<p>그런데 지연 로딩의 치명적인 단점이 있다. 바로 N+1 문제이다.</p>
<h2 id="n1-문제">N+1 문제</h2>
<p>N+1문제란, 짧게 말하자면 외래키로 연관되어있는 필드를 가져올 때, LAZY로 설정하였을 경우 DB요청 쿼리가 과도하게 발생하는 문제를 말한다.</p>
<p>예를 들어서 아래 코드를 봐보자</p>
<p>user의 friends 정보를 가져오려고한다. 하지만, user에는 friends 정보가 없으므로, 다음과 같이 가져와보자.</p>
<p>user를 가져오고 → friend에서 가져온 user_id로 friends 정보를 가져올 수 있다. </p>
<pre><code class="language-java">List&lt;User&gt; users = userRepository.findAll(); // 1번 쿼리
for (User user : users) {
    System.out.println(user.getFriends()); // N번 쿼리
  // (getFriends() 는 SELECT * FROM friend WHERE user_id = ? 쿼리로 동작한다.)
}</code></pre>
<pre><code class="language-sql"># SQL로 표현하면 다음과 같다.
SELECT * FROM USERS;
# 가져온 모든 USERS.ID 에 대해서
SELECT * FROM friend WHERE user_id = ?1 
...
SELECT * FROM friend WHERE user_id = ?n</code></pre>
<p>위에서 findAll()의 경우, <code>SELECT * FROM user</code> 쿼리가 1번 발생한다.</p>
<p>하지만 아래의 경우에는 N번의 쿼리가 발생한다.</p>
<p>이를 N+1 문제라고 한다.</p>
<p>(왜 1+N이 아니라 N+1로 불리는지는 모르겠다. 순서상 1+N이 더 타탕한데…)</p>
<p>이를 해결하기 위해서는 User 정보를 가져올때, Friends 정보도 같이 가져오는 쿼리를 사용하면 된다. JPA에서는 이를 Fetch Join으로 구현할 수 있다.</p>
<h2 id="그럼-friends-정보를-같이-가져오고-싶으면">그럼 friends 정보를 같이 가져오고 싶으면?</h2>
<ol>
<li><p><strong>Fetch Join 사용</strong></p>
<pre><code class="language-java"> @Query(&quot;SELECT u FROM User u LEFT JOIN FETCH u.friends WHERE u.id = :id&quot;)
 Optional&lt;User&gt; findByIdWithFriends(@Param(&quot;id&quot;) Long id);</code></pre>
</li>
</ol>
<ol>
<li><p><strong>EntityGraph 사용</strong></p>
<pre><code class="language-java"> @EntityGraph(attributePaths = &quot;friends&quot;)
 Optional&lt;User&gt; findWithFriendsById(Long id);</code></pre>
</li>
</ol>
<ol>
<li><p><strong>별도 Repository로 Friend 조회</strong></p>
<pre><code class="language-java"> List&lt;Friend&gt; findByUser(User user);</code></pre>
</li>
</ol>
<ol>
<li>EAGER (즉시 로딩) 사용</li>
</ol>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;user&quot;, fetch = FetchType.EAGER)
private Set&lt;Friend&gt; friends;</code></pre>
<p>항상 join된 데이터를 가져와야한다면 간편할 수는 있으나, 중복 row, 순환참조 문제 등으로 위험하기 때문에 LAZY과 fetch join을 사용하는 것이 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Static Factory Method]]></title>
            <link>https://velog.io/@standard-chan/Static-Factory-Method</link>
            <guid>https://velog.io/@standard-chan/Static-Factory-Method</guid>
            <pubDate>Thu, 20 Mar 2025 15:47:02 GMT</pubDate>
            <description><![CDATA[<h1 id="factory-method">Factory Method</h1>
<hr>
<h2 id="개요">개요</h2>
<p>RPG 게임 프로젝트에서 Weapon과 WeaponFactory를 구현하는 중에 더 효율적인 <strong>Static Factory Method</strong>라는 코드를 발견했다.</p>
<hr>
<h2 id="기존방식-concreate-factory-method">기존방식 (Concreate Factory Method)</h2>
<p><img src="https://velog.velcdn.com/images/standard-chan/post/3a9acc41-88dc-4a80-98dd-84f846e5e1d6/image.png" alt=""></p>
<p>기존에는 Factory 추상클래스를 구현하는 <strong>각각의 <code>ConcreteFactory class</code></strong>를 별도로 구현하였다. </p>
<p>사진으로 설명하자면, <code>VehicleDriver</code> 추상클래스를 만들고 이를 구현하는 <code>CarDriver</code>, <code>BusDriver</code> 클래스를 각각 만들어왔다. </p>
<p>이 방식은 새로운 <code>Vehicle</code>을 상속받는 운송수단, <strong><code>Train</code>이 추가되면 <code>VehicleDriver</code>를 상속받는 <code>TrainDriver</code>를 구현하여야한다.</strong>  새로운 Class가 추가될때마다 추가적인 Factory Method Class를 만들어야하기때문에 클래스가 많아지면, 관리에 있어 어려움이 있을 수 있다.</p>
<pre><code class="language-java">public abstract class VehicleFactory {

    public final Weapon createVehicle(String name) {
        return create(name);
    }

    abstract protected Vehicle create(String name);
}</code></pre>
<pre><code class="language-java">public class CarFactory extends VehicleFactory {

    @Override
    protected Vehicle create(String name) {
        return new Car(name);
    }
}</code></pre>
<pre><code class="language-java">    VehicleFactory carFactory = new CarFactory();
    Car car = vehicleFactory.createVehicle(&quot;sonata&quot;);</code></pre>
<p>추상 메서드 VehicleFactory타입으로 선언한 모든 Factory들은 <code>createVehicle</code>메서드 하나로 <code>Vehicle</code> 생성이 가능하다. 이는 OCP을 철저히 준수한다고 볼 수 있다.</p>
<h3 id="ocp는-준수하지만-의문">OCP는 준수하지만... 의문</h3>
<blockquote>
<p>CarDriver나 BusDriver나 생성하는 인스턴스만 다르지, 나머지는 똑같은데, 굳이 나눌 필요가 있을까..?</p>
</blockquote>
<p>그래서 알아보니, <strong>정적 팩토리 메서드</strong>라는 방식이 있다고한다.</p>
<hr>
<h2 id="static-factory-method">Static Factory Method</h2>
<blockquote>
<p>&quot;Static Method를 통해 간접적으로 생성자를 호출하는 객체를 생성하는 디자인 패턴&quot;</p>
</blockquote>
<hr>
<p>글로는 이해하기 난해한듯 한데, 다시 그림을 통해 이해해보자.
<img src="https://velog.velcdn.com/images/standard-chan/post/3a9acc41-88dc-4a80-98dd-84f846e5e1d6/image.png" alt=""></p>
<p>이전에는 CarDriver와 BusDriver라는 팩토리를 별도로 만들어서 생성하였다. 하지만 아래와 같은 방식으로도 생성이 가능하다.</p>
<pre><code class="language-java">public class VehicleFactory {

    public static Weapon createVehicle(String type, String name) {
        if (type.equals(&quot;car&quot;)) {
            return new Car(name);
        } else if (type.equals(&quot;bus&quot;)) {
            return new Bus(name, ATK);
        }

        throw new IllegalArgumentException(&quot;타입이 존재하지 않습니다.&quot;);
    }
}
</code></pre>
<p>위와 같이 if-else 문을 이용해서 Car와 Bus를 생성할 수 있다. 하지만 위 코드는 가독성이 떨어지니 다음과 같이 수정하여보자.</p>
<pre><code class="language-java">public class VehicleFactory {
    private static final Map&lt;String, Function&lt;String, Vegicle&gt;&gt; vehicleMap = new HashMap&lt;&gt;();

    static {
        vehicleMap.put(&quot;car&quot;, Car::new);
        vehicleMap.put(&quot;bus&quot;, Bus::new);
    }

    public static Vehicle createVehicle(String type, String name) {
        Function&lt;String, Vehicle&gt; constructor = weaponMap.get(&quot;type&quot;);
        return constructor.apply(name);
    }
}
</code></pre>
<p>훨씬 가독성이 좋아진 걸 볼 수 있다.</p>
<p>새로운 vehicle을 추가할 때에도, vehicleMap에 새로운 Class 생성자만 추가하면 되니 유지보수 또한 쉽다.</p>
<p>또한 Static으로 선언되었기 때문에, 위처럼 별도의 <code>VehicleFactory</code>객체를 선언하는 것 없이 Vehicle을 바로 생성할 수 있다.</p>
<p>마지막으로 <code>String type</code>이라는 매개변수를 통해서 Vehicle type객체를 생성할 수 있으니, <strong>동적으로 생성이 가능</strong>하다는 장점이 있다.</p>
<blockquote>
<h3 id="static-factory-method-장점">Static Factory Method 장점</h3>
</blockquote>
<ol>
<li>코드가 간결하고 유지보수가 쉽다.</li>
<li>팩토리 객체를 생성하지 않아도 된다.</li>
<li>Map을 활용해 동적 생성 가능하다
 기존 concreateFactory를 각각 생성하는 방식은 동적으로 객체를 생성하는데에 어려움이 있다. 인터페이스/추상메서드의 create를 호출하기 위해서 하위 클래스를 생성해야하기 때문이다.
 하지만 <code>Static Factory Method</code> 방식으로는 매개변수에 생성할 HashKey만 넣으면 되니, <strong>동적으로 생성이 가능</strong>하다.</li>
</ol>
<hr>
<p>이렇게 생각해보면 대부분의 면에서 Static Factory Method 방식이 유리해보인다. 하지만 해당 클래스의 생성자가 private인 경우, static 방식을 사용할 수 없기 때문에 유의가 필요해보인다.</p>
<hr>
<h3 id="레퍼런스">레퍼런스</h3>
<p>Factory Method Image
<a href="https://www.startertutorials.com/patterns/factory-method-pattern.html">https://www.startertutorials.com/patterns/factory-method-pattern.html</a></p>
<p>static Factory Method
<a href="https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%A0%95%EC%A0%81-%ED%8C%A9%ED%86%A0%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%8C%80%EC%8B%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90">https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%A0%95%EC%A0%81-%ED%8C%A9%ED%86%A0%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%8C%80%EC%8B%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[첫 Java 프로젝트]]></title>
            <link>https://velog.io/@standard-chan/%EC%B2%AB-Java-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</link>
            <guid>https://velog.io/@standard-chan/%EC%B2%AB-Java-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</guid>
            <pubDate>Mon, 10 Mar 2025 12:16:11 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/standard-chan/post/1bbb10a3-a5a7-4555-95a0-d32eb3977c09/image.png" alt=""></p>
<blockquote>
<p>처음으로 프론트 설계부터 API서버 배포까지한 프로젝트.
Java + Spring Boot + JPA를 사용하여 만든 과정에서 느꼈던 점을 공유합니다.</p>
</blockquote>
<h2 id="어떻게-백엔드를-시작했나">어떻게 백엔드를 시작했나?</h2>
<h3 id="react-프로젝트-시작">React 프로젝트 시작</h3>
<p>저는 JS와 React를 사용하여 개인 프로젝트를 시작했었습니다. 어렸을 때부터 미술을 좋아해서 시각적으로 꾸민다는 것에 마음이 가서 프론트에 마음이 갔었습니다. 백엔드는 잘 모르다보니, 저에게 익숙한 firebase를 사용해서 프로젝트를 시작 하게 되었습니다.</p>
<h3 id="firebase말고-내가-직접-만들어보자">firebase말고 내가 직접 만들어보자</h3>
<p>주변 친구들이나 개발자 사이에서 firebase에 대한 인식이 그렇게 좋은 것 같지 않다는 기분이 들었어요. firebase는 빠르게 Prototype을 만드는데 사용되는 반쪽짜리 서비스 느낌이었습니다. 그런 firebase에 의존해서 프로젝트를 만든다는게 뭔가 부끄러웠습니다. 제 프로젝트가 반쪽짜리 프로젝트인 것 같았거든요. <del>(비하할 의도는 아닙니다.)</del></p>
<p>firebase 다른 관계형 데이터 모델과 달리, firebase는 Docuemnt-Collection모델의 언어라서, path를 통해서 저장하는 특징이 있습니다. 그래서 데이터가 연관된다는 느낌보다는 서로 독립적이라는 느낌이 많이 들었습니다. 또 백엔드에서 테이터 구조를 설계한다기 보다는, JS에서 설계해서 데이터를 저장한다는 느낌때문에 너무 복잡하다는 생강도 들었던 것 같습니다. 그래서 나만의 API server를 만들어보고 프로젝트에 적용해보자는 생각을 가지게 되었습니다.</p>
<hr>
<blockquote>
<p>아래는 프로젝트를 진행하면서 느꼈던 점을 적어보았습니다.</p>
</blockquote>
<h2 id="패키지-내에서-클래스-분류하기">패키지 내에서 클래스 분류하기</h2>
<p>Spring Boot가 처음이라 책 2개를 보면서 공부를 했습니다. 하지만 2개의 책에서 패키지로 클래스를 분류하는 방식이 달라 재밌었습니다.</p>
<p>하나는 Controller, Repository, Service, Dto, Entity 이렇게 기능별로 패키지로 묶어서 관리를 했습니다. </p>
<p>다른 하나는 User, Question, Answer,… 등 도메인별로 관리를 하더군요.</p>
<p>‘어떻게 디렉터리를 작성해야하나?’는 항상 고민이었습니다. 때로는 기능별로, 때로는 관련있는 도메인별로, 때로는 제 마음대로 하기도 했으니까요.</p>
<p>이번프로젝트에는 뭘 선택하더라도 크게 상관은 없었지만, 저는 기능별로 분류해서 관리하는 방식을 택했습니다. 구현할 기능이 적어서 기능별로 분류해서 관리하는게 찾기에 편했기 때문입니다. 하지만 구현할 기능이 많아진다면 도메인별로 관리하는 것이 한 패키지 내에 클래스들이 적어 관리하기 편할 것 같다는 생각이 드네요.</p>
<hr>
<h2 id="테스트-코드를-배우자">테스트 코드를 배우자</h2>
<p>이번 프로젝트에서는 JUnit을 이용한 테스트 코드를 많이 사용하지 않았습니다. 빠르게 만들 필요가 있는데 JUnit 사용법을 몰랐기 때문입니다. 그래서 테스트를 크롬 개발자 도구 콘솔이나 Post man을 이용해서 테스트를 진행했습니다. </p>
<p>이번에는 테스트를 url을 중심으로 진행했기때문에, 위 툴만으로도 충분히 테스트가 가능했다고 생각이 듭니다. 하지만 중간 과정이 복잡할수록 이번 기회에 JUnit이 필요할 거라는걸 느낍니다.</p>
<h2 id="리팩토링은-언제하나">리팩토링은 언제하나…</h2>
<p>사실 리팩토링이 중요하다는 걸 알고있지만, 기능을 막상 구현하고나면 그냥 넘어가버리는 경우가 많았습니다. 아무래도 빠르게 프로젝트를 완성해야한다는 압박때문인지도 모르겠습니다. </p>
<p>그렇게 미루고 미루다 결국 끝나면 결국 잘 안하게 됩니다. 끝나면 까먹은 코드도 많고, 다시 처음부터 이해해야하는 코드도 많으니 하기가 싫어지는 듯 합니다. 역시 리팩토링은 개발 도중에 주기적으로 하는게 좋아보입니다.</p>
<p>가장 이상적인 시기는 테스트를 성공적으로 마친 후 라고 생각을 합니다. 이 시기가 해당 기능에 대해서 구체적으로 기억도 잘나고 금방금방 수정을 할 수 있을 거라는 생각이 드네요.</p>
<hr>
<h2 id="이해-없이-그냥-가져다가-쓰는-것-같아">이해 없이 그냥 가져다가 쓰는 것 같아</h2>
<p>Spring Security는 이해한다는 느낌보다, 이미 구현이 되어있는 걸 가져다가 쓰기만 한다는 느낌이 강했습니다. 메서드 하나하나를 이해보려해도, 사용하는 메서드가 너무 많아서 차마 이해해보겠다는 엄두가 안났습니다. 그래서 큰 흐름만 공부하고 이미 구현되어있는 코드만 이해하고 가져가 쓰는 방식으로 코딩을 했습니다. </p>
<p>대충 넘어갔지만, 이러한 미스테리함이 저에게는 상당히 매력적이었습니다. 미지의 세계를 탐험하며 수수께끼를 풀어가는 느낌이 들었습니다. 그래서 이번 기회에 인증 부분을 깊게 공부해볼 예정입니다. </p>
<h2 id="데이터-통신하는게-이렇게-어려워-잠도-못자고-cors-https">데이터 통신하는게 이렇게 어려워? 잠도 못자고… (CORS, HTTPS)</h2>
<p>웹 프론트와 API server를 분리해서 배포를 했습니다. 이때 로컬 localHost 상에서는 HTTP 통신으로 데이터를 잘 주고 받았는데, 배포된 서비스에서는 데이터를 주고받지 못하는 문제가 발생했습니다. 2가지 이유 때문인데요, 첫번째는 CORS(Cross-Origin Resource Sharing)이고 두번째는  HTTPS와 HTTP간의 통신 문제 때문입니다.</p>
<p>CORS는 생각보다 금방 해결할 수 있었습니다. 하지만 HTTPS는 상당히 번거로웠습니다. 도메인 구매부터 IP에 도메인 할당, NGINX를 이용해서 SSL인증서 적용까지 해야했습니다. 모든게 처음이었고 네트워크 관련 지식도 부족했습니다. 밤 11시부터 기숙사 휴게실에 혼자 앉아 새벽 5시까지 이런 저런 시도를 해봤습니다. ‘내일하자’라는 생각이 많이 들었지만, 내일이 되면 흐름이 끊겨서 더 오래걸릴거라는 두려움에 ‘어떻게든 오늘 끝내고 잔다!’라는 생각으로 임했던 것 같습니다. 결국 6시간의 대장정 끝에 인증을 받았습니다. 아직도 그때 통신이 잘 되었던 순간을 떠올리면 도파민이 뿜뿜 나오네요.</p>
<hr>
<h2 id="처음은-뭐든-어려워">처음은 뭐든 어려워</h2>
<p>프론트부터 백엔드까지 처음부터 구현하고 배포한 첫 프로젝트였습니다. 누군가가 &#39;가장 어려운게 뭐였어?&#39;라고 물어본다면, 저는 AWS에서 SSL 인증은 받는 거라고 답할 것 같습니다. 사실 코딩과 구현은 어렵다기보단 시간만 들이면 할 수 있다는 느낌이 강했습니다. 하지만 SSL은 한번도 밟아보지 않은 미지의 영역이라 망망대해를 떠나는 느낌이었습니다. &#39;3시간만 하면 끝낸다!&#39;라는 느낌이 아니었거든요. 마치 콜럼버스가 대서양 한 가운데에 있을 때, &#39;아메리카 대륙엔 언제 도착할지&#39; 를 모르듯이, SSL 인증을 언제 받을 수 있을지 몰랐거든요. 특히 DOMAIN이 전세계에 퍼지는데에 시간도 24시간 정도 소요된다고 하는데, 언제 될지도 모르는 상태에서 발발 떨었던 기억이 납니다.
하지만 이제는 이미 발을 밟은 대륙이니만큼 다음번 할때는 손쉽게 할것 같습니다. 저에겐 뭐든 처음만 어려우니까요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@Annotation 에 기능이 어떻게 전달되는가?]]></title>
            <link>https://velog.io/@standard-chan/java-%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%B4%EB%9D%BC%EB%8A%94%EA%B2%8C-%EB%8F%84%EB%8C%80%EC%B2%B4-%EB%AD%90%EC%95%BC</link>
            <guid>https://velog.io/@standard-chan/java-%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%B4%EB%9D%BC%EB%8A%94%EA%B2%8C-%EB%8F%84%EB%8C%80%EC%B2%B4-%EB%AD%90%EC%95%BC</guid>
            <pubDate>Fri, 21 Feb 2025 10:41:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>결론부터 말하자면 애너테이션에 기능은 전달될 수 없다. 기능을 전달하는 것처럼 보일 뿐.</p>
</blockquote>
<hr>
<h1 id="배경">배경</h1>
<blockquote>
<p><strong>@에 어떻게 기능이 전달되는 걸까?</strong></p>
</blockquote>
<p>최근에 스프링에 입문을 하며 코드를 작성을 하는데,  <strong>애너테이션을 많이 사용하며 구현</strong>을 해야했었다. Javascript, C, Python 만 사용하다가 넘어온 사람으로서 타 언어에 없는 &#39;<strong>@ 문법</strong>&#39;은 정말 이질감이 들었다.</p>
<p>이보다 더 당황스러웠던 건 상속도, 구현도, 외부함수를 끌어다 사용하는것도 아닌데 <strong>기능이 어떻게 들어갈 수 있는지는 정말 의문이다.</strong> 
(학부생활 때 배웠던 @Override는 단순히 주석정도의 기능밖에 하지 않는다고 배웠는데...)</p>
<p>그래서 오늘 그 @에 대해서 이해해보려고 한다.</p>
<hr>
<h2 id="getter">@Getter</h2>
<p>간편해서 많이 사용하는 @Getter, @Setter으로 알아보려고 한다.</p>
<blockquote>
<p><strong>@Getter</strong>
해당 클래스의 객체를 생성할 때, 자동으로 필드를 get할 수 있는 메서드를 생성해준다.</p>
</blockquote>
<p>아래는 @Getter 의 내부 구조이다.</p>
<pre><code class="language-java">
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Getter {
    AccessLevel value() default AccessLevel.PUBLIC;

    AnyAnnotation[] onMethod() default {};

    boolean lazy() default false;
}</code></pre>
<p>유의해서 볼 것들은 다음 것들이 되겠다. (내가 모르는 것들)</p>
<ul>
<li>@interface</li>
<li>@Taraget</li>
<li>@Retention</li>
<li>AccessLevel value() ...</li>
</ul>
<p>우선 위에 것들부터 차근차근 알아가보자.</p>
<hr>
<h3 id="interface">@interface</h3>
<blockquote>
<p>커스텀 애너테이션 생성</p>
</blockquote>
<p>애너테이션을 만들 때 사용하는 문법이다. </p>
<pre><code>public @interface MyAnnotation {
    String value() default &quot;default&quot;;
}</code></pre><p>사용법은 클래스나 특정 대상 위에 애너테이션을 추가하면 된다.</p>
<pre><code>@MyAnnotation(value = &quot;Hello World&quot;)
public class ExampleClass {}</code></pre><hr>
<h3 id="target">@Target</h3>
<blockquote>
<p>애너테이션을 붙일 대상을 지정한다.</p>
</blockquote>
<pre><code>@Target(ElementType.TYPE)
public @interface MyAnnotation {}</code></pre><p>ElementType에는 여러 종류가 있다.</p>
<ul>
<li>TYPE : 클래스, 인터페이스 등</li>
<li>FILED</li>
<li>METHOD</li>
<li>PARAMETER</li>
<li>CONSTRUCTOR</li>
</ul>
<hr>
<h3 id="retention">@Retention</h3>
<blockquote>
<p>애너테이션이 유지되는 시간을 결정한다.</p>
</blockquote>
<pre><code>@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {}</code></pre><p>Retention</p>
<ul>
<li>SOURCE : 컴파일까지 유지</li>
<li>CLASS : 클래스 파일에 포함</li>
<li>RUNTIME    : 런타임에 유지</li>
</ul>
<hr>
<h3 id="field">field</h3>
<blockquote>
<p>애너테이션에 저장할 메타데이터</p>
</blockquote>
<pre><code>public @interface MyAnno {
    String value();
}</code></pre><p>MyAnno라는 애너테이션에 저장할 메타데이터를 정의한다. String 타입의 value라는 명칭의 데이터를 저장하겠다는 의미이다.</p>
<h2 id="getter-해석">@Getter 해석</h2>
<pre><code class="language-java">
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Getter {
    AccessLevel value() default AccessLevel.PUBLIC;

    AnyAnnotation[] onMethod() default {};

    boolean lazy() default false;
}</code></pre>
<blockquote>
<p>@Getter =&gt; 클래스, 필드를 대상으로
@Retention =&gt; 컴파일 시기에 유지되는
@interface =&gt; 커스텀 애너테이션</p>
</blockquote>
<p>필드를 보면</p>
<blockquote>
<p>AccessLevel (접근제어자) : 생성되는 대상의 접근 제어자를 PUBLIC으로 설정</p>
<p>AnyAnnotation[] onMethod() default {};
생성되는 getter 메서드에 추가적인 애너테이션을 붙일 수 있는 기능.</p>
<p>boolean lazy() : 필드 초기화를 지연(lazy) 여부</p>
</blockquote>
<p><strong>해당 get 기능을 생성해주는 코드가 어디에도 없다.</strong> </p>
<p>어떻게 어디에서 getter 함수가 자동으로 달리는 건지 궁금했다.</p>
<hr>
<h2 id="lombok">Lombok</h2>
<blockquote>
<p>애너테이션은 단순한 표지판일 뿐</p>
</blockquote>
<p>애너테이션은 기능을 구현해주지 않는다. 단순히 &#39;@Getter에 메서드를 만들어 줘&#39; 라는 표시일 뿐이다. 그래서 field도 단순 정보를 넣을 뿐인 것 같다.</p>
<pre><code>@MyAnno(maker = &quot;애너테이션을 만든 사람은 Jeong이야&quot;, time=&quot;2025.02.21&quot;)</code></pre><p>해당 get 기능은 <strong>Lombok</strong>에서 해당 애너테이션을 보고 구현해주는 것이다. 이때 사용되는 것이 <strong>애너테이션 프로세서</strong>이다.</p>
<h3 id="애너테이션-프로세서">애너테이션 프로세서</h3>
<blockquote>
<p>애너테이션을 읽고 기능을 구현해주는 역할을 수행</p>
</blockquote>
<p>다음과 같은 방식으로 get 메서드가 구현된다.</p>
<ol>
<li>컴파일 </li>
<li>Lombok실행 </li>
<li>Lombok의 애너테이션 프로세서가 @Getter을 확인 </li>
<li>해당 클래스의 필드에 대한 getter 메서드 생성</li>
</ol>
<hr>
<h3 id="생각">생각</h3>
<blockquote>
<p>뭔가 예상은 어느정도 했지만, 허무하다.</p>
<p>프로그래밍 언어별로 크게 차이가 없는 듯 해서  아쉽다가도, 또 새로운 문법을 공부하지 않아도 된다는 안도감이 동시에 든다.</p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>