<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 공부 노트</title>
    <link>https://html-jc.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 5 Apr 2026 09:42:01 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>개발자성장기</managingEditor>
    <image>
      <title>개발 공부 노트</title>
      <url>https://tistory1.daumcdn.net/tistory/4426094/attach/74de156064514a1ca45ca81743e8e774</url>
      <link>https://html-jc.tistory.com</link>
    </image>
    <item>
      <title>에러없이 선착순 이벤트 진행하기</title>
      <link>https://html-jc.tistory.com/773</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6YeBh/dJMcabDyxqy/mvK2xzsUT3yzrvmE2Yj4TK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6YeBh/dJMcabDyxqy/mvK2xzsUT3yzrvmE2Yj4TK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6YeBh/dJMcabDyxqy/mvK2xzsUT3yzrvmE2Yj4TK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6YeBh%2FdJMcabDyxqy%2FmvK2xzsUT3yzrvmE2Yj4TK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;787&quot; height=&quot;377&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;드디어 !&lt;br /&gt;우리 팀이 운영하는 서비스가 2026년 2월 10일 기준 회원 수 500명을 돌파했다.  &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 미뤄왔던 이벤트를 이제는 정말 해볼 수 있겠다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;유저가 어느 정도 모이고, 보여주고 싶은 기능이 갖춰지면 하자&amp;rdquo;라고 계속 미뤄왔는데, 드디어 그 시점이 온 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전부터 꼭 해보고 싶었던 이벤트가 하나 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 &lt;b&gt;선착순 이벤트&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1073&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEdCBi/dJMcab4EPoY/d3iQ0ibdetRIk0DXveIjF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEdCBi/dJMcab4EPoY/d3iQ0ibdetRIk0DXveIjF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEdCBi/dJMcab4EPoY/d3iQ0ibdetRIk0DXveIjF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEdCBi%2FdJMcab4EPoY%2Fd3iQ0ibdetRIk0DXveIjF0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;651&quot; height=&quot;546&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1073&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;998&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nHIhS/dJMcagLGObi/iWx8XckPQkzgkvDp9bW4IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nHIhS/dJMcagLGObi/iWx8XckPQkzgkvDp9bW4IK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nHIhS/dJMcagLGObi/iWx8XckPQkzgkvDp9bW4IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnHIhS%2FdJMcagLGObi%2FiWx8XckPQkzgkvDp9bW4IK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;656&quot; height=&quot;446&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;998&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 참여자 입장에서 여러 이벤트를 보며 늘 아쉬웠던 점이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응모형 이벤트는 당첨 과정이 잘 보이지 않다 보니,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가끔은 &amp;ldquo;정말 주는 건가?&amp;rdquo; 싶은 순간이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 나중에 내가 서비스를 만들게 된다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적어도 이벤트만큼은 결과가 더 분명하게 보이는 방식으로 해보고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 회의에서도 비슷한 의견이 나왔고, 팀원들 역시 선착순 이벤트에 찬성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 시작된 것이 봄봄의 첫 이벤트였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예산이 넉넉한 편은 아니었지만, 그래도 총 &lt;b&gt;70개의 메가커피 기프티콘&lt;/b&gt;을 상품으로 준비했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 참여 인원은 대략 &lt;b&gt;1,000명&lt;/b&gt; 정도로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 선착순 이벤트가 생각보다 단순하지 않았다는 점이다.&lt;br /&gt;짧은 시간에 요청이 한꺼번에 몰릴 가능성이 높았고, 여기에 새로고침이나 재시도까지 겹치면 순간 트래픽은 예상 참여 인원보다 훨씬 커질 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 봄봄은 웹도 지원하고 있었기 때문에, 여러 개의 탭을 띄워 거의 동시에 시도하는 경우도 고려해야 했다.&lt;br /&gt;실제로 선착순 이벤트를 할 때 탭을 2~3개 열어두고 동시에 시도하는 사람은 꽤 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;906&quot; data-start=&quot;886&quot; data-ke-size=&quot;size16&quot;&gt;그래서 최악의 시나리오를 먼저 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;906&quot; data-start=&quot;886&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1398&quot; data-start=&quot;1318&quot; data-ke-size=&quot;size16&quot;&gt;1,000명 중 80%가 오픈 직후 첫 1~2초 안에 진입한다고 가정하면,&lt;br /&gt;진입 요청만으로도 순간적으로 약 400~800 TPS가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1398&quot; data-start=&quot;1318&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1466&quot; data-start=&quot;1400&quot; data-ke-size=&quot;size16&quot;&gt;여기에 사용자가 평균 2개의 탭을 띄운다고 가정하면,&lt;br /&gt;순간 트래픽은 약 800~1,600 TPS 수준까지 올라간다.&lt;/p&gt;
&lt;p data-end=&quot;1466&quot; data-start=&quot;1400&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1581&quot; data-start=&quot;1468&quot; data-ke-size=&quot;size16&quot;&gt;그리고 실제 운영에서는 재시도, 네트워크 지연, 예상을 벗어난 동시 진입 같은 변수도 충분히 생길 수 있다.&lt;br /&gt;그래서 우리는 어느 정도 여유를 두고, 목표치를 &lt;b&gt;피크 2,000 TPS&lt;/b&gt;로 잡았다.&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1583&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1583&quot; data-ke-size=&quot;size16&quot;&gt;즉 이번 이벤트는 단순한 기능 구현이 아니라,&lt;br /&gt;짧은 순간에 몰리는 대량 요청을 어떻게 안전하게 처리할 것인가까지 함께 풀어야 하는 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리해보면 요구사항은 아래와 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;선착순 발급 순서 보장&lt;/li&gt;
&lt;li&gt;목표 TPS 2,000&lt;/li&gt;
&lt;li&gt;1인당 1개의 쿠폰만 발급 보장&lt;/li&gt;
&lt;li&gt;이벤트 종료이후 발급 차단&lt;/li&gt;
&lt;li&gt;발급 성공/실패를 명확히 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;고민사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;순간적인 엄청난 트래픽을 어떻게 감당해낼지?&lt;/li&gt;
&lt;li&gt;발급 시점에 DB 경합(락/커넥션 고갈)을 어떻게 해결할지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 환경&lt;/b&gt;에서 동시성을 어떻게 잡을지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;현재 상태&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;지금 구조로는 2000TPS는 절대 못 버틴다&amp;rdquo;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 아키텍처를 아주 간단하게 그리면 아래처럼 분산서버로 되어있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UGCqD/dJMcahDjriH/qtT2LAQ1zmjGdJz4DjL3Ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UGCqD/dJMcahDjriH/qtT2LAQ1zmjGdJz4DjL3Ck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UGCqD/dJMcahDjriH/qtT2LAQ1zmjGdJz4DjL3Ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUGCqD%2FdJMcahDjriH%2FqtT2LAQ1zmjGdJz4DjL3Ck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;606&quot; height=&quot;265&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &amp;ldquo;API 서버가 분산이냐&amp;rdquo;가 아니라, &lt;b&gt;순간적으로 수많은 요청이 몰릴 때 시스템이 버티느냐&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;778&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;현재 운영 환경의 피크 TPS는 250 정도 버틸 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 상태에서 기능만 구현해버리면 서버 스레드/커넥션 풀 대기 증가 &amp;rarr; 응답 지연/타임아웃, 이로인한 사용자 경험 붕괴 그리고 최악은&amp;hellip;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이벤트 때문에 기존 서비스까지 영향을 받는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 어떻게 할지 고민을 많았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1차 문제 :&amp;nbsp; 운영서버에 이벤트 로직을?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 운영 서버에 이벤트 관련 로직을 넣는게 가장 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 현재 운영서버에 아무리 설계를 해봐도 운영서버로는 한 번에 수 많은 요청을 처리할 수 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 무작정 스케일 아웃하기에도 비용적 부담도 있었고 혹시라도 예측이 틀려서 기존에 서비스를 사용하는 유저가 이벤트 때문에 이용하지 못하는 경우는 없어야 된다고 생각했다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1426&quot; data-start=&quot;1394&quot; data-ke-size=&quot;size26&quot;&gt;1차 해결: 이벤트 서버로 분리&lt;/h2&gt;
&lt;p data-end=&quot;1526&quot; data-start=&quot;1428&quot; data-ke-size=&quot;size16&quot;&gt;이벤트는 시작 순간에 트래픽이 급격히 몰린다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1526&quot; data-start=&quot;1428&quot; data-ke-size=&quot;size16&quot;&gt;따라서 이벤트 트래픽을 &lt;b&gt;운영 서버&amp;nbsp;API&lt;/b&gt;와 분리한 &lt;b&gt;이벤트 서버&lt;/b&gt;로 받는 것이 1차적인 안정화 전략이다.&lt;/p&gt;
&lt;p data-end=&quot;378&quot; data-start=&quot;304&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 설사 이벤트 서버가 다운되더라도, &lt;b&gt;기존 서비스 이용은 큰 문제가 없게 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;378&quot; data-start=&quot;304&quot; data-ke-size=&quot;size16&quot;&gt;어차피 이벤트 할 때만 서버를 사용하기에 비용적 부담도 적었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1048&quot; data-origin-height=&quot;584&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beZODY/dJMcadnNcrY/KMe1ixq3vog99MtlgliSIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beZODY/dJMcadnNcrY/KMe1ixq3vog99MtlgliSIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beZODY/dJMcadnNcrY/KMe1ixq3vog99MtlgliSIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeZODY%2FdJMcadnNcrY%2FKMe1ixq3vog99MtlgliSIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;607&quot; height=&quot;338&quot; data-origin-width=&quot;1048&quot; data-origin-height=&quot;584&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-end=&quot;271&quot; data-start=&quot;239&quot; data-section-id=&quot;j1qd3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;차 &lt;/span&gt;&lt;span&gt;문제: &lt;/span&gt;&lt;span&gt;제일 &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;span&gt;트래픽을 &lt;/span&gt;&lt;span&gt;받는 &lt;/span&gt;&lt;span&gt;곳은&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;409&quot; data-start=&quot;273&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버를 &lt;/span&gt;&lt;span&gt;분리했다면, &lt;/span&gt;&lt;span&gt;다음 &lt;/span&gt;&lt;span&gt;고민은 &lt;/span&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;트래픽의 &lt;/span&gt;&lt;span&gt;입구를 &lt;/span&gt;&lt;span&gt;어떻게 &lt;/span&gt;&lt;span&gt;구성할 &lt;/span&gt;&lt;span&gt;것인가&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;였다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트에서는 &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;자체도 &lt;/span&gt;&lt;span&gt;중요하지만, &lt;/span&gt;&lt;span&gt;그보다 &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;b&gt;&lt;span&gt;모든 &lt;/span&gt;&lt;span&gt;요청이 &lt;/span&gt;&lt;span&gt;처음 &lt;/span&gt;&lt;span&gt;도착하는 &lt;/span&gt;&lt;span&gt;지점이 과연 수 많은 요청을 받을 수 있는지 확인해야한다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;499&quot; data-start=&quot;411&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;뒤쪽 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버를 &lt;/span&gt;&lt;span&gt;여러 &lt;/span&gt;&lt;span&gt;대로 &lt;/span&gt;&lt;span&gt;늘려 &lt;/span&gt;&lt;span&gt;두더라도, &lt;/span&gt;&lt;span&gt;정작 &lt;/span&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;앞단에서 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;제대로 &lt;/span&gt;&lt;span&gt;받아주지 &lt;/span&gt;&lt;span&gt;못하면 &lt;/span&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;뒤의 &lt;/span&gt;&lt;span&gt;확장성은 &lt;/span&gt;&lt;span&gt;아무 &lt;/span&gt;&lt;span&gt;의미가 &lt;/span&gt;&lt;span&gt;없어지기 &lt;/span&gt;&lt;span&gt;때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;599&quot; data-start=&quot;501&quot; data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 &lt;b&gt;&lt;span&gt;트래픽이 &lt;/span&gt;&lt;span&gt;얼마나 &lt;/span&gt;&lt;span&gt;몰릴지 &lt;/span&gt;&lt;span&gt;예측할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;없는 &lt;/span&gt;&lt;span&gt;상황에서 &lt;/span&gt;&lt;span&gt;입구를 어떻게 구성할까였다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;623&quot; data-start=&quot;601&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그래서 &lt;/span&gt;&lt;span&gt;몇 &lt;/span&gt;&lt;span&gt;가지 &lt;/span&gt;&lt;span&gt;선택지를 &lt;/span&gt;&lt;span&gt;두고 &lt;/span&gt;&lt;span&gt;비교했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1526&quot; data-start=&quot;1428&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;669&quot; data-start=&quot;630&quot; data-section-id=&quot;6i723i&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;방법 1: &lt;/span&gt;&lt;span&gt;EC2 &lt;/span&gt;&lt;span&gt;인스턴스에 &lt;/span&gt;&lt;span&gt;HAProxy&lt;/span&gt;&lt;span&gt;를 &lt;/span&gt;&lt;span&gt;두고 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;로드밸런싱하기&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;756&quot; data-start=&quot;671&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;span&gt;떠오른 &lt;/span&gt;&lt;span&gt;선택지는 &lt;/span&gt;&lt;span&gt;별도 &lt;/span&gt;&lt;span&gt;EC2 &lt;/span&gt;&lt;span&gt;인스턴스에 &lt;/span&gt;&lt;span&gt;HAProxy &lt;/span&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;리버스 &lt;/span&gt;&lt;span&gt;프록시를 &lt;/span&gt;&lt;span&gt;두고,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;인스턴스가 &lt;/span&gt;&lt;span&gt;로드밸런서 &lt;/span&gt;&lt;span&gt;역할을 &lt;/span&gt;&lt;span&gt;하게 &lt;/span&gt;&lt;span&gt;만드는 &lt;/span&gt;&lt;span&gt;방식이었다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;773&quot; data-start=&quot;758&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;방식의 &lt;/span&gt;&lt;span&gt;장점은 &lt;/span&gt;&lt;span&gt;분명하다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;848&quot; data-start=&quot;775&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;798&quot; data-start=&quot;775&quot; data-section-id=&quot;8heh9v&quot;&gt;&lt;span&gt;익숙한 &lt;/span&gt;&lt;span&gt;도구로 &lt;/span&gt;&lt;span&gt;빠르게 &lt;/span&gt;&lt;span&gt;구성할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있있어서 러닝커브가 전혀없다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;822&quot; data-start=&quot;799&quot; data-section-id=&quot;1s401x2&quot;&gt;&lt;span&gt;처음에는 &lt;/span&gt;&lt;span&gt;작은 &lt;/span&gt;&lt;span&gt;비용으로 &lt;/span&gt;&lt;span&gt;시작하기 &lt;/span&gt;&lt;span&gt;쉽다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;848&quot; data-start=&quot;823&quot; data-section-id=&quot;161uhlu&quot;&gt;&lt;span&gt;세부 &lt;/span&gt;&lt;span&gt;튜닝 &lt;/span&gt;&lt;span&gt;포인트를 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;통제할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;883&quot; data-start=&quot;850&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;작게 &lt;/span&gt;&lt;span&gt;시작해보기&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;에는 &lt;/span&gt;&lt;span&gt;꽤 &lt;/span&gt;&lt;span&gt;매력적인 &lt;/span&gt;&lt;span&gt;선택지다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;917&quot; data-start=&quot;885&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 &lt;/span&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;관점에서는 &lt;/span&gt;&lt;span&gt;단점이 &lt;/span&gt;&lt;span&gt;훨씬 &lt;/span&gt;&lt;span&gt;크게 &lt;/span&gt;&lt;span&gt;보였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;917&quot; data-start=&quot;885&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;첫째, &lt;/span&gt;&lt;span&gt;로드밸런서 &lt;/span&gt;&lt;span&gt;역할을 &lt;/span&gt;&lt;span&gt;하는 &lt;/span&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;자체가 &lt;/span&gt;&lt;span&gt;단일 &lt;/span&gt;&lt;span&gt;장애 &lt;/span&gt;&lt;span&gt;지점 즉, &lt;/span&gt;&lt;span&gt;SPOF&lt;/span&gt;&lt;/b&gt;&lt;span&gt;가&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;된다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;현재 운영의 로드밸런서를 담당하는 인스턴스는&amp;nbsp; t4g.&lt;/span&gt;&lt;span&gt;nano이다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;물론 해당 스펙을 가지고 그대로 이벤트 서버에도 적용할 수는 없다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그런다고 스펙을 올린다면 버틸 수는 있을 것 같다.&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;하지만 그건 &lt;/span&gt;&lt;span&gt;어디까지나 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;큰 &lt;/span&gt;&lt;span&gt;단일 인스턴스가&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;되는 &lt;/span&gt;&lt;span&gt;것에 &lt;/span&gt;&lt;span&gt;가깝다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;트래픽이 &lt;/span&gt;&lt;span&gt;예측 &lt;/span&gt;&lt;span&gt;범위를 &lt;/span&gt;&lt;span&gt;넘는 &lt;/span&gt;&lt;span&gt;순간, &lt;/span&gt;&lt;span&gt;병목은 &lt;/span&gt;&lt;span&gt;뒤쪽 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;아니라 &lt;/span&gt;&lt;b&gt;&lt;span&gt;입구 &lt;/span&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;하나&lt;/span&gt;&lt;/b&gt;&lt;span&gt;에 &lt;/span&gt;&lt;span&gt;집중될 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;물론 &lt;/span&gt;&lt;span&gt;Route &lt;/span&gt;&lt;span&gt;53&lt;/span&gt;&lt;span&gt;의 &lt;/span&gt;&lt;span&gt;failover &lt;/span&gt;&lt;span&gt;라우팅을 &lt;/span&gt;&lt;span&gt;사용하면 &lt;/span&gt;&lt;span&gt;장애 &lt;/span&gt;&lt;span&gt;시 &lt;/span&gt;&lt;span&gt;다른 &lt;/span&gt;&lt;span&gt;대상으로 &lt;/span&gt;&lt;span&gt;우회시키는 &lt;/span&gt;&lt;span&gt;구성은 &lt;/span&gt;&lt;span&gt;가능하다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;또는&amp;nbsp;&lt;/span&gt;&lt;span&gt;Auto &lt;/span&gt;&lt;span&gt;Scaling&lt;/span&gt;&lt;span&gt;을 &lt;/span&gt;&lt;span&gt;통해 &lt;/span&gt;&lt;span&gt;장애가 &lt;/span&gt;&lt;span&gt;난 &lt;/span&gt;&lt;span&gt;로드밸런서 &lt;/span&gt;&lt;span&gt;인스턴스를 &lt;/span&gt;&lt;span&gt;새 &lt;/span&gt;&lt;span&gt;인스턴스로 &lt;/span&gt;&lt;span&gt;교체하도록 &lt;/span&gt;&lt;span&gt;만들 &lt;/span&gt;&lt;span&gt;수도 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1246&quot; data-start=&quot;979&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;방식들은 &lt;/span&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트의 &amp;ldquo;&lt;/span&gt;&lt;span&gt;첫 &lt;/span&gt;&lt;span&gt;관문&amp;rdquo;&lt;/span&gt;&lt;span&gt;을 &lt;/span&gt;&lt;span&gt;맡기기에는 &lt;/span&gt;&lt;span&gt;한계가 &lt;/span&gt;&lt;span&gt;있었다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;Auto &lt;/span&gt;&lt;span&gt;Scaling &lt;/span&gt;&lt;span&gt;기반 &lt;/span&gt;&lt;span&gt;교체는 &lt;/span&gt;&lt;span&gt;새 &lt;/span&gt;&lt;span&gt;인스턴스가 &lt;/span&gt;&lt;span&gt;기동되고 &lt;/span&gt;&lt;span&gt;서비스 &lt;/span&gt;&lt;span&gt;가능 &lt;/span&gt;&lt;span&gt;상태가 &lt;/span&gt;&lt;span&gt;되기까지 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;분이 &lt;/span&gt;&lt;span&gt;걸릴 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있고,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;Route &lt;/span&gt;&lt;span&gt;53 &lt;/span&gt;&lt;span&gt;failover&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;DNS &lt;/span&gt;&lt;span&gt;기반 &lt;/span&gt;&lt;span&gt;전환이기 &lt;/span&gt;&lt;span&gt;때문에 &lt;/span&gt;&lt;span&gt;캐시와 &lt;/span&gt;&lt;span&gt;전파 &lt;/span&gt;&lt;span&gt;지연의 &lt;/span&gt;&lt;span&gt;영향을 &lt;/span&gt;&lt;span&gt;받는다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;span&gt;둘 &lt;/span&gt;&lt;span&gt;다 &lt;/span&gt;&lt;b&gt;&lt;span&gt;장애 &lt;/span&gt;&lt;span&gt;복구 &lt;/span&gt;&lt;span&gt;수단&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;으로는 &lt;/span&gt;&lt;span&gt;의미가 &lt;/span&gt;&lt;span&gt;있지만,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;짧은 &lt;/span&gt;&lt;span&gt;순간에 &lt;/span&gt;&lt;span&gt;대량 &lt;/span&gt;&lt;span&gt;요청이 &lt;/span&gt;&lt;span&gt;몰리는 &lt;/span&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트에서 &lt;/span&gt;&lt;b&gt;&lt;span&gt;입구를 &lt;/span&gt;&lt;span&gt;즉시 &lt;/span&gt;&lt;span&gt;안정적으로 &lt;/span&gt;&lt;span&gt;유지하는 &lt;/span&gt;&lt;span&gt;방식&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;으로 &lt;/span&gt;&lt;span&gt;보기는 &lt;/span&gt;&lt;span&gt;어려웠다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;919&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1287&quot; data-start=&quot;1124&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;둘째, &lt;/span&gt;&lt;span&gt;운영 &lt;/span&gt;&lt;span&gt;부담이 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;생긴다.&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span&gt;커넥션 &lt;/span&gt;&lt;span&gt;수, &lt;/span&gt;&lt;span&gt;커널 &lt;/span&gt;&lt;span&gt;파라미터, &lt;/span&gt;&lt;span&gt;헬스체크, &lt;/span&gt;&lt;span&gt;장애 &lt;/span&gt;&lt;span&gt;복구, &lt;/span&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;교체 &lt;/span&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;문제를 &lt;/span&gt;&lt;span&gt;전부 &lt;/span&gt;&lt;span&gt;스스로 &lt;/span&gt;&lt;span&gt;챙겨야 &lt;/span&gt;&lt;span&gt;한다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;평소 &lt;/span&gt;&lt;span&gt;트래픽이 &lt;/span&gt;&lt;span&gt;안정적이라면 &lt;/span&gt;&lt;span&gt;괜찮지만, &lt;/span&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트처럼 &lt;/span&gt;&lt;b&gt;&lt;span&gt;짧은 &lt;/span&gt;&lt;span&gt;시간에 &lt;/span&gt;&lt;span&gt;요청이 &lt;/span&gt;&lt;span&gt;급격히 &lt;/span&gt;&lt;span&gt;몰릴 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는 &lt;/span&gt;&lt;span&gt;상황&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;에서는 &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;부담이 &lt;/span&gt;&lt;span&gt;꽤 &lt;/span&gt;&lt;span&gt;크게 &lt;/span&gt;&lt;span&gt;느껴졌다 &lt;/span&gt;&lt;span&gt;, 즉&amp;nbsp;&lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;방식은 &lt;/span&gt;&lt;b&gt;&lt;span&gt;비용과 &lt;/span&gt;&lt;span&gt;단순함 &lt;/span&gt;&lt;span&gt;면에서는 &lt;/span&gt;&lt;span&gt;장점이 &lt;/span&gt;&lt;span&gt;있지만&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;예상치 못한 트래픽에서 가장 &lt;/span&gt;&lt;span&gt;앞단을 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;운영해야 &lt;/span&gt;&lt;span&gt;한다는 &lt;/span&gt;&lt;span&gt;점에서 &lt;/span&gt;&lt;span&gt;구조적인 &lt;/span&gt;&lt;span&gt;리스크가 &lt;/span&gt;&lt;span&gt;컸다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1402&quot; data-start=&quot;1369&quot; data-section-id=&quot;1ai0xw0&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;방법 2: &lt;/span&gt;&lt;span&gt;NLB(&lt;/span&gt;&lt;span&gt;Network &lt;/span&gt;&lt;span&gt;Load &lt;/span&gt;&lt;span&gt;Balancer)&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;1441&quot; data-start=&quot;1404&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 &lt;/span&gt;&lt;span&gt;번째로 고려해본 건&amp;nbsp;&lt;/span&gt;&lt;span&gt;AWS&lt;/span&gt;&lt;span&gt;의 &lt;/span&gt;&lt;span&gt;관리형 &lt;/span&gt;&lt;span&gt;로드밸런서인 &lt;/span&gt;&lt;b&gt;&lt;span&gt;NLB&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1607&quot; data-start=&quot;1443&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;NLB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;L4 &lt;/span&gt;&lt;span&gt;계층에서 &lt;/span&gt;&lt;span&gt;동작하고, &lt;/span&gt;&lt;span&gt;AWS &lt;/span&gt;&lt;span&gt;공식 &lt;/span&gt;&lt;span&gt;문서에서도 &lt;/span&gt;&lt;b&gt;&lt;span&gt;급격하고 &lt;/span&gt;&lt;span&gt;변동성 &lt;/span&gt;&lt;span&gt;큰 &lt;/span&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;패턴에 &lt;/span&gt;&lt;span&gt;최적화&lt;/span&gt;&lt;/b&gt;&lt;span&gt;되어 &lt;/span&gt;&lt;span&gt;있다고 &lt;/span&gt;&lt;span&gt;안내한다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;또 &lt;/span&gt;&lt;span&gt;AZ&lt;/span&gt;&lt;span&gt;별 &lt;/span&gt;&lt;span&gt;static &lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;를 &lt;/span&gt;&lt;span&gt;제공할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있고, &lt;/span&gt;&lt;span&gt;매우 &lt;/span&gt;&lt;span&gt;낮은 &lt;/span&gt;&lt;span&gt;지연 &lt;/span&gt;&lt;span&gt;시간과 &lt;/span&gt;&lt;span&gt;높은 &lt;/span&gt;&lt;span&gt;처리량이 &lt;/span&gt;&lt;span&gt;강점이다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1736&quot; data-start=&quot;1609&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;특성만 &lt;/span&gt;&lt;span&gt;보면, &amp;ldquo;&lt;/span&gt;&lt;span&gt;순간적으로 &lt;/span&gt;&lt;span&gt;몰리는 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;트래픽&amp;rdquo;&lt;/span&gt;&lt;span&gt;과 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;맞아 &lt;/span&gt;&lt;span&gt;보인다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;실제로 &lt;/span&gt;&lt;span&gt;초고성능 &lt;/span&gt;&lt;span&gt;TCP &lt;/span&gt;&lt;span&gt;처리나 &lt;/span&gt;&lt;span&gt;정적 &lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;중요한 &lt;/span&gt;&lt;span&gt;환경이라면 &lt;/span&gt;&lt;span&gt;굉장히 &lt;/span&gt;&lt;span&gt;좋은 &lt;/span&gt;&lt;span&gt;선택지다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1768&quot; data-start=&quot;1738&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 &lt;/span&gt;&lt;span&gt;이번에는 &lt;/span&gt;&lt;span&gt;몇 &lt;/span&gt;&lt;span&gt;가지 &lt;/span&gt;&lt;span&gt;이유로 &lt;/span&gt;&lt;span&gt;최종 &lt;/span&gt;&lt;span&gt;선택에서는 &lt;/span&gt;&lt;span&gt;밀렸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1803&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;우리에게 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;중요했던 &lt;/span&gt;&lt;span&gt;건 &lt;/span&gt;&lt;span&gt;단순한 &lt;/span&gt;&lt;span&gt;L4 &lt;/span&gt;&lt;span&gt;전달 &lt;/span&gt;&lt;span&gt;성능만이 &lt;/span&gt;&lt;span&gt;아니라,&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1914&quot; data-start=&quot;1805&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1831&quot; data-start=&quot;1805&quot; data-section-id=&quot;5wu6u4&quot;&gt;&lt;b&gt;&lt;span&gt;HTTP &lt;/span&gt;&lt;span&gt;레벨에서 &lt;/span&gt;&lt;span&gt;다루기 &lt;/span&gt;&lt;span&gt;쉬운 &lt;/span&gt;&lt;span&gt;운영성&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1860&quot; data-start=&quot;1832&quot; data-section-id=&quot;1h6jgfm&quot;&gt;&lt;b&gt;&lt;span&gt;웹 &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;앞단으로서의 &lt;/span&gt;&lt;span&gt;관리 &lt;/span&gt;&lt;span&gt;편의성&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1882&quot; data-start=&quot;1861&quot; data-section-id=&quot;ig6x5&quot;&gt;&lt;b&gt;&lt;span&gt;WAF &lt;/span&gt;&lt;span&gt;연동 &lt;/span&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;보안 &lt;/span&gt;&lt;span&gt;구성&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1914&quot; data-start=&quot;1883&quot; data-section-id=&quot;1e9e5po&quot;&gt;&lt;b&gt;&lt;span&gt;타깃 &lt;/span&gt;&lt;span&gt;상태를 &lt;/span&gt;&lt;span&gt;보고 &lt;/span&gt;&lt;span&gt;빠르게 &lt;/span&gt;&lt;span&gt;제외/&lt;/span&gt;&lt;span&gt;복구시키는 &lt;/span&gt;&lt;span&gt;흐름&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1926&quot; data-start=&quot;1916&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;요소들이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2147&quot; data-start=&quot;1928&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히 &lt;/span&gt;&lt;span&gt;AWS &lt;/span&gt;&lt;span&gt;WAF&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;대표적으로 &lt;/span&gt;&lt;span&gt;CloudFront, &lt;/span&gt;&lt;span&gt;API &lt;/span&gt;&lt;span&gt;Gateway, &lt;/span&gt;&lt;span&gt;그리고 &lt;/span&gt;&lt;b&gt;&lt;span&gt;Application &lt;/span&gt;&lt;span&gt;Load &lt;/span&gt;&lt;span&gt;Balancer&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;와 &lt;/span&gt;&lt;span&gt;연동되는 &lt;/span&gt;&lt;span&gt;서비스로 &lt;/span&gt;&lt;span&gt;안내된다. &lt;/span&gt;&lt;span&gt;이번 &lt;/span&gt;&lt;span&gt;이벤트는 &lt;/span&gt;&lt;span&gt;웹 &lt;/span&gt;&lt;span&gt;요청&lt;/span&gt;&lt;span&gt;을 &lt;/span&gt;&lt;span&gt;받는 &lt;/span&gt;&lt;span&gt;구조였기 &lt;/span&gt;&lt;span&gt;때문에, &lt;/span&gt;&lt;span&gt;순수한 &lt;/span&gt;&lt;span&gt;연결 &lt;/span&gt;&lt;span&gt;레벨 &lt;/span&gt;&lt;span&gt;처리보다 &lt;/span&gt;&lt;b&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;레벨에서 &lt;/span&gt;&lt;span&gt;다루기 &lt;/span&gt;&lt;span&gt;쉬운 &lt;/span&gt;&lt;span&gt;선택&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;적합하다고 &lt;/span&gt;&lt;span&gt;판단했다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2225&quot; data-start=&quot;2149&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;span&gt;NLB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;성능 &lt;/span&gt;&lt;span&gt;면에서는 &lt;/span&gt;&lt;span&gt;매우 &lt;/span&gt;&lt;span&gt;강력하지만,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;이번 &lt;/span&gt;&lt;span&gt;문제에서는 &amp;ldquo;&lt;/span&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;앞단의 &lt;/span&gt;&lt;span&gt;웹 &lt;/span&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;운영&amp;rdquo;&lt;/span&gt;&lt;span&gt;이라는 &lt;/span&gt;&lt;span&gt;목적과 &lt;/span&gt;&lt;span&gt;완전히 &lt;/span&gt;&lt;span&gt;일치하지는 &lt;/span&gt;&lt;span&gt;않았다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-end=&quot;2230&quot; data-start=&quot;2227&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2269&quot; data-start=&quot;2232&quot; data-section-id=&quot;1eop796&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;방버 3: &lt;/span&gt;&lt;span&gt;ALB(&lt;/span&gt;&lt;span&gt;Application &lt;/span&gt;&lt;span&gt;Load &lt;/span&gt;&lt;span&gt;Balancer)&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;2455&quot; data-start=&quot;2296&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;계층(&lt;/span&gt;&lt;span&gt;L7)&lt;/span&gt;&lt;span&gt;에서 &lt;/span&gt;&lt;span&gt;동작하고,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;AWS &lt;/span&gt;&lt;span&gt;문서 &lt;/span&gt;&lt;span&gt;기준으로 &lt;/span&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;변화에 &lt;/span&gt;&lt;span&gt;따라 &lt;/span&gt;&lt;span&gt;자동으로 &lt;/span&gt;&lt;span&gt;확장되며,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;헬스 &lt;/span&gt;&lt;span&gt;체크를 &lt;/span&gt;&lt;span&gt;통해 &lt;/span&gt;&lt;span&gt;정상 &lt;/span&gt;&lt;span&gt;타깃에만 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;전달할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2455&quot; data-start=&quot;2296&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 &lt;/span&gt;&lt;span&gt;타깃은 &lt;/span&gt;&lt;span&gt;유연하게 &lt;/span&gt;&lt;span&gt;추가/&lt;/span&gt;&lt;span&gt;제거할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2596&quot; data-start=&quot;2457&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;게다가&amp;nbsp;&lt;/span&gt;&lt;span&gt;ELB &lt;/span&gt;&lt;span&gt;계열은 &lt;/span&gt;&lt;span&gt;멀티 &lt;/span&gt;&lt;span&gt;AZ &lt;/span&gt;&lt;span&gt;구성이 &lt;/span&gt;&lt;span&gt;가능하고, &lt;/span&gt;&lt;span&gt;AWS&lt;/span&gt;&lt;span&gt;에서도 &lt;/span&gt;&lt;span&gt;둘 &lt;/span&gt;&lt;span&gt;이상의 &lt;/span&gt;&lt;span&gt;AZ&lt;/span&gt;&lt;span&gt;에 &lt;/span&gt;&lt;span&gt;걸쳐 &lt;/span&gt;&lt;span&gt;운영하는 &lt;/span&gt;&lt;span&gt;구성을 &lt;/span&gt;&lt;span&gt;권장하는 &lt;/span&gt;&lt;span&gt;흐름을 &lt;/span&gt;&lt;span&gt;확인할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2596&quot; data-start=&quot;2457&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이런 &lt;/span&gt;&lt;span&gt;점은 &lt;/span&gt;&lt;span&gt;앞단의 &lt;/span&gt;&lt;span&gt;가용성을 &lt;/span&gt;&lt;span&gt;높이는 &lt;/span&gt;&lt;span&gt;데 &lt;/span&gt;&lt;span&gt;유리하다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2624&quot; data-start=&quot;2598&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;의 &lt;/span&gt;&lt;span&gt;장점은 &lt;/span&gt;&lt;span&gt;이번 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;상황과 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;맞았다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2862&quot; data-start=&quot;2626&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2645&quot; data-start=&quot;2626&quot; data-section-id=&quot;e5s54x&quot;&gt;&lt;span&gt;앞단을 &lt;/span&gt;&lt;span&gt;관리형으로 &lt;/span&gt;&lt;span&gt;둘 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;2726&quot; data-start=&quot;2646&quot; data-section-id=&quot;pcw1g6&quot;&gt;&lt;span&gt;특정 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;비정상이면 &lt;/span&gt;&lt;span&gt;헬스 &lt;/span&gt;&lt;span&gt;체크 &lt;/span&gt;&lt;span&gt;기반으로 &lt;/span&gt;&lt;span&gt;자동 &lt;/span&gt;&lt;span&gt;제외할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다. &lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;2807&quot; data-start=&quot;2727&quot; data-section-id=&quot;14x2pnh&quot;&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;증가를 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;프록시 &lt;/span&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;한 &lt;/span&gt;&lt;span&gt;대로 &lt;/span&gt;&lt;span&gt;받아내는 &lt;/span&gt;&lt;span&gt;구조보다 &lt;/span&gt;&lt;span&gt;안전하다. &lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;2862&quot; data-start=&quot;2808&quot; data-section-id=&quot;1gi3d7&quot;&gt;&lt;span&gt;WAF &lt;/span&gt;&lt;span&gt;연동이 &lt;/span&gt;&lt;span&gt;자연스럽다. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2880&quot; data-start=&quot;2864&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;물론 &lt;/span&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;에도 &lt;/span&gt;&lt;span&gt;단점은 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3066&quot; data-start=&quot;2882&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2914&quot; data-start=&quot;2882&quot; data-section-id=&quot;1q5plvc&quot;&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;프록시를 &lt;/span&gt;&lt;span&gt;운영하는 &lt;/span&gt;&lt;span&gt;것보다 &lt;/span&gt;&lt;span&gt;비용이 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;들 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3013&quot; data-start=&quot;2915&quot; data-section-id=&quot;1jq7368&quot;&gt;&lt;span&gt;L4 &lt;/span&gt;&lt;span&gt;중심의 &lt;/span&gt;&lt;span&gt;극단적인 &lt;/span&gt;&lt;span&gt;성능 &lt;/span&gt;&lt;span&gt;최적화나 &lt;/span&gt;&lt;span&gt;static &lt;/span&gt;&lt;span&gt;IP &lt;/span&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;요구사항에서는 &lt;/span&gt;&lt;span&gt;NLB&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;맞을 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다. &lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3066&quot; data-start=&quot;3014&quot; data-section-id=&quot;1fl790t&quot;&gt;&lt;span&gt;로드밸런서 &lt;/span&gt;&lt;span&gt;동작을 &lt;/span&gt;&lt;span&gt;세밀하게 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;제어하는 &lt;/span&gt;&lt;span&gt;자유도는 &lt;/span&gt;&lt;span&gt;self-&lt;/span&gt;&lt;span&gt;managed &lt;/span&gt;&lt;span&gt;프록시보다 &lt;/span&gt;&lt;span&gt;낮다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3094&quot; data-start=&quot;3068&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그럼에도 &lt;/span&gt;&lt;span&gt;이번에는 &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;단점보다 &lt;/span&gt;&lt;span&gt;장점이 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;컸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3172&quot; data-start=&quot;3096&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;우리가 &lt;/span&gt;&lt;span&gt;정말 &lt;/span&gt;&lt;span&gt;피하고 &lt;/span&gt;&lt;span&gt;싶었던 &lt;/span&gt;&lt;span&gt;것은&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;트래픽의 &lt;/span&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;첫 &lt;/span&gt;&lt;span&gt;번째 &lt;/span&gt;&lt;span&gt;관문을 &lt;/span&gt;&lt;span&gt;단일 &lt;/span&gt;&lt;span&gt;인스턴스로 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;책임지는 &lt;/span&gt;&lt;span&gt;상황&lt;/span&gt;&lt;/b&gt;&lt;span&gt;이었기 &lt;/span&gt;&lt;span&gt;때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1526&quot; data-start=&quot;1428&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;3194&quot; data-start=&quot;3179&quot; data-section-id=&quot;tc9igu&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2차 문제 해결: &lt;/span&gt;&lt;span&gt;ALB로 결정!&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;3212&quot; data-start=&quot;3196&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정리하면 &lt;/span&gt;&lt;span&gt;판단 &lt;/span&gt;&lt;span&gt;기준은 &lt;/span&gt;&lt;span&gt;이랬다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3304&quot; data-start=&quot;3214&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;HAProxy&lt;/span&gt;&lt;span&gt;를 &lt;/span&gt;&lt;span&gt;EC2&lt;/span&gt;&lt;span&gt;에 &lt;/span&gt;&lt;span&gt;직접 &lt;/span&gt;&lt;span&gt;올리는 &lt;/span&gt;&lt;span&gt;방식은&lt;/span&gt;&lt;br /&gt;&lt;span&gt;작고 &lt;/span&gt;&lt;span&gt;빠르게 &lt;/span&gt;&lt;span&gt;시작하기엔 &lt;/span&gt;&lt;span&gt;좋지만,&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;입구 &lt;/span&gt;&lt;span&gt;자체가 &lt;/span&gt;&lt;span&gt;단일 &lt;/span&gt;&lt;span&gt;병목이 &lt;/span&gt;&lt;span&gt;되기 &lt;/span&gt;&lt;span&gt;쉽고 &lt;/span&gt;&lt;span&gt;운영 &lt;/span&gt;&lt;span&gt;부담이 &lt;/span&gt;&lt;span&gt;모두 &lt;/span&gt;&lt;span&gt;우리에게 &lt;/span&gt;&lt;span&gt;남는다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;3304&quot; data-start=&quot;3214&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3445&quot; data-start=&quot;3306&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;NLB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;급격한 &lt;/span&gt;&lt;span&gt;트래픽과 &lt;/span&gt;&lt;span&gt;고성능 &lt;/span&gt;&lt;span&gt;연결 &lt;/span&gt;&lt;span&gt;처리에는 &lt;/span&gt;&lt;span&gt;매우 &lt;/span&gt;&lt;span&gt;강력하지만,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;이번처럼 &lt;/span&gt;&lt;span&gt;웹 &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;앞단에서 &lt;/span&gt;&lt;b&gt;&lt;span&gt;운영성과 &lt;/span&gt;&lt;span&gt;보안 &lt;/span&gt;&lt;span&gt;연동까지 &lt;/span&gt;&lt;span&gt;함께 &lt;/span&gt;&lt;span&gt;고려하는 &lt;/span&gt;&lt;span&gt;상황&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;에서는 &lt;/span&gt;&lt;span&gt;다소 &lt;/span&gt;&lt;span&gt;결이 &lt;/span&gt;&lt;span&gt;달랐다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3591&quot; data-start=&quot;3447&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3591&quot; data-start=&quot;3447&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;반면 &lt;/span&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;변화 &lt;/span&gt;&lt;span&gt;대응, &lt;/span&gt;&lt;span&gt;헬스 &lt;/span&gt;&lt;span&gt;체크 &lt;/span&gt;&lt;span&gt;기반 &lt;/span&gt;&lt;span&gt;타깃 &lt;/span&gt;&lt;span&gt;제외, &lt;/span&gt;&lt;span&gt;멀티 &lt;/span&gt;&lt;span&gt;AZ &lt;/span&gt;&lt;span&gt;구성, &lt;/span&gt;&lt;span&gt;WAF &lt;/span&gt;&lt;span&gt;연동이라는 &lt;/span&gt;&lt;span&gt;측면에서&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;입구를 &lt;/span&gt;&lt;span&gt;안전하게 &lt;/span&gt;&lt;span&gt;관리형으로 &lt;/span&gt;&lt;span&gt;넘긴다&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;목적에 &lt;/span&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;맞았다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3702&quot; data-start=&quot;3593&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3702&quot; data-start=&quot;3593&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;결국 &lt;/span&gt;&lt;span&gt;이번 &lt;/span&gt;&lt;span&gt;선택의 &lt;/span&gt;&lt;span&gt;핵심은&lt;/span&gt;&lt;br /&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;무조건 &lt;/span&gt;&lt;span&gt;최고라서&amp;rdquo;&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;아니라,&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;트래픽 &lt;/span&gt;&lt;span&gt;규모를 &lt;/span&gt;&lt;span&gt;정확히 &lt;/span&gt;&lt;span&gt;예측할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;없는 &lt;/span&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트에서 &lt;/span&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;위험한 &lt;/span&gt;&lt;span&gt;단일 &lt;/span&gt;&lt;span&gt;병목을 &lt;/span&gt;&lt;span&gt;제거하는 &lt;/span&gt;&lt;span&gt;것이 &lt;/span&gt;&lt;span&gt;우선이었기 &lt;/span&gt;&lt;span&gt;때문&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3812&quot; data-start=&quot;3704&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;입구는 &lt;/span&gt;&lt;span&gt;관리형으로 &lt;/span&gt;&lt;span&gt;안정성을 &lt;/span&gt;&lt;span&gt;확보하고, &lt;/span&gt;&lt;span&gt;우리는 &lt;/span&gt;&lt;span&gt;뒤쪽 &lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버의 &lt;/span&gt;&lt;span&gt;오토스케일링, &lt;/span&gt;&lt;span&gt;큐 &lt;/span&gt;&lt;span&gt;처리, &lt;/span&gt;&lt;span&gt;재고 &lt;/span&gt;&lt;span&gt;차감 &lt;/span&gt;&lt;span&gt;같은&lt;/span&gt;&lt;br /&gt;&lt;span&gt;서비스 &lt;/span&gt;&lt;span&gt;핵심 &lt;/span&gt;&lt;span&gt;문제에 &lt;/span&gt;&lt;span&gt;집중하는 &lt;/span&gt;&lt;span&gt;편이 &lt;/span&gt;&lt;span&gt;전체 &lt;/span&gt;&lt;span&gt;시스템 &lt;/span&gt;&lt;span&gt;관점에서 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;합리적이라고 &lt;/span&gt;&lt;span&gt;판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1933&quot; data-start=&quot;1907&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1933&quot; data-start=&quot;1907&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 하면 서버 형태는 아래와 같아진다.&lt;/p&gt;
&lt;p data-end=&quot;1933&quot; data-start=&quot;1907&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1620&quot; data-origin-height=&quot;630&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YyZza/dJMcahwVLGx/O3cFTd3BDnP8b1WKTkR5bK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YyZza/dJMcahwVLGx/O3cFTd3BDnP8b1WKTkR5bK/img.png&quot; data-alt=&quot;ALB까지 적용 완료!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YyZza/dJMcahwVLGx/O3cFTd3BDnP8b1WKTkR5bK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYyZza%2FdJMcahwVLGx%2FO3cFTd3BDnP8b1WKTkR5bK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;685&quot; height=&quot;266&quot; data-origin-width=&quot;1620&quot; data-origin-height=&quot;630&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ALB까지 적용 완료!!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;68&quot; data-start=&quot;51&quot; data-section-id=&quot;evlagh&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;차 &lt;/span&gt;&lt;span&gt;문제: 로드밸런서 뒤의 서버는 얼마나 받아낼 수 있을까&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;자 그럼 ALB까지는 무사히 들어왔다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;그 다음으로 마주해야 할 곳은 서버다.&lt;br /&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;여러 &lt;/span&gt;&lt;span&gt;서버로 &lt;/span&gt;&lt;span&gt;분산시켜 &lt;/span&gt;&lt;span&gt;줄 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있지만, &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정작 &lt;/span&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;뒤에 &lt;/span&gt;&lt;span&gt;있는 &lt;/span&gt;&lt;span&gt;각 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;b&gt;&lt;span&gt;제대로 &lt;/span&gt;&lt;span&gt;받아낼 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는 &lt;/span&gt;&lt;span&gt;상태인지&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;또 &lt;/span&gt;&lt;span&gt;다른 &lt;/span&gt;&lt;span&gt;문제였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;만약 서버가 2대라면 각 서버는 1,000 TPS씩 감당해야한다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;1,000 TPS를 받기 위해 과연 아무런 준비를 하지 않아도 될까??&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아니다&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;549&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트에서 &lt;/span&gt;&lt;span&gt;중요한 &lt;/span&gt;&lt;span&gt;건 &lt;/span&gt;&lt;span&gt;단순히 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;여러 &lt;/span&gt;&lt;span&gt;대로 &lt;/span&gt;&lt;span&gt;나누는 &lt;/span&gt;&lt;span&gt;것이 &lt;/span&gt;&lt;span&gt;아니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;짧은 &lt;/span&gt;&lt;span&gt;시간에 &lt;/span&gt;&lt;span&gt;많은 &lt;/span&gt;&lt;span&gt;요청이 &lt;/span&gt;&lt;span&gt;몰릴 &lt;/span&gt;&lt;span&gt;때, &lt;/span&gt;&lt;span&gt;각 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;b&gt;&lt;span&gt;웹 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;레벨에서 &lt;/span&gt;&lt;span&gt;연결을 &lt;/span&gt;&lt;span&gt;받아들이고&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, &lt;/span&gt;&lt;b&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;큐잉하고&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, &lt;/span&gt;&lt;b&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;스레드로 &lt;/span&gt;&lt;span&gt;넘겨 &lt;/span&gt;&lt;span&gt;처리할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있어야&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;한다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;앞단에서 &lt;/span&gt;&lt;span&gt;분산이 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;되어도, &lt;/span&gt;&lt;span&gt;뒤쪽 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;받아내지 &lt;/span&gt;&lt;span&gt;못하면 &lt;/span&gt;&lt;span&gt;병목은 &lt;/span&gt;&lt;span&gt;단지 &lt;/span&gt;&lt;span&gt;위치만 &lt;/span&gt;&lt;span&gt;바뀔 &lt;/span&gt;&lt;span&gt;뿐이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;549&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그래서 &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;단계에서는 &lt;/span&gt;&lt;span&gt;DB&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;같은 &lt;/span&gt;&lt;span&gt;하위 &lt;/span&gt;&lt;span&gt;시스템까지 &lt;/span&gt;&lt;span&gt;한 &lt;/span&gt;&lt;span&gt;번에 &lt;/span&gt;&lt;span&gt;보지 &lt;/span&gt;&lt;span&gt;않았다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;그건 &lt;/span&gt;&lt;span&gt;다음 &lt;/span&gt;&lt;span&gt;문제로 &lt;/span&gt;&lt;span&gt;넘기고, &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;앞단의 &lt;/span&gt;&lt;span&gt;질문부터 &lt;/span&gt;&lt;span&gt;풀고 &lt;/span&gt;&lt;span&gt;싶었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;706&quot; data-start=&quot;638&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;한 &lt;/span&gt;&lt;span&gt;대는, &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;자체만 &lt;/span&gt;&lt;span&gt;놓고 &lt;/span&gt;&lt;span&gt;봤을 &lt;/span&gt;&lt;span&gt;때 &lt;/span&gt;&lt;span&gt;얼마나 &lt;/span&gt;&lt;span&gt;많은 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;안정적으로 &lt;/span&gt;&lt;span&gt;받아낼 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는까?&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;551&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1730&quot; data-origin-height=&quot;812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvtXCR/dJMcadgYPdC/YstyKrNOW5ttsUfq3NYUsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvtXCR/dJMcadgYPdC/YstyKrNOW5ttsUfq3NYUsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvtXCR/dJMcadgYPdC/YstyKrNOW5ttsUfq3NYUsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvtXCR%2FdJMcadgYPdC%2FYstyKrNOW5ttsUfq3NYUsk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;643&quot; height=&quot;302&quot; data-origin-width=&quot;1730&quot; data-origin-height=&quot;812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;평소라면 크게 고민할 필요가 없다.&amp;nbsp;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;대략적으로 계산해도, 현재 우리가 겪는 소규모 트래픽보다 훨씬 많은 요청을 서버가 감당할 수 있기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1501&quot; data-start=&quot;1414&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 이벤트는 다르다.&lt;/p&gt;
&lt;p data-end=&quot;1501&quot; data-start=&quot;1414&quot; data-ke-size=&quot;size16&quot;&gt;짧은 시간에 많은 요청이 몰릴 것으로 예상되는 만큼, 서버가 어디까지 버틸 수 있는지 대략적인 한계는 미리 알고 있어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;그래야 이 정도 트래픽은 서버 단까지는 큰 문제 없이 받아낼 수 있겠구나라는 판단을 할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size23&quot;&gt;1. OS단 설정&lt;/h3&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;일단 서버 관점에서 보면, 인스턴스의 운영체제는 리눅스다.&lt;/p&gt;
&lt;p data-end=&quot;1670&quot; data-start=&quot;1600&quot; data-ke-size=&quot;size16&quot;&gt;외부에서 들어온 요청은 곧바로 애플리케이션으로 전달되는 것이 아니라, 먼저 운영체제가 연결을 받아들이고 소켓 형태로 관리한다.&lt;/p&gt;
&lt;p data-end=&quot;1845&quot; data-start=&quot;1672&quot; data-ke-size=&quot;size16&quot;&gt;이때 소켓 역시 파일 디스크립터로 취급되기 때문에, 동시에 많은 연결이 몰릴 수 있는 환경이라면 nofile 같은 운영체제 한도도 함께 확인해야 한다.&lt;br /&gt;또한 순간적으로 연결이 몰릴 수 있으므로 `somaxconn`, `tcp_max_syn_backlog` 같은 `backlog` 관련 설정도 같이 점검했다.&lt;/p&gt;
&lt;h4 data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;1) nofile&lt;/span&gt;&lt;/h4&gt;
&lt;p data-end=&quot;827&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;우선 ulimit -n 명령어는 현재 셸에서 열 수 있는 파일 디스크립터의 최대 개수를 확인하는 명령어다.&lt;/p&gt;
&lt;pre id=&quot;code_1774248496870&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ulimit -n&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JfCaw/dJMcabQ47Du/Lu2Ky2uWbb7clJldN5DYv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JfCaw/dJMcabQ47Du/Lu2Ky2uWbb7clJldN5DYv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JfCaw/dJMcabQ47Du/Lu2Ky2uWbb7clJldN5DYv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJfCaw%2FdJMcabQ47Du%2FLu2Ky2uWbb7clJldN5DYv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;72&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;167&quot;&gt;리눅스 환경에서 모든 네트워크 소켓은 파일 디스크립터로 관리되므로, 대량의 동시 연결이 발생하는 선착순 이벤트 특성상 &lt;/span&gt;nofile&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;248&quot;&gt; 한도 점검은 필수적이었다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;265&quot;&gt;&amp;nbsp;AWS Linux 2023의 기본값인 &lt;/span&gt;&lt;b data-start-index=&quot;288&quot;&gt;65,535&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;294&quot;&gt;는 16비트 시스템의 전통적인 상한값으로, 본 시스템에서 설정한 톰캣 &lt;/span&gt;maxConnections = &lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;349&quot;&gt;10,000를 상회하는 충분한 여유 수치였다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;380&quot;&gt; 따라서 하위 인프라의 파일 디스크립터 한도보다는 실제 연결 성립의 병목이었던 커널 대기열 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;445&quot;&gt;최적화에 집중하는 것이 더 합리적인 우선순위라고 판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-style=&quot;style3&quot;&gt;왜 65535가 기본값일까?&lt;br /&gt;&lt;br /&gt;이 부분은 AWS 공식 문서에서 직접적인 설명을 찾지는 못했다.&lt;br /&gt;65535는 2의 16제곱에서 1을 뺀 값(16비트 최대값)이라, 옛날부터 포트 번호 개수, 각종 커널/네트워크 한도 등에서 많이 쓰이던 전통적인 상한 값이라고 한다. 그래서 운영체제나 네트워크 관련 기본 한도에서도 종종 보이는 숫자라고 이해하면 된다.&lt;/blockquote&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size20&quot;&gt;2) backlog 관련 설정&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEKbPX/dJMcafTveFI/gNsxES2pLx5nIEP26K2Vz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEKbPX/dJMcafTveFI/gNsxES2pLx5nIEP26K2Vz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEKbPX/dJMcafTveFI/gNsxES2pLx5nIEP26K2Vz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEKbPX%2FdJMcafTveFI%2FgNsxES2pLx5nIEP26K2Vz0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;450&quot; height=&quot;253&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;576&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;요청이 서버에 들어온다고 해서 곧바로 애플리케이션이 처리하는 것은 아니다.&lt;br /&gt;그 전에 리눅스 커널이 먼저 연결을 받고, 잠깐 줄을 세워두는 구간이 있다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;327&quot; data-start=&quot;300&quot; data-ke-size=&quot;size16&quot;&gt;이걸 놀이공원 입장 줄에 비유해보면 이해가 쉽다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;398&quot; data-start=&quot;329&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;354&quot; data-start=&quot;329&quot; data-section-id=&quot;1543imb&quot;&gt;&lt;b&gt;문 앞에서 입장 확인을 기다리는 줄&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;398&quot; data-start=&quot;355&quot; data-section-id=&quot;10coxbh&quot;&gt;&lt;b&gt;입장 확인은 끝났지만, 실제로 안으로 들어가기 전 잠시 대기하는 줄&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;487&quot; data-start=&quot;400&quot; data-ke-size=&quot;size16&quot;&gt;리눅스는 이런 식으로 연결을 단계별로 잠깐 보관한다.&lt;br /&gt;그리고 이때 중요한 설정이 바로 `tcp_max_syn_backlog`와 `somaxconn`이다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;(1) tcp_max_syn_backlog&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;이건 아직 연결이 완전히 성립되기 전,&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;즉 TCP 3-way handshake가 진행 중인 연결들을 얼마나 대기열에 쌓아둘 수 있는지에 대한 값이다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;TCP 연결은 보통 아래 순서로 맺어진다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;724&quot; data-start=&quot;645&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;666&quot; data-start=&quot;645&quot; data-section-id=&quot;18o59kw&quot;&gt;클라이언트가 SYN을 보낸다.&lt;/li&gt;
&lt;li data-end=&quot;689&quot; data-start=&quot;667&quot; data-section-id=&quot;ceo923&quot;&gt;서버가 SYN-ACK를 보낸다.&lt;/li&gt;
&lt;li data-end=&quot;724&quot; data-start=&quot;690&quot; data-section-id=&quot;1homfbe&quot;&gt;클라이언트가 마지막 ACK를 보내면 연결이 성립된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;여기서 마지막 ACK가 오기 전까지는&lt;br /&gt;아직 연결이 완전히 끝난 것이 아니라 &lt;b&gt;half-open connection &lt;/b&gt;라고 볼 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;tcp_max_syn_backlog는 바로 &lt;b&gt;이 반쯤 열린 연결들을 잠깐 담아두는 대기열 크기다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;쉽게 말하면, 이 값은 &lt;b&gt;서버 입장 직전, 문 앞에서 신분 확인을 기다리는 줄의 길이&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;817&quot; data-start=&quot;726&quot; data-ke-size=&quot;size16&quot;&gt;이 값이 너무 작으면 짧은 순간에 연결 시도가 몰렸을 때 문제가 생길 수 있다.&lt;br /&gt;아직 연결이 완전히 맺어지지 않은 연결들이 금방 대기열을 채워 버리고,&lt;br /&gt;그 뒤에 들어온 연결들은 재시도되거나 지연될 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;사용자 입장에서는 이런 식으로 보일 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1162&quot; data-start=&quot;1103&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1115&quot; data-start=&quot;1103&quot; data-section-id=&quot;1rrlrmw&quot;&gt;접속이 바로 안 됨&lt;/li&gt;
&lt;li data-end=&quot;1131&quot; data-start=&quot;1116&quot; data-section-id=&quot;1q2mcvv&quot;&gt;잠깐 멈춘 것처럼 느껴짐&lt;/li&gt;
&lt;li data-end=&quot;1146&quot; data-start=&quot;1132&quot; data-section-id=&quot;1k2flto&quot;&gt;재시도 끝에 겨우 붙음&lt;/li&gt;
&lt;li data-end=&quot;1162&quot; data-start=&quot;1147&quot; data-section-id=&quot;1auo4f3&quot;&gt;심하면 타임아웃처럼 보임&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1232&quot; data-start=&quot;1164&quot; data-ke-size=&quot;size16&quot;&gt;즉, `tcp_max_syn_backlog`는 연결이 막 시작되는 초반 구간에서 순간적인 몰림을 흡수해 주는 완충 공간이라고 이해하면 좋다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(2) net.core.somaxconn&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 TCP handshake가 이미 끝난 뒤,&lt;br /&gt;즉 &lt;b&gt;연결은 성립했지만 애플리케이션이 아직 accept() 해서 가져가지 못한 상태&lt;/b&gt;에서 사용되는 대기열과 관련 있다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 연결 자체는 이미 성공했는데,&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;nginx가 아직 꺼내 가지 못했거나&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;tomcat이 아직 받아 가지 못했거나&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;애플리케이션이 잠깐 밀리고 있는 상태 라면 커널은 그 연결을 잠시 줄 세워 둔다.&lt;/p&gt;
&lt;p data-end=&quot;1564&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이때 실제 대기열 크기는 애플리케이션이 listen(fd, backlog)로 요청한 값과, 커널의 somaxconn 상한이 함께 작용해 결정된다.&lt;br /&gt;쉽게 설명하면 somaxconn은 그 상한선 역할을 하는 값이라고 보면 된다.&lt;/p&gt;
&lt;p data-end=&quot;1564&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1564&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;비유하면 이건 &lt;b&gt;입장 확인은 끝났지만, 실제로 안으로 들어가기 전까지 서 있는 안쪽 대기줄&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1719&quot; data-start=&quot;1633&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1669&quot; data-start=&quot;1633&quot; data-section-id=&quot;1xawi3t&quot;&gt;tcp_max_syn_backlog는 &lt;b&gt;문 앞 대기줄&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1719&quot; data-start=&quot;1670&quot; data-section-id=&quot;4x0b3p&quot;&gt;somaxconn은 &lt;b&gt;문은 통과했지만 아직 직원이 안으로 안내하기 전 대기줄&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1731&quot; data-start=&quot;1721&quot; data-ke-size=&quot;size16&quot;&gt;애플리케이션은 보통 listen(fd, backlog) 같은 방식으로&lt;br /&gt;&quot;이 정도까지는 대기열을 두고 싶다&quot;는 값을 넘긴다.&lt;/p&gt;
&lt;p data-end=&quot;1900&quot; data-start=&quot;1838&quot; data-ke-size=&quot;size16&quot;&gt;하지만 커널은 그 값을 무조건 다 받아주지 않는다.&lt;/p&gt;
&lt;p data-end=&quot;1900&quot; data-start=&quot;1838&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1900&quot; data-start=&quot;1838&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 애플리케이션이 backlog를 10000으로 주더라도, 커널의 somaxconn 값이 4096이라면 실제로는 그보다 크게 잡히지 못한다. 즉, 애플리케이션에서 아무리 크게 설정해도 커널 쪽 상한이 더 낮으면 결국 그 값에 막히게 된다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 이렇다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;tcp_max_syn_backlog&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2259&quot; data-start=&quot;2177&quot; data-section-id=&quot;btm7ij&quot;&gt;아직 handshake가 끝나지 않은 연결들의 대기열&lt;/li&gt;
&lt;li data-end=&quot;2259&quot; data-start=&quot;2177&quot; data-section-id=&quot;btm7ij&quot;&gt;&lt;b&gt;연결 시작 단계의 버퍼&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;somaxconn&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2347&quot; data-start=&quot;2261&quot; data-section-id=&quot;qy3hma&quot;&gt;handshake는 끝났지만 애플리케이션이 아직 못 받아간 연결의 대기열 상한&lt;/li&gt;
&lt;li data-end=&quot;2347&quot; data-start=&quot;2261&quot; data-section-id=&quot;qy3hma&quot;&gt;&lt;b&gt;연결 성립 이후의 버퍼&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2445&quot; data-start=&quot;2349&quot; data-ke-size=&quot;size16&quot;&gt;즉, 요청이 몰릴 때는 단순히 톰캣이나 nginx 설정만 볼 것이 아니라,&lt;br /&gt;그 앞단에서 리눅스 커널이 &lt;b&gt;어디까지 연결을 받아두고 버틸 수 있는지&lt;/b&gt;도 함께 봐야 한다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;직접 확인해보고 싶다면 아래 명령어로 현재 서버 값을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1774253926143&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인한 AWS Linux 2023 인스턴스에서는 아래와 같은 값으로 설정되어 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;346&quot; data-origin-height=&quot;80&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z7Kg6/dJMcabDzi85/zkKK4a7GnOAkrdaJZeWgF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z7Kg6/dJMcabDzi85/zkKK4a7GnOAkrdaJZeWgF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z7Kg6/dJMcabDzi85/zkKK4a7GnOAkrdaJZeWgF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz7Kg6%2FdJMcabDzi85%2FzkKK4a7GnOAkrdaJZeWgF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;346&quot; height=&quot;80&quot; data-origin-width=&quot;346&quot; data-origin-height=&quot;80&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;확인 결과, somaxconn = 4096은 우리가 예상한 순간적인 접속 몰림을 기준으로 봤을 때 당장 부족해 보이지는 않았다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;또한 accept-count를 기본값으로 둘 예정이라 실질적으로 영향을 끼치지는 않는다. 다만 만약 accept-count를 크게 올려야 하는 상황이 생겼을 때 OS 상한에 막히지 않도록 조정이 필요하다.&lt;br /&gt;반면 tcp_max_syn_backlog = 128은 선착순 이벤트처럼 아주 짧은 시간에 연결 시도가 몰릴 수 있는 상황에서는 다소 작게 느껴졌다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1874&quot; data-start=&quot;1667&quot; data-ke-size=&quot;size16&quot;&gt;somaxconn이 연결 성립 이후의 대기열이라면, tcp_max_syn_backlog는 연결이 완전히 맺어지기 전 단계의 완충 공간에 가깝다.&lt;br /&gt;이번 이벤트에서는 전체 처리량보다도 오픈 직후의 순간적인 몰림이 더 중요하다고 판단했고, 따라서 somaxconn보다는 tcp_max_syn_backlog를 먼저 여유 있게 조정하는 것이 더 필요하다고 봤다.&lt;/p&gt;
&lt;p data-end=&quot;1874&quot; data-start=&quot;1667&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2044&quot; data-start=&quot;1876&quot; data-ke-size=&quot;size16&quot;&gt;물론 backlog 관련 설정만으로 모든 문제가 해결되지는 않는다.&lt;br /&gt;애플리케이션이 연결을 충분히 빠르게 받아가지 못하면 병목은 결국 다른 구간에서 다시 생길 수 있다.&lt;br /&gt;그럼에도 이 설정은 적어도 애플리케이션에 도달하기 전, 커널 단계에서 연결이 밀려나는 상황을 줄이기 위한 기본적인 대비라고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;2044&quot; data-start=&quot;1876&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;2. 톰캣&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;설정 점검&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;그다음으로 봐야 할 것은 톰캣 설정과 이벤트 서버의 수용 한계였다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;이 부분을 먼저 살펴본 이유는 단순했다.&lt;br /&gt;설정이 맞지 않으면, 실제 병목은 톰캣이나 연결 처리 구간에 있는데도 인스턴스 성능이 부족한 것처럼 오해할 수 있기 때문이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;829&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 CPU나 메모리는 아직 여유가 있는데도 maxThreads나 acceptCount가 먼저 한계에 도달하면, 겉으로는 서버 스펙이 부족해 보일 수 있다.&lt;br /&gt;하지만 실제 원인은 인스턴스 체급이 아니라 톰캣 설정일 수도 있다.&lt;/p&gt;
&lt;p data-end=&quot;1196&quot; data-start=&quot;1058&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1610&quot; data-start=&quot;1539&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;단계에서 &lt;/span&gt;&lt;span&gt;알고 &lt;/span&gt;&lt;span&gt;싶었던 &lt;/span&gt;&lt;span&gt;것은 &lt;/span&gt;&lt;span&gt;조금 &lt;/span&gt;&lt;span&gt;더 &lt;/span&gt;&lt;span&gt;앞단의 &lt;/span&gt;&lt;span&gt;질문들이었다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1787&quot; data-start=&quot;1612&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1634&quot; data-start=&quot;1612&quot; data-section-id=&quot;5fu9y1&quot;&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;스펙은 &lt;/span&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;정도가 &lt;/span&gt;&lt;span&gt;적절한가&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1663&quot; data-start=&quot;1635&quot; data-section-id=&quot;18vbbae&quot;&gt;&lt;span&gt;톰캣의 &lt;/span&gt;&lt;span&gt;최대 &lt;/span&gt;&lt;span&gt;스레드 &lt;/span&gt;&lt;span&gt;수는 &lt;/span&gt;&lt;span&gt;몇으로 &lt;/span&gt;&lt;span&gt;두는 &lt;/span&gt;&lt;span&gt;게 &lt;/span&gt;&lt;span&gt;맞는가&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1663&quot; data-start=&quot;1635&quot; data-section-id=&quot;18vbbae&quot;&gt;&lt;span&gt;요청이 &lt;/span&gt;&lt;span&gt;몰릴 &lt;/span&gt;&lt;span&gt;때 &lt;/span&gt;&lt;span&gt;잠시 &lt;/span&gt;&lt;span&gt;대기하는 &lt;/span&gt;accept queue&lt;span&gt; &lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;정도가 &lt;/span&gt;&lt;span&gt;적절한가?&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1787&quot; data-start=&quot;1741&quot; data-section-id=&quot;xyhoj&quot;&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;한 &lt;/span&gt;&lt;span&gt;대가 &lt;/span&gt;&lt;span&gt;안정적으로 &lt;/span&gt;&lt;span&gt;받아낼 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는 &lt;/span&gt;&lt;span&gt;요청량의 &lt;/span&gt;&lt;span&gt;대략적인 &lt;/span&gt;&lt;span&gt;상한은 &lt;/span&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;정도인가&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1857&quot; data-start=&quot;1789&quot; data-ke-size=&quot;size16&quot;&gt;궁금했던 것은 결국 하나였다.&lt;br /&gt;&lt;b&gt;&amp;ldquo;무너지지 않고 받아낼 수 있는 양이 어느 정도인가?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1857&quot; data-start=&quot;1789&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2130&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;이게 중요했던 이유는, 선착순 이벤트 초반에는 &lt;b&gt;처리하는 것보다 먼저 받아내는 것 자체가 문제&lt;/b&gt;가 될 수 있기 때문이다.&lt;br /&gt;모든 사용자가 거의 같은 시점에 같은 API를 호출하면, 그 순간의 병목은 비즈니스 로직보다 앞단의 연결 수용, 스레드 수, 대기 구간, 메모리 사용량 같은 곳에서 먼저 드러날 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;816&quot; data-start=&quot;770&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 단계에서는 범위를 의도적으로 애플리케이션 서버 자체로 좁혀 보려고 했다.&lt;/p&gt;
&lt;p data-end=&quot;2130&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2147&quot; data-start=&quot;2132&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt;1) &lt;/span&gt;&lt;span&gt;인스턴스 체급&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;2249&quot; data-start=&quot;2148&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;가장 &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;span&gt;본 &lt;/span&gt;&lt;span&gt;것은 &lt;/span&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;체급이었다.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;2249&quot; data-start=&quot;2148&quot; data-ke-size=&quot;size16&quot;&gt;너무 작은 인스턴스는 테스트 초반에는 버텨 보일 수 있다.&lt;br /&gt;하지만 요청이 몰리는 순간 CPU, 메모리, 네트워크 처리 여유가 빠르게 줄어들 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2249&quot; data-start=&quot;2148&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2341&quot; data-start=&quot;2251&quot; data-ke-size=&quot;size16&quot;&gt;반대로 무조건 큰 인스턴스를 선택하는 것도 좋은 답은 아니라고 생각했다.&lt;br /&gt;비용은 높아지는데, 실제 병목이 톰캣 설정이나 연결 처리 구간이라면 기대한 만큼의 효과가 나오지 않을 수 있기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;2341&quot; data-start=&quot;2251&quot; data-ke-size=&quot;size16&quot;&gt;즉, 트래픽을 아무리&amp;nbsp; 받아도 리소스가 남아 돌면 비용부담만 커질뿐이다.&lt;/p&gt;
&lt;p data-end=&quot;2341&quot; data-start=&quot;2251&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그래서 &lt;/span&gt;&lt;span&gt;중요한 &lt;/span&gt;&lt;span&gt;것은 &lt;/span&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;무조건 &lt;/span&gt;&lt;span&gt;큰 &lt;/span&gt;&lt;span&gt;인스턴스&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;아니라,&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;&amp;ldquo;&lt;/span&gt;&lt;span&gt;이벤트 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;역할에 &lt;/span&gt;&lt;span&gt;맞는 &lt;/span&gt;&lt;span&gt;최소한의 &lt;/span&gt;&lt;span&gt;체급이 &lt;/span&gt;&lt;span&gt;어디인가&amp;rdquo;&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;를 &lt;/span&gt;&lt;span&gt;찾는 &lt;/span&gt;&lt;span&gt;것이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;일단 기준점으로는 t4g.small로 잡고 조정해나갈 생각이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2443&quot; data-start=&quot;2426&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt;2) &lt;/span&gt;&lt;span&gt;톰캣 &lt;/span&gt;&lt;span&gt;스레드 &lt;/span&gt;&lt;span&gt;설정&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2444&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그다음으로 &lt;/span&gt;&lt;span&gt;중요한 &lt;/span&gt;&lt;span&gt;건 &lt;/span&gt;&lt;span&gt;톰캣이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2444&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span&gt;서버는 &lt;/span&gt;&lt;span&gt;결국 &lt;/span&gt;&lt;span&gt;톰캣을 &lt;/span&gt;&lt;span&gt;통해 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;받아 &lt;/span&gt;&lt;span&gt;애플리케이션으로 &lt;/span&gt;&lt;span&gt;넘긴다.&lt;/span&gt;&lt;br /&gt;이때 maxThreads가 너무 작으면 요청이 빠르게 대기 상태로 밀리고, 응답 시간도 급격히 늘어날 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2444&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2444&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그렇다고 &lt;/span&gt;&lt;span&gt;무작정 &lt;/span&gt;&lt;span&gt;스레드를 &lt;/span&gt;&lt;span&gt;늘리는 &lt;/span&gt;&lt;span&gt;것도 &lt;/span&gt;&lt;span&gt;정답은 &lt;/span&gt;&lt;span&gt;아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2444&quot; data-ke-size=&quot;size16&quot;&gt;스레드 수가 지나치게 많아지면 문맥 전환 비용이 커지고, 메모리 사용량도 늘어나며, 오히려 처리 효율이 떨어질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2661&quot; data-start=&quot;2565&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2727&quot; data-start=&quot;2663&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;span&gt;톰캣 &lt;/span&gt;&lt;span&gt;설정은 &amp;ldquo;&lt;/span&gt;&lt;span&gt;많을수록 &lt;/span&gt;&lt;span&gt;좋다&amp;rdquo;&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;아니라&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;인스턴스 &lt;/span&gt;&lt;span&gt;자원과 &lt;/span&gt;&lt;span&gt;요청 &lt;/span&gt;&lt;span&gt;특성에 &lt;/span&gt;&lt;span&gt;맞는 &lt;/span&gt;&lt;span&gt;적정값을 &lt;/span&gt;&lt;span&gt;찾는 &lt;/span&gt;&lt;span&gt;문제&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2727&quot; data-start=&quot;2663&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;61&quot; data-start=&quot;43&quot; data-section-id=&quot;e43jzv&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt;3) &lt;/span&gt;&lt;span&gt;연결 &lt;/span&gt;&lt;span&gt;수와 &lt;/span&gt;&lt;span&gt;대기 &lt;/span&gt;&lt;span&gt;구간&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;163&quot; data-start=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;선착순 &lt;/span&gt;&lt;span&gt;이벤트에서는 &lt;/span&gt;&lt;span&gt;짧은 &lt;/span&gt;&lt;span&gt;시간에 &lt;/span&gt;&lt;span&gt;연결이 &lt;/span&gt;&lt;span&gt;몰릴 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;경우 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;실제 &lt;/span&gt;&lt;span&gt;비즈니스 &lt;/span&gt;&lt;span&gt;로직을 &lt;/span&gt;&lt;span&gt;실행하기도 &lt;/span&gt;&lt;span&gt;전에, &lt;/span&gt;&lt;span&gt;연결 &lt;/span&gt;&lt;span&gt;수용 &lt;/span&gt;&lt;span&gt;단계나 &lt;/span&gt;&lt;span&gt;요청 &lt;/span&gt;&lt;span&gt;대기 &lt;/span&gt;&lt;span&gt;구간에서 &lt;/span&gt;&lt;span&gt;먼저 &lt;/span&gt;&lt;span&gt;압박이 &lt;/span&gt;&lt;span&gt;생길 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;163&quot; data-start=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;380&quot; data-start=&quot;165&quot; data-ke-size=&quot;size16&quot;&gt;Tomcat 기준으로 보면 maxThreads는 실제 요청 처리에 투입될 수 있는 작업 스레드 수에 가깝고, maxConnections는 동시에 유지할 수 있는 연결 수의 상한, acceptCount는 처리 여력을 넘긴 연결이 잠시 대기하는 구간과 관련된 값이다.&lt;/p&gt;
&lt;p data-end=&quot;380&quot; data-start=&quot;165&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;b&gt;&lt;span&gt;얼마나 &lt;/span&gt;&lt;span&gt;처리할 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, &lt;/span&gt;&lt;b&gt;&lt;span&gt;얼마나 &lt;/span&gt;&lt;span&gt;받아둘 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, &lt;/span&gt;&lt;b&gt;&lt;span&gt;순간적으로 &lt;/span&gt;&lt;span&gt;얼마나 &lt;/span&gt;&lt;span&gt;버텨줄 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있는지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;는 &lt;/span&gt;&lt;span&gt;서로 &lt;/span&gt;&lt;span&gt;다른 &lt;/span&gt;&lt;span&gt;문제였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;380&quot; data-start=&quot;165&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;583&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;값을 &lt;/span&gt;&lt;span&gt;너무 &lt;/span&gt;&lt;span&gt;작게 &lt;/span&gt;&lt;span&gt;잡으면 &lt;/span&gt;&lt;span&gt;순간적으로 &lt;/span&gt;&lt;span&gt;몰리는 &lt;/span&gt;&lt;span&gt;트래픽을 &lt;/span&gt;&lt;span&gt;흡수하지 &lt;/span&gt;&lt;span&gt;못한다.&lt;/span&gt;&lt;br /&gt;연결 수 한도에 빠르게 도달하고 대기 구간도 금방 차 버리면, 일부 요청은 애플리케이션까지 도달하지도 못한 채 거절되거나 타임아웃처럼 보일 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;583&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span&gt;앞단에 &lt;/span&gt;&lt;span&gt;ALB&lt;/span&gt;&lt;span&gt;가 &lt;/span&gt;&lt;span&gt;있어 &lt;/span&gt;&lt;span&gt;요청을 &lt;/span&gt;&lt;span&gt;잘 &lt;/span&gt;&lt;span&gt;분산하더라도, &lt;/span&gt;&lt;span&gt;뒤쪽 &lt;/span&gt;&lt;span&gt;서버가 &lt;/span&gt;&lt;span&gt;연결 &lt;/span&gt;&lt;span&gt;자체를 &lt;/span&gt;&lt;span&gt;받아내지 &lt;/span&gt;&lt;span&gt;못하면 &lt;/span&gt;&lt;span&gt;사용자는 &lt;/span&gt;&lt;span&gt;결국 &amp;ldquo;&lt;/span&gt;&lt;span&gt;접속이 &lt;/span&gt;&lt;span&gt;안 &lt;/span&gt;&lt;span&gt;된다&amp;rdquo;&lt;/span&gt;&lt;span&gt;라고 &lt;/span&gt;&lt;span&gt;느끼게 &lt;/span&gt;&lt;span&gt;된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;583&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;반대로 &lt;/span&gt;&lt;span&gt;값을 &lt;/span&gt;&lt;span&gt;너무 &lt;/span&gt;&lt;span&gt;크게 &lt;/span&gt;&lt;span&gt;잡는다고 &lt;/span&gt;&lt;span&gt;해서 &lt;/span&gt;&lt;span&gt;무조건 &lt;/span&gt;&lt;span&gt;좋은 &lt;/span&gt;&lt;span&gt;것도 &lt;/span&gt;&lt;span&gt;아니었다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;처리 &lt;/span&gt;&lt;span&gt;가능한 &lt;/span&gt;&lt;span&gt;수준보다 &lt;/span&gt;&lt;span&gt;훨씬 &lt;/span&gt;&lt;span&gt;많은 &lt;/span&gt;&lt;span&gt;연결을 &lt;/span&gt;&lt;span&gt;오래 &lt;/span&gt;&lt;span&gt;붙잡아 &lt;/span&gt;&lt;span&gt;두면, &lt;/span&gt;&lt;span&gt;실패가 &lt;/span&gt;&lt;span&gt;빨리 &lt;/span&gt;&lt;span&gt;드러나는 &lt;/span&gt;&lt;span&gt;대신 &lt;/span&gt;&lt;span&gt;지연이 &lt;/span&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;내부에 &lt;/span&gt;&lt;span&gt;쌓이게 &lt;/span&gt;&lt;span&gt;된다.&lt;/span&gt;&lt;span&gt;초반에는 &lt;/span&gt;&lt;span&gt;버티는 &lt;/span&gt;&lt;span&gt;것처럼 &lt;/span&gt;&lt;span&gt;보일 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있지만, &lt;/span&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;순간부터 &lt;/span&gt;&lt;span&gt;응답 &lt;/span&gt;&lt;span&gt;시간이 &lt;/span&gt;&lt;span&gt;급격히 &lt;/span&gt;&lt;span&gt;증가하고 &lt;/span&gt;&lt;span&gt;전체 &lt;/span&gt;&lt;span&gt;처리 &lt;/span&gt;&lt;span&gt;효율도 &lt;/span&gt;&lt;span&gt;오히려 &lt;/span&gt;&lt;span&gt;떨어질 &lt;/span&gt;&lt;span&gt;수 &lt;/span&gt;&lt;span&gt;있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그래서 &lt;/span&gt;&lt;span&gt;중요한 &lt;/span&gt;&lt;span&gt;것은 &lt;/span&gt;&lt;span&gt;단순히 &lt;/span&gt;&lt;span&gt;값을 &lt;/span&gt;&lt;span&gt;크게 &lt;/span&gt;&lt;span&gt;늘리는 &lt;/span&gt;&lt;span&gt;것이 &lt;/span&gt;&lt;span&gt;아니라,&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;시점부터 &lt;/span&gt;&lt;span&gt;연결이 &lt;/span&gt;&lt;span&gt;쌓이기 &lt;/span&gt;&lt;span&gt;시작하는지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;,&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span&gt;어느 &lt;/span&gt;&lt;span&gt;순간부터 &lt;/span&gt;&lt;span&gt;응답 &lt;/span&gt;&lt;span&gt;시간이 &lt;/span&gt;&lt;span&gt;갑자기 &lt;/span&gt;&lt;span&gt;증가하는지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;,&lt;/span&gt;&lt;br /&gt;&lt;span&gt;그리고 &lt;/span&gt;&lt;b&gt;&lt;span&gt;그 &lt;/span&gt;&lt;span&gt;지연이 &lt;/span&gt;&lt;span&gt;일시적인 &lt;/span&gt;&lt;span&gt;버스트 &lt;/span&gt;&lt;span&gt;흡수인지, &lt;/span&gt;&lt;span&gt;아니면 &lt;/span&gt;&lt;span&gt;병목의 &lt;/span&gt;&lt;span&gt;시작인지&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 구분해서 보는 것이 더 중요하다고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 &lt;/span&gt;&lt;span&gt;이 &lt;/span&gt;&lt;span&gt;지점의 &lt;/span&gt;&lt;span&gt;정확한 &lt;/span&gt;&lt;span&gt;한계는 &lt;/span&gt;&lt;span&gt;애플리케이션 &lt;/span&gt;&lt;span&gt;서버만 &lt;/span&gt;&lt;span&gt;보고 &lt;/span&gt;&lt;span&gt;단정하기 &lt;/span&gt;&lt;span&gt;어려웠다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;실제 &lt;/span&gt;&lt;span&gt;서비스에서는 &lt;/span&gt;&lt;span&gt;후단 &lt;/span&gt;&lt;span&gt;DB&lt;/span&gt;&lt;span&gt;까지 &lt;/span&gt;&lt;span&gt;포함한 &lt;/span&gt;&lt;span&gt;전체 &lt;/span&gt;&lt;span&gt;처리 &lt;/span&gt;&lt;span&gt;경로가 &lt;/span&gt;&lt;span&gt;함께 &lt;/span&gt;&lt;span&gt;영향을 &lt;/span&gt;&lt;span&gt;주기 &lt;/span&gt;&lt;span&gt;때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 단계에서는 이런 위험이 존재한다는 점을 먼저 인지하고, 구체적인 병목 지점과 임계치는 다음 단계에서 DB를 포함한 부하 테스트를 통해 확인하기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;915&quot; data-start=&quot;765&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2949&quot; data-start=&quot;2916&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;4) 무엇이 먼저 한계에 도달하는지&lt;/span&gt;&lt;/h4&gt;
&lt;p data-end=&quot;3038&quot; data-start=&quot;2950&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서버 &lt;/span&gt;&lt;span&gt;한계는 &lt;/span&gt;&lt;span&gt;단순히 &lt;/span&gt;&lt;span&gt;CPU &lt;/span&gt;&lt;span&gt;사용률 &lt;/span&gt;&lt;span&gt;하나로 &lt;/span&gt;&lt;span&gt;설명되지 &lt;/span&gt;&lt;span&gt;않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;3161&quot; data-start=&quot;3040&quot; data-ke-size=&quot;size16&quot;&gt;어떤 경우에는 CPU가 먼저 치솟을 수 있고, 어떤 경우에는 메모리 사용량이나 GC 부담이 먼저 문제를 일으킬 수도 있다.&lt;br /&gt;또 어떤 경우에는 톰캣 스레드 풀이 먼저 포화되거나, 연결 수와 대기 구간이 먼저 압박을 받을 수도 있다.&lt;/p&gt;
&lt;p data-end=&quot;3161&quot; data-start=&quot;3040&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;지금 인스턴스가 버티는가&amp;rdquo;만 보는 것으로는 부족하다고 생각했다.&lt;br /&gt;&lt;b&gt;무엇 때문에 버티지 못하는가&lt;/b&gt;까지 같이 봐야, 스레드를 조정할지, 인스턴스 스펙을 올릴지, 서버 수를 늘릴지를 판단할 수 있기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3차 문제의 잠정 판단&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, 이 단계에서 정답이 되는 설정값을 한 번에 확정할 수는 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스 체급, 톰캣 설정, 그리고 후단 DB까지 모든 요소가 유기적으로 연결되어 있기 때문이다. 문서나 감에 의존하기보다, 명확한 기준점을 먼저 잡고 부하 테스트를 통해 수치를 깎아 나가는 방식이 더 합리적이라 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 첫 기준 인스턴스는 t4g.small로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 설정도 이 기준점 위에서 잠정적으로 아래와 같이 시작해보기로 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;maxThreads = 150&lt;/li&gt;
&lt;li&gt;accept-count = 300&lt;/li&gt;
&lt;li&gt;max-connections = 10000&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;maxThreads는 150으로 잡았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;t4g.small은 vCPU가 2개다. 톰캣의 기본값은 200이지만, vCPU 2개 위에서 200개의 스레드가 동시에 활성화되면 컨텍스트 스위칭 비용이 커지고, 오히려 처리 효율이 떨어질 수 있다. 이번 이벤트의 핵심 로직은 Redis 조회 &amp;rarr; 상태 판단 &amp;rarr; 응답 반환으로, CPU를 오래 점유하는 무거운 연산이 아니라 I/O 대기가 주를 이루는 가벼운 흐름이었다. 이런 구조에서는 스레드를 무작정 늘리기보다, 한정된 CPU 위에서 문맥 전환 비용을 억제하면서 가벼운 요청을 빠르게 회전시키는 쪽이 더 유리하다고 봤다. 기본값 200에서 조금 줄여 150을 기준점으로 잡되, 부하 테스트에서 스레드 풀이 먼저 포화되는지 CPU가 먼저 한계에 도달하는지를 보고 조정할 계획이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;accept-count는 건드리지 않았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;accept-count는 톰캣이 서버 소켓을 만들 때 OS에 전달하는 listen backlog 값이다. 쉽게 말하면 OS 레벨에서 잠시 연결을 줄 세워 둘 수 있는 대기 공간의 크기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤에서 설명할 max-connections와의 관계를 먼저 정리하면 이렇다. 새 연결이 도착하면 OS가 TCP handshake를 처리하고, 톰캣의 Acceptor 스레드가 그 연결을 accept()해서 NIO Selector에 등록한다. 이 시점부터 해당 연결은 max-connections에 카운트된다. 그리고 스레드가 다 바쁜 상태에서 새 요청이 들어와도, NIO 안에서 스레드가 빌 때까지 대기할 수 있다. 스레드 없이도 연결을 가볍게 유지할 수 있는 것이 NIO의 장점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 accept-count가 의미를 갖을 때는 &lt;b&gt;max-connections가 꽉 찼을 때다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;max-connections 한도에 도달하면 톰캣은 accept()를 멈추고, 그 이후에 도착하는 연결은 OS backlog, 즉 accept-count 큐에서 대기하게 된다. 이 큐마저 차면 OS가 연결 자체를 거부하고, 사용자는 &quot;접속이 안 된다&quot;고 느끼게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 연결 거부까지의 흐름은 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;max-connections에 여유가 있으면 &amp;rarr; 연결은 정상적으로 accept()되고, 스레드가 바쁘더라도 NIO 안에서 대기&lt;/li&gt;
&lt;li&gt;max-connections가 꽉 차면 &amp;rarr; 새 연결은 accept-count 큐에서 대기&lt;/li&gt;
&lt;li&gt;accept-count마저 차면 &amp;rarr; OS가 연결을 거부&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, accept-count는 평소에 사용되는 큐가 아니라 &lt;b&gt;max-connections가 한계에 도달한 극단적인 상황에서의 마지막 완충 장치&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 이벤트에서 이 값을 조정할 필요가 있는지를 생각해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 &lt;b&gt;기본값을 유지하기로 했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리의 max-connections는 5,000이고, 예상 동시 연결은 약 2,000~3,000 수준이다. 즉, 정상 시나리오에서는 max-connections에 충분한 여유가 있기 때문에 accept-count 큐가 사용될 일이 거의 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 동시 연결이 5,000을 넘어서 이 큐까지 사용되는 상황이라면, 이미 예상 트래픽을 한참 벗어난 비정상 상태다. 그 시점에서 100건을 더 받아주든 300건을 더 받아주든 상황이 크게 달라지지 않는다. 오히려 빠르게 거절해서 사용자가 &quot;지금은 접속이 어렵다&quot;는 피드백을 빨리 받는 편이, OS 레벨에서 응답 없이 오래 기다리는 것보다 나은 경험이라고 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 중요한 것은 모든 설정을 무조건 건드리는 것이 아니라, 조정할 근거가 있는 값만 건드리는 것이라고 생각했다. accept-count는 조정할 근거가 없었고, 부하 테스트에서 이 구간이 실제로 문제가 된다면 그때 조정해도 늦지 않다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;max-connections는 5,000으로 잡았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 max-connections가 뭔지부터 정리하면, 이 값은 톰캣이 동시에 열어둘 수 있는 TCP 연결의 최대 개수다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;열어둘 수 있는&quot;이라는 표현이다. 연결이 열려 있다고 해서 그 연결이 지금 당장 처리되고 있다는 뜻은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot의 톰캣은 기본적으로 NIO(Non-blocking I/O) 커넥터를 사용한다. NIO 방식에서는 연결 하나가 스레드 하나를 계속 점유하지 않는다. 대신 selector라는 구조가 여러 연결을 관리하다가, 실제로 데이터를 읽거나 쓸 준비가 된 연결만 스레드에 넘겨준다. 즉, 처리 중이 아닌 유휴 연결은 스레드를 잡아먹지 않고 가볍게 유지된다. 그래서 max-connections를 maxThreads보다 훨씬 크게 잡아도 CPU나 메모리 부담이 바로 늘어나지는 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 참여 인원 1,000명이 평균 2~3개의 탭을 열면 동시 TCP 연결은 약 2,000~3,000개 수준이다. 이 연결들은 대기열 상태 조회 폴링이 이어지는 동안 keep-alive로 유지되므로, 이벤트가 진행되는 수 분간 연결이 쌓인 채 유지될 수 있다. 여기에 예상을 벗어난 재시도나 네트워크 지연으로 연결이 예상보다 오래 남아 있는 경우까지 감안해, 약 2배의 여유를 두어 5,000으로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣의 기본값은 8,192다. 기본값보다 오히려 낮게 잡은 셈인데, 이건 의도적인 판단이었다. 무작정 높게 잡기보다는 예상 트래픽에 근거한 값을 기준으로 두고, 부하 테스트에서 실제로 동시 연결이 어디까지 올라가는지를 확인한 뒤 조정하는 쪽이 더 낫다고 봤다. NIO 기반이라 유휴 연결의 부담은 크지 않지만, 그렇다고 근거 없이 큰 수를 넣어두면 나중에 병목이 생겼을 때 이 값이 원인인지 아닌지를 구분하기가 어려워지기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 연결을 많이 열어둔다고 처리가 빨라지는 것은 아니다. 실제 처리 속도를 결정하는 것은 여전히 maxThreads다. 다만 앞단에서 연결이 거부되어 요청이 아예 도달하지 못하는 상황보다는, 일단 연결은 받아두고 그 안에서 스레드가 순서대로 처리하는 흐름이 이번 이벤트에는 더 적합하다고 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4차 문제: DB 병목&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1444&quot; data-origin-height=&quot;726&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bt6kiw/dJMcabjjTly/VCzIYH2d928xvgGR9gjxBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bt6kiw/dJMcabjjTly/VCzIYH2d928xvgGR9gjxBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bt6kiw/dJMcabjjTly/VCzIYH2d928xvgGR9gjxBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbt6kiw%2FdJMcabjjTly%2FVCzIYH2d928xvgGR9gjxBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;586&quot; height=&quot;295&quot; data-origin-width=&quot;1444&quot; data-origin-height=&quot;726&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;서버를 여러개로 대비한다고 해도 이벤트 요청이 결국 같은 DB로 향한다면, &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;병목은 사라진 게 아니라 위치만 바뀐 셈이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리의 DB는 하나였고, 사양도 AWS RDS t4g.micro 수준이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스펙에서는 사용할 수 있는 커넥션 수에도 분명한 한계가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를들어 RDS t4g.micro로 한다면 최대 커넥션이 83개 수준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이상으로 늘리는 것은 현실적으로 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무리하게 확장하려 해도 메모리 부담이 커지고, 작은 인스턴스 특성상 안정적으로 버티기 힘들다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉으로 보기엔 83개면 적지 않아 보일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 선착순 이벤트처럼 여러 서버가 동시에 같은 DB를 바라보는 상황에서는 이야기가 달라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 한 대당 커넥션 풀을 기본값인 10개로만 잡아도 서버 2대면 20개 서버 3대면 30개 서버 4대면 40개 정도다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이벤트 서버를 여러 대로 늘리더라도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 입장에서 동시에 처리할 수 있는 요청 수는 결국 이 커넥션 수에 의해 제한된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 더 큰 문제는 &lt;b&gt;처리하지 못한 요청이 그냥 사라지는 것이 아니라, 전부 애플리케이션 서버 안에서 대기하게 된다는 점&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 시작과 동시에 2천명이 동시에 요청을 보낸다고 가정해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 서버를 4대로 늘렸다고 해도,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시에 DB 작업을 수행할 수 있는 요청 수는 극히 적다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그보다 많은 요청은 DB에 도달하지도 못한 채 커넥션을 기다리게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 순간부터다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB가 병목이 되는 순간, 단순히 &amp;ldquo;조금 느려지는 것&amp;rdquo;으로 끝나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 내부에서는 연쇄적으로 장애가 퍼지기 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째로, &lt;b&gt;스레드가 반환되지 못하고 점점 쌓인다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션을 얻지 못한 요청은 작업을 끝내지 못한 채 계속 대기한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기 중인 요청 하나하나는 스레드, 메모리, 소켓 같은 자원을 붙잡고 있고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 수가 많아질수록 서버는 점점 새로운 요청을 받을 여유를 잃어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째로, &lt;b&gt;대기열이 DB가 아니라 애플리케이션 서버 내부에 생긴다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 원한 것은 &amp;ldquo;DB가 천천히 처리하는 것&amp;rdquo;이 아니라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;DB 앞에서 질서 있게 제어하는 것&amp;rdquo;이었는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무 장치 없이 DB로 바로 보내는 구조에서는 그 대기열이 전부 서버 내부에 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 굉장히 좋지 않은 신호다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기 중인 수천개의 요청은 스레드 점유 힙/네이티브, 메모리 증가, 커넥션 대기 증가, GC 부담 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로 이어지고, 결국 서버 전체를 무겁게 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째로, &lt;b&gt;타임아웃이 나기 시작한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션을 오래 기다리던 요청은 결국 timeout에 걸리고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 502, 504 같은 차가운 에러 페이지를 마주하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 문제는 여기서 끝나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순 이벤트에서 사용자는 에러를 보면 그냥 떠나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분은 새로고침을 하거나 다시 시도한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, DB가 느려진다, 요청이 서버에 쌓인다, 타임아웃이 난다, 사용자가 재시도한다, 요청이 더 몰린다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;는 식의 악순환이 만들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이벤트 서버를 늘리는 것만으로는 부족했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 늘릴수록 앞단은 더 많은 요청을 받을 수 있게 되지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 요청이 모두 같은 DB로 향하는 순간 병목은 더 선명해질 뿐이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &amp;ldquo;서버가 몇 대냐&amp;rdquo;가 아니라,&lt;b&gt;그 많은 요청을 DB까지 그대로 보내도 되느냐&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 답은 명확했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그대로 보내면 안 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB는 최종 확정만 책임지게 하고, 그 앞에서 트래픽을 흡수하고 순서를 정리해 줄 완충 구간이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 해결 시도 1: DB 동시성 제어&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 당연히 DB 안에서 해결하는 방법들을 떠올렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락, 낙관적 락, 스킵 락, 조건부 업데이트 같은 방법들이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식들은 분명 의미가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 같은 재고를 여러 요청이 동시에 건드릴 때, 정합성을 지키기 위해 자주 사용하는 방법이기도 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기서 중요한 건&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기법들은 &lt;b&gt;데이터 정합성&lt;/b&gt;을 지키는 데는 강하지만, &lt;b&gt;폭주하는 트래픽&lt;/b&gt;을 막아내는 근본적인 해결책은 아니라는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 비관적 락은 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 동시에 몰리는 요청이 많아질수록 기다리는 요청도 함께 늘어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하나의 요청이 락을 쥐고 있는 동안 수십, 수백 개의 요청이 DB 커넥션을 붙잡고 대기하게 되는데, 이는 결국 커넥션 풀 고갈과 시스템 전체의 응답 지연으로 이어진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정합성을 지키려다 서비스 전체가 멈추는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락은 락을 오래 잡지 않아 가볍지만, 충돌이 많아지면 실패와 재시도가 급격히 늘어난다. 선착순처럼 같은 자원에 경쟁이 집중되는 상황에서는 적합하지 않는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킵 락은 여러 작업자가 병렬로 작업을 나눠 처리할 때 유용하지만, 그 전에 이미 요청이 DB까지 쏟아지고 있다는 사실 자체는 바꾸지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이 기법들은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DB에 들어온 요청을 어떻게 안전하게 처리할 것인가&lt;/b&gt;에 대한 답이지,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DB로 너무 많은 요청이 한꺼번에 몰리는 상황 자체를 어떻게 막을 것인가&lt;/b&gt;에 대한 답은 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 문제의 본질이 더 선명해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 막아야 했던 것은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;같은 row를 동시에 수정하는 문제&amp;rdquo; 이전에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;수많은 요청이 동시에 DB 앞에 몰려드는 상황 그 자체&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB는 마지막에 정합성을 보장하는 곳으로는 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이벤트 시작 순간의 폭주를 정면으로 받아내는 곳으로 쓰기에는 너무 비싸고, 너무 무겁고, 너무 느렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 DB를 정면으로 방패 삼는 구조 대신, &lt;b&gt;DB 앞단에서 들어오는 폭발적인 트래픽을 흡수하는 구조&lt;/b&gt;로 생각을 바꾸게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 해결 시도 2: 메모리 기반 저장소 도입&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 앞단에서 트래픽을 흡수할 완충 지대를 찾기 위해, 자연스럽게 메모리 기반 저장소를 떠올리게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2776&quot; data-start=&quot;2664&quot; data-ke-size=&quot;size16&quot;&gt;다만 이때 필요한 것은 단순히 &amp;ldquo;빠른 캐시&amp;rdquo;가 아니었다.&lt;br /&gt;먼저 들어온 순서를 매기고, 같은 사용자의 중복 참여를 막고, 현재 상태를 저장하고, 필요하면 여러 조건을 한 번에 처리할 수 있어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;2776&quot; data-start=&quot;2664&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2800&quot; data-start=&quot;2778&quot; data-ke-size=&quot;size16&quot;&gt;당시 기준에서 후보는 크게 세 가지였다.&lt;/p&gt;
&lt;p data-end=&quot;2800&quot; data-start=&quot;2778&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2909&quot; data-start=&quot;2802&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;Memcached&lt;/b&gt;였다.&lt;br /&gt;Memcached는 매우 빠른 인메모리 key-value 저장소이고, 원래도 DB 부하를 줄이기 위한 캐시 용도로 널리 쓰인다. 장점은 단순하고 가볍다는 점이다. 대신 기본 성격이 어디까지나 단순한 key-value 캐시에 가깝기 때문에, 선착순 이벤트처럼 순서를 매기고 상태를 저장하고 중복 참여를 막아야 하는 문제에는 상대적으로 표현력이 부족했다.&lt;/p&gt;
&lt;p data-end=&quot;2909&quot; data-start=&quot;2802&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3060&quot; data-start=&quot;2911&quot; data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;Apache Ignite&lt;/b&gt; 같은 계열이었다.&lt;br /&gt;Ignite는 단순 캐시라기보다, 메모리 우선 구조 위에 분산 SQL, ACID 트랜잭션, 지속성까지 함께 제공하는 분산 데이터 플랫폼에 가깝다. 대규모 분산 처리나 더 복합적인 데이터 처리 문제를 다루기에는 분명 강력한 선택지다. 다만 이번 이벤트에서 우리에게 필요한 것은 거대한 분산 데이터 플랫폼이라기보다, 짧은 시간 안에 빠르게 검증하고 운영할 수 있는 비교적 단순한 구조였다.&lt;/p&gt;
&lt;p data-end=&quot;3060&quot; data-start=&quot;2911&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3264&quot; data-start=&quot;3062&quot; data-ke-size=&quot;size16&quot;&gt;세 번째는 &lt;b&gt;Redis 계열&lt;/b&gt;이었다.&lt;br /&gt;Redis는 string, hash, list, set, sorted set, stream 같은 다양한 자료구조를 제공한다. 여기에 Lua Script를 이용하면 여러 연산을 서버 안에서 원자적으로 묶어 처리할 수도 있다. 단순 캐시를 넘어 순서, 상태, 중복 방지, 큐잉 같은 요구사항을 함께 다루기 좋은 쪽이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 놓고 보니 기준은 분명해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Memcached를 선택했다면 결국 &amp;ldquo;순서&amp;rdquo;, &amp;ldquo;중복 방지&amp;rdquo;, &amp;ldquo;상태&amp;rdquo; 같은 개념을 애플리케이션 코드에서 더 많이 다시 만들어야 했을 것이다.&lt;br /&gt;반면 Redis는 필요한 개념을 자료구조 수준에서 어느 정도 바로 제공해 주기 때문에, 문제를 더 자연스럽게 모델링할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2217&quot; data-start=&quot;2060&quot; data-ke-size=&quot;size16&quot;&gt;또 Redis는 명령 단위 처리 자체가 단순하고 빠른 편이고, 필요하다면 이후에 여러 연산을 서버 안에서 묶어 처리하는 방향도 열려 있었다.&lt;br /&gt;이 시점에서는 아직 최종 원자적 처리 방식을 확정한 것은 아니었지만, 적어도 그런 방향으로 확장할 수 있는 선택지라는 점도 중요했다.&lt;/p&gt;
&lt;p data-end=&quot;2380&quot; data-start=&quot;2345&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2394&quot; data-start=&quot;2382&quot; data-ke-size=&quot;size16&quot;&gt;무엇보다도 Redis는 문제를 자료구조 수준에서 비교적 자연스럽게 표현할 수 있었고, 단순 캐시를 넘어 상태 관리 계층으로 쓰기에도 무리가 적었으며, 복잡한 분산 플랫폼까지 들고 오지 않아도 될 만큼 구조가 적절히 가벼웠다&lt;/p&gt;
&lt;p data-end=&quot;2530&quot; data-start=&quot;2513&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2532&quot; data-ke-size=&quot;size16&quot;&gt;여기에 더해 Redis는 현업에서도 워낙 널리 쓰이고 있었고, 참고할 수 있는 사례와 자료도 가장 풍부했다.&lt;br /&gt;처음 도입하는 입장에서는 새로운 기술을 실험하는 것보다, 이미 많이 검증된 선택지를 가져가는 편이 훨씬 안전했다.&lt;/p&gt;
&lt;p data-end=&quot;2767&quot; data-start=&quot;2660&quot; data-ke-size=&quot;size16&quot;&gt;그래서 Redis를 선택하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;3592&quot; data-start=&quot;3541&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 해결 시도 3: 원자적 연산을 향한 고민&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;3687&quot; data-start=&quot;3650&quot; data-ke-size=&quot;size16&quot;&gt;Redis를 도입하고 나서 처음 떠올린 건 매우 단순한 방식이었다.&lt;/p&gt;
&lt;p data-end=&quot;3750&quot; data-start=&quot;3689&quot; data-ke-size=&quot;size16&quot;&gt;요청이 들어올 때마다 번호를 하나씩 증가시키고, 그 값이 정해진 수량 이내라면 성공으로 처리하면 되지 않을까?&lt;/p&gt;
&lt;p data-end=&quot;3750&quot; data-start=&quot;3689&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3750&quot; data-start=&quot;3689&quot; data-ke-size=&quot;size16&quot;&gt;피크 2,000 TPS를 목표로 잡은 상황에서, 가장 먼저 눈에 들어온 것은 Redis의 INCR 였다.&lt;br /&gt;Redis는 명령을 순차적으로 처리하고, INCR 자체도 원자적으로 동작한다.&lt;br /&gt;즉, 요청이 들어올 때마다 번호를 하나씩 증가시키고, 그 값이 발급 수량 이내라면 성공으로 처리하는 구조를 생각할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3750&quot; data-start=&quot;3689&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 쿠폰이 70개라면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3829&quot; data-start=&quot;3770&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3783&quot; data-start=&quot;3770&quot;&gt;첫 번째 요청은 1번&lt;/li&gt;
&lt;li data-end=&quot;3797&quot; data-start=&quot;3784&quot;&gt;두 번째 요청은 2번&lt;/li&gt;
&lt;li data-end=&quot;3813&quot; data-start=&quot;3798&quot;&gt;70번째 요청까지는 성공&lt;/li&gt;
&lt;li data-end=&quot;3829&quot; data-start=&quot;3814&quot;&gt;71번째 요청부터는 실패&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3915&quot; data-start=&quot;3831&quot; data-ke-size=&quot;size16&quot;&gt;이 구조는 처음엔 꽤 매력적으로 보였다.&lt;br /&gt;Redis는 빠르고, INCR 같은 원자적 연산도 지원하고, 구현도 단순하게 가져갈 수 있기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;3915&quot; data-start=&quot;3831&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3928&quot; data-start=&quot;3917&quot; data-ke-size=&quot;size16&quot;&gt;하지만 구현하다 보니 놓친점이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3928&quot; data-start=&quot;3917&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3975&quot; data-start=&quot;3930&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;번호를 하나 올리는 것과, 실제 발급을 확정하는 것은 전혀 다른 문제였다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;4005&quot; data-start=&quot;3977&quot; data-ke-size=&quot;size16&quot;&gt;실제 발급 과정에서는 이런 조건들이 함께 따라온다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4102&quot; data-start=&quot;4007&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4024&quot; data-start=&quot;4007&quot;&gt;이미 참여한 사용자는 아닌지&lt;/li&gt;
&lt;li data-end=&quot;4045&quot; data-start=&quot;4025&quot;&gt;이벤트가 이미 종료된 것은 아닌지&lt;/li&gt;
&lt;li data-end=&quot;4061&quot; data-start=&quot;4046&quot;&gt;재고가 아직 남아 있는지&lt;/li&gt;
&lt;li data-end=&quot;4078&quot; data-start=&quot;4062&quot;&gt;발급 이력을 어떻게 남길지&lt;/li&gt;
&lt;li data-end=&quot;4102&quot; data-start=&quot;4079&quot;&gt;최종적으로 성공 상태를 어떻게 확정할지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4225&quot; data-start=&quot;4104&quot; data-ke-size=&quot;size16&quot;&gt;문제는 INCR 가 보장해주는 것은 어디까지나 &lt;b&gt;숫자를 하나 증가시키는 것&lt;/b&gt;뿐이라는 점이었다.&lt;br /&gt;번호를 올리는 것 자체는 원자적이지만, 그 번호를 바탕으로 실제 발급을 확정하는 과정은 그 뒤에 따로 이어진다.&lt;/p&gt;
&lt;p data-end=&quot;4225&quot; data-start=&quot;4104&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 문제가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1030&quot; data-start=&quot;980&quot; data-ke-size=&quot;size16&quot;&gt;INCR 자체는 원자적이다.&lt;br /&gt;하지만 INCR 결과를 받아와서 애플리케이션 서버가&lt;/p&gt;
&lt;p data-end=&quot;1030&quot; data-start=&quot;980&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이 번호면 발급 가능하네&amp;rdquo;라고 판단하고&lt;/p&gt;
&lt;p data-end=&quot;1030&quot; data-start=&quot;980&quot; data-ke-size=&quot;size16&quot;&gt;그다음에 중복 확인, 상태 변경, 저장 같은 실제 발급 로직을 수행하는 구조라면&lt;/p&gt;
&lt;p data-end=&quot;1131&quot; data-start=&quot;1111&quot; data-ke-size=&quot;size16&quot;&gt;그 사이에는 아주 짧은 틈이 생긴다.&lt;/p&gt;
&lt;p data-end=&quot;1131&quot; data-start=&quot;1111&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1167&quot; data-start=&quot;1133&quot; data-ke-size=&quot;size16&quot;&gt;이게 바로 전형적인 &lt;b&gt;Check-Then-Act&lt;/b&gt; 문제다.&lt;/p&gt;
&lt;p data-end=&quot;1289&quot; data-start=&quot;1203&quot; data-ke-size=&quot;size16&quot;&gt;평소에는 별문제 없어 보일 수 있다.&lt;br /&gt;하지만 선착순 이벤트처럼 많은 요청이 동시에 몰리는 상황에서는, 이 짧은 틈이 그대로 레이스 컨디션으로 이어진다.&lt;/p&gt;
&lt;p data-end=&quot;1463&quot; data-start=&quot;1291&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1463&quot; data-start=&quot;1291&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 여러 서버가 거의 동시에 INCR 결과를 받아 &amp;ldquo;발급 가능하다&amp;rdquo;고 판단했다고 해보자.&lt;br /&gt;그다음 단계에서 중복 확인이나 상태 변경이 분리되어 있다면, 그 찰나의 순간에 여러 요청이 동시에 같은 자원을 통과할 수 있다.&lt;br /&gt;결과적으로 중복 발급이나 초과 발급 같은 정합성 문제가 발생할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1560&quot; data-start=&quot;1465&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1560&quot; data-start=&quot;1465&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;번호를 정확히 매기는 것만으로는 부족했다.&lt;/b&gt;&lt;br /&gt;정말 필요한 것은&lt;br /&gt;&lt;b&gt;번호 증가, 조건 확인, 상태 변경이 하나의 원자적 단위로 움직이는 구조&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1567&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;분산 락 vs Lua Script&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1567&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1690&quot; data-start=&quot;1599&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하는 방법으로 분산 락을 떠올릴 수 있다.&lt;br /&gt;실제로 여러 서버가 동시에 같은 자원을 다루는 상황에서는 분산 락이 꽤 익숙한 선택지이기도 하다.&lt;/p&gt;
&lt;p data-end=&quot;1690&quot; data-start=&quot;1599&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1726&quot; data-start=&quot;1692&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 상황에서는 분산 락이 꼭 좋은 선택만은 아니었다.&lt;/p&gt;
&lt;p data-end=&quot;1726&quot; data-start=&quot;1692&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1752&quot; data-start=&quot;1728&quot; data-ke-size=&quot;size16&quot;&gt;가장 큰 이유는 &lt;b&gt;비용 대비 효과&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;1752&quot; data-start=&quot;1728&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1932&quot; data-start=&quot;1754&quot; data-ke-size=&quot;size16&quot;&gt;분산 락을 쓰려면 보통 락을 잡고, 확인하고, 해제하는 여러 단계가 필요하다.&lt;br /&gt;이 과정은 결국 네트워크 왕복을 여러 번 발생시키고, 락 만료 시간이나 해제 주체 같은 세부 설계까지 함께 고민해야 한다.&lt;br /&gt;2,000 TPS처럼 짧은 순간에 요청이 몰리는 상황에서는, 이 추가 비용 자체가 부담이 될 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1962&quot; data-start=&quot;1934&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1962&quot; data-start=&quot;1934&quot; data-ke-size=&quot;size16&quot;&gt;반면 우리가 하려던 작업은 생각보다 짧고 명확했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2021&quot; data-start=&quot;1964&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1977&quot; data-start=&quot;1964&quot;&gt;중복 참여를 확인하고&lt;/li&gt;
&lt;li data-end=&quot;1991&quot; data-start=&quot;1978&quot;&gt;현재 재고를 확인하고&lt;/li&gt;
&lt;li data-end=&quot;2009&quot; data-start=&quot;1992&quot;&gt;가능하면 카운터를 증가시키고&lt;/li&gt;
&lt;li data-end=&quot;2021&quot; data-start=&quot;2010&quot;&gt;상태를 바꾸는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도의 짧은 로직이라면, 락을 외부에서 관리하기보다 &lt;b&gt;Redis 내부에서 한 번에 실행하는 편이 더 단순하고 빠르다&lt;/b&gt;고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;2129&quot; data-start=&quot;2101&quot; data-ke-size=&quot;size16&quot;&gt;그래서 선택한 것이 &lt;b&gt;Lua Script&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;2129&quot; data-start=&quot;2101&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2239&quot; data-start=&quot;2131&quot; data-ke-size=&quot;size16&quot;&gt;Redis는 Lua Script가 실행되는 동안 다른 명령이 중간에 끼어들지 않도록 보장한다.&lt;br /&gt;즉, 여러 명령을 나눠 호출하는 대신, &lt;b&gt;하나의 스크립트 안에서 한 번에 처리&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2239&quot; data-start=&quot;2131&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2256&quot; data-start=&quot;2241&quot; data-ke-size=&quot;size16&quot;&gt;이 방식의 장점은 분명했다.&lt;/p&gt;
&lt;p data-end=&quot;2256&quot; data-start=&quot;2241&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2346&quot; data-start=&quot;2258&quot; data-ke-size=&quot;size16&quot;&gt;첫째, &lt;b&gt;진짜로 묶고 싶은 구간을 하나의 원자적 작업으로 만들 수 있다.&lt;/b&gt;&lt;br /&gt;중복 확인, 재고 체크, 번호 증가, 상태 변경을 한 번에 처리할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2346&quot; data-start=&quot;2258&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2451&quot; data-start=&quot;2348&quot; data-ke-size=&quot;size16&quot;&gt;둘째, &lt;b&gt;네트워크 왕복 횟수를 줄일 수 있다.&lt;/b&gt;&lt;br /&gt;애플리케이션 서버가 Redis와 여러 번 통신하며 판단하는 대신, 필요한 명령을 Redis 내부에서 바로 수행하게 만들 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2451&quot; data-start=&quot;2348&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2555&quot; data-start=&quot;2453&quot; data-ke-size=&quot;size16&quot;&gt;셋째, &lt;b&gt;로직의 책임이 더 분명해진다.&lt;/b&gt;&lt;br /&gt;발급 가능 여부를 애플리케이션 서버가 분산해서 판단하는 것이 아니라, Redis 안에서 한 번에 판정하게 되므로 흐름이 더 단단해진다.&lt;/p&gt;
&lt;p data-end=&quot;2662&quot; data-start=&quot;2607&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2662&quot; data-start=&quot;2607&quot; data-ke-size=&quot;size16&quot;&gt;결국 중요한 건 단순한 카운팅이 아니었다.&lt;br /&gt;&lt;b&gt;발급 가능 여부를 안전하게 판단하는 것&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;2707&quot; data-start=&quot;2664&quot; data-ke-size=&quot;size16&quot;&gt;그래서 Lua Script 안에서는 이런 흐름을 한 번에 처리하도록 생각했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2820&quot; data-start=&quot;2709&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2729&quot; data-start=&quot;2709&quot;&gt;이미 발급된 사용자인지 확인한다&lt;/li&gt;
&lt;li data-end=&quot;2751&quot; data-start=&quot;2730&quot;&gt;현재 재고가 남아 있는지 확인한다&lt;/li&gt;
&lt;li data-end=&quot;2774&quot; data-start=&quot;2752&quot;&gt;조건을 만족하면 카운터를 증가시킨다&lt;/li&gt;
&lt;li data-end=&quot;2802&quot; data-start=&quot;2775&quot;&gt;해당 사용자를 발급된 사용자 집합에 기록한다&lt;/li&gt;
&lt;li data-end=&quot;2820&quot; data-start=&quot;2803&quot;&gt;성공/실패 결과를 반환한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 같다.&lt;/p&gt;
&lt;p data-end=&quot;3487&quot; data-start=&quot;3395&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중복 확인 &amp;rarr; 재고 확인 &amp;rarr; 상태 변경&lt;/b&gt;&lt;br /&gt;이 흐름을 더 이상 애플리케이션 서버에서 나눠 처리하지 않고,&lt;br /&gt;Redis 안에서 한 번에 끝내도록 만든 것이다.&lt;/p&gt;
&lt;p data-end=&quot;3652&quot; data-start=&quot;3627&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3652&quot; data-start=&quot;3627&quot; data-ke-size=&quot;size16&quot;&gt;다만 여기서 한 가지는 분명히 구분해야 했다.&lt;/p&gt;
&lt;p data-end=&quot;3728&quot; data-start=&quot;3654&quot; data-ke-size=&quot;size16&quot;&gt;Lua Script가 보장해주는 원자성은 스크립트 실행 중 다른 Redis 명령이 끼어들지 않는다는 의미의 원자성이다.&lt;/p&gt;
&lt;p data-end=&quot;3830&quot; data-start=&quot;3730&quot; data-ke-size=&quot;size16&quot;&gt;즉, 애플리케이션 서버에서 여러 번 나눠 호출할 때 생기던 레이스 컨디션을 줄이는 데는 매우 효과적이다.&lt;/p&gt;
&lt;p data-end=&quot;3830&quot; data-start=&quot;3730&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3830&quot; data-start=&quot;3730&quot; data-ke-size=&quot;size16&quot;&gt;다만 이것이 SQL 트랜잭션처럼 자동 롤백까지 보장해주는 것은 아니다.&lt;/p&gt;
&lt;p data-end=&quot;4023&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size16&quot;&gt;그래서 스크립트는 최대한 짧고 단순하게 유지해야 했다.&lt;br /&gt;오류가 날 여지가 적은 명령만 넣고, Redis 안에서 오래 실행되는 무거운 로직은 피해야 했다.&lt;br /&gt;Lua Script가 강력한 이유는복잡한 로직을 다 때려 넣을 수 있어서가 아니라,&lt;br /&gt;&lt;b&gt;짧고 명확한 임계 구간을 안전하게 묶을 수 있어서&lt;/b&gt;라는 점을 분명히 인식하게 됐다.&lt;/p&gt;
&lt;p data-end=&quot;4023&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;4023&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size23&quot;&gt;문제 해결 시도 4&amp;nbsp; : Redis를 최종 저장소로?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua Script까지 고려하고 나니, 적어도 Redis 안에서 발급 판단을 꽤 안전하게 처리할 수는 있어 보였다.&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;하지만 여기서 또 다른 고민이 생겼다.&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;판단을 Redis에서 할 수 있다는 것과, 최종 발급 사실까지 Redis가 책임져도 되는가는 같은 문제인가?&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;선착순 이벤트에서 정말 중요한 것은&lt;br /&gt;카운터가 몇까지 올라갔는지가 아니라,&lt;br /&gt;&lt;b&gt;최종적으로 누구에게 발급이 확정되었는지가 흔들리지 않게 남는 것&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;4858&quot; data-start=&quot;4837&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5126&quot; data-start=&quot;5028&quot; data-ke-size=&quot;size16&quot;&gt;물론 Redis에도 스냅샷, 복제 같은 보완 장치는 있다.&lt;br /&gt;하지만 그렇다고 해서 Redis만으로 최종 발급 사실까지 모두 책임지게 두는 구조는 적어도 선착순 이벤트에서는 위험하다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;5126&quot; data-start=&quot;5028&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5126&quot; data-start=&quot;5028&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5126&quot; data-start=&quot;5028&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 주기적으로 DB에 반영하는 구조를 생각할 수도 있다.&lt;br /&gt;하지만 3초마다 반영하든, 1초마다 반영하든, 그 사이 장애가 발생하면 마지막 반영 구간의 데이터는 유실될 수 있다.&lt;br /&gt;이 공백은 단순한 데이터 손실 문제가 아니라, 잘못하면 누가 실제 당첨자인가가 흔들릴 수 있는 문제였다.&lt;/p&gt;
&lt;p data-end=&quot;5126&quot; data-start=&quot;5028&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5370&quot; data-start=&quot;5321&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그렇다면 발급이 확정될 때마다 메시지 큐를 통해 DB에 저장하면 되지 않을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;5390&quot; data-start=&quot;5372&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5390&quot; data-start=&quot;5372&quot; data-ke-size=&quot;size16&quot;&gt;구조만 놓고 보면 꽤 그럴듯했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5390&quot; data-start=&quot;5372&quot; data-ke-size=&quot;size16&quot;&gt;실제로 몇몇 테크 기업 블로그를 보면 메시큐를 활용한 모습을 자주 목격할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5466&quot; data-start=&quot;5392&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5413&quot; data-start=&quot;5392&quot;&gt;Redis는 빠르게 경쟁을 처리하고&lt;/li&gt;
&lt;li data-end=&quot;5439&quot; data-start=&quot;5414&quot;&gt;메시지 큐는 발급 이벤트를 안전하게 넘기고&lt;/li&gt;
&lt;li data-end=&quot;5466&quot; data-start=&quot;5440&quot;&gt;DB는 최종 발급 내역을 영속적으로 기록한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;5493&quot; data-start=&quot;5468&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 방식도 고민할수록 간단하지 않았다.&lt;/p&gt;
&lt;p data-end=&quot;5625&quot; data-start=&quot;5495&quot; data-ke-size=&quot;size16&quot;&gt;메시지 큐는 &lt;b&gt;DB를 보호하는 데는 효과적이지만 운영 복잡도와 러닝커브가 엄청났다.&lt;/b&gt;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;034&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/034.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/034.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5842&quot; data-start=&quot;5714&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5743&quot; data-start=&quot;5714&quot;&gt;producer와 consumer를 설계해야 하고&lt;/li&gt;
&lt;li data-end=&quot;5764&quot; data-start=&quot;5744&quot;&gt;메시지 중복 처리도 고려해야 하고&lt;/li&gt;
&lt;li data-end=&quot;5781&quot; data-start=&quot;5765&quot;&gt;재시도 정책도 정해야 하고&lt;/li&gt;
&lt;li data-end=&quot;5801&quot; data-start=&quot;5782&quot;&gt;장애 복구 흐름도 설계해야 하고&lt;/li&gt;
&lt;li data-end=&quot;5842&quot; data-start=&quot;5802&quot;&gt;적재는 성공했지만 응답은 실패한 경우 같은 애매한 상황도 다뤄야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;5891&quot; data-start=&quot;5844&quot; data-ke-size=&quot;size16&quot;&gt;구조는 더 단단해질 수 있지만, 함께 감당해야 할 설계와 운영의 무게는 엄청나게 커진다.&lt;/p&gt;
&lt;p data-end=&quot;5891&quot; data-start=&quot;5844&quot; data-ke-size=&quot;size16&quot;&gt;개념적으로는 아주 얕게만 알고 있어 짧은 기간 안에 실제 운영 관점까지 고려해 운영할 수 없다고 판단했다.&lt;br /&gt;또한 러닝커브도 엄청나기에 더더욱 판단에 확신이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;6040&quot; data-start=&quot;6021&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6086&quot; data-start=&quot;6042&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메시지 큐까지 도입하는 것은, 당시 상황에서는 오버엔지니어링에 가까웠다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;6105&quot; data-start=&quot;6088&quot; data-ke-size=&quot;size16&quot;&gt;그래서 빠르게 포기했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6105&quot; data-start=&quot;6088&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;6105&quot; data-start=&quot;6088&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4차 문제 해결 : 대기열&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;div data-start-index=&quot;963&quot;&gt;&lt;span data-start-index=&quot;963&quot;&gt;고민 끝에 내린 결론은 병목은&amp;nbsp;&lt;/span&gt;&lt;b data-start-index=&quot;976&quot;&gt;Redis를 통해서 잡지만 최종 저장소로는 쓰지 않기로 했다. &lt;/b&gt;&lt;/div&gt;
&lt;div data-start-index=&quot;963&quot;&gt;대신 빠르게 줄을 세우고 중복을 거르는 대기열로 쓰고자 했다.&lt;/div&gt;
&lt;div data-start-index=&quot;1032&quot;&gt;&lt;span data-start-index=&quot;1032&quot;&gt;아무리 서버를 늘려도 결국 모든 요청이 사양이 낮은 DB로 향하면 커넥션 고갈로 인한 연쇄 장애는 피할 수 없기 때문이다.&lt;/span&gt;&lt;span data-start-index=&quot;1115&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span data-start-index=&quot;1115&quot;&gt;결국 &lt;/span&gt;&lt;b data-start-index=&quot;1120&quot;&gt;Redis가 대기열과 상태 관리를 전담하며 폭주하는 트래픽을 1차로 흡수&lt;/b&gt;&lt;span data-start-index=&quot;1160&quot;&gt;하고, &lt;/span&gt;&lt;b data-start-index=&quot;1164&quot;&gt;실제 발급 처리는 DB가 감당할 수 있는 만큼만 질서 있게 넘겨주는 구조&lt;/b&gt;&lt;span data-start-index=&quot;1204&quot;&gt;로 재정의하여 시스템 전체의 생존력을 확보하고자 했다.&lt;/span&gt;&lt;/div&gt;
&lt;p data-end=&quot;6231&quot; data-start=&quot;6160&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;6231&quot; data-start=&quot;6160&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5차 문제 : Redis 안에서 줄 세우기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1668&quot; data-ke-size=&quot;size16&quot;&gt;이제 Redis를 대기열로 쓰기로 했으니 어떤식으로 줄을 세울지를 정하면된다.&lt;/p&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1668&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1668&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;b&gt;List&lt;/b&gt;를 제일 먼저 떠올렸다.&lt;br /&gt;대기열이라고 하면 가장 직관적으로 떠오르는 자료구조가 리스트였기 때문이다.&lt;br /&gt;앞에서 넣고 뒤에서 빼거나, 뒤에 쌓고 앞에서 꺼내는 방식 자체는 딱 줄 서기의 이미지와 잘 맞아 보였다.&lt;/p&gt;
&lt;p data-end=&quot;295&quot; data-start=&quot;220&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;295&quot; data-start=&quot;220&quot; data-ke-size=&quot;size16&quot;&gt;하지만 실제 요구사항을 하나씩 대입해보니 생각보다 불편한 점이 많았다.&lt;br /&gt;우리에게 필요한 건 단순히 순서대로 꺼내기만이 아니었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;400&quot; data-start=&quot;297&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;337&quot; data-start=&quot;297&quot;&gt;특정 사용자가 이미 줄에 들어와 있는지 빠르게 확인할 수 있어야 하고&lt;/li&gt;
&lt;li data-end=&quot;371&quot; data-start=&quot;338&quot;&gt;사용자가 지금 몇 번째인지도 바로 보여줄 수 있어야 하고&lt;/li&gt;
&lt;li data-end=&quot;400&quot; data-start=&quot;372&quot;&gt;중간에 이탈하거나 재시도하는 상황도 다뤄야 했다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;628&quot; data-start=&quot;402&quot; data-ke-size=&quot;size16&quot;&gt;리스트는 순서를 유지하는 데는 좋았지만, 사용자가 중복으로 들어있는지 현재 내 순번이 몇 번인지는 알기 어려웠다.&amp;nbsp;&lt;br /&gt;결국 순서는 리스트로 관리하고, 중복 확인은 또 다른 자료구조로 따로 관리해야 하는 식으로 구조가 갈라질 가능성이 높았다.&lt;br /&gt;줄을 세우는 문제를 단순하게 풀고 싶었는데, 오히려 자료구조가 둘 셋으로 흩어질 수 있다는 점이 걸렸다.&lt;/p&gt;
&lt;p data-end=&quot;628&quot; data-start=&quot;402&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;766&quot; data-start=&quot;630&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Set&lt;/b&gt;도 당연히 후보에 올랐다.&lt;br /&gt;중복 방지만 놓고 보면 오히려 List보다 더 매력적이었다.&lt;br /&gt;같은 사용자가 여러 번 요청하더라도 한 번만 들어가게 만들 수 있고, &amp;ldquo;이미 참여한 사용자인가?&amp;rdquo; 같은 체크도 훨씬 자연스럽기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;766&quot; data-start=&quot;630&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;948&quot; data-start=&quot;768&quot; data-ke-size=&quot;size16&quot;&gt;문제는 Set에는 &lt;b&gt;순서가 없다는 것&lt;/b&gt;이었다.&lt;br /&gt;선착순 이벤트에서 핵심은 결국 &amp;ldquo;누가 먼저 들어왔는가&amp;rdquo;인데, Set만으로는 이걸 표현할 수 없다.&lt;br /&gt;즉, 중복 방지는 해결되더라도 &lt;b&gt;선착순의 본질인 순번 관리가 빠져버린다.&lt;/b&gt;&lt;br /&gt;결국 Set 역시 단독으로는 부족했고, 또 다른 순서용 자료구조를 옆에 붙여야 했다.&lt;/p&gt;
&lt;p data-end=&quot;948&quot; data-start=&quot;768&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1136&quot; data-start=&quot;950&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stream&lt;/b&gt;도 후보에서 완전히 배제한 건 아니었다.&lt;br /&gt;오히려 한때는 꽤 진지하게 고민했다.&lt;br /&gt;요청이 들어온 순서대로 append 되고, 소비자 그룹 같은 개념도 있어서 &amp;ldquo;이벤트를 순서대로 처리한다&amp;rdquo;는 느낌에는 잘 맞아 보였기 때문이다.&lt;br /&gt;특히 로그처럼 요청 흐름을 쌓아두고 뒤에서 소비하는 구조를 떠올리면 꽤 그럴듯했다.&lt;/p&gt;
&lt;p data-end=&quot;1136&quot; data-start=&quot;950&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1483&quot; data-start=&quot;1138&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 문제를 다시 놓고 보니 우리가 원하는 건 단순한 이벤트 로그 저장이 아니었다.&lt;br /&gt;중요한 건 &lt;b&gt;&amp;ldquo;지금 누가 몇 번째인가&amp;rdquo;&lt;/b&gt;, &lt;b&gt;&amp;ldquo;이 사용자가 현재 WAITING인지 ACTIVE인지&amp;rdquo;&lt;/b&gt;, &lt;b&gt;&amp;ldquo;중복 없이 줄을 서게 할 수 있는가&amp;rdquo;&lt;/b&gt; 였다.&lt;br /&gt;즉, 뒤에서 차례대로 소비하는 것만으로는 부족했고, &lt;b&gt;현재 대기열 상태를 바로 조회하고 제어할 수 있어야 했다.&lt;/b&gt;&lt;br /&gt;Stream은 이런 요구를 풀 수는 있겠지만, 지금 우리 문제에 비해 구조가 다소 무겁게 느껴졌다.&lt;br /&gt;이번 이벤트에서는 메시지를 쌓고 소비하는 시스템보다, &lt;b&gt;현재 대기 중인 줄을 빠르게 관리하는 시스템&lt;/b&gt;이 더 중요했다.&lt;/p&gt;
&lt;p data-end=&quot;1508&quot; data-start=&quot;1485&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1508&quot; data-start=&quot;1485&quot; data-ke-size=&quot;size16&quot;&gt;결국 하나씩 비교해보니 기준이 분명해졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1683&quot; data-start=&quot;1510&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1554&quot; data-start=&quot;1510&quot;&gt;List는 &lt;b&gt;순서&lt;/b&gt;에는 강하지만 &lt;b&gt;중복 확인과 순번 조회&lt;/b&gt;가 불편했고&lt;/li&gt;
&lt;li data-end=&quot;1599&quot; data-start=&quot;1555&quot;&gt;Set은 &lt;b&gt;중복 방지&lt;/b&gt;에는 강하지만 &lt;b&gt;순서 자체를 표현할 수 없었고&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1683&quot; data-start=&quot;1600&quot;&gt;Stream은 &lt;b&gt;이벤트 흐름을 쌓고 소비하는 데는 좋지만&lt;/b&gt;, 이번처럼 &lt;b&gt;현재 대기열의 순번과 상태를 즉시 다루는 문제&lt;/b&gt;에는 다소 무거웠다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1708&quot; data-start=&quot;1685&quot; data-ke-size=&quot;size16&quot;&gt;결국에 도달한 곳은&lt;/p&gt;
&lt;p data-end=&quot;1771&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;순서, 중복 방지, 현재 순번 조회를 한 번에 조금 더 자연스럽게 다룰 수 있는 자료구조&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1810&quot; data-start=&quot;1773&quot; data-ke-size=&quot;size16&quot;&gt;바로 &lt;b&gt;Sorted Set&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-end=&quot;194&quot; data-start=&quot;159&quot; data-ke-size=&quot;size26&quot;&gt;5차 문제 해결: Sorted Set&lt;/h2&gt;
&lt;p data-end=&quot;256&quot; data-start=&quot;221&quot; data-ke-size=&quot;size16&quot;&gt;우리가 원하는 대기열은 아래와 같았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;388&quot; data-start=&quot;258&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;283&quot; data-start=&quot;258&quot;&gt;먼저 들어온 순서를 유지할 수 있어야 하고&lt;/li&gt;
&lt;li data-end=&quot;320&quot; data-start=&quot;284&quot;&gt;같은 사용자가 여러 번 요청해도 중복으로 줄 서지 않아야 하고&lt;/li&gt;
&lt;li data-end=&quot;354&quot; data-start=&quot;321&quot;&gt;사용자가 지금 몇 번째인지 빠르게 확인할 수 있어야 하고&lt;/li&gt;
&lt;li data-end=&quot;388&quot; data-start=&quot;355&quot;&gt;필요하면 현재 상태와도 자연스럽게 연결할 수 있어야 했다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;452&quot; data-start=&quot;390&quot; data-ke-size=&quot;size16&quot;&gt;즉, 필요한 건 값 저장소가 아니라 &lt;b&gt;정렬된 줄 그 자체를 표현할 수 있는 자료구조&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;452&quot; data-start=&quot;390&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;492&quot; data-start=&quot;454&quot; data-ke-size=&quot;size16&quot;&gt;그 기준으로 보니 가장 잘 맞는 건 &lt;b&gt;Sorted Set&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;492&quot; data-start=&quot;454&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;580&quot; data-origin-height=&quot;580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bANoQa/dJMcagrmq1o/Ff5Khjij5gOCd3TapuoKXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bANoQa/dJMcagrmq1o/Ff5Khjij5gOCd3TapuoKXK/img.png&quot; data-alt=&quot;https://medium.com/analytics-vidhya&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bANoQa/dJMcagrmq1o/Ff5Khjij5gOCd3TapuoKXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbANoQa%2FdJMcagrmq1o%2FFf5Khjij5gOCd3TapuoKXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;406&quot; height=&quot;406&quot; data-origin-width=&quot;580&quot; data-origin-height=&quot;580&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://medium.com/analytics-vidhya&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;492&quot; data-start=&quot;454&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;567&quot; data-start=&quot;494&quot; data-ke-size=&quot;size16&quot;&gt;Sorted Set은 member 와 score 를 함께 저장한다.&lt;br /&gt;이 구조가 대기열 문제와 잘 맞았던 이유는 단순했다.&lt;/p&gt;
&lt;p data-end=&quot;660&quot; data-start=&quot;569&quot; data-ke-size=&quot;size16&quot;&gt;사용자 ID를 member 로 넣고, 들어온 순서를 나타내는 값을 score 로 넣으면 Redis 안에서 대기열 자체가 곧 &lt;b&gt;정렬된 줄&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-end=&quot;717&quot; data-start=&quot;662&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;717&quot; data-start=&quot;662&quot; data-ke-size=&quot;size16&quot;&gt;즉, 별도로 정렬 로직을 돌리지 않아도&lt;br /&gt;Redis가 score 기준으로 항상 순서를 유지해준다.&lt;/p&gt;
&lt;p data-end=&quot;730&quot; data-start=&quot;719&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;730&quot; data-start=&quot;719&quot; data-ke-size=&quot;size16&quot;&gt;이게 생각보다 컸다.&lt;/p&gt;
&lt;p data-end=&quot;823&quot; data-start=&quot;732&quot; data-ke-size=&quot;size16&quot;&gt;List를 쓸 때는 &amp;ldquo;순서는 유지되지만 중복 확인은 따로&amp;rdquo;, Set을 쓸 때는 &amp;ldquo;중복은 막히지만 순서는 따로&amp;rdquo;처럼 구조가 계속 둘로 갈라질 가능성이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;823&quot; data-start=&quot;732&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;879&quot; data-start=&quot;825&quot; data-ke-size=&quot;size16&quot;&gt;반면 Sorted Set은 &lt;b&gt;순서와 중복 제어를 한 번에 더 자연스럽게 다룰 수 있었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1008&quot; data-start=&quot;881&quot; data-ke-size=&quot;size16&quot;&gt;특히 member 가 사용자 ID이기 때문에, 같은 사용자가 다시 요청했을 때도 대기열 안에 중복으로 여러 번 쌓이지 않게 제어하기가 훨씬 수월했다. 즉, 한 사람이 여러 번 줄 서는 문제를 다루기에도 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;1008&quot; data-start=&quot;881&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1034&quot; data-start=&quot;1010&quot; data-ke-size=&quot;size16&quot;&gt;또 하나 중요했던 건 &lt;b&gt;순번 조회&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;1179&quot; data-start=&quot;1036&quot; data-ke-size=&quot;size16&quot;&gt;이번 이벤트에서는 단순히 뒤에서 차례대로 꺼내기만 하면 되는 것이 아니었다.&lt;br /&gt;사용자 입장에서는 &amp;ldquo;내가 지금 몇 번째인가?&amp;rdquo;를 확인할 수 있어야 했고,&lt;br /&gt;서버 입장에서도 특정 사용자가 대기열 안에서 어디쯤 있는지를 비교적 빠르게 알아낼 수 있어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;1289&quot; data-start=&quot;1181&quot; data-ke-size=&quot;size16&quot;&gt;이 점에서도 Sorted Set은 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;1289&quot; data-start=&quot;1181&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정렬된 구조를 유지하고 있기 때문에,&lt;br /&gt;&amp;ldquo;누가 앞에 있는가&amp;rdquo;뿐 아니라 &lt;b&gt;&amp;ldquo;내가 몇 번째인가&amp;rdquo;&lt;/b&gt; 같은 정보까지 자연스럽게 연결할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1354&quot; data-start=&quot;1291&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1354&quot; data-start=&quot;1291&quot; data-ke-size=&quot;size16&quot;&gt;결국 Sorted Set은 우리가 대기열에 기대했던 핵심 요구사항을 가장 균형 있게 만족시켜주는 자료구조였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1424&quot; data-start=&quot;1356&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1370&quot; data-start=&quot;1356&quot;&gt;순서를 유지할 수 있고&lt;/li&gt;
&lt;li data-end=&quot;1387&quot; data-start=&quot;1371&quot;&gt;중복 제어와 연결하기 쉽고&lt;/li&gt;
&lt;li data-end=&quot;1405&quot; data-start=&quot;1388&quot;&gt;현재 순번 조회에도 유리하고&lt;/li&gt;
&lt;li data-end=&quot;1424&quot; data-start=&quot;1406&quot;&gt;상태 관리 구조와도 잘 붙는다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1536&quot; data-start=&quot;1426&quot; data-ke-size=&quot;size16&quot;&gt;즉, 우리가 찾고 있던 것은 큐처럼 보이는 자료구조가 아니라, &lt;b&gt;선착순 이벤트에 필요한 질서를 계속 유지해주는 자료구조&lt;/b&gt;였고,&lt;br /&gt;그 역할에 가장 잘 맞는 것이 Sorted Set이었다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-start=&quot;6160&quot; data-end=&quot;6231&quot;&gt;&lt;b&gt;6차 문제 : 대기열 상태에서 자신의 차례를 어떻게 확인할지&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sorted Set으로 대기열을 만들고, 사용자를 WAITING 상태로 관리하기 시작하자 바로 다음 문제가 따라왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;507&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;이제 사용자는 자신의 차례를 어떻게 확인할 것인가?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;507&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;680&quot; data-start=&quot;509&quot; data-ke-size=&quot;size16&quot;&gt;대기열은 서버 입장에서는 정리된 줄이지만, 사용자 입장에서는 보이지 않는 줄이다.&lt;br /&gt;사용자는 단순히 &amp;ldquo;요청이 접수되었다&amp;rdquo;는 사실만으로는 충분하지 않았다.&lt;br /&gt;지금 내가 여전히 WAITING 상태인지, 아니면 다음 단계로 넘어갈 수 있는지, 가능하다면 현재 순번이 어느 정도인지를 계속 확인할 수 있어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;680&quot; data-start=&quot;509&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;770&quot; data-start=&quot;682&quot; data-ke-size=&quot;size16&quot;&gt;즉, 대기열을 만든 순간부터는 단순히 줄을 세우는 문제만 남는 것이 아니었다.&lt;br /&gt;&lt;b&gt;그 줄을 사용자가 어떤 방식으로 조회하게 할 것인가&lt;/b&gt;까지 함께 설계해야 했다.&lt;/p&gt;
&lt;p data-end=&quot;770&quot; data-start=&quot;682&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;739&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 부분도 꽤 단순하게 볼 수 있었다.&lt;br /&gt;&amp;ldquo;상태가 바뀌면 서버가 알려주면 되는 것 아닌가?&amp;rdquo;&lt;br /&gt;&amp;ldquo;아니면 사용자가 일정 간격으로 물어보게 하면 되지 않을까?&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;739&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;782&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이 질문을 조금만 더 깊게 파고들면, 바로 또 다른 문제가 생긴다.&lt;/p&gt;
&lt;p data-end=&quot;939&quot; data-start=&quot;784&quot; data-ke-size=&quot;size16&quot;&gt;이번 이벤트에서는 대기 사용자가 수백 명 수준이 아니라, 많게는 &lt;b&gt;2,000명 가까이 동시에 줄을 설 수 있다&lt;/b&gt;고 보고 있었다.&lt;br /&gt;즉, 상태 조회 방식 하나를 잘못 선택하면, 실제 발급 요청보다 &lt;b&gt;상태 조회 요청이나 연결 유지 비용이 더 큰 부담&lt;/b&gt;이 될 수도 있었다.&lt;/p&gt;
&lt;p data-end=&quot;979&quot; data-start=&quot;941&quot; data-ke-size=&quot;size16&quot;&gt;이 지점에서 상태 조회 방식으로는 크게 세 가지를 검토할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1132&quot; data-start=&quot;992&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1146&quot; data-start=&quot;1134&quot; data-ke-size=&quot;size16&quot;&gt;결국 문제는 단순했다.&lt;/p&gt;
&lt;p data-end=&quot;1146&quot; data-start=&quot;1134&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1215&quot; data-start=&quot;1148&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;상태를 보여주는 것&amp;rdquo;도 중요하지만, 그 상태 조회 요청 자체가 또 다른 트래픽 폭탄이 되지 않게 만들어야 했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1215&quot; data-start=&quot;1148&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1215&quot; data-start=&quot;1148&quot; data-ke-size=&quot;size16&quot;&gt;(대기열 잘 못 만들면 ... 큰일난다)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;758&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vLiR7/dJMcaarigk6/IxQB6dBT9kaNppXmpacylK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vLiR7/dJMcaarigk6/IxQB6dBT9kaNppXmpacylK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vLiR7/dJMcaarigk6/IxQB6dBT9kaNppXmpacylK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvLiR7%2FdJMcaarigk6%2FIxQB6dBT9kaNppXmpacylK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;334&quot; height=&quot;260&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;758&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1215&quot; data-start=&quot;1148&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;6231&quot; data-start=&quot;6160&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6차 문제&lt;span&gt; 해결 방법 1: SSE&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;SSE&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;1160&quot; data-start=&quot;1040&quot; data-ke-size=&quot;size16&quot;&gt;SSE는 서버와의 연결을 유지한 채, 서버가 클라이언트에게 상태 변화를 밀어주는 방식이다.&lt;br /&gt;사용자 입장에서는 별도 요청 없이도 상태가 갱신되기 때문에, 대기열처럼 상태 변화가 중요한 기능에 꽤 잘 어울려 보였다.&lt;/p&gt;
&lt;p data-end=&quot;1183&quot; data-start=&quot;1162&quot; data-ke-size=&quot;size16&quot;&gt;특히 처음에는 이런 점이 좋아 보였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1264&quot; data-start=&quot;1185&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1208&quot; data-start=&quot;1185&quot;&gt;사용자가 계속 새로고침하지 않아도 된다&lt;/li&gt;
&lt;li data-end=&quot;1237&quot; data-start=&quot;1209&quot;&gt;상태가 바뀌는 순간 서버가 바로 알려줄 수 있다&lt;/li&gt;
&lt;li data-end=&quot;1264&quot; data-start=&quot;1238&quot;&gt;WebSocket보다는 구조가 단순해 보인다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1318&quot; data-start=&quot;1266&quot; data-ke-size=&quot;size16&quot;&gt;즉, &amp;ldquo;대기열에서 내 차례가 오면 서버가 바로 알려주는 구조&amp;rdquo;라는 점만 보면 꽤 매력적이었다.&lt;/p&gt;
&lt;p data-end=&quot;1346&quot; data-start=&quot;1320&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1346&quot; data-start=&quot;1320&quot; data-ke-size=&quot;size16&quot;&gt;하지만 실제 상황에 대입해보니 부담도 분명했다.&lt;/p&gt;
&lt;p data-end=&quot;1346&quot; data-start=&quot;1320&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1524&quot; data-start=&quot;1348&quot; data-ke-size=&quot;size16&quot;&gt;가장 큰 문제는 &lt;b&gt;연결을 오래 유지해야 한다는 점&lt;/b&gt;이었다.&lt;br /&gt;대기 인원이 많아질수록 서버와 로드밸런서는 그 수만큼의 연결을 오래 붙들고 있어야 한다.&lt;br /&gt;우리가 현재 예상하는 1,500 ~ 2,000명이 동시에 대기열에 들어와 있고, 이 중 상당수가 몇 분씩 WAITING 상태를 유지한다고 생각해보면, 그 연결 자체가 새로운 관리 대상이 된다.&lt;/p&gt;
&lt;p data-end=&quot;1524&quot; data-start=&quot;1348&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1693&quot; data-start=&quot;1526&quot; data-ke-size=&quot;size16&quot;&gt;게다가 대기열에서는 모든 사용자에게 실시간으로 많은 이벤트를 보내는 것도 아니다.&lt;br /&gt;대부분의 사용자는 상당 시간 동안 그냥 WAITING 상태에 머물러 있고, 상태 변화는 생각보다 자주 일어나지 않는다.&lt;br /&gt;즉, &lt;b&gt;연결은 계속 붙잡고 있지만 실제로 자주 보내는 정보는 많지 않은 구조&lt;/b&gt;가 된다.&lt;/p&gt;
&lt;p data-end=&quot;1693&quot; data-start=&quot;1526&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1761&quot; data-start=&quot;1695&quot; data-ke-size=&quot;size16&quot;&gt;이런 상황에서는 실시간으로 밀어줄 수 있다는 장점보다,&lt;br /&gt;&lt;b&gt;장시간 연결을 유지하는 비용&lt;/b&gt;이 더 크게 느껴졌다.&lt;/p&gt;
&lt;p data-end=&quot;1761&quot; data-start=&quot;1695&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1787&quot; data-start=&quot;1768&quot; data-ke-size=&quot;size23&quot;&gt;6차 문제 해결 방법 2: WebSocket&lt;/h3&gt;
&lt;p data-end=&quot;1813&quot; data-start=&quot;1789&quot; data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;WebSocket&lt;/b&gt; 이었다.&lt;/p&gt;
&lt;p data-end=&quot;1925&quot; data-start=&quot;1815&quot; data-ke-size=&quot;size16&quot;&gt;WebSocket은 양방향 통신이 가능하고, 실시간성이 가장 뛰어난 방식 중 하나다.&lt;br /&gt;서버가 상태를 즉시 밀어줄 수 있을 뿐 아니라, 필요하다면 클라이언트와 더 풍부한 상호작용도 만들 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1925&quot; data-start=&quot;1815&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1949&quot; data-start=&quot;1927&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 방식도 상당히 좋아 보였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2020&quot; data-start=&quot;1951&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1969&quot; data-start=&quot;1951&quot;&gt;가장 실시간에 가까운 방식이고&lt;/li&gt;
&lt;li data-end=&quot;1994&quot; data-start=&quot;1970&quot;&gt;서버가 상태 변화를 즉시 전달할 수 있고&lt;/li&gt;
&lt;li data-end=&quot;2020&quot; data-start=&quot;1995&quot;&gt;이후 더 복잡한 상호작용까지 확장하기 좋다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2063&quot; data-start=&quot;2022&quot; data-ke-size=&quot;size16&quot;&gt;즉, 실시간 대기열이라는 이미지와 제일 잘 어울리는 방식처럼 느껴졌다.&lt;/p&gt;
&lt;p data-end=&quot;2063&quot; data-start=&quot;2022&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2125&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 이벤트에서 정말 필요한 것을 다시 생각해보니, WebSocket은 다소 과한 선택일 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;2125&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2265&quot; data-start=&quot;2127&quot; data-ke-size=&quot;size16&quot;&gt;우리가 필요했던 것은 채팅처럼 &lt;b&gt;양방향 상호작용이 핵심인 시스템&lt;/b&gt;이 아니었다.&lt;br /&gt;대부분의 경우 사용자는 지금 내 상태가 무엇인가만 알면 됐고, 서버도 기다리기 or&amp;nbsp; 다음 단계 진행 정도의 정보만 전달하면 충분했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2342&quot; data-start=&quot;2267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2342&quot; data-start=&quot;2267&quot; data-ke-size=&quot;size16&quot;&gt;즉, WebSocket이 제공하는 강한 양방향성은 유용해보였지만&lt;br /&gt;이번 문제에서는 &lt;b&gt;필수 기능이라기보다 여유 기능에 가까웠다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2359&quot; data-start=&quot;2344&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2359&quot; data-start=&quot;2344&quot; data-ke-size=&quot;size16&quot;&gt;운영 측면의 부담도 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2489&quot; data-start=&quot;2361&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2377&quot; data-start=&quot;2361&quot;&gt;연결을 오래 유지해야 하고&lt;/li&gt;
&lt;li data-end=&quot;2405&quot; data-start=&quot;2378&quot;&gt;연결이 끊겼을 때 재연결 전략도 생각해야 하고&lt;/li&gt;
&lt;li data-end=&quot;2453&quot; data-start=&quot;2406&quot;&gt;배포나 일시적인 네트워크 문제 이후 재접속이 한꺼번에 몰릴 가능성도 고려해야 하고&lt;/li&gt;
&lt;li data-end=&quot;2489&quot; data-start=&quot;2454&quot;&gt;애플리케이션 서버 입장에서도 지속 연결을 계속 관리해야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2491&quot; data-ke-size=&quot;size16&quot;&gt;한마디로, WebSocket은 분명 강력했지만&lt;br /&gt;이번 이벤트가 요구하는 수준에 비해 &lt;b&gt;운영 복잡도가 커질 가능성&lt;/b&gt;이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2491&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2692&quot; data-start=&quot;2565&quot; data-ke-size=&quot;size16&quot;&gt;특히 이번 이벤트는 첫 시도였고,&lt;br /&gt;짧은 기간 안에 &lt;b&gt;안정적으로 붙일 수 있는가&lt;/b&gt;가 훨씬 중요했다.&lt;br /&gt;그 관점에서 보면 WebSocket은 &amp;ldquo;할 수는 있지만 지금 꼭 필요한가?&amp;rdquo;라는 질문에 선뜻 그렇다고 답할수가 없었다.&lt;/p&gt;
&lt;p data-end=&quot;2692&quot; data-start=&quot;2565&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2716&quot; data-start=&quot;2699&quot; data-ke-size=&quot;size23&quot;&gt;6차 문제 해결 방법 3: Polling&lt;/h3&gt;
&lt;p data-end=&quot;2740&quot; data-start=&quot;2718&quot; data-ke-size=&quot;size16&quot;&gt;세 번째는 &lt;b&gt;Polling&lt;/b&gt; 이었다.&lt;/p&gt;
&lt;p data-end=&quot;2740&quot; data-start=&quot;2718&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2870&quot; data-start=&quot;2742&quot; data-ke-size=&quot;size16&quot;&gt;Polling은 사용자가 일정 주기마다 서버에 상태를 다시 요청하는 방식이다.&lt;br /&gt;실시간성만 놓고 보면 SSE나 WebSocket보다 덜 세련돼 보일 수 있다.&lt;br /&gt;사용자가 계속 상태가 바뀌었는지를 물어봐야 하기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;2894&quot; data-start=&quot;2872&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2894&quot; data-start=&quot;2872&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 가장 단순한 방식처럼 느껴졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2950&quot; data-start=&quot;2896&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2906&quot; data-start=&quot;2896&quot;&gt;구현이 단순하고&lt;/li&gt;
&lt;li data-end=&quot;2929&quot; data-start=&quot;2907&quot;&gt;브라우저와 서버 모두 익숙한 방식이고&lt;/li&gt;
&lt;li data-end=&quot;2950&quot; data-start=&quot;2930&quot;&gt;디버깅이나 운영도 상대적으로 쉽다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3005&quot; data-start=&quot;2952&quot; data-ke-size=&quot;size16&quot;&gt;하지만 단순하다고 해서 무조건 좋은 것은 아니었다.&lt;br /&gt;Polling도 잘못 쓰면 꽤 위험하다.&lt;/p&gt;
&lt;p data-end=&quot;3005&quot; data-start=&quot;2952&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3160&quot; data-start=&quot;3007&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 대기 중인 사용자가 2,000명인데, 모두가 1초마다 상태를 조회한다고 해보자.&lt;br /&gt;그러면 발급 요청과는 별개로 상태 조회 요청만으로도 초당 2,000건이 추가된다.&lt;br /&gt;즉, 실제 발급 요청은 잘 제어해놓고도 &lt;b&gt;조회 요청 때문에 앞단이 다시 흔들릴 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;3160&quot; data-start=&quot;3007&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3257&quot; data-start=&quot;3162&quot; data-ke-size=&quot;size16&quot;&gt;그래서 Polling은 처음부터 &amp;ldquo;쉽다&amp;rdquo;는 이유만으로 선택할 수 있는 방식은 아니었다.&lt;br /&gt;중요한 건 &lt;b&gt;그 polling을 얼마나 통제된 형태로 만들 수 있느냐&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;3257&quot; data-start=&quot;3162&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3284&quot; data-start=&quot;3259&quot; data-ke-size=&quot;size16&quot;&gt;다만 Polling에는 분명한 장점도 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3392&quot; data-start=&quot;3286&quot; data-ke-size=&quot;size16&quot;&gt;Polling은 요청이 들어온 순간에만 자원을 사용하고, 응답을 반환한 뒤에는 연결을 정리할 수 있다.&lt;br /&gt;즉, SSE나 WebSocket처럼 대규모 지속 연결을 오래 유지하지 않아도 된다.&lt;/p&gt;
&lt;p data-end=&quot;3392&quot; data-start=&quot;3286&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3494&quot; data-start=&quot;3394&quot; data-ke-size=&quot;size16&quot;&gt;물론 매번 새 연결을 맺는다면 비효율적일 수 있다.&lt;br /&gt;하지만 HTTP keep-alive를 활용하면 같은 연결을 재사용할 수 있어서, 연결 생성 비용을 어느 정도 줄일 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;3599&quot; data-start=&quot;3496&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3599&quot; data-start=&quot;3496&quot; data-ke-size=&quot;size16&quot;&gt;즉, Polling은 실시간성에서는 조금 손해를 보더라도,&lt;/p&gt;
&lt;p data-end=&quot;3599&quot; data-start=&quot;3496&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;대규모 대기열 환경에서 지속 연결을 오래 유지하지 않고도 비교적 단순하게 운영할 수 있는 방식&lt;/b&gt;이라는 장점이 있었다.&lt;/p&gt;
&lt;h2 data-end=&quot;3636&quot; data-start=&quot;3606&quot; data-ke-size=&quot;size26&quot;&gt;6차 문제 해결&lt;/h2&gt;
&lt;p data-end=&quot;3667&quot; data-start=&quot;3638&quot; data-ke-size=&quot;size16&quot;&gt;세 가지를 놓고 비교해보니 기준이 조금씩 분명해졌다.&lt;/p&gt;
&lt;p data-end=&quot;3667&quot; data-start=&quot;3638&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3766&quot; data-start=&quot;3669&quot; data-ke-size=&quot;size16&quot;&gt;이번 이벤트에서 정말 중요했던 것은&lt;br /&gt;가장 실시간에 가까운 방식이 아니라,&lt;br /&gt;&lt;b&gt;대기열 상태를 사용자에게 충분히 보여주면서도 시스템 전체를 흔들지 않는 방식&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;3766&quot; data-start=&quot;3669&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3947&quot; data-start=&quot;3768&quot; data-ke-size=&quot;size16&quot;&gt;그 관점에서 보면 SSE와 WebSocket은 분명 매력적이었지만,&lt;br /&gt;대기 인원이 많을 때 장시간 연결을 계속 유지해야 한다는 부담이 있었다.&lt;br /&gt;반면 Polling은 덜 세련돼 보일 수는 있어도,&lt;br /&gt;요청이 들어온 순간에만 자원을 사용하고 비교적 단순하게 운영할 수 있다는 점에서 이번 이벤트의 조건에 더 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;3947&quot; data-start=&quot;3768&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3979&quot; data-start=&quot;3949&quot; data-ke-size=&quot;size16&quot;&gt;그래서 최종적으로는 &lt;b&gt;Polling&lt;/b&gt; 을 선택했다.&lt;/p&gt;
&lt;p data-end=&quot;3979&quot; data-start=&quot;3949&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4034&quot; data-start=&quot;3981&quot; data-ke-size=&quot;size16&quot;&gt;다만 여기서 중요한 건,&lt;br /&gt;우리가 선택한 것은 단순한 &amp;ldquo;고정 주기 폴링&amp;rdquo;이 아니라는 점이었다.&lt;/p&gt;
&lt;p data-end=&quot;4034&quot; data-start=&quot;3981&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4120&quot; data-start=&quot;4036&quot; data-ke-size=&quot;size16&quot;&gt;문제는 폴링을 선택했다고 해서 끝이 아니라는 것이었다.&lt;br /&gt;대기 인원이 많아질수록 상태 조회 요청 자체가 또 다른 트래픽 폭탄이 될 수 있기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;4120&quot; data-start=&quot;4036&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4170&quot; data-start=&quot;4122&quot; data-ke-size=&quot;size16&quot;&gt;그래서 폴링은 허용하되, &lt;b&gt;조회 주기를 클라이언트에 맡기지 않았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;4241&quot; data-start=&quot;4172&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4241&quot; data-start=&quot;4172&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4241&quot; data-start=&quot;4172&quot; data-ke-size=&quot;size16&quot;&gt;대신 서버가 응답에 retryAfterMs 같은 값을 함께 내려주고, 클라이언트는 그 간격에 맞춰 다시 조회하도록 만들었다.&lt;/p&gt;
&lt;p data-end=&quot;4241&quot; data-start=&quot;4172&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4324&quot; data-start=&quot;4243&quot; data-ke-size=&quot;size16&quot;&gt;핵심은 조회 빈도까지 서버가 통제하는 것이었다.&lt;br /&gt;대기열 뒤쪽 사용자는 더 긴 간격으로, 앞쪽에 가까워질수록 더 짧은 간격으로 조회하게 해서&lt;br /&gt;상태 조회 요청 자체도 시스템이 감당 가능한 범위 안에서 움직이도록 했다.&lt;/p&gt;
&lt;p data-end=&quot;4324&quot; data-start=&quot;4243&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4493&quot; data-start=&quot;4326&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 뒤쪽 사용자에게는 최대 1분 수준의 간격을 주고,&lt;br /&gt;순번이 앞당겨질수록 그 값을 점차 줄여&lt;br /&gt;앞쪽 사용자에게는 1초 수준으로 짧게 조회하도록 했다.&lt;/p&gt;
&lt;p data-end=&quot;4493&quot; data-start=&quot;4326&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4590&quot; data-start=&quot;4505&quot; data-ke-size=&quot;size16&quot;&gt;이 방식이 중요했던 이유는 단순히 현재 상태를 보여주기 위해서가 아니었다.&lt;br /&gt;우리는 상태 조회 요청조차도 전체 시스템 부하 관점에서 통제 가능한 구조를 만들고 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;4590&quot; data-start=&quot;4505&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4778&quot; data-start=&quot;4592&quot; data-ke-size=&quot;size16&quot;&gt;결국 이번 이벤트에서는 SSE나 WebSocket이 가진 장점도 분명히 있었지만,&lt;br /&gt;대규모 대기열 상황에서의 연결 유지 비용, 운영 복잡도, 그리고 우리가 실제로 필요로 하는 실시간성 수준을 함께 비교했을 때&lt;br /&gt;가장 현실적인 선택은 &lt;b&gt;서버 주도 폴링 전략&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;708&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Dw6re/dJMcacJg0EX/VLUzIYztqLUEf2IUDHQaJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Dw6re/dJMcacJg0EX/VLUzIYztqLUEf2IUDHQaJ1/img.png&quot; data-alt=&quot;;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Dw6re/dJMcacJg0EX/VLUzIYztqLUEf2IUDHQaJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDw6re%2FdJMcacJg0EX%2FVLUzIYztqLUEf2IUDHQaJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;532&quot; height=&quot;300&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;708&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-end=&quot;139&quot; data-start=&quot;89&quot; data-ke-size=&quot;size26&quot;&gt;7차 문제:&amp;nbsp; 실제 발급 구간을 어떻게 제어할 것인가&lt;/h2&gt;
&lt;p data-end=&quot;473&quot; data-start=&quot;446&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 오면 대기열 자체는 어느 정도 정리된 셈이다.&lt;/p&gt;
&lt;p data-end=&quot;473&quot; data-start=&quot;446&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;646&quot; data-start=&quot;567&quot; data-ke-size=&quot;size16&quot;&gt;Redis 안에서 요청을 줄 세웠고,&lt;br /&gt;사용자는 WAITING 상태로 관리되며,&lt;br /&gt;자신의 순번과 상태도 폴링을 통해 확인할 수 있게 됐다.&lt;/p&gt;
&lt;p data-end=&quot;646&quot; data-start=&quot;567&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;664&quot; data-start=&quot;648&quot; data-ke-size=&quot;size16&quot;&gt;하지만 여기서 끝은 아니었다.&lt;/p&gt;
&lt;p data-end=&quot;664&quot; data-start=&quot;648&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;729&quot; data-start=&quot;666&quot; data-ke-size=&quot;size16&quot;&gt;대기열을 잘 만들었다고 해서, 그다음 단계인 &lt;b&gt;실제 발급 구간&lt;/b&gt;까지 자동으로 안전해지는 것은 아니기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;740&quot; data-start=&quot;731&quot; data-ke-size=&quot;size16&quot;&gt;문제는 단순했다.&lt;/p&gt;
&lt;p data-end=&quot;791&quot; data-start=&quot;742&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;줄을 선 사람들을 어떤 기준으로, 어떤 속도로 실제 발급 단계로 넘길 것인가?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;791&quot; data-start=&quot;742&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;952&quot; data-start=&quot;793&quot; data-ke-size=&quot;size16&quot;&gt;여기서 가장 경계했던 건 WAITING 상태의 사용자를 한꺼번에 DB로 보내는 구조였다.&lt;br /&gt;애초에 대기열을 만든 이유 자체가 폭주한 요청을 바로 DB로 흘려보내지 않기 위해서였는데, 줄을 세운 뒤 다시 모든 사용자가 동시에 발급을 시도하게 만들면 결국 병목은 다시 DB로 되돌아간다.&lt;/p&gt;
&lt;p data-end=&quot;952&quot; data-start=&quot;793&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1009&quot; data-start=&quot;954&quot; data-ke-size=&quot;size16&quot;&gt;즉, 대기열은 만들었지만 &lt;b&gt;실제 경쟁이 일어나는 구간은 여전히 통제되지 않은 상태&lt;/b&gt;였던 셈이다.&lt;/p&gt;
&lt;p data-end=&quot;1009&quot; data-start=&quot;954&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1152&quot; data-start=&quot;1011&quot; data-ke-size=&quot;size16&quot;&gt;특히 쿠폰 발급은 단순 조회가 아니다.&lt;br /&gt;실제 발급 단계에 들어오면 캡차 검증이 필요하고, 발급 가능 여부를 다시 확인해야 하고, 최종적으로는 DB에 발급 내역까지 남겨야 한다.&lt;br /&gt;즉, WAITING 상태에서의 조회보다 훨씬 무거운 작업이 뒤따른다.&lt;/p&gt;
&lt;p data-end=&quot;1152&quot; data-start=&quot;1011&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1229&quot; data-start=&quot;1154&quot; data-ke-size=&quot;size16&quot;&gt;이 구간에 너무 많은 사용자가 동시에 진입하면, 앞단에서 아무리 대기열을 잘 정리해도 마지막 발급 단계에서 다시 병목이 생길 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1250&quot; data-start=&quot;1231&quot; data-ke-size=&quot;size16&quot;&gt;결국 여기서 내린 결론은 분명했다.&lt;/p&gt;
&lt;p data-end=&quot;1294&quot; data-start=&quot;1252&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1379&quot; data-start=&quot;1296&quot; data-ke-size=&quot;size16&quot;&gt;대기열은 전체 요청을 흡수하기 위한 장치였고, 실제 발급 구간은 그중에서도 &lt;b&gt;지금 처리 가능한 만큼만 제한적으로 들어가야 하는 구간&lt;/b&gt;이어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;1469&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;그래서 WAITING 상태의 사용자 전체를 바로 발급 단계로 보내지 않고, 일부 트래픽만 &lt;b&gt;ACTIVE&lt;/b&gt; 상태로 전환해 실제 발급 구간으로 넘기기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;1469&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1499&quot; data-start=&quot;1471&quot; data-ke-size=&quot;size16&quot;&gt;그런데 여기서도 문제가 완전히 끝난 것은 아니었다.&lt;/p&gt;
&lt;p data-end=&quot;1499&quot; data-start=&quot;1471&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1622&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;Redis를 도입해서 DB로 들어오는 트래픽은 획기적으로 줄였지만, 여전히 ACTIVE 상태의 사용자들은 거의 동시에 실제 발급을 시도할 수 있다.&lt;br /&gt;그리고 이 순간에는 결국 같은 쿠폰 자원을 두고 경쟁이 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1622&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1642&quot; data-start=&quot;1624&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이제는 질문이 조금 바뀐다.&lt;/p&gt;
&lt;p data-end=&quot;1642&quot; data-start=&quot;1624&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1691&quot; data-start=&quot;1644&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;ACTIVE로 넘어온 요청들은 DB 안에서 어떻게 안전하게 처리할 것인가?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1720&quot; data-start=&quot;1693&quot; data-ke-size=&quot;size16&quot;&gt;처음 떠올린 건 익숙한 동시성 제어 기법들이었다.&lt;/p&gt;
&lt;h3 data-end=&quot;541&quot; data-start=&quot;532&quot; data-ke-size=&quot;size23&quot;&gt;7차 문제 해결 방법 1: 비관적 락&lt;/h3&gt;
&lt;p data-end=&quot;699&quot; data-start=&quot;543&quot; data-ke-size=&quot;size16&quot;&gt;비관적 락은 가장 직관적이었다.&lt;br /&gt;먼저 락을 잡고, 다른 요청은 기다리게 만들면 정합성은 강하게 보장할 수 있다.&lt;br /&gt;처음에는 오히려 이 방식이 제일 안전해 보이기도 했다.&lt;br /&gt;&amp;ldquo;어차피 마지막 발급 단계인데, 확실하게 잠그고 처리하면 되지 않을까?&amp;rdquo;라는 생각이 들었기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;699&quot; data-start=&quot;543&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1189&quot; data-start=&quot;1110&quot; data-ke-size=&quot;size16&quot;&gt;그런데 곰곰이 생각해보니, 비관적 락의 본질은 결국 &lt;b&gt;기다림&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;1189&quot; data-start=&quot;1110&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;344&quot; data-start=&quot;196&quot; data-ke-size=&quot;size16&quot;&gt;경합이 생기면 뒤의 요청이 다음 발급 가능한 대상을 바로 처리하는 것이 아니라,&lt;br /&gt;&lt;b&gt;앞선 트랜잭션이 락을 놓을 때까지 그대로 기다리게 된다.&lt;/b&gt;&lt;br /&gt;즉, 충돌 상황에서 문제를 &amp;ldquo;없애는&amp;rdquo; 방식이 아니라,&lt;br /&gt;&lt;b&gt;대기를 통해 순서를 세워 해결하는 방식&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;344&quot; data-start=&quot;196&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;526&quot; data-start=&quot;346&quot; data-ke-size=&quot;size16&quot;&gt;문제는 우리가 이미 앞단에서 WAITING과 ACTIVE를 나눠서,&lt;br /&gt;DB 앞에 너무 많은 요청이 한꺼번에 몰리지 않도록 구조를 바꿔둔 상태였다는 점이다.&lt;br /&gt;그런데 DB 안에서 다시 비관적 락으로 줄을 세우기 시작하면,&lt;br /&gt;앞단에서 힘들게 줄여놓은 경쟁을 마지막 단계에서 또 다른 &lt;b&gt;긴 대기열&lt;/b&gt;로 바꾸는 셈이 된다.&lt;/p&gt;
&lt;p data-end=&quot;526&quot; data-start=&quot;346&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;736&quot; data-start=&quot;528&quot; data-ke-size=&quot;size16&quot;&gt;특히 선착순 발급에서는 몇 장 남지 않은 상황에서 충돌이 가장 심해진다.&lt;br /&gt;이때 하나의 트랜잭션이 락을 잡고 있는 동안, 뒤의 요청들은 그냥 실패하는 것이 아니라&lt;br /&gt;&lt;b&gt;DB 커넥션을 붙잡은 채 대기&lt;/b&gt;하게 된다.&lt;br /&gt;즉, ACTIVE로 넘어온 요청 수를 줄였다고 해도 그 안에서 다시 대기가 길어지면,&lt;br /&gt;커넥션은 빠르게 반환되지 못하고 응답 시간은 점점 길어진다.&lt;/p&gt;
&lt;p data-end=&quot;736&quot; data-start=&quot;528&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;802&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;이 구조가 무서운 이유는, 비관적 락이 단순히 &amp;ldquo;한 요청을 잠깐 기다리게 만드는 것&amp;rdquo;에서 끝나지 않기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;802&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;802&quot; data-start=&quot;738&quot; data-ke-size=&quot;size16&quot;&gt;대기 중인 트랜잭션은 커넥션을 점유하고, 커넥션이 오래 점유되면 풀은 빠르게 고갈되고, 커넥션을 얻지 못한 뒤 요청들은 애플리케이션 서버에서 다시 대기하고, 그 대기는 다시 스레드 점유와 응답 지연으로 이어진다&lt;/p&gt;
&lt;p data-end=&quot;1026&quot; data-start=&quot;928&quot; data-ke-size=&quot;size16&quot;&gt;즉, 락 경합 하나가 DB 안에서만 머무는 것이 아니라&lt;br /&gt;&lt;b&gt;커넥션 풀 고갈 &amp;rarr; 애플리케이션 서버 대기 증가 &amp;rarr; 전체 응답 지연&lt;/b&gt;으로 이어지는 연쇄 문제로 번질 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1026&quot; data-start=&quot;928&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1085&quot; data-start=&quot;1028&quot; data-ke-size=&quot;size16&quot;&gt;여기서 더 걸렸던 건, 비관적 락이 &lt;b&gt;선착순 보장 자체를 깔끔하게 설명해주지도 못한다는 점&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;1085&quot; data-start=&quot;1028&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1231&quot; data-start=&quot;1087&quot; data-ke-size=&quot;size16&quot;&gt;실제 서비스에서는 사용자가 먼저 버튼을 눌렀다는 사실과, 그 요청이 더 먼저 DB 커넥션을 얻고 락 대기열에 진입했다는 사실이 항상 일치하지는 않는다.&lt;/p&gt;
&lt;p data-end=&quot;1302&quot; data-start=&quot;1233&quot; data-ke-size=&quot;size16&quot;&gt;요청은 네트워크 지연을 겪고, 로드밸런서를 지나고, 서버에서 스케줄링되고, 커넥션 풀을 기다리는 과정까지 거친다.&lt;/p&gt;
&lt;p data-end=&quot;1473&quot; data-start=&quot;1304&quot; data-ke-size=&quot;size16&quot;&gt;즉, 사용자의 체감상 &amp;ldquo;내가 먼저 눌렀다&amp;rdquo;와&lt;br /&gt;DB 레벨에서 &amp;ldquo;내가 먼저 락을 기다리기 시작했다&amp;rdquo;는 전혀 다른 이야기일 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1473&quot; data-start=&quot;1304&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;결국 비관적 락은 &lt;b&gt;row 하나의 정합성&lt;/b&gt;은 강하게 지켜줄 수 있어도,&lt;br /&gt;우리가 원했던 &lt;b&gt;서비스 전체 관점의 선착순을 자연스럽게 보장해주는 장치&lt;/b&gt;는 아니었다.&lt;/p&gt;
&lt;p data-end=&quot;1637&quot; data-start=&quot;1475&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1637&quot; data-start=&quot;1475&quot; data-ke-size=&quot;size16&quot;&gt;정리하면, 비관적 락은 안전한 선택처럼 보였지만&lt;br /&gt;실제로는 우리가 이 구조 전체를 통해 줄이고 싶었던 &lt;b&gt;기다림의 비용&lt;/b&gt;을 마지막 단계에서 다시 크게 키울 가능성이 있었다.&lt;br /&gt;그리고 그 기다림은 단순한 응답 지연을 넘어,&lt;br /&gt;커넥션 풀 고갈과 전체 시스템 불안정으로 이어질 수 있었다.&lt;/p&gt;
&lt;h3 data-end=&quot;1200&quot; data-start=&quot;1191&quot; data-ke-size=&quot;size23&quot;&gt;7차 문제 해결 방법 2: 낙관적 락&lt;/h3&gt;
&lt;p data-end=&quot;1780&quot; data-start=&quot;1693&quot; data-ke-size=&quot;size16&quot;&gt;낙관적 락도 처음에는 꽤 괜찮아 보였다.&lt;/p&gt;
&lt;p data-end=&quot;1780&quot; data-start=&quot;1693&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;226&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;비관적 락처럼 먼저 row를 붙잡고 기다리게 만드는 방식이 아니라,&lt;br /&gt;일단 읽고 수정한 뒤 &lt;b&gt;충돌이 발생했을 때만 실패로 처리하거나 재시도&lt;/b&gt;하면 되기 때문이다.&lt;br /&gt;즉, 락을 오래 잡지 않으니 겉으로 보기에는 더 가볍고, DB 안에서 긴 대기열을 만들지 않는다는 점도 매력적으로 느껴졌다.&lt;/p&gt;
&lt;p data-end=&quot;394&quot; data-start=&quot;228&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;394&quot; data-start=&quot;228&quot; data-ke-size=&quot;size16&quot;&gt;특히 우리는 이미 앞단에서 WAITING과 ACTIVE를 나눠 DB로 들어오는 요청 수 자체를 줄여둔 상태였다.&lt;br /&gt;그래서 한때는 &amp;ldquo;이 정도로 줄여놨다면 낙관적 락으로도 충분하지 않을까?&amp;rdquo;라는 생각도 했다.&lt;br /&gt;평상시처럼 충돌이 드문 상황이라면, 실제로 이 방식은 꽤 깔끔하게 동작할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;394&quot; data-start=&quot;228&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;396&quot; data-ke-size=&quot;size16&quot;&gt;하지만 선착순 발급은 낙관적 락이 가장 잘 맞는 상황과는 결이 달랐다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;396&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;570&quot; data-start=&quot;437&quot; data-ke-size=&quot;size16&quot;&gt;낙관적 락은 기본적으로 &lt;b&gt;&amp;ldquo;충돌은 가끔 일어나는 예외적인 상황&amp;rdquo; &lt;/b&gt;이라는 전제를 깔고 있다.&lt;br /&gt;그런데 선착순 이벤트, 특히 마지막 몇 장을 두고 경쟁하는 구간에서는 충돌이 예외가 아니라 거의 &lt;b&gt;예상된 기본 상황&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;707&quot; data-start=&quot;572&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;707&quot; data-start=&quot;572&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 ACTIVE 상태의 여러 사용자가 거의 동시에 마지막 쿠폰 몇 장을 두고 발급을 시도한다고 해보자.&lt;br /&gt;이때 먼저 읽은 데이터가 이미 다른 트랜잭션에 의해 바뀌어 있으면,&lt;br /&gt;낙관적 락은 그 요청을 실패시키고 다시 시도하게 만든다.&lt;/p&gt;
&lt;p data-end=&quot;738&quot; data-start=&quot;709&quot; data-ke-size=&quot;size16&quot;&gt;문제는 이 &amp;ldquo;다시 시도&amp;rdquo;가 공짜가 아니라는 점이었다.&lt;/p&gt;
&lt;p data-end=&quot;761&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;한 번 충돌이 나면 끝나는 게 아니라,&lt;/p&gt;
&lt;p data-end=&quot;761&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;다시 읽어야 하고&lt;/p&gt;
&lt;p data-end=&quot;761&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;다시 검증해야 하고&lt;/p&gt;
&lt;p data-end=&quot;761&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;다시 업데이트를 시도해야 한다&lt;/p&gt;
&lt;p data-end=&quot;761&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;931&quot; data-start=&quot;808&quot; data-ke-size=&quot;size16&quot;&gt;즉, 충돌이 많아질수록 요청 하나가 끝날 때까지 거치는 왕복이 늘어난다.&lt;br /&gt;겉으로는 락을 오래 잡지 않아서 가벼워 보이지만,&lt;br /&gt;실제로는 &lt;b&gt;실패와 재시도가 누적되면서 DB에 더 많은 읽기/쓰기 부담&lt;/b&gt;을 줄 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;931&quot; data-start=&quot;808&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;961&quot; data-start=&quot;933&quot; data-ke-size=&quot;size16&quot;&gt;특히 선착순 이벤트에서는 이 재시도가 더 불편했다.&lt;/p&gt;
&lt;p data-end=&quot;961&quot; data-start=&quot;933&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;963&quot; data-ke-size=&quot;size16&quot;&gt;우리는 앞단에서 힘들게 WAITING과 ACTIVE로 구간을 나눠&lt;br /&gt;DB에 들어오는 경쟁 자체를 줄여놓은 상태였다.&lt;br /&gt;그런데 마지막 단계에서 낙관적 락으로 재시도가 반복되기 시작하면,&lt;br /&gt;앞단에서 줄여놓은 트래픽을 DB 안에서 다시 &lt;b&gt;충돌과 재시도 형태로 되살리는 셈&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-end=&quot;1122&quot; data-start=&quot;963&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1154&quot; data-start=&quot;1124&quot; data-ke-size=&quot;size16&quot;&gt;게다가 선착순 관점에서도 마음에 걸리는 지점이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1261&quot; data-start=&quot;1156&quot; data-ke-size=&quot;size16&quot;&gt;낙관적 락은 결국 &lt;b&gt;먼저 성공적으로 커밋한 요청이 이기는 구조&lt;/b&gt;에 가깝다.&lt;br /&gt;하지만 사용자의 관점에서 선착순은 &amp;ldquo;내가 먼저 눌렀는가&amp;rdquo;에 더 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;1261&quot; data-start=&quot;1156&quot; data-ke-size=&quot;size16&quot;&gt;이 둘은 항상 일치하지 않는다.&lt;/p&gt;
&lt;p data-end=&quot;1261&quot; data-start=&quot;1156&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1437&quot; data-start=&quot;1263&quot; data-ke-size=&quot;size16&quot;&gt;누군가는 먼저 요청을 보냈더라도 네트워크 지연이나 서버 스케줄링 때문에 재시도가 한 번 더 발생할 수 있고,&lt;br /&gt;반대로 조금 늦게 들어온 요청이 더 빠르게 재시도를 끝내고 먼저 성공할 수도 있다.&lt;br /&gt;즉, 낙관적 락은 &lt;b&gt;정합성은 지킬 수 있어도, 우리가 기대하는 선착순의 체감과는 어긋날 가능성&lt;/b&gt;이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1576&quot; data-start=&quot;1439&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1576&quot; data-start=&quot;1439&quot; data-ke-size=&quot;size16&quot;&gt;정리하면, 낙관적 락은&lt;br /&gt;평소처럼 충돌이 적은 상황에서는 가볍고 좋은 선택일 수 있다.&lt;br /&gt;하지만 이번처럼 마지막 순간에 충돌이 집중되는 선착순 이벤트에서는&lt;br /&gt;실패와 재시도가 빠르게 늘어나고, 그 비용이 다시 DB 부담으로 돌아올 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1686&quot; data-start=&quot;1578&quot; data-ke-size=&quot;size16&quot;&gt;즉, 낙관적 락은&lt;br /&gt;비관적 락처럼 긴 대기를 만들지는 않지만,&lt;br /&gt;대신 &lt;b&gt;충돌을 재시도로 바꿔서 처리하는 방식&lt;/b&gt;이었다.&lt;br /&gt;그리고 이번 문제에서는 그 재시도 비용 역시 충분히 부담스러웠다.&lt;/p&gt;
&lt;h3 data-end=&quot;1797&quot; data-start=&quot;1782&quot; data-ke-size=&quot;size23&quot;&gt;7차 문제 해결 방법 3: 원자적 조건 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원자적 조건 업데이트도 후보였다.&lt;br /&gt;예를 들어 remaining &amp;gt; 0 일 때만 차감하는 방식은 단순하고 빠르다.&lt;br /&gt;비관적 락처럼 기다림을 길게 만들지도 않고, 낙관적 락처럼 실패 후 재시도를 전제로 하지도 않는다는 점에서 꽤 매력적으로 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;418&quot; data-start=&quot;269&quot; data-ke-size=&quot;size16&quot;&gt;하지만 우리 구조를 다시 대입해보니, 이번 문제는 단순한 &lt;b&gt;수량 차감&lt;/b&gt;으로만 설명하기 어려웠다.&lt;br /&gt;우리가 정말로 처리해야 했던 것은 remaining 값을 1 줄이는 일이 아니라, &lt;b&gt;발급 가능한 쿠폰 하나를 특정 사용자에게 확정하는 과정&lt;/b&gt;이었기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;418&quot; data-start=&quot;269&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;523&quot; data-start=&quot;420&quot; data-ke-size=&quot;size16&quot;&gt;즉, 원자적 조건 업데이트는&lt;br /&gt;&amp;ldquo;재고가 남아 있는가&amp;rdquo;를 판단하고 차감하는 데는 강했지만,&lt;br /&gt;&lt;b&gt;어떤 쿠폰이 누구에게 발급되었는가&lt;/b&gt;를 자연스럽게 표현하는 구조와는 조금 결이 달랐다.&lt;/p&gt;
&lt;p data-end=&quot;523&quot; data-start=&quot;420&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;606&quot; data-start=&quot;525&quot; data-ke-size=&quot;size16&quot;&gt;결국 이번 문제에는 숫자를 줄이는 방식보다,&lt;br /&gt;&lt;b&gt;실제 발급 가능한 row 하나를 안전하게 가져와 사용자에게 연결하는 방식&lt;/b&gt;이 더 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;606&quot; data-start=&quot;525&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1454&quot; data-start=&quot;1440&quot; data-section-id=&quot;15b8ffc&quot; data-ke-size=&quot;size23&quot;&gt;7차 문제 해결 방법 4: 스킵 락&lt;/h3&gt;
&lt;p data-end=&quot;1511&quot; data-start=&quot;1456&quot; data-ke-size=&quot;size16&quot;&gt;스킵 락도 후보였다.&lt;br /&gt;이미 다른 트랜잭션이 잡고 있는 row는 기다리지 않고 건너뛰는 방식이다.&lt;/p&gt;
&lt;p data-end=&quot;1511&quot; data-start=&quot;1456&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1706&quot; data-start=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;사실 이 방법이 제일 먼저 생갔났고 우리 구조에 대입해보면 가장 잘 맞을 가능성이 커 보였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1706&quot; data-start=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1706&quot; data-start=&quot;1513&quot; data-ke-size=&quot;size16&quot;&gt;중요한 건 선착순의 질서를&amp;nbsp;&lt;b&gt;DB에서 처음부터 만들고 있지 않았다는 점&lt;/b&gt;이다.&lt;br /&gt;우리는 이미 앞단에서 Redis 대기열을 통해 요청 순서를 정리했고,&lt;br /&gt;WAITING과 ACTIVE로 상태를 나눠서 실제 발급 단계에 들어오는 요청 수까지 줄여둔 상태였다.&lt;br /&gt;즉, DB의 역할은 &amp;ldquo;누가 먼저인가&amp;rdquo;를 처음부터 다시 판단하는 것이 아니라,&lt;br /&gt;&lt;b&gt;이미 정리된 경쟁 구간을 빠르게 마무리하는 것&lt;/b&gt;에 더 가까웠다.&lt;/p&gt;
&lt;p data-end=&quot;3284&quot; data-start=&quot;3123&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 우리는 쿠폰을 단순한 숫자 재고가 아니라, &lt;b&gt;미발급 상태의 row 집합&lt;/b&gt;으로 보고 있었다.&lt;br /&gt;즉, 실제 발급 시점에는 그중 아직 아무에게도 할당되지 않은 row 하나를 가져와 사용자에게 연결하는 구조였다.&lt;/p&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;888&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;888&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 보면 문제는 remaining 값을 1 줄이는 것이 아니다.&lt;br /&gt;핵심은 &lt;b&gt;발급 가능한 쿠폰 row 하나를 지금 이 사용자에게 안전하게 확정할 수 있는가&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;888&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1131&quot; data-start=&quot;992&quot; data-ke-size=&quot;size16&quot;&gt;이때 비관적 락처럼 하나의 row 앞에서 기다리게 만들면, 결국 뒤의 요청들은 커넥션을 붙잡은 채 대기하게 된다.&lt;br /&gt;반대로 스킵 락은 이미 다른 트랜잭션이 잡고 있는 row를 기다리지 않고, 다음으로 발급 가능한 row를 바로 찾게 만든다.&lt;/p&gt;
&lt;p data-end=&quot;1131&quot; data-start=&quot;992&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1151&quot; data-start=&quot;1133&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이 방식의 장점은 분명했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1270&quot; data-start=&quot;1153&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1175&quot; data-start=&quot;1153&quot; data-section-id=&quot;122qw96&quot;&gt;락은 사용하되 긴 대기를 만들지 않고&lt;/li&gt;
&lt;li data-end=&quot;1222&quot; data-start=&quot;1176&quot; data-section-id=&quot;1auzl93&quot;&gt;여러 트랜잭션이 동시에 들어와도 서로 다른 쿠폰 row를 병렬로 가져갈 수 있고&lt;/li&gt;
&lt;li data-end=&quot;1270&quot; data-start=&quot;1223&quot; data-section-id=&quot;r3j259&quot;&gt;앞단에서 줄여놓은 ACTIVE 경쟁 구간을 DB 안에서 짧고 빠르게 끝낼 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1356&quot; data-start=&quot;1272&quot; data-ke-size=&quot;size16&quot;&gt;결국 스킵 락은&lt;br /&gt;&lt;b&gt;이미 정리된 질서를 DB 안에서 대기 없이 확정하는 도구&lt;/b&gt;에 더 가까웠다.&lt;/p&gt;
&lt;h2 data-end=&quot;1356&quot; data-start=&quot;1272&quot; data-ke-size=&quot;size26&quot;&gt;7차 해결 : 결국 스킵 락이 가장 잘 맞았다&lt;/h2&gt;
&lt;p data-end=&quot;1356&quot; data-start=&quot;1272&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 비교해보니 기준이 조금씩 분명해졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1054&quot; data-start=&quot;935&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;959&quot; data-start=&quot;935&quot; data-section-id=&quot;2k61h4&quot;&gt;비관적 락은 안전하지만 기다림이 길어진다&lt;/li&gt;
&lt;li data-end=&quot;998&quot; data-start=&quot;960&quot; data-section-id=&quot;1cngeyu&quot;&gt;낙관적 락은 가볍지만 충돌이 많아지면 실패와 재시도 비용이 커진다&lt;/li&gt;
&lt;li data-end=&quot;1054&quot; data-start=&quot;999&quot; data-section-id=&quot;wm3ezg&quot;&gt;원자적 조건 업데이트는 재고 차감에는 좋지만, 개별 발급 대상을 확정하는 흐름과는 조금 어긋난다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;반면 스킵 락은 우리 구조와 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 다시 한 번, 선착순의 질서를 DB가 처음부터 만들고 있지 않았다는 점이다.&lt;br /&gt;우리는 이미 Redis 대기열에서 순서를 정리했고, WAITING과 ACTIVE를 나눠 실제 발급 단계로 들어오는 요청 수도 제한해둔 상태였다.&lt;br /&gt;즉, DB의 역할은 &amp;ldquo;누가 먼저인가&amp;rdquo;를 다시 판정하는 것이 아니라, 이미 줄어든 경쟁 구간을 빠르게 확정하는 것이었다.&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;이게 중요했다.&lt;/p&gt;
&lt;p data-end=&quot;2251&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;비관적 락처럼 기다리게 만들면 ACTIVE로 들어온 요청들조차 다시 DB 안에서 줄을 서게 된다.&lt;br /&gt;그 기다림은 곧 커넥션 점유 시간 증가로 이어지고, 응답 지연과 풀 고갈 위험도 다시 커진다.&lt;br /&gt;앞단에서 어렵게 줄여놓은 경쟁을 마지막 단계에서 다시 대기열로 바꾸는 셈이다.&lt;/p&gt;
&lt;p data-end=&quot;2251&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2357&quot; data-start=&quot;2253&quot; data-ke-size=&quot;size16&quot;&gt;반면 스킵 락은 락을 사용하되 대기를 최소화한다.&lt;br /&gt;이미 잠긴 row는 기다리지 않고 건너뛰기 때문에, 여러 트랜잭션이 동시에 들어와도 서로 다른 쿠폰 row를 병렬로 확정할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2443&quot; data-start=&quot;2359&quot; data-ke-size=&quot;size16&quot;&gt;즉, 우리 구조에서 스킵 락은&lt;br /&gt;&lt;b&gt;선착순의 질서를 만드는 도구라기보다, 이미 정리된 질서를 DB 안에서 빠르게 마무리하는 도구&lt;/b&gt;에 더 가까웠다.&lt;/p&gt;
&lt;p data-end=&quot;2443&quot; data-start=&quot;2359&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2580&quot; data-start=&quot;2445&quot; data-ke-size=&quot;size16&quot;&gt;물론 이것만으로 모든 문제가 끝나는 것은 아니었다.&lt;br /&gt;같은 사용자가 여러 번 발급을 시도하는 문제는 락만으로 막을 수 없었다.&lt;br /&gt;그래서 마지막 안전장치로 (event_id, member_id) 같은 UNIQUE 제약도 함께 두었다.&lt;/p&gt;
&lt;p data-end=&quot;2599&quot; data-start=&quot;2582&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 역할은 이렇게 나뉜다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2727&quot; data-start=&quot;2601&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2638&quot; data-start=&quot;2601&quot; data-section-id=&quot;jw71f4&quot;&gt;WAITING / ACTIVE: 앞단에서 전체 경쟁을 줄인다&lt;/li&gt;
&lt;li data-end=&quot;2686&quot; data-start=&quot;2639&quot; data-section-id=&quot;1jcfbkh&quot;&gt;SKIP LOCKED: DB 안에서 발급 가능한 쿠폰 row를 빠르게 확정한다&lt;/li&gt;
&lt;li data-end=&quot;2727&quot; data-start=&quot;2687&quot; data-section-id=&quot;h2hkbc&quot;&gt;UNIQUE 제약: 같은 사용자의 중복 발급을 마지막으로 차단한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2873&quot; data-start=&quot;2729&quot; data-ke-size=&quot;size16&quot;&gt;결국 우리가 원했던 건 모든 요청을 강한 락으로 세워놓는 구조가 아니었다.&lt;br /&gt;앞단에서는 경쟁 자체를 줄이고, DB 안에서는 이미 줄어든 경쟁을 짧고 빠르게 확정하는 구조가 더 잘 맞았다.&lt;br /&gt;그 기준에서 봤을 때, 최종 선택은 스킵 락이 가장 자연스러웠다.&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size26&quot;&gt;최종 설계&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1296&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXZGzx/dJMcac3xS3j/2Fksfjbi41MObsMZ7z3MkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXZGzx/dJMcac3xS3j/2Fksfjbi41MObsMZ7z3MkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXZGzx/dJMcac3xS3j/2Fksfjbi41MObsMZ7z3MkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXZGzx%2FdJMcac3xS3j%2F2Fksfjbi41MObsMZ7z3MkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;676&quot; height=&quot;331&quot; data-origin-width=&quot;1296&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1924&quot; data-origin-height=&quot;1418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3ZffJ/dJMcacWMV0t/HMyl3TOkebRoYtZKQoIQYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3ZffJ/dJMcacWMV0t/HMyl3TOkebRoYtZKQoIQYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3ZffJ/dJMcacWMV0t/HMyl3TOkebRoYtZKQoIQYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3ZffJ%2FdJMcacWMV0t%2FHMyl3TOkebRoYtZKQoIQYK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;590&quot; height=&quot;435&quot; data-origin-width=&quot;1924&quot; data-origin-height=&quot;1418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-end=&quot;1111&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size26&quot;&gt;부하테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫 부하 테스트: &amp;ldquo;짧은 순간의 폭주&amp;rdquo;를 버틸 수 있는지부터 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 부하 테스트는 &lt;b&gt;N명의 가상 유저가 한 번씩만 요청을 보내는 방식&lt;/b&gt;으로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;440&quot; data-start=&quot;261&quot; data-ke-size=&quot;size16&quot;&gt;선착순 이벤트는 일반적인 API처럼 꾸준히 많은 요청이 들어오는 상황과는 조금 다르다.&lt;br /&gt;결국 중요한 건 &lt;b&gt;처음 등록하는 그 순간&lt;/b&gt;, 아주 짧은 시간 안에 요청이 한꺼번에 몰렸을 때 버틸 수 있느냐였다.&lt;br /&gt;그래서 테스트도 그 특성에 맞춰, 지속 부하보다는 &lt;b&gt;순간적인 버스트 트래픽&lt;/b&gt;을 재현하는 쪽으로 시작했다.&lt;/p&gt;
&lt;p data-end=&quot;440&quot; data-start=&quot;261&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;484&quot; data-start=&quot;442&quot; data-ke-size=&quot;size16&quot;&gt;첫 테스트 한계점을 알기위해 일부러 크게 잡아서 &lt;b&gt;가상 유저 5,000명&lt;/b&gt;, &lt;b&gt;서버 2대&lt;/b&gt;로 진행했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2180&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UEJki/dJMcaa5LBgp/jXJv3fHgag3b2ymakiTjzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UEJki/dJMcaa5LBgp/jXJv3fHgag3b2ymakiTjzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UEJki/dJMcaa5LBgp/jXJv3fHgag3b2ymakiTjzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUEJki%2FdJMcaa5LBgp%2FjXJv3fHgag3b2ymakiTjzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;665&quot; height=&quot;263&quot; data-origin-width=&quot;2180&quot; data-origin-height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;426&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqrgyR/dJMcadg6VhU/GlbV5caRX7dTNhb8kPiCy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqrgyR/dJMcadg6VhU/GlbV5caRX7dTNhb8kPiCy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqrgyR/dJMcadg6VhU/GlbV5caRX7dTNhb8kPiCy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcqrgyR%2FdJMcadg6VhU%2FGlbV5caRX7dTNhb8kPiCy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;106&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;426&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BVqBW/dJMcai3JDYn/VwzEKqJkeK99X8s9UsPr0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BVqBW/dJMcai3JDYn/VwzEKqJkeK99X8s9UsPr0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BVqBW/dJMcai3JDYn/VwzEKqJkeK99X8s9UsPr0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBVqBW%2FdJMcai3JDYn%2FVwzEKqJkeK99X8s9UsPr0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;192&quot; height=&quot;101&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 기대와는 꽤 거리가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;612&quot; data-start=&quot;507&quot; data-ke-size=&quot;size16&quot;&gt;우선 &lt;b&gt;p90이 5초대&lt;/b&gt;로 너무 느렸다.&lt;br /&gt;TPS도 &lt;b&gt;742 수준&lt;/b&gt;으로 낮게 나왔다.&lt;br /&gt;또한&amp;nbsp;&lt;b&gt;연결 구간에서 시간이 꽤 많이 소요되고 있었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;672&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;672&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;즉, 단순히 비즈니스 로직만 느린 게 아니라,&lt;br /&gt;&lt;b&gt;연결 자체도 느리고 서버 처리도 느린 상태&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;672&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;672&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;일단 비즈니스 로직이 왜 느린지 부터 점검해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;779&quot; data-start=&quot;745&quot; data-section-id=&quot;1qbai0s&quot; data-ke-size=&quot;size26&quot;&gt;병목 원인 1: 대기열 API가 세션 DB를 타고 있었다&lt;/h2&gt;
&lt;p data-end=&quot;816&quot; data-start=&quot;781&quot; data-ke-size=&quot;size16&quot;&gt;원인을 확인해보니 생각보다 단순하면서도 치명적인 문제가 있었다.&lt;/p&gt;
&lt;p data-end=&quot;816&quot; data-start=&quot;781&quot; data-ke-size=&quot;size16&quot;&gt;이벤트 API에만 집중하고 있다보니 세션을 생각하지 못했다.&lt;/p&gt;
&lt;p data-end=&quot;816&quot; data-start=&quot;781&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;946&quot; data-start=&quot;818&quot; data-ke-size=&quot;size16&quot;&gt;우리 서비스는 분산 서버 환경이라 세션 로그인 방식을 사용하고 있었고,&lt;br /&gt;로그인 세션 정보는 &lt;b&gt;세션 DB&lt;/b&gt;에 저장하고 있었다.&lt;br /&gt;즉, 대기열 관련 API를 아주 짧게 조회하는 순간에도&lt;br /&gt;매번 세션 저장소를 거치고 있었던 것이다.&lt;/p&gt;
&lt;p data-end=&quot;946&quot; data-start=&quot;818&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1056&quot; data-start=&quot;948&quot; data-ke-size=&quot;size16&quot;&gt;선착순 이벤트에서는 이런 짧은 조회가 매우 자주 일어난다.&lt;br /&gt;그런데 매 요청마다 세션 DB를 한 번씩 건드리고 있었다면,&lt;br /&gt;대기열 API는 시작부터 불필요한 DB 비용을 안고 있었던 셈이다.&lt;/p&gt;
&lt;p data-end=&quot;1056&quot; data-start=&quot;948&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1147&quot; data-start=&quot;1058&quot; data-ke-size=&quot;size16&quot;&gt;결국 대기열 관련 API만큼은 기존 세션 로그인 방식과는 다르게 가져가기로 했다.&lt;br /&gt;즉, 이벤트&amp;nbsp;&lt;b&gt;API에서 세션 DB 의존을 제거하는 방향&lt;/b&gt;으로 바꿨다.&lt;/p&gt;
&lt;p data-end=&quot;1147&quot; data-start=&quot;1058&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1191&quot; data-start=&quot;1154&quot; data-section-id=&quot;6z3ery&quot; data-ke-size=&quot;size26&quot;&gt;1차 개선 이후: 시간은 절반 가까이 줄었다.&lt;/h2&gt;
&lt;p data-end=&quot;1230&quot; data-start=&quot;1193&quot; data-ke-size=&quot;size16&quot;&gt;세션 DB를 완전히 제거한 뒤 다시 같은 조건으로 테스트를 해봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2356&quot; data-origin-height=&quot;858&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3urFk/dJMcabcxNOI/NTnKAC4KIhKeMzfMKdH7R0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3urFk/dJMcabcxNOI/NTnKAC4KIhKeMzfMKdH7R0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3urFk/dJMcabcxNOI/NTnKAC4KIhKeMzfMKdH7R0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3urFk%2FdJMcabcxNOI%2FNTnKAC4KIhKeMzfMKdH7R0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;703&quot; height=&quot;256&quot; data-origin-width=&quot;2356&quot; data-origin-height=&quot;858&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Yp9q7/dJMcaf65SIl/16ibJlP90h28EpmNARNK2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Yp9q7/dJMcaf65SIl/16ibJlP90h28EpmNARNK2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Yp9q7/dJMcaf65SIl/16ibJlP90h28EpmNARNK2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYp9q7%2FdJMcaf65SIl%2F16ibJlP90h28EpmNARNK2k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;220&quot; height=&quot;186&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0LFC8/dJMcadBnlIh/G0wmDYcnJLiCvkfZXkHPIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0LFC8/dJMcadBnlIh/G0wmDYcnJLiCvkfZXkHPIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0LFC8/dJMcadBnlIh/G0wmDYcnJLiCvkfZXkHPIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0LFC8%2FdJMcadBnlIh%2FG0wmDYcnJLiCvkfZXkHPIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;245&quot; height=&quot;203&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1322&quot; data-start=&quot;1232&quot; data-ke-size=&quot;size16&quot;&gt;확실히 효과는 있었다.&lt;br /&gt;전체 응답 시간은 이전보다 &lt;b&gt;절반 가까이 줄었다.&lt;/b&gt;&lt;br /&gt;즉, 처음 병목 중 하나였던 불필요한 DB 접근은 분명히 제거된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-section-id=&quot;6z3ery&quot; data-start=&quot;1154&quot; data-end=&quot;1191&quot;&gt;병목 원인 2:&amp;nbsp; 왕복 횟수&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;더 개선할 수 있는 방법이 있는지 찾아보다가, 내가 의심한 병목은 복잡한 비즈니스 로직 그 자체보다는 &lt;b&gt;Redis 왕복 횟수&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;375&quot; data-start=&quot;265&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;375&quot; data-start=&quot;265&quot; data-ke-size=&quot;size16&quot;&gt;대기열 등록과 조회 요청 하나를 처리하는 동안 Redis에 여러 번 왕복하고 있었고,&lt;br /&gt;오픈 시점처럼 짧은 시간에 요청이 몰리는 상황에서는 이 작은 왕복 비용이 그대로 지연으로 누적될 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;449&quot; data-start=&quot;377&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;449&quot; data-start=&quot;377&quot; data-ke-size=&quot;size16&quot;&gt;결국 핵심은 비즈니스 로직을 바꾸는 것이 아니라,&lt;br /&gt;&lt;b&gt;같은 정보를 더 적은 네트워크 왕복으로 읽도록 바꾸는 것&lt;/b&gt;이라고 봤다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제: 요청 하나가 Redis를 너무 자주 왕복하고 있었다&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;대기열 등록 API는 단순히 사용자를 queue에 넣는 작업으로 끝나지 않는다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;요청을 처리하려면 현재 사용자가 어떤 상태인지 먼저 확인해야 한다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들면 아래 정보들이 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 발급을 받은 사용자인지&lt;/li&gt;
&lt;li&gt;현재 sold out 상태인지&lt;/li&gt;
&lt;li&gt;이미 active 상태인지&lt;/li&gt;
&lt;li&gt;이미 queue에 들어가 있는지&lt;/li&gt;
&lt;li&gt;queue에서 몇 번째인지&lt;/li&gt;
&lt;li&gt;active 인원이 몇 명인지&lt;/li&gt;
&lt;li&gt;queue 전체 인원이 몇 명인지&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 이런 상태를 각각 개별 메서드로 조회하는 방식에 가까웠다. 개념적으로는 아래와 같은 형태였다.&lt;/p&gt;
&lt;pre id=&quot;code_1774684168050&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;long activeCount = getActiveCount(couponName);
boolean issued = isIssued(couponName, memberId);
boolean soldOut = isSoldOut(couponName);
Long activeExpireAt = getActiveExpireAtMillis(couponName, memberId);
Long queueRank = rankQueue(couponName, memberId);
long queueCount = getQueueCount(couponName);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 메서드는 Redis 기준으로 보면&lt;span&gt;&amp;nbsp;&lt;/span&gt;ZCARD,&amp;nbsp;SISMEMBER,&amp;nbsp;GET,&amp;nbsp;ZSCORE,&amp;nbsp;ZRANK&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 가벼운 명령이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 명령이 무겁다는 것이 아니라, 상태를 잘게 나누어 조회하면서 요청 하나가 Redis와 여러 번 왕복하게 된다는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 이게 선착순 이벤트에서 더 치명적일까&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순 이벤트는 일반적인 CRUD API와 다르다.&lt;br /&gt;사용자가 고르게 들어오지 않고, 아주 짧은 시간에 요청이 한꺼번에 몰린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1775&quot; data-start=&quot;1713&quot; data-ke-size=&quot;size16&quot;&gt;이런 상황에서는 평균 응답시간보다 p95, p99 같은 tail latency가 더 중요해진다.&lt;br /&gt;몇 개의 요청이 조금 느린 정도로 끝나는 것이 아니라, 느려진 요청이 뒤쪽 요청들을 밀어내면서 전체 구간의 체감 성능을 급격히 무너뜨릴 수 있기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;1775&quot; data-start=&quot;1713&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1775&quot; data-start=&quot;1713&quot; data-ke-size=&quot;size16&quot;&gt;단건 요청만 보면 Redis 왕복 한두 번 차이는 작아 보일 수 있다.&lt;br /&gt;한 번 조회하는 데 걸리는 시간 자체는 짧고, 로컬 캐시나 메모리 접근과 비교하지 않는 이상 충분히 빠른 편이기도 하다.&lt;/p&gt;
&lt;p data-end=&quot;1775&quot; data-start=&quot;1713&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1775&quot; data-start=&quot;1713&quot; data-ke-size=&quot;size16&quot;&gt;하지만 선착순 이벤트처럼 같은 시점에 수천, 수만 건의 요청이 몰리면 이야기가 달라진다.&lt;/p&gt;
&lt;p data-end=&quot;556&quot; data-start=&quot;517&quot; data-ke-size=&quot;size16&quot;&gt;요청 하나가 Redis를 여러 번 오갈수록 다음과 같은 문제가 생긴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;716&quot; data-start=&quot;558&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;582&quot; data-start=&quot;558&quot;&gt;네트워크 왕복 횟수 자체가 늘어난다.&lt;/li&gt;
&lt;li data-end=&quot;612&quot; data-start=&quot;583&quot;&gt;Redis 커넥션을 점유하는 시간이 길어진다.&lt;/li&gt;
&lt;li data-end=&quot;650&quot; data-start=&quot;613&quot;&gt;애플리케이션 스레드가 응답을 기다리는 시간도 함께 늘어난다.&lt;/li&gt;
&lt;li data-end=&quot;684&quot; data-start=&quot;651&quot;&gt;그 결과 동시에 처리할 수 있는 요청 수가 줄어든다.&lt;/li&gt;
&lt;li data-end=&quot;716&quot; data-start=&quot;685&quot;&gt;밀린 요청들은 다시 tail latency를 키운다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;776&quot; data-start=&quot;718&quot; data-ke-size=&quot;size16&quot;&gt;특히 선착순 이벤트에서는 이 문제가 더 치명적이다.&lt;br /&gt;이벤트 시작 직후에는 모든 사용자가 거의 동시에 상태를 확인하려고 들어온다.&lt;br /&gt;이때 요청 하나당 Redis 왕복이 1번인 구조와 3번인 구조는, 같은 트래픽이라도 Redis와 애플리케이션이 감당해야 하는 총 작업량이 완전히 달라진다.&lt;/p&gt;
&lt;p data-end=&quot;776&quot; data-start=&quot;718&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1063&quot; data-start=&quot;950&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 초당 5,000개의 요청이 들어오는 상황이라면,&lt;br /&gt;요청당 Redis 왕복이 1번일 때는 초당 5,000번의 왕복이 발생하지만&lt;br /&gt;요청당 3번 왕복하면 초당 15,000번의 왕복이 필요해진다.&lt;/p&gt;
&lt;p data-end=&quot;1063&quot; data-start=&quot;950&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1201&quot; data-start=&quot;1065&quot; data-ke-size=&quot;size16&quot;&gt;이 차이는 단순히 2번 더 조회했다에서 끝나지 않는다.&lt;br /&gt;커넥션 풀 경쟁, 스레드 대기, 응답 지연, 재시도 증가까지 연쇄적으로 이어질 수 있다.&lt;br /&gt;그리고 선착순 시스템에서는 바로 이런 작은 지연이 공정성 문제와 실패율 증가로 이어진다.&lt;/p&gt;
&lt;p data-end=&quot;1201&quot; data-start=&quot;1065&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1284&quot; data-ke-size=&quot;size16&quot;&gt;결국 이 구간에서는&lt;br /&gt;&lt;b&gt;조회 한 번이 얼마나 빠른가&lt;/b&gt;보다&lt;br /&gt;&lt;b&gt;요청 하나를 끝내기 위해 Redis를 몇 번 오가야 하는가&lt;/b&gt;가 더 중요하다고 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 방향: 상태 조회를 pipeline으로 묶자&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 상태 조회를 의미 단위로 묶었다.&lt;/p&gt;
&lt;pre id=&quot;code_1774684492882&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public QueuePreState getQueuePreState(String couponName, Long memberId) {
    List&amp;lt;Object&amp;gt; results = redisTemplate.executePipelined(
            new SessionCallback&amp;lt;Object&amp;gt;() {
                @Override
                public Object execute(RedisOperations operations) {
                    operations.opsForZSet().zCard(activeKey(couponName));
                    operations.opsForSet().isMember(issuedKey(couponName), memberId.toString());
                    operations.opsForValue().get(soldOutKey(couponName));
                    operations.opsForZSet().score(activeKey(couponName), memberId.toString());
                    operations.opsForZSet().rank(queueKey(couponName), memberId.toString());
                    operations.opsForZSet().zCard(queueKey(couponName));
                    return null;
                }
            }
    );

    long activeCount = asLong(results.get(0));
    boolean isIssued = asBoolean(results.get(1));
    boolean isSoldOut = &quot;1&quot;.equals(asText(results.get(2)));
    Long activeExpireAt = asLong(results.get(3));
    Long rank = asLong(results.get(4));
    long queueCount = asLong(results.get(5));

    return new QueuePreState(activeCount, isIssued, isSoldOut, activeExpireAt, rank, queueCount);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;핵심은 조회하는 값이 줄어든 게 아니었다.&lt;br /&gt;&lt;b&gt;같은 조회를 더 적은 왕복으로 처리하게 바꿨다는 점&lt;/b&gt;이 중요했다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;3920&quot; data-start=&quot;3904&quot; data-ke-size=&quot;size16&quot;&gt;이전 방식이 아래와 같았다면,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;질문 1 -&amp;gt; 응답 &lt;br /&gt;질문 2 -&amp;gt; 응답 &lt;br /&gt;질문 3 -&amp;gt; 응답 &lt;br /&gt;질문 4 -&amp;gt; 응답 &lt;br /&gt;질문 5 -&amp;gt; 응답 &lt;br /&gt;질문 6 -&amp;gt; 응답&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;지금 방식은 이렇게 바뀐 셈이다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;질문 1,2,3,4,5,6 한 번에 전송 -&amp;gt; 결과 한 번에 수신&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉 이번 개선의 본질은 &quot;상태를 덜 읽는 것&quot;이 아니라, &quot;같은 상태를 덜 왕복하며 읽는 것&quot;이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2차 개선 이후&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6Pcu1/dJMcacWPCgp/KPFBfJFwPHwvBUHv1q6sFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6Pcu1/dJMcacWPCgp/KPFBfJFwPHwvBUHv1q6sFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6Pcu1/dJMcacWPCgp/KPFBfJFwPHwvBUHv1q6sFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6Pcu1%2FdJMcacWPCgp%2FKPFBfJFwPHwvBUHv1q6sFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;649&quot; height=&quot;246&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;452&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1860&quot; data-origin-height=&quot;1232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Du71R/dJMcagkBeoA/KRikUhN64SzPc4qGbn4CV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Du71R/dJMcagkBeoA/KRikUhN64SzPc4qGbn4CV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Du71R/dJMcagkBeoA/KRikUhN64SzPc4qGbn4CV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDu71R%2FdJMcagkBeoA%2FKRikUhN64SzPc4qGbn4CV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;314&quot; height=&quot;208&quot; data-origin-width=&quot;1860&quot; data-origin-height=&quot;1232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제 5,000 VU 기준 부하 테스트에서도 응답 지표 개선이 확인됐다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개선 전에는 처리량이 &lt;b&gt;516 req/s&lt;/b&gt; 수준이었고, 평균 응답시간은 &lt;b&gt;3.02s&lt;/b&gt; 정도였다.&lt;br /&gt;Redis 왕복을 줄인 이후에는 처리량이 &lt;b&gt;871 req/s&lt;/b&gt; 수준까지 올라갔고, 평균 응답시간도 &lt;b&gt;2.26s&lt;/b&gt; 정도로 줄어들었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, 같은 구조 안에서 &lt;b&gt;요청당 Redis 왕복 패턴만 줄였는데도&lt;/b&gt; 처리량과 응답시간이 모두 눈에 띄게 좋아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 왕복 최적화가 직접 줄이는 것은 요청 내부의 상태 조회 오버헤드다.&lt;br /&gt;그리고 그 변화가 누적되면서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4688&quot; data-start=&quot;4581&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4601&quot; data-start=&quot;4581&quot;&gt;요청당 Redis 대기 시간 감소&lt;/li&gt;
&lt;li data-end=&quot;4624&quot; data-start=&quot;4602&quot;&gt;동시 요청 시 커넥션 점유 시간 감소&lt;/li&gt;
&lt;li data-end=&quot;4643&quot; data-start=&quot;4625&quot;&gt;애플리케이션 스레드 대기 감소&lt;/li&gt;
&lt;li data-end=&quot;4673&quot; data-start=&quot;4644&quot;&gt;burst 상황에서의 tail latency 완화&lt;/li&gt;
&lt;li data-end=&quot;4688&quot; data-start=&quot;4674&quot;&gt;결과적으로 처리량 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4702&quot; data-start=&quot;4690&quot; data-ke-size=&quot;size16&quot;&gt;로 이어질 수 있었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;물론 여기서 조심할 점도 있다.&lt;br /&gt;모든 지표 개선을 Redis 최적화 하나만의 효과로 단정할 수는 없다.&lt;br /&gt;실제 부하 테스트에는 연결 상태, 스레드 상태, 서버 부하 등 여러 요소가 함께 영향을 준다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4850&quot; data-start=&quot;4820&quot; data-ke-size=&quot;size16&quot;&gt;다만 적어도 이번 테스트는 한 가지는 분명히 보여줬다.&lt;/p&gt;
&lt;p data-end=&quot;4943&quot; data-start=&quot;4852&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4943&quot; data-start=&quot;4852&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선착순 이벤트에서는 Redis가 빠르다는 사실만으로는 부족하고, Redis를 어떤 패턴으로 읽고 쓰느냐가 전체 응답 성능에 직접적인 영향을 준다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;본격적인 부하 테스트: 현재 구조에서 어디까지 버틸 수 있는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2402&quot; data-origin-height=&quot;1346&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwiznM/dJMcaiiqjO0/FpOkAQyxKydgb8PSQlHN3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwiznM/dJMcaiiqjO0/FpOkAQyxKydgb8PSQlHN3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwiznM/dJMcaiiqjO0/FpOkAQyxKydgb8PSQlHN3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdwiznM%2FdJMcaiiqjO0%2FFpOkAQyxKydgb8PSQlHN3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;546&quot; height=&quot;306&quot; data-origin-width=&quot;2402&quot; data-origin-height=&quot;1346&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에는 1분 동안 일정한 TPS를 유지하는 방식으로 테스트를 진행했다.&lt;br /&gt;조건은 다음과 같았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1701&quot; data-start=&quot;1656&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1663&quot; data-start=&quot;1656&quot; data-section-id=&quot;u8le5e&quot;&gt;서버 2대&lt;/li&gt;
&lt;li data-end=&quot;1677&quot; data-start=&quot;1664&quot; data-section-id=&quot;10dqhur&quot;&gt;톰캣 스레드 MAX 150개&lt;/li&gt;
&lt;li data-end=&quot;1701&quot; data-start=&quot;1678&quot; data-section-id=&quot;1ogabkf&quot;&gt;목표 TPS를 점진적으로 올려가며 측정&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TPS 2,000&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2426&quot; data-origin-height=&quot;858&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cP5kww/dJMcaiQehWm/skpgwTiKDzwkBRhVDXn4X0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cP5kww/dJMcaiQehWm/skpgwTiKDzwkBRhVDXn4X0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cP5kww/dJMcaiQehWm/skpgwTiKDzwkBRhVDXn4X0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcP5kww%2FdJMcaiQehWm%2FskpgwTiKDzwkBRhVDXn4X0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;826&quot; height=&quot;292&quot; data-origin-width=&quot;2426&quot; data-origin-height=&quot;858&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1248&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KodS8/dJMcaibGCcq/oPrXKsugKMSsHNQ7Vc5p5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KodS8/dJMcaibGCcq/oPrXKsugKMSsHNQ7Vc5p5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KodS8/dJMcaibGCcq/oPrXKsugKMSsHNQ7Vc5p5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKodS8%2FdJMcaibGCcq%2FoPrXKsugKMSsHNQ7Vc5p5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;451&quot; height=&quot;185&quot; data-origin-width=&quot;1248&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2078&quot; data-origin-height=&quot;1232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pCOl9/dJMcaaEJeTP/8yanupQ9Zy2gBp9fg0fjw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pCOl9/dJMcaaEJeTP/8yanupQ9Zy2gBp9fg0fjw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pCOl9/dJMcaaEJeTP/8yanupQ9Zy2gBp9fg0fjw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpCOl9%2FdJMcaaEJeTP%2F8yanupQ9Zy2gBp9fg0fjw0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;484&quot; height=&quot;287&quot; data-origin-width=&quot;2078&quot; data-origin-height=&quot;1232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 구간에서는 꽤 안정적이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1786&quot; data-start=&quot;1769&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1786&quot; data-start=&quot;1769&quot; data-section-id=&quot;wp01bg&quot;&gt;&lt;b&gt;p90 33.85ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1853&quot; data-start=&quot;1788&quot; data-ke-size=&quot;size16&quot;&gt;응답 시간도 충분히 짧았고 CPU도 3%이하로 사용했다.&lt;br /&gt;현재 구조로는 이 정도 트래픽까지는 큰 무리 없이 처리할 수 있겠다는 느낌이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;1895&quot; data-start=&quot;1855&quot; data-ke-size=&quot;size16&quot;&gt;즉, 최소한 &lt;b&gt;2,000 TPS 구간은 안정권&lt;/b&gt;으로 볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TPS 2,500&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 &lt;b&gt;TPS 2,500&lt;/b&gt;을 시도했다.&lt;/p&gt;
&lt;p data-end=&quot;1957&quot; data-start=&quot;1940&quot; data-ke-size=&quot;size16&quot;&gt;여기서부터는 분위기가 달라졌다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2218&quot; data-origin-height=&quot;910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caao51/dJMcajaxibK/Z2QLq8ih7ZYXzJxd6jLk90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caao51/dJMcajaxibK/Z2QLq8ih7ZYXzJxd6jLk90/img.png&quot; data-alt=&quot;ㄱ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caao51/dJMcajaxibK/Z2QLq8ih7ZYXzJxd6jLk90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcaao51%2FdJMcajaxibK%2FZ2QLq8ih7ZYXzJxd6jLk90%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2218&quot; height=&quot;910&quot; data-origin-width=&quot;2218&quot; data-origin-height=&quot;910&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ㄱ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1404&quot; data-origin-height=&quot;588&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Gikom/dJMcagx8i2u/MLhuyBME2KBxrB4jkkeOZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Gikom/dJMcagx8i2u/MLhuyBME2KBxrB4jkkeOZK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Gikom/dJMcagx8i2u/MLhuyBME2KBxrB4jkkeOZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGikom%2FdJMcagx8i2u%2FMLhuyBME2KBxrB4jkkeOZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;375&quot; height=&quot;157&quot; data-origin-width=&quot;1404&quot; data-origin-height=&quot;588&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1510&quot; data-origin-height=&quot;1182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJdGPz/dJMcadBlTud/1gOTUulTeibtZKvTvcU2IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJdGPz/dJMcadBlTud/1gOTUulTeibtZKvTvcU2IK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJdGPz/dJMcadBlTud/1gOTUulTeibtZKvTvcU2IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJdGPz%2FdJMcadBlTud%2F1gOTUulTeibtZKvTvcU2IK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;320&quot; height=&quot;250&quot; data-origin-width=&quot;1510&quot; data-origin-height=&quot;1182&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1977&quot; data-start=&quot;1959&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1977&quot; data-start=&quot;1959&quot; data-section-id=&quot;be7h93&quot;&gt;&lt;b&gt;p90 345.62ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2029&quot; data-start=&quot;1979&quot; data-ke-size=&quot;size16&quot;&gt;불과 500 TPS 차이인데도,&lt;br /&gt;p90 응답 시간은 거의 &lt;b&gt;10배 이상&lt;/b&gt; 느려졌다.&lt;/p&gt;
&lt;p data-end=&quot;2029&quot; data-start=&quot;1979&quot; data-ke-size=&quot;size16&quot;&gt;CPU 사용률도 20%로 확 뛰었다.&lt;/p&gt;
&lt;p data-end=&quot;2137&quot; data-start=&quot;2031&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;평균값만 보면 아직 버티는 것처럼 보일 수 있지만,&lt;br /&gt;상위 구간 응답 시간이 급격히 튀기 시작했다는 건&lt;br /&gt;시스템이 슬슬 여유를 잃고 있다는 뜻에 더 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;2196&quot; data-start=&quot;2139&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;TPS 2,500부터는 &amp;ldquo;된다&amp;rdquo;가 아니라 &amp;ldquo;버티긴 버티지만 힘들어한다&amp;rdquo;&lt;/b&gt;는 느낌이 강했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2211&quot; data-start=&quot;2198&quot; data-section-id=&quot;1hv7vbr&quot; data-ke-size=&quot;size23&quot;&gt;TPS 2,800&lt;/h3&gt;
&lt;p data-end=&quot;2241&quot; data-start=&quot;2213&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 &lt;b&gt;TPS 2,800&lt;/b&gt;도 시도해봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2386&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcDFie/dJMcaflIy1Y/WnODHqxnf7UwOD7M1Nx8rk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcDFie/dJMcaflIy1Y/WnODHqxnf7UwOD7M1Nx8rk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcDFie/dJMcaflIy1Y/WnODHqxnf7UwOD7M1Nx8rk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcDFie%2FdJMcaflIy1Y%2FWnODHqxnf7UwOD7M1Nx8rk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2386&quot; height=&quot;860&quot; data-origin-width=&quot;2386&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEqbI0/dJMcabp6tYg/cr6Abc9iCzCpBoKsjRRmE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEqbI0/dJMcabp6tYg/cr6Abc9iCzCpBoKsjRRmE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEqbI0/dJMcabp6tYg/cr6Abc9iCzCpBoKsjRRmE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEqbI0%2FdJMcabp6tYg%2Fcr6Abc9iCzCpBoKsjRRmE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;472&quot; height=&quot;203&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;540&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2264&quot; data-origin-height=&quot;1224&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QUZMh/dJMcad2rFeF/xVGwfnWkkvDjAhu3YRQbu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QUZMh/dJMcad2rFeF/xVGwfnWkkvDjAhu3YRQbu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QUZMh/dJMcad2rFeF/xVGwfnWkkvDjAhu3YRQbu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQUZMh%2FdJMcad2rFeF%2FxVGwfnWkkvDjAhu3YRQbu1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;466&quot; height=&quot;252&quot; data-origin-width=&quot;2264&quot; data-origin-height=&quot;1224&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;2316&quot; data-start=&quot;2243&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2316&quot; data-start=&quot;2243&quot;&gt;&lt;b&gt;P90 2.26s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2316&quot; data-start=&quot;2243&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로는 목표한 2,800 TPS를 온전히 처리하지 못했고,&lt;br /&gt;실제 처리량은 &lt;b&gt;약 2,600 TPS 수준&lt;/b&gt;에서 수렴했다.&lt;/p&gt;
&lt;p data-end=&quot;2316&quot; data-start=&quot;2243&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2422&quot; data-start=&quot;2318&quot; data-ke-size=&quot;size16&quot;&gt;이 구간에서는 응답 시간도 크게 늘었고,&lt;br /&gt;드롭된 요청도 발생하기 시작했다.&lt;br /&gt;Load Average 역시 올라가면서,&lt;br /&gt;이제는 시스템이 명확하게 포화 구간에 들어섰다는 게 보였다.&lt;/p&gt;
&lt;p data-end=&quot;2422&quot; data-start=&quot;2318&quot; data-ke-size=&quot;size16&quot;&gt;CPU 사용률도 50%에 육박했다.&lt;/p&gt;
&lt;p data-end=&quot;2422&quot; data-start=&quot;2318&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2441&quot; data-start=&quot;2424&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이 테스트는 꽤 명확했다.&lt;/p&gt;
&lt;p data-end=&quot;2511&quot; data-start=&quot;2443&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지금 구조에서의 한계는 2,600 TPS 전후&lt;/b&gt;로 보였고,&lt;br /&gt;안정적으로 운영 가능한 구간은 2,200TPS 정도였다.&lt;/p&gt;
&lt;p data-end=&quot;2511&quot; data-start=&quot;2443&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;2561&quot; data-start=&quot;2518&quot; data-section-id=&quot;11g4frs&quot; data-ke-size=&quot;size26&quot;&gt;정리: 현재 서버 2대 기준 안정권은 약 2,100 TPS 수준으로 봤다&lt;/h2&gt;
&lt;p data-end=&quot;2577&quot; data-start=&quot;2563&quot; data-ke-size=&quot;size16&quot;&gt;결과를 종합해보면 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2682&quot; data-start=&quot;2579&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2599&quot; data-start=&quot;2579&quot; data-section-id=&quot;1v7rhmf&quot;&gt;&lt;b&gt;2,000 TPS&lt;/b&gt;: 안정적&lt;/li&gt;
&lt;li data-end=&quot;2636&quot; data-start=&quot;2600&quot; data-section-id=&quot;1hyibpj&quot;&gt;&lt;b&gt;2,500 TPS&lt;/b&gt;: 응답 지연이 눈에 띄게 커지기 시작&lt;/li&gt;
&lt;li data-end=&quot;2682&quot; data-start=&quot;2637&quot; data-section-id=&quot;tn1gx9&quot;&gt;&lt;b&gt;2,800 TPS 시도&lt;/b&gt;: 실제 처리량은 2,600 TPS 수준에서 한계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2742&quot; data-start=&quot;2684&quot; data-ke-size=&quot;size16&quot;&gt;즉, 현재 서버 2대 구성에서는&lt;br /&gt;&lt;b&gt;약 2,200 TPS 전후가 현실적인 안정권&lt;/b&gt;이라고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;2859&quot; data-start=&quot;2744&quot; data-ke-size=&quot;size16&quot;&gt;2,500 TPS부터는 분명히 버거워하는 신호가 보였고,&lt;br /&gt;2,800 TPS에서는 사실상 한계가 드러났다.&lt;br /&gt;이 이상으로 밀어붙이면 &amp;ldquo;된다&amp;rdquo;기보다&lt;br /&gt;&lt;b&gt;언제든 흔들릴 수 있는 구간&lt;/b&gt;에 가깝다고 봤다.&lt;/p&gt;
&lt;p data-end=&quot;2859&quot; data-start=&quot;2744&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2859&quot; data-start=&quot;2744&quot; data-ke-size=&quot;size16&quot;&gt;여기서 조금 더 개선해볼 수 있을까해서 몇 가지 더 고민해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2859&quot; data-start=&quot;2744&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;611&quot; data-start=&quot;589&quot; data-ke-size=&quot;size23&quot;&gt;1. 스레드 설정&lt;/h3&gt;
&lt;p data-end=&quot;691&quot; data-start=&quot;613&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 본 건 스레드였다.&lt;br /&gt;순간적으로 요청이 확 몰리는 구조다 보니, 처음에는 스레드 수가 부족해서 병목이 생기는 것 아닐까 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;691&quot; data-start=&quot;613&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;693&quot; data-ke-size=&quot;size16&quot;&gt;그런데 지표를 같이 보면 조금 다르게 보였다.&lt;br /&gt;TPS를 올릴수록 &lt;b&gt;CPU 사용률이 같이 올라가고&lt;/b&gt;, 동시에 &lt;b&gt;p90 / p95도 함께 나빠졌다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;879&quot; data-start=&quot;782&quot; data-ke-size=&quot;size16&quot;&gt;즉, 단순히 &amp;ldquo;스레드가 부족해서 대기만 늘어나는 상황&amp;rdquo;이라기보다,&lt;br /&gt;&lt;b&gt;서버 한 대가 실제로 감당할 수 있는 처리 한계에 가까워지고 있었다&lt;/b&gt;고 보는 편이 더 자연스러웠다.&lt;/p&gt;
&lt;p data-end=&quot;984&quot; data-start=&quot;881&quot; data-ke-size=&quot;size16&quot;&gt;스레드 문제가 핵심이라면 CPU는 상대적으로 덜 쓰면서 대기만 길어지는 그림이 먼저 보일 수도 있는데,&lt;br /&gt;지금은 처리량이 올라갈수록 CPU와 응답 지표가 함께 무너지는 쪽에 가까웠다.&lt;/p&gt;
&lt;p data-end=&quot;1105&quot; data-start=&quot;986&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1105&quot; data-start=&quot;986&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 시점에서는 스레드 수를 무작정 늘리는 것이 근본적인 해결책은 아닐 수 있다고 판단했다.&lt;br /&gt;오히려 그 경우, 더 많은 요청을 동시에 붙잡고 있다가 tail latency만 더 나빠질 가능성도 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1105&quot; data-start=&quot;986&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1105&quot; data-start=&quot;986&quot; data-ke-size=&quot;size16&quot;&gt;실제로 더 높여보았는데 훨씬 더 성능이 안 좋아졌다.&lt;/p&gt;
&lt;p data-end=&quot;1105&quot; data-start=&quot;986&quot; data-ke-size=&quot;size16&quot;&gt;스레드 max를 170으로 20을 더 높여보았는데 p90/p95는 폭증하고 CPU도 사용률이 10% 더 늘었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2292&quot; data-origin-height=&quot;900&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWPZww/dJMcadH9Dco/QaJO5W3fGjX0OaiCyFuQ4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWPZww/dJMcadH9Dco/QaJO5W3fGjX0OaiCyFuQ4k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWPZww/dJMcadH9Dco/QaJO5W3fGjX0OaiCyFuQ4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWPZww%2FdJMcadH9Dco%2FQaJO5W3fGjX0OaiCyFuQ4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;654&quot; height=&quot;257&quot; data-origin-width=&quot;2292&quot; data-origin-height=&quot;900&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxD8Tk/dJMcaaSgr6W/6kNH8BPTmzMTt8UVtcE7uK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxD8Tk/dJMcaaSgr6W/6kNH8BPTmzMTt8UVtcE7uK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxD8Tk/dJMcaaSgr6W/6kNH8BPTmzMTt8UVtcE7uK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxD8Tk%2FdJMcaaSgr6W%2F6kNH8BPTmzMTt8UVtcE7uK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;353&quot; height=&quot;172&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2246&quot; data-origin-height=&quot;1232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHAmWy/dJMcahw4USm/6a08Fhiu7dKLf7KDXVtMt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHAmWy/dJMcahw4USm/6a08Fhiu7dKLf7KDXVtMt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHAmWy/dJMcahw4USm/6a08Fhiu7dKLf7KDXVtMt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHAmWy%2FdJMcahw4USm%2F6a08Fhiu7dKLf7KDXVtMt1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;364&quot; height=&quot;200&quot; data-origin-width=&quot;2246&quot; data-origin-height=&quot;1232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-end=&quot;1139&quot; data-start=&quot;1107&quot; data-ke-size=&quot;size23&quot;&gt;2. Redis 쪽도 더 줄일 수 있는지 다시 봤다&lt;/h3&gt;
&lt;p data-end=&quot;1162&quot; data-start=&quot;1141&quot; data-ke-size=&quot;size16&quot;&gt;다음으로는 Redis 쪽을 다시 봤다.&lt;/p&gt;
&lt;p data-end=&quot;1321&quot; data-start=&quot;1164&quot; data-ke-size=&quot;size16&quot;&gt;혹시 요청당 Redis 명령이 불필요하게 많지는 않은지,&lt;br /&gt;조금이라도 더 줄일 수 있는 부분이 없는지 다시 점검했다.&lt;/p&gt;
&lt;p data-end=&quot;1321&quot; data-start=&quot;1164&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이미 Redis 쪽은 꽤 단순하게 가져간 상태였다.&lt;br /&gt;필요한 최소한의 명령만 사용하고 있었고, 구조적으로도 더 크게 덜어낼 만한 부분은 많지 않았다.&lt;/p&gt;
&lt;p data-end=&quot;1432&quot; data-start=&quot;1323&quot; data-ke-size=&quot;size16&quot;&gt;즉, 애플리케이션 레벨에서 줄일 수 있는 비용은 이미 한 번 많이 걷어낸 상태였고,&lt;br /&gt;여기서 추가 최적화를 한다고 해도 TPS를 큰 폭으로 더 끌어올릴 수 있을 만큼의 여지는 크지 않아 보였다.&lt;/p&gt;
&lt;p data-end=&quot;2859&quot; data-start=&quot;2744&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1453&quot; data-start=&quot;1434&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-end=&quot;1580&quot; data-start=&quot;1455&quot; data-ke-size=&quot;size16&quot;&gt;스레드도 먼저 의심해봤고,&lt;br /&gt;Redis 쪽도 다시 점검해봤지만,&lt;br /&gt;결국 지금 한계는 &lt;b&gt;코드 몇 줄 더 줄이거나 설정 하나 바꾼다고 해결될 문제라기보다, 서버 한 대가 감당할 수 있는 물리적인 처리량에 가까워 보였다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1650&quot; data-start=&quot;1582&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이 시점에서 더 안정적으로 TPS를 올리려면 &lt;b&gt;수평 확장&lt;/b&gt; 쪽으로 가는 것이 가장 현실적인 선택지에 가까웠다.&lt;/p&gt;
&lt;p data-end=&quot;1754&quot; data-start=&quot;1652&quot; data-ke-size=&quot;size16&quot;&gt;한 대가 감당해야 하는 TPS를 낮추고, 같은 구조를 여러 대로 분산해서 받는 것.&lt;br /&gt;적어도 현재 테스트 결과만 놓고 보면, 그게 가장 직접적이고 예측 가능한 다음 단계였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 이벤트에서는 ?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봄봄 선착순 이벤트는 2026년 2월 23일 오후 2시에 진행되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 아무 문제 없이&amp;nbsp;무사히 잘 마쳤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;1632&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oOKWX/dJMcacCx7fV/ckEukkhhKzsFPLhtWsP2Jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oOKWX/dJMcacCx7fV/ckEukkhhKzsFPLhtWsP2Jk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oOKWX/dJMcacCx7fV/ckEukkhhKzsFPLhtWsP2Jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoOKWX%2FdJMcacCx7fV%2FckEukkhhKzsFPLhtWsP2Jk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;435&quot; height=&quot;364&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;1632&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bF19Yd/dJMcahX8K6X/z5BAR3a1dtbntszOB5zQn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bF19Yd/dJMcahX8K6X/z5BAR3a1dtbntszOB5zQn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bF19Yd/dJMcahX8K6X/z5BAR3a1dtbntszOB5zQn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbF19Yd%2FdJMcahX8K6X%2Fz5BAR3a1dtbntszOB5zQn1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;619&quot; height=&quot;228&quot; data-origin-width=&quot;3550&quot; data-origin-height=&quot;1310&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rhqwZ/dJMcagdSDYi/9KU3Ae9VOSKHpJjzjhSIG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rhqwZ/dJMcagdSDYi/9KU3Ae9VOSKHpJjzjhSIG0/img.png&quot; width=&quot;644&quot; height=&quot;472&quot; data-origin-width=&quot;1678&quot; data-origin-height=&quot;1230&quot; data-is-animation=&quot;false&quot; style=&quot;width: 34.8755%; margin-right: 10px;&quot; data-widthpercent=&quot;35.29&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rhqwZ/dJMcagdSDYi/9KU3Ae9VOSKHpJjzjhSIG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrhqwZ%2FdJMcagdSDYi%2F9KU3Ae9VOSKHpJjzjhSIG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1678&quot; height=&quot;1230&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EH7zT/dJMcaiQe5HT/oKmWPNCQ3bAK0O6Ii0SkDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EH7zT/dJMcaiQe5HT/oKmWPNCQ3bAK0O6Ii0SkDk/img.png&quot; width=&quot;643&quot; height=&quot;257&quot; data-origin-width=&quot;1256&quot; data-origin-height=&quot;502&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;64.71&quot; style=&quot;width: 63.9617%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EH7zT/dJMcaiQe5HT/oKmWPNCQ3bAK0O6Ii0SkDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEH7zT%2FdJMcaiQe5HT%2FoKmWPNCQ3bAK0O6Ii0SkDk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1256&quot; height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1540&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAhwKe/dJMb99MA1om/nSgrFTtlOb29tJEFkq11yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAhwKe/dJMb99MA1om/nSgrFTtlOb29tJEFkq11yk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAhwKe/dJMb99MA1om/nSgrFTtlOb29tJEFkq11yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAhwKe%2FdJMb99MA1om%2FnSgrFTtlOb29tJEFkq11yk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;506&quot; height=&quot;198&quot; data-origin-width=&quot;1540&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상보다는 많이 적지만 총 179+분 정도가 참여해주셨고 피크 5분간 총 요청은 9k, 피크 RPM 550,&amp;nbsp; p90 67ms, 에러 0건으로 잘 마무리 될 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성과로는 회원수 300+이 늘었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설날도 반납하면서 3주간 열심히 고민하고 기획/설계/구현을 했는데 큰 문제없이 마무리되어서 다행이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현하면서 생각할것도 많아서 배워가는 것도 참 많은 기회였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이벤트 구현도 중요하지말 알리는게 더 어렵다는걸 이번에 알게되었다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2099&quot; data-start=&quot;2023&quot; data-ke-size=&quot;size20&quot;&gt;Reference&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=uWcn7omddxs&amp;amp;t=1050s&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[우아한테크토크] 엔드게임 이벤트 긴급 대응기 개발자 어!셈블?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=MTSn93rNPPE&amp;amp;t=681s&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[우아한테크토크]&amp;nbsp;선착순&amp;nbsp;이벤트&amp;nbsp;서버&amp;nbsp;생존기!&amp;nbsp;47만&amp;nbsp;RPM에서&amp;nbsp;살아남다?!&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://medium.com/analytics-vidhya/redis-sorted-sets-explained-2d8b6302525&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Redis Sorted Sets Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://techblog.gccompany.co.kr/redis-kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EA%B8%B0-feat-%EB%84%A4%EA%B3%A0%EC%99%95-ec6682e39731&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Redis&amp;amp;Kafka를 활용한 선착순 쿠폰 이벤트 개발기 (feat. 네고왕)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <category>선착순</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/773</guid>
      <comments>https://html-jc.tistory.com/773#entry773comment</comments>
      <pubDate>Mon, 23 Mar 2026 17:37:26 +0900</pubDate>
    </item>
    <item>
      <title>봄봄 코드 리뷰 문화 개선</title>
      <link>https://html-jc.tistory.com/772</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bR6kZc/dJMcacVZx1s/XoOTXYRpF8eroE63N6PfqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bR6kZc/dJMcacVZx1s/XoOTXYRpF8eroE63N6PfqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bR6kZc/dJMcacVZx1s/XoOTXYRpF8eroE63N6PfqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbR6kZc%2FdJMcacVZx1s%2FXoOTXYRpF8eroE63N6PfqK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;670&quot; height=&quot;321&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말해볼게요. 코드 리뷰, 다들 좋은 거 알잖아요? 하지만 가끔은 슬랙 알림에 뜬 &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;3&quot;&gt;[PR]&lt;/b&gt; 글자만 봐도 &quot;아, 조금만 있다가 볼까...&quot; 하고 창을 닫게 되는 순간이 있죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;저에게 코드 리뷰는 단순히 '오타 찾기'나 '버그 검수'가 아니에요. 우리 팀이 어떤 코드를 '좋은 코드'라고 정의하는지, 이 복잡한 문제를 어떤 로직으로 풀어냈는지 서로의 머릿속을 동기화하는 &lt;b data-index-in-node=&quot;108&quot; data-path-to-node=&quot;4&quot;&gt;가장 찐한 협업의 시간&lt;/b&gt;이라고 생각하거든요. 그래서 우리 팀은 &quot;무조건 리뷰 후 머지&quot;라는 꽤 깐깐한 원칙을 고수해왔고, 실제로 그 덕분에 대형 사고를 막거나 설계가 훨씬 단단해지는 짜릿한 경험도 꽤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5&quot;&gt;그런데 팀이 프로젝트가 커져가며 바빠지니 문제가 생기더라고요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;리뷰가 '즐거운 대화'가 아니라 '밀린 숙제'가 되어버린 거죠. 쌓여있는 PR은 누군가에겐 압박이 되고, 누군가에겐 긴 기다림이 됩니다. 꼼꼼하게 보자니 진도가 안 나가고, 대충 하자니 찝찝한 그 미묘한 상황. 좋은 의도로 만든 시스템이 어느새 팀의 발목을 잡는 &lt;b&gt;'병목'&lt;/b&gt;이 된 거예요.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;여기서 제가 내린 결론은 하나였습니다. &lt;b data-index-in-node=&quot;22&quot; data-path-to-node=&quot;7&quot;&gt;&quot;사람들을 더 채찍질하지 말자.&quot;&lt;/b&gt; 리뷰가 늦어지는 건 팀원들의 성실함 문제가 아니라, 리뷰를 하기 힘들게 만드는 &lt;b&gt;'운영의 문제'&lt;/b&gt;였거든요. 초반에는 &quot;리뷰 좀 빨리해주세요&quot;라고 서로 독촉하면서 다녔는데 이제는 리뷰어가 '딱 5분만 써도 충분히 맥락을 파악할 수 있는 환경'을 만드는 데 집중했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 바꾸고 나니 변화가 숫자로도 보이더라고요. &lt;b&gt;평균 리드 타임이 3~6일에서 1~2일 이내로 줄었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;어떻게 하면 리뷰어가 길을 잃지 않게 할지, 불필요한 알림 지옥에서 어떻게 탈출했는지&amp;mdash;저희 팀의 시행착오 끝에 정착한 &lt;b data-index-in-node=&quot;66&quot; data-path-to-node=&quot;8&quot;&gt;'지속 가능한 코드 리뷰 운영법'&lt;/b&gt;을&amp;nbsp;공유합니다.&lt;/p&gt;
&lt;p data-end=&quot;438&quot; data-start=&quot;370&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;438&quot; data-start=&quot;370&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;479&quot; data-start=&quot;445&quot; data-ke-size=&quot;size26&quot;&gt;1. 문제의 시작&lt;/h2&gt;
&lt;p data-end=&quot;519&quot; data-start=&quot;481&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하며 공통적으로 느낀 문제가 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;617&quot; data-start=&quot;521&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;547&quot; data-start=&quot;521&quot;&gt;어떤 PR은 사소한 코멘트로 논의가 길어지고&lt;/li&gt;
&lt;li data-end=&quot;578&quot; data-start=&quot;548&quot;&gt;중요한 변경이 들어간 PR은 늦게 발견되거나 묻히고&lt;/li&gt;
&lt;li data-end=&quot;617&quot; data-start=&quot;579&quot;&gt;리뷰 요청이 제대로 인지되지 않아 며칠씩 대기하는 상황이 발생했다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;676&quot; data-start=&quot;619&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 개인의 리뷰 성향 문제라고 생각했다.&lt;br /&gt;&amp;ldquo;누구는 꼼꼼하고, 누구는 빠르다&amp;rdquo; 정도로 인식했다.&lt;/p&gt;
&lt;p data-end=&quot;702&quot; data-start=&quot;678&quot; data-ke-size=&quot;size16&quot;&gt;하지만 PR 수가 늘어날수록 확신이 들었다.&lt;/p&gt;
&lt;blockquote data-end=&quot;741&quot; data-start=&quot;704&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;741&quot; data-start=&quot;706&quot; data-ke-size=&quot;size16&quot;&gt;결국&amp;nbsp;더&amp;nbsp;열심히&amp;nbsp;하자는&amp;nbsp;방식엔&amp;nbsp;한계가&amp;nbsp;있었다.&amp;nbsp;&lt;br /&gt;같은&amp;nbsp;노력으로도&amp;nbsp;리뷰가&amp;nbsp;더&amp;nbsp;잘&amp;nbsp;굴러가게&amp;nbsp;만드는&amp;nbsp;구조를&amp;nbsp;손봐야겠다고&amp;nbsp;느꼈다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-end=&quot;778&quot; data-start=&quot;748&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h2 data-end=&quot;778&quot; data-start=&quot;748&quot; data-ke-size=&quot;size26&quot;&gt;2. 문제 정의&lt;/h2&gt;
&lt;p data-end=&quot;814&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt;팀원들과 리뷰 상황을 정리해보며, 문제를 세 가지로 정리했다.&lt;/p&gt;
&lt;p data-end=&quot;814&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;843&quot; data-start=&quot;816&quot; data-ke-size=&quot;size18&quot;&gt;1) 리뷰 코멘트의 중요도 기준이 제각각&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;924&quot; data-start=&quot;844&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;867&quot; data-start=&quot;844&quot;&gt;필수 수정인지, 제안인지 구분되지 않음&lt;/li&gt;
&lt;li data-end=&quot;896&quot; data-start=&quot;868&quot;&gt;&amp;ldquo;이건 꼭 반영해야 하나요?&amp;rdquo; 같은 질문이 반복&lt;/li&gt;
&lt;li data-end=&quot;924&quot; data-start=&quot;897&quot;&gt;논의가 길어지고 PR 처리 속도가 들쭉날쭉해짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;951&quot; data-start=&quot;926&quot; data-ke-size=&quot;size18&quot;&gt;2) PR에 맥락이 충분히 남지 않음&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;997&quot; data-start=&quot;952&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;965&quot; data-start=&quot;952&quot;&gt;왜 이 변경을 했는지&lt;/li&gt;
&lt;li data-end=&quot;982&quot; data-start=&quot;966&quot;&gt;다른 선택지는 무엇이었는지&lt;/li&gt;
&lt;li data-end=&quot;997&quot; data-start=&quot;983&quot;&gt;비즈니스적인 요소와 어떤 연관관계가 있는지&lt;/li&gt;
&lt;li data-end=&quot;997&quot; data-start=&quot;983&quot;&gt;단순히 코드 컨벤션이나 코드 구조에 대해서만 리뷰하게 됨&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;997&quot; data-start=&quot;983&quot;&gt;전체적인 그림으로 리뷰하기 위해서는 리뷰할 사람이 따로 찾아봐야 하는게 많았음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1056&quot; data-start=&quot;1035&quot; data-ke-size=&quot;size18&quot;&gt;3) 리뷰 요청이 묻히는 구조&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1136&quot; data-start=&quot;1057&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1087&quot; data-start=&quot;1057&quot;&gt;GitHub 알림은 많지만 중요 이벤트가 섞여 있음&lt;/li&gt;
&lt;li data-end=&quot;1116&quot; data-start=&quot;1088&quot;&gt;Discord 알림은 노이즈가 많아 점점 무시됨&lt;/li&gt;
&lt;li data-end=&quot;1136&quot; data-start=&quot;1117&quot;&gt;결과적으로 리뷰 누락&amp;middot;지연 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1180&quot; data-start=&quot;1143&quot; data-ke-size=&quot;size26&quot;&gt;3. &amp;ldquo;빨리&amp;rdquo;가 아니라 &amp;ldquo;덜 소모되게 하자&amp;rdquo;&lt;/h2&gt;
&lt;p data-end=&quot;1199&quot; data-start=&quot;1182&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 합의가 필요했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1265&quot; data-start=&quot;1201&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1265&quot; data-start=&quot;1203&quot; data-ke-size=&quot;size16&quot;&gt;목표는 &lt;b&gt;리뷰를 대충 빠르게&lt;/b&gt;가 아니라&lt;br /&gt;&lt;b&gt;리뷰 품질을 유지하면서도 예측 가능한 속도를 만드는 것&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1323&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1323&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;이를 위해 개인의 책임을 늘리는 대신, &lt;b&gt;팀 전체의 기준과 흐름을 정리하는 방향&lt;/b&gt;을 선택했다.&lt;/p&gt;
&lt;p data-end=&quot;1323&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1365&quot; data-start=&quot;1330&quot; data-ke-size=&quot;size26&quot;&gt;4. 해결 방법&lt;/h2&gt;
&lt;h3 data-end=&quot;1365&quot; data-start=&quot;1330&quot; data-ke-size=&quot;size23&quot;&gt;1) 리뷰 코멘트의 언어를 통일하다 (Pn 규칙)&lt;/h3&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;우리 팀은 평소 대화할 때는 말뿐 아니라 표정, 톤, 손짓 같은 비언어적 신호로 &amp;ldquo;이 얘기가 얼마나 진지한지&amp;rdquo;, &amp;ldquo;급한 건지&amp;rdquo;, &amp;ldquo;그냥 아이디어인지&amp;rdquo;를 함께 전달한다.&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;그런데 코드 리뷰는 대부분 텍스트로만 진행되다 보니, 의도와 뉘앙스가 쉽게 빠진다.&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;가볍게 던진 코멘트가 예상보다 날카롭게 읽히거나, 꼭 반영돼야 하는 지점이 &amp;ldquo;참고&amp;rdquo; 정도로 받아들여져서 서로 같은 내용을 반복 설명하게 되기도 한다.&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;결국 리뷰가 길어지고, 감정 소모가 생기고, 때로는 PR 흐름 자체가 멈추는 상황까지 이어질 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는 &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://blog.banksalad.com/tech/banksalad-code-review-culture/#%EC%BB%A4%EB%AE%A4%EB%8B%88%EC%BC%80%EC%9D%B4%EC%85%98-%EB%B9%84%EC%9A%A9%EC%9D%84-%EC%A4%84%EC%9D%B4%EA%B8%B0-%EC%9C%84%ED%95%9C-pn-%EB%A3%B0&quot;&gt;뱅크샐러드에서 만든 룰&lt;/a&gt;을 통해 리뷰 코멘트의 &lt;b&gt;중요도를 명확히 표현&lt;/b&gt;하기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;해당 룰은 우테코 프리코스를 할 때 처음 사용해보았는데 커뮤니케이션 비용이 많이 줄었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;뱅샐 블로그 글을 보면 아래와 같이 쓰여 있다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-style=&quot;style3&quot;&gt;커뮤니케이션 비용을 줄이기 위한 Pn 룰&lt;br /&gt;&lt;br /&gt;뱅크샐러드 기술 조직은 코드 리뷰의 코멘트에 Pn 룰을 사용하여 리뷰어가 코멘트를 강조하고 싶은 정도를 표현합니다.&lt;br /&gt;&lt;br /&gt;P1: 꼭 반영해주세요 (Request changes)&lt;br /&gt;리뷰어는 PR의 내용이 서비스에 중대한 오류를 발생할 수 있는 가능성을 잠재하고 있는 등 중대한 코드 수정이 반드시 필요하다고 판단되는 경우, P1 태그를 통해 리뷰 요청자에게 수정을 요청합니다. 리뷰 요청자는 p1 태그에 대해 리뷰어의 요청을 반영하거나, 반영할 수 없는 합리적인 의견을 통해 리뷰어를 설득할 수 있어야 합니다.&lt;br /&gt;&lt;br /&gt;P2: 적극적으로 고려해주세요 (Request changes)&lt;br /&gt;작성자는 P2에 대해 수용하거나 만약 수용할 수 없는 상황이라면 적합한 의견을 들어 토론할 것을 권장합니다.&lt;br /&gt;&lt;br /&gt;P3: 웬만하면 반영해 주세요 (Comment)&lt;br /&gt;작성자는 P3에 대해 수용하거나 만약 수용할 수 없는 상황이라면 반영할 수 없는 이유를 들어 설명하거나 다음에 반영할 계획을 명시적으로(JIRA 티켓 등으로) 표현할 것을 권장합니다. Request changes 가 아닌 Comment 와 함께 사용됩니다.&lt;br /&gt;&lt;br /&gt;P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)&lt;br /&gt;작성자는 P4에 대해서는 아무런 의견을 달지 않고 무시해도 괜찮습니다. 해당 의견을 반영하는 게 좋을지 고민해 보는 정도면 충분합니다.&lt;br /&gt;&lt;br /&gt;P5: 그냥 사소한 의견입니다 (Approve)&lt;br /&gt;작성자는 P5에 대해 아무런 의견을 달지 않고 무시해도 괜찮습니다.&lt;br /&gt;FYI) Pn 룰은 뱅크샐러드에서&amp;nbsp;성장하는 회사에서 커뮤니케이션은 가장 큰 비용이라는 문제의식에서 이를 해결하기 위한 문화를 배경으로 탄생했습니다. Pn 룰은 코드 리뷰 외에도 슬랙 등 텍스트로 커뮤니케이션을 하는 곳에서도 활용하고 있습니다&lt;/blockquote&gt;
&lt;p data-end=&quot;1400&quot; data-start=&quot;1367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mL3GW/dJMcafZt0gi/3daUJPw8YAptVWFFbBGPPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mL3GW/dJMcafZt0gi/3daUJPw8YAptVWFFbBGPPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mL3GW/dJMcafZt0gi/3daUJPw8YAptVWFFbBGPPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmL3GW%2FdJMcafZt0gi%2F3daUJPw8YAptVWFFbBGPPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;648&quot; height=&quot;174&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1598&quot; data-start=&quot;1518&quot; data-ke-size=&quot;size16&quot;&gt;위와 같이 p3면 comment이기 때문에&amp;nbsp; 반영할 수 없거나 다음에 반영할 계획을 명시적으로 표현을 하면된다.&lt;/p&gt;
&lt;p data-end=&quot;1598&quot; data-start=&quot;1518&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1598&quot; data-start=&quot;1518&quot; data-ke-size=&quot;size16&quot;&gt;확실히 이렇게 앞에 pn을 작성하면&lt;/p&gt;
&lt;p data-end=&quot;1598&quot; data-start=&quot;1518&quot; data-ke-size=&quot;size16&quot;&gt;불필요한 감정 소모 감소가 감소하고 논의 포인트가 명확해진다. 또한 리뷰어도 &amp;ldquo;어디까지 개입해야 하는지&amp;rdquo; 판단이 쉬워진다.&lt;/p&gt;
&lt;hr data-end=&quot;1673&quot; data-start=&quot;1670&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1712&quot; data-start=&quot;1675&quot; data-ke-size=&quot;size23&quot;&gt;2) PR의 시급성을 명확히 드러내다 (D-n 룰)&lt;/h3&gt;
&lt;p data-end=&quot;1744&quot; data-start=&quot;1714&quot; data-ke-size=&quot;size16&quot;&gt;이 룰을 도입하기 전에는 코드 리뷰를 시간 순서대로 하거나 변경이 적은 PR 부터 했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1744&quot; data-start=&quot;1714&quot; data-ke-size=&quot;size16&quot;&gt;얼마나 시급한지를 단순히 PR 목록만 봐서는 알 수 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1744&quot; data-start=&quot;1714&quot; data-ke-size=&quot;size16&quot;&gt;엄청 시급하면 해당 크루가 구두로 말하거나&amp;nbsp; 전체를 태그해서 빠르게 코드리뷰를 요청하는 경우가 종종 발생했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;406&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kQ1NU/dJMcaaYbqFi/D0kqWqTq3F2VckdoCJK781/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kQ1NU/dJMcaaYbqFi/D0kqWqTq3F2VckdoCJK781/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kQ1NU/dJMcaaYbqFi/D0kqWqTq3F2VckdoCJK781/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkQ1NU%2FdJMcaaYbqFi%2FD0kqWqTq3F2VckdoCJK781%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;611&quot; height=&quot;206&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;406&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 어떻게 해결하지 하다가 D-n룰이란걸 알게되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;D-n 룰은 리뷰를 요청하는 시점에 PR이 Merge 되어야 하는 일정을 공유하여 리뷰어가 Working day 안에서 스스로 우선순위를 결정한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bopsX7/dJMcahwexkj/GrNI9wPKTCvLLkbcTgZ1Mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bopsX7/dJMcahwexkj/GrNI9wPKTCvLLkbcTgZ1Mk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bopsX7/dJMcahwexkj/GrNI9wPKTCvLLkbcTgZ1Mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbopsX7%2FdJMcahwexkj%2FGrNI9wPKTCvLLkbcTgZ1Mk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;774&quot; height=&quot;224&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;434&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1756&quot; data-start=&quot;1746&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1756&quot; data-start=&quot;1746&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 16px; letter-spacing: 0px;&quot;&gt;이 규칙의 핵심은 &amp;ldquo;압박&amp;rdquo;이 아니라 &lt;/span&gt;&lt;b&gt;정렬&lt;/b&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 16px; letter-spacing: 0px;&quot;&gt;이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1756&quot; data-start=&quot;1746&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1756&quot; data-start=&quot;1746&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 16px; letter-spacing: 0px;&quot;&gt;리뷰어가 맥락을 읽기 전에 &lt;/span&gt;&lt;b&gt;이 PR을 언제까지 보면 되는지&lt;/b&gt; 바로 알 수 있도록 하는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size16&quot;&gt;확실히 이를 도입하고 나서 우선순위에 따라 더 빠르게 처리할 수 있었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;D-n 라벨 자동 조정&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size16&quot;&gt;그런데 며칠 지나니, 작은 문제가 보이기 시작했다.&lt;br /&gt;&lt;b&gt;라벨은 시간이 지나도 그대로 고정&lt;/b&gt;된다는 점이다.&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;349&quot; data-start=&quot;200&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 D-5를 붙여두면, 5일이 지나도 여전히 D-5다.&lt;br /&gt;리뷰어는 결국 PR 생성일을 다시 보고 &amp;ldquo;오늘 기준으로 며칠 남았지?&amp;rdquo;를 계산해야 했다.&lt;/p&gt;
&lt;p data-end=&quot;382&quot; data-start=&quot;351&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;506&quot; data-start=&quot;384&quot; data-ke-size=&quot;size16&quot;&gt;이 불편함을 그냥 남겨놓으면 D-n룰을 잘 활용하지 않을 것 같았고 또 다른 불편함을 초래할 것 같았다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;내가 룰을 도입했으니, &lt;b&gt;끝까지 쓰이게 만드는 것&lt;/b&gt;도 내 몫이라고 봤다.&lt;br /&gt;그래서 라벨을 사람이 관리하지 않도록, &lt;b&gt;자동으로 굴러가게&lt;/b&gt; 만들기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;506&quot; data-start=&quot;384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;594&quot; data-start=&quot;508&quot; data-ke-size=&quot;size16&quot;&gt;어떻게 하면 할 수 있을까 찾아보다가 GitHub Actions에서 스케줄러를 지원하는 것을 알게되었다.&lt;br /&gt;GitHub Actions는 cron 기반 실행을 지원하니, 매일 아침 10시에 워크플로우를 돌려서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;680&quot; data-start=&quot;596&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;617&quot; data-start=&quot;596&quot;&gt;열려 있는 PR 목록을 조회하고&lt;/li&gt;
&lt;li data-end=&quot;650&quot; data-start=&quot;618&quot;&gt;D-n 라벨을 &lt;b&gt;하루마다 -1씩 자동 조정&lt;/b&gt;하고&lt;/li&gt;
&lt;li data-end=&quot;680&quot; data-start=&quot;651&quot;&gt;&lt;b&gt;D-0에 도달하면 디스코드로 알림&lt;/b&gt;을 보낸다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wqAqe/dJMcab3R2k0/gKRbp1AxEWKP1Ke5kQbYPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wqAqe/dJMcab3R2k0/gKRbp1AxEWKP1Ke5kQbYPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wqAqe/dJMcab3R2k0/gKRbp1AxEWKP1Ke5kQbYPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwqAqe%2FdJMcab3R2k0%2FgKRbp1AxEWKP1Ke5kQbYPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;549&quot; height=&quot;330&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;736&quot; data-ke-size=&quot;size16&quot;&gt;그런데 여기에 디테일을 조금 더 넣었다.&lt;/p&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;736&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;736&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;D-0 알림은 &amp;ldquo;그냥 PR 링크 던지기&amp;rdquo;에서 끝내지 않았다.&lt;/b&gt;&lt;br /&gt;D-0이 된 PR마다 리뷰 상태를 다시 조회해서, 지금 누구를 깨워야 하는지를 자동으로 판단하게 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1287&quot; data-start=&quot;920&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1249&quot; data-start=&quot;1035&quot;&gt;승인 수(approvedCount)와 수정 요청 여부(hasChangesRequested)를 기준으로 멘션 대상을 고른다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1249&quot; data-start=&quot;1112&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1150&quot; data-start=&quot;1112&quot;&gt;&lt;b&gt;수정 요청이 있으면&lt;/b&gt; 작성자를 먼저 멘션(수정이 필요하니까)&lt;/li&gt;
&lt;li data-end=&quot;1206&quot; data-start=&quot;1153&quot;&gt;&lt;b&gt;승인 2명 미만이면&lt;/b&gt; 아직 승인하지 않은 리뷰어들을 멘션(단, 작성자는 후보에서 제외)&lt;/li&gt;
&lt;li data-end=&quot;1249&quot; data-start=&quot;1209&quot;&gt;&lt;b&gt;승인 2명 이상이면&lt;/b&gt; 작성자를 멘션(이제 merge가 가능하니까)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1287&quot; data-start=&quot;1250&quot;&gt;멘션은 중복 호출이 생기지 않도록 Set으로 한번 더 정리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;736&quot; data-ke-size=&quot;size16&quot;&gt;이제 리뷰어는 PR을 열어보기 전에, 라벨만 보고 &amp;ldquo;오늘 처리할 건지&amp;rdquo;를 바로 판단한다.&lt;br /&gt;남은 기간을 계산하기 위해 누군가가 기억하고 챙길 필요도 없다.&lt;/p&gt;
&lt;p data-end=&quot;1877&quot; data-start=&quot;1853&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1877&quot; data-start=&quot;1853&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사람의 기억 대신, 시스템에 맡겼다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1904&quot; data-start=&quot;1848&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;1909&quot; data-start=&quot;1906&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1941&quot; data-start=&quot;1911&quot; data-ke-size=&quot;size23&quot;&gt;3) 맥락을 남기는 PR&lt;/h3&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;코드 리뷰가 지연될 때 흔히 &amp;ldquo;PR 템플릿을 만들자&amp;rdquo;는 해결책이 나온다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;나도 처음엔 그 방법을 떠올렸다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;하지만 여러 번의 협업 경험을 돌아보니, 리뷰가 느려지는 핵심 원인은 템플릿 유무가 아니라&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;리뷰어가 PR을 이해하기 위해 치르는 &amp;lsquo;맥락 로딩 비용&amp;rsquo;&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;변경된 코드는 있는데 &amp;ldquo;왜 바꿨는지, 어떤 대안을 고민했는지, 어떻게 검증했는지, 비즈니스 요구사항이 뭔지&amp;rdquo;가 PR에 남아 있지 않으면&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;리뷰는 코드 컨벤션이나 코드 구조에 대해서만 리뷰하게 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;전체 그림으로 리뷰를 하거나 딥하게 하기 위해서는 직접 코드를 작성한 사람에게 질문을 하거나 PR외 추가적으로 정보를 찾는 비용이 발생된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;이런식으로 논의가 길어지고, 정보를 찾는데 시간이 걸리기에 리뷰가 뒤로 밀리며, PR 처리 속도가 느려지기 시작한다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;그래서 형식적인 PR 템플릿을 강제하는 대신, PR을 리뷰할 사람이 따로 찾아봐야하는 일이 거의 없도록 상세하게 쓰는 습관을 팀에 정착시키는 방향을 선택했다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;얼마전 향로님 블로그를 보다가 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이와 관련된&lt;/span&gt;&amp;nbsp;코멘트를 발견했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1474&quot; data-origin-height=&quot;786&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWwVIO/dJMb99SvNoj/MApBoghtIGgf6RkRp3UqJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWwVIO/dJMb99SvNoj/MApBoghtIGgf6RkRp3UqJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWwVIO/dJMb99SvNoj/MApBoghtIGgf6RkRp3UqJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWwVIO%2FdJMb99SvNoj%2FMApBoghtIGgf6RkRp3UqJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;579&quot; height=&quot;309&quot; data-origin-width=&quot;1474&quot; data-origin-height=&quot;786&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;요즘들어 개선되고 있기는 한데 우리팀에 있어서 조금 부족한 부분이었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;사실 PR을 자세히 쓰는것은 상당한 비용이 들어가고 귀찮기도 하다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;하지만 나로인해 리뷰어들은 시간을 엄청 세이브 할 수 있고 코드 리뷰의 질도 높일 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;그 결과 품질이 높은 코드로 이어질 확률이 높고 이는 결국 유지보수성을 올려주고 버그를 줄여줄 수 있는 효과를 가져올 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1616&quot; data-origin-height=&quot;1208&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpPzke/dJMcaiaQmsD/So7XF8HgLj2jgZ11pAe22K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpPzke/dJMcaiaQmsD/So7XF8HgLj2jgZ11pAe22K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpPzke/dJMcaiaQmsD/So7XF8HgLj2jgZ11pAe22K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpPzke%2FdJMcaiaQmsD%2FSo7XF8HgLj2jgZ11pAe22K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;607&quot; height=&quot;454&quot; data-origin-width=&quot;1616&quot; data-origin-height=&quot;1208&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;여기에 추가적으로, 실제로 도움이 많이 되었던 것은 PR에 &lt;b&gt;&amp;lsquo;리뷰 포인트&amp;rsquo;를 한 줄로 미리 열어두는 습관&lt;/b&gt;이었다. 예를 들어 &amp;ldquo;동시성 관점에서 이 변경이 안전한지 확인 부탁드립니다&amp;rdquo;, &amp;ldquo;이 선택(설계/쿼리/API 응답)이 팀 컨벤션에 맞는지 의견이 필요합니다&amp;rdquo;처럼, 리뷰어가 어디를 중점적으로 보면 좋을지 작성자가 먼저 제시하는 방식이다. 이 한 줄이 있으면 리뷰어는 PR을 열자마자 &amp;lsquo;읽는 방향&amp;rsquo;을 잡고, 코멘트는 스타일 지적에서 벗어나 의사결정&amp;middot;설계 논의로 이동한다. 결과적으로 리뷰 품질은 유지되면서도 속도는 훨씬 안정적으로 나왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x8mIy/dJMcaiomNJg/uXfOlEPo9YfrKM0YZeUKz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x8mIy/dJMcaiomNJg/uXfOlEPo9YfrKM0YZeUKz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x8mIy/dJMcaiomNJg/uXfOlEPo9YfrKM0YZeUKz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx8mIy%2FdJMcaiomNJg%2FuXfOlEPo9YfrKM0YZeUKz0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;718&quot; height=&quot;96&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;246&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;이 문화를 정착시키는 과정에서 내가 중요하게 본 건 &amp;ldquo;규칙을 공지하는 것&quot;도 있지만 던 신경쓴 것은&amp;nbsp;&lt;b&gt;운영으로 습관이 퍼지게 만드는 것&lt;/b&gt;이었다. 누군가에게 요구하기 전에 내가 먼저 모든 PR에서 맥락을 남기고, 리뷰 포인트를 적고, 좋은 사례가 나오면 &amp;ldquo;이 PR은 맥락이 좋아서 리뷰가 쉬웠다&amp;rdquo;처럼 공개적으로 피드백했다.&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;586&quot; data-start=&quot;495&quot; data-ke-size=&quot;size16&quot;&gt;반대로 부담을 늘리는 방식은 피했다. 완벽한 문서를 쓰게 하는 게 목표가 아니라, &amp;ldquo;리뷰어가 다시 묻지 않게 만들 정도의 설명&amp;rdquo;만 남기면 충분하다고 계속 강조했다. 그 결과, 반복 질문이 줄고 리뷰가 미뤄지는 빈도가 감소했으며 평균 리뷰 리드타임이 3&lt;b&gt;~6일에서 1~2일 이내&lt;/b&gt;로 단축됐다. 무엇보다 리뷰가 &amp;lsquo;지적받는 자리&amp;rsquo;가 아니라 &amp;lsquo;생각을 맞추는 과정&amp;rsquo;으로 인식되기 시작했고, 이는 팀의 협업 피로도를 눈에 띄게 낮췄다.&lt;/p&gt;
&lt;hr data-end=&quot;2177&quot; data-start=&quot;2174&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2213&quot; data-start=&quot;2179&quot; data-ke-size=&quot;size23&quot;&gt;4) &amp;ldquo;알림&amp;rdquo;이 아니라 &amp;ldquo;주의(Attention)&amp;rdquo;를 운영하기&lt;/h3&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2370&quot; data-ke-size=&quot;size16&quot;&gt;리뷰 문화가 아무리 좋아져도, PR이 제때 보이지 않으면 결국 병목이 된다.&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2370&quot; data-ke-size=&quot;size16&quot;&gt;실제로 우리 팀에서 리뷰 누락&amp;middot;지연이 발생하던 이유 중 하나는 &amp;ldquo;사람들이 게을러서&amp;rdquo;가 아니라 &lt;b&gt;알림이 너무 많아 중요한 신호가 묻히는 구조&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2370&quot; data-ke-size=&quot;size16&quot;&gt;GitHub 알림은 기본적으로 촘촘하지만, PR이 늘어나면 이벤트가 쏟아지고(코멘트, 업데이트, 라벨, 머지 시도 등) 사람은 결국 노이즈를 무시하게 된다. 이 상태가 되면 가장 중요한 순간인 &amp;ldquo;리뷰 요청&amp;rdquo;조차 지나가 버린다.&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2370&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1534&quot; data-origin-height=&quot;1104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/POzrg/dJMcacItpus/j329ItbuwlNQlbkMkqObi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/POzrg/dJMcacItpus/j329ItbuwlNQlbkMkqObi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/POzrg/dJMcacItpus/j329ItbuwlNQlbkMkqObi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPOzrg%2FdJMcacItpus%2Fj329ItbuwlNQlbkMkqObi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;606&quot; height=&quot;436&quot; data-origin-width=&quot;1534&quot; data-origin-height=&quot;1104&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 쓰던 PR 알림은 너무 이벤트가 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 보니 어느순간 나조차도 이 알림을 무시하기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이렇게 알림이 많이 오는 걸까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깃허브가 제공해주는 웹훅 설정을 보면 그 이유를 알 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LsnhE/dJMcai9HIoA/lCrcskR93j7HjwsCHtTaok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LsnhE/dJMcai9HIoA/lCrcskR93j7HjwsCHtTaok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LsnhE/dJMcai9HIoA/lCrcskR93j7HjwsCHtTaok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLsnhE%2FdJMcai9HIoA%2FlCrcskR93j7HjwsCHtTaok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;554&quot; height=&quot;307&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR관련 거의 모든 이벤트를 감지해서 알림을 보내게된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 Pull request reviews, pull request review comments 같은게 있긴한데 필요한 많은 부분이 pull requests에 선물꾸러미처럼 한꺼번에 다 담겨있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 우리 입맛에 맞게 커스터마이징을 해야한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 질문은 단순했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;513&quot; data-start=&quot;481&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;513&quot; data-start=&quot;483&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;팀이 지금 당장 주의를 써야 하는 순간은 언제인가?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;564&quot; data-start=&quot;515&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;564&quot; data-start=&quot;515&quot; data-ke-size=&quot;size16&quot;&gt;나는 GitHub의 수많은 이벤트 중에서, 리뷰 흐름을 실제로 움직이는 이벤트만 남겼다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;713&quot; data-start=&quot;566&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;604&quot; data-start=&quot;566&quot;&gt;&lt;b&gt;PR Open&lt;/b&gt;: 새 작업이 시작됐음을 알려야 하는 순간&lt;/li&gt;
&lt;li data-end=&quot;663&quot; data-start=&quot;605&quot;&gt;&lt;b&gt;Review Request / Re-request&lt;/b&gt;: 누군가의 행동(리뷰)이 필요하다는 신호&lt;/li&gt;
&lt;li data-end=&quot;713&quot; data-start=&quot;664&quot;&gt;&lt;b&gt;Approve&lt;/b&gt;: 병목이 풀렸고 다음 단계(머지/배포)로 넘어갈 수 있다는 신호&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이 이벤트들을 기준으로 Discord 알림을 &lt;b&gt;상황별로 다르게&lt;/b&gt; 설계했다.&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1) PR Open&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;444&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b594R7/dJMcahbVRXr/JoUPWFScBZaDdEk2c5IOZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b594R7/dJMcahbVRXr/JoUPWFScBZaDdEk2c5IOZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b594R7/dJMcahbVRXr/JoUPWFScBZaDdEk2c5IOZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb594R7%2FdJMcahbVRXr%2FJoUPWFScBZaDdEk2c5IOZ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;415&quot; height=&quot;198&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;444&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;PR 생성에서는 리뷰어를 멘션을 걸고 제목, 작성자 D-n룰만 보이도록 적용했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;그래서 리뷰어가 제목으로 어떤 pr인지 알 수 있고 D-n룰을 보고 시급성을 알 수 있도록 했다.&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 내용도 포함하려고 했으나 길어져서 다른 알림이 묻힐 수 도 있다고 생각해서 과감하게 제거했다.&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;715&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2) PR Request&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;354&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z2cc9/dJMb99LJGx8/ag4itgQfcsMNN1ultWR0D1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z2cc9/dJMb99LJGx8/ag4itgQfcsMNN1ultWR0D1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z2cc9/dJMb99LJGx8/ag4itgQfcsMNN1ultWR0D1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz2cc9%2FdJMb99LJGx8%2Fag4itgQfcsMNN1ultWR0D1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;551&quot; height=&quot;196&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰 재요청을 재요청을 받을 당사자면 멘션되게 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3) Request Change&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰 이벤트 중에서 가장 &lt;b&gt;우선순위가 높은 알림&lt;/b&gt;은 Request changes였다.&lt;br /&gt;이건 단순한 진행 상황 공유가 아니라, &lt;b&gt;작성자가 다음 액션을 해야 PR이 앞으로 갈 수 있는 상태&lt;/b&gt;가 되기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;428&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRHWwA/dJMcafZt8XN/rP1fFcYbiWEetVUNbpZU9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRHWwA/dJMcafZt8XN/rP1fFcYbiWEetVUNbpZU9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRHWwA/dJMcafZt8XN/rP1fFcYbiWEetVUNbpZU9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRHWwA%2FdJMcafZt8XN%2FrP1fFcYbiWEetVUNbpZU9k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;445&quot; height=&quot;222&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;428&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Request changes 알림은 다음 원칙으로 설계했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;830&quot; data-start=&quot;362&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;487&quot; data-start=&quot;362&quot;&gt;&lt;b&gt;멘션 대상은 PR 작성자(Author)만&lt;/b&gt;&lt;br /&gt;리뷰어 전체를 흔드는 순간 노이즈가 되기 쉽다.&lt;br /&gt;&amp;ldquo;이 PR은 작성자가 수정해야 진행된다&amp;rdquo;라는 메시지이기 때문에, 작성자만 콕 집어 멘션하는 편이 효과가 컸다.&lt;/li&gt;
&lt;li data-end=&quot;597&quot; data-start=&quot;488&quot;&gt;&lt;b&gt;알림 본문은 &amp;lsquo;한 번에 파악&amp;rsquo; 가능하게&lt;/b&gt;&lt;br /&gt;PR 링크/제목 + 변경 요청자 + 핵심 코멘트 위치(리뷰 링크) 정도만 넣고,&lt;br /&gt;&amp;ldquo;지금 뭐 해야 하지?&amp;rdquo;가 바로 보이도록 만들었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4) Approve&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vWOwQ/dJMcaajzI38/UqH3kdcUeJkH7bW1mJwBA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vWOwQ/dJMcaajzI38/UqH3kdcUeJkH7bW1mJwBA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vWOwQ/dJMcaajzI38/UqH3kdcUeJkH7bW1mJwBA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvWOwQ%2FdJMcaajzI38%2FUqH3kdcUeJkH7bW1mJwBA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;428&quot; height=&quot;207&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Approve 알림은 반대로, 너무 자주 울리면 가치가 급격히 떨어졌다.&lt;br /&gt;한 PR에 승인 리뷰가 여러 번 달릴 수 있고, 팀 규모가 커질수록 &amp;ldquo;승인 알림 폭탄&amp;rdquo;이 되기 쉽다.&lt;/p&gt;
&lt;p data-end=&quot;1239&quot; data-start=&quot;1195&quot; data-ke-size=&quot;size16&quot;&gt;그래서 승인 알림은 내부 기준으로 merge 가능한 숫자에 도달하면 알림을 보내도록 설정했다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;920&quot; data-start=&quot;814&quot; data-ke-size=&quot;size16&quot;&gt;이 작업은 단순한 자동화가 아니었다.&lt;br /&gt;나는 알림을 &amp;ldquo;정보를 전달하는 기능&amp;rdquo;이 아니라, &lt;b&gt;팀의 주의를 어디에 쓰게 할지 결정하는 운영 설계&lt;/b&gt;로 봤다.&lt;/p&gt;
&lt;p data-end=&quot;300&quot; data-start=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;이 구조가 자리 잡으면서 리뷰 요청이 묻히거나 지나가는 일이 줄었고, &amp;ldquo;봤는데 놓쳤다&amp;rdquo; 같은 상황도 눈에 띄게 감소했다. 리뷰가 더 빨라졌다는 사실보다, &lt;b&gt;리뷰가 안정적으로 돌아가기 시작했다&lt;/b&gt;는 게 더 큰 변화였다.&lt;/p&gt;
&lt;p data-end=&quot;300&quot; data-start=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;465&quot; data-start=&quot;302&quot; data-ke-size=&quot;size16&quot;&gt;이번 개선의 핵심은 자동화나 규칙 그 자체가 아니라, 팀의 시간을 덜 낭비하게 만드는 방식이었다. 맥락이 남는 PR은 리뷰어의 추측을 줄이고, 이벤트 기반 알림은 팀의 주의를 흩뜨리지 않는다. 결국 협업 문제는 &amp;ldquo;누가 더 잘하느냐&amp;rdquo;보다, &lt;b&gt;덜 흔들리게 만드는 구조가 있느냐&lt;/b&gt;에 더 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;465&quot; data-start=&quot;302&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;576&quot; data-start=&quot;467&quot; data-ke-size=&quot;size16&quot;&gt;나는 앞으로도 개인의 역량으로 버티는 방식보다, 팀이 지속 가능하게 굴러가는 시스템을 설계하는 쪽에 집중하려 한다. 리뷰가 편해지면 속도는 따라오고, 속도가 안정되면 품질을 논의할 여유가 생긴다.&lt;/p&gt;
&lt;p data-end=&quot;576&quot; data-start=&quot;467&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;707&quot; data-start=&quot;578&quot; data-ke-size=&quot;size16&quot;&gt;그리고 팀 규모가 더 커진다면, 한 리뷰에 모든 사람이 매달리지 않도록 &lt;b&gt;리뷰 참여를 설계하는 방식&lt;/b&gt;도 고민해보고 싶다. 메타가 공유한 &amp;ldquo;&lt;a href=&quot;https://engineering.fb.com/2022/11/16/culture/meta-code-review-time-improving&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;코드 리뷰 시간을 개선한 방식&lt;/a&gt;&amp;rdquo;은 그 다음 단계에서 충분히 참고할 만한 사례라고 생각한다.&lt;/p&gt;
&lt;p data-end=&quot;920&quot; data-start=&quot;814&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;920&quot; data-start=&quot;814&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;920&quot; data-start=&quot;814&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Reference&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://toss.tech/article/25431&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub&amp;nbsp;Actions로&amp;nbsp;개선하는&amp;nbsp;코드&amp;nbsp;리뷰&amp;nbsp;문화&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.banksalad.com/tech/banksalad-code-review-culture/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;코드&amp;nbsp;리뷰&amp;nbsp;in&amp;nbsp;뱅크샐러드&amp;nbsp;개발&amp;nbsp;문화&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://techblog.woowahan.com/7152/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공통시스템개발팀&amp;nbsp;코드&amp;nbsp;리뷰&amp;nbsp;문화&amp;nbsp;개선&amp;nbsp;이야기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://engineering.fb.com/2022/11/16/culture/meta-code-review-time-improving/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Move&amp;nbsp;faster,&amp;nbsp;wait&amp;nbsp;less:&amp;nbsp;Improving&amp;nbsp;code&amp;nbsp;review&amp;nbsp;time&amp;nbsp;at&amp;nbsp;Meta&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <category>리뷰</category>
      <category>코드리뷰</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/772</guid>
      <comments>https://html-jc.tistory.com/772#entry772comment</comments>
      <pubDate>Tue, 23 Dec 2025 09:44:13 +0900</pubDate>
    </item>
    <item>
      <title>이메일 수신 서버 구축 및 뉴스레터 적재 파이프라인 고도화</title>
      <link>https://html-jc.tistory.com/771</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnDVMa/dJMcadN0Fsz/A5npg7LP7N2J8OzY3GkQ60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnDVMa/dJMcadN0Fsz/A5npg7LP7N2J8OzY3GkQ60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnDVMa/dJMcadN0Fsz/A5npg7LP7N2J8OzY3GkQ60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnDVMa%2FdJMcadN0Fsz%2FA5npg7LP7N2J8OzY3GkQ60%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;632&quot; height=&quot;303&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노션에 정리해놓았다가 드디어 블로그로 옮기는 작업을 하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 쓰는 글을 우리 서비스의 핵심기능인 이메일 서버관련 글이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 우리 서비스가 모르시는 분이 있을 수도 있는데 이 서비스를 만든 가장 큰 이유 중 하나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 페인포인트는 여러 메일이 섞인다는 것이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 예시를 보자&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2256&quot; data-origin-height=&quot;646&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8Xt7m/dJMcafE54Wo/9qYRV5LaKIDW3F1aRYCTHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8Xt7m/dJMcafE54Wo/9qYRV5LaKIDW3F1aRYCTHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8Xt7m/dJMcafE54Wo/9qYRV5LaKIDW3F1aRYCTHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8Xt7m%2FdJMcafE54Wo%2F9qYRV5LaKIDW3F1aRYCTHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;206&quot; data-origin-width=&quot;2256&quot; data-origin-height=&quot;646&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 이미지는 제 받은메일함입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 종류를 보면 결제 내역, 뉴스레터, facebook, github 등등 뒤죽박죽 섞여있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 정리하기 위해 메일함을 만들어서 분류하기도 귀찮습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 뉴스레터 전용 이메일을 만든다? 뉴스레터 이메일 갔다가 다시 원래 이메일 갔다가 하기가 번거롭다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2676&quot; data-origin-height=&quot;1890&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWrJSz/dJMcafLSFDZ/7mLr6HM5RFvxVBZFAtc5tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWrJSz/dJMcafLSFDZ/7mLr6HM5RFvxVBZFAtc5tk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWrJSz/dJMcafLSFDZ/7mLr6HM5RFvxVBZFAtc5tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWrJSz%2FdJMcafLSFDZ%2F7mLr6HM5RFvxVBZFAtc5tk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;650&quot; height=&quot;459&quot; data-origin-width=&quot;2676&quot; data-origin-height=&quot;1890&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 서비스를 보면 이렇게 이메일이 도착하면 오늘의 뉴스레터 페이지에서 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 화면을 보여주면 크루들은 이렇게 말한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 어디서 긁어오는거야?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;크롤링 해도 괜찮아??&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 이렇게 대답한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 이메일 서버를 통해서 직접 이메일 받을 거야!!&quot;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이제부터 이메일서버가 왜 필요했는지? 어떻게 구축했는지에 대해서 알아보자 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;338&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTIehO/dJMcac2Edx2/erTrESyOUm9dt25ELl4aek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTIehO/dJMcac2Edx2/erTrESyOUm9dt25ELl4aek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTIehO/dJMcac2Edx2/erTrESyOUm9dt25ELl4aek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTIehO%2FdJMcac2Edx2%2FerTrESyOUm9dt25ELl4aek%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;430&quot; height=&quot;242&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스레터는 크롤링 같은걸 해오는게 아니다 이것도 하나의 저작권으로 인정되기 때문에 우리가 함부로 가져올 수가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/907zN/dJMcaaju7GC/M0gYtZFVaD2r4i0AYdk2T0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/907zN/dJMcaaju7GC/M0gYtZFVaD2r4i0AYdk2T0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/907zN/dJMcaaju7GC/M0gYtZFVaD2r4i0AYdk2T0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F907zN%2FdJMcaaju7GC%2FM0gYtZFVaD2r4i0AYdk2T0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;790&quot; height=&quot;202&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 그대로 보여주기 때문에 공정사용에 해당하지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 누구나와서 볼 수 있게 하면 불법이 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오직 뉴스레터를 신청한 당사자만 메일을 통해서 볼 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기에 우리서비스는 로그인이 필수이고 이메일을 받을 수 있어야 했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 이메일은 이렇게 더럽다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 너무 불편했다.&amp;nbsp; 또 가끔은 이렇게 스팸메일함에 들어가있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;354&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bA5Ae9/dJMb99Sp9aP/qsDChjjAjoKb8evKk0kLL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bA5Ae9/dJMb99Sp9aP/qsDChjjAjoKb8evKk0kLL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bA5Ae9/dJMb99Sp9aP/qsDChjjAjoKb8evKk0kLL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbA5Ae9%2FdJMb99Sp9aP%2FqsDChjjAjoKb8evKk0kLL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;562&quot; height=&quot;119&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지메일 같은 경우는 아래의 설정을 해줘 스팸으로 오지 않는다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1558&quot; data-origin-height=&quot;1716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bB3z0C/dJMcaihwmX6/y5brGcAI4V7bmkJzZ5dka0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bB3z0C/dJMcaihwmX6/y5brGcAI4V7bmkJzZ5dka0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bB3z0C/dJMcaihwmX6/y5brGcAI4V7bmkJzZ5dka0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbB3z0C%2FdJMcaihwmX6%2Fy5brGcAI4V7bmkJzZ5dka0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;459&quot; height=&quot;506&quot; data-origin-width=&quot;1558&quot; data-origin-height=&quot;1716&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 뉴스레터만 받을 수 있는 앱이 있으면 어떨까 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하려면 이메일을 받아야했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫 번째 시도: Gmail API로 뉴스레터 긁어오기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 했던 작은 사이드 프로젝트에서는 인프라 구축에 익숙하지 않았고 여러가지 방법도 잘 찾아보지 않았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 당시 조사했던 방법은 딱 두 가지였다. Gmail API를 통해 활용하는 것 이메일 서버를 구축하는 것&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 당시 인프라 구축에 익숙하지 않았고 비용을 최대한 아끼기 위해 돈의 거의 들지 않는 Gmail API로 부턱대고 시작했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google Gmail API를 이용해서 다음과 같은 구조로 만들었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 유저가 OAuth로 우리 서비스에 Gmail 접근 권한을 준다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 우리가 유저의 Gmail에 들어가서 뉴스레터 메일만 골라 읽고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 그 내용을 파싱해서 DB에 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 꽤 그럴듯해 보였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 기존 이메일 주소를 그대로 쓰면 되고 우리는 권한만 받아서 필요한 뉴스레터만 읽으면 되니 별도 메일 서버를 운영하지 않다도 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로도 잘 동작했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 엄청난 문제가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신규회원가입을 하려면 화면이 아래와 같이 뜬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1412&quot; data-origin-height=&quot;746&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVmmsC/dJMcagqulSK/U7HkcXK817S2YI2tgQAt21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVmmsC/dJMcagqulSK/U7HkcXK817S2YI2tgQAt21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVmmsC/dJMcagqulSK/U7HkcXK817S2YI2tgQAt21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVmmsC%2FdJMcagqulSK%2FU7HkcXK817S2YI2tgQAt21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;741&quot; height=&quot;391&quot; data-origin-width=&quot;1412&quot; data-origin-height=&quot;746&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 바로 CASA다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Gmail 본문을 읽으려면 넘어야 하는 벽, CASA&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글은 요즘, Gmail 같은 &lt;b&gt;민감한 사용자 데이터&lt;/b&gt;에 접근하는 앱에 대해&lt;br /&gt;보안 검증을 굉장히 강하게 요구하고 있다.&lt;/p&gt;
&lt;p data-end=&quot;1230&quot; data-start=&quot;1164&quot; data-ke-size=&quot;size16&quot;&gt;그 중심에 있는 제도가 바로 CASA (Cloud Application Security Assessment)다.&lt;/p&gt;
&lt;p data-end=&quot;1244&quot; data-start=&quot;1232&quot; data-ke-size=&quot;size16&quot;&gt;요약하면 이런 제도다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;너희 앱이 사용자 데이터를 안전하게 다루는지,&lt;br /&gt;제3의 보안 기관이 공식적으로 확인해주는 인증 제도&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;b&gt;Gmail 본문&lt;/b&gt;을 읽는 권한 같은 걸 쓰려면,&lt;br /&gt;그냥 &amp;ldquo;우리가 잘 할게요&amp;rdquo; 수준으로는 안 되고,&lt;br /&gt;&lt;b&gt;Tier 2 이상의 CASA 인증&lt;/b&gt;을 요구한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1429&quot; data-start=&quot;1410&quot; data-ke-size=&quot;size23&quot;&gt;CASA Tier 간단 정리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1586&quot; data-start=&quot;1431&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1482&quot; data-start=&quot;1431&quot;&gt;&lt;b&gt;Tier 1&lt;/b&gt; &amp;ndash; 개발자가 체크리스트를 보고 &lt;b&gt;셀프 점검&lt;/b&gt;하는 단계 (무료)&lt;/li&gt;
&lt;li data-end=&quot;1532&quot; data-start=&quot;1483&quot;&gt;&lt;b&gt;Tier 2&lt;/b&gt; &amp;ndash; 공인 보안 실험실에서 &lt;b&gt;코드/구성 자동 분석 + 검증&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1586&quot; data-start=&quot;1533&quot;&gt;&lt;b&gt;Tier 3&lt;/b&gt; &amp;ndash; 인프라, 네트워크, 데이터 저장 방식까지 포함한 &lt;b&gt;풀 보안 감사&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 비용이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1667&quot; data-start=&quot;1599&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1613&quot; data-start=&quot;1599&quot;&gt;Tier 1: 무료&lt;/li&gt;
&lt;li data-end=&quot;1640&quot; data-start=&quot;1614&quot;&gt;Tier 2: &lt;b&gt;수백만원&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1667&quot; data-start=&quot;1641&quot;&gt;Tier 3: &lt;b&gt;수천만원&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 작은 팀이나 사이드 프로젝트 입장에서&lt;/p&gt;
&lt;blockquote data-end=&quot;1768&quot; data-start=&quot;1694&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1768&quot; data-start=&quot;1696&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;Gmail API로 메일 본문을 읽는 서비스&amp;rdquo;를 만들려면&lt;br /&gt;&lt;b&gt;CASA Tier 2 비용 때문에 사실상 불가능에 가깝다&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;라는 결론이 나온다.&lt;/p&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;그 당시에는 자체 CASA Tier 2를 받을 수 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;레퍼런스도 너무 부족해서 어디서부터 시작해야할지 감잡기도 어려웠고 링크드인에 어떤 현업자분이 인증받았다해서 연락을 드렸지만 회신이없었고 우리나름대로 여러가지 시도를 해보았지만 실패했었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1781&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;심지어 지금은 자체 스캔 프로세스는 지원하지않고 무조건 인증기관에 돈을 주고 받게 되어잇다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;1298&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bahmlK/dJMcajgnlLQ/KU6FA3TTDnxFDmxidIh5yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bahmlK/dJMcajgnlLQ/KU6FA3TTDnxFDmxidIh5yk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bahmlK/dJMcajgnlLQ/KU6FA3TTDnxFDmxidIh5yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbahmlK%2FdJMcajgnlLQ%2FKU6FA3TTDnxFDmxidIh5yk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;604&quot; height=&quot;429&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;1298&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;우리도 결국 이 문제 때문에, 정식 출시를 하지 못햇다.&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 기간도 거의 끝나갔고 아쉬움에 남았었다.&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;(혹시 지금 CASA Tier 2 인증을 받고 싶다면 &lt;a href=&quot;https://www.reddit.com/r/googlecloud/comments/1i1dgtm/our_experience_with_google_casa_tier_2/?tl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;를 참고하면 좋을 것 같다)&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1837&quot; data-start=&quot;1783&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;같은 실수를 반복하지 않기 위해: 이메일 수신 옵션들 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 우테코 팀프로젝트를 시작하고 뾰족한 대안이 없을 때 뉴스레터 관련 아이템을 제시했고 이전에 수많은 자료조사와 실패한 경을 토대로 팀원들을 설득했고 결국 이 프로젝트를 진행하게되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 아이템의 핵심은 이메일 도착이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아본 방법은 크게 네 가지 정도다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. AWS SES로 메일로 수신&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. SendGrid / Mailgun inbound API&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Cloudflare Email Routing&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 수신 전용 이메일 서버 직접 구축&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) AWS SES(Simple Email Service)로 이메일 받기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 검토한 선택지는 AWS SES로 메일을 받는 방식이다.&lt;br /&gt;news@mydomain.com 같은 주소로 온 메일을 SES가 대신 수신하고, 그 메일을 S3에 저장하거나 Lambda를 실행하거나 SNS로 이벤트를 발행하는 식으로 다른 AWS 리소스로 넘겨주는 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 AWS를 쓰고 있다면, 그림 자체는 꽤 깔끔하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;739&quot; data-start=&quot;435&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;518&quot; data-start=&quot;435&quot;&gt;&lt;b&gt;직접 메일 서버를 운영할 필요가 없다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;518&quot; data-start=&quot;464&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;518&quot; data-start=&quot;464&quot;&gt;Postfix, Dovecot 같은 걸 설치하고 운영/백업/보안 패치를 신경 쓸 필요가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;608&quot; data-start=&quot;519&quot;&gt;AWS가 &lt;b&gt;기본적인 스팸 필터링을 어느 정도 해준다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;608&quot; data-start=&quot;555&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;608&quot; data-start=&quot;555&quot;&gt;우리가 여러 이메일 관련 설정과 스팸 필터를 처음부터 다 짜는 것보다 부담이 적다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;739&quot; data-start=&quot;609&quot;&gt;&lt;b&gt;Lambda와 바로 붙일 수 있다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;739&quot; data-start=&quot;636&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;701&quot; data-start=&quot;636&quot;&gt;&amp;ldquo;메일 수신 &amp;rarr; Lambda 트리거 &amp;rarr; 본문 파싱 후 DB 저장&amp;rdquo; 같은 파이프라인을 서버 없이 구성할 수 있어서,&lt;/li&gt;
&lt;li data-end=&quot;739&quot; data-start=&quot;704&quot;&gt;초기에 빠르게 프로토타입을 만들기에는 꽤 매력적인 옵션이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;초기 작은 서비스이거나,&lt;br /&gt;&amp;ldquo;메일 수신량이 많지 않고, AWS 안에서만 빠르게 파이프라인을 구성하고 싶다&amp;rdquo;면&lt;br /&gt;실제로 도입을 충분히 고려해볼 만한 구조다.&lt;/p&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;하지만 우리 서비스 기준으로는, &lt;b&gt;치명적인 단점이 몇 가지 있었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;741&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;경제성 문제&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;SES는 발송뿐만 아니라 &lt;b&gt;수신도 건당 과금&lt;/b&gt;이 붙는다.&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SbQxA/dJMcadHf95g/iKUSL3fbafCCsme4KDgK4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SbQxA/dJMcadHf95g/iKUSL3fbafCCsme4KDgK4k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SbQxA/dJMcadHf95g/iKUSL3fbafCCsme4KDgK4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSbQxA%2FdJMcadHf95g%2FiKUSL3fbafCCsme4KDgK4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;641&quot; height=&quot;290&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1230&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;보수적으로 계산을 해보았다.&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;평균 1명당 4개의 뉴스레터를 구독하고 그 뉴스레터는 매일 도착한다고 해보자&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;그럼 회원수가 500명일 때 하루에 2000개의 메일을 수신해야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;여기에 청크 비용도 계산을 해야한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;도착하는 이메일은 뉴스레터고 거의 대부분이 HTML이다&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;HTML 평균 크기는 11.5KB로 계산했다. (8,000 ~ 15,000자 -&amp;gt; 평균 11,500자)&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;183&quot; data-start=&quot;151&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메일 크기 11.5KB 기준 청크 비용 계산&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;206&quot; data-start=&quot;185&quot; data-ke-size=&quot;size16&quot;&gt;✔ 청크 단위 = 256KB&lt;/p&gt;
&lt;p data-end=&quot;240&quot; data-start=&quot;207&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 11.5KB / 256KB = &lt;b&gt;0.04492 청크&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;255&quot; data-start=&quot;242&quot; data-ke-size=&quot;size16&quot;&gt;✔ 청크 비용&lt;/p&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;256&quot; data-ke-size=&quot;size16&quot;&gt;0.04492 &amp;times; 0.00009 USD = &lt;b&gt;0.000004042 USD / 건&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;319&quot; data-start=&quot;305&quot; data-ke-size=&quot;size16&quot;&gt;✔ 메시지 비용&lt;/p&gt;
&lt;p data-end=&quot;334&quot; data-start=&quot;320&quot; data-ke-size=&quot;size16&quot;&gt;0.0001 USD / 건&lt;/p&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;336&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;336&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 1건당 비용 : &lt;/b&gt;&lt;b&gt;0.0001 + 0.000004042 = 0.000104042 USD / 건&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 비용 예상&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;회원 수&lt;/td&gt;
&lt;td&gt;하루 수신량&lt;/td&gt;
&lt;td&gt;하루 비용(USD)&lt;/td&gt;
&lt;td&gt;월 비용(USD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;500명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2,000건&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0.208 USD&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;6.24 USD&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1,000명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;4,000건&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0.416 USD&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;12.48 USD&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;2,500명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;10,000건&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1.040 USD&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;31.20 USD&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;5,000명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;20,000건&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2.080 USD&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;62.40 USD&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;회원 수가 증가할 수록 비용이 선형 증가하게된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;중요한건 여기에 스팸처리 비용은 계산하지 않았다.&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;스팸 메일도 똑같이 비용이 든다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;947&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;뉴스레터 서비스 특성상,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1019&quot; data-start=&quot;949&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;964&quot; data-start=&quot;949&quot;&gt;사용자 수가 늘어나고&lt;/li&gt;
&lt;li data-end=&quot;988&quot; data-start=&quot;965&quot;&gt;구독하는 뉴스레터 종류가 많아질수록&lt;/li&gt;
&lt;li data-end=&quot;1019&quot; data-start=&quot;989&quot;&gt;하루에 들어오는 메일 수도 기하급수적으로 증가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1102&quot; data-start=&quot;1021&quot; data-ke-size=&quot;size16&quot;&gt;초기에야 &amp;ldquo;얼마 안 나오네?&amp;rdquo; 싶다가도,&lt;br /&gt;규모가 커졌을 때 고정 비용이 아닌 &amp;lsquo;트래픽 기반 비용&amp;rsquo;으로 계속 빠져나간다는 점이 큰 부담이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;커스터마이징의 제약&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;1187&quot; data-start=&quot;1129&quot; data-ke-size=&quot;size16&quot;&gt;SES를 쓰는 순간, 메일 수신 이후의 파이프라인이 &lt;b&gt;AWS 리소스 중심&lt;/b&gt;으로 고정되는 느낌이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1386&quot; data-start=&quot;1189&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1229&quot; data-start=&quot;1189&quot;&gt;Maildir 같은 전통적인 디렉터리 구조를 그대로 쓰기도 어렵고&lt;/li&gt;
&lt;li data-end=&quot;1301&quot; data-start=&quot;1230&quot;&gt;원본 .eml 파일을 어떻게 보관할지, 장기 보관 정책을 어떻게 가져갈지 등에서&lt;br /&gt;SES 설계에 맞춰서 생각해야 한다.&lt;/li&gt;
&lt;li data-end=&quot;1386&quot; data-start=&quot;1302&quot;&gt;인프라를 완전히 AWS 밖으로 빼거나,&lt;br /&gt;자체 메일 서버로 구조를 전환하고 싶어지는 시점이 왔을 때 &lt;b&gt;마이그레이션 비용&lt;/b&gt;도 고려해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스처럼&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1500&quot; data-start=&quot;1399&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1435&quot; data-start=&quot;1399&quot;&gt;장기적으로 &lt;b&gt;메일을 아주 많이 받게 될 가능성이 높고&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1456&quot; data-start=&quot;1436&quot;&gt;원본 메일을 그대로 쌓아두고,&lt;/li&gt;
&lt;li data-end=&quot;1500&quot; data-start=&quot;1457&quot;&gt;파이프라인과 저장 구조를 &lt;b&gt;우리가 원하는 대로 세밀하게 설계하고 싶다&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1518&quot; data-start=&quot;1502&quot; data-ke-size=&quot;size16&quot;&gt;라는 요구사항을 생각했을 때,&lt;/p&gt;
&lt;p data-end=&quot;1518&quot; data-start=&quot;1502&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;초기에는 편하지만, &lt;/span&gt;&lt;b&gt;규모가 커질수록 비용과 제약이 서서히 발목을 잡을 수 있는 선택지&lt;/b&gt;&amp;nbsp;라는 결론을 내렸다.&lt;/p&gt;
&lt;p data-end=&quot;1518&quot; data-start=&quot;1502&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1518&quot; data-start=&quot;1502&quot; data-ke-size=&quot;size16&quot;&gt;그래서 실제 도입 후보에서 완전히 제외한 것은 아니지만,&lt;br /&gt;프로토타입/소규모 서비스에는 좋지만,&lt;br /&gt;장기적으로 우리가 가고 싶은 아키텍처와는 방향이 다르다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2) SendGrid/Mailgun 수신 API&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; letter-spacing: 0px;&quot;&gt;한 마디로 하면 &amp;ldquo;메일을 직접 &amp;lsquo;받아&amp;rsquo; API로 전달해주는 서비스&amp;rdquo;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;SES 다음으로 눈이 간 건 &amp;ldquo;메일 수신을 SaaS에 맡기고, 우리는 HTTP로만 받자&amp;rdquo;는 접근이었다.&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;메일 프로토콜(MX/SMTP) 세계를 직접 다루지 않아도 되고, 우리 서버는 &lt;b&gt;웹훅 요청 하나만 처리&lt;/b&gt;하면 되니까 초반 생산성이 압도적으로 좋아 보였다.&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;180&quot; data-start=&quot;171&quot; data-ke-size=&quot;size16&quot;&gt;동작 방식&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;365&quot; data-start=&quot;182&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;273&quot; data-start=&quot;182&quot;&gt;발신자 &amp;rarr; (MX) &amp;rarr; &lt;b&gt;SendGrid/Mailgun 수신&lt;/b&gt; &amp;rarr; 메일 파싱 &amp;rarr; &lt;b&gt;우리 Webhook 엔드포인트로 POST&lt;/b&gt; &amp;rarr; 저장/파이프라인 실행&lt;/li&gt;
&lt;li data-end=&quot;365&quot; data-start=&quot;274&quot;&gt;SendGrid는 &lt;b&gt;Inbound Email Parse Webhook&lt;/b&gt; 형태로 제공한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;또 하나 마음에 들었던 포인트는, Webhook이 실패해도 &lt;b&gt;재시도 메커니즘이 있다는 점&lt;/b&gt;이었다. 예를 들어 SendGrid는 우리 엔드포인트가 5xx로 응답하면 큐잉 후 재시도하고, 2xx를 받으면 처리를 끝낸다(최대 3일 재시도 후 드랍)&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;우리 기준에서 걸렸던 지점들&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;1) 비용이 고정 + 제한 형태로 다가온다&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;SES가 &amp;ldquo;쓴 만큼&amp;rdquo;에 가깝다면, 이쪽은 &lt;b&gt;월 플랜(포함량 + 초과 과금)&lt;/b&gt; 성격이 강했다.&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;우리 서비스 가정(1인당 4개/일, 30일 기준)으로 &lt;b&gt;수신량만 단순 환산&lt;/b&gt;하면 대략 아래 정도다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;회원 수&lt;/td&gt;
&lt;td&gt;월 수신량(건)&lt;/td&gt;
&lt;td&gt;Mailgun 추정 월 비용(USD)&lt;/td&gt;
&lt;td&gt;SendGrid 추정 월 비용(USD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;60,000&lt;/td&gt;
&lt;td&gt;약 48 (Foundation 50k + 초과 10k)&amp;nbsp;&lt;/td&gt;
&lt;td&gt;약 33.25 (Essentials 50k + 초과 10k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;120,000&lt;/td&gt;
&lt;td&gt;약 112 (Scale 100k + 초과 20k)&amp;nbsp;&lt;/td&gt;
&lt;td&gt;약 52.95 (Essentials 100k + 초과 20k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;td&gt;약 310 (Scale 100k + 초과 200k)&lt;/td&gt;
&lt;td&gt;약 214.95 (Essentials 100k + 초과 200k)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;600,000&lt;/td&gt;
&lt;td&gt;약 640 (Scale 100k + 초과 500k)&amp;nbsp;&lt;/td&gt;
&lt;td&gt;약 484.95 (Essentials 100k + 초과 500k)&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;Mailgun은 무료 구간이 작고(&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;하루에 100통)&lt;/span&gt;, 유료는 월 구독(Basic이 월 15$)이 붙고, SendGrid도 월 구독(월 19.95$)이 시작점이라 장기적으로 수신량이 늘어나는 서비스에선 부담이 될 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;수신량 증가가 곧 비용 증가&lt;/b&gt;로 직결되고, 스팸/오발송처럼 &amp;ldquo;원치 않는 메일&amp;rdquo;도 같은 방식으로 비용에 들어온다는 점이었다.&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;2) 원문(.eml) 보관이 &amp;lsquo;공짜&amp;rsquo;가 아니다&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;Maildir처럼 &amp;ldquo;메일이 오면 원문이 파일로 남는&amp;rdquo; 흐름이 아니라, Webhook으로 받은 결과를 기반으로&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;원문(raw MIME)을 &lt;b&gt;그대로 저장할지&amp;nbsp;&lt;/b&gt;어디에,&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;얼마나 오래 보관할지(S3/DB/파일)&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;장애/버그 시 &lt;b&gt;재처리(리플레이)&lt;/b&gt; 를 어떻게 할지 를 &lt;b&gt;별도로 설계&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;즉 &amp;ldquo;수신&amp;rdquo;은 SaaS가 해주지만, &amp;ldquo;아카이빙&amp;rdquo;은 결국 우리가 책임져야 했다.&lt;/p&gt;
&lt;p data-end=&quot;1449&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;388&quot; data-start=&quot;287&quot; data-ke-size=&quot;size18&quot;&gt;초기 구현 속도는 최고였다. 하지만 우리 서비스는 &lt;b&gt;이메일 수신이 핵심 도메인&lt;/b&gt;이고, 1~2달 하고 끝낼 프로젝트도 아니다. 그래서 &lt;b&gt;월 플랜 기반 비용 구조 + 원문 보관 설계 부담&lt;/b&gt;까지 합쳐 보면, 장기적으로 우리가 원하는 아키텍처 방향과는 거리가 있다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 직접 이메일 서버 운영하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 선택지는, 우리가 직접 &lt;b&gt;이메일 수신 전용 서버&lt;/b&gt;를 운영하는 방식이었다.&lt;br /&gt;즉, SendGrid/Mailgun 같은 외부 Inbound 서비스에 의존하지 않고 &lt;b&gt;SMTP(Postfix)로 직접 수신&lt;/b&gt;하고, &lt;b&gt;Maildir에 원문을 저장&lt;/b&gt;한 뒤 내부 파이프라인(파서/저장/후처리)로 넘기는 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 우리는 이 방식을 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;왜 직접 운영을 택했을까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 비용이 &amp;ldquo;고정비&amp;rdquo;로 떨어진다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Inbound SaaS는 보통 월 구독(플랜) 형태가 시작점이다. 예를 들어 SendGrid는 Essentials가 월 &lt;b&gt;$19.95~&lt;/b&gt;부터 시작하고, Mailgun도 무료 구간 이후는 유료 플랜을 전제로 한다.&lt;br /&gt;반면 &lt;b&gt;직접 수신 서버는 &amp;lsquo;메일 건수&amp;rsquo;가 아니라 &amp;lsquo;서버/디스크&amp;rsquo; 중심으로 비용이 잡힌다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서울 리전 기준으로 아주 러프하게 잡으면(온디맨드)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;716&quot; data-start=&quot;523&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;617&quot; data-start=&quot;523&quot;&gt;&lt;b&gt;EC2 t4g.micro&lt;/b&gt;: 시간당 약 &lt;b&gt;$0.0104&lt;/b&gt; &amp;rarr; 월 약 &lt;b&gt;$7.59&lt;/b&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;716&quot; data-start=&quot;618&quot;&gt;&lt;b&gt;EBS gp3 10GB&lt;/b&gt;: 대략 &lt;b&gt;$0.08/GB-month&lt;/b&gt; 기준이면 월 &lt;b&gt;$0.80&lt;/b&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;718&quot; data-ke-size=&quot;size16&quot;&gt;합치면 &lt;b&gt;월 $8~9 수준&lt;/b&gt;에서 시작할 수 있고, 무엇보다 메일이 늘어도 &lt;b&gt;&amp;ldquo;건당 과금&amp;rdquo;이 붙지 않아서&lt;/b&gt; 규모가 커질수록 예측이 쉬워진다.&lt;/p&gt;
&lt;p data-end=&quot;825&quot; data-start=&quot;718&quot; data-ke-size=&quot;size16&quot;&gt;여기에 Saving plans을 적용하면 40%는 더 싸진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 데이터 전송은 인바운드는 과금이 없고(원칙), 아웃바운드는 월 100GB까지 무료 구간이 있다.&lt;br /&gt;뉴스레터 수신/파싱 구조에서는 보통 &lt;b&gt;인바운드가 중심&lt;/b&gt;이라 네트워크 비용이 폭발하는 패턴이 상대적으로 덜했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1014&quot; data-start=&quot;989&quot; data-ke-size=&quot;size20&quot;&gt;2) 설계를 마음대로 가져갈 수 있다&lt;/h4&gt;
&lt;p data-end=&quot;1036&quot; data-start=&quot;1015&quot; data-ke-size=&quot;size16&quot;&gt;이 방식의 핵심은 &amp;ldquo;제약이 없다&amp;rdquo;였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1193&quot; data-start=&quot;1038&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1069&quot; data-start=&quot;1038&quot;&gt;Maildir 구조 그대로 저장(원문이 파일로 쌓임)&lt;/li&gt;
&lt;li data-end=&quot;1104&quot; data-start=&quot;1070&quot;&gt;파싱 로직/재처리 전략(실패 메일 재시도, 파서 교체 등)&lt;/li&gt;
&lt;li data-end=&quot;1123&quot; data-start=&quot;1105&quot;&gt;첨부파일 처리, 전처리/후처리&lt;/li&gt;
&lt;li data-end=&quot;1158&quot; data-start=&quot;1124&quot;&gt;보관/삭제 정책(예: N일 보관, 특정 발행처 영구 보관)&lt;/li&gt;
&lt;li data-end=&quot;1193&quot; data-start=&quot;1159&quot;&gt;트래킹/감사 로그(수신 시각, 처리 시각, 원문 해시 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1242&quot; data-start=&quot;1195&quot; data-ke-size=&quot;size16&quot;&gt;메일이 핵심 도메인인 서비스에서, 이 &amp;ldquo;완전한 통제권&amp;rdquo;은 장기적으로 큰 자산이 된다.&lt;/p&gt;
&lt;p data-end=&quot;1628&quot; data-start=&quot;1591&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1282&quot; data-start=&quot;1244&quot; data-ke-size=&quot;size20&quot;&gt;3) 원본 이메일(.eml)을 &lt;b&gt;그대로&lt;/b&gt; 확보할 수 있다&lt;/h4&gt;
&lt;p data-end=&quot;1437&quot; data-start=&quot;1283&quot; data-ke-size=&quot;size16&quot;&gt;Inbound API는 &amp;ldquo;파싱된 결과&amp;rdquo;를 주는 대신, 원문을 장기 보관하려면 결국 별도 저장 설계가 필요하다.&lt;br /&gt;반면 자체 수신은 &lt;b&gt;원문(.eml) 확보가 기본값&lt;/b&gt;이라, 나중에 파서가 바뀌어도 &amp;ldquo;원문 기준으로 재처리&amp;rdquo;가 가능하고, 문제 상황에서 디버깅도 훨씬 단단해진다.&lt;/p&gt;
&lt;p data-end=&quot;1722&quot; data-start=&quot;1661&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1461&quot; data-start=&quot;1439&quot; data-ke-size=&quot;size20&quot;&gt;4) 대량 수신/버스트에 강하다&lt;/h4&gt;
&lt;p data-end=&quot;1578&quot; data-start=&quot;1462&quot; data-ke-size=&quot;size16&quot;&gt;Postfix는 원래 &lt;b&gt;큐 기반&lt;/b&gt;으로 트래픽을 흡수하도록 설계되어 있다.&lt;br /&gt;뉴스레터처럼 특정 시간대에 몰아치는 패턴에서도, 큐/워커/파서 처리량을 조절하면서 안정적으로 버틸 수 있다는 점이 매력적이었다.&lt;/p&gt;
&lt;p data-end=&quot;1578&quot; data-start=&quot;1462&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1612&quot; data-start=&quot;1585&quot; data-ke-size=&quot;size16&quot;&gt;물론, 공짜는 아니다&lt;/p&gt;
&lt;p data-end=&quot;1644&quot; data-start=&quot;1613&quot; data-ke-size=&quot;size16&quot;&gt;직접 운영을 선택하는 순간 아래는 &amp;ldquo;우리 책임&amp;rdquo;이 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1734&quot; data-start=&quot;1646&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;1646&quot; data-end=&quot;1675&quot;&gt;스팸/어뷰징 대응(차단, 레이트리밋, 로그 감시)&lt;/li&gt;
&lt;li data-end=&quot;1695&quot; data-start=&quot;1676&quot;&gt;보안 패치/권한 관리/접근 통제&lt;/li&gt;
&lt;li data-end=&quot;1734&quot; data-start=&quot;1696&quot;&gt;디스크/백업/모니터링(메일이 쌓이는 속도, 큐 적체, 파서 지연)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1736&quot; data-ke-size=&quot;size16&quot;&gt;다만 우리는 이메일이 서비스의 중심이고, 1~2달짜리 실험이 아니라 &lt;b&gt;지속 운영&lt;/b&gt;이 전제라서, 이 운영 부담을 감수하더라도 &lt;b&gt;비용 예측 가능성 + 설계 자유도 + 원문 확보&lt;/b&gt; 쪽이 장기적으로 더 맞다고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1736&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1858&quot; data-start=&quot;1736&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3) 수신 전용 이메일 서버 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적인 필요한 구조는 아래와 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 발송 서버에서 메일 전송 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스레터 발행 서비스의 메일 서버는 수신자 목록에 있는 user@bombom.news 주소를 보고, 해당 주소로 메일 전송을 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. DNS에서 메일 담당 서버 조회(MX 레코드)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발송 서버는 먼저 DNS에 bombom.news 도메인의 메일을 어느 서버가 맡고 있는지 조회한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 DNS는 bombom.news 도메인에 설정된 MX 레코드를 보고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 mail.bombom.news 같은 메일 서버 주소를 알려준다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이어서 발송 서버는 mail.bombom.news의 실제 ip 주소도 DNS로부터 조회한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 발송 서버 -&amp;gt; 우리 메일 서버 (SMTP 접속)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발송 서버는 조회한 IP로 SMTP(메일 전송 프로토콜) 연결을 맺고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보내는 사람, 받는 사람, 제목, 본문 등 메일 데이터를 순차적으로 전송한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 메일 서버는 이 내용을 차례대로 수시하고, 메일을 저장할 준비를 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 서버 내부의 Maildir에 메일을 파일로 저장&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 서버는 수신이 끝난 이메일을 Maildir 형식의 디렉터리에 파일로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 한 통의 메일은 Maildir/new 디렉터리 아래 파일 하나로 떨어지며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일 안에는 헤더(보낸 사람, 받는 사람, 제목, 날짜)와 본문 내용이 모두 포함된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까직가 이메일이 발송 서버에서 출발해, 우리 서버의 Maildir까지 도착하는 과정이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 그럼 이제 우리가 구현한다고 했으니까&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;일단 이메일을 받는 것 까지 구현해보자&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;MX 레코드, 25번 포트, SMTP 서버&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;메일을 어디로, 어떤방식으로 받는 것을 정해한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;1. DNS에서 메일 담당 서버를 정하는 것(MX 레코드)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;2. 그 서버가 실제로 메일을 받을 준비하는 것(25번포트&amp;nbsp; + SMTP 서버)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;1. MX 레코드&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;먼저, user@bombom.news로 메일이 도착하려면 DNS에게 bombom.news로 오는 메일은 어느서버가 담당하나요? 라는 질문을 했을 때, 우리 메일 서버를 가리키도록 MX 레코드를 설정해야 한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;예를 들어&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;bombom.news의 MX레코드를 mail.bombom.news로 지정하고 mail.bombom.news에는 실제 메일 서버 IP주소를 연결한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;이렇게 해두면, 외부 발송 서버는 항상&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;bombom.news -&amp;gt; MX 조회 -&amp;gt; mail.bombom.news -&amp;gt; IP 조회&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;라는 순서로 우리 서버를 찾아오게 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1736&quot; data-end=&quot;1858&quot;&gt;그래서 우리가 아는 유명한 메일서버인 naver.com를 보면 아래와 같이 되어있다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1066&quot; data-origin-height=&quot;250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKeA9e/dJMcac2Hkaw/tZT3Ar8DJOauMnnJdgIrl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKeA9e/dJMcac2Hkaw/tZT3Ar8DJOauMnnJdgIrl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKeA9e/dJMcac2Hkaw/tZT3Ar8DJOauMnnJdgIrl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKeA9e%2FdJMcac2Hkaw%2FtZT3Ar8DJOauMnnJdgIrl1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;553&quot; height=&quot;130&quot; data-origin-width=&quot;1066&quot; data-origin-height=&quot;250&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 여러개인이유는 네이버는 대형 메일 서비스 회사이기에 하루에 아마 수억 통 이상을 받을 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 수신을 담당하는 서버가 하나로 버티기 힘들기도 하고 장애를 대비해야하기에 이렇게 여러개로 분산해놓는 거다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 그럼 이렇게 하면 메일을 받을 수 있을까??&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니다&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MX 레코드만 있어서는 메일을 받을 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표지판만 있고 정작 건물 문이 잠겨 있거나 안에 사람이 없는 것과 비슷하다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음 단계가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 메일 서버에서 25번 포트를 연다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 이메일 전송에 사용되는 기본 포트가 25번이기 때문에 외부 발송 서버가 이 포트로 접속할 수 있어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. MTA 서버를 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 25번 포트에서 대기하면서 들어오는 메일을 받아서 Maildir 같은 저장소에 기록하는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 발송 서버가 제목,본문,수신자 정보를 보내면 이를 정상적으로 수신하고 파일로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞선 글에서 SMTP는&lt;br /&gt;&lt;b&gt;메일 서버들이 서로 대화하기 위해 지켜야 하는 통신 규약&lt;/b&gt;이라고 설명했습니다.&lt;br /&gt;메일을 보낼 때 어떤 순서로 명령을 주고받고, 성공이나 실패를 어떻게 응답하는지에 대한 약속이 바로 SMTP입니다.&lt;/p&gt;
&lt;p data-end=&quot;256&quot; data-start=&quot;233&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 자연스럽게 이런 질문이 생깁니다.&lt;/p&gt;
&lt;blockquote data-end=&quot;303&quot; data-start=&quot;258&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;그럼 이 SMTP 규약을 실제로 구현해서 메일을 주고받는 주체는 누구일까?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;SMTP(Simple&amp;nbsp;Mail&amp;nbsp;Transfer&amp;nbsp;Protocol)는&amp;nbsp;이메일을&amp;nbsp;주고받기&amp;nbsp;위한&amp;nbsp;표준&amp;nbsp;통신&amp;nbsp;규약(프로토콜)이다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;사람이&amp;nbsp;메신저에서&amp;nbsp;대화를&amp;nbsp;주고받듯이,&amp;nbsp;메일&amp;nbsp;서버끼리는&amp;nbsp;SMTP라는&amp;nbsp;약속된&amp;nbsp;형식에&amp;nbsp;따라&amp;nbsp;대화를&amp;nbsp;주고받는다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;이때&amp;nbsp;보낸&amp;nbsp;사람&amp;nbsp;주소,&amp;nbsp;받는&amp;nbsp;사람&amp;nbsp;주소,&amp;nbsp;제목,&amp;nbsp;본문&amp;nbsp;내용,&amp;nbsp;그&amp;nbsp;외&amp;nbsp;헤더&amp;nbsp;정보들이&amp;nbsp;오간다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 역할을 맡는 것이 &lt;b&gt;MTA(Mail Transfer Agent)&lt;/b&gt;&amp;nbsp;이다..&lt;/p&gt;
&lt;p data-end=&quot;487&quot; data-start=&quot;352&quot; data-ke-size=&quot;size16&quot;&gt;MTA는 SMTP 규약에 따라&lt;br /&gt;외부 메일 서버와 통신하면서 메일을 받고, 전달하고, 필요하다면 큐에 쌓아 재시도하는 역할을 한다..&lt;br /&gt;SMTP가 &amp;ldquo;규칙&amp;rdquo;이라면, MTA는 그 규칙을 &lt;b&gt;현실에서 실행하는 프로그램&lt;/b&gt;이라고 볼 수 있다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 이해했다면, 다음으로 궁금해지는 건 이 질문일 것이다.&lt;/p&gt;
&lt;blockquote data-end=&quot;634&quot; data-start=&quot;598&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;634&quot; data-start=&quot;600&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;MTA에는 어떤 종류들이 있고, 각각 어떤 특징이 있을까?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;580&quot; data-start=&quot;565&quot; data-ke-size=&quot;size20&quot;&gt;대표적인 MTA 종류들&lt;/h4&gt;
&lt;p data-end=&quot;597&quot; data-start=&quot;582&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1️⃣ Postfix&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;622&quot; data-start=&quot;599&quot; data-ke-size=&quot;size16&quot;&gt;가장 널리 사용되는 MTA 중 하나이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;730&quot; data-start=&quot;624&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;648&quot; data-start=&quot;624&quot;&gt;보안, 성능, 설정 난이도의 균형이 좋음&lt;/li&gt;
&lt;li data-end=&quot;667&quot; data-start=&quot;649&quot;&gt;구조가 비교적 단순하고 안정적&lt;/li&gt;
&lt;li data-end=&quot;698&quot; data-start=&quot;668&quot;&gt;Maildir, 스팸 필터, 외부 필터 연동이 쉬움&lt;/li&gt;
&lt;li data-end=&quot;730&quot; data-start=&quot;699&quot;&gt;많은 리눅스 배포판에서 기본 또는 권장 MTA로 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;732&quot; data-ke-size=&quot;size16&quot;&gt;요즘 새로 메일 서버를 구축한다면&lt;br /&gt;&lt;b&gt;가장 먼저 고려되는 선택지&lt;/b&gt;라고 봐도 무방하다.&lt;/p&gt;
&lt;hr data-end=&quot;788&quot; data-start=&quot;785&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;806&quot; data-start=&quot;790&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2️⃣ Sendmail&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;833&quot; data-start=&quot;808&quot; data-ke-size=&quot;size16&quot;&gt;한때 표준처럼 사용되던 전통적인 MTA이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;880&quot; data-start=&quot;835&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;846&quot; data-start=&quot;835&quot;&gt;아주 오래된 역사&lt;/li&gt;
&lt;li data-end=&quot;862&quot; data-start=&quot;847&quot;&gt;강력하지만 설정이 복잡함&lt;/li&gt;
&lt;li data-end=&quot;880&quot; data-start=&quot;863&quot;&gt;설정 파일 문법이 난해한 편&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;933&quot; data-start=&quot;882&quot; data-ke-size=&quot;size16&quot;&gt;현재는 신규 구축보다는&lt;br /&gt;&lt;b&gt;기존 레거시 시스템 유지&lt;/b&gt;에서 주로 만나는 경우가 많다.&lt;/p&gt;
&lt;hr data-end=&quot;938&quot; data-start=&quot;935&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;952&quot; data-start=&quot;940&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3️⃣ Exim&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;979&quot; data-start=&quot;954&quot; data-ke-size=&quot;size16&quot;&gt;특히 유럽 쪽에서 많이 사용되는 MTA이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1036&quot; data-start=&quot;981&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;989&quot; data-start=&quot;981&quot;&gt;유연한 설정&lt;/li&gt;
&lt;li data-end=&quot;1010&quot; data-start=&quot;990&quot;&gt;복잡한 메일 정책을 표현하기 좋음&lt;/li&gt;
&lt;li data-end=&quot;1036&quot; data-start=&quot;1011&quot;&gt;설정 자유도가 높은 만큼 진입 장벽도 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1079&quot; data-start=&quot;1038&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;메일 흐름을 세밀하게 제어해야 하는 환경&amp;rdquo;에서 선택되는 경우가 많다.&lt;/p&gt;
&lt;p data-end=&quot;1079&quot; data-start=&quot;1038&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1079&quot; data-start=&quot;1038&quot; data-ke-size=&quot;size16&quot;&gt;여기서 우리가 고려해야할 점들은 아래와 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1684&quot; data-start=&quot;1659&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1684&quot; data-start=&quot;1659&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(1) 운영 난이도와 장애 대응 가능성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1793&quot; data-start=&quot;1685&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1715&quot; data-start=&quot;1685&quot;&gt;설정이 복잡하면 장애 때 &amp;ldquo;원인 추적&amp;rdquo;이 어려워진다.&lt;/li&gt;
&lt;li data-end=&quot;1754&quot; data-start=&quot;1716&quot;&gt;작은 팀/개인 운영일수록 &lt;b&gt;디버깅 가능한 복잡도&lt;/b&gt;가 중요하다.&lt;/li&gt;
&lt;li data-end=&quot;1793&quot; data-start=&quot;1755&quot;&gt;로그가 명확한지, 흔한 문제의 해결책이 잘 정리돼 있는지도 크다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1816&quot; data-start=&quot;1795&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(2) 보안 기본값과 설계 철학&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1889&quot; data-start=&quot;1817&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1848&quot; data-start=&quot;1817&quot;&gt;외부에서 직접 연결을 받는 컴포넌트라 공격 표면이 큼&lt;/li&gt;
&lt;li data-end=&quot;1889&quot; data-start=&quot;1849&quot;&gt;프로세스/권한 분리, 기본 설정의 안전함, 업데이트 흐름이 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1915&quot; data-start=&quot;1891&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(3) 메일 큐(queue)의 신뢰성&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1943&quot; data-start=&quot;1916&quot; data-ke-size=&quot;size16&quot;&gt;현실에서는 메일이 항상 한 번에 성공하지 않는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1980&quot; data-start=&quot;1944&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1957&quot; data-start=&quot;1944&quot;&gt;상대 서버 장애/지연&lt;/li&gt;
&lt;li data-end=&quot;1966&quot; data-start=&quot;1958&quot;&gt;DNS 문제&lt;/li&gt;
&lt;li data-end=&quot;1980&quot; data-start=&quot;1967&quot;&gt;일시적 네트워크 오류&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2028&quot; data-start=&quot;1982&quot; data-ke-size=&quot;size16&quot;&gt;이때 MTA가 &lt;b&gt;큐잉/재시도/백오프&lt;/b&gt;를 얼마나 안정적으로 해주느냐가 핵심이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2051&quot; data-start=&quot;2030&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-end=&quot;2051&quot; data-start=&quot;2030&quot; data-ke-style=&quot;style3&quot;&gt;큐잉(Queuing): 실패해도 메일을 안전하게 보관하기&lt;br /&gt;&lt;br /&gt;전송이 실패하면 MTA는 메일을 바로 버리지 않고 큐(Queue) 에 넣어둔다.&lt;br /&gt;큐는 보통 디스크에 저장되기 때문에, 잠깐 장애가 있어도 메일이 사라지지 않는다.&lt;br /&gt;즉 큐잉은 &amp;ldquo;전송 실패를 곧바로 &amp;lsquo;유실&amp;rsquo;로 만들지 않고, 다시 시도할 수 있도록 메일을 안전하게 보관하는 장치&amp;rdquo;이다.&lt;br /&gt;&lt;br /&gt;백오프(Backoff): 재시도 간격을 점점 늘려 폭주를 막기&lt;br /&gt;&lt;br /&gt;일시 실패가 났을 때, 바로바로 계속 재시도하면 문제가 생긴다.&lt;br /&gt;상대 서버가 다운인데 계속 두드리면 상대도 더 힘들어지고 우리 서버도 큐 처리 때문에 리소스가 소모되고 특히 뉴스레터처럼 한꺼번에 많이 보내는 상황에서는 재시도가 겹쳐 폭주하기 쉽다.&lt;br /&gt;그래서 MTA는 보통 재시도 간격을 점점 늘린다.&lt;br /&gt;처음엔 몇 분 뒤 재시도 &amp;rarr; 또 실패하면 더 늦게 &amp;rarr; 계속 실패하면 간격을 더 벌리는 식이다.&lt;br /&gt;이게 백오프이다. &lt;br /&gt;&amp;ldquo;빠르게 회복을 노리되, 장애가 길어지면 시스템을 보호하는 전략&amp;rdquo;이다.&lt;/blockquote&gt;
&lt;p data-end=&quot;2051&quot; data-start=&quot;2030&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2051&quot; data-start=&quot;2030&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(4) 스팸/어뷰징/검증 확장성&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2077&quot; data-start=&quot;2052&quot; data-ke-size=&quot;size16&quot;&gt;운영하다 보면 &amp;ldquo;메일이 온다&amp;rdquo;가 끝이 아니라,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2139&quot; data-start=&quot;2078&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2085&quot; data-start=&quot;2078&quot;&gt;스팸 유입&lt;/li&gt;
&lt;li data-end=&quot;2093&quot; data-start=&quot;2086&quot;&gt;봇/어뷰징&lt;/li&gt;
&lt;li data-end=&quot;2139&quot; data-start=&quot;2094&quot;&gt;도메인 신뢰 판단(SPF/DKIM/DMARC)&lt;br /&gt;같은 이슈가 반드시 따라온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2171&quot; data-start=&quot;2141&quot; data-ke-size=&quot;size16&quot;&gt;MTA가 이런 확장 지점을 붙이기 쉬운지도 봐야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2196&quot; data-start=&quot;2173&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2196&quot; data-start=&quot;2173&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(5) 저장 방식과 파이프라인 궁합&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2235&quot; data-start=&quot;2197&quot; data-ke-size=&quot;size16&quot;&gt;Bombom처럼 &amp;ldquo;수신 &amp;rarr; 저장 &amp;rarr; 파싱&amp;rdquo; 구조라면 특히 중요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2309&quot; data-start=&quot;2236&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2255&quot; data-start=&quot;2236&quot;&gt;Maildir로 떨구기 쉬운가?&lt;/li&gt;
&lt;li data-end=&quot;2280&quot; data-start=&quot;2256&quot;&gt;파일 기반 파이프라인으로 넘기기 쉬운가?&lt;/li&gt;
&lt;li data-end=&quot;2309&quot; data-start=&quot;2281&quot;&gt;장애 시 &amp;ldquo;어디까지 처리됐는지&amp;rdquo; 추적이 쉬운가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2335&quot; data-start=&quot;2311&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(6) 레퍼런스(문서/커뮤니티/사례)&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2361&quot; data-start=&quot;2336&quot; data-ke-size=&quot;size16&quot;&gt;이건 너무 실무적이라서 오히려 진짜 중요하다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2361&quot; data-start=&quot;2336&quot; data-ke-size=&quot;size16&quot;&gt;시간 단축을 엄청 해준다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2429&quot; data-start=&quot;2362&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2379&quot; data-start=&quot;2362&quot;&gt;검색했을 때 바로 나오는가?&lt;/li&gt;
&lt;li data-end=&quot;2402&quot; data-start=&quot;2380&quot;&gt;배포판 패키지/운영 사례가 충분한가?&lt;/li&gt;
&lt;li data-end=&quot;2429&quot; data-start=&quot;2403&quot;&gt;&amp;ldquo;처음 운영&amp;rdquo;의 시행착오 비용을 줄여주는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2467&quot; data-start=&quot;2436&quot; data-ke-size=&quot;size23&quot;&gt;5) Bombom은 왜 Postfix를 선택했을까?&lt;/h3&gt;
&lt;p data-end=&quot;2500&quot; data-start=&quot;2469&quot; data-ke-size=&quot;size16&quot;&gt;Bombom의 목표는 단순히 &amp;ldquo;메일을 받는다&amp;rdquo;가 아니라,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2576&quot; data-start=&quot;2502&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2522&quot; data-start=&quot;2502&quot;&gt;&lt;b&gt;도메인으로 직접 메일 수신&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2547&quot; data-start=&quot;2523&quot;&gt;수신 메일을 &lt;b&gt;Maildir에 저장&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2576&quot; data-start=&quot;2548&quot;&gt;이후 내부 로직에서 &lt;b&gt;파싱/가공해 서비스화&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2600&quot; data-start=&quot;2578&quot; data-ke-size=&quot;size16&quot;&gt;이 흐름을 안정적으로 만드는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;2633&quot; data-start=&quot;2602&quot; data-ke-size=&quot;size16&quot;&gt;그래서 MTA 선택 기준도 자연스럽게 이렇게 잡혔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2808&quot; data-start=&quot;2635&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2654&quot; data-start=&quot;2635&quot;&gt;운영 난이도가 과도하지 않을 것&lt;/li&gt;
&lt;li data-end=&quot;2692&quot; data-start=&quot;2655&quot;&gt;외부 포트(25) 공개를 감당할 수 있을 만큼 안전한 설계일 것&lt;/li&gt;
&lt;li data-end=&quot;2723&quot; data-start=&quot;2693&quot;&gt;큐/재시도 같은 &amp;ldquo;전달 신뢰성&amp;rdquo;이 검증되어 있을 것&lt;/li&gt;
&lt;li data-end=&quot;2756&quot; data-start=&quot;2724&quot;&gt;앞으로 스팸/필터/검증을 붙일 때 확장 지점이 있을 것&lt;/li&gt;
&lt;li data-end=&quot;2785&quot; data-start=&quot;2757&quot;&gt;Maildir 기반 파이프라인과 궁합이 좋을 것&lt;/li&gt;
&lt;li data-end=&quot;2808&quot; data-start=&quot;2786&quot;&gt;문제 생겼을 때 레퍼런스가 풍부할 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2896&quot; data-start=&quot;2810&quot; data-ke-size=&quot;size16&quot;&gt;이 기준으로 보면 Postfix는 &amp;ldquo;특정 하나가 압도적이라서&amp;rdquo;라기보다,&lt;br /&gt;&lt;b&gt;운영 현실에서 중요한 요소들의 균형이 가장 좋았던 선택지&lt;/b&gt;에 가까웠다.&lt;/p&gt;
&lt;p data-end=&quot;2920&quot; data-start=&quot;2898&quot; data-ke-size=&quot;size16&quot;&gt;즉, Bombom의 Postfix 선택은&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;많이 쓰니까&amp;rdquo;가 아니라 &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2920&quot; data-start=&quot;2898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;우리가 감당해야 할 운영 책임(보안/큐/확장/디버깅)을 고려했을 때, 가장 합리적인 균형점이라서&amp;rdquo;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100.93%; height: 331px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;height: 10px; width: 27.8835%;&quot;&gt;&lt;b&gt;기준&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 10px; width: 26.3025%;&quot;&gt;&lt;b&gt;Postfix&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 10px; width: 27.6744%;&quot;&gt;Exim&lt;/td&gt;
&lt;td style=&quot;height: 10px; width: 18.9566%;&quot;&gt;&lt;b&gt;Sendmail&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;운영 난이도(초기 세팅/유지보수)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;낮음~중간&lt;/b&gt; (문서/예제 많음)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;중간~높음 (유연하지만 복잡성&lt;span style=&quot;background-color: #f9f9f9; color: #333333; text-align: start;&quot;&gt;&amp;uarr;)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;&lt;b&gt;높음&lt;/b&gt; (레거시/설정 난해)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;장애 대응(로그/트러블슈팅 레퍼런스)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;매우 좋음&lt;/b&gt; (사례 풍부)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;좋음 (다만 설정 복잡 시 난이도&amp;uarr;)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;보통~낮음 (레거시)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;보안 기본값/설계 철학&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;좋음&lt;/b&gt; (현대적 권한 분리 구조)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;좋음 (설정에 따라 좌우)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;보통 (역사 오래됨)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;메일 큐/재시도 신뢰성(운영 안정성)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;매우 좋음&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;매우 좋음&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;확장성(필터/검증/정책 연동)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;좋음&lt;/b&gt; (milter, policy, content_filter 등)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;&lt;b&gt;매우 좋음&lt;/b&gt; (정책 표현 강력)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;좋음(난이도&amp;uarr;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;Maildir/파이프라인 궁합&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;좋음&lt;/b&gt; (구성 흔함)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;좋음&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;보통&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;생태계/도입 사례(배포판 기본/가이드)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;매우 많음&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;많음&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;레거시 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;height: 38px; width: 27.8835%;&quot;&gt;&lt;b&gt;작은 팀이 처음 직접 운영 적합도&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 26.3025%;&quot;&gt;&lt;b&gt;높음&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 27.6744%;&quot;&gt;조건부(운영 숙련도 있으면 강력)&lt;/td&gt;
&lt;td style=&quot;height: 38px; width: 18.9566%;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-end=&quot;2920&quot; data-start=&quot;2898&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 적용해보기&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 Postfix에 잘 도달할 수 있도록 미리 설정해줘야할게 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발신 서버: &amp;ldquo;example.com 메일 어디로 보내지?&amp;rdquo; &amp;rarr; &lt;b&gt;MX 조회&lt;/b&gt; &amp;rarr; mail.example.com&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발신 서버: &amp;ldquo;mail.example.com의 IP 뭐지?&amp;rdquo; &amp;rarr; &lt;b&gt;A 조회&lt;/b&gt; &amp;rarr; x.x.x.x&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발신 서버: 그 IP의 &lt;b&gt;25번 포트로 SMTP 접속&lt;/b&gt;해서 메일 전달&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) MX 레코드 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누군가가 user@example.com 으로 메일을 보내면, 보낸 쪽 메일 서버는 DNS에서 example.com의 MX 레코드를 조회하고, 그 결과로 나온 메일 서버 호스팅명(mail.example.com)으로 SMTP 연결을 시도한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGuReg/dJMcaaYaE4d/ufGvm9JSBw3HIELyvS8p0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGuReg/dJMcaaYaE4d/ufGvm9JSBw3HIELyvS8p0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGuReg/dJMcaaYaE4d/ufGvm9JSBw3HIELyvS8p0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGuReg%2FdJMcaaYaE4d%2FufGvm9JSBw3HIELyvS8p0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;718&quot; height=&quot;101&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;264&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2) A 레코드 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MX가 mail.example.com을 알려줬다면, 이제 실제로 접속하려면 IP가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 DNS에서mail.example.com의 A레코드를 조회해서 서버 IP를 알아낸 다음 그 IP의 25번 포트(SMTP)로 접속한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/srTHy/dJMcabCLXfW/6UVfPuEtVGgSkULbjSowbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/srTHy/dJMcabCLXfW/6UVfPuEtVGgSkULbjSowbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/srTHy/dJMcabCLXfW/6UVfPuEtVGgSkULbjSowbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsrTHy%2FdJMcabCLXfW%2F6UVfPuEtVGgSkULbjSowbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;423&quot; height=&quot;219&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;496&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3) 보안 그룹/방화벽 25번 포트 인바운드 허용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 발신도 한다면 안정장치를 추가로 2가지를 해주면 좋다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;429&quot; data-end=&quot;516&quot;&gt;
&lt;li data-start=&quot;429&quot; data-end=&quot;457&quot;&gt;&lt;b&gt;PTR(Reverse DNS)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정&lt;/li&gt;
&lt;li data-start=&quot;458&quot; data-end=&quot;516&quot;&gt;&lt;b&gt;오픈 릴레이(Open Relay)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;차단 (reject_unauth_destination)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 우리는 수신전용이기 때문에 이 부분은 넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Postfix를 설치하면된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Amazon Linux 2023&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1766339734216&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo dnf install -y postfix
sudo systemctl enable --now postfix&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 기본 설정 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postfix 핵심 설정 파일&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- `/etc/postfix/main.cf`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1766339808346&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 1) 서버 식별
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain

# 2) 바인딩
inet_interfaces = all
inet_protocols = all

# 3) 로컬 수신 도메인 (최소)
mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain

# 4) 오픈 릴레이 방지(필수)
smtpd_recipient_restrictions =
    permit_mynetworks,
    reject_unauth_destination

# 5) 메일 저장 형식(간단하게 Maildir 권장)
home_mailbox = Maildir/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 메일 저장 위치 만들기 Maildir&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;home_mailbox = Maildir/를 쓰면, 유저 홈 디렉토리에 Maildir로 떨어져.&lt;/p&gt;
&lt;p data-end=&quot;1615&quot; data-start=&quot;1580&quot; data-ke-size=&quot;size16&quot;&gt;예: newsletter라는 시스템 유저로 수신 받고 싶으면&lt;/p&gt;
&lt;pre id=&quot;code_1766339840240&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo adduser newsletter
sudo -u newsletter mkdir -p /home/newsletter/Maildir&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 서비스 재시작(적용 완료)&lt;/p&gt;
&lt;pre id=&quot;code_1766339858191&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl restart postfix
sudo systemctl status postfix --no-pager&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postfix 설치와 DNS(MX/A), 25번 포트 개방까지 끝나면 다음 고민이 생긴다.&lt;/p&gt;
&lt;blockquote data-end=&quot;239&quot; data-start=&quot;186&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;239&quot; data-start=&quot;188&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;메일은 이제 서버로 들어오는데&amp;hellip; 이걸 우리 애플리케이션이 &lt;b&gt;어떻게 읽어서 파싱&lt;/b&gt;하지?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;349&quot; data-start=&quot;241&quot; data-ke-size=&quot;size16&quot;&gt;메일 수신 파이프라인을 만들 때 핵심은 단순히 &amp;ldquo;읽기&amp;rdquo;가 아니라, &lt;b&gt;장애가 나도 유실 없이 처리되고&lt;/b&gt;, &lt;b&gt;재처리/운영이 쉬우며&lt;/b&gt;, &lt;b&gt;추후 확장에도 부담이 적은 구조&lt;/b&gt;를 고르는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;372&quot; data-start=&quot;351&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;372&quot; data-start=&quot;351&quot; data-ke-size=&quot;size16&quot;&gt;우리는 크게 두 가지 방식을 비교했다.&lt;/p&gt;
&lt;h4 data-end=&quot;1617&quot; data-start=&quot;1568&quot; data-ke-size=&quot;size20&quot;&gt;1) pipe 방식(Push): 메일이 오면 Postfix가 즉시 프로그램에 넘긴다&lt;/h4&gt;
&lt;p data-end=&quot;1697&quot; data-start=&quot;1629&quot; data-ke-size=&quot;size16&quot;&gt;이 방식은 Postfix가 메일을 받은 뒤, 그 내용을 &lt;b&gt;바로 애플리케이션(혹은 스크립트)&lt;/b&gt; 에 전달해 즉시 처리한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1826&quot; data-start=&quot;1699&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1713&quot; data-start=&quot;1699&quot;&gt;Postfix 수신&lt;/li&gt;
&lt;li data-end=&quot;1747&quot; data-start=&quot;1714&quot;&gt;특정 주소/도메인 규칙에 따라 &amp;ldquo;로컬 배달&amp;rdquo; 단계에서&lt;/li&gt;
&lt;li data-end=&quot;1781&quot; data-start=&quot;1748&quot;&gt;메일 본문을 프로그램의 stdin으로 전달(pipe)&lt;/li&gt;
&lt;li data-end=&quot;1826&quot; data-start=&quot;1782&quot;&gt;프로그램이 즉시 파싱/저장하고 종료 코드로 성공/실패를 Postfix에 알림&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1866&quot; data-start=&quot;1828&quot; data-ke-size=&quot;size16&quot;&gt;즉 &amp;ldquo;메일이 도착한 순간 처리&amp;rdquo;가 가능해져서 체감상 실시간에 가깝다.&lt;/p&gt;
&lt;h3 data-end=&quot;1888&quot; data-start=&quot;1868&quot; data-ke-size=&quot;size23&quot;&gt;장점은 확실하다: 거의 실시간&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1955&quot; data-start=&quot;1889&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1918&quot; data-start=&quot;1889&quot;&gt;폴더에 쌓아두고 나중에 처리하는 단계가 줄어든다.&lt;/li&gt;
&lt;li data-end=&quot;1955&quot; data-start=&quot;1919&quot;&gt;&amp;ldquo;도착 &amp;rarr; 파싱 &amp;rarr; 저장&amp;rdquo;이 곧바로 이어져 반응성이 좋아진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1981&quot; data-start=&quot;1957&quot; data-ke-size=&quot;size23&quot;&gt;그런데 운영 난이도가 급격히 올라간다&lt;/h3&gt;
&lt;p data-end=&quot;2032&quot; data-start=&quot;1982&quot; data-ke-size=&quot;size16&quot;&gt;pipe의 핵심 문제는, &lt;b&gt;메일 수신과 애플리케이션 처리가 강하게 결합&lt;/b&gt;된다는 점이다.&lt;/p&gt;
&lt;p data-end=&quot;2075&quot; data-start=&quot;2034&quot; data-ke-size=&quot;size16&quot;&gt;애플리케이션이 아래 상황에 들어가면, &amp;ldquo;수신&amp;rdquo; 자체가 불안정해질 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2158&quot; data-start=&quot;2077&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2103&quot; data-start=&quot;2077&quot;&gt;파싱 로직이 느려져서 처리 시간이 길어짐&lt;/li&gt;
&lt;li data-end=&quot;2131&quot; data-start=&quot;2104&quot;&gt;순간 트래픽으로 처리 요청이 몰림(버스트)&lt;/li&gt;
&lt;li data-end=&quot;2158&quot; data-start=&quot;2132&quot;&gt;애플리케이션이 재시작/배포/장애로 응답 못함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2236&quot; data-start=&quot;2160&quot; data-ke-size=&quot;size16&quot;&gt;이때 Postfix는 &amp;ldquo;전달 실패&amp;rdquo;로 인식하고 큐에 쌓거나 재시도하게 되는데, 이 동작을 안정적으로 굴리려면 생각보다 고려할 것이 많다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2365&quot; data-start=&quot;2238&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2262&quot; data-start=&quot;2238&quot;&gt;성공/실패 기준(exit code) 설계&lt;/li&gt;
&lt;li data-end=&quot;2287&quot; data-start=&quot;2263&quot;&gt;프로그램 실행 시간 제한(timeout)&lt;/li&gt;
&lt;li data-end=&quot;2315&quot; data-start=&quot;2288&quot;&gt;밀릴 때의 backpressure(폭주 제어)&lt;/li&gt;
&lt;li data-end=&quot;2337&quot; data-start=&quot;2316&quot;&gt;재시도 중 중복 처리 방지(멱등성)&lt;/li&gt;
&lt;li data-end=&quot;2365&quot; data-start=&quot;2338&quot;&gt;장애 시 어떤 메일이 어디까지 처리됐는지 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2418&quot; data-start=&quot;2367&quot; data-ke-size=&quot;size16&quot;&gt;정리하면, pipe는 &amp;ldquo;실시간&amp;rdquo;을 얻는 대신 &lt;b&gt;운영에서 맞아야 할 변수가 확 늘어난다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;423&quot; data-start=&quot;379&quot; data-ke-size=&quot;size26&quot;&gt;2) Maildir 폴링 방식(Pull): 폴더에 쌓아두고, 앱이 가져간다&lt;/h2&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;435&quot; data-ke-size=&quot;size16&quot;&gt;이 방식은 Postfix가 받은 메일을 서버 파일로 저장하고, 애플리케이션이 주기적으로 확인해서 처리한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;669&quot; data-start=&quot;496&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;537&quot; data-start=&quot;496&quot;&gt;Postfix 수신 &amp;rarr; Maildir/new/에 메일 파일 저장&lt;/li&gt;
&lt;li data-end=&quot;578&quot; data-start=&quot;538&quot;&gt;애플리케이션(스케줄러/워커)이 new/ 폴더를 주기적으로 확인&lt;/li&gt;
&lt;li data-end=&quot;608&quot; data-start=&quot;579&quot;&gt;발견한 메일을 읽어서 파싱 &amp;rarr; DB 저장/가공&lt;/li&gt;
&lt;li data-end=&quot;669&quot; data-start=&quot;609&quot;&gt;처리 완료 후 cur/ 또는 별도 archive/ 폴더로 이동 (실패 시 failed/ 이동)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;740&quot; data-start=&quot;671&quot; data-ke-size=&quot;size16&quot;&gt;즉, Postfix는 &amp;ldquo;수신 및 저장&amp;rdquo; 역할에 집중하고, 애플리케이션은 &amp;ldquo;처리&amp;rdquo;만 담당한다. 수신과 처리가 분리되는 구조다.&lt;/p&gt;
&lt;h3 data-end=&quot;760&quot; data-start=&quot;742&quot; data-ke-size=&quot;size23&quot;&gt;왜 이 방식이 안정적일까?&lt;/h3&gt;
&lt;p data-end=&quot;811&quot; data-start=&quot;761&quot; data-ke-size=&quot;size16&quot;&gt;이 방식의 가장 큰 장점은 &lt;b&gt;애플리케이션 장애가 메일 수신을 망치지 않는다는 점&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;952&quot; data-start=&quot;813&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;860&quot; data-start=&quot;813&quot;&gt;애플리케이션이 잠깐 죽어도 Postfix는 계속 메일을 받아서 디스크에 쌓아둔다.&lt;/li&gt;
&lt;li data-end=&quot;913&quot; data-start=&quot;861&quot;&gt;장애 복구 후 애플리케이션이 다시 켜지면, 밀려 있던 메일 파일을 순서대로 처리하면 된다.&lt;/li&gt;
&lt;li data-end=&quot;952&quot; data-start=&quot;914&quot;&gt;운영 관점에서 &amp;ldquo;유실&amp;rdquo;보다 &amp;ldquo;지연&amp;rdquo;이 감당 가능한 문제로 바뀐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1012&quot; data-start=&quot;954&quot; data-ke-size=&quot;size16&quot;&gt;뉴스레터 수집/파싱처럼 &amp;ldquo;조금 늦게 처리되어도 되지만, 빠뜨리면 안 되는&amp;rdquo; 작업에는 이 점이 특히 크다.&lt;/p&gt;
&lt;h3 data-end=&quot;1022&quot; data-start=&quot;1014&quot; data-ke-size=&quot;size23&quot;&gt;단점은?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1069&quot; data-start=&quot;1023&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1053&quot; data-start=&quot;1023&quot;&gt;폴링 주기만큼 지연이 생긴다. (예: 2초~30초)&lt;/li&gt;
&lt;li data-end=&quot;1069&quot; data-start=&quot;1054&quot;&gt;폴더 스캔 비용이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1189&quot; data-start=&quot;1071&quot; data-ke-size=&quot;size16&quot;&gt;하지만 뉴스레터 수신량이 폭발적으로 크지 않은 초기 단계에서는, 이 비용은 보통 미미하다. 폴링 주기를 적절히 잡고(예: 2~5초), 처리 중인 메일을 별도 폴더로 옮겨 중복 처리만 막아주면 안정적으로 돌아간다.&lt;/p&gt;
&lt;h3 data-end=&quot;1203&quot; data-start=&quot;1191&quot; data-ke-size=&quot;size23&quot;&gt;운영하면서 추가 고려할 점&lt;/h3&gt;
&lt;p data-end=&quot;1257&quot; data-start=&quot;1204&quot; data-ke-size=&quot;size16&quot;&gt;Pull 방식은 &amp;ldquo;폴더를 읽는다&amp;rdquo;로 끝나지 않고, 운영을 위해 보통 아래 3가지를 함께 챙긴다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1561&quot; data-start=&quot;1259&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1381&quot; data-start=&quot;1259&quot;&gt;&lt;b&gt;실패 폴더 + 재처리 전략&lt;/b&gt;&lt;br /&gt;파싱 실패/DB 장애 등으로 처리에 실패하면 failed/로 옮겨두고, 나중에 재처리 배치로 다시 돌린다.&lt;/li&gt;
&lt;li data-end=&quot;1561&quot; data-start=&quot;1470&quot;&gt;&lt;b&gt;중복 방지(멱등성)&lt;/b&gt;&lt;br /&gt;메일은 재전송/중복 수신이 생각보다 자주 있다. Message-ID 같은 값을 DB에 저장해 unique 처리하면 안전하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;2456&quot; data-start=&quot;2425&quot; data-ke-size=&quot;size26&quot;&gt;우리는 왜 Pull(Maildir 폴링)로 결정했나&lt;/h2&gt;
&lt;p data-end=&quot;2549&quot; data-start=&quot;2458&quot; data-ke-size=&quot;size16&quot;&gt;우리의 목표는 &amp;ldquo;메일이 도착하자마자 즉시 처리&amp;rdquo;가 아니라, &lt;b&gt;유실 없이 안정적으로 수집하고 파싱하는 것&lt;/b&gt;이었다. 뉴스레터 파이프라인에서는 특히 다음이 중요했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2661&quot; data-start=&quot;2551&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2588&quot; data-start=&quot;2551&quot;&gt;애플리케이션이 잠깐 실패해도 &lt;b&gt;메일 수신이 멈추지 않을 것&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2620&quot; data-start=&quot;2589&quot;&gt;실패한 메일을 따로 모아 &lt;b&gt;재처리할 수 있을 것&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2661&quot; data-start=&quot;2621&quot;&gt;운영이 단순해서 디버깅이 쉬울 것(원문 eml 파일 기반 확인 가능)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2848&quot; data-start=&quot;2663&quot; data-ke-size=&quot;size16&quot;&gt;이 기준으로 보면, Maildir 폴링 방식은 수신과 처리를 분리해 리스크를 줄여주고, 장애가 나도 &amp;ldquo;지연&amp;rdquo;으로 흡수할 수 있는 구조였다. 반면 pipe 방식은 실시간이라는 장점은 크지만, 애플리케이션 상태가 곧바로 수신 안정성에 영향을 줄 수 있고, 재시도/폭주 제어/중복 방지 같은 운영 설계를 초기에부터 탄탄히 가져가야 했다.&lt;/p&gt;
&lt;p data-end=&quot;3005&quot; data-start=&quot;2850&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는 초기 단계에서는 안정성과 단순성이 가장 큰 가치라고 판단했고, 최종적으로 &lt;b&gt;Maildir 폴링(Pull)&lt;/b&gt; 을 선택했다.&amp;nbsp;&lt;/p&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <category>이메일</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/771</guid>
      <comments>https://html-jc.tistory.com/771#entry771comment</comments>
      <pubDate>Sun, 14 Dec 2025 17:52:38 +0900</pubDate>
    </item>
    <item>
      <title>봄봄 AWS 비용 다이어트 이야기</title>
      <link>https://html-jc.tistory.com/770</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6wwYF/dJMcafd2xCZ/tYXOJGFI3K3d0jyRm81Fgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6wwYF/dJMcafd2xCZ/tYXOJGFI3K3d0jyRm81Fgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6wwYF/dJMcafd2xCZ/tYXOJGFI3K3d0jyRm81Fgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6wwYF%2FdJMcafd2xCZ%2FtYXOJGFI3K3d0jyRm81Fgk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;843&quot; height=&quot;404&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;봄봄의 상황&lt;/h3&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;현재 &lt;b&gt;봄봄&lt;/b&gt;은 300명이 넘는 회원이 쓰고 있는 서비스다.&lt;br /&gt;&amp;ldquo;우테코 끝나면 이거 그냥 접는 거 아니야?&amp;rdquo;라는 질문을 여러 번 들었는데,&lt;br /&gt;처음 기획할 때부터 우리 팀의 목표는 분명했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;실험용 토이 프로젝트 말고, 진짜 계속 운영하는 서비스 한 번 만들어보자.&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;처음 합을 맞출 때도 그 얘기를 했고,&lt;br /&gt;우테코가 거의 끝나갈 즈음에 내가 다시 한 번 팀원들에게 물어봤다.&lt;/p&gt;
&lt;blockquote data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;진짜야? 우테코 끝나도 이거 계속 한다?&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;그리고 모두가 &amp;ldquo;당연하지&amp;rdquo; 쪽에 손을 들어줬다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;그런데 한가지 문제가 있다.&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;바로 우리 서비스는 아직 &lt;b&gt;수익이 없다&lt;/b&gt;는 점이다.&lt;br /&gt;수익 모델에 대한 아이디어는 몇 가지 가지고 있지만, 거기까지 가려면 아직 시간이 꽤 걸린다.&lt;/p&gt;
&lt;p data-end=&quot;457&quot; data-start=&quot;337&quot; data-ke-size=&quot;size16&quot;&gt;그 전까지는, 말 그대로 &lt;b&gt;버티는 힘&lt;/b&gt;이 중요하다.&lt;/p&gt;
&lt;p data-end=&quot;520&quot; data-start=&quot;459&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;520&quot; data-start=&quot;459&quot; data-ke-size=&quot;size16&quot;&gt;버티려면 결국 &lt;b&gt;고정비용을 줄여야 한다.&lt;/b&gt;&lt;br /&gt;매달 나가는 돈이 적어야, 서비스는 오래 살아남을 수 있다.&lt;br /&gt;(카드값이 가벼워야 마음도 가볍다&amp;hellip;)&lt;/p&gt;
&lt;p data-end=&quot;520&quot; data-start=&quot;459&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;609&quot; data-start=&quot;522&quot; data-ke-size=&quot;size16&quot;&gt;사실상 봄봄의 고정비 대부분은 &lt;b&gt;AWS 서버 비용&lt;/b&gt;이다.&lt;br /&gt;도메인, S3 같은 것도 있지만, 진짜 돈을 쓰는 곳은 EC2, RDS 같은 인프라 쪽이다.&lt;/p&gt;
&lt;p data-end=&quot;609&quot; data-start=&quot;522&quot; data-ke-size=&quot;size16&quot;&gt;(아 물론 도메인 비용은 내년부터 5만원이다 ㅜㅜ)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/balpbB/dJMcad1xVgP/CzPdCkCUzaQ0xEhPg0NmY0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/balpbB/dJMcad1xVgP/CzPdCkCUzaQ0xEhPg0NmY0/img.jpg&quot; data-alt=&quot;도메인 더 싼걸로 할껄&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/balpbB/dJMcad1xVgP/CzPdCkCUzaQ0xEhPg0NmY0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbalpbB%2FdJMcad1xVgP%2FCzPdCkCUzaQ0xEhPg0NmY0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;187&quot; height=&quot;140&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;300&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도메인 더 싼걸로 할껄&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;609&quot; data-start=&quot;522&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;692&quot; data-start=&quot;611&quot; data-ke-size=&quot;size16&quot;&gt;팀원들과 논의한 결과, 봄봄의 서버 운영 예산은&lt;/p&gt;
&lt;p data-end=&quot;692&quot; data-start=&quot;611&quot; data-ke-size=&quot;size16&quot;&gt;한 달에 &lt;b&gt;2만 원 초반대&lt;/b&gt;,&lt;br /&gt;7명이기에&lt;b&gt;&amp;nbsp;14만 ~ 17만 원 수준&lt;/b&gt;으로 잡았다.&lt;/p&gt;
&lt;p data-end=&quot;804&quot; data-start=&quot;694&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;804&quot; data-start=&quot;694&quot; data-ke-size=&quot;size16&quot;&gt;단순히 이 예산 안에만 맞추는 게 아니라,&lt;br /&gt;&lt;b&gt;나중에 서비스가 더 커졌을 때도 유지 가능한 구조&lt;/b&gt;를 만들고 싶었다.&lt;br /&gt;그래서 &amp;ldquo;지금 줄일 수 있는 건 최대한 줄여 놓자&amp;rdquo;는 방향으로 정리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 서버비 줄이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말했지만 AWS 서버 비용이 대부분이라 이 부분을 최대한 줄여야했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중에 첫 번재 방법은 인스턴스 스펙 낮추고 Gravition으로 옮기기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;인스턴스 스펙 낮추기 / Graviton으로 옮기기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 대부분의 서버가 &lt;b&gt;t4g.nano&lt;/b&gt;, &lt;b&gt;t4g.micro&lt;/b&gt;처럼 최소 사양으로 운영되고 있다.&lt;/p&gt;
&lt;p data-end=&quot;355&quot; data-start=&quot;237&quot; data-ke-size=&quot;size16&quot;&gt;처음부터 &lt;b&gt;Graviton(ARM 기반) 인스턴스&lt;/b&gt;를 선택한 것도 같은 이유다.&lt;br /&gt;동일한 성능 대비 비용이 훨씬 저렴하기 때문에, 별도의 구조 변경 없이도 &lt;b&gt;고정비를 크게 줄일 수 있는 선택지&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;424&quot; data-start=&quot;357&quot; data-ke-size=&quot;size16&quot;&gt;간단히 말해, &lt;b&gt;ARM으로 전환하고 스펙을 낮추는 것만으로도 매달 나가는 서버비를 확실히 줄일 수 있었다&lt;/b&gt;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;인스턴스&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;vCPU&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;메모리&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;아키텍처&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;시간당 요금(USD)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;월 요금(약 730h)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;t2.micro&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1GB&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;x86&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$0.0128/h&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$9.34/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;t3.micro&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1GB&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;x86&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$0.0104/h&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$7.59/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;t4g.micro&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1GB&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;ARM(Graviton)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$0.0084/h&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;$6.13/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 굳이 x86을 고집하지 않기로 했을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 인스턴스를 고를 때 예전에는 아무 생각 없이 &lt;b&gt;t2, t3 같은 x86 계열&lt;/b&gt;부터 보는 게 자연스러웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;서버 = 인텔(x86)&amp;rdquo;이라는 인식이 워낙 강했기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;284&quot; data-start=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;하지만 봄봄 인프라를 설계하면서는, &lt;b&gt;굳이 x86을 계속 써야 할 이유가 없다고&lt;/b&gt; 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;284&quot; data-start=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;오히려 &lt;b&gt;ARM(Graviton)을 쓰는 쪽이 더 합리적&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;284&quot; data-start=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;284&quot; data-start=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;그 이유는 아래와 같다.&lt;/p&gt;
&lt;h4 data-end=&quot;314&quot; data-start=&quot;286&quot; data-ke-size=&quot;size20&quot;&gt;1) ARM은 더 이상 &amp;lsquo;실험용&amp;rsquo;이 아니다&lt;/h4&gt;
&lt;p data-end=&quot;399&quot; data-start=&quot;316&quot; data-ke-size=&quot;size16&quot;&gt;예전 ARM은 라즈베리파이, 스마트폰 같은 저전력 디바이스 느낌에 가까웠다.&lt;br /&gt;&amp;ldquo;운영 서버에 쓰기엔 아직 부족하지 않을까?&amp;rdquo; 하는 이미지가 강했다.&lt;/p&gt;
&lt;p data-end=&quot;421&quot; data-start=&quot;401&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이제는 상황이 완전히 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;606&quot; data-start=&quot;423&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;480&quot; data-start=&quot;423&quot;&gt;AWS가 &lt;b&gt;Graviton2, Graviton3, Graviton4&lt;/b&gt;까지 계속 밀어주고 있고&lt;/li&gt;
&lt;li data-end=&quot;545&quot; data-start=&quot;481&quot;&gt;공식 벤치마크에서도 &amp;ldquo;같은 돈이면 더 좋은 성능, 같은 성능이면 더 싼 비용&amp;rdquo;이라는 메시지를 강하게 이야기한다.&lt;/li&gt;
&lt;li data-end=&quot;606&quot; data-start=&quot;546&quot;&gt;실제로 EC2 요금표만 봐도, 같은 급에서는 &lt;b&gt;x86보다 Graviton(t4g 계열)이 더 싸다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;673&quot; data-start=&quot;608&quot; data-ke-size=&quot;size16&quot;&gt;즉, ARM은 더 이상 특이한 선택&amp;rdquo;이 아니라,안 쓰면 오히려 손해일 수 있는 기본 옵션에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;710&quot; data-start=&quot;675&quot; data-ke-size=&quot;size20&quot;&gt;2) 우리가 사용하는 스택은 ARM에서 전혀 문제 없다&lt;/h4&gt;
&lt;p data-end=&quot;737&quot; data-start=&quot;712&quot; data-ke-size=&quot;size16&quot;&gt;봄봄의 백엔드는 전형적인 웹 서비스 스택이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;811&quot; data-start=&quot;739&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;756&quot; data-start=&quot;739&quot;&gt;Java&lt;/li&gt;
&lt;li data-end=&quot;772&quot; data-start=&quot;757&quot;&gt;Spring Boot&lt;/li&gt;
&lt;li data-end=&quot;788&quot; data-start=&quot;773&quot;&gt;Docker 컨테이너&lt;/li&gt;
&lt;li data-end=&quot;811&quot; data-start=&quot;789&quot;&gt;MySQL등 오픈소스들&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;894&quot; data-start=&quot;813&quot; data-ke-size=&quot;size16&quot;&gt;이 조합은 이미 수많은 회사에서 &lt;b&gt;Graviton 위에서 정상적으로 운영&lt;/b&gt;되고 있고,&lt;br /&gt;도커 이미지도 arm64 빌드만 추가해주면 된다.&lt;/p&gt;
&lt;p data-end=&quot;965&quot; data-start=&quot;896&quot; data-ke-size=&quot;size16&quot;&gt;특별한 &lt;b&gt;네이티브 x86 바이너리&lt;/b&gt;를 쓰는 것도 아니라서,&lt;br /&gt;&amp;ldquo;x86이라서만 되는 무언가&amp;rdquo;가 우리 서비스에는 없다.&lt;/p&gt;
&lt;p data-end=&quot;1003&quot; data-start=&quot;967&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;굳이 x86을 써야 한다&amp;rdquo;는 기술적인 이유가 거의 없다.&lt;/p&gt;
&lt;h4 data-end=&quot;1039&quot; data-start=&quot;1005&quot; data-ke-size=&quot;size20&quot;&gt;3) 가격만 놓고 보면 x86을 선택하기가 더 어렵다&lt;/h4&gt;
&lt;p data-end=&quot;1067&quot; data-start=&quot;1041&quot; data-ke-size=&quot;size16&quot;&gt;단순히 요금만 놓고 보면 선택은 더 명확해진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1237&quot; data-start=&quot;1069&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1150&quot; data-start=&quot;1069&quot;&gt;같은 vCPU/메모리 기준으로&lt;br /&gt;&lt;b&gt;t2/t3(x86) &amp;lt; t4g(ARM)&lt;/b&gt; 순서로 나란히 놓고 보면&lt;br /&gt;대부분 t4g가 더 싸다.&lt;/li&gt;
&lt;li data-end=&quot;1237&quot; data-start=&quot;1151&quot;&gt;심지어 우리가 쓰는 구간처럼, &lt;b&gt;nano / micro / medium&lt;/b&gt; 같이 작은 스펙에서는&lt;br /&gt;&lt;b&gt;t4g가 사실상 최저가 라인&lt;/b&gt;에 가깝다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1315&quot; data-start=&quot;1239&quot; data-ke-size=&quot;size16&quot;&gt;즉, 현재 봄봄처럼 &amp;ldquo;어떻게든 고정비를 줄여야 하는&amp;rdquo; 상황에서&lt;br /&gt;&lt;b&gt;굳이 더 비싼 x86을 선택하는 건, 이유 없는 사치&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;h3 data-end=&quot;1877&quot; data-start=&quot;1871&quot; data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-end=&quot;1912&quot; data-start=&quot;1879&quot; data-ke-size=&quot;size16&quot;&gt;그래서 인스턴스를 고를 때 우리는 이렇게 스스로에게 물었다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1950&quot; data-start=&quot;1914&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1950&quot; data-start=&quot;1916&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;지금 이 서비스는 정말 x86을 써야만 하는 이유가 있나?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1968&quot; data-start=&quot;1952&quot; data-ke-size=&quot;size16&quot;&gt;그리고 내린 결론은 단순했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2071&quot; data-start=&quot;1970&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1995&quot; data-start=&quot;1970&quot;&gt;기술적으로도 ARM이 충분히 안정적이고&lt;/li&gt;
&lt;li data-end=&quot;2013&quot; data-start=&quot;1996&quot;&gt;가격은 ARM이 더 싸고&lt;/li&gt;
&lt;li data-end=&quot;2045&quot; data-start=&quot;2014&quot;&gt;우리가 사용하는 스택은 ARM에서 아무 문제 없고&lt;/li&gt;
&lt;li data-end=&quot;2071&quot; data-start=&quot;2046&quot;&gt;개발 환경도 이미 ARM이 일상인 시대다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2204&quot; data-start=&quot;2073&quot; data-ke-size=&quot;size16&quot;&gt;그래서 봄봄은 &lt;b&gt;굳이 x86을 고집하지 않고, 처음부터 Graviton(t4g) 인스턴스를 기본 선택지로 가져가는 쪽&lt;/b&gt;을 택했다.&lt;br /&gt;이 선택 하나만으로도, &lt;b&gt;똑같이 서버를 돌리면서 매달 나가는 돈을 꽤 크게 줄일 수 있었다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;그렇다면 인스턴스 타입은 왜 t 시리즈(버스터형)로 했을까?&amp;nbsp;&lt;/h4&gt;
&lt;p data-end=&quot;158&quot; data-start=&quot;141&quot; data-ke-size=&quot;size16&quot;&gt;그전에 인스턴스 타입에 대해 간단하게 알아보자&lt;/p&gt;
&lt;p data-end=&quot;158&quot; data-start=&quot;141&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;158&quot; data-start=&quot;141&quot; data-ke-size=&quot;size16&quot;&gt;EC2를 아주 단순하게 말하면&lt;/p&gt;
&lt;blockquote data-end=&quot;185&quot; data-start=&quot;160&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;185&quot; data-start=&quot;162&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;클라우드 위에 빌려 쓰는 컴퓨터&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;245&quot; data-start=&quot;187&quot; data-ke-size=&quot;size16&quot;&gt;인데, AWS가 여러 용도에 맞게 &lt;b&gt;미리 스펙을 나눠둔 PC 세트&lt;/b&gt;를 만들어 둔 거라고 보면 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;365&quot; data-start=&quot;247&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;274&quot; data-start=&quot;247&quot;&gt;어떤 건 &lt;b&gt;싸고 가볍게&lt;/b&gt; 쓰는 용도 (T)&lt;/li&gt;
&lt;li data-end=&quot;299&quot; data-start=&quot;275&quot;&gt;어떤 건 &lt;b&gt;균형 잡힌 기본형&lt;/b&gt; (M)&lt;/li&gt;
&lt;li data-end=&quot;329&quot; data-start=&quot;300&quot;&gt;어떤 건 &lt;b&gt;CPU 빡세게 돌리는 용도&lt;/b&gt; (C)&lt;/li&gt;
&lt;li data-end=&quot;365&quot; data-start=&quot;330&quot;&gt;어떤 건 &lt;b&gt;메모리(RAM) 엄청 많이 쓰는 용도&lt;/b&gt; (R)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 4개만 먼저 머릿속에 넣어두면 훨씬 편해진다.&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size18&quot;&gt;T 시리즈 - 가끔만 힘 쓰는 가성비형&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;ex) t4g.nano, t4g.micro ...&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;평소엔 조용히 돌아가는 경차지만 필요할 때 잠깐 터보 버튼 눌러서 쎄게 달리는 느낌이라고 생각하면 편하다.&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;평소에 CPU 사용량이 낮을 때 아주 싸게 쓸 수 있고 짧은 순간 트래픽이 확몰료도, 잠깐은 높은 CPU 성능으로 버스트가 가능하다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;만약 무제한 모드라면 CPU를 계속 일정 기준치 이상으로 쓴다면 그만큼 비용을 내야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;그래서 사이드 프로젝트나, 개인 서비스,&amp;nbsp; 소규모 서비스, 하루 종일 빡세게 돌리진 않는데 가끔 잠깐 바빠지는 서비스에 어울린다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size18&quot;&gt;M 시리즈 - 범용 인스턴스&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ex) m6g.large, m7g.medium&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;회사에서 주는 기본 사무용 PC 느낌이다.&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;CPU와 메모리가 적당히 균형 잡혀있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;T처럼 버스트 크레딧 이런 거 없기에 CPU를 80%, 90%를 써도 추가 요금이 붙지 않는다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 언제 쓰면 좋을까?&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;- 본 서비스용 API, 웹 서버&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;- 소규모/중간 정도 DB 서버&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;395&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1308&quot; data-start=&quot;1271&quot; data-ke-size=&quot;size18&quot;&gt;3. C 시리즈 &amp;ndash; &amp;ldquo;근육 많은 운동선수형&amp;rdquo; (컴퓨팅 최적화)&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;예: c7g.large, c7g.xlarge &amp;hellip;&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;생각하는 시간 말고, 연산은 내가 다 때려박는다&amp;rdquo; 타입&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;특징으로는 같은 가격이라면 &lt;b&gt;CPU 코어 수가 더 많고&lt;/b&gt;, 메모리는 M 시리즈보다 상대적으로 적은 편이다.&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;게다가&lt;b&gt; 계산/연산이 많은 작업&lt;/b&gt;에 최적화되어 있다.&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1340&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이건 언제쓰면 좋을까?&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1618&quot; data-start=&quot;1519&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1541&quot; data-start=&quot;1519&quot;&gt;&lt;b&gt;배치 작업, 로그/데이터 처리&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1585&quot; data-start=&quot;1542&quot;&gt;&lt;b&gt;통계 집계, 랭킹 계산, 추천 알고리즘&lt;/b&gt; 등 CPU를 많이 쓰는 코드&lt;/li&gt;
&lt;li data-end=&quot;1618&quot; data-start=&quot;1586&quot;&gt;&lt;b&gt;트래픽이 많고, 요청당 계산이 많은 API 서버&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1670&quot; data-start=&quot;1667&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;1708&quot; data-start=&quot;1672&quot; data-ke-size=&quot;size18&quot;&gt;4. R 시리즈 &amp;ndash; &amp;ldquo;메모리 빵빵한 서버&amp;rdquo; (메모리 최적화)&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;예: r6g.large, r7g.large &amp;hellip;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;비유하자면 램 64GB, 128GB 달린 고급 사무용 PC이라고 생각하면 좋다.&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;엑셀, IDE, 크롬 탭 50개 켜도 멀쩡한 컴퓨터 느낌??&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;특징으로는 같은 세대 기준으로 &lt;b&gt;CPU 개수는 M과 비슷하지만&amp;nbsp;&lt;/b&gt;대신 &lt;b&gt;메모리가 훨씬 더 많다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;하지만 메모리 많이 주는 만큼 &lt;b&gt;가격도 M보다 비싸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1739&quot; data-start=&quot;1710&quot; data-ke-size=&quot;size16&quot;&gt;언제쓰면 좋을까?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2116&quot; data-start=&quot;1934&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2010&quot; data-start=&quot;1934&quot;&gt;&lt;b&gt;DB 서버(MySQL, PostgreSQL 등)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2010&quot; data-start=&quot;1969&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2010&quot; data-start=&quot;1969&quot;&gt;InnoDB 버퍼 풀 크게 잡아서 &lt;b&gt;디스크 I/O 줄이고 싶을 때&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;2043&quot; data-start=&quot;2011&quot;&gt;&lt;b&gt;Redis / Memcached 같은 캐시 서버&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2116&quot; data-start=&quot;2044&quot;&gt;&lt;b&gt;검색/분석 서버&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2116&quot; data-start=&quot;2044&quot;&gt;(Elasticsearch, OpenSearch 등 인덱스를 메모리에 많이 올려두고 싶은 경우)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 바탕으로 생각해보았을 때&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 이유 때문에 t시리즈를 선택했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;124&quot; data-start=&quot;95&quot; data-ke-size=&quot;size23&quot;&gt;1. 필요한 최소 사양이 T 시리즈에만 있었다&lt;/h3&gt;
&lt;p data-end=&quot;243&quot; data-start=&quot;126&quot; data-ke-size=&quot;size16&quot;&gt;우리가 실제로 쓰고 싶었던 &lt;b&gt;낮은 등급(nano, micro, small)&lt;/b&gt; 은 T 시리즈에만 존재했다.&lt;br /&gt;반대로 M, C, R 같은 다른 패밀리는 대부분 &lt;b&gt;medium 또는 large부터&lt;/b&gt; 시작한다.&lt;/p&gt;
&lt;p data-end=&quot;358&quot; data-start=&quot;245&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이제 막 서비스를 시작한 입장에서,&lt;br /&gt;애초에 트래픽이 거의 없기 때문에 medium, large 급을 쓸 이유가 없다.&lt;br /&gt;지금 시점에서 그런 스펙을 선택하는 것은 완전히 &lt;b&gt;오버 스펙&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;416&quot; data-start=&quot;360&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;일단 가장 작은 단위로 시작하자&amp;rdquo;는 관점에서,&lt;br /&gt;선택지는 자연스럽게 T 시리즈로 좁혀졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;452&quot; data-start=&quot;423&quot; data-ke-size=&quot;size23&quot;&gt;2. 비용&lt;/h3&gt;
&lt;p data-end=&quot;472&quot; data-start=&quot;454&quot; data-ke-size=&quot;size16&quot;&gt;두 번째 이유는 &lt;b&gt;비용&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;567&quot; data-start=&quot;474&quot; data-ke-size=&quot;size16&quot;&gt;T 시리즈는 &lt;b&gt;CPU 버스트(짧은 시간 높은 CPU 사용)&lt;/b&gt;만 자주 일어나지 않는다면,&lt;br /&gt;같은 세대의 다른 인스턴스 타입에 비해 더 저렴하게 사용할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;567&quot; data-start=&quot;474&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;670&quot; data-start=&quot;569&quot; data-ke-size=&quot;size16&quot;&gt;지금처럼 트래픽이 크지 않고,&lt;br /&gt;CPU를 &lt;b&gt;계속 50~80% 이상 사용하는 구조가 아니라면&lt;/b&gt;,&lt;br /&gt;굳이 M이나 C처럼 &amp;ldquo;항상 일정 성능이 보장되는 인스턴스&amp;rdquo;를 쓸 필요가 없다.&lt;/p&gt;
&lt;p data-end=&quot;670&quot; data-start=&quot;569&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;670&quot; data-start=&quot;569&quot; data-ke-size=&quot;size16&quot;&gt;성능도 중요하지만 지금 그만큼의 성능도 필요없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;773&quot; data-start=&quot;742&quot; data-ke-size=&quot;size23&quot;&gt;3. 실제 CPU 사용량이 T 시리즈에 딱 맞는다&lt;/h3&gt;
&lt;p data-end=&quot;818&quot; data-start=&quot;775&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, &lt;b&gt;실제 CPU 사용량 데이터가 T 시리즈 선택을 뒷받침&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-end=&quot;882&quot; data-start=&quot;820&quot; data-ke-size=&quot;size16&quot;&gt;CloudWatch 그래프를 보면,&lt;br /&gt;대부분의 시간 동안 인스턴스의 &lt;b&gt;CPU 사용률이 5% 미만&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;938&quot; data-start=&quot;884&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;901&quot; data-start=&quot;884&quot;&gt;평소에는 거의 놀고 있고&lt;/li&gt;
&lt;li data-end=&quot;938&quot; data-start=&quot;902&quot;&gt;가끔 트래픽이 생기더라도 잠깐 버스트로 커버 가능한 수준이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1058&quot; data-start=&quot;940&quot; data-ke-size=&quot;size16&quot;&gt;즉, 현재와 가까운 미래의 트래픽을 고려했을 때&lt;br /&gt;&lt;b&gt;항상 높은 성능을 유지하는 인스턴스&lt;/b&gt;보다,&lt;br /&gt;평소에는 가볍게 돌다가 필요할 때만 잠깐 힘을 쓰는 &lt;b&gt;버스트형인 T 시리즈가 더 잘 맞는다&lt;/b&gt;고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c30U5V/dJMcabpamp1/ETNunyb0NvHPbbE9mvLUJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c30U5V/dJMcabpamp1/ETNunyb0NvHPbbE9mvLUJ0/img.png&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;418&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.7083%; margin-right: 10px;&quot; data-widthpercent=&quot;50.29&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c30U5V/dJMcabpamp1/ETNunyb0NvHPbbE9mvLUJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc30U5V%2FdJMcabpamp1%2FETNunyb0NvHPbbE9mvLUJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;790&quot; height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cd281Y/dJMcajtVgpN/kkrHAsbg47ArPFopfF6KA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cd281Y/dJMcajtVgpN/kkrHAsbg47ArPFopfF6KA0/img.png&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;424&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.1289%;&quot; data-widthpercent=&quot;49.71&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cd281Y/dJMcajtVgpN/kkrHAsbg47ArPFopfF6KA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcd281Y%2FdJMcajtVgpN%2FkkrHAsbg47ArPFopfF6KA0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;792&quot; height=&quot;424&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 월 약 $3.21 절감 &amp;rarr; &lt;b&gt;연 $38.5 절감&lt;/b&gt;을 할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2.&amp;nbsp; Saving plans 구매하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS에서 EC2를 쓰면 기본은 &lt;b&gt;온디맨드(On-Demand) 요금제&lt;/b&gt;다.&lt;br /&gt;딱 쓴 만큼만 과금되고, 약정도 없다. 대신 &lt;b&gt;가장 비싸다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-end=&quot;2173&quot; data-start=&quot;2110&quot; data-ke-size=&quot;size16&quot;&gt;AWS는 여기에 &amp;ldquo;장기 고객&amp;rdquo;을 위한 할인을 하나 더 얹어준다.&lt;br /&gt;그게 바로 &lt;b&gt;Savings Plans&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;2190&quot; data-start=&quot;2175&quot; data-ke-size=&quot;size16&quot;&gt;이걸 아주 단순하게 말하면:&lt;/p&gt;
&lt;blockquote data-end=&quot;2284&quot; data-start=&quot;2192&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2284&quot; data-start=&quot;2194&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;앞으로 1년(또는 3년) 동안,&lt;br /&gt;매시간 최소 얼마어치는 꼭 쓸게요&amp;rdquo;&lt;br /&gt;라고 약속하는 대신&lt;br /&gt;&lt;b&gt;온디맨드보다 싸게&lt;/b&gt; 쓰게 해주는 제도다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2305&quot; data-start=&quot;2286&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 포인트는 두 가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2371&quot; data-start=&quot;2307&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2329&quot; data-start=&quot;2307&quot;&gt;&lt;b&gt;기간 약정&lt;/b&gt;: 1년 또는 3년&lt;/li&gt;
&lt;li data-end=&quot;2371&quot; data-start=&quot;2330&quot;&gt;&lt;b&gt;금액 약정&lt;/b&gt;: &amp;ldquo;시간당 얼마만큼은 무조건 쓴다&amp;rdquo;라는 사용량 약속&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;2379&quot; data-start=&quot;2373&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;시간당 0.02 USD어치는 항상 쓸게요&amp;rdquo; 라고 1년 약정을 하면,&lt;/p&gt;
&lt;p data-end=&quot;2508&quot; data-start=&quot;2427&quot; data-ke-size=&quot;size16&quot;&gt;해당 금액까지는 &lt;b&gt;할인된 요금&lt;/b&gt;(Savings Plans 요금)이 적용되고,&lt;br /&gt;그걸 넘어가는 사용량은 &lt;b&gt;기존 온디맨드 요율&lt;/b&gt;로 계산된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2549&quot; data-start=&quot;2515&quot; data-ke-size=&quot;size23&quot;&gt;2. Reserved Instances랑 뭐가 다를까?&lt;/h3&gt;
&lt;p data-end=&quot;2633&quot; data-start=&quot;2551&quot; data-ke-size=&quot;size16&quot;&gt;비슷한 개념으로 &lt;b&gt;Reserved Instances(RI)&lt;/b&gt;도 있다.&lt;br /&gt;둘 다 &amp;ldquo;장기 약정 + 할인&amp;rdquo;이라는 점은 같지만, 성격이 조금 다르다.&lt;/p&gt;
&lt;p data-end=&quot;2646&quot; data-start=&quot;2635&quot; data-ke-size=&quot;size16&quot;&gt;아주 대충 정리하면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2866&quot; data-start=&quot;2648&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2757&quot; data-start=&quot;2648&quot;&gt;&lt;b&gt;Reserved Instances&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2757&quot; data-start=&quot;2677&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2718&quot; data-start=&quot;2677&quot;&gt;특정 인스턴스 타입, 리전, OS 등에 &lt;b&gt;꽤 딱 맞게&lt;/b&gt; 묶인다.&lt;/li&gt;
&lt;li data-end=&quot;2757&quot; data-start=&quot;2721&quot;&gt;&amp;ldquo;서울 리전의 t3.medium Linux, 1년&amp;rdquo; 이런 식.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;2866&quot; data-start=&quot;2758&quot;&gt;&lt;b&gt;Savings Plans&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2866&quot; data-start=&quot;2782&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2806&quot; data-start=&quot;2782&quot;&gt;좀 더 &lt;b&gt;유연하게&lt;/b&gt; 쓸 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;2866&quot; data-start=&quot;2809&quot;&gt;특히 &lt;b&gt;Compute Savings Plans&lt;/b&gt; 는 Fargate, Lambda까지 적용 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가격도 직접 비교해보았을 때 Savings Plans랑 같거나 거의 비슷한 수준이라서 유연성이 높은 Savings Plans을 사용하기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2202&quot; data-origin-height=&quot;466&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pbNkF/dJMcagqtLqe/soKVdrgUptWOMs9p9KyG5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pbNkF/dJMcagqtLqe/soKVdrgUptWOMs9p9KyG5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pbNkF/dJMcagqtLqe/soKVdrgUptWOMs9p9KyG5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpbNkF%2FdJMcagqtLqe%2FsoKVdrgUptWOMs9p9KyG5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;732&quot; height=&quot;155&quot; data-origin-width=&quot;2202&quot; data-origin-height=&quot;466&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3104&quot; data-origin-height=&quot;758&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VAiYT/dJMcabCHMUH/yg5VX3bnkcrJ9aYpi9muK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VAiYT/dJMcabCHMUH/yg5VX3bnkcrJ9aYpi9muK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VAiYT/dJMcabCHMUH/yg5VX3bnkcrJ9aYpi9muK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVAiYT%2FdJMcabCHMUH%2Fyg5VX3bnkcrJ9aYpi9muK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;680&quot; height=&quot;166&quot; data-origin-width=&quot;3104&quot; data-origin-height=&quot;758&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-end=&quot;2992&quot; data-start=&quot;2968&quot; data-ke-size=&quot;size23&quot;&gt;3. Savings Plans의 종류&lt;/h3&gt;
&lt;p data-end=&quot;3021&quot; data-start=&quot;2994&quot; data-ke-size=&quot;size16&quot;&gt;Savings Plans는 크게 두 종류가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3318&quot; data-start=&quot;3023&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;3150&quot; data-start=&quot;3023&quot;&gt;&lt;b&gt;Compute Savings Plans&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3150&quot; data-start=&quot;3055&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3065&quot; data-start=&quot;3055&quot;&gt;가장 유연하다.&lt;/li&gt;
&lt;li data-end=&quot;3125&quot; data-start=&quot;3069&quot;&gt;EC2 인스턴스 타입이 바뀌어도, 리전이 바뀌어도, Lambda/Fargate를 써도 할인 적용.&lt;/li&gt;
&lt;li data-end=&quot;3150&quot; data-start=&quot;3129&quot;&gt;그만큼 &lt;b&gt;할인율은 조금 낮다.&amp;nbsp;&lt;/b&gt;(2025년 12월 기준으로 약 30%정도 된다)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2194&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xsTaV/dJMcahiCvIK/3zNak7LuzZIdYMkARBKNVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xsTaV/dJMcahiCvIK/3zNak7LuzZIdYMkARBKNVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xsTaV/dJMcahiCvIK/3zNak7LuzZIdYMkARBKNVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxsTaV%2FdJMcahiCvIK%2F3zNak7LuzZIdYMkARBKNVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;578&quot; height=&quot;189&quot; data-origin-width=&quot;2194&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3318&quot; data-start=&quot;3023&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;3318&quot; data-start=&quot;3152&quot;&gt;&lt;b&gt;EC2 Instance Savings Plans&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3318&quot; data-start=&quot;3189&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3228&quot; data-start=&quot;3189&quot;&gt;특정 인스턴스 패밀리(예: t4g, m6g) + 리전에 묶인다.&lt;/li&gt;
&lt;li data-end=&quot;3271&quot; data-start=&quot;3232&quot;&gt;예) ap-northeast-2(서울) 리전의 t4g 패밀리&lt;/li&gt;
&lt;li data-end=&quot;3318&quot; data-start=&quot;3275&quot;&gt;대신 &lt;b&gt;Compute Savings Plans보다 할인율이 더 높다.&amp;nbsp;&lt;/b&gt;(2025년 12월 기준으로 약 40%정도 된다)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;866&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9wGoO/dJMcagD12Ay/DSV05fhQ62iR2TQop0ZEWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9wGoO/dJMcagD12Ay/DSV05fhQ62iR2TQop0ZEWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9wGoO/dJMcagD12Ay/DSV05fhQ62iR2TQop0ZEWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9wGoO%2FdJMcagD12Ay%2FDSV05fhQ62iR2TQop0ZEWk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;562&quot; height=&quot;293&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;866&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;3326&quot; data-start=&quot;3320&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 1년안에&amp;nbsp; ec2 인스턴스 타입이 바뀔 확률이 낮고 리전은 추가하면 추가했지 절대 바뀌지 않고 Lambda/Fargate도 사용량이 많지 않을 것이라 판단해 할인율이 더 높은 &lt;b&gt;EC2 Instance Savings&lt;/b&gt; &lt;b&gt;Plans&lt;/b&gt;을 구매했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;3471&quot; data-start=&quot;3451&quot; data-ke-size=&quot;size23&quot;&gt;4. 어떻게 할인되는 건가요?&lt;/h3&gt;
&lt;p data-end=&quot;3522&quot; data-start=&quot;3473&quot; data-ke-size=&quot;size16&quot;&gt;Savings Plans를 구매하면, AWS는 &lt;b&gt;매 시간마다&lt;/b&gt; 이런 식으로 계산한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3837&quot; data-start=&quot;3524&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;3588&quot; data-start=&quot;3524&quot;&gt;이 계정에 걸린 Savings Plans 약정 금액을 확인한다.&lt;br /&gt;예: &amp;ldquo;시간당 0.02 USD 약정&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;3665&quot; data-start=&quot;3590&quot;&gt;현재 이 계정에서 실제로 사용 중인 리소스 비용을 확인한다.&lt;br /&gt;예: &amp;ldquo;이번 시간 EC2 비용이 0.018 USD 나왔네?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;3747&quot; data-start=&quot;3667&quot;&gt;약정 금액(0.02 USD) 안에서 커버 가능한 usage에 대해서&lt;br /&gt;&lt;b&gt;할인 요금(Savings Plans 요율)&lt;/b&gt;을 적용한다.&lt;/li&gt;
&lt;li data-end=&quot;3837&quot; data-start=&quot;3749&quot;&gt;만약 실제 사용량이 약정을 넘으면(예: 0.03 USD 썼다),&lt;br /&gt;약정 금액까지는 할인 요금,&lt;br /&gt;그 이상은 기존 온디맨드 요율로 과금된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;3841&quot; data-start=&quot;3839&quot; data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3916&quot; data-start=&quot;3843&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3871&quot; data-start=&quot;3843&quot;&gt;&lt;b&gt;약정 금액 이하로 쓰면&lt;/b&gt; &amp;rarr; 전부 할인&lt;/li&gt;
&lt;li data-end=&quot;3916&quot; data-start=&quot;3872&quot;&gt;&lt;b&gt;약정 금액 이상으로 쓰면&lt;/b&gt; &amp;rarr; 약정 구간까지만 할인, 나머지는 온디맨드&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3986&quot; data-start=&quot;3918&quot; data-ke-size=&quot;size16&quot;&gt;그래서 약정을 걸 때,&lt;br /&gt;&lt;b&gt;너무 높게 잡으면 손해&lt;/b&gt;고,&lt;br /&gt;너무 낮게 잡으면 &amp;ldquo;할인을 더 받을 수 있었는데&amp;rdquo;가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;4156&quot; data-start=&quot;4123&quot; data-ke-size=&quot;size23&quot;&gt;5. Savings Plans를 사기 전까지&lt;/h3&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4158&quot; data-ke-size=&quot;size16&quot;&gt;세이빙 플랜을 사기 전, 봄봄의 EC2 사용 상황은 대략 이랬다.&lt;/p&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4194&quot; data-start=&quot;4158&quot; data-ke-size=&quot;size16&quot;&gt;봄봄에서 사용하는 인스턴스는 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;904&quot; data-start=&quot;782&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;820&quot; data-start=&quot;782&quot;&gt;t4g.micro 3대 (prod1, prod2, email)&lt;/li&gt;
&lt;li data-end=&quot;853&quot; data-start=&quot;821&quot;&gt;t4g.nano 3대 (lb 2대 + nat gatway 1대)&lt;/li&gt;
&lt;li data-end=&quot;884&quot; data-start=&quot;854&quot;&gt;t4g.medium 1대 (monitoring)&lt;/li&gt;
&lt;li data-end=&quot;904&quot; data-start=&quot;885&quot;&gt;t2.micro 1대 (dev) - 프리티어&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 총 8대의 EC2 인스턴스를 24시간 돌리고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 전부 온디맨드 요금으로만 계산하면 한 단 기준으로 EC2 비용만 대략 8~9만 원 정도가 나온다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 그냥 아무 생각 없이 온디맨드로만 1년을 버틴다라고 가정하면&amp;nbsp; 1년 동안 EC2만 100만 원 가까이 나가는 구조였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 Cost Explorer로 지난 몇 달간의 사용량을 쭉 보면서 트래픽이 갑자기 10배 튈 것 같지는 않고, 지금 구성도 이미 &quot;최소한으로 쳐낸 인프라&quot;에 가깝고 1년 동안은 최소 이 정도 인스턴스는 무조건 돌릴 수 밖에 없다는 걸 확인했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &quot;무조건 쓸 수 밖에 없는 최소 구간&quot;만큼은 온디맨드로 돈을 태우지 말고, EC2 Savings Plans 1년 약정으로 묶어버리기로 했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어차피 앞으로 확장 계획도 있기에 적어도 이것보다는 많이 쓸 것 같았고 같은 t4g에서 언제든 다른 타입으로 바꿀 수 있기에 걱정이 없었다.&lt;/p&gt;
&lt;h3 data-end=&quot;4942&quot; data-start=&quot;4916&quot; data-ke-size=&quot;size23&quot;&gt;6. 왜 지금 이 타이밍에 약정을 걸었나&lt;/h3&gt;
&lt;p data-end=&quot;4997&quot; data-start=&quot;4944&quot; data-ke-size=&quot;size16&quot;&gt;Savings Plans는 &amp;ldquo;돌이키기 힘든 결정&amp;rdquo;이라,&lt;br /&gt;초기에는 나도 꽤 고민을 많이 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5088&quot; data-start=&quot;4999&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5027&quot; data-start=&quot;4999&quot;&gt;혹시 몇 달 뒤에 서비스를 접게 되면 어떡하지?&lt;/li&gt;
&lt;li data-end=&quot;5061&quot; data-start=&quot;5028&quot;&gt;인스턴스 타입을 전면 교체해야 할 상황이 오면 어떡하지?&lt;/li&gt;
&lt;li data-end=&quot;5088&quot; data-start=&quot;5062&quot;&gt;트래픽 패턴이 완전 바뀌어 버리면 어떡하지?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;5109&quot; data-start=&quot;5090&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는 다음 기준을 세웠다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;5233&quot; data-start=&quot;5111&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;5149&quot; data-start=&quot;5111&quot;&gt;팀원 전원이 &amp;ldquo;앞으로 1년은 최소한 운영해보자&amp;rdquo;에 동의할 것&lt;/li&gt;
&lt;li data-end=&quot;5183&quot; data-start=&quot;5150&quot;&gt;현재 인프라 구성이 &amp;ldquo;최소치에 가깝다&amp;rdquo;고 판단될 것&lt;/li&gt;
&lt;li data-end=&quot;5233&quot; data-start=&quot;5184&quot;&gt;약정 금액은 앞으로도 고려해서 80% 정도 수준으로 잡을 것&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;5312&quot; data-start=&quot;5235&quot; data-ke-size=&quot;size16&quot;&gt;이 기준을 만족한 시점에서,&lt;br /&gt;우리는 &amp;ldquo;이제는 약정을 걸어도 되겠다&amp;rdquo;고 보고&lt;br /&gt;&lt;b&gt;EC2 Savings Plans 1년&lt;/b&gt;을 구매했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총합 53.5$&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 한 달에 약 21.4$ 절감 -&amp;gt; &lt;b&gt;연 256.8$&lt;/b&gt;을 절감 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. NAT Gateway 비용 줄이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;곧 출시&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. ALB 대신 직접 로드밸런서 구성하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;곧 출시&lt;/p&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/770</guid>
      <comments>https://html-jc.tistory.com/770#entry770comment</comments>
      <pubDate>Tue, 9 Dec 2025 17:13:09 +0900</pubDate>
    </item>
    <item>
      <title>봄봄에서 서드파티 라이브러리를 대하는 방법</title>
      <link>https://html-jc.tistory.com/769</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zo2d9/dJMcah3TReb/GedXKKodouYwaT66isDR30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zo2d9/dJMcah3TReb/GedXKKodouYwaT66isDR30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zo2d9/dJMcah3TReb/GedXKKodouYwaT66isDR30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzo2d9%2FdJMcah3TReb%2FGedXKKodouYwaT66isDR30%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;787&quot; height=&quot;377&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스레터 검색 기능을 재설계 및 개선하다가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뜻밖에 먼저 건드려야 할 친구를 하나 발견했다. 바로 &lt;b&gt;HTML 태그&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;본문은 텍스트가 중요한데, 왜 굳이 태그까지 다 들고 있어야 하지?&amp;rdquo;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 질문에서 시작된 고민과, 그걸 정리해 가는 과정들을 한 번 적어봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;뉴스레터&amp;nbsp;검색,&amp;nbsp;첫&amp;nbsp;번째&amp;nbsp;장애물:&amp;nbsp;HTML&amp;nbsp;태그&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스에서 다루는 뉴스레터는 이런 식으로 생겼다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2576&quot; data-origin-height=&quot;1838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xzoiZ/dJMcaiobJyb/k7J6Y50WXY2stH5KTrdxB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xzoiZ/dJMcaiobJyb/k7J6Y50WXY2stH5KTrdxB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xzoiZ/dJMcaiobJyb/k7J6Y50WXY2stH5KTrdxB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxzoiZ%2FdJMcaiobJyb%2Fk7J6Y50WXY2stH5KTrdxB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;521&quot; height=&quot;372&quot; data-origin-width=&quot;2576&quot; data-origin-height=&quot;1838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 뉴스레터는 &lt;span&gt;&lt;b&gt;HTML 형태&lt;/b&gt;&lt;/span&gt;로 들어오고, DB에는 대략 이런 형태로 저장된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;698&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/omD21/dJMcajtPma1/fTxE0HSIJDzDDn1ei0tkl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/omD21/dJMcajtPma1/fTxE0HSIJDzDDn1ei0tkl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/omD21/dJMcajtPma1/fTxE0HSIJDzDDn1ei0tkl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FomD21%2FdJMcajtPma1%2FfTxE0HSIJDzDDn1ei0tkl0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;267&quot; height=&quot;227&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;698&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능을 개선하면서, 이 `&lt;span&gt;contents`&lt;/span&gt; 칼럼에서 &lt;span&gt;&lt;b&gt;HTML 태그를 걷어내는 작업&lt;/b&gt;&lt;/span&gt;이 필요해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 `&lt;span&gt;contents`&lt;/span&gt;에는 HTML 태그를 포함한 본문 전체가 들어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 칼럼의 길이가 보통 &lt;span&gt;&lt;b&gt;8,000자 ~ 15,000자&lt;/b&gt;&lt;/span&gt; 정도라는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 성능을 위해 일부 아티클만 골라서 &lt;span&gt;FULLTEXT INDEX&lt;/span&gt; + &lt;span&gt;ngram&lt;/span&gt; 파서를 쓰고 있는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 태그까지 전부 포함해서 토큰을 만들면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 수가 불필요하게 많아지고&lt;/li&gt;
&lt;li&gt;&lt;span&gt;아티클이 쌓일수록 &lt;/span&gt;&lt;b&gt;인덱스 용량이 기하급수적으로 증가&lt;/b&gt;&lt;span&gt;한다&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &amp;ldquo;검색에 진짜 필요한 텍스트&amp;rdquo;만 남겨놓고 인덱스를 만드는 게 훨씬 효율적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 HTML 태그를 걷어내면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본문 길이가 평균 &lt;span&gt;&lt;b&gt;800 ~ 1,000자 수준&lt;/b&gt;&lt;/span&gt;까지 줄어든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 인덱스 입장에서는 거의 &lt;span&gt;&lt;b&gt;10배 가까이 가벼워지는 셈&lt;/b&gt;&lt;/span&gt;이라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 태그 제거는 사실상 &amp;ldquo;있으면 좋은 기능&amp;rdquo;이 아니라 &lt;span&gt;&lt;b&gt;거의 필수에 가까운 기능&lt;/b&gt;&lt;/span&gt;이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 자연스럽게 다음 질문이 나온다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;HTML 태그, 어떻게 제거할까?&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제일 먼저 떠오른 건 &amp;ldquo;잘 만들어진 서드파티 라이브러리 하나 가져다 쓰자&amp;rdquo;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 단순히 정규표현식으로 지워버리는 건 리스크가 크다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 본문에 이런 문장이 들어올 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1763800027111&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;진짜 텍스트 안에 &amp;lt;이렇게&amp;gt; 꺾쇠를 써야 하는 경우&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 무지성으로 `&lt;span&gt;&amp;lt;[^&amp;gt;]*&amp;gt;`&lt;/span&gt; 같은 정규식으로 지워버리면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTML 태그가 아니라, 사용자가 적어둔 실제 텍스트까지 같이 날아갈 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음부터 정규식을 주력으로 쓰는 방식은 제외하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;HTML을 이해하고 파싱하는 서드파티&amp;rdquo;들을 먼저 살펴보기로 했다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span&gt;그렇다면, &lt;/span&gt;&lt;b&gt;어떤 서드파티들이 있을까?&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;서드 파티 종류&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. Jsoup&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jsoup.org/?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 사이트 링크&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/jhy/jsoup?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;깃허브 링크&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML을 DOM으로 파싱해서, text() 한 번으로 태그 싹 지우는 라이브러리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그/속성/텍스트 구조를 다 이해하는 제대로 된 파서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그 제거뿐 아니라, 크롤링, DOM 탐색, 필터링 등에도 자주 쓰임&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 라이브러리를 쓴다면 워낙 쉽기 때문에 학습 비용이 거의 없고 문서와 예제가 많다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 방식에서 DOM을 구성하기 때문에 아주 거대한 HTML에선 메모리/속도 이슈가 발생할 수 있다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3iW7g/dJMcabo30Aa/SgSH8GyBbBardFJ5tZHuHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3iW7g/dJMcabo30Aa/SgSH8GyBbBardFJ5tZHuHk/img.png&quot; data-origin-width=&quot;3386&quot; data-origin-height=&quot;1800&quot; data-is-animation=&quot;false&quot; width=&quot;610&quot; height=&quot;324&quot; style=&quot;width: 47.5848%; margin-right: 10px;&quot; data-widthpercent=&quot;48.14&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3iW7g/dJMcabo30Aa/SgSH8GyBbBardFJ5tZHuHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3iW7g%2FdJMcabo30Aa%2FSgSH8GyBbBardFJ5tZHuHk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3386&quot; height=&quot;1800&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bROL2X/dJMcaacCBBp/4Prcjw6hzxuYAiRDTe5qE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bROL2X/dJMcaacCBBp/4Prcjw6hzxuYAiRDTe5qE0/img.png&quot; data-origin-width=&quot;2484&quot; data-origin-height=&quot;1226&quot; data-is-animation=&quot;false&quot; style=&quot;width: 51.2524%;&quot; data-widthpercent=&quot;51.86&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bROL2X/dJMcaacCBBp/4Prcjw6hzxuYAiRDTe5qE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbROL2X%2FdJMcaacCBBp%2F4Prcjw6hzxuYAiRDTe5qE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2484&quot; height=&quot;1226&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레포지토리를 가보면 사용하는 사람도 많고 버전관리도 잘하고 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 본 것 중 가장 많은 사용자들이 있는 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. Jericho HTML Parser&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Jericho HTML Parser&lt;/span&gt;는 예전에 많이 쓰이던 HTML 파서 라이브러리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;최근에는 릴리스가 거의 멈춘 상태지만, &lt;/span&gt;&lt;b&gt;기본적인 텍스트 추출 기능 자체는 아직도 동작&lt;/b&gt;&lt;span&gt;한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 문서에서 텍스트를 뽑아내거나, 일부 태그를 분석&amp;middot;조작하는 용도로 설계되어 있다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jsoup처럼 &amp;ldquo;브라우저스러운 DOM&amp;rdquo;을 만들어서 다루기보다는, &lt;span&gt;&lt;b&gt;마크업을 훑으면서 필요한 부분만 처리하는 스타일&lt;/b&gt;&lt;/span&gt;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 릴리스가 2010년대 중반쯤이라, 최근 HTML5 환경이나 새로운 자바 버전에 맞춰진 발전은 거의 없다. &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2015년 이후로는 릴리스가 끊긴 상태라, &amp;ldquo;지속적으로 관리되는 라이브러리&amp;rdquo;를 선호하기에 선택하기는 어려울 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2602&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhMdUy/dJMcabCBc3y/GqMOG9p5MsqzPqsqDBTUiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhMdUy/dJMcabCBc3y/GqMOG9p5MsqzPqsqDBTUiK/img.png&quot; data-alt=&quot;공식 홈페이지 지원이 끝난 것 같다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhMdUy/dJMcabCBc3y/GqMOG9p5MsqzPqsqDBTUiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhMdUy%2FdJMcabCBc3y%2FGqMOG9p5MsqzPqsqDBTUiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;665&quot; height=&quot;126&quot; data-origin-width=&quot;2602&quot; data-origin-height=&quot;494&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공식 홈페이지 지원이 끝난 것 같다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;828&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dM5JJf/dJMcahJAxDd/q5Fy9647gZvUeyzGDGlKn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dM5JJf/dJMcahJAxDd/q5Fy9647gZvUeyzGDGlKn0/img.png&quot; data-alt=&quot;2015년 이후 유지보수 X&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dM5JJf/dJMcahJAxDd/q5Fy9647gZvUeyzGDGlKn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdM5JJf%2FdJMcahJAxDd%2Fq5Fy9647gZvUeyzGDGlKn0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;677&quot; height=&quot;251&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;828&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2015년 이후 유지보수 X&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. HTMLCleaner&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;HTMLCleaner는 이름 그대로, &lt;/span&gt;&lt;b&gt;지저분한 HTML을 한 번 &amp;ldquo;정리(clean)&amp;rdquo;해서 잘formed된 구조로 바꿔주는 자바 기반 HTML 파서&lt;/b&gt;&lt;span&gt;다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 목적 자체가 &amp;ldquo;웹에서 긁어온 더러운 마크업을 브라우저처럼 해석해서, 괜찮은 XML/HTML로 만들어주자&amp;rdquo;에 더 가깝다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTMLCleaner는 대략 이런 흐름으로 동작한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;깨진 태그, 잘못 닫힌 태그, 중첩이 엉킨 HTML을 파싱한다.&lt;/li&gt;
&lt;li&gt;브라우저가 DOM을 만들 때 비슷하게 적용하는 규칙을 따라, 태그를 재배치&amp;middot;보정해서 &lt;span&gt;&lt;b&gt;정상적인 트리 구조&lt;/b&gt;&lt;/span&gt;로 만든다.&lt;/li&gt;
&lt;li&gt;그 정리된 트리에서 텍스트를 꺼내 쓰거나, XML/깔끔한 HTML로 다시 출력할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Jsoup처럼 &amp;ldquo;바로 DOM에 접근해서 text()를 꺼내 쓰는 라이브러리&amp;rdquo;라기보다는,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;먼저 HTML을 정돈하고 나서 쓰는 정리기(Cleaner)&amp;rdquo;라는 색깔이 더 강하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;부터 보면, 정말 지저분하게 &lt;b&gt;깨진 HTML을 만나도 한 번 정리 과정을 거치고 나면 훨씬 다루기 편해진다는 점이&lt;/b&gt; 가장 크다. 게다가 결과를 꽤 깔끔한 HTML/XHTML 형태로 다시 내보낼 수 있기 때문에, 이후에 다른 XML/HTML 도구나 파이프라인에 태워야 할 때도 궁합이 괜찮다. &amp;ldquo;먼저 HTMLCleaner로 한 번 씻기고, 그 다음 다른 도구에 넘기는&amp;rdquo; 식의 구조를 만들기 좋은 편이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;b&gt;단점&lt;/b&gt;도 분명하다. 그냥 &amp;ldquo;HTML 태그만 빠르게 걷어내고 텍스트만 얻고 싶다&amp;rdquo; 정도의 요구라면, HTMLCleaner는 조금 과한 선택일 수 있다. 구조를 정리하고, 트리를 만들고, 다시 출력할 수 있는 기능까지 포함하다 보니, &lt;b&gt;단순 태그 제거만 필요한 경우에는 오버스펙에 가까운 느낌이다.&lt;/b&gt; 게다가 Jsoup처럼 친숙한 문법과 풍부한 예제를 제공하는 라이브러리는 아니라서, 처음 접하는 입장에서는 문서나 API 스타일이 다소 낯설게 느껴질 수도 있다. 프로젝트 분위기도 최신 트렌디한 라이브러리라기보다는 &amp;ldquo;꽤 오래된 도구&amp;rdquo;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하자면, HTMLCleaner는 &lt;b&gt;HTML을 한 번 깨끗하게 &amp;lsquo;세탁&amp;rsquo;한 뒤에 &lt;/b&gt;텍스트를 뽑거나, 다른 도구들로 넘기고 싶을 때 고려해볼 만한 선택지다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. Apache Tika&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache Tika는 &amp;ldquo;텍스트 추출 전담 엔진&amp;rdquo;에 가까운 라이브러리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 HTML만 다루는 게 아니라, 아래 같은 것들을 전부 텍스트로 바꿔준다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML, XML&lt;/li&gt;
&lt;li&gt;PDF&lt;/li&gt;
&lt;li&gt;MS Office(Word, Excel, PowerPoint)&lt;/li&gt;
&lt;li&gt;OpenOffice, RTF&lt;/li&gt;
&lt;li&gt;이미지에서의 메타데이터 등등&amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용법 자체는 꽤 단순하다.&lt;/p&gt;
&lt;pre id=&quot;code_1763800474531&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Tika tika = new Tika();
String text = tika.parseToString(inputStreamOrBytes);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게만 호출해도, 파일 포맷을 스스로 감지해서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;이 문서에서 뽑을 수 있는 텍스트를 최대한 긁어서&amp;rdquo;&lt;/b&gt;&lt;span&gt; 문자열로 돌려준다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 라이브러리는, &lt;b&gt;&amp;ldquo;우리는 HTML만 처리하는 서비스야&amp;rdquo;&lt;/b&gt;&lt;span&gt; 같은 경우에는 솔직히 좀 과하고, &lt;/span&gt;&lt;b&gt;&amp;ldquo;메일 본문뿐 아니라 PDF&amp;middot;Word 같은 첨부파일까지 한 번에 검색하고 싶다&amp;rdquo;&lt;/b&gt;&lt;span&gt; 같은 요구가 있을 때 진지하게 검토할 만한 도구에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점을 정리하면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서 포맷을 일일이 구분하지 않고 &lt;span&gt;&lt;b&gt;&amp;ldquo;그냥 문서를 넣고 텍스트를 받고 싶을 때&amp;rdquo;&lt;/b&gt;&lt;/span&gt; 압도적으로 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일 시스템에서 첨부파일까지 검색 대상에 포함시키고 싶다면, Apache Tika는 거의 항상 후보 목록에 올라오는 편이다. &amp;ldquo;첨부까지 전부 검색 가능하게 만들고 싶다&amp;rdquo;는 요구가 나오는 순간, Tika는 꽤 매력적인 선택지가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 단점도 분명하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 태그만 제거해서 텍스트로 만들고 싶은 정도의 요구라면 &lt;span&gt;&lt;b&gt;너무 무겁다&lt;/b&gt;&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리 자체도 크고, 내부에서 포맷 감지 &amp;rarr; 파서 선택 &amp;rarr; 파싱 과정을 전부 거치기 때문에 그만큼 비용이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 단순히 &amp;ldquo;뉴스레터 본문 HTML &amp;rarr; 텍스트&amp;rdquo; 수준의 작업에는 Jsoup 같은 HTML 파서에 비해 확실히 과한 선택일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정리하자면, Apache Tika는 &lt;/span&gt;&lt;b&gt;HTML만 다루는 서비스 입장에선 다소 과한 도구&lt;/b&gt;&lt;span&gt;이고,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메일 본문뿐 아니라 PDF&amp;middot;Word 같은 첨부파일까지 함께 검색하고 싶을 때&lt;/b&gt;&lt;span&gt; 빛을 발하는 도구라고 볼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;검색 범위를 어디까지 보고 있는지&amp;rdquo;에 따라, 사용할지 말지를 결정하게 되는 라이브러리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. JFiveParse&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JFiveParse는 digitalfondue에서 만든 &lt;span&gt;&lt;b&gt;순수 자바 HTML5 파서&lt;/b&gt;&lt;/span&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름처럼 HTML5 파싱에 꽤 진심인 친구인데, html5lib에서 제공하는 토크나이저/트리 구성 테스트 중 스크립트가 필요 없는 부분은 전부 통과하도록 만들어졌다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특징을 정리하면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTML5 스펙을 꽤 충실히 따라가는 파서&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;html5lib 테스트 스위트(토크나이저 + 트리 구성 비스크립트 케이스)를 통과하도록 구현됨&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;0 dependencies + 작은 JAR 크기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 의존성이 아예 없고, JAR 크기도 대략 150KB 정도로 가볍게 유지하는 걸 목표로 한다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문서 전체/조각(fragment) 둘 다 파싱 가능&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;String에서 바로 파싱하거나, Reader를 통해 스트리밍 파싱도 지원한다. 다만 인코딩은 호출하는 쪽에서 알고 있어야 하고, 자동 인코딩 감지는 지원하지 않는다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지속적으로 관리되는 프로젝트&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub를 보면 2025년까지도 커밋이 이어지는 등, 생각보다 꾸준히 관리되고 있는 편이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점 쪽으로 보면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTML5 스펙에 더 가까운 파서가 필요할 때&lt;/b&gt;&lt;span&gt; 후보로 올릴 만하다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;브라우저가 파싱하는 방식과 얼추 비슷하게, 깨진 HTML까지 최대한 관대하게 받아주고 싶다&amp;rdquo; 쪽에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;라이브러리 크기/의존성에 민감할 때&lt;/b&gt;&lt;span&gt; 꽤 매력적이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 dependency + 소형 JAR이라, &amp;ldquo;메일 처리용 유틸에 괜히 거대한 HTML 파서를 끌어오고 싶지 않다&amp;rdquo;는 팀에 잘 맞는다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 단점/고민거리는 이런 쪽에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생태계와 예제가 Jsoup만큼 풍부하진 않다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Maven Central에서 사용 예시를 보면, 실제로 의존하는 프로젝트 수가 많지는 않다.&lt;span&gt;&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;태그 싹 지우고 텍스트만&amp;rdquo;이라는 고수준 API는 직접 만들어야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DOM 비슷한 트리를 제공해주지만, Jsoup의 &lt;span&gt;doc.text()&lt;/span&gt;처럼 한 줄로 &amp;ldquo;텍스트만 주세요&amp;rdquo; 하는 느낌은 아니어서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 &lt;span&gt;JFiveParse&lt;/span&gt;로 파싱 &amp;rarr; 노드를 순회하면서 텍스트 노드만 모으는 &lt;span&gt;&lt;b&gt;커스텀 TextExtractor&lt;/b&gt;&lt;/span&gt;를 한 번 더 감싸서 쓰게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2794&quot; data-origin-height=&quot;1462&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/548ZQ/dJMcai9wCRw/XcmnKdJussO5Ocj5vVSKDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/548ZQ/dJMcai9wCRw/XcmnKdJussO5Ocj5vVSKDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/548ZQ/dJMcai9wCRw/XcmnKdJussO5Ocj5vVSKDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F548ZQ%2FdJMcai9wCRw%2FXcmnKdJussO5Ocj5vVSKDk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;636&quot; height=&quot;333&quot; data-origin-width=&quot;2794&quot; data-origin-height=&quot;1462&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;활발하지는 않지만 관리는 되고있다는 걸 알 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 굳이 여러 서드파티를 살펴볼까?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼핏&amp;nbsp;보면&amp;nbsp;&amp;ldquo;그냥&amp;nbsp;적당한&amp;nbsp;거&amp;nbsp;하나&amp;nbsp;골라&amp;nbsp;쓰면&amp;nbsp;되지&amp;nbsp;않나?&amp;rdquo;&amp;nbsp;싶은데,&amp;nbsp;막상&amp;nbsp;라이브러리&amp;nbsp;고르기&amp;nbsp;모드에&amp;nbsp;들어가&amp;nbsp;보면&amp;nbsp;체크리스트가&amp;nbsp;끝도&amp;nbsp;없이&amp;nbsp;늘어난다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유지보수가 잘 되고 있는지&lt;/li&gt;
&lt;li&gt;얼마나 많은 사람들이 쓰는지&lt;/li&gt;
&lt;li&gt;문서와 예제가 충분한지&lt;/li&gt;
&lt;li&gt;이슈가 꾸준히 처리되는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 것들을 보는 이유도 물론 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 내가 여러 후보를 일부러 더 찾아보는 &lt;span&gt;&lt;b&gt;진짜 이유&lt;/b&gt;&lt;/span&gt;는 따로 있다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&amp;ldquo;하나가 실패했을 때, 바로 대신 들어올 다음 주자가 있는가?&amp;rdquo;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서드파티 라이브러리는 결국 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;내가 통제할 수 없는 코드&lt;/b&gt;&lt;/span&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML이 비정상적으로 들어온다든지, 라이브러리 내부 버그라든지,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어딘가에서 예상 못 한 예외가 터질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 &lt;b&gt;그냥 실패로 끝낼 건지&lt;/b&gt;, &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니면 &lt;b&gt;다음 후보로 넘겨서 한 번 더 시도해볼 수 있는 구조인지&lt;/b&gt;에 따라 &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안정성 측면에서 차이가 크게 벌어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 나는 메인 1개 + 서브 1개 정도면 충분하다고 생각했고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 둘을 어떻게 엮어서 &quot;안전한 구조&quot;로 만들지에 더 집중했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DispatcherServlet의 getHandler()에서 가져온 아이디어&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 어떻게 잡을까 고민하다가,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우테코 레벨2에서 스프링을 배우면서 &lt;span&gt;DispatcherServlet&lt;/span&gt;을 뜯어보던 기억이 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 봤던 &lt;span&gt;getHandler()&lt;/span&gt; 메서드가 머릿속에 딱 떠올랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;DispatcherServlet&lt;/span&gt;은 한 번에 딱 하나의 핸들러만 고르는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 내부에서 하는 일은 대략 이런 흐름이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록된 &lt;span&gt;HandlerMapping&lt;/span&gt; 들을 &lt;span&gt;&lt;b&gt;순서대로&lt;/b&gt;&lt;/span&gt; 훑어보면서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이 요청을 처리할 수 있는 핸들러가 누구인지&amp;rdquo;를 차례로 물어보고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 가운데 가장 먼저 &amp;ldquo;제가 처리할 수 있습니다&amp;rdquo;라고 응답하는 핸들러를 선택한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;b&gt;여러 후보를 앞에서부터 차례로 시도해 보고, 가장 먼저 &amp;lsquo;가능하다&amp;rsquo;고 답하는 애를 채택하는 구조&lt;/b&gt;&lt;span&gt;다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 이 동작 방식이 HTML 파서에도 잘 어울린다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 아래처럼 1차원적인 &lt;span&gt;`try-catch`&lt;/span&gt;로만 묶는 구조가 아니라,&lt;/p&gt;
&lt;pre id=&quot;code_1763798989043&quot; class=&quot;angelscript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;try {
	서드파티 1 동작
} catch (e) {
	서드파티 2 동작
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 가능한 파서들을 리스트에 담아두고 앞에서부터 순서대로 돌려 보다가 &lt;b&gt;처음으로 성공한 결과를 바로 쓰는 방식&lt;/b&gt;으로 가져오고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 가능한 후보들을 리스트로 만들어두고, 차례대로 시도하면서, 가장 먼저 성공한 서드파티를 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 훨씬 읽기 좋고, 나중에 후보를 추가하거나 순서를 바꾸기 편하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 인터페이스를 정의해줬다.&lt;/p&gt;
&lt;pre id=&quot;code_1763809701967&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface HtmlTagCleaner {
    String clean(String html);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음, 실제로 HTML을 파싱해서 태그를 제거하는 구현체들을 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Jsoup를 사용하는 구현체는 이렇게 생겼다.&lt;/p&gt;
&lt;pre id=&quot;code_1763809774755&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import org.jsoup.Jsoup;

public class JsoupHtmlTagCleaner implements HtmlTagCleaner {

    @Override
    public String clean(String html) {
        return Jsoup.parse(html).text();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;(여기서 &lt;/span&gt;JFiveTextExtractor&lt;span&gt;, &lt;/span&gt;RegexHtmlTagCleaner&lt;span&gt; 등 다른 후보들도&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 인터페이스를 구현하도록 맞춰준다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 구현체를 &lt;span&gt;&lt;b&gt;failover 체인&lt;/b&gt;&lt;/span&gt;으로 묶기 위해 스프링 설정에서 &lt;span&gt;FailoverHtmlTagCleaner&lt;/span&gt;를 Bean으로 등록한다.&lt;/p&gt;
&lt;pre id=&quot;code_1763811602102&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class HtmlCleanerConfig {

    @Bean
    public HtmlTagCleaner htmlTagCleaner() {
        return new FailoverHtmlTagCleaner(
                new JsoupHtmlTagCleaner(),
                new JFiveTextExtractor(),
                new RegexHtmlTagCleaner()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 쪽에서는 &lt;span&gt;HtmlTagCleaner&lt;/span&gt; 인터페이스만 주입받고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤에 어떤 구현체들이 몇 개나 줄 서 있는지는 전혀 알 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1763811686093&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
public class FailoverHtmlTagCleaner implements HtmlTagCleaner {

    private final List&amp;lt;HtmlTagCleaner&amp;gt; cleaners;

// .. (생략)

    @Override
    public String clean(String html) {
        if (!StringUtils.hasText(html)) {
            return &quot;&quot;;
        }

        for (HtmlTagCleaner cleaner : cleaners) {
            try {
                String cleaned = cleaner.clean(html);
                if (cleaned != null) {
                    return cleaned;
                }
                log.warn(&quot;{} 가 null을 반환해서 다음 후보로 넘어갑니다.&quot;, cleaner.getClass().getSimpleName());
            } catch (Exception e) {
                log.warn(&quot;Cleaner {} 실패&quot;, cleaner.getClass().getSimpleName(), e);
            }
        }

        log.warn(&quot;모든 cleaners 실패&quot;);
        return html;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;FailoverHtmlTagCleaner&lt;span&gt;에서는&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HtmlTagCleaner&lt;span&gt; 구현체들을 &lt;/span&gt;List&amp;lt;HtmlTagCleaner&amp;gt;&lt;span&gt;로 받아두고&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;for&lt;/span&gt;문을 돌리면서 하나씩 &lt;span&gt;clean()&lt;/span&gt;을 호출해보고&lt;/li&gt;
&lt;li&gt;예외가 나면 quietly 로그만 남기고 다음 후보로 넘어가고&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처음으로 정상적인 결과를 돌려준 구현체의 결과를 그대로 반환&lt;/b&gt;&lt;span&gt;한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jsoup이 대부분의 케이스를 처리하고&lt;/li&gt;
&lt;li&gt;만약 Jsoup이 예상치 못한 HTML에서 실패하더라도&lt;/li&gt;
&lt;li&gt;Jericho 같은 두 번째 후보가 뒤에서 받아준다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면, Jsoup이 대부분의 케이스를 처리하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Jsoup이 예상치 못한 HTML에서 실패하더라도,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;J&lt;/span&gt;&lt;span&gt;FiveTextExtractor&lt;/span&gt;나 정규식 기반 클리너 같은 두 번째, 세 번째 후보가 뒤에서 받아주는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;failover 구조&lt;/b&gt;&lt;/span&gt;가 자연스럽게 만들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 중요한 건&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;어떤 서드파티를 쓰느냐&amp;rdquo; 뿐만 아니라&lt;/blockquote&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;그 서드파티가 실패했을 때, 시스템이 어떻게 반응하도록 설계했느냐&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라고 생각했고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그 고민의 결과가 지금의 &lt;/span&gt;&lt;b&gt;리스트+순회 기반 Failover Cleaner&lt;/b&gt;&lt;span&gt; 구조다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이 구조로 한 이유 (확장성 / 테스트 / 운영 관점)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 굳이 이렇게까지 나눠서 설계한 이유는 단순히&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;멋있어 보이려고&amp;rdquo;가 아니라, 실제로 운영할 때 이점이 많기 때문이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 확장성: 새로운 파서를 붙이기 쉽다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 &amp;ldquo;새 HTML 파서를 한 번 써보고 싶다&amp;rdquo;라는 요구가 생기면 어떻게 될까?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NewFancyHtmlTagCleaner&lt;span&gt; 구현체를 하나 더 만들고&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;설정에서 이렇게만 바꿔주면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763798410814&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public HtmlTagCleaner htmlTagCleaner() {
    return new FailoverHtmlTagCleaner(
            new JsoupHtmlTagCleaner(),
            new JerichoHtmlTagCleaner(),
            new NewFancyHtmlTagCleaner(), // 추가
            new RegexHtmlTagCleaner()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;서비스 코드(&lt;/span&gt;ArticleService&lt;span&gt;, &lt;/span&gt;IngestionPipeline&lt;span&gt; 등)는&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여전히 &lt;/span&gt;&lt;b&gt;HtmlTagCleaner 인터페이스만 알고 있고&lt;/b&gt;&lt;span&gt;,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤에서 어떤 서드파티들이 줄 서 있는지는 전혀 모른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리를 교체하거나 순서를 바꾸는 일도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구현체 리스트만 건드리면 끝&lt;/b&gt;&lt;span&gt; 나기 때문에,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 입장에서 유지보수가 훨씬 편해진다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테스트: 가짜 Cleaner로 쉽게 시뮬레이션 가능&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 또 다른 장점은 &lt;span&gt;&lt;b&gt;테스트가 쉽다&lt;/b&gt;&lt;/span&gt;는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;첫 번째 파서가 실패하고, 두 번째 파서가 성공할 때&amp;rdquo; 케이스를 테스트하고 싶다면&lt;/li&gt;
&lt;li&gt;간단하게 이런 가짜 구현체들을 만들어서 넣어줄 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1763798456118&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class AlwaysFailCleaner implements HtmlTagCleaner {
    @Override
    public String clean(String html) {
        throw new RuntimeException(&quot;항상 실패&quot;);
    }
}

class AlwaysSuccessCleaner implements HtmlTagCleaner {
    @Override
    public String clean(String html) {
        return &quot;cleaned&quot;;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 테스트에서는&lt;/p&gt;
&lt;pre id=&quot;code_1763798464924&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HtmlTagCleaner cleaner = new FailoverHtmlTagCleaner(
        new AlwaysFailCleaner(),
        new AlwaysSuccessCleaner()
);

String result = cleaner.clean(&quot;&amp;lt;p&amp;gt;hello&amp;lt;/p&amp;gt;&quot;);
// result == &quot;cleaned&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 시나리오별로 조합해서 넣을 수 있기 때문에,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;failover 로직이 제대로 동작하는지&lt;/li&gt;
&lt;li&gt;모든 후보가 실패했을 때 raw HTML을 돌려주는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 것들을 쉽게 검증할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;운영: 장애가 나도 &amp;ldquo;어디서, 얼마나&amp;rdquo;가 보인다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 이 구조는 &lt;span&gt;&lt;b&gt;운영과 모니터링&lt;/b&gt;&lt;/span&gt; 관점에서도 이점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jsoup에서 예외가 나면 로그에 JsoupHtmlTagCleaner 실패 이유=...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jericho에서 또 예외가 나면 JerichoHtmlTagCleaner 실패 이유=...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처럼 정확히 어느 레벨에서 실패했는지 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 로그/메트릭을 보면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;전체 요청 중 몇 %가 메인 파서에서 처리되었는지&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;얼마나 자주 failover가 발생하는지&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;특정 뉴스레터 발행처에서만 유난히 자주 깨지는 HTML이 들어오는지&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 것들을 파악할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 중요한 건,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;어떤 서드파티 하나가 의도대로 동작하지 않더라도,&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote style=&quot;color: #0e0e0e;&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;메일 저장과 검색 파이프라인 전체가 같이 쓰러지지 않게 막는 구조&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리해 보면, 이번 작업은 단순히 &amp;ldquo;HTML 태그를 한 번 지워보자&amp;rdquo; 수준의 문제가 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스레터 본문이 대부분 HTML로 들어오고 검색 인덱스 용량과 응답 속도에 직접적인 영향을 주고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서드파티 라이브러리가 언제든지 예상치 못한 방식으로 실패할 수 있기 때문에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;무엇을 쓰느냐&amp;rdquo; 못지않게 &amp;ldquo;어떻게 설계하느냐&amp;rdquo;가 중요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 태그를 지우는 동작이 실패하면 인덱스 10배는 더 들어가고 만약 실패하면 검색기능이 제대로 되지 않기 때문에 어떻게든 동작해야했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 봄봄에서는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;`&lt;/span&gt;Jsoup` 같은 검증된 라이브러리를 1순위로 쓰되 필요하면 다른 파서로 자연스럽게 넘어갈 수 있는 failover 구조를 만들고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 코드에서는 &lt;span&gt;`HtmlTagCleaner`&lt;/span&gt;라는 인터페이스만 바라보게 해서 성능&amp;middot;안정성&amp;middot;확장성 사이의 균형을 맞추려고 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로도 새로운 HTML 파서나 텍스트 추출 도구를 도입하게 되더라도,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드를 뜯어고치기보다는 이 체인에 &amp;ldquo;후보 하나 추가&amp;rdquo;하는 수준에서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유연하게 대응할 수 있을 거라고 기대하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 태그를 어떻게 지울지 고민하고 있다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;정규식으로 한 방에&amp;rdquo; 대신 어떤 서드파티를 쓸지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 그 서드파티가 실패했을 때 어떻게 다룰지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;까지 같이 고민해보면 좋을 것 같다.&lt;/p&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/769</guid>
      <comments>https://html-jc.tistory.com/769#entry769comment</comments>
      <pubDate>Sat, 22 Nov 2025 21:33:51 +0900</pubDate>
    </item>
    <item>
      <title>봄봄 서비스에 맞게 검색 성능 개선기</title>
      <link>https://html-jc.tistory.com/768</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-11-20 오후 10.03.12.png&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z0tun/dJMcafE0cEf/tuXkOmHP6KXKBO80BgCad0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z0tun/dJMcafE0cEf/tuXkOmHP6KXKBO80BgCad0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z0tun/dJMcafE0cEf/tuXkOmHP6KXKBO80BgCad0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz0tun%2FdJMcafE0cEf%2FtuXkOmHP6KXKBO80BgCad0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;368&quot; data-filename=&quot;스크린샷 2025-11-20 오후 10.03.12.png&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 검색을 정말 단순한 문제라고 생각했다.&lt;br /&gt;그냥 검색어만 넣으면 결과가 쭉 나오면 되는 거 아닌가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;172&quot; data-start=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;그런데 직접 구현하고, 부하 테스트까지 돌려 보니&lt;br /&gt;생각보다 함께 고려해야 할 것들이 훨씬 많다는 걸 알게 됐다.&lt;br /&gt;그리고 아마 앞으로도 계속 고민할 지점이 남아 있을 것 같다.&lt;/p&gt;
&lt;p data-end=&quot;172&quot; data-start=&quot;71&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;237&quot; data-start=&quot;174&quot; data-ke-size=&quot;size16&quot;&gt;지난 몇 달 동안 검색 기능을 조금씩 만들고 고쳐 오면서 했던 생각들을&lt;br /&gt;드디어 글로 정리하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  우리 서버의 검색기능&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2890&quot; data-origin-height=&quot;1758&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pOBVf/dJMcacBuh9j/rkEvxfDSMivrWtdbzkDrfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pOBVf/dJMcacBuh9j/rkEvxfDSMivrWtdbzkDrfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pOBVf/dJMcacBuh9j/rkEvxfDSMivrWtdbzkDrfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpOBVf%2FdJMcacBuh9j%2FrkEvxfDSMivrWtdbzkDrfK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;663&quot; height=&quot;403&quot; data-origin-width=&quot;2890&quot; data-origin-height=&quot;1758&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봄봄 서비스에는 &amp;ldquo;내가 받은 아티클 안에서 검색하는 기능&amp;rdquo;이 있다.&lt;br /&gt;처음 구현은 아주 단순했다.&lt;/p&gt;
&lt;pre id=&quot;code_1763362142170&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE title LIKE '%:keyword%'
   OR content LIKE '%:keyword%'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 제목이나 본문에 keyword가 어디에라도 포함되어 있으면 검색 결과에 나오도록 한 것이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는&amp;nbsp;이&amp;nbsp;단순함의&amp;nbsp;대가가&amp;nbsp;너무&amp;nbsp;컸다는&amp;nbsp;거다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 괜찮았는데 데이터가 점점 쌓이다보니 일부 검색은 응답시간이 6초까지 나오게 되었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 페이지는 떴는데, 검색 버튼 한 번 눌렀을 뿐인데 6초를 기다려야 한다면 개발자인 나조차도 안 쓰고 싶다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;584&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c14ghC/dJMcajUODrM/jgHYjYuDMFOzk8g8nAfjN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c14ghC/dJMcajUODrM/jgHYjYuDMFOzk8g8nAfjN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c14ghC/dJMcajUODrM/jgHYjYuDMFOzk8g8nAfjN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc14ghC%2FdJMcajUODrM%2FjgHYjYuDMFOzk8g8nAfjN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;186&quot; height=&quot;178&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;584&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;751&quot; data-start=&quot;719&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;❓ &lt;/span&gt;왜 `LIKE %keyword%`는 이렇게 느릴까?&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-end=&quot;751&quot; data-start=&quot;719&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;핵심은 간단하다. LIKE '%keyword%'는 일반 B-Tree 인덱스를 제대로 못 탄다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;B-Tree 인덱스는 왼쪽부터 정렬된 값을 기준으로 &amp;ldquo;어디서부터 읽기 시작할지(start key)&amp;rdquo;를 잡고 내려가는 구조다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;LIKE abc% &amp;rarr; abc로 시작하는 구간을 ['abc', 'abd')처럼 범위로 잡고 range scan이 가능하다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;LIKE %abc% &amp;rarr; 앞에 %가 붙어 있어서, 어디서부터 읽어야 할지 시작 지점을 정할 수 없다.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;시작 지점을 못 정하면 어떻게 될까?&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;결국 &amp;ldquo;그냥 다 훑어본다(풀 스캔)&amp;rdquo; 쪽으로 떨어진다.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;데이터가 조금일 때는 티가 안 나지만, 뉴스레터가 쌓이고 회원이 늘어나면 이 방식은 데이터 양만큼 선형으로 느려지는 구조다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size20&quot;&gt;속도만의 문제는 아니었다.&lt;/h4&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;문제는 성능만이 아니었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;뉴스&amp;middot;시사 도메인에서 사용자는 종종 이렇게 검색한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1418&quot; data-start=&quot;1236&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1288&quot; data-start=&quot;1236&quot;&gt;&quot;기준 금리 인하&quot;, &quot;소비 심리&quot;처럼 &lt;b&gt;공백이 섞인 표현&lt;/b&gt;을 그대로 입력하거나&lt;/li&gt;
&lt;li data-end=&quot;1343&quot; data-start=&quot;1289&quot;&gt;정확한 단어 대신 &quot;금리 인하&quot;, &quot;소비심리&quot;처럼 &lt;b&gt;조합을 조금 다르게&lt;/b&gt; 입력하거나&lt;/li&gt;
&lt;li data-end=&quot;1418&quot; data-start=&quot;1344&quot;&gt;아예 &quot;리쇼어링&quot;이 아니라 &quot;쇼어링&quot;, &quot;연착륙&quot;이 아니라 &quot;착륙&quot;처럼 &lt;b&gt;단어 조각만&lt;/b&gt; 떠올리기도 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 최근에 어떤 뉴스레터에서 이런 문장을 봤다고 해보자.&lt;/p&gt;
&lt;blockquote data-end=&quot;1494&quot; data-start=&quot;1458&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1494&quot; data-start=&quot;1460&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이번 조사에서 &lt;b&gt;소비 심리 회복&lt;/b&gt;이 뚜렷하게 나타났다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 나중에 이 아티클을 다시 찾고 싶을 때&lt;br /&gt;꼭 &quot;소비 심리 회복&quot;을 정확히 입력하지는 않는다.&lt;/p&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;&quot;소비 심리&quot;만 치기도 하고&lt;/p&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;&quot;소비심리&quot;라고 붙여 쓰기도 하고&lt;/p&gt;
&lt;p data-end=&quot;1555&quot; data-start=&quot;1496&quot; data-ke-size=&quot;size16&quot;&gt;심지어 &quot;심리 회복&quot; 같은 애매한 조각만 떠올릴 수도 있다.&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1643&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1643&quot; data-ke-size=&quot;size16&quot;&gt;LIKE는 이런 상황에 꽤 까다롭다.&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1643&quot; data-ke-size=&quot;size16&quot;&gt;&quot;소비 심리&quot;로 검색하면 본문에 정확히 소비␣심리가 &lt;b&gt;그대로 연속해서&lt;/b&gt; 들어 있어야 매칭되고&lt;/p&gt;
&lt;p data-end=&quot;1663&quot; data-start=&quot;1643&quot; data-ke-size=&quot;size16&quot;&gt;&quot;소비심리&quot;로 검색하면 소비심리라는 문자열이 통째로 있어야 찾을 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1794&quot; data-start=&quot;1786&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1794&quot; data-start=&quot;1786&quot; data-ke-size=&quot;size16&quot;&gt;결국 LIKE는 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;이 문자열 조각이 &lt;/span&gt;&lt;b&gt;이 모양 그대로&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 붙어 있는지&amp;rdquo; &lt;/span&gt;를 보는 데는 강하지만,&lt;/p&gt;
&lt;p data-end=&quot;1794&quot; data-start=&quot;1786&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;여러 단어를 토큰처럼 쪼개서, &lt;/span&gt;공백&amp;middot;순서&amp;middot;위치가 조금 달라도 관련 문서를 유연하게 찾아주는 검색&amp;rdquo; 을 만들기에는 한계가 있다.&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1961&quot; data-start=&quot;1932&quot; data-ke-size=&quot;size20&quot;&gt;도메인 특성상 &amp;ldquo;부분 검색&amp;rdquo;은 포기하기 어렵다&lt;/h4&gt;
&lt;p data-end=&quot;1979&quot; data-start=&quot;1963&quot; data-ke-size=&quot;size16&quot;&gt;여기에 도메인 특성이 얹힌다.&lt;/p&gt;
&lt;p data-end=&quot;1993&quot; data-start=&quot;1981&quot; data-ke-size=&quot;size16&quot;&gt;뉴스/시사 도메인에서는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2040&quot; data-start=&quot;1995&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2018&quot; data-start=&quot;1995&quot;&gt;&amp;ldquo;리&amp;hellip; 뭐더라? 리쇼&amp;hellip; 리쇼어링?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;2040&quot; data-start=&quot;2019&quot;&gt;&amp;ldquo;연&amp;hellip; 뭐였지, 연착륙? 경착륙?&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2145&quot; data-start=&quot;2042&quot; data-ke-size=&quot;size16&quot;&gt;처럼 &lt;b&gt;앞&amp;middot;뒤가 흐릿한 상태&lt;/b&gt;에서 다시 찾는 일이 너무 많다.&lt;br /&gt;머릿속에 남는 건 온전한 단어 전체가 아니라,&lt;br /&gt;&lt;b&gt;&amp;lsquo;쇼어링&amp;rsquo;, &amp;lsquo;착륙&amp;rsquo; 같은 단어 조각&lt;/b&gt;인 경우가 대부분이다.&lt;/p&gt;
&lt;p data-end=&quot;2192&quot; data-start=&quot;2147&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 최근 통계청이 국가데이터처로 승격되었다.&lt;/p&gt;
&lt;p data-end=&quot;2192&quot; data-start=&quot;2147&quot; data-ke-size=&quot;size16&quot;&gt;사용자는 익숙하지 않는 표현이라&quot;국가데이터처&quot;를 기억하지 못하고&lt;/p&gt;
&lt;p data-end=&quot;2192&quot; data-start=&quot;2147&quot; data-ke-size=&quot;size16&quot;&gt;&quot;국가데이터&quot;까지만 치거나 &quot;데이터처&quot;만 칠 확률이 높다.&lt;/p&gt;
&lt;p data-end=&quot;2192&quot; data-start=&quot;2147&quot; data-ke-size=&quot;size16&quot;&gt;이때&amp;nbsp; 관련 뉴스&amp;middot;뉴스레터가 나와야 사용자의 &lt;b&gt;읽기 리듬을 끊지 않고&lt;/b&gt; 이어갈 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2297&quot; data-start=&quot;2284&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2297&quot; data-start=&quot;2284&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리 서비스에서는&lt;/p&gt;
&lt;blockquote data-end=&quot;2334&quot; data-start=&quot;2299&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2334&quot; data-start=&quot;2301&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;부분만 기억해도, 공백이 섞여 있어도 바로 찾아주는 검색&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2394&quot; data-start=&quot;2336&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2394&quot; data-start=&quot;2336&quot; data-ke-size=&quot;size16&quot;&gt;을 단순한 편의 기능이 아니라,&lt;br /&gt;&lt;b&gt;뉴스/시사 도메인에 꽤 필수에 가까운 요구사항&lt;/b&gt;이라고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;2394&quot; data-start=&quot;2336&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2438&quot; data-start=&quot;2396&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 요구사항을 만족시키려고 LIKE '%keyword%'를 썼더니&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2513&quot; data-start=&quot;2440&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2466&quot; data-start=&quot;2440&quot;&gt;실제 검색 속도는 &lt;b&gt;6초&lt;/b&gt;까지 치솟았고,&lt;/li&gt;
&lt;li data-end=&quot;2513&quot; data-start=&quot;2467&quot;&gt;최소한 &lt;b&gt;1초 이하&lt;/b&gt;로는 줄여야 &amp;ldquo;써볼 만한 기능&amp;rdquo;이 된다는 점이 드러났다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는&lt;/p&gt;
&lt;blockquote data-end=&quot;2606&quot; data-start=&quot;2529&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2606&quot; data-start=&quot;2531&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;부분 검색 + 공백이 섞인 여러 단어 검색 경험은 유지하면서,&lt;br /&gt;LIKE보다 훨씬 빠르게 만들 수 있는 방법이 없을까?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2683&quot; data-start=&quot;2608&quot; data-ke-size=&quot;size16&quot;&gt;라는 질문을 들고,&lt;br /&gt;그 다음 단계 설계를 시작했다.&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size26&quot;&gt;  해결 방안 후보&lt;/h2&gt;
&lt;h3 data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size23&quot;&gt;1. 캐싱 계층 적용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자주 검색되는 키워드에 대해 &lt;b&gt;검색 결과를 캐싱&lt;/b&gt;해 두는 방법도 고민했다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;쇼핑몰&amp;nbsp;검색처럼,&amp;nbsp;모두가&amp;nbsp;같은&amp;nbsp;상품&amp;nbsp;풀에서&amp;nbsp;`&quot;아이폰&quot;`,&amp;nbsp;`&quot;에어팟&quot;`&amp;nbsp;같은&amp;nbsp;키워드를&amp;nbsp;반복해서&amp;nbsp;검색하는&amp;nbsp;서비스라면&amp;nbsp;&amp;nbsp;&lt;br /&gt;한&amp;nbsp;번&amp;nbsp;계산한&amp;nbsp;검색&amp;nbsp;결과를&amp;nbsp;여러&amp;nbsp;사용자가&amp;nbsp;같이&amp;nbsp;재사용할&amp;nbsp;수&amp;nbsp;있어서&amp;nbsp;효과가&amp;nbsp;크다.&lt;br /&gt;&lt;br /&gt;하지만&amp;nbsp;봄봄의&amp;nbsp;검색은&amp;nbsp;구조가&amp;nbsp;조금&amp;nbsp;다르다.&lt;br /&gt;&lt;br /&gt;우리는 &amp;ldquo;전체 아카이브&amp;rdquo;가 아니라, 각 사용자가 받은 메일함 안에서만 검색한다.&lt;br /&gt;같은 `&quot;금리 인하&quot;`라는 키워드를 검색해도 A 사용자가 받은 뉴스레터와 B 사용자가 받은 뉴스레터가 다르기 때문에 검색 결과도 완전히 달라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 캐시 키가 유저 수 &amp;times; 키워드 수만큼 흩어지는 롱테일(long-tail) 구조라&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;ldquo;한&amp;nbsp;번&amp;nbsp;캐싱해&amp;nbsp;두면&amp;nbsp;여러&amp;nbsp;사람이&amp;nbsp;같이&amp;nbsp;쓰는&amp;rdquo;&amp;nbsp;전형적인&amp;nbsp;캐시&amp;nbsp;패턴이&amp;nbsp;아니다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;히트율이 낮을 가능성이 높다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 우리 서비스는&amp;nbsp; &amp;ldquo;자주 반복되는 글로벌 검색 결과를 여러 유저가 함께 쓰는&amp;rdquo; 구조가 아니라,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;각자 받은 메일박스 안에서, 가끔씩, 제각각 다른 키워드를 검색하는&amp;rdquo; 구조다.&lt;br /&gt;&lt;br /&gt;이런&amp;nbsp;특성에서는&amp;nbsp;&amp;nbsp;&lt;br /&gt;캐시를 위한 비용과 복잡도에 비해 얻을 수 있는 이득이 크지 않다고 보고,&amp;nbsp;&amp;nbsp;&lt;br /&gt;이번 검색 개선에서는 캐싱 계층 도입은 후보에서 제외했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 경량 검색 엔진 도입&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째로 고민했던 건 Meilisearch / Typesense 같은 경량 검색 엔진을 붙이는 것이었다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;결론만 말하면, 검색 품질과 성능만 놓고 보면 이게 가장 깔끔한 선택이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전담&amp;nbsp;검색&amp;nbsp;엔진을&amp;nbsp;쓰면&amp;nbsp;MySQL만&amp;nbsp;쓸&amp;nbsp;때보다&amp;nbsp;이런&amp;nbsp;점들이&amp;nbsp;좋아진다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;더 풍부한 검색 기능&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;prefix 검색, typo 허용, 필터 + 정렬 조합 등 다양한 쿼리 조합을 자연스럽게 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;더 나은 관련도 정렬(Relevance)&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;어떤 결과가 더 그럴듯한가?&amp;rdquo;를 점수로 표현하고, 상위 몇 개만 빠르게 가져올 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;확장성&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;검색 트래픽이 늘어나면 검색 엔진 인스턴스를 수평 확장해서 대응하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, MySQL 한 대로 LIKE・기본 FULLTEXT만 쓰는 검색에는 이런 한계가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한국어에&amp;nbsp;약함&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;&amp;ldquo;리쇼어링 / 리-쇼어링 / 리 쇼어링&amp;rdquo;처럼 표기가 조금만 달라져도 MySQL 기본 기능만으로는 &amp;ldquo;같은 단어다&amp;rdquo;라고 이해시켜 주기가 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동의어 처리 없음&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;&amp;ldquo;연착륙 / 소프트 랜딩&amp;rdquo;, &amp;ldquo;금리 인상 / 긴축&amp;rdquo;처럼 다른 단어지만 비슷한 맥락인 것들을 한꺼번에 묶어 주려면 직접 전처리&amp;middot;매핑 로직을 짜야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;페이징 성능&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;OFFSET / LIMIT 기반 페이징은 데이터가 쌓일수록 뒤 페이지로 갈수록 점점 느려진다.&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;부분 단어 검색의 구조적 한계&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;N-gram 같은 기능을 활용해 부분 검색을 구현할 수는 있지만, 인덱스 용량과 버퍼 풀 사이즈 안에서 어느 시점에는 타협이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스/시사&amp;nbsp;도메인은&amp;nbsp;&amp;ldquo;단어가&amp;nbsp;아니라&amp;nbsp;단어&amp;nbsp;조각으로&amp;nbsp;검색하고&amp;nbsp;싶은&amp;rdquo;&amp;nbsp;도메인이라&amp;nbsp;&amp;nbsp;&lt;br /&gt;이런 고급 검색 기능이 있으면 확실히 품질을 더 끌어올릴 수 있다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;그럼에도 우리는 검색 엔진 도입을 이번에는 보류했다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;이유는&amp;nbsp;두&amp;nbsp;가지&amp;nbsp;정도로&amp;nbsp;정리할&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;1. 비용 문제&lt;br /&gt;Meilisearch / Typesense를 쓰려면&amp;nbsp; MySQL과는 별도로 항상 떠 있는 인스턴스 1개 이상이 필요하다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;지금도 t4g.micro / t4g.micro 조합으로 인프라 비용을 아껴가면서 운영하고 있어서,&amp;nbsp; 여기에 &amp;ldquo;검색 엔진 전용 인스턴스&amp;rdquo;까지 추가하는 건&amp;nbsp; 현재 팀 상황에서는 부담이 컸다.&lt;br /&gt;&lt;br /&gt;2. 우리 서비스에서 검색의 위치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봄봄에서 검색은 &amp;ldquo;하루에도 몇 번씩 누르는 메인 기능&amp;rdquo;이라기보다는,&lt;br /&gt;&lt;b&gt;&amp;ldquo;필요한 순간엔 반드시 잘 되어 있어야 하는 기능&amp;rdquo;&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;185&quot; data-ke-size=&quot;size16&quot;&gt;예전에 훑어본 뉴스레터를 다시 찾아보거나,&lt;br /&gt;구독을 고민하면서 &amp;ldquo;그때 그 메일 다시 보고 싶다&amp;rdquo; 싶은 순간에 주로 쓰인다.&lt;br /&gt;사용 빈도는 높지 않지만, &lt;b&gt;한 번 검색할 때마다의 중요도는 꽤 높은 편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;185&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;380&quot; data-start=&quot;305&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이런 질문을 다시 꺼내 들었다.&lt;br /&gt;&amp;ldquo;지금 단계에서, 전용 검색 엔진을 붙일 만큼 검색이 비즈니스의 한가운데에 와 있을까?&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;407&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;내린 결론은 이거였다.&lt;/p&gt;
&lt;p data-end=&quot;407&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;ldquo;아직은 아니다.&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;407&quot; data-start=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;525&quot; data-start=&quot;409&quot; data-ke-size=&quot;size16&quot;&gt;정리하자면, 검색 엔진이 더 나은 해답인 건 분명하지만,&lt;br /&gt;현재 트래픽과 팀 리소스, 서비스 단계까지 함께 놓고 보면&lt;br /&gt;&lt;b&gt;지금은 검색 엔진까지 도입하는 것이 투자 대비 과한 선택&lt;/b&gt;에 가깝다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정리하면, 검색 엔진은 분명 더 좋은 해답이지만,&amp;nbsp; 지금 팀의 리소스와 서비스 단계에서는 과투자에 가깝다고 판단했다.&lt;/p&gt;
&lt;h3 data-end=&quot;766&quot; data-start=&quot;683&quot; data-ke-size=&quot;size23&quot;&gt;3. MySQL 플러그인 사용&lt;/h3&gt;
&lt;p data-end=&quot;766&quot; data-start=&quot;683&quot; data-ke-size=&quot;size16&quot;&gt;Mroonga는 MySQL에서 쓸 수 있는 스토리지 엔진 플러그인이다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;안쪽에는 Groonga라는 전문 검색 엔진이 들어 있고,&lt;/p&gt;
&lt;p data-end=&quot;766&quot; data-start=&quot;683&quot; data-ke-size=&quot;size16&quot;&gt;이걸&amp;nbsp;MySQL에서&amp;nbsp;바로&amp;nbsp;쓸&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;감싼&amp;nbsp;형태라고&amp;nbsp;보면&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 설명을 한 줄로 정리하면 대략 이런 느낌이다.&lt;/p&gt;
&lt;blockquote data-end=&quot;489&quot; data-start=&quot;391&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;489&quot; data-start=&quot;393&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;중국어&amp;middot;일본어&amp;middot;한국어를 포함해 모든 언어에서 빠른 전문 검색을 제공하는 MySQL용 스토리지 엔진&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1021&quot; data-start=&quot;992&quot; data-ke-size=&quot;size18&quot;&gt;왜&amp;nbsp;한국어에&amp;nbsp;Mroonga가&amp;nbsp;잘&amp;nbsp;맞을까?&lt;/p&gt;
&lt;p data-end=&quot;1038&quot; data-start=&quot;1023&quot; data-ke-size=&quot;size16&quot;&gt;한국어 검색이 힘든 이유는 띄어쓰기 믿기 어렵고 조사, 어미, 합성어가 많고 외래어, 영문, 숫자까지 뒤섞이기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;1223&quot; data-start=&quot;1098&quot; data-ke-size=&quot;size16&quot;&gt;기본 InnoDB FULLTEXT는 &lt;b&gt;영어 기준&lt;/b&gt;으로 설계되어 있어서,&lt;br /&gt;중국어&amp;middot;일본어&amp;middot;한국어(CJK) 텍스트에는 잘 맞지 않는다는 게 늘 문제였다.&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1223&quot; data-start=&quot;1098&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1272&quot; data-start=&quot;1225&quot; data-ke-size=&quot;size16&quot;&gt;Mroonga/Groonga 쪽은 애초에 &lt;b&gt;CJK 텍스트용 전문 검색 엔진이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1272&quot; data-start=&quot;1225&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;N-gram 방식&lt;/b&gt;으로 &amp;ldquo;문자 조각&amp;rdquo; 기준 검색을 잘 지원하고&lt;/p&gt;
&lt;p data-end=&quot;1272&quot; data-start=&quot;1225&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;다양한 토크나이저(tokenizer)를 통해 언어 특성에 맞게 쪼개서 인덱싱해준다.&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1272&quot; data-start=&quot;1225&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1453&quot; data-start=&quot;1442&quot; data-ke-size=&quot;size16&quot;&gt;그래서 한국어에서도 쇼어링만 쳐도 리쇼어링, 프렌드쇼어링 같은 것들을 잘 찾아주고 &quot;데이터처&quot;만 쳐도 &quot;국가데이터처&quot; 관련 글을 잘 집어내고 인덱스 기반이라 속도도 MySQL 기본 LIKE, 기본 FULLTEXT보다 훨씬 빠른 편이다.&lt;/p&gt;
&lt;p data-end=&quot;1453&quot; data-start=&quot;1442&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1453&quot; data-start=&quot;1442&quot; data-ke-size=&quot;size16&quot;&gt;하지만 우리 상황에서는 사용불가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1453&quot; data-start=&quot;1442&quot; data-ke-size=&quot;size16&quot;&gt;우리가 사용하는 DB는 RDS mysql이다&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1453&quot; data-start=&quot;1442&quot; data-ke-size=&quot;size16&quot;&gt;RDS mysql은 플러그인 사용이 불가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2804&quot; data-origin-height=&quot;1288&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wnayG/dJMcabbu5ds/ew5fOIYOTCHB0f97Suyu5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wnayG/dJMcabbu5ds/ew5fOIYOTCHB0f97Suyu5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wnayG/dJMcabbu5ds/ew5fOIYOTCHB0f97Suyu5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwnayG%2FdJMcabbu5ds%2Few5fOIYOTCHB0f97Suyu5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;734&quot; height=&quot;337&quot; data-origin-width=&quot;2804&quot; data-origin-height=&quot;1288&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;플러그인을 설치하는 데 필요한 모든 조건을 RDS가 막아놓았기 때문에 결과적으로 불가능한 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mysql 플러그인 설치에는 아래 3개가 반드시 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. OS에 .so파일 업로드(shared Library) -&amp;gt; RDS는 OS 접근 자체가 불가능&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Mysql 플러그인을 활성화를 위해 `INSTALL PLUGIN mroonga SONAME 'ha_mroonga.so'` 해당 명령어 입력 필요 -&amp;gt; RDS는 SUPER 권한을 안 줌&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 플러그인 설치 시 내부적으로 파일 읽기 작업이 발생하는데 RDS는 file 권한 역시 차단&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 aws에서는 이걸 왜 막을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS는 RDS를 다음과 같은 서비스로 정의함&lt;/p&gt;
&lt;blockquote data-end=&quot;636&quot; data-start=&quot;593&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;595&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;운영을 AWS가 대신 관리해주는 Managed Database 서비스.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;664&quot; data-start=&quot;638&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;664&quot; data-start=&quot;638&quot; data-ke-size=&quot;size16&quot;&gt;Managed DB에서 AWS가 책임지는 영역&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;746&quot; data-start=&quot;666&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;683&quot; data-start=&quot;666&quot;&gt;DB엔진 설치/업데이트/패치&lt;/li&gt;
&lt;li data-end=&quot;691&quot; data-start=&quot;684&quot;&gt;백업/복구&lt;/li&gt;
&lt;li data-end=&quot;698&quot; data-start=&quot;692&quot;&gt;모니터링&lt;/li&gt;
&lt;li data-end=&quot;720&quot; data-start=&quot;699&quot;&gt;장애 자동 감지 / Failover&lt;/li&gt;
&lt;li data-end=&quot;732&quot; data-start=&quot;721&quot;&gt;버전 호환성 유지&lt;/li&gt;
&lt;li data-end=&quot;746&quot; data-start=&quot;733&quot;&gt;보안 패치 자동 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;781&quot; data-start=&quot;748&quot; data-ke-size=&quot;size16&quot;&gt;플러그인이 설치되면 이 모든 보장이 깨질 수 있음.&lt;/p&gt;
&lt;p data-end=&quot;847&quot; data-start=&quot;783&quot; data-ke-size=&quot;size16&quot;&gt;플러그인이 버전별 호환성을 망가뜨리거나,&lt;br /&gt;업데이트할 때 죽어버리거나,&lt;br /&gt;crash recovery에 간섭할 수 있음.&lt;/p&gt;
&lt;p data-end=&quot;890&quot; data-start=&quot;849&quot; data-ke-size=&quot;size16&quot;&gt;AWS는 이 리스크를 관리할 방법이 없기 때문에 &lt;b&gt;일괄적으로 막는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;890&quot; data-start=&quot;849&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;890&quot; data-start=&quot;849&quot; data-ke-size=&quot;size16&quot;&gt;정리해보면 한국어 검색 품질과 성능만 놓고 보면&lt;br /&gt;Mroonga는 굉장히 매력적인 해법이지만,&lt;br /&gt;&lt;b&gt;&amp;ldquo;우리가 RDS MySQL을 쓰기로 한 인프라 선택&amp;rdquo;&lt;/b&gt; 때문에&lt;br /&gt;현실적으로는 고려할 수 없는 옵션이었다.&lt;/p&gt;
&lt;p data-end=&quot;890&quot; data-start=&quot;849&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Full-text Search&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Full-text Search는 말 그대로 &lt;b&gt;문서의 전체 텍스트를 대상으로 검색&lt;/b&gt;하는 기능이다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;문장/본문이&amp;nbsp;긴&amp;nbsp;컬럼을&amp;nbsp;통째로&amp;nbsp;훑는&amp;nbsp;대신,&lt;br /&gt;먼저 텍스트를 분석해서 &lt;b&gt;검색용 인덱스(역색인)&lt;/b&gt;를 만들어 두고&lt;br /&gt;나중에 검색할 때는 이 인덱스를 통해 &lt;b&gt;빠르게 후보를 찾는 방식&lt;/b&gt;으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Full-text Search 동작 방법&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 토큰화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문장을 단어 단위로 잘게 쪼갠다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) &quot;연착륙 전망 나오는 미국 경제&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; [&quot;연착륙&quot;, &quot;전망&quot;, &quot;나오는&quot;, &quot;미국&quot;, &quot;경제&quot;]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 역색인(Inverted Index) 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단어가 어디에서 등장하는지 역으로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) &quot;연착륙&quot; -&amp;gt; [문서 3, 문서 10, 문서 25]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미국 -&amp;gt; [문서 1, 문서 3, 문서 8, 문서 25]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조를 역색인라고 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 B-Tree 인덱스가 PRIMARY KEY -&amp;gt; 행을 찾는 지도라면, 역색인은 단어 -&amp;gt; 이 단어가 들어간 문서들을 찾는 지도에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 검색 시 빠른 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 &quot;연착륙 미국&quot;을 검색하면, &quot;연착륙&quot;이 들어간 문서 목록과 &quot;미국&quot;이 들어간 문서 목록을 가져와서 교집합을 구하는 식으로, 해당 단어들이 포함된 문서를 바로 찾는다. 이 과정에서 테이블 전체를 풀 스캔할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 관련도 점수 계산(Scoring)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Full-text Search는 단순히 &quot;포함 여부&quot;만 보는게 아니라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이 단어가 몇 번 나왔는지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 제목에 있는지, 본문에 있는지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 전체 텍스트 대비 비율은 어떤지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 것들을 이용해 관련도 점수(score)를 매긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 점수를 기준으로 ORDER BY score DESC 정렬을 해 주기 때문에,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 보기에도 &quot;더 그럴듯한 결과&quot;가 위로 올라온다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만&amp;nbsp;MySQL의&amp;nbsp;`FULLTEXT`&amp;nbsp;검색은&amp;nbsp;기본적으로&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;b&gt;영어 및 공백 기반 언어&lt;/b&gt;에 잘 맞게 설계되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공백&amp;middot;구두점을 기준으로 단어를 나누고 형태소 분석을 따로 해주지는 않는다.&lt;br /&gt;&lt;br /&gt;그래서 한글 뉴스 본문처럼 띄어쓰기가 애매하거나 조사/어미/합성어가 많고 외래어, 영어, 숫자가 섞여 있는 텍스트에 대해서는&lt;br /&gt;&amp;ldquo;그럭저럭 동작은 하지만, 한국어에 완전히 최적화된 검색이라고 보긴 어렵다.&amp;rdquo; 라는 한계가 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 빈 부분을 우리는 &lt;b&gt;n그램 + FULLTEXT 혼합 전략&lt;/b&gt;으로 어느 정도 보완해 보려 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;우리가 선택한 방법&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러&amp;nbsp;가지&amp;nbsp;대안을&amp;nbsp;검토한&amp;nbsp;끝에,&amp;nbsp;&amp;nbsp;&lt;br /&gt;우리는 RDS MySQL에서 제공하는 &lt;b&gt;FULLTEXT Search(기본 파서 + ngram 테이블을 함께 쓰는 구조)&lt;/b&gt;를 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는&amp;nbsp;아래와&amp;nbsp;같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 추가 인프라 비용이 들지 않는다.&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;Elasticsearch / Meilisearch / Typesense 같은 검색 전용 엔진을 새로 띄우지 않고,&amp;nbsp;&amp;nbsp;현재 사용 중인 RDS MySQL 안에서 해결할 수 있다.&lt;br /&gt;&lt;br /&gt;2. 기존 MySQL 운영/백업 체계를 그대로 활용할 수 있다.&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;검색 전용 테이블과 FULLTEXT 인덱스도 RDS 자동 백업/스냅샷에 같이 포함되기 때문에 장애 대응, 복구, 모니터링을 별도로 설계할 필요가 없다.&lt;br /&gt;&lt;br /&gt;3. &lt;b&gt;`LIKE&amp;nbsp;'%keyword%'`와&amp;nbsp;비슷한&amp;nbsp;사용자&amp;nbsp;경험을&amp;nbsp;유지하면서,&amp;nbsp;인덱스&amp;nbsp;기반으로&amp;nbsp;훨씬&amp;nbsp;빠르게&amp;nbsp;동작한다.&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;사용자 입장에서는 여전히&amp;nbsp; &amp;ldquo;키워드를 치면 아티클이 나온다&amp;rdquo;는 흐름이 그대로이고,&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp; &amp;nbsp;&lt;b&gt;부분 검색 + 공백이 섞인 여러 단어 검색&lt;/b&gt;도 어느 정도 받아준다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;대신 서버 입장에서는 테이블 풀 스캔이 아닌&amp;nbsp;&amp;nbsp;&lt;b&gt;FULLTEXT 인덱스(ngram + 기본 파서)&lt;/b&gt;를 사용하기 때문에&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;데이터가&amp;nbsp;쌓여도&amp;nbsp;검색&amp;nbsp;응답&amp;nbsp;시간이&amp;nbsp;훨씬&amp;nbsp;안정적으로&amp;nbsp;유지된다.&lt;br /&gt;&lt;br /&gt;4. 관련도(relevance) 점수 기반 정렬을 통해, &amp;lsquo;더 그럴듯한 결과&amp;rsquo;를 위에 올릴 수 있다.&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;Full-text&amp;nbsp;Search는&amp;nbsp;단순히&amp;nbsp;포함&amp;nbsp;여부만&amp;nbsp;판단하는&amp;nbsp;것이&amp;nbsp;아니라&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;키워드&amp;nbsp;등장&amp;nbsp;빈도,&amp;nbsp;위치&amp;nbsp;등을&amp;nbsp;기반으로&amp;nbsp;점수를&amp;nbsp;계산해&amp;nbsp;준다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;우리는&amp;nbsp;`ORDER&amp;nbsp;BY&amp;nbsp;score&amp;nbsp;DESC,&amp;nbsp;published_at&amp;nbsp;DESC`&amp;nbsp;형태로&amp;nbsp;정렬해&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;ldquo;검색어와&amp;nbsp;더&amp;nbsp;잘&amp;nbsp;맞으면서도&amp;nbsp;최신에&amp;nbsp;가까운&amp;nbsp;아티클&amp;rdquo;이&amp;nbsp;상단에&amp;nbsp;노출되도록&amp;nbsp;했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  관련도 점수 계산&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;관련도 점수 계산&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL MATCH() AGAINST()가 점수를 계산할 때 기본 아이디어는 언어 안 가린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 어떤 '토큰(단어/조각)' 들이 문서에 있고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 검색어에서 온 토큰과 얼마나 겹치고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 문서에서 얼마나 자주 나오고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 전체 길이에 비해 어느 정도 비율을 차지하는지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이런 걸 가지고 relevance scroe를 만든다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 로직은 영어/한글/숫자/기호 가리지 않고 &quot;토큰 단위&quot;로 동작한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;토큰만 잘 뽑혀 있으면 점수는 무조건 나온다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;522&quot; data-start=&quot;489&quot; data-ke-size=&quot;size20&quot;&gt;차이점의 핵심: &amp;ldquo;토큰이 뭐냐&amp;rdquo;가 언어별로 다르다&lt;/h4&gt;
&lt;p data-end=&quot;536&quot; data-start=&quot;524&quot; data-ke-size=&quot;size16&quot;&gt;차이는 여기서 생긴다.&lt;/p&gt;
&lt;h4 data-end=&quot;561&quot; data-start=&quot;538&quot; data-ke-size=&quot;size20&quot;&gt;1. 영어 (기본 parser)&lt;/h4&gt;
&lt;p data-end=&quot;584&quot; data-start=&quot;563&quot; data-ke-size=&quot;size16&quot;&gt;영어는 대략 이런 식으로 토큰이 생성된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;672&quot; data-start=&quot;586&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;672&quot; data-start=&quot;586&quot;&gt;&quot;Reshoring is coming back to the US&quot;&lt;br /&gt;&amp;rarr; [&quot;reshoring&quot;, &quot;coming&quot;, &quot;back&quot;, &quot;us&quot;]&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;695&quot; data-start=&quot;674&quot; data-ke-size=&quot;size16&quot;&gt;검색어가 &quot;reshoring&quot;이면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;753&quot; data-start=&quot;697&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;725&quot; data-start=&quot;697&quot;&gt;&quot;reshoring&quot;이 포함된 문서만 고르고&lt;/li&gt;
&lt;li data-end=&quot;753&quot; data-start=&quot;726&quot;&gt;등장 횟수/위치/문서 길이 기반으로 점수 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;755&quot; data-ke-size=&quot;size16&quot;&gt;즉, &amp;ldquo;단어 = 의미 단위&amp;rdquo;에 상당히 가깝게 떨어진다.&lt;br /&gt;그래서 관련도 점수가 &lt;b&gt;&amp;ldquo;실제 의미 관련도&amp;rdquo;랑 비슷하게 느껴진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;829&quot; data-start=&quot;755&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;859&quot; data-start=&quot;836&quot; data-ke-size=&quot;size20&quot;&gt;2. 한글 + 기본 parser&lt;/h4&gt;
&lt;p data-end=&quot;883&quot; data-start=&quot;861&quot; data-ke-size=&quot;size16&quot;&gt;한글은 공백이 있을 땐 그럭저럭 괜찮다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;948&quot; data-start=&quot;885&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;948&quot; data-start=&quot;885&quot;&gt;&quot;연착륙 전망 나오는 미국 경제&quot;&lt;br /&gt;&amp;rarr; [&quot;연착륙&quot;, &quot;전망&quot;, &quot;나오는&quot;, &quot;미국&quot;, &quot;경제&quot;]&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;985&quot; data-start=&quot;950&quot; data-ke-size=&quot;size16&quot;&gt;이 정도면 &quot;연착륙&quot; 검색 시 점수가 꽤 그럴듯하게 나온다.&lt;/p&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;987&quot; data-ke-size=&quot;size16&quot;&gt;그런데&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1089&quot; data-start=&quot;992&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1056&quot; data-start=&quot;992&quot;&gt;&quot;연착륙이냐 경착륙이냐&quot; &amp;rarr; 조사/어미까지 붙어서 한 덩어리
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1056&quot; data-start=&quot;1034&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1056&quot; data-start=&quot;1034&quot;&gt;[&quot;연착륙이냐&quot;, &quot;경착륙이냐&quot;]&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1089&quot; data-start=&quot;1057&quot;&gt;&quot;리쇼어링/리-쇼어링/리 쇼어링&quot; &amp;rarr; 표기 다 다름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1096&quot; data-start=&quot;1091&quot; data-ke-size=&quot;size16&quot;&gt;여기서부터 사람이 보기엔 &amp;ldquo;다 같은 개념&amp;rdquo;인데 Full-text 입장에선 &lt;b&gt;완전히 다른 토큰&lt;/b&gt;이라 관련도 점수가 허당처럼 느껴질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1211&quot; data-start=&quot;1178&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;영어에 비해 한글 관련도 별로다&amp;rdquo;라는 말이나온는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1244&quot; data-start=&quot;1218&quot; data-ke-size=&quot;size20&quot;&gt;3. 한글 + ngram parser&lt;/h4&gt;
&lt;p data-end=&quot;1292&quot; data-start=&quot;1246&quot; data-ke-size=&quot;size16&quot;&gt;ngram 파서를 쓰면 토큰이 이렇게 바뀐다 (예: 2글자 n-gram)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1457&quot; data-start=&quot;1294&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1327&quot; data-start=&quot;1294&quot;&gt;&quot;리쇼어링&quot; &amp;rarr; [&quot;리쇼&quot;, &quot;쇼어&quot;, &quot;어링&quot;]&lt;/li&gt;
&lt;li data-end=&quot;1380&quot; data-start=&quot;1328&quot;&gt;&quot;리 쇼어링&quot; &amp;rarr; 공백/기호 기준으로 나뉘면서도, 내부에서는 다시 2글자 단위로 쪼개짐&lt;/li&gt;
&lt;li data-end=&quot;1457&quot; data-start=&quot;1381&quot;&gt;&quot;연착륙이냐 경착륙이냐&quot;&lt;br /&gt;&amp;rarr; 대충 [&quot;연착&quot;, &quot;착륙&quot;, &quot;륙이&quot;, &quot;이냐&quot;, &quot;경착&quot;, &quot;착륙&quot;, ...] 이런 느낌&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1487&quot; data-start=&quot;1459&quot; data-ke-size=&quot;size16&quot;&gt;검색어 &quot;쇼어링&quot; &amp;rarr; [&quot;쇼어&quot;, &quot;어링&quot;]&lt;/p&gt;
&lt;p data-end=&quot;1512&quot; data-start=&quot;1489&quot; data-ke-size=&quot;size16&quot;&gt;그럼 점수 계산은 이런 느낌으로 이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1593&quot; data-start=&quot;1514&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1533&quot; data-start=&quot;1514&quot;&gt;이 문서의 n-gram 리스트랑&lt;/li&gt;
&lt;li data-end=&quot;1557&quot; data-start=&quot;1534&quot;&gt;검색어의 n-gram 리스트를 비교해서&lt;/li&gt;
&lt;li data-end=&quot;1593&quot; data-start=&quot;1558&quot;&gt;&lt;b&gt;얼마나 많이 겹치는지 + 전체 대비 비율&lt;/b&gt;로 점수 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1599&quot; data-start=&quot;1595&quot; data-ke-size=&quot;size16&quot;&gt;그래서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1768&quot; data-start=&quot;1601&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1695&quot; data-start=&quot;1601&quot;&gt;&amp;ldquo;리쇼어링&amp;rdquo; / &amp;ldquo;리-쇼어링&amp;rdquo; / &amp;ldquo;리 쇼어링&amp;rdquo;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1695&quot; data-start=&quot;1632&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1695&quot; data-start=&quot;1632&quot;&gt;전부 &quot;쇼어&quot;, &quot;어링&quot; 같은 공통 n-gram을 공유&lt;br /&gt;&amp;rarr; 관련도 점수가 &amp;ldquo;나름 그럴듯하게&amp;rdquo; 나온다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1768&quot; data-start=&quot;1696&quot;&gt;&amp;ldquo;연착륙&amp;rdquo;, &amp;ldquo;연착륙이냐&amp;rdquo;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1768&quot; data-start=&quot;1715&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1768&quot; data-start=&quot;1715&quot;&gt;[&quot;연착&quot;, &quot;착륙&quot;, ...] 같은 공통 n-gram 공유&lt;br /&gt;&amp;rarr; 이것도 꽤 잘 잡힌다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1785&quot; data-start=&quot;1770&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이때의 관련도 점수는&lt;/p&gt;
&lt;blockquote data-end=&quot;1865&quot; data-start=&quot;1787&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1865&quot; data-start=&quot;1789&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이 문서와 검색어가 &lt;b&gt;같은 의미를 가지냐&lt;/b&gt;&amp;rdquo;가 아니라&lt;br /&gt;&amp;ldquo;이 문서와 검색어가 &lt;b&gt;같은 글자 조합을 많이 공유하냐&lt;/b&gt;&amp;rdquo; 이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기에는 한 가지 문제점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL Full-text Search를 쓰면 기본 파서(Buil-In Parser)는 공백과, 쉼표(,), 마침표(.) 같은 구분자 문자를 기준으로 &lt;b&gt;단어의 시작과 끝을 판별해서&lt;/b&gt; 텍스트를 토큰으로 분리한다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;(공식 문서: The built-in FULLTEXT parser determines where words start and end by looking for certain delimiter characters&amp;hellip;)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영어처럼 단어 사이에 항상 띄어쓰기가 있는 언어에서는 이 방식이 잘 맞는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는, 한국어는 띄어쓰시가 &quot;단어 경계 = 의미 단위&quot;와 거의 맞지 않는다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 이런 문장이 있다고 하자&lt;/p&gt;
&lt;pre id=&quot;code_1763296615662&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;한국은행이 기준금리를 인상했다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Built-in parser / Natural Language 모드에서는 다음처럼 토큰화된다.&lt;/blockquote&gt;
&lt;pre id=&quot;code_1763296588257&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[&quot;한국은행이&quot;, &quot;기준금리를&quot;, &quot;인상했다&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Full-text Search는 이 토큰들을 기준으로 검색을 수행한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 &lt;b&gt;기본 Natural Language 모드에서는 prefix / 부분 문자열 검색을 지원하지 않는다&lt;/b&gt;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 매칭 동작은 대략 아래와 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;built-in parser + Natural Language 모드 (모두 기본값)&lt;/blockquote&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;토큰&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;검색어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;매칭 여부&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;이유&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기준금리를&lt;/td&gt;
&lt;td&gt;기준&lt;/td&gt;
&lt;td&gt;❌ X&lt;/td&gt;
&lt;td&gt;단어 중간 substring &amp;rarr; 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기준금리를&lt;/td&gt;
&lt;td&gt;기준금리&lt;/td&gt;
&lt;td&gt;❌ X&lt;/td&gt;
&lt;td&gt;prefix(접두어) 매칭 &amp;rarr; 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기준금리를&lt;/td&gt;
&lt;td&gt;기준금리를&lt;/td&gt;
&lt;td&gt;✅ O&lt;/td&gt;
&lt;td&gt;토큰 전체와 검색어가 완전히 일치할 때만 매칭&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인상했다&lt;/td&gt;
&lt;td&gt;인상&lt;/td&gt;
&lt;td&gt;❌ X&lt;/td&gt;
&lt;td&gt;어근 수준 분석 없음, 부분 문자열 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인상했다&lt;/td&gt;
&lt;td&gt;인상했다&lt;/td&gt;
&lt;td&gt;✅ O&lt;/td&gt;
&lt;td&gt;단어 전체가 동일할 때만 매칭&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;단어 전체가 동일할 때만&lt;/b&gt; 매칭되기 때문에, 우리가 직관적으로 기대하는 것처럼&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &quot;기준금리&quot;로 검색했을 때 &quot;기준금리를&quot;이 자동으로 잡힌다거나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &quot;인상&quot;으로 검색했을 때 &quot;인상했다&quot;가 함께 매칭된다거나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하는 &quot;부분 검색&quot; 경험을 주기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;한국어에서는, 기본 Built-in parser + Natural Language 모드만으로는 한글 부분 검색 요구를 충족하기 힘들고&lt;/b&gt;,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 ngram 파서를 고려하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한글의 부분 검색을 자연스럽게 지원하려면, Full-text Search의 파서를 기본값대신 'ngram' 파서로 바꾸는 게 더 잘맞는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ngram은 말 그대로 텍스트를 &lt;b&gt;N글자 단위로 잘게 쪼개는 방식&lt;/b&gt;이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 2-gram 기준으로 보면&lt;/p&gt;
&lt;pre id=&quot;code_1763297019765&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;금리인상&quot; -&amp;gt; [&quot;금리&quot;, &quot;리인&quot;, &quot;인상&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 잘게 쪼개 두면 사용자가 &quot;금리 인상&quot;을 검색했을 때 &quot;금리&quot;, &quot;인상&quot; 이라는 조각이 겹치는 문장들을 찾을 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, ngram 파서는 &quot;단어 단위 의미 분석&quot;이라기보다 &quot;글자 조각이 얼마나 많이 겹치냐&quot;를 기준으로 검색하는 방식이라고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완벽한 한국어 형태소 분석 수준은 아니지만, 기본 full-text의 공백 기반 파서보다 우리가 기대한 &quot;부분 검색&quot; 경험에 훨씬 가깝고 여전히 Full-text 인덱스를 사용하기 때문에 성능도 충분히 확보할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇지만 여기서 고려해야할 부분이 또 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 인덱스의 크기이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ngram 파서를 쓰면, 인덱스가 폭발적으로 늘어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ngram은 텍스트를 n글자 단위로 잘게 쪼개기 때문에 같은 문자열이라도 토큰 개수가 기본 파서보다 훨씬 많아진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ngram으로 바꾸는 순간 동일한 문장을 훨씬 더 많은 토큰으로 나눠서 인덱스에 저장하게 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 전체로 보면 이&amp;nbsp; 차이는 더 크게 누적된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 수가 몇 배까지도 쉽게 뛰어오르고, 그만큼 인덱스 크기도 커진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 크기가 단순히 디스크만 조금 더 쓰는 문제에서 끝나면 좋겠지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로는 MySQL 입장에서는 꽤 많은 부담이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1287&quot; data-start=&quot;762&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;908&quot; data-start=&quot;762&quot;&gt;&lt;b&gt;디스크 용량 압박&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;908&quot; data-start=&quot;782&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;816&quot; data-start=&quot;782&quot;&gt;Full-text 인덱스는 일반 인덱스보다도 덩치가 크다.&lt;/li&gt;
&lt;li data-end=&quot;867&quot; data-start=&quot;820&quot;&gt;ngram까지 쓰면 &amp;ldquo;본문 컬럼 + 인덱스&amp;rdquo;가 합쳐져서 테이블 용량이 훅 커진다.&lt;/li&gt;
&lt;li data-end=&quot;908&quot; data-start=&quot;871&quot;&gt;RDS처럼 디스크를 넉넉하게 잡기 힘든 환경에서는 꽤 부담된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1072&quot; data-start=&quot;910&quot;&gt;&lt;b&gt;버퍼 풀(메모리) 부담&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1072&quot; data-start=&quot;933&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;967&quot; data-start=&quot;933&quot;&gt;InnoDB는 인덱스 페이지도 버퍼 풀에 올려서 캐싱한다.&lt;/li&gt;
&lt;li data-end=&quot;1030&quot; data-start=&quot;971&quot;&gt;인덱스가 커질수록, &lt;b&gt;같은 메모리에서 더 많은 페이지를 돌려 써야 한다&lt;/b&gt; &amp;rarr; 캐시 히트율이 떨어짐.&lt;/li&gt;
&lt;li data-end=&quot;1072&quot; data-start=&quot;1034&quot;&gt;그만큼 디스크 I/O가 늘고, 검색/쓰기 성능이 서서히 떨어진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1287&quot; data-start=&quot;1074&quot;&gt;&lt;b&gt;쓰기(INSERT/UPDATE) 비용 증가&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1287&quot; data-start=&quot;1108&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1158&quot; data-start=&quot;1108&quot;&gt;한 번 INSERT를 할 때마다, ngram 토큰 개수만큼 인덱스에 엔트리가 들어간다.&lt;/li&gt;
&lt;li data-end=&quot;1240&quot; data-start=&quot;1162&quot;&gt;&amp;ldquo;문자열 1개 &amp;rarr; 인덱스 1번&amp;rdquo;이 아니라&lt;br /&gt;&amp;ldquo;문자열 1개 &amp;rarr; ngram 토큰 수만큼 여러 번 인덱스 갱신&amp;rdquo;이 일어나는 셈이다.&lt;/li&gt;
&lt;li data-end=&quot;1287&quot; data-start=&quot;1244&quot;&gt;뉴스레터처럼 글이 계속 쌓이는 서비스라면 이 쓰기 비용도 무시하기 어렵다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;결국, ngram은 &lt;b&gt;검색 품질과 한글 부분 검색 경험을 개선해 주는 대신,&lt;br /&gt;인덱스 크기와 자원 사용량이라는 꽤 큰 대가&lt;/b&gt;를 요구한다.&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;어디선가는 타협을 해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;172&quot; data-start=&quot;133&quot; data-ke-size=&quot;size16&quot;&gt;우리는 먼저 기술적인 타협보다, &lt;b&gt;비즈니스적인 타협&lt;/b&gt;부터 살펴봤다.&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;우리 서비스의 구독자 대부분은 &lt;b&gt;뉴스 분야&lt;/b&gt;를 구독하고 있다.&lt;br /&gt;뉴스의 특성상, 어제 본 기사도 &lt;b&gt;하루만 지나도 가치가 빠르게 떨어지고&lt;/b&gt;,&lt;br /&gt;일주일 전 기사는 &amp;ldquo;검색해서 다시 읽을 만한 기사&amp;rdquo;의 비율이 훨씬 낮다.&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이렇게 생각했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;404&quot; data-start=&quot;316&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-start=&quot;318&quot; data-end=&quot;404&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;모든 기간 데이터를 완벽한 한글 부분 검색으로 지원하기보다는,&lt;br /&gt;사용자가 실제로 많이 찾는 &amp;lsquo;최근 뉴스&amp;rsquo;에만 ngram의 비용을 집중하자.&amp;rdquo;&lt;/p&gt;
&lt;p data-start=&quot;406&quot; data-end=&quot;517&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;517&quot; data-start=&quot;406&quot; data-ke-size=&quot;size16&quot;&gt;구체적으로는 &lt;b&gt;ngram 인덱스는 최근 5일 치 데이터에만 적용&lt;/b&gt;하고,&lt;br /&gt;그 이후의 데이터는 기본 파서 기반 Full-text 인덱스로만 두는 방식을 택했다.&lt;/p&gt;
&lt;p data-end=&quot;1368&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면, 사용자가 가장 자주 찾는 최신 뉴스는 ngram 덕분에 &quot;부분 검색 경험&quot;을 충분히 제공하고 상대적으로 조회가 적은 예전 뉴스들은 인덱스를 과하게 부풀리지 않고, 저장 공간,버퍼 풀 부담도 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 모든 데이터를 완벽하게 검색할 수 있게 하기 대신 자주 검색되는 구간에 비용을 집중하는 전략으로 타협&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 왜 최근 5일치인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 5일보다 7일이나 15일정도로&amp;nbsp; 더 잡고 싶었지만 5일이 한계다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL(InnoDB)은 디스크에 있는 데이터를 바로 읽어 쓰지 않고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버퍼 풀(Buffer Pool)이라는 메모리 캐시에 올려 두고 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 버퍼 풀이 넉넉하면 자주 쓰는 데이터, 인덱스가 메모리에 머물러서 빠르게 응답할 수 있지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 버퍼 풀이 작으면 디스크 I/O가 급격히 늘어나면서 전체 성능이 눈에 띄게 떨어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 사용하는 RDS MySQL 인스턴스는 t4g.micro이고, 메모리는 1GB이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS에서 이 구간의 innodb_buffer_pool_size 최대값을 384MB 정도로 제한하고 있어서 결국 Full-text / ngram 인덱스가 쓸 수 있는 메모리 예산도 그 안에서 해결해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버퍼 풀(buffer pool)&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 버퍼 풀??&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL(InnoDB)은 실제 데이터(테이블, 인덱스)를 디스크에 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 디스크에서 직접 읽고 쓰는 건 엄청 느리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 InnoDB는 이렇게 행동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 디스크에서 데이터를 읽을 때 한 페이지(보통 16KB) 단위로 가져와서 메모리(버퍼 풀)에 올려둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 그 다음 같은 데이터를 또 읽을 읽이 생기면 디스크 말고 버퍼 풀(메모리)에서 바로 읽는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 데이터를 변경(insert/update/delete)할 때도 먼저 버퍼 풀에 있는 페이지를 수정하고 나중에 디스크에 천천히 반영한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 버퍼풀은 자주 쓰는 데이터/인덱스를 메모리에 캐싱해 두는 큰 바구니라고 생각하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2.&amp;nbsp; 버퍼 풀 사이즈(buffer pool size)란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값이 크면 더 많은 데이터/인덱스를 메모리에 올려둘 수 있어서 디스크를 덜 읽고 조회/쓰기 성능이 좋아진다. 다만 너무 크게 잡으면 OS나 다른 프로세스가 사용할 메모리가 부족해 질 수 있고 swap 걸리면 오히려 성능이 망가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 AWS RDS는 안전하게 40% ~50% 이하로 주고있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값이 작으면 캐시 미스가 자주 일어나고 이는 디스크 I/O가 폭증으로 이어지고 이로인해 쿼리 응답 시간이 기하급수적으로 느려질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;159&quot; data-start=&quot;119&quot; data-ke-size=&quot;size23&quot;&gt;왜 전체 아티클에 n그램을 못 걸고, &amp;lsquo;최근 5일&amp;rsquo;만 선택했을까?&lt;/h3&gt;
&lt;p data-end=&quot;190&quot; data-start=&quot;161&quot; data-ke-size=&quot;size16&quot;&gt;먼저 우리가 서 있는 인프라부터 짚고 가야 한다.&lt;/p&gt;
&lt;p data-end=&quot;190&quot; data-start=&quot;161&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;322&quot; data-start=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;우리가 사용하는 DB는 &lt;b&gt;RDS MySQL db t4g.micro&lt;/b&gt;이고, 이 인스턴스의 RAM은 &lt;b&gt;1GB&lt;/b&gt;다.&lt;br /&gt;MySQL에서는 디스크에 있는 데이터를 메모리에 캐싱해 두기 위해 &lt;b&gt;InnoDB 버퍼 풀&lt;/b&gt;이라는 공간을 사용한다.&lt;/p&gt;
&lt;p data-end=&quot;499&quot; data-start=&quot;324&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;499&quot; data-start=&quot;324&quot; data-ke-size=&quot;size16&quot;&gt;AWS 공식 문서를 보면, RDS for MySQL에서는 MySQL 8.4 기준으로 기본적으로 `innodb_dedicated_server`를 켜두고,&lt;br /&gt;&amp;ldquo;DB 인스턴스 메모리를 기준으로 `innodb_buffer_pool_size`를 자동 계산&amp;rdquo;하도록 되어 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2014&quot; data-origin-height=&quot;1004&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ci63ak/dJMcag4XoL8/H1ombZC2cN2bk7wPFshpwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ci63ak/dJMcag4XoL8/H1ombZC2cN2bk7wPFshpwK/img.png&quot; data-alt=&quot;micro 이면 처음 기본값은 128MB&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ci63ak/dJMcag4XoL8/H1ombZC2cN2bk7wPFshpwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fci63ak%2FdJMcag4XoL8%2FH1ombZC2cN2bk7wPFshpwK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;331&quot; height=&quot;165&quot; data-origin-width=&quot;2014&quot; data-origin-height=&quot;1004&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;micro 이면 처음 기본값은 128MB&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;602&quot; data-start=&quot;501&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 우리는 `innodb_buffer_pool_size`를 &lt;b&gt;640MB&lt;/b&gt;로 설정해 봤는데,&lt;br /&gt;SHOW VARIABLES로 확인해 보면 값이 402,653,184(&amp;asymp; 384MB)로 잡혀 있었다.&lt;br /&gt;설정을 더 크게 줘도, RDS가 &lt;b&gt;인스턴스 메모리 비율에 맞게 384MB 정도로 깎아서 적용한 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDDsPf/dJMcafrst2h/owvFIXN13N262VRQisZV60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDDsPf/dJMcafrst2h/owvFIXN13N262VRQisZV60/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;134&quot; data-origin-width=&quot;600&quot; width=&quot;557&quot; height=&quot;96&quot; data-widthpercent=&quot;59.17&quot; data-filename=&quot;blob&quot; style=&quot;width: 58.4856%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDDsPf/dJMcafrst2h/owvFIXN13N262VRQisZV60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDDsPf%2FdJMcafrst2h%2FowvFIXN13N262VRQisZV60%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;134&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/djlGkJ/dJMcafycO6H/22zRx4jPemmxek07FevH0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/djlGkJ/dJMcafycO6H/22zRx4jPemmxek07FevH0K/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;224&quot; data-origin-width=&quot;692&quot; width=&quot;593&quot; height=&quot;109&quot; style=&quot;width: 40.3516%;&quot; data-widthpercent=&quot;40.83&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/djlGkJ/dJMcafycO6H/22zRx4jPemmxek07FevH0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdjlGkJ%2FdJMcafycO6H%2F22zRx4jPemmxek07FevH0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;692&quot; height=&quot;224&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;640MB로 설정했지만 실제 적용은 최적화 값인 384MB로 설정되는 것을 볼 수 있음&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 이 인스턴스(t4g.micro)에서 우리가 실제로 쓸 수 있는 InnoDB 버퍼 풀은 대략 &lt;b&gt;384MB 정도가 상한선&lt;/b&gt;이라고 보는 게 현실적이다. 그리고 이 384MB 안에서 &lt;b&gt;검색 인덱스 + 나머지 모든 쿼리/데이터&lt;/b&gt;가 함께 살아야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1058&quot; data-start=&quot;1035&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1134&quot; data-start=&quot;1098&quot; data-ke-size=&quot;size23&quot;&gt;&amp;ldquo;Ngram을 전체적용 한다면?&amp;rdquo;&lt;/h3&gt;
&lt;p data-end=&quot;1154&quot; data-start=&quot;1136&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 솔직히 이렇게 생각했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1221&quot; data-start=&quot;1156&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1221&quot; data-start=&quot;1158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;그냥 전체 아티클에 ngram 인덱스를 걸어버리면&lt;br /&gt;한글 부분 검색도 잘 되고, 품질은 제일 좋지 않을까?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1451&quot; data-start=&quot;1223&quot; data-ke-size=&quot;size16&quot;&gt;하지만 MySQL이 제공하는 ngram 풀텍스트 파서는,&lt;br /&gt;&lt;b&gt;문장을 고정 길이 n글자씩 잘게 쪼개서 인덱싱&lt;/b&gt;하는 구조라 토큰 수가 급격히 늘어난다.&lt;/p&gt;
&lt;p data-end=&quot;1451&quot; data-start=&quot;1223&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;전 기간 아티클 전체에 ngram 인덱스를 걸면 &lt;b&gt;인덱스 용량이 폭발&lt;/b&gt;하고,&lt;br /&gt;384MB짜리 버퍼 풀로는 &lt;b&gt;자주 쓰이는 인덱스/데이터를 메모리에 유지하기가 사실상 불가능&lt;/b&gt;해진다.&lt;/p&gt;
&lt;p data-end=&quot;1451&quot; data-start=&quot;1223&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1469&quot; data-start=&quot;1453&quot; data-ke-size=&quot;size16&quot;&gt;그래서 전략을 이렇게 바꿨다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1588&quot; data-start=&quot;1471&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1588&quot; data-start=&quot;1473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;전체를 ngram으로 도배하는 건 욕심이고,&lt;br /&gt;&lt;b&gt;버퍼 풀 안에서 감당 가능한 범위만큼만&lt;/b&gt; ngram을 쓰자.&lt;br /&gt;나머지는 MySQL이 기본으로 제공하는 FULLTEXT 인덱스를 최대한 활용하자.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1603&quot; data-start=&quot;1590&quot; data-ke-size=&quot;size16&quot;&gt;그 결과 나온 게 바로,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1685&quot; data-start=&quot;1605&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1638&quot; data-start=&quot;1605&quot;&gt;&lt;b&gt;최근 5일치&lt;/b&gt; &amp;rarr; n그램 FULLTEXT 인덱스&lt;/li&gt;
&lt;li data-end=&quot;1685&quot; data-start=&quot;1639&quot;&gt;&lt;b&gt;그 이후 전 기간&lt;/b&gt; &amp;rarr; MySQL 기본 FULLTEXT 인덱스(기본 파서)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1705&quot; data-start=&quot;1687&quot; data-ke-size=&quot;size16&quot;&gt;라는 &lt;b&gt;하이브리드 전략&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이 5일이라는 기간을 어떻게 잡았을까?&lt;/p&gt;
&lt;h3 data-end=&quot;1388&quot; data-start=&quot;1371&quot; data-ke-size=&quot;size23&quot;&gt;아티클&amp;middot;인덱스 용량 가정&lt;/h3&gt;
&lt;p data-end=&quot;1421&quot; data-start=&quot;1390&quot; data-ke-size=&quot;size16&quot;&gt;버퍼 풀 안에서 ngram을 얼마나 쓸 수 있을지 보려면,&lt;br /&gt;대략이라도 &lt;b&gt;아티클 1건당 용량&lt;/b&gt;을 계산해 봐야 했다.&lt;/p&gt;
&lt;p data-end=&quot;1421&quot; data-start=&quot;1390&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1165&quot; data-start=&quot;1151&quot; data-ke-size=&quot;size16&quot;&gt;일단 우리 서비스가 현재 기준으로 대략 300명이니까 근시일 목표로하자면 아래와 같다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1519&quot; data-start=&quot;1423&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1440&quot; data-start=&quot;1423&quot;&gt;회원 수: &lt;b&gt;500명&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1468&quot; data-start=&quot;1441&quot;&gt;1인당 하루에 받는 뉴스레터 수: &lt;b&gt;4개&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1519&quot; data-start=&quot;1469&quot;&gt;&amp;rArr; 서버 입장에서 하루에 받는 아티클 수&lt;br /&gt;500명 &amp;times; 4개 = 2,000개&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1547&quot; data-start=&quot;1521&quot; data-ke-size=&quot;size16&quot;&gt;각 아티클의 텍스트 크기는 대략 이렇게 잡았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1784&quot; data-start=&quot;1549&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1579&quot; data-start=&quot;1549&quot;&gt;원본 뉴스레터 본문(HTML 태그 포함): &lt;b&gt;8천자 ~ 1만 5천자&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1622&quot; data-start=&quot;1580&quot;&gt;HTML 태그 제거 후 실제 텍스트: &lt;b&gt;약 1,000자&lt;/b&gt; 정도로 가정&lt;/li&gt;
&lt;li data-end=&quot;1713&quot; data-start=&quot;1623&quot;&gt;한글/영문 섞인 UTF-8 기준
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1713&quot; data-start=&quot;1647&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1664&quot; data-start=&quot;1647&quot;&gt;한글 1글자 ≒ 3바이트&lt;/li&gt;
&lt;li data-end=&quot;1684&quot; data-start=&quot;1667&quot;&gt;영문 1글자 ≒ 1바이트&lt;/li&gt;
&lt;li data-end=&quot;1713&quot; data-start=&quot;1687&quot;&gt;보수적으로 &lt;b&gt;한글 기준 3바이트&lt;/b&gt;로 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1743&quot; data-start=&quot;1714&quot;&gt;본문: 1,000자 &amp;times; 3바이트 ≒ &lt;b&gt;3KB&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1784&quot; data-start=&quot;1744&quot;&gt;제목: 보통 30자 안팎 &amp;rarr; 30자 &amp;times; 3바이트 ≒ &lt;b&gt;0.1KB&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1805&quot; data-start=&quot;1786&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;텍스트 자체만 보면 &lt;/b&gt;1건당 텍스트 용량 ≒ &lt;b&gt;3.1KB&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1887&quot; data-start=&quot;1833&quot; data-ke-size=&quot;size16&quot;&gt;여기에 n그램 인덱스까지 포함해서, 보수적으로 &lt;b&gt;1건당 10KB&lt;/b&gt; 정도를 사용한다고 가정했다.&lt;/p&gt;
&lt;p data-end=&quot;1887&quot; data-start=&quot;1833&quot; data-ke-size=&quot;size16&quot;&gt;(즉, &lt;b&gt;10KB는 &amp;ldquo;인덱스만&amp;rdquo;이 아니라 &amp;ldquo;텍스트 + ngram 인덱스 전체&amp;rdquo;에 대한 근사치&lt;/b&gt;다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1919&quot; data-start=&quot;1894&quot; data-ke-size=&quot;size23&quot;&gt;5일치 n그램 인덱스가 차지하는 메모리&lt;/h3&gt;
&lt;p data-end=&quot;1945&quot; data-start=&quot;1921&quot; data-ke-size=&quot;size16&quot;&gt;이제 이 가정으로 실제 숫자를 계산해 보면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2022&quot; data-start=&quot;1947&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1962&quot; data-start=&quot;1947&quot;&gt;1건당: &lt;b&gt;10KB&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1985&quot; data-start=&quot;1963&quot;&gt;하루 아티클 수: &lt;b&gt;2,000건&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2022&quot; data-start=&quot;1986&quot;&gt;5일치 아티클 수: 2,000건 &amp;times; 5일 = 10,000건&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2028&quot; data-start=&quot;2024&quot; data-ke-size=&quot;size16&quot;&gt;따라서,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2069&quot; data-start=&quot;2030&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2069&quot; data-start=&quot;2030&quot;&gt;10,000건 &amp;times; 10KB = 100,000KB ≒ 97.7MB&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2135&quot; data-start=&quot;2071&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;최근 5일치 아티클에 대해 n그램 인덱스를 건다고 가정하면 약 98MB 정도&lt;/b&gt;의 메모리를 사용하게 된다.&lt;/p&gt;
&lt;p data-end=&quot;2177&quot; data-start=&quot;2137&quot; data-ke-size=&quot;size16&quot;&gt;우리가 줄 수 있는 InnoDB 버퍼 풀 최대값이 384MB이기 때문에,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2203&quot; data-start=&quot;2179&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2203&quot; data-start=&quot;2179&quot;&gt;98MB ≒ &lt;b&gt;버퍼 풀의 약 25%&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2277&quot; data-start=&quot;2205&quot; data-ke-size=&quot;size16&quot;&gt;정도로만 n그램 인덱스 + 텍스트 캐싱에 쓰고,&lt;br /&gt;나머지 75%는 다른 테이블/쿼리/작업들이 사용할 수 있게 남겨두는 전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &amp;ldquo;버퍼 풀의 25%를 검색에 쓴다&amp;rdquo;는 말은&lt;br /&gt;버퍼 풀을 파티션 나눠서 &lt;b&gt;100MB를 검색 전용으로 락 걸어둔다는 뜻이 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2155&quot; data-start=&quot;2152&quot; data-ke-size=&quot;size16&quot;&gt;단지,&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;최근 5일치 n그램 인덱스의 작업 세트가 &lt;/span&gt;대략 100MB 안에서 움직일 것 같다&amp;rdquo; 라고 추정한 값이고,&lt;br /&gt;버퍼 풀 자체는 여전히 &lt;b&gt;모든 워크로드가 함께 공유하는 캐시&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2184&quot; data-start=&quot;2140&quot; data-ke-size=&quot;size23&quot;&gt;그래서 검색은 어떻게 동작하냐? (n그램 + FULLTEXT 혼합 전략)&lt;/h3&gt;
&lt;p data-end=&quot;2200&quot; data-start=&quot;2186&quot; data-ke-size=&quot;size16&quot;&gt;중요한 건, 우리는 단순히 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;최근 5일만 빠르게 보이기 위해 n그램을 썼다&amp;rdquo; &lt;/span&gt;에서 끝내고 싶었던 게 아니다.&lt;br /&gt;사용자 입장에서는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2347&quot; data-start=&quot;2264&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2293&quot; data-start=&quot;2264&quot;&gt;띄어쓰기가 들어간 검색이든 (&quot;항공 연착률&quot;)&lt;/li&gt;
&lt;li data-end=&quot;2330&quot; data-start=&quot;2294&quot;&gt;단어 중간만 잘라 치는 검색이든 (&quot;연착&quot;, &quot;착률&quot;)&lt;/li&gt;
&lt;li data-end=&quot;2347&quot; data-start=&quot;2331&quot;&gt;영어/숫자가 섞인 검색이든&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2391&quot; data-start=&quot;2349&quot; data-ke-size=&quot;size16&quot;&gt;가능한 한 다양한 형태의 검색이 &amp;ldquo;된다&amp;rdquo;는 경험을 주고 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;2416&quot; data-start=&quot;2393&quot; data-ke-size=&quot;size16&quot;&gt;그래서 실제 검색 로직은 이렇게 동작한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3149&quot; data-start=&quot;2418&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2683&quot; data-start=&quot;2418&quot;&gt;&lt;b&gt;최근 5일치 + ngram FULLTEXT 인덱스 검색&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2683&quot; data-start=&quot;2457&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2578&quot; data-start=&quot;2518&quot;&gt;한글을 n자씩 잘라 인덱싱하기 때문에 연착, 착률 같은 &lt;b&gt;부분 검색&lt;/b&gt;에 강하다.&lt;/li&gt;
&lt;li data-end=&quot;2683&quot; data-start=&quot;2582&quot;&gt;검색어에 띄어쓰기가 있어도, 내부적으로는 n그램 토큰으로 잘게 쪼개져서&lt;br /&gt;&quot;항공 연착률&quot; &amp;rarr; 항공, 연착, 착률, &amp;hellip; 같은 조합으로 매칭된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;2954&quot; data-start=&quot;2685&quot;&gt;&lt;b&gt;전 기간 + 기본 FULLTEXT 인덱스(기본 파서) 검색&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2954&quot; data-start=&quot;2728&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2899&quot; data-start=&quot;2838&quot;&gt;원본 article 테이블에는 MySQL 기본 파서 기반의 &lt;b&gt;FULLTEXT 인덱스&lt;/b&gt;도 걸려 있다.&lt;/li&gt;
&lt;li data-end=&quot;2978&quot; data-start=&quot;2903&quot;&gt;이 인덱스는 공백을 기준으로 단어를 나누기 때문에 &quot;항공 연착률&quot;은 항공, 연착률 단위로 깔끔하게 처리된다.&lt;/li&gt;
&lt;li data-end=&quot;3034&quot; data-start=&quot;2982&quot;&gt;덕분에 &lt;b&gt;오래된 아티클까지 포함해서 전 기간 데이터를 단어 기준으로 검색&lt;/b&gt;할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;3149&quot; data-start=&quot;2956&quot;&gt;&lt;b&gt;두 결과를 합쳐서 하나의 검색 결과로 반환&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3149&quot; data-start=&quot;2990&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3057&quot; data-start=&quot;2990&quot;&gt;동일한 아티클이 양쪽에서 모두 걸리면 하나로 합치고,&lt;br /&gt;최근순 + 관련도 같은 기준으로 다시 정렬한다.&lt;/li&gt;
&lt;li data-end=&quot;3149&quot; data-start=&quot;3061&quot;&gt;사용자는 &amp;ldquo;지금 이 키워드를 검색했을 때 나올 수 있는 결과&amp;rdquo;를&lt;br /&gt;&lt;b&gt;n그램 + 기본 FULLTEXT를 모두 활용한 합산 결과&lt;/b&gt;로 보게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;3164&quot; data-start=&quot;3151&quot; data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 사용자는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3373&quot; data-start=&quot;3166&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3202&quot; data-start=&quot;3166&quot;&gt;&amp;ldquo;띄어쓰기를 어떻게 넣어야 하지&amp;hellip;?&amp;rdquo;를 고민하지 않아도 되고,&lt;/li&gt;
&lt;li data-end=&quot;3275&quot; data-start=&quot;3203&quot;&gt;&quot;연착률&quot;, &quot;연착&quot;, &quot;항공 연착률&quot;처럼 &lt;b&gt;조금씩 다르게 쳐도&lt;/b&gt;&lt;br /&gt;그나마 합리적인 결과를 볼 수 있으며,&lt;/li&gt;
&lt;li data-end=&quot;3329&quot; data-start=&quot;3276&quot;&gt;최근 며칠치 아티클에 대해서는 &lt;b&gt;부분 검색까지 포함해서 더 빠르고 풍부한 결과&lt;/b&gt;를 얻고,&lt;/li&gt;
&lt;li data-end=&quot;3373&quot; data-start=&quot;3330&quot;&gt;오래된 아티클에 대해서도 &lt;b&gt;기본적인 단어 기준 검색은 계속 가능&lt;/b&gt;하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;3486&quot; data-start=&quot;3457&quot; data-ke-size=&quot;size23&quot;&gt;&amp;ldquo;그런데 검색에 25%나 쓸 만큼 중요한가요?&amp;rdquo;&lt;/h3&gt;
&lt;p data-end=&quot;3507&quot; data-start=&quot;3488&quot; data-ke-size=&quot;size16&quot;&gt;자연스럽게 나올 수 있는 질문이다.&lt;/p&gt;
&lt;blockquote data-end=&quot;3571&quot; data-start=&quot;3509&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;3571&quot; data-start=&quot;3511&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;검색이 DAU 대비 엄청 자주 쓰이는 기능도 아닐 텐데,&lt;br /&gt;메모리 25%나 쓸 만큼 가치가 있어?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;3627&quot; data-start=&quot;3573&quot; data-ke-size=&quot;size16&quot;&gt;사용 빈도만 놓고 보면,&lt;br /&gt;피드 스크롤이나 홈 화면 진입보다 검색은 분명 덜 쓰일 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;3671&quot; data-start=&quot;3629&quot; data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;검색 버튼을 누르는 순간만 놓고 보면 얘기가 완전히 달라진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;3692&quot; data-start=&quot;3673&quot; data-ke-size=&quot;size16&quot;&gt;검색을 쓰는 상황은 대체로 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3806&quot; data-start=&quot;3694&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3733&quot; data-start=&quot;3694&quot;&gt;&amp;ldquo;예전에 봤던 &lt;b&gt;그 뉴스레터 한 편&lt;/b&gt;을 꼭 다시 찾고 싶을 때&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;3770&quot; data-start=&quot;3734&quot;&gt;&amp;ldquo;며칠 전에 읽었던 글인데, 정확한 제목은 기억이 안 날 때&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;3806&quot; data-start=&quot;3771&quot;&gt;&amp;ldquo;특정 키워드로 관련된 아티클을 한 번에 모아보고 싶을 때&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3863&quot; data-start=&quot;3808&quot; data-ke-size=&quot;size16&quot;&gt;이때 검색이 &lt;b&gt;6초씩 걸리거나&lt;/b&gt;, 결과가 엉뚱하게 나온다면&lt;br /&gt;사용자는 한 번에 이렇게 느낀다.&lt;/p&gt;
&lt;blockquote data-end=&quot;3897&quot; data-start=&quot;3865&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;3897&quot; data-start=&quot;3867&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;어&amp;hellip; 이 서비스에 내 뉴스레터를 계속 맡겨도 되나?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;3964&quot; data-start=&quot;3899&quot; data-ke-size=&quot;size16&quot;&gt;검색은 &lt;b&gt;&amp;ldquo;매일 자주 쓰는 기능&amp;rdquo;은 아닐 수 있지만&lt;/b&gt;,&lt;br /&gt;&amp;ldquo;필요한 순간의 중요도가 매우 높은 기능&amp;rdquo;이다.&lt;/p&gt;
&lt;p data-end=&quot;3964&quot; data-start=&quot;3899&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4064&quot; data-start=&quot;3966&quot; data-ke-size=&quot;size16&quot;&gt;특히 봄봄처럼 &amp;ldquo;내 메일박스에 쌓여 있는 콘텐츠를 대신 관리해주는 서비스&amp;rdquo;라면,&lt;br /&gt;&amp;ldquo;언제든지 다시 찾아볼 수 있다&amp;rdquo;는 감각이 서비스 신뢰와 직결된다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;4083&quot; data-start=&quot;4066&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는 이렇게 타협했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;4223&quot; data-start=&quot;4085&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;4223&quot; data-start=&quot;4087&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;검색 사용률은 낮지만, 검색이 필요한 순간의 중요도는 높다.&lt;br /&gt;&amp;rarr; 그렇다면 검색을 완전히 포기하기보다는,&lt;br /&gt;&lt;b&gt;메모리 예산 안에서 &amp;lsquo;최근 5일&amp;rsquo;만이라도 확실히 빠르고,&lt;br /&gt;다양한 형태의 검색을 받아줄 수 있게 만들자.&lt;/b&gt;&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;4238&quot; data-start=&quot;4225&quot; data-ke-size=&quot;size16&quot;&gt;여기서 25%라는 수치는 &amp;ldquo;검색이 너무 중요하니까 무조건 25%를 박제했다&amp;rdquo;가 아니라,&lt;/p&gt;
&lt;p data-end=&quot;4238&quot; data-start=&quot;4225&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재 인프라(t4g.micro + 384MB 버퍼 풀)&lt;/b&gt; 기준에서&lt;br /&gt;&amp;ldquo;&lt;b&gt;n그램 기간을 3일&amp;middot;5일&amp;middot;7일 중 어디쯤 두면 좋을까?&lt;/b&gt;&amp;rdquo;를 고민하면서 잡은&lt;br /&gt;&lt;b&gt;초기 가설값&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;4238&quot; data-start=&quot;4225&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4238&quot; data-start=&quot;4225&quot; data-ke-size=&quot;size16&quot;&gt;지금은 굉장히 보수적으로 잡았기 때문에 이보다 낮을 확률이 더 높다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4238&quot; data-start=&quot;4225&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4437&quot; data-start=&quot;4394&quot; data-ke-size=&quot;size16&quot;&gt;실제 운영에서는 버퍼 풀 히트율, 검색 사용량, 전체 쿼리 성능을 계속 보면서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4470&quot; data-start=&quot;4439&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4454&quot; data-start=&quot;4439&quot;&gt;5일을 3일로 줄이거나,&lt;/li&gt;
&lt;li data-end=&quot;4470&quot; data-start=&quot;4455&quot;&gt;오히려 7일로 늘리거나,&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4495&quot; data-start=&quot;4472&quot; data-ke-size=&quot;size16&quot;&gt;하는 식으로 &lt;b&gt;계속 조정할 계획&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr data-end=&quot;4500&quot; data-start=&quot;4497&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;4520&quot; data-start=&quot;4502&quot; data-ke-size=&quot;size23&quot;&gt;왜 LIKE보다도 빠를까?&lt;/h3&gt;
&lt;p data-end=&quot;4543&quot; data-start=&quot;4522&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, 이런 의문도 들 수 있다.&lt;/p&gt;
&lt;blockquote data-end=&quot;4632&quot; data-start=&quot;4545&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;4632&quot; data-start=&quot;4547&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;n그램 한 번, 기본 FULLTEXT 한 번이면&lt;br /&gt;쿼리를 두 번이나 치는 건데,&lt;br /&gt;LIKE '%키워드%' 한 번보다 느린 거 아냐?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;4656&quot; data-start=&quot;4634&quot; data-ke-size=&quot;size16&quot;&gt;하지만 구조를 뜯어 보면 완전히 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4978&quot; data-start=&quot;4658&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4781&quot; data-start=&quot;4658&quot;&gt;LIKE '%키워드%'
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4781&quot; data-start=&quot;4679&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4739&quot; data-start=&quot;4679&quot;&gt;B-Tree 인덱스를 탈 수 없어서&lt;br /&gt;사실상 &lt;b&gt;테이블 전체를 훑는 것과 크게 다르지 않다.&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;4781&quot; data-start=&quot;4742&quot;&gt;데이터가 쌓일수록 검색 시간이 &lt;b&gt;행 개수에 비례해서&lt;/b&gt; 늘어난다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;4978&quot; data-start=&quot;4783&quot;&gt;MATCH AGAINST (ngram / 기본 FULLTEXT)
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4978&quot; data-start=&quot;4827&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4900&quot; data-start=&quot;4827&quot;&gt;미리 만들어 둔 &lt;b&gt;역색인(토큰 &amp;rarr; 아티클 id 목록)&lt;/b&gt; 에서&lt;br /&gt;&amp;ldquo;이 토큰을 포함하는 아티클 id&amp;rdquo;만 빠르게 뽑는다.&lt;/li&gt;
&lt;li data-end=&quot;4978&quot; data-start=&quot;4903&quot;&gt;즉, 전체 N개 중에서 &lt;b&gt;필요한 것들만 골라 읽는 구조&lt;/b&gt;라&lt;br /&gt;데이터가 늘어나도 LIKE처럼 폭발적으로 느려지지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4994&quot; data-start=&quot;4980&quot; data-ke-size=&quot;size16&quot;&gt;결국 우리가 선택한 것은,&lt;/p&gt;
&lt;blockquote data-end=&quot;5150&quot; data-start=&quot;4996&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;5150&quot; data-start=&quot;4998&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;버퍼 풀이라는 한정된 자원 안에서,&lt;br /&gt;&lt;b&gt;전체를 n그램으로 도배하지 않으면서도&lt;/b&gt;&lt;br /&gt;한글 부분 검색 + 띄어쓰기 포함 검색 + 전 기간 검색을&lt;br /&gt;최대한 모두 만족시키기 위한&lt;br /&gt;&amp;lsquo;최근 5일 n그램 + 전 기간 FULLTEXT&amp;rsquo; 혼합 전략&amp;rdquo;이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚒️ 실제 적용 &amp;amp; 부하 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말로만 &amp;ldquo;LIKE보다 빠르다&amp;rdquo;라고 할 수는 없으니,&amp;nbsp;&amp;nbsp;&lt;br /&gt;실제 서비스를 기준으로 &lt;b&gt;부하 테스트&lt;/b&gt;를 돌려봤다.&lt;br /&gt;&lt;br /&gt;테스트&amp;nbsp;조건은&amp;nbsp;다음과&amp;nbsp;같다.&lt;br /&gt;&lt;br /&gt;-&amp;nbsp;도구:&amp;nbsp;k6&lt;br /&gt;-&amp;nbsp;시나리오:&amp;nbsp;`ramping-vus`&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;10&amp;nbsp;VU&amp;nbsp;&amp;rarr;&amp;nbsp;30&amp;nbsp;VU&amp;nbsp;(2분)&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;30&amp;nbsp;VU&amp;nbsp;&amp;rarr;&amp;nbsp;60&amp;nbsp;VU&amp;nbsp;(2분)&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;60&amp;nbsp;VU&amp;nbsp;&amp;rarr;&amp;nbsp;100&amp;nbsp;VU&amp;nbsp;(2분)&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;이후&amp;nbsp;1분&amp;nbsp;동안&amp;nbsp;0&amp;nbsp;VU까지&amp;nbsp;감소&lt;br /&gt;-&amp;nbsp;한&amp;nbsp;VU는&amp;nbsp;2초에&amp;nbsp;한&amp;nbsp;번씩&amp;nbsp;검색&amp;nbsp;수행&lt;br /&gt;-&amp;nbsp;검색&amp;nbsp;키워드:&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;- `makeNaturalKoreanKeyword()`로 생성한 자연스러운 한글 조합*&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;예)&amp;nbsp;`뜨거운&amp;nbsp;경제`,&amp;nbsp;`뉴스&amp;nbsp;성장`,&amp;nbsp;`콘텐츠&amp;nbsp;기록`&amp;nbsp;등&lt;br /&gt;-&amp;nbsp;k6&amp;nbsp;스크립트에서는&amp;nbsp;`endpoint:search`&amp;nbsp;태그를&amp;nbsp;달아서&amp;nbsp;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&amp;nbsp;검색&amp;nbsp;API만&amp;nbsp;따로&amp;nbsp;지표를&amp;nbsp;뽑을&amp;nbsp;수&amp;nbsp;있게&amp;nbsp;했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1763378615676&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;group('search-only', () =&amp;gt; {
  const keyword = makeNaturalKoreanKeyword();
  const searchUrl = `${BASE}/api/v1/articles?keyword=${keyword}&amp;amp;limit=10`;

  const res = http.get(searchUrl, {
    headers,
    cookies: COOKIE,
    tags: { endpoint: 'search' },
  });

  check(res, { 'search 200': (r) =&amp;gt; r.status === 200 });
  warnSlow(res, 'search');
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;974&quot; data-start=&quot;940&quot; data-ke-size=&quot;size23&quot;&gt;BEFORE: LIKE '%keyword%' 기반 검색&lt;/h3&gt;
&lt;p data-end=&quot;1031&quot; data-start=&quot;976&quot; data-ke-size=&quot;size16&quot;&gt;먼저 기존 구현이었던 LIKE '%keyword%' 기반 검색에 대해 같은 시나리오로 돌려봤다.&lt;/p&gt;
&lt;p data-end=&quot;1031&quot; data-start=&quot;976&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2068&quot; data-origin-height=&quot;908&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ba0iZ8/dJMcabWSOv4/pWhnBzifMXQ9gTIGUfWG21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ba0iZ8/dJMcabWSOv4/pWhnBzifMXQ9gTIGUfWG21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ba0iZ8/dJMcabWSOv4/pWhnBzifMXQ9gTIGUfWG21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fba0iZ8%2FdJMcabWSOv4%2FpWhnBzifMXQ9gTIGUfWG21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;548&quot; height=&quot;241&quot; data-origin-width=&quot;2068&quot; data-origin-height=&quot;908&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1126&quot; data-start=&quot;1033&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1126&quot; data-start=&quot;1033&quot;&gt;http_req_duration{endpoint:search}
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1126&quot; data-start=&quot;1076&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1100&quot; data-start=&quot;1076&quot;&gt;&lt;b&gt;평균(avg)&lt;/b&gt;: 약 4.03s&lt;/li&gt;
&lt;li data-end=&quot;1126&quot; data-start=&quot;1103&quot;&gt;&lt;b&gt;p95&lt;/b&gt;: 약 &lt;b&gt;10.34s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1209&quot; data-start=&quot;1128&quot; data-ke-size=&quot;size16&quot;&gt;검색 한 번에 &lt;b&gt;10초를 넘기는 구간이 적지 않게 존재&lt;/b&gt;했고,&lt;br /&gt;사실상 &amp;ldquo;검색 버튼을 누르면 한참 기다려야 하는 서비스&amp;rdquo;에 가까운 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;AFTER: ngram+ FULLTEXT 혼합 검색&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1265&quot; data-start=&quot;1249&quot; data-ke-size=&quot;size16&quot;&gt;이후, 본문에서 설명한 것처럼&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1317&quot; data-start=&quot;1267&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1293&quot; data-start=&quot;1267&quot;&gt;최근 5일치: n그램 FULLTEXT 인덱스&lt;/li&gt;
&lt;li data-end=&quot;1317&quot; data-start=&quot;1294&quot;&gt;전 기간: 기본 FULLTEXT 인덱스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1365&quot; data-start=&quot;1319&quot; data-ke-size=&quot;size16&quot;&gt;를 함께 사용하는 검색으로 교체한 뒤, 똑같은 조건으로 다시 부하 테스트를 돌렸다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2176&quot; data-origin-height=&quot;992&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0gSVM/dJMcacnXbLM/ExyLCMolBrkzoIxZuqPobk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0gSVM/dJMcacnXbLM/ExyLCMolBrkzoIxZuqPobk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0gSVM/dJMcacnXbLM/ExyLCMolBrkzoIxZuqPobk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0gSVM%2FdJMcacnXbLM%2FExyLCMolBrkzoIxZuqPobk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;603&quot; height=&quot;275&quot; data-origin-width=&quot;2176&quot; data-origin-height=&quot;992&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1458&quot; data-start=&quot;1367&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1458&quot; data-start=&quot;1367&quot;&gt;http_req_duration{endpoint:search}
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1458&quot; data-start=&quot;1410&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1434&quot; data-start=&quot;1410&quot;&gt;&lt;b&gt;평균(avg)&lt;/b&gt;: 약 698ms&lt;/li&gt;
&lt;li data-end=&quot;1458&quot; data-start=&quot;1437&quot;&gt;&lt;b&gt;p95&lt;/b&gt;: 약 &lt;b&gt;1.9s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1465&quot; data-start=&quot;1460&quot; data-ke-size=&quot;size16&quot;&gt;정리하면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1548&quot; data-start=&quot;1467&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1508&quot; data-start=&quot;1467&quot;&gt;평균 응답 시간: &lt;b&gt;4.03s &amp;rarr; 0.69s&lt;/b&gt; (약 5.8배 개선)&lt;/li&gt;
&lt;li data-end=&quot;1548&quot; data-start=&quot;1509&quot;&gt;p95 기준: &lt;b&gt;10.34s &amp;rarr; 1.9s&lt;/b&gt; (약 5.4배 개선)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1596&quot; data-start=&quot;1550&quot; data-ke-size=&quot;size16&quot;&gt;으로, 단순 LIKE 검색에 비해 &lt;b&gt;5배 이상&lt;/b&gt; 빨라진 것을 확인할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;아직 k6 threshold로 잡아둔 목표치(p95 &amp;lt; 350ms, p99 &amp;lt; 600ms)를 완전히 만족시키지는 못하지만,&lt;br /&gt;t4g.micro + 384MB 버퍼 풀이라는 제약 안에서&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;ldquo;부분 검색 + 공백이 섞인 여러 단어 검색&amp;rdquo; 경험을 유지하면서&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;테이블 풀 스캔 없이 인덱스 기반으로 동작하는 검색&amp;rdquo; 을 만들었다는 점에서 의미 있는 개선이라고 생각한다.&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;⚒️ 수정된 설계&amp;nbsp;&lt;/h2&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;이 글을 초안까지 다 쓰고, 잠들기 전에 친구랑 통화를 하면서 내가 짠 설계를 쭉 설명했다.&lt;br /&gt;말로 풀어서 설명하다 보니, 듣는 사람보다 오히려 내가 더 많이 깨닫는 느낌이었다.&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;189&quot; data-start=&quot;131&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 &amp;ldquo;유저 500명 정도&amp;rdquo;를 가정하고 설계를 짰다.&lt;br /&gt;하지만 전화를 끊고 나니 이런 생각이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;189&quot; data-start=&quot;131&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;258&quot; data-start=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;근데&amp;hellip; 정말 500명에서 끝날까?&amp;rdquo;&lt;br /&gt;&amp;ldquo;유저가 5,000명, 1만 명이 되면 그때도 이 구조가 버틸까?&amp;rdquo;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;258&quot; data-start=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;258&quot; data-start=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;냉정하게 말하면, 지금 구조로는 버틸 수 없다.&lt;br /&gt;나는 항상 오버엔지니어링을 기피해 왔고,&lt;br /&gt;유저 증가 속도를 보면서 대략 6개월 정도를 내다보고 설계를 했지만,&lt;br /&gt;곰곰이 따져 보니 허점이 꽤 많아 보였다.&lt;/p&gt;
&lt;p data-end=&quot;258&quot; data-start=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;유저가 많아질수록 아티클은 기하급수적으로 쌓이고, 그때마다 인덱스 용량도 같이 커진다.&lt;br /&gt;기본 FULLTEXT 인덱스는 Ngram FULLTEXT에 비해 훨씬 가볍긴 하지만,&lt;br /&gt;그래도 10만 개, 100만 개, 1,000만 개까지 쌓인다고 생각하면&lt;br /&gt;&amp;ldquo;이 정도면 괜찮겠지&amp;rdquo; 하고 넘길 수 있는 수준은 아니다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;메일이 새로 오거나 삭제될 때마다 인덱스도 같이 수정해야 한다.&lt;br /&gt;데이터가 커질수록 이 비용은 눈덩이처럼 커진다.&lt;br /&gt;문득, 이걸 기술로만 버티게 만드는 건 너무 근시안적인 선택이 아닐까 하는 생각이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;그래서 관점을 살짝 바꿔 보기로 했다.&lt;br /&gt;&amp;ldquo;이걸 DB 튜닝으로만 버틸 게 아니라,&lt;br /&gt;애초에 &amp;lsquo;우리가 어떤 서비스를 만들고 싶은지&amp;rsquo;에서부터 답을 찾을 수는 없을까?&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;이때 예전에 팀원들끼리 했던 대화만 하고 그냥 넘어갔던게 떠올랐다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;그 당시 이런 이야기를 했던 기억이 있다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1014&quot; data-start=&quot;967&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;개인이 메일을 무제한으로 받아야 할까?&amp;rdquo;&lt;br /&gt;&amp;ldquo;어느 정도에서 잘라주는 게 맞을까?&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;1014&quot; data-start=&quot;967&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1128&quot; data-start=&quot;1016&quot; data-ke-size=&quot;size16&quot;&gt;네이버 메일처럼 일반 메일 서비스는 용량 단위로 제한을 둔다.&lt;br /&gt;하지만 봄봄은 일반 메일이 아니라 &amp;ldquo;뉴스레터만 모으는 서비스&amp;rdquo;라서,&lt;br /&gt;용량보다 &amp;ldquo;몇 개까지 보관해 줄지&amp;rdquo;가 훨씬 직관적이라고 느꼈다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;게다가 우리는 아직 수익도 발생하지 않고,&lt;br /&gt;언제부터 어떤 방식으로 돈을 벌게 될지도 명확하지 않은 프로젝트다.&lt;br /&gt;그런 상황에서 &amp;ldquo;무제한 보관&amp;rdquo;을 약속해 버리면&lt;br /&gt;검색 이전에, 저장 자체가 비용 폭탄이 될 수밖에 없다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 문제는 &amp;ldquo;DB 성능 문제&amp;rdquo;이기 전에&lt;br /&gt;&amp;ldquo;서비스가 어디까지를 책임질 것인지&amp;rdquo;에 대한 비즈니스 결정이라고 보기로 했다.&lt;br /&gt;결국, 일정 개수에서 잘라야 한다는 결론에 도달했다.&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;435&quot; data-start=&quot;260&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 몇 개가 적당할까?&lt;/p&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;뉴스레터를 실제로 사용해보며 대략적인 평균치를 잡아봤다.&lt;br /&gt;한 사용자가 하루에 받는 뉴스레터를 4개 정도로 가정하면&lt;br /&gt;한 달이면 120개, 4개월이면 약 480개다.&lt;br /&gt;대부분의 사용자는 4개월 치 뉴스레터를 모두 다시 찾아볼 일은 거의 없다.&lt;br /&gt;그래서 &amp;ldquo;4개월 치를 넉넉히 보관해 주는 정도면 초반엔 충분하겠다&amp;rdquo;는 기준을 세웠다.&lt;/p&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;4개월이면 충분히 습관이 형성되고 계속 우리 서비스를 이용할 것이라 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;여기서 500개라는 숫자가 나왔다.&lt;/p&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1381&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1771&quot; data-start=&quot;1592&quot; data-ke-size=&quot;size16&quot;&gt;너무 낮게 잡으면 처음부터 불편하고,&lt;br /&gt;너무 높게 잡으면 나중에 줄여야 할 수도 있는데,&lt;br /&gt;서비스 입장에서 &amp;ldquo;용량을 줄이는 경험&amp;rdquo;은 꽤나 최악의 경험이다.&lt;br /&gt;반대로, 처음엔 보수적으로 잡아두고&lt;br /&gt;서비스가 성장하면 &amp;ldquo;이제 1,000개까지 보관해 드릴게요&amp;rdquo;라고 올려주는 쪽이&lt;br /&gt;훨씬 좋은 사용자 경험이라고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;1771&quot; data-start=&quot;1592&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1771&quot; data-start=&quot;1592&quot; data-ke-size=&quot;size16&quot;&gt;그래서 1유저 = 최대 500개 뉴스레터 보관이라는 정책을 정했다.&lt;br /&gt;대략 아래와 같은 계산도 같이 했다.&lt;/p&gt;
&lt;p data-end=&quot;1859&quot; data-start=&quot;1835&quot; data-ke-size=&quot;size16&quot;&gt;한 뉴스레터를 평균 25KB 정도로 잡으면,&lt;/p&gt;
&lt;p data-end=&quot;1949&quot; data-start=&quot;1861&quot; data-ke-size=&quot;size16&quot;&gt;1유저 저장 용량 ≒ 25KB &amp;times; 500개 ≒ 12.2MB&lt;br /&gt;1,000명일 때 ≒ 12GB&lt;br /&gt;5,000명일 때 ≒ 60GB&lt;br /&gt;1만 명일 때 ≒ 120GB&lt;/p&gt;
&lt;p data-end=&quot;1999&quot; data-start=&quot;1951&quot; data-ke-size=&quot;size16&quot;&gt;대략 이 정도면 지금 인프라와 비용 구조 안에서&lt;br /&gt;&amp;ldquo;감당 가능한 선&amp;rdquo;이라고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;1999&quot; data-start=&quot;1951&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1999&quot; data-start=&quot;1951&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 해서 나온 &amp;ldquo;사용자당 500개&amp;rdquo; 정책은&lt;br /&gt;단순히 DB를 살리기 위한 기술적 꼼수가 아니라,&lt;br /&gt;우리가 감당할 수 있는 비용과&lt;br /&gt;사용자에게 약속할 수 있는 서비스 범위를 함께 고려한&lt;br /&gt;비즈니스적인 결정에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;1999&quot; data-start=&quot;1951&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2155&quot; data-start=&quot;2127&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이 결정은 바로 검색 설계에도 영향을 줬다.&lt;/p&gt;
&lt;p data-end=&quot;2155&quot; data-start=&quot;2127&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2358&quot; data-start=&quot;2157&quot; data-ke-size=&quot;size16&quot;&gt;이 조건이 들어오자 전제가 다시 한 번 달라졌다.&lt;br /&gt;memberId 인덱스를 타고 조회하면, 한 사용자 기준으로는 어차피 최대 500개까지만 남는다.&lt;br /&gt;그렇다면 이 500개에 대해서는 LIKE '%keyword%'로 풀스캔을 하더라도,&lt;br /&gt;예전에 전체 article 테이블을 대상으로 LIKE를 날렸을 때와는&lt;br /&gt;성능 특성이 완전히 다르다고 볼 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l0wKX/dJMcaajoAn7/vQQt7T0Xk33vTYJamPoje0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l0wKX/dJMcaajoAn7/vQQt7T0Xk33vTYJamPoje0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l0wKX/dJMcaajoAn7/vQQt7T0Xk33vTYJamPoje0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl0wKX%2FdJMcaajoAn7%2FvQQt7T0Xk33vTYJamPoje0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;318&quot; height=&quot;192&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1701&quot; data-start=&quot;1598&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1625&quot; data-start=&quot;1553&quot; data-ke-size=&quot;size16&quot;&gt;그래서 먼저, 가장 단순한 구조부터 실험해 보기로 했다.&lt;br /&gt;memberId로 범위를 500개 이내로 좁힌 뒤,&lt;br /&gt;그 안에서 LIKE '%keyword%'로만 검색하는 방식이다.&lt;/p&gt;
&lt;p data-end=&quot;1625&quot; data-start=&quot;1553&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2536&quot; data-start=&quot;2462&quot; data-ke-size=&quot;size16&quot;&gt;이 구조로 k6 부하 테스트를 돌려 보았다.&lt;br /&gt;가상 유저를 최대 100명까지 올리고 약 7,300건 정도의 검색 요청을 보냈을 때,&lt;/p&gt;
&lt;p data-end=&quot;2536&quot; data-start=&quot;2462&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2146&quot; data-origin-height=&quot;898&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4lv0Y/dJMcaacCirO/elbJDNPKQ81LxJthIzB1O1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4lv0Y/dJMcaacCirO/elbJDNPKQ81LxJthIzB1O1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4lv0Y/dJMcaacCirO/elbJDNPKQ81LxJthIzB1O1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4lv0Y%2FdJMcaacCirO%2FelbJDNPKQ81LxJthIzB1O1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;612&quot; height=&quot;256&quot; data-origin-width=&quot;2146&quot; data-origin-height=&quot;898&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;2536&quot; data-start=&quot;2462&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2607&quot; data-start=&quot;2538&quot; data-ke-size=&quot;size16&quot;&gt;평균 응답 시간은 대략 800ms,&lt;br /&gt;중앙값은 220ms 안쪽,&lt;br /&gt;p95(95퍼센트 지점)는 약 1.6초 정도가 나왔다.&lt;/p&gt;
&lt;p data-end=&quot;2607&quot; data-start=&quot;2538&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2820&quot; data-start=&quot;2609&quot; data-ke-size=&quot;size16&quot;&gt;예전처럼 전체 article 테이블에 LIKE '%keyword%'를 날렸을 때&lt;br /&gt;p95가 10초를 넘기던 것을 생각하면,&lt;br /&gt;이 정도면 더 이상 &amp;ldquo;망했다&amp;rdquo; 수준의 수치는 아니었다.&lt;br /&gt;게다가 실제 서비스에서 검색은 동시성이 아주 높지도 않고,&lt;br /&gt;전체 트래픽에서 차지하는 비중도 크지 않다.&lt;br /&gt;실제 사용자 환경에서는 이보다 더 여유 있는 숫자가 나오리라 기대할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;2904&quot; data-start=&quot;2822&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2904&quot; data-start=&quot;2822&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 보면 &amp;ldquo;그냥 LIKE만 써도 되겠다&amp;rdquo;는 결론을 내려도 이상할 건 없다.&lt;br /&gt;하지만 처음 설계를 시작할 때 가졌던 생각은 여전히 같았다.&lt;/p&gt;
&lt;p data-end=&quot;2958&quot; data-start=&quot;2906&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2958&quot; data-start=&quot;2906&quot; data-ke-size=&quot;size16&quot;&gt;검색은 자주 쓰이지는 않더라도,&lt;br /&gt;막상 사용했을 때는 &amp;ldquo;제대로&amp;rdquo; 동작해야 한다는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;3039&quot; data-start=&quot;2960&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3039&quot; data-start=&quot;2960&quot; data-ke-size=&quot;size16&quot;&gt;그러기 위해서는 앞에서 이야기한 것처럼&lt;br /&gt;어느 정도 이상의 부분 검색 품질을 유지해야 했고,&lt;br /&gt;줄일 수 있는 속도는 최대한 줄이고 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;3066&quot; data-start=&quot;3041&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3066&quot; data-start=&quot;3041&quot; data-ke-size=&quot;size16&quot;&gt;그래서 한 걸음만 더 나아가 보기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;3196&quot; data-start=&quot;3068&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3196&quot; data-start=&quot;3068&quot; data-ke-size=&quot;size16&quot;&gt;최신 5일 이내의 아티클은 Ngram 기반 FULLTEXT 인덱스로 검색하고,&lt;br /&gt;그 이후(5일 이전) 아티클은 memberId로 최대 500개까지 좁힌 뒤&lt;br /&gt;LIKE '%keyword%'로 검색하는 혼합 구조를 다시 만들었다.&lt;/p&gt;
&lt;p data-end=&quot;3273&quot; data-start=&quot;3198&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3273&quot; data-start=&quot;3198&quot; data-ke-size=&quot;size16&quot;&gt;이때 explain으로 실제 실행 계획을 확인해 가며&lt;br /&gt;인덱스를 타는 조건과 정렬 조건을 계속 바꿔보면서 쿼리 튜닝도 함께 진행했다.&lt;/p&gt;
&lt;p data-end=&quot;3370&quot; data-start=&quot;3275&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3370&quot; data-start=&quot;3275&quot; data-ke-size=&quot;size16&quot;&gt;구조를 바꾼 뒤, 동일하게 k6로 부하 테스트를 한 번 더 돌렸다.&lt;br /&gt;마찬가지로 가상 유저를 최대 100명까지 올리고,&lt;br /&gt;약 8,900건 정도의 검색 요청을 보냈을 때&lt;/p&gt;
&lt;p data-end=&quot;3370&quot; data-start=&quot;3275&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;898&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Kf1Om/dJMcacIg0uS/okY7ND5sMwqOkSlXxKYqi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Kf1Om/dJMcacIg0uS/okY7ND5sMwqOkSlXxKYqi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Kf1Om/dJMcacIg0uS/okY7ND5sMwqOkSlXxKYqi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKf1Om%2FdJMcacIg0uS%2FokY7ND5sMwqOkSlXxKYqi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;669&quot; height=&quot;274&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;898&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;3457&quot; data-start=&quot;3372&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3457&quot; data-start=&quot;3372&quot; data-ke-size=&quot;size16&quot;&gt;http_req_duration 기준 평균은 약 260ms,&lt;br /&gt;중앙값은 약 85ms,&lt;br /&gt;p90은 약 510ms,&lt;br /&gt;p95는 약 740ms가 나왔다.&lt;/p&gt;
&lt;p data-end=&quot;3553&quot; data-start=&quot;3459&quot; data-ke-size=&quot;size16&quot;&gt;LIKE만 썼을 때와 비교하면,&lt;/p&gt;
&lt;p data-end=&quot;3553&quot; data-start=&quot;3459&quot; data-ke-size=&quot;size16&quot;&gt;평균은 800ms에서 260ms 수준으로,&lt;br /&gt;중앙값은 220ms에서 85ms로,&lt;br /&gt;p95도 1.6초에서 0.74초 정도로 줄었다.&lt;/p&gt;
&lt;p data-end=&quot;3684&quot; data-start=&quot;3555&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3684&quot; data-start=&quot;3555&quot; data-ke-size=&quot;size16&quot;&gt;즉 &amp;ldquo;사용자당 500개 제한 + 최신 5일 Ngram + 나머지 LIKE&amp;rdquo; 구조가&lt;br /&gt;단순히 LIKE만 쓰는 구조보다 응답 시간이 전반적으로 훨씬 안정적이고,&lt;br /&gt;꼬리 구간을 제외한 대부분 요청에서 체감 속도를 꽤 끌어올려 주었다.&lt;/p&gt;
&lt;p data-end=&quot;3790&quot; data-start=&quot;3686&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3790&quot; data-start=&quot;3686&quot; data-ke-size=&quot;size16&quot;&gt;결국 지금 구조는&lt;br /&gt;당장 눈앞의 성능 문제만 보는 것도 아니고,&lt;br /&gt;그렇다고 먼 미래의 막연한 트래픽을 위해 과하게 투자하는 것도 아닌,&lt;br /&gt;그 중간 어딘가에서 찾아낸 타협점에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;3958&quot; data-start=&quot;3792&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3958&quot; data-start=&quot;3792&quot; data-ke-size=&quot;size16&quot;&gt;서비스가 더 커지고, 검색이 정말 비즈니스의 중심 기능이 되는 순간이 온다면&lt;/p&gt;
&lt;p data-end=&quot;3958&quot; data-start=&quot;3792&quot; data-ke-size=&quot;size16&quot;&gt;아마 또 한 번 설계를 갈아엎게 될 것이다.&lt;br /&gt;예를 들어 &amp;ldquo;최신 1년만 풀텍스트 인덱싱&amp;rdquo;,&lt;br /&gt;&amp;ldquo;기간 선택을 더 적극적으로 강제&amp;rdquo;,&lt;br /&gt;혹은 &amp;ldquo;전용 검색 엔진 도입&amp;rdquo; 같은 선택지를&lt;br /&gt;다시 꺼내 들게 될지도 모른다.&lt;/p&gt;
&lt;p data-end=&quot;4126&quot; data-start=&quot;3960&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4126&quot; data-start=&quot;3960&quot; data-ke-size=&quot;size16&quot;&gt;하지만 적어도 지금 시점에서 이 선택은,&lt;br /&gt;서비스의 성장 가능성과 현재 인프라의 한계를 동시에 바라보며&lt;br /&gt;여러 밤을 고민하고, 실제 부하 테스트 결과까지 확인한 뒤에 내린 결정이라는 점이 중요하다고 생각한다.&lt;br /&gt;지금 이 글에 적힌 설계는 그런 고민의 흔적을 기록해 둔 하나의 스냅샷에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;1811&quot; data-start=&quot;1593&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1811&quot; data-start=&quot;1593&quot; data-ke-size=&quot;size26&quot;&gt;  정리하며&lt;/h2&gt;
&lt;p data-end=&quot;3391&quot; data-start=&quot;3203&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 LIKE '%keyword%' 하나로 부분 검색과 공백이 섞인 검색을 모두 해결해 보려 했다가&lt;br /&gt;인덱스를 타지 못하면서 p95가 6초를 넘어가는 검색을 얻었다.&lt;br /&gt;그다음에는 캐시를 얹을지, 별도 검색 엔진(Meilisearch, Typesense)을 쓸지,&lt;br /&gt;MySQL 플러그인을 붙일 수 있을지 등 여러 후보를 검토했다.&lt;/p&gt;
&lt;p data-end=&quot;3391&quot; data-start=&quot;3203&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3527&quot; data-start=&quot;3393&quot; data-ke-size=&quot;size16&quot;&gt;하지만 RDS MySQL(t4g.micro, 1GB RAM)이라는 인프라 제약과&lt;br /&gt;검색이 &amp;ldquo;매일 수백 번 쓰이는 메인 기능&amp;rdquo;은 아니라는 서비스 특성을 함께 놓고 봤을 때&lt;br /&gt;지금 단계에서 전용 검색 엔진을 도입하는 건 과투자라고 판단했다.&lt;/p&gt;
&lt;p data-end=&quot;3527&quot; data-start=&quot;3393&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3678&quot; data-start=&quot;3529&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;주어진 MySQL 안에서, 부분 검색과 공백이 섞인 검색 경험을 최대한 유지하면서&lt;br /&gt;LIKE보다 훨씬 빠르게 만들 수 있는 방법&amp;rdquo;을 찾는 쪽으로 방향을 틀었고,&lt;br /&gt;그 결과가 &amp;ldquo;최근 5일 Ngram + 이후 LIKE '%keyword%'&amp;rdquo; 혼합 전략이었다.&lt;/p&gt;
&lt;p data-end=&quot;3678&quot; data-start=&quot;3529&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3755&quot; data-start=&quot;3680&quot; data-ke-size=&quot;size16&quot;&gt;부하 테스트 기준으로 보면,&lt;br /&gt;p95 기준 10.34초에서 1.9초,&lt;br /&gt;평균 응답 시간도 4초대에서 0.6~0.7초대로 줄었다.&lt;/p&gt;
&lt;p data-end=&quot;3755&quot; data-start=&quot;3680&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3824&quot; data-start=&quot;3757&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;검색 버튼을 누르고 기다리기 싫은 서비스&amp;rdquo;에서&lt;br /&gt;적어도 &amp;ldquo;한 번쯤은 써볼 만한 검색&amp;rdquo; 정도까지는 끌어올렸다고 느낀다.&lt;/p&gt;
&lt;p data-end=&quot;3824&quot; data-start=&quot;3757&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3931&quot; data-start=&quot;3826&quot; data-ke-size=&quot;size16&quot;&gt;아직 완벽과는 거리가 멀다.&lt;br /&gt;Ngram 기간(5일)을 3일이나 7일로 바꿔 본다든지,&lt;br /&gt;쿼리 튜닝, 인덱스 재설계, 인스턴스 스펙 업그레이드 후 재측정 같은 실험 여지는 여전히 많다.&lt;/p&gt;
&lt;p data-end=&quot;3931&quot; data-start=&quot;3826&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4019&quot; data-start=&quot;3933&quot; data-ke-size=&quot;size16&quot;&gt;그래도 이번에 느낀 건,&lt;br /&gt;검색은 멋진 엔진을 쓰는지보다&lt;br /&gt;우리 서비스와 인프라가 감당할 수 있는 선에서 어떤 타협을 했는지가 더 중요하다는 점이었다.&lt;/p&gt;
&lt;p data-end=&quot;4019&quot; data-start=&quot;3933&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;4100&quot; data-start=&quot;4021&quot; data-ke-size=&quot;size16&quot;&gt;언젠가 정말 검색이 비즈니스의 한가운데로 들어오는 시점이 오면,&lt;br /&gt;그때는 전용 검색 엔진을 도입하는 글을 또 한 편 쓰게 되지 않을까 싶다.  &lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  Reference&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/MySQL.Concepts.FeatureSupport.html?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/MySQL.Concepts.FeatureSupport.html?utm_source=chatgpt.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/fulltext-natural-language.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.mysql.com/doc/refman/8.4/en/fulltext-natural-language.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Real MySQL 8.0 (1)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <category>fulltext</category>
      <category>fulltext search</category>
      <category>fulltext-index</category>
      <category>MySQL</category>
      <category>ngram</category>
      <category>Search</category>
      <category>검색</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/768</guid>
      <comments>https://html-jc.tistory.com/768#entry768comment</comments>
      <pubDate>Mon, 17 Nov 2025 16:23:16 +0900</pubDate>
    </item>
    <item>
      <title>저장이 왜 안되는 거지?</title>
      <link>https://html-jc.tistory.com/767</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CJf3v/dJMcafkE6ru/K65vpAoJMnpNkgFsK9BPG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CJf3v/dJMcafkE6ru/K65vpAoJMnpNkgFsK9BPG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CJf3v/dJMcafkE6ru/K65vpAoJMnpNkgFsK9BPG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCJf3v%2FdJMcafkE6ru%2FK65vpAoJMnpNkgFsK9BPG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;671&quot; height=&quot;340&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 우리 서버는 한창 알림을 만드는 중이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 알림을 보내기 위한 기본 정보들이 DB에 저장이 안 되는 일이 발생했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;what?&lt;/blockquote&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;niniz&quot; data-emoticon-name=&quot;007&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/007.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/007.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명 코드적으로 문제가 없는 것 같은데 왜 저장이 안되는 걸까??&amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이유를 알 수 없었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1763129334616&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @TransactionalEventListener
    public void onArticleArrived(ArticleArrivedEvent event) {
        try {
            ArticleArrivalNotification articleArrivalNotification = ArticleArrivalNotification.builder()
                    .articleId(event.articleId())
                    .memberId(event.memberId())
                    .newsletterName(event.newsletterName())
                    .articleTitle(event.articleTitle())
                    .status(NotificationStatus.PENDING)
                    .attempts(0)
                    .isRead(false)
                    .build();
            articleArrivalNotificationRepository.save(articleArrivalNotification);

            log.info(&quot;아티클 도착 알림 저장 완료: 멤버 ID={}, 뉴스레터={}, 아티클 제목={}&quot;,
                    event.memberId(), event.newsletterName(), event.articleTitle());
        } catch (Exception e) {
			// ...
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 위와 같이 생겼다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아티클이 저장된 뒤, &lt;b&gt;AFTER_COMMIT 시점에서 알림용 엔티티를 저장하는 구조&lt;/b&gt;다.&lt;br /&gt;로그를 봐도 save()가 이후 로그가 정상적으로 호출되었고, 예외도 보이지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명 AFTER_COMMIT 이후에 저장이 되어야하는데 되지 않는것이었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 에러하나 뜨지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2584&quot; data-origin-height=&quot;156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tzCjG/dJMcacBsZmU/hjpbEt3hZ2Hd0cG8iwO9Kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tzCjG/dJMcacBsZmU/hjpbEt3hZ2Hd0cG8iwO9Kk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tzCjG/dJMcacBsZmU/hjpbEt3hZ2Hd0cG8iwO9Kk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtzCjG%2FdJMcacBsZmU%2FhjpbEt3hZ2Hd0cG8iwO9Kk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;794&quot; height=&quot;48&quot; data-origin-width=&quot;2584&quot; data-origin-height=&quot;156&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 DB를 열어보면 이렇게 텅 비어 있었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J2qoA/dJMcagjy2xr/tthCeSaGbUHsNd2ERYjz80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J2qoA/dJMcagjy2xr/tthCeSaGbUHsNd2ERYjz80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J2qoA/dJMcagjy2xr/tthCeSaGbUHsNd2ERYjz80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ2qoA%2FdJMcagjy2xr%2FtthCeSaGbUHsNd2ERYjz80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;786&quot; height=&quot;180&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그를 자세히 보면, 해당 로그는 분명 save() 호출 이후에 찍힌 로그였다.&lt;br /&gt;즉, &lt;b&gt;&amp;ldquo;엔티티를 저장하라&amp;rdquo;는 명령 자체는 JPA까지는 잘 전달된 상황&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 DB를 열어보면 테이블이 텅 비어 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 제일 먼저 떠올린 생각은&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;정말로 DB에 INSERT 쿼리가 실행되었는가?&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 당연히 `save()` 메서드가 동작했다고 해서 무조건 DB에 저장되는 것은 아니다.&lt;br /&gt;JPA 입장에서 save()는 &lt;b&gt;영속성 컨텍스트에 엔티티를 올려두는 작업&lt;/b&gt;일 뿐이고,&lt;br /&gt;실제 DB에 반영되는 시점은 &lt;b&gt;flush + commit&lt;/b&gt;이 일어날 때다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;INSERT SQL / flush 로그 확인&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 가장 먼저 할 일은 &lt;b&gt;INSERT SQL이 찍혔는지 확인하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 제일 확실하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 안타깝게도, 이 서버는 운영 이메일 서버라 SQL 쿼리 로그를 별도로 켜지 않은 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신, &lt;b&gt;flush 로그&lt;/b&gt;를 통해 우회적으로 확인해보기로 했다.&lt;/p&gt;
&lt;p data-end=&quot;2204&quot; data-start=&quot;2151&quot; data-ke-size=&quot;size16&quot;&gt;Hibernate는 flush 시점에 몇 개의 엔티티가 반영되었는지 다음과 같이 로그를 남긴다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1850&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bguo9W/dJMcacnV5ta/g15CkAW7ZZgGfIBhlJTHH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bguo9W/dJMcacnV5ta/g15CkAW7ZZgGfIBhlJTHH0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bguo9W/dJMcacnV5ta/g15CkAW7ZZgGfIBhlJTHH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbguo9W%2FdJMcacnV5ta%2Fg15CkAW7ZZgGfIBhlJTHH0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;841&quot; height=&quot;45&quot; data-origin-width=&quot;1850&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 insertions(혹은 creations)가 &lt;b&gt;0&lt;/b&gt;이었다는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;flush 시점에 &amp;ldquo;새로 추가된 엔티티&amp;rdquo;가 하나도 없었다는 것&lt;/b&gt;이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1178&quot; data-start=&quot;1039&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1078&quot; data-start=&quot;1039&quot;&gt;save()는 호출되었다. 엔티티는 영속성 컨텍스트에 올라감&lt;/li&gt;
&lt;li data-end=&quot;1122&quot; data-start=&quot;1079&quot;&gt;하지만 flush 시점에 &lt;b&gt;insert 대상 엔티티가 하나도 없었다.&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1153&quot; data-start=&quot;1123&quot;&gt;그래서 DB로 날아간 INSERT SQL도 없었고,&lt;/li&gt;
&lt;li data-end=&quot;1178&quot; data-start=&quot;1154&quot;&gt;결국 DB에는 아무것도 저장되지 않았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서는 외부 트랜잭션이 rollback 된 것도 아니고, readOnly 트랜잭션도 아니었고, 다른 DB를 바라보고 있는 것도 아니었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &quot;전형적인&quot; 원인들은 아니었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 팀 슬랙에 상황을 공유해두고 계속 로그를 뜯어보다가 진짜 원인을 발견했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;238&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6fyuj/dJMcafkE2Uj/59kC9Y8O6t5F8SF7jI3IhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6fyuj/dJMcafkE2Uj/59kC9Y8O6t5F8SF7jI3IhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6fyuj/dJMcafkE2Uj/59kC9Y8O6t5F8SF7jI3IhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6fyuj%2FdJMcafkE2Uj%2F59kC9Y8O6t5F8SF7jI3IhK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;445&quot; height=&quot;105&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;238&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dg33Cg/dJMcacuHHpW/K7TsZn6qKCA3b9K7i1ZKlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dg33Cg/dJMcacuHHpW/K7TsZn6qKCA3b9K7i1ZKlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dg33Cg/dJMcacuHHpW/K7TsZn6qKCA3b9K7i1ZKlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdg33Cg%2FdJMcacuHHpW%2FK7TsZn6qKCA3b9K7i1ZKlK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;396&quot; height=&quot;310&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;716&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;진짜 원인: AFTER_COMMIT 리스너 + 트랜잭션 없음&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 이 로직이 `@TransactionalEventListener` 를 사용하고 있었다는 점이다.&lt;br /&gt;이 애너테이션의 기본 `phase` 값은 &lt;b&gt;AFTER_COMMIT&lt;/b&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;기존 트랜잭션이 커밋된 이후에&lt;/b&gt; 리스너가 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 기본 트랜잭션은 끝났고, 영속성 컨텍스트도 닫힌 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 별도의 `@Transactional` 이 없다면, &lt;b&gt;트랜잭션 없이 save()를 호출하는 상황&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;niniz&quot; data-emoticon-name=&quot;034&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/034.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/niniz/large/034.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이름 자체가 AFTER_COMMIT인데 왜 트랜잭션이 있다고 생각했지??? .. 하...)&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 JPA/Hibernate는 &lt;b&gt;트랜잭션 밖에서 save()를 호출하면 어떻게 될까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;내부에서는 `entityManager.persist()` 까지는 호출된다.&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;하지만 자동 flush는 &amp;ldquo;활성화된 트랜잭션&amp;rdquo;이 있을 때만 일어난다.&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;commit 역시 트랜잭션이 있어야만 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1482&quot; data-start=&quot;1425&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1640&quot; data-start=&quot;1599&quot; data-ke-size=&quot;size16&quot;&gt;결국, &lt;b&gt;트랜잭션이 없으면 flush/commit이 발생하지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1640&quot; data-start=&quot;1599&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 경우는 이렇게 정리된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1743&quot; data-start=&quot;1663&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1692&quot; data-start=&quot;1663&quot;&gt;JPA는 엔티티를 영속성 컨텍스트에 올리기만 하고&lt;/li&gt;
&lt;li data-end=&quot;1723&quot; data-start=&quot;1693&quot;&gt;실제 DB로 INSERT를 날릴 기회를 갖지 못하고&lt;/li&gt;
&lt;li data-end=&quot;1743&quot; data-start=&quot;1724&quot;&gt;그 상태로 메서드가 끝나버린다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1818&quot; data-start=&quot;1745&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로 &lt;b&gt;INSERT SQL이 준비되긴 했지만, 트랜잭션이 없어 커밋되지 않았고 DB에는 아무 것도 들어가지 않은 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1640&quot; data-start=&quot;1599&quot; data-ke-style=&quot;style3&quot;&gt;❗️AFTER_COMMIT 리스너의 특징 정리&lt;br /&gt;&lt;br /&gt;`@TransactionalEventListener(phase = AFTER_COMMIT)` 의 특징을 정리하면 다음과 같다.&lt;br /&gt;&lt;br /&gt;- 이미 원래 트랜잭션이 끝난 시점에 실행된다. &lt;br /&gt;- 이때 별도의 @Transactional 을 붙이지 않으면 새로운 트랜잭션이 생성되지 않고 모든 변경 작업은 DB에 반영되지 않는다. &lt;br /&gt;- 영속성 컨텍스트도 이미 닫혀 있어서 단순 조회는 가능하지만 Lazy 로딩은 Detached 상태라 LazyInitializationException 이 발생한다. &lt;br /&gt;&lt;br /&gt;그래서 트랜잭션 없이 AFTER_COMMIT 리스너에서 할 수 있는 일은 사실상 조회 외에는 거의 없다.&lt;br /&gt;&lt;br /&gt;✔ 저장(save) &amp;rarr; 거의 100% DB 반영 안 됨 &lt;br /&gt;✔ 수정(update) &amp;rarr; Dirty checking이 안 돼서 반영 안 됨 &lt;br /&gt;✔ 삭제(delete) &amp;rarr; flush/commit 없어서 반영 안 됨 &lt;br /&gt;✔ 조회(find) &amp;rarr; 가능은 하지만 Lazy 관계는 터진다&lt;/blockquote&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2.&amp;nbsp; 해결 방안&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &amp;ldquo;왜 저장이 안 되었는지&amp;rdquo;는 알게 되었으니,&lt;br /&gt;남은 것은 &amp;ldquo;그렇다면 어떻게 고쳐야 하는가&amp;rdquo;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 리스너를 본 트랜잭션 안에서 돌리기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 후보는 리스너를 &lt;b&gt;기존 트랜잭션 안에서&lt;/b&gt; 함께 돌리는 방법이다.&lt;/p&gt;
&lt;pre id=&quot;code_1763192801199&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onArticleArrived(ArticleArrivedEvent event) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BEFORE_COMMIT으로 설정하면, 아티클 저장 트랜잭션이 커밋되기 전에 리스너가 실행되고, 알림 저장도 같은 트랜잭션 안에서 함께 커밋/롤백된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방법은 바로 제외했다. 이유는 명확하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;671&quot; data-start=&quot;563&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;614&quot; data-start=&quot;563&quot;&gt;알림 저장에서 예외가 발생하면 &amp;rarr; &lt;b&gt;원본 아티클 트랜잭션까지 같이 롤백&lt;/b&gt;된다.&lt;/li&gt;
&lt;li data-end=&quot;671&quot; data-start=&quot;615&quot;&gt;우리 도메인에서는 &amp;rarr; &amp;ldquo;알림은 실패해도 상관없지만, 아티클 저장은 반드시 성공해야 한다.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;798&quot; data-start=&quot;673&quot; data-ke-size=&quot;size16&quot;&gt;알림은 본 비즈니스의 &lt;b&gt;부가 기능&lt;/b&gt;일 뿐인데,&lt;br /&gt;부가 기능이 실패했다고 해서 핵심 흐름까지 같이 실패하게 만들고 싶지는 않았다.&lt;br /&gt;그래서 트랜잭션을 분리하기 위해 계속 &lt;b&gt;AFTER_COMMIT&lt;/b&gt;을 유지하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 리스너에서 새 트랜잭션을 열어주기&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 방법은 &lt;b&gt;리스너에서 아예 새 트랜잭션을 여는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 메서드에 `@Transactional(propagation = Propagation.REQUIRES_NEW)` 애너테이션만 붙이면 해결이 가능하다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 아티클 트랜잭션이 &lt;b&gt;정상적으로 커밋된 이후&lt;/b&gt;에 리스너가 &lt;b&gt;완전히 별도의 트랜잭션&lt;/b&gt;을 열고 알림을 저장한 뒤 &lt;b&gt;그 트랜잭션만 따로 커밋&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-end=&quot;1179&quot; data-start=&quot;1177&quot; data-ke-size=&quot;size16&quot;&gt;즉 아티클 저장 성공 여부와 알림 저장 성공 여부가 &lt;b&gt;완전히 독립&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1229&quot; data-start=&quot;1213&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1229&quot; data-start=&quot;1213&quot; data-ke-size=&quot;size16&quot;&gt;알림 로직에서 예외가 터져도,&lt;br /&gt;이미 끝난 아티클 트랜잭션에는 전혀 영향을 주지 않는다.&lt;/p&gt;
&lt;p data-end=&quot;1229&quot; data-start=&quot;1213&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1229&quot; data-start=&quot;1213&quot; data-ke-size=&quot;size16&quot;&gt;그래서 우리는 &lt;b&gt;2번 방식, 즉 &amp;ldquo;리스너에서 새 트랜잭션 열기&amp;rdquo;를 선택했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;❗️그런데 왜 옵션을 `Propagation.REQUIRES_NEW` 로 했을까?&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;사실 @Transactional 만 붙여도 되지 않나?&lt;br /&gt;기본 전파 옵션(REQUIRED)이면 트랜잭션이 없을 때 새로 만들어주는데?&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 지금 구조에서는 아래 코드만 있어도 &lt;b&gt;정상 동작&lt;/b&gt;한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1763194069769&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional // propagation = REQUIRED 기본값
public void onArticleArrived(ArticleArrivedEvent event) {
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AFTER_COMMIT 시점에는 이미 기존 트랜잭션이 끝난 상태라,&lt;br /&gt;진행 중인 트랜잭션이 없고, REQUIRED는 &lt;b&gt;새 트랜잭션을 생성&lt;/b&gt;한다.&lt;br /&gt;그래서 버그 자체만 놓고 보면, &lt;b&gt;기본값으로도 해결은 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1941&quot; data-start=&quot;1880&quot; data-ke-size=&quot;size16&quot;&gt;그런데도 불구하고 우리는 &lt;b&gt;REQUIRES_NEW를 명시적으로 선택&lt;/b&gt;했다.&lt;br /&gt;그 이유는 한마디로 말하면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이 로직은 &lt;b&gt;항상&lt;/b&gt; 독립된 트랜잭션에서 돌려야 한다&amp;rdquo;는&lt;br /&gt;&lt;b&gt;의도를 코드에 박아두고 싶었기 때문&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, REQUIRES_NEW는 단순히 &amp;ldquo;동작시켜 보니 되더라&amp;rdquo; 수준이 아니라,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2473&quot; data-start=&quot;2348&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2395&quot; data-start=&quot;2348&quot;&gt;&amp;ldquo;이 메서드는 &lt;b&gt;언제나 새 영속성 컨텍스트 + 새 트랜잭션&lt;/b&gt;에서 돌려야 한다&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;2438&quot; data-start=&quot;2396&quot;&gt;&amp;ldquo;알림 저장은 &lt;b&gt;하나의 작은 단위 작업으로 묶여서 커밋&lt;/b&gt;되어야 한다&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;2473&quot; data-start=&quot;2439&quot;&gt;&amp;ldquo;메인 비즈니스와는 &lt;b&gt;철저히 분리된 사이드 이펙트&lt;/b&gt;다&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2513&quot; data-start=&quot;2475&quot; data-ke-size=&quot;size16&quot;&gt;라는 설계 의도를 코드만 보고도 바로 이해할 수 있게 해주는 장치다.&lt;/p&gt;
&lt;p data-end=&quot;2599&quot; data-start=&quot;2515&quot; data-ke-size=&quot;size16&quot;&gt;이런 면에서 REQUIRES_NEW는 일종의 &lt;b&gt;자기 문서화(self-documenting) 도구&lt;/b&gt; 역할을 한다.&lt;br /&gt;나중에 팀원이 코드를 보더라도,&lt;/p&gt;
&lt;blockquote data-end=&quot;2632&quot; data-start=&quot;2601&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2632&quot; data-start=&quot;2603&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;아, 이건 꼭 독립 트랜잭션이어야 하는 작업이구나&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;라는 걸 주석 없이도 바로 읽어낼 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size26&quot;&gt;  한 걸음 더&lt;/h2&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;여기서 또 주제에 벗어나지만 또 고려하면 좋을게 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;AFTER_COMMIT을 하더라도 하나의 스레드에서 동작한다 그럼 과연 동기로계속 유지하는게 좋을까? 아니면 비동기로 유지하는게 좋을까?&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;이건 다음시간에...&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2658&quot; data-start=&quot;2634&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;  Reference&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionalEventListener.html?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionalEventListener.html?utm_source=chatgpt.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionPhase.html?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionPhase.html?utm_source=chatgpt.com&lt;/a&gt;&lt;/p&gt;</description>
      <category>서비스 운영 일지/봄봄</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/767</guid>
      <comments>https://html-jc.tistory.com/767#entry767comment</comments>
      <pubDate>Sat, 15 Nov 2025 17:38:42 +0900</pubDate>
    </item>
    <item>
      <title>트랜잭션이 없는데 왜 더티 체킹이 일어나지? (feat. OSIV)</title>
      <link>https://html-jc.tistory.com/764</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqdYNp/btsOWmnnmGV/CvfbKfAuXijIxVLiAjrxO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqdYNp/btsOWmnnmGV/CvfbKfAuXijIxVLiAjrxO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqdYNp/btsOWmnnmGV/CvfbKfAuXijIxVLiAjrxO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcqdYNp%2FbtsOWmnnmGV%2FCvfbKfAuXijIxVLiAjrxO0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;588&quot; height=&quot;392&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(이 글에서는 OSIV/OEMV 모두 OSIV로 지칭합니다)&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(이 글을 작성할 수 있게 소스를 준 노랑에게 감사합니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우연히 노랑이라는 크루를 통해 `@Transactional`을 붙이지 않았는데 더디체킹이 된다는 소리를 들었고&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Query를 보니 update를 하지 않았는데 update가 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 봤는데 신기했다. 동시에 무엇때문에 그러는지 궁금해지기 시작했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 코드는 아래와 같다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750698711092&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void cancelReservationById(final long id) {
    waitingRepository.findFirstByReservationIdOrderByCreatedAtAsc(id)
            .ifPresentOrElse(
                    (waiting) -&amp;gt; processWaitingToReservation(id, waiting),
                    () -&amp;gt; reservationRepository.deleteById(id)
            );
}

private void processWaitingToReservation(final long id, final Waiting waiting) {
    final Reservation reservation = reservationRepository.findById(id)
            .orElseThrow(() -&amp;gt; new NotFoundException(&quot;예약을 찾을 수 없습니다.&quot;));
    reservation.updateMember(waiting.getMember());
    waitingRepository.delete(waiting);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 메서드는&amp;nbsp; `@Transactional` 이 존재하지 않는다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 reservation이 업데이트가 되었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@Transactional`이 존재하지 않기 때문에 더티 체킹이 일어나지 않으니 당연히 reservation은 따로 save를 하지 않으면 업데이트가 일어나면 안 되지만 일어났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그럴까?&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 사전지식으로 JPA의 모든 CRUD메서드에는 `@Transactional`이 붙어있다는 것은 알고 있었다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1602&quot; data-origin-height=&quot;870&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KTpJf/btsORlgYQ2f/QWLRoQKlVunXLaYowWObD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KTpJf/btsORlgYQ2f/QWLRoQKlVunXLaYowWObD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KTpJf/btsORlgYQ2f/QWLRoQKlVunXLaYowWObD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKTpJf%2FbtsORlgYQ2f%2FQWLRoQKlVunXLaYowWObD1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;591&quot; height=&quot;321&quot; data-origin-width=&quot;1602&quot; data-origin-height=&quot;870&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(물론 CUD에는 @Transactional이 적용되어있다)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pCizQ/btsORxOXdYn/jWHTgk1zWW3bkQtNr8XyP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pCizQ/btsORxOXdYn/jWHTgk1zWW3bkQtNr8XyP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pCizQ/btsORxOXdYn/jWHTgk1zWW3bkQtNr8XyP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpCizQ%2FbtsORxOXdYn%2FjWHTgk1zWW3bkQtNr8XyP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;512&quot; height=&quot;345&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;628&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 알고 있었지만 해당 동작은 이해가 되지 않았다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇지만 노랑이 말하길 delete의 위치에 따라 update가 될 수도 되지 않을 수도 있다고 했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ update 쿼리 발생 O&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1750868925307&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private void processWaitingToReservation(final long id, final Waiting waiting) {
    final Reservation reservation = reservationRepository.findById(id)
            .orElseThrow(() -&amp;gt; new NotFoundException(&quot;예약을 찾을 수 없습니다.&quot;));
    reservation.updateMember(waiting.getMember());
    waitingRepository.delete(waiting);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ update 쿼리 발생 X&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750868962416&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private void processWaitingToReservation(final long id, final Waiting waiting) {
    final Reservation reservation = reservationRepository.findById(id)
            .orElseThrow(() -&amp;gt; new NotFoundException(&quot;예약을 찾을 수 없습니다.&quot;));
    waitingRepository.delete(waiting);
    reservation.updateMember(waiting.getMember());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이는 단지 delete의 위치였다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 위치에 따라 update가 발생한 이유는 바로 &lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;OSIV&lt;/span&gt; 때문이다.&lt;/b&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  더티 체킹 (Dirty Checking)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV에 대해 알아보기 전에 일단 더티 체킹에 대해 다시 알아보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더티 체킹은 &lt;b&gt;상태 변형 검사&lt;/b&gt;이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영해 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 '변화가 있다'의 기준은 최초 조회 상태이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA에서는 엔티티를 조회하면 해당 엔티티의 조회 상태 그대로 스냅샷으로 만들어 놓는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 트랜잭션이 끝나는 시점에 이 스냅샷과 비교해서 다른 점이 있다면 Update Query를 데이터베이스로 전달한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 이런 상태 변경 검사의 대상은 영속성 컨텍스트가 관리하는 엔티티에만 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;준영속, 비영속 상태의 엔티티는 더티 체킹 대상에 포함되지 않는다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 값을 변경해도 데이터베이스에 반영되지 않는다는 것이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 그럼 다시 코드를 보자&lt;/p&gt;
&lt;pre id=&quot;code_1750875167563&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private void processWaitingToReservation(final long id, final Waiting waiting) {
    final Reservation reservation = reservationRepository.findById(id)
            .orElseThrow(() -&amp;gt; new NotFoundException(&quot;예약을 찾을 수 없습니다.&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 조회를 했을 때는 JPA의 구현체인 Hibernate를 이용할 경우 트랜잭션 범위에서 Entity를 조회할 경우 아래와 같이 조회시점의 Entity 복사본을 만들어 준다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;479&quot; data-origin-height=&quot;386&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uNt2C/btsOR7oTfgC/IVclvGWkhfbJ7vVjuxPguk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uNt2C/btsOR7oTfgC/IVclvGWkhfbJ7vVjuxPguk/img.png&quot; data-alt=&quot;https://jojoldu.tistory.com/536&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uNt2C/btsOR7oTfgC/IVclvGWkhfbJ7vVjuxPguk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuNt2C%2FbtsOR7oTfgC%2FIVclvGWkhfbJ7vVjuxPguk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;412&quot; height=&quot;332&quot; data-origin-width=&quot;479&quot; data-origin-height=&quot;386&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://jojoldu.tistory.com/536&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 트랜잭션이 끝나는 시점에 여러 가지 서비스 로직으로 원본 Entity의 변경이 있다면 조회시점에 복사해 둔 복사본과 비교를 하여 다른 점이 있다면 Update 쿼리를 발생시킨다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 여기서는 로직을 실행하는 메서드에 트랜잭션이 붙어있지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐야 그럼 더티 체킹이 일어날 수 없는 조건에서 도대체 누가 업데이트 쿼리를 날리는 거야?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 알기 위해서는 EntityManager도 알아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영속성 컨텍스트는 EntityManager 단위로 존재하고 일반적으로 트랜잭션이 시작될 때 스프링이 EntityManger를 만들어서 트랜잭션 바인딩을 시킨다.&amp;nbsp; 하지만 트랜잭션이 먼저 열리지 않아도 EntityManger는 존재할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언제? &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;OSIV가 켜져 있을 때&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 이때 `deleteById()` 메서드를 사용하면 `deleteById()`가 트랜잭션을 열 때 이미 존재하던 영속성 컨텍스트에 reservation이라는 엔티티가 들어 있었다면, reservation.updateMember()로 수정된 상태였을 테고,&amp;nbsp; reservation도 같이 DB에 반영된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 만약 OSIV 설정이 꺼져 있다면 반영되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  OSIV&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링&amp;nbsp;부트에서&amp;nbsp;기본으로&amp;nbsp;켜져&amp;nbsp;있는&amp;nbsp;설정으로, HTTP&amp;nbsp;요청의&amp;nbsp;시작부터&amp;nbsp;끝(View&amp;nbsp;렌더링까지)&amp;nbsp;영속성&amp;nbsp;컨텍스트(EntityManager)를&amp;nbsp;열어두는&amp;nbsp;전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있으면 컨트롤러나 서비스에 `@Transactional`이 없어도 이미 요청 시점에 영속성 컨텍스트가 열려 있고, 엔티티가 조회되면 영속 상태로 관리 된다.&amp;nbsp; 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지연&amp;nbsp;로딩은&amp;nbsp;영속성&amp;nbsp;컨텍스트가&amp;nbsp;살아있어야&amp;nbsp;가능하고,&amp;nbsp;영속성&amp;nbsp;컨텍스트는&amp;nbsp;기본적으로&amp;nbsp;데이터베이스&amp;nbsp;커넥션을&amp;nbsp;유지한다.&lt;br /&gt;이것&amp;nbsp;자체가&amp;nbsp;큰&amp;nbsp;장점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 전략은 너무 오랜 시간 동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는&amp;nbsp;커넥션이&amp;nbsp;모자랄&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;이것은&amp;nbsp;결국&amp;nbsp;장애로&amp;nbsp;이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어서 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간만큼 커넥션 리소스를 반환하지 못하고, 유지해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 이미지는 OSIV가 켜고 꺼짐에 따른 영속성 컨텍스트 생존 범위를 나타낸 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;&lt;b&gt;OSIV ON&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbjhA2/btsOXQgYSVC/2eoM46dlbUMW7iXSaPWLoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbjhA2/btsOXQgYSVC/2eoM46dlbUMW7iXSaPWLoK/img.png&quot; data-alt=&quot;스프링 부트와 JPA 활용 2 - 김영한&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbjhA2/btsOXQgYSVC/2eoM46dlbUMW7iXSaPWLoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbjhA2%2FbtsOXQgYSVC%2F2eoM46dlbUMW7iXSaPWLoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;833&quot; height=&quot;337&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;494&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스프링 부트와 JPA 활용 2 - 김영한&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #009a87;&quot;&gt;&lt;b&gt;OSIV OFF&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r4lal/btsOXFNJV4d/kGbyWZfk4kxvTj8cfsb6a0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r4lal/btsOXFNJV4d/kGbyWZfk4kxvTj8cfsb6a0/img.png&quot; data-alt=&quot;스프링 부트와 JPA 활용 2 - 김영한&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r4lal/btsOXFNJV4d/kGbyWZfk4kxvTj8cfsb6a0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr4lal%2FbtsOXFNJV4d%2FkGbyWZfk4kxvTj8cfsb6a0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;799&quot; height=&quot;326&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스프링 부트와 JPA 활용 2 - 김영한&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 꺼져 있으면 HTTP 요청을 시작할 때 영속성 컨텍스트가 열리지 않는다.&amp;nbsp;&lt;br /&gt;`@Transactional`이 걸린 시점에서야 EntityManager가 열린다.&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br /&gt;상위 메서드에서 조회한 엔티티는 영속 상태가 아님 (Detached) 즉, 더티 체킹 대상이 아니다.&lt;br /&gt;따라서 하위에서 트랜잭션이 열려도 flush 시킬 변경사항이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리해 보면 아래와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &amp;nbsp;왜 트랜잭션 어노테이션이 붙어있지 않은 상위&amp;nbsp;메서드의&amp;nbsp;변경사항이&amp;nbsp;하위&amp;nbsp;메서드에 붙어있는 트랜잭션에&amp;nbsp;영향을&amp;nbsp;받을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링에서는 보통 HTTP 요청 전체 기간 동안 하나의 영속성 컨텍스트를 유지하는 OSIV 전략을 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OSIV&amp;nbsp;전략이 켜져 있으면&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청을 처리하는 동안 항상 영속성 컨텍스트가 유지된다.&amp;nbsp; 그래서 별도의 트랜잭션 선언이 없어도 엔티티가 계속 영속 상태로 유지된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 하위 메서드에서 트랜잭션이 열릴 때, 기존 영속성 컨텍스트의 모든 변경사항이 DB에 반영된다(flush)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OSIV&amp;nbsp;전략이 꺼져 있으면&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명시적으로 트랜잭션이 붙은 메서드에서만 영속성 컨텍스트가 열린다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 메서드에서 조회한 엔티티는 영속성 컨텍스트가 닫힌 상태라 더티 체킹이 일어나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  왜 스프링 부트는 OSIV 기본값을 true로 ?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 이런 궁금증이 생길 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 부트는 도대체 왜 OSIV를 기본값으로 설정해 놓았을까??&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 아래 spring-projects 이슈에서 알 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(읽는데 1시간 이상 걸리니 주의해야 한다. 아주 피 터지게 토론을 한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1751272656143&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;Log a warning on startup when spring.jpa.open-in-view is enabled but user has not explicitly opted in &amp;middot; Issue #7107 &amp;middot; spring-p&quot; data-og-description=&quot;Considering OSIV/OEMIV is widely considered an anti-pattern, OpenEntityManagerInViewInterceptor should IMO not be enabled by default. Rather than that it should be opt-in. If this proposal isn't ac...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/spring-projects/spring-boot/issues/7107&quot; data-og-url=&quot;https://github.com/spring-projects/spring-boot/issues/7107&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/gByWk/hyZfXaYt62/AMHG2fMWY0xSb3lwLgXr00/img.png?width=1200&amp;amp;height=600&amp;amp;face=971_150_1023_208,https://scrap.kakaocdn.net/dn/brjs1Y/hyZfVEfeVS/CLxI02edcuAqVgk6XBqjL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=971_150_1023_208&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/issues/7107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/spring-projects/spring-boot/issues/7107&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/gByWk/hyZfXaYt62/AMHG2fMWY0xSb3lwLgXr00/img.png?width=1200&amp;amp;height=600&amp;amp;face=971_150_1023_208,https://scrap.kakaocdn.net/dn/brjs1Y/hyZfVEfeVS/CLxI02edcuAqVgk6XBqjL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=971_150_1023_208');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Log a warning on startup when spring.jpa.open-in-view is enabled but user has not explicitly opted in &amp;middot; Issue #7107 &amp;middot; spring-p&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Considering OSIV/OEMIV is widely considered an anti-pattern, OpenEntityManagerInViewInterceptor should IMO not be enabled by default. Rather than that it should be opt-in. If this proposal isn't ac...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  OSIV 기본 활성화 찬성 측 주장&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신규 사용자가 LazyInitializationException을 자주 겪고, 이를 회피하기 위한 개발자 경험(DX)을 고려해야 한다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  OSIV 기본 활성화 반대 측 주장&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV는 지연 로딩을 무분별하게 하게 만들어 예기치 않은 DB 쿼리 폭주, 커넥션 풀 고가, 심각한 성능 저하를 유발할 수 있음&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reddit에서 OSIV off 버전이 처리량에서 2배 이상 월등하다는 비판적인 의견도 게시&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.reddit.com/r/java/comments/1hcgyc4/the_opensessioninview_pattern_of_spring_boot_a/?utm_source=chatgpt.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.reddit.com/r/java/comments/1hcgyc4/the_opensessioninview_pattern_of_spring_boot_a/?utm_source=chatgpt.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 의견&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dQkSJx/btsOWGeFI95/dmNj19jOTNKKwW2HGYuik1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dQkSJx/btsOWGeFI95/dmNj19jOTNKKwW2HGYuik1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dQkSJx/btsOWGeFI95/dmNj19jOTNKKwW2HGYuik1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdQkSJx%2FbtsOWGeFI95%2FdmNj19jOTNKKwW2HGYuik1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;783&quot; height=&quot;182&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1914&quot; data-origin-height=&quot;1100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4Tp2J/btsOYOQai6F/VKXbZXayLENqOaX8UOI581/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4Tp2J/btsOYOQai6F/VKXbZXayLENqOaX8UOI581/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4Tp2J/btsOYOQai6F/VKXbZXayLENqOaX8UOI581/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4Tp2J%2FbtsOYOQai6F%2FVKXbZXayLENqOaX8UOI581%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;693&quot; height=&quot;398&quot; data-origin-width=&quot;1914&quot; data-origin-height=&quot;1100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1932&quot; data-origin-height=&quot;822&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c7gTM8/btsOWHEH2a2/eFJPdcggl3NpoQxYaNpXWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c7gTM8/btsOWHEH2a2/eFJPdcggl3NpoQxYaNpXWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c7gTM8/btsOWHEH2a2/eFJPdcggl3NpoQxYaNpXWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc7gTM8%2FbtsOWHEH2a2%2FeFJPdcggl3NpoQxYaNpXWk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;717&quot; height=&quot;305&quot; data-origin-width=&quot;1932&quot; data-origin-height=&quot;822&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국은 스프링의 철학은 아래와 같다.&amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;ldquo;Spring은 복잡한 엔터프라이즈 개발을 쉽게 만들기 위해 시작되었다.&amp;rdquo;&lt;br /&gt;&amp;mdash; Rod Johnson (Spring 창시자), &amp;ldquo;Expert One-on-One J2EE Design and Development&amp;rdquo; 서문&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV 기본값을 true로 해놓는 것은 누구나 쉽게 러닝커브가 거의 없이 무언가를 스프링을 통해 쉽게 만들기 위해서인데 이를 false로 해놓으면 초보자 입장에서는 알아야 할게 너무나 많아진다는 것이다.(어차피 숙련자라면 상황에 따라 true/false를 자유롭게 선택할 수 있을 것이다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 이슈 글을 읽을 때 주의할 점은 OSIV를 써야 한다가 아니라 OSIV 기본값을 true로 해놓은 이유라는 걸 잊으면 안 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  현업에서 osiv를 관련 문제 발생 사례&amp;nbsp;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://medium.com/frientrip/spring-boot%EC%9D%98-open-in-view-%EA%B7%B8-%EC%9C%84%ED%97%98%EC%84%B1%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC-83483a03e5dc&quot;&gt;Spring Boot의 open-in-view, 그 위험성에 대하여 - 프렙&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.blog.kcd.co.kr/jpa-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%99%80-osiv-3c5521e6de9f&quot;&gt;JPA 영속성 컨텍스트와 OSIV - 한국신용데이터&lt;/a&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  Reference&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://docs.spring.io/spring-framework/docs/3.2.0.M1_to_3.2.0.M2/Spring%20Framework%203.2.0.M1/org/springframework/orm/hibernate3/support/OpenSessionInViewFilter.html?utm_source=chatgpt.com&quot;&gt;&amp;nbsp;OpenSessionInViewFilter&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 스프링 부트와 JPA 활용2&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://robertniestroj.hashnode.dev/two-reasons-why-you-might-want-to-disable-open-session-in-view-in-a-spring-application?utm_source=chatgpt.com&quot;&gt;Two reasons why you might want to disable Open Session in View in a Spring application&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://medium.com/frientrip/spring-boot%EC%9D%98-open-in-view-%EA%B7%B8-%EC%9C%84%ED%97%98%EC%84%B1%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC-83483a03e5dc&quot;&gt;Spring&amp;nbsp;Boot의&amp;nbsp;open-in-view,&amp;nbsp;그&amp;nbsp;위험성에&amp;nbsp;대하여.&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://www.blog.kcd.co.kr/jpa-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%99%80-osiv-3c5521e6de9f&quot;&gt;JPA&amp;nbsp;영속성&amp;nbsp;컨텍스트와&amp;nbsp;OSIV&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://xeounxzxu.medium.com/jpa-what-is-osiv-open-session-in-view-c0155008169b&quot;&gt;JPA | What is OSIV (Open Session In View)&lt;/a&gt;&lt;/p&gt;</description>
      <category>Spring</category>
      <category>JPA</category>
      <category>OSIV</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/764</guid>
      <comments>https://html-jc.tistory.com/764#entry764comment</comments>
      <pubDate>Tue, 1 Jul 2025 20:21:33 +0900</pubDate>
    </item>
    <item>
      <title>Controller에서 왜 반환값을 ResponseEntity로 안 해?</title>
      <link>https://html-jc.tistory.com/763</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MyEGv/btsOLgNHbbZ/InTPndoKTPdDSRFfMk4I80/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MyEGv/btsOLgNHbbZ/InTPndoKTPdDSRFfMk4I80/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MyEGv/btsOLgNHbbZ/InTPndoKTPdDSRFfMk4I80/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMyEGv%2FbtsOLgNHbbZ%2FInTPndoKTPdDSRFfMk4I80%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;753&quot; height=&quot;316&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 2 미션을 처음 했을 때, 습관처럼 컨트롤러에서 반환 값을 ResponseEntity로 감싸서 처리했다.&lt;/p&gt;
&lt;p data-end=&quot;1993&quot; data-start=&quot;1974&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 아래와 같은 코드였다.&lt;/p&gt;
&lt;pre id=&quot;code_1750613549054&quot; class=&quot;less&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@GetMapping(&quot;/users/{id}&quot;)
public ResponseEntity&amp;lt;UserDto&amp;gt; getUser(@PathVariable Long id) {
    return ResponseEntity.ok(userService.findUser(id));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;2241&quot; data-start=&quot;2157&quot; data-ke-size=&quot;size16&quot;&gt;사실 이전 프로젝트에서도 큰 고민 없이 이렇게 사용했고, &quot;왜?&quot;라는 질문조차 던지지 않았다.&lt;br /&gt;미션을 제출한 후에도 자연스레 의심 없이 지나쳤다.&lt;/p&gt;
&lt;p data-end=&quot;2241&quot; data-start=&quot;2157&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2282&quot; data-start=&quot;2243&quot; data-ke-size=&quot;size16&quot;&gt;그러다 우연히 다른 크루의 코드 리뷰를 보며 처음으로 궁금증이 생겼다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1588&quot; data-origin-height=&quot;218&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JHjlj/btsOL82eEBW/KD3NVxbbD4BSzqWuwAIgP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JHjlj/btsOL82eEBW/KD3NVxbbD4BSzqWuwAIgP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JHjlj/btsOL82eEBW/KD3NVxbbD4BSzqWuwAIgP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJHjlj%2FbtsOL82eEBW%2FKD3NVxbbD4BSzqWuwAIgP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;838&quot; height=&quot;115&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1588&quot; data-origin-height=&quot;218&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때부터 생각했다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ResponseEntity`가 무엇이고 왜 써야 하지?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안 쓰면 어떻게 될까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  ResponseEntity란 ?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 ResponseEntity가 무엇인지부터 간단히 정리하면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ResponseEntity&amp;lt;T&amp;gt;는 스프링의 HttpEntity&amp;lt;T&amp;gt;를 확장한 클래스로, HTTP 상태 코드와 헤더를 함께 담아 응답을 유연하게 제어할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 컨트롤러에서 객체를 반환하면 HTTP 본문(body)만 전달된다. 그러나 ResponseEntity를 쓰면 상태 코드와 헤더를 직접 제어할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 상태 코드 제어&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 반환은 200 OK만 반환되지만, ReponseEntity로는 200대, 300대, 400대, 500 대 등 다양한 코드를 직접 지정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 커스텀 헤더 추가&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 ETag, location, 인증 토큰 등을 응답 헤더에 넣고 싶을 때 유용하게 활용할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 예외 처리 시 일관된 응답 포맷&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@ControllerAdvice와 함께 ReponseEntityExceptionHandler를 상속해 오류 등답을 한 곳에서 관리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  ResponseEntity 없이 상태코드와 헤더 관리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇지만 다른 방법도 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 dto만 반환하는 거다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 상태코드와 헤더는? 할 것이다.&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태코드는 `&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ResponseStatus.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@ResponseStatus&lt;/a&gt;` 어노테이션을 활용하면 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 헤더는??&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아쉽게도 헤더는 어노테이션이 없다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 &lt;a href=&quot;https://github.com/spring-projects/spring-framework/issues/14179&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring-framework issues에서&lt;/a&gt; 찾을 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 요약하자면 아래와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 어노테이션은 컴파일 시점에 상수만 허용하기 때문에 헤더 값이 요청 비즈니스 로직, DB 결과 등에 따라지는 &quot;런타임 계산&quot;이 필요한 경우를 처리할 수 없다.&amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 쉽게 이야기하면&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@ResponseStatus`는 항상 고정된 HTTP 상태 코드(예: 404, 201 등)를 선언할 때 쓰이고, 이 경우엔 어노테이션 속성으로 충분히 표현이 가능 반면 Location, ETag, Set-Cookie 같은 헤더는 대개 &quot;저장된 객체의 ID&quot;나 &quot;해시 값&quot;처럼 동적으로 생성된 값을 담아야 한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;정적 헤더는 예를 들어 Content-Type: application/json처럼 변하지 않는 값이고,&lt;br /&gt;동적 헤더는 Location: /users/1234처럼 사용자의 요청 결과에 따라 변하는 값이다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 정적과 동적 모든 경우를 다 커버하는 `@ResponseHeader` 어노테이션을 추가하려면&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 값 바인딩 로직, 여러 타입 반환, 예외 처리 등 복잡도가 크게 늘어나고 오히려 프레임워크가 무거워진다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 헤더도 어노테이션으로 달 수야 있지만, 동적인 특성이 강하니 굳이 프레임워크 차원에서 `@RsponseHeader` 같은 어노테이션은 만들지 않은 것 같다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  ResponseEntity를 쓸 때와 쓰지 않을 때 어떻게 동작할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 요청을 받아 컨트롤러 메서드 실행하는 단계로 가면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ServletInvocableHandlerMethod.invodkeAndHandle(...)` 호출하게 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 다음 리턴값을 처리할 핸들러를 선택한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`HandlerMethodReturnValueHandlerComposite.handleReturnValue()`가 호출된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1852&quot; data-origin-height=&quot;1096&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wwyBX/btsONxnZVxb/YviqI9345CtYQHnPH99ESK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wwyBX/btsONxnZVxb/YviqI9345CtYQHnPH99ESK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wwyBX/btsONxnZVxb/YviqI9345CtYQHnPH99ESK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwwyBX%2FbtsONxnZVxb%2FYviqI9345CtYQHnPH99ESK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;717&quot; height=&quot;424&quot; data-origin-width=&quot;1852&quot; data-origin-height=&quot;1096&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`handleReturnValue`메서드는 위와 같이 생겼다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 `selectHandler`가 동작할 때 `this.returnValueHandlers`를 순서대로 돌면서 `supportsReturnType()`이 true인 핸들러를 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핸들러의 종류는 아래와 같다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;`HandlerMethodReturnValueHandler` 구현체들&lt;/blockquote&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;RequestResponseBodyMethodProcessor&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;@ResponseBody 또는 @RestController가 붙은 컨트롤러의 리턴값&lt;/td&gt;
&lt;td&gt;JSON/XML로 변환해서 응답 본문에 직접 씀 (@ResponseBody 기반)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;HttpEntityMethodProcessor&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;ResponseEntity, HttpEntity&lt;/td&gt;
&lt;td&gt;상태 코드, 헤더, 본문 등을 직접 지정해서 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ModelAndViewMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;ModelAndView 타입&lt;/td&gt;
&lt;td&gt;뷰 이름과 모델 정보를 함께 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ViewNameMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;String 뷰 이름&lt;/td&gt;
&lt;td&gt;String을 반환하면 뷰 이름으로 해석&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ModelMethodProcessor&lt;/td&gt;
&lt;td&gt;Model, Map&amp;lt;String, Object&amp;gt; 등&lt;/td&gt;
&lt;td&gt;모델 객체를 직접 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ViewMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;View 타입&lt;/td&gt;
&lt;td&gt;뷰 객체를 직접 반환 (ThymeleafView, InternalResourceView 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RedirectViewMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;String + &quot;redirect:&quot; prefix&lt;/td&gt;
&lt;td&gt;redirect:로 시작하면 리다이렉트 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CallableMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;Callable&amp;lt;T&amp;gt;&lt;/td&gt;
&lt;td&gt;비동기 처리용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeferredResultMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;DeferredResult&amp;lt;T&amp;gt;&lt;/td&gt;
&lt;td&gt;비동기 처리용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ResponseBodyEmitterReturnValueHandler&lt;/td&gt;
&lt;td&gt;ResponseBodyEmitter, SseEmitter&lt;/td&gt;
&lt;td&gt;서버-푸시 또는 SSE 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StreamingResponseBodyReturnValueHandler&lt;/td&gt;
&lt;td&gt;StreamingResponseBody&lt;/td&gt;
&lt;td&gt;스트리밍 응답 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AsyncHandlerMethodReturnValueHandler&lt;/td&gt;
&lt;td&gt;위 비동기 관련 핸들러들의 공통 super type 역할&lt;/td&gt;
&lt;td&gt;내부에서 조건 분기할 때 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 우리가 주목해야 할 것은&amp;nbsp;`HttpEneityMethodProcessor` 와 `RequestResponseBodyMethodProcessor`이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 대부분&amp;nbsp; `ResponseEntity`를 반환하거나&amp;nbsp; `@ResponseBody`가 붙은 메서드를 사용하니까 말이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;HttpEntityMethodProcessor&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpEntityMethodProcessor는 스프링 MVC에서 컨트롤러 메서드의 반환값이 HttpEntity나 ResponseEntity일 때 그 결과를 직접 HTTP 응답으로 만들어주는 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더&amp;middot;상태코드&amp;middot;본문을 ResponseEntity 객체로부터 꺼내서 HttpServletResponse에 세팅한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;⁉️ HttpEntity vs ResponseEntity&lt;br /&gt;&lt;br /&gt;`HttpEntity`는 본문 + 헤더&lt;br /&gt;`ResponseEntity`는 본문 + 헤더 + 상태코드&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;RequestResponseBodyMethodProcessor&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환 타입이 그 외(예: DTO 객체, String, void 등)이고 메서드나 클래스에 `@ResponseBody` 또는 `@RestController` 가 붙어 있을 때 동작한다.&lt;br /&gt;이 핸들러는 상태 코드를 항상 200 OK로, 헤더는 기본값으로 두고&lt;br /&gt;반환 객체를 `HttpMessageConverter` (예: MappingJackson2 HttpMessageConverter)로 직렬화해서 응답 바디에 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;❓ResponseEntity를 사용하지 않는 이유?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히 말하면 필요한 곳이 아니면 사용하지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://github.com/woowacourse/spring-roomescape-member/pull/215#discussion_r2071418205&quot;&gt;실제로 받은 코드 리뷰&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;지금은 코드 리뷰에서 작성된 것에서 조금은 수정되었다.&lt;/blockquote&gt;
&lt;blockquote style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot; data-ke-style=&quot;style3&quot;&gt;  RestController에서 반환값 전체를 ResponseEntity로 했다가 제거한 이유&lt;br /&gt;&lt;br /&gt;처음에는 RestController에서 모든 메서드가 ResponseEntity를 반환하도록 했습니다.&lt;br /&gt;상태 코드 제어 및 헤더 조작이 가능하기에 해당기능이 필요할 때만 사용하기보다는 일관성 있게 모든 RestController 메서드에서 동일한 값을 반환하도록 해서 일관성을 확보하고자 했습니다.&lt;br /&gt;&lt;br /&gt;하지만 지금 다시 생각해 보니 잘못 생각한 것 같았습니다.&lt;br /&gt;일단 반환할 때마다 &lt;b&gt;불필요한 객체생성&lt;/b&gt;이 됩니다. &lt;br /&gt;물론 해당 기능으로 조작이 필요할 때는 유용하지만 다른 어노테이션으로 일부는 커버가 가능합니다.&lt;br /&gt;단순 상태 코드 지정을 위해서는 @ResponseStatus어노테이션을 사용했습니다.&lt;br /&gt;즉,&amp;nbsp; &lt;b&gt;헤더를 조작하거나 동적으로 상태코드를 조작할 필요가 없다면 ResponseEntity를 쓸 이유를 전혀 찾지 못했습니다.&amp;nbsp;&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;또한 ResponseEntity 객체로 반환 값을 감싼다는 것은 해당 컨트롤러에서 여러 가지 케이스에 대해서 대체하여 그에 따른 HTTP Status Code를 포함하여 Response Body를 내려주겠다는 의미입니다.&lt;br /&gt;그렇다는 건 개별 컨트롤러 단에서 예외에 대한 핸들링을 진행할 수도 있다는 의미입니다.&lt;br /&gt;이러한 코드는 바람직하지 않다고 생각하여 GlobalExceptionHandler를 만들고 거기서 ResponseEntity를 사용하도록 하였습니다.&amp;nbsp;&lt;br /&gt;이렇게 만들어주면 API 서버에서 발생하는 모든 예외를 해당 객체에서 처리하여 통일성 있는 예외 Response를 내려주고 각 개별적인 컨트롤러에 예외에 대한 핸들링에 대한 책임을 부여하지 않아 &lt;b&gt;컨트롤러 계층을 작게 가져가기 위함&lt;/b&gt;입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  ResponseEntity 사용하기 for 캐싱&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이 부분은 그냥 건너뛰어도 된다)&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 &lt;b&gt;ETag 기반 캐시 처리를 제대로 사용하려면 `ResponseEntity`를 반환해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;왜냐하면 `HttpEntityMethodProcessor`만이 ETag와 관련된 조건부 요청(If-None-Match 등)을 해석하고 처리하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`handleReturnValue` 메서드를 살펴보다 우연히 발견한 것이 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드를 보면 200 OK 만 따로 선별해서 처리하고 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 보는 순간 &quot;왜 200 OK만 따로 처리하지?&quot;라는 궁금증이 생겼고&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 캐시와 연관되어 있다는 것을 알게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;610&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tLFl7/btsOPmThp7W/0L3QsiItZ3Yup7hAku8Y0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tLFl7/btsOPmThp7W/0L3QsiItZ3Yup7hAku8Y0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tLFl7/btsOPmThp7W/0L3QsiItZ3Yup7hAku8Y0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtLFl7%2FbtsOPmThp7W%2F0L3QsiItZ3Yup7hAku8Y0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;245&quot; data-origin-width=&quot;1494&quot; data-origin-height=&quot;610&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  &lt;b&gt;ETag란?&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;ETag는 서버가 리소스(파일, JSON, HTML 등)의 고유한 식별자를 만들어 클라이언트에게 응답 헤더로 보내주는 값이다.&lt;br /&gt;이 값은 주로 리소스의 내용 기반 해시 또는 버전 번호 등으로 만들어지며, 클라이언트는 이 값을 캐시에 저장하고 다음 요청 시 서버에 보내 비교하게 된다.&lt;br /&gt;&lt;br /&gt;자주 조회되지만 자주 바뀌지 않는 데이터에 사용할 때 가장 효과적이다.&amp;nbsp;&lt;br /&gt;(ex 사용자 프로필, 게시글, 상품 상세 등)&amp;nbsp;&lt;br /&gt;&lt;br /&gt;ETag는 서버가 저장해서 기억하고 있는 게 아니라 리소스의 현재 상태를 기반으로 매번 계산해서 비교하기 때문에 서버가 여러대라도 대응이 가능하다.&lt;br /&gt;즉, 매번 동일한 리소스 상태라면 매번 동일한 ETag가 생성된다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ETag를 활용한 예시&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 서버 응답 시&lt;/p&gt;
&lt;pre id=&quot;code_1750686149662&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
ETag: &quot;abc123&quot;
Content-Type: application/json

{ &quot;name&quot;: &quot;cheolwon&quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 &quot;abc123&quot;이라는 ETag를 캐시에 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  클라이언트가 재요청 시&lt;/p&gt;
&lt;pre id=&quot;code_1750686186359&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET /user HTTP/1.1
If-None-Match: &quot;abc123&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 이 ETag와 현재 리소스의 ETag를 비교한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 같다면&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750686223159&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 304 Not Modified&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 응답하고 클라이언트에서 캐시 된 데이터를 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 다르다면&lt;/p&gt;
&lt;pre id=&quot;code_1750686257578&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
ETag: &quot;new456&quot;
Content-Type: application/json

{ &quot;name&quot;: &quot;cheolwon updated&quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 ETag와 함께 새로운 데이터를 반환한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당글은 ResponseEntity에 관한 글이기 때문에 ETag설명은 여기까지 하고 더 궁금하다면 아래 글을 참고해 보면 좋을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://yozm.wishket.com/magazine/detail/1772/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span style=&quot;background-color: #fffbff; color: #1c1c14; text-align: left;&quot;&gt;Etag를 이용하여 더 나은 Restful API 만들기&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  Reference&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webflux/controller/ann-methods/responsebody.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ResponseBody&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/responseentity.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ResponseEntity&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://www.baeldung.com/spring-response-entity&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Using&amp;nbsp;Spring&amp;nbsp;ResponseEntity&amp;nbsp;to&amp;nbsp;Manipulate&amp;nbsp;the&amp;nbsp;HTTP&amp;nbsp;Response&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://www.youtube.com/watch?v=fCgU_VFv33M&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;제제의 ResponseBody vs ResponseEntity&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://yeonyeon.tistory.com/257&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[Spring]&amp;nbsp;ResponseEntity&amp;nbsp;vs&amp;nbsp;DTO&lt;/a&gt;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/763</guid>
      <comments>https://html-jc.tistory.com/763#entry763comment</comments>
      <pubDate>Fri, 20 Jun 2025 21:56:57 +0900</pubDate>
    </item>
    <item>
      <title>@RequestBody  이게 뭐야?</title>
      <link>https://html-jc.tistory.com/762</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;159&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1FdPg/btsNrfPt87e/qRNc74aVKkBDY3YYbAJIE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1FdPg/btsNrfPt87e/qRNc74aVKkBDY3YYbAJIE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1FdPg/btsNrfPt87e/qRNc74aVKkBDY3YYbAJIE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1FdPg%2FbtsNrfPt87e%2FqRNc74aVKkBDY3YYbAJIE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;346&quot; height=&quot;173&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;159&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;웹 개발을 공부하다 보면 클라이언트와 서버라는 말을 자주 듣게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 말해 클라이언트는 웹브라우저처럼 서버에 정보를 요청하는 쪽이고, 서버는 이 요청을 받아서 처리한 후 응답을 보내주는 쪽이이다.&lt;br /&gt;클라이언트가 서버에게 보내는 메시지를 요청(Request)이라고 하고, 반대로 서버가 클라이언트에게 보내는 메시지를 응답(Response)이라고 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;우리가 사용하는 많은 웹사이트는 페이지를 새로 고치지 않고도 데이터를 주고받으며 자연스럽게 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 방식을 바로`비동기 통신`라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;비동기 통신을 하려면 클라이언트는 서버에게 요청을 보낼 때 데이터를 'Body'라는 부분에 담아서 보내야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버도 클라이언트에게 응답할 때 마찬가지로 'Body'에 데이터를 담아서 보내준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이렇게 Body에 담아서 보내는 데이터를 각각 요청 Body, 응답 Body이라고 한다.&lt;br /&gt;'Body'에 담길 수 있는 데이터 형식은 다양하지만, 가장 널리 사용되는 것은 JSON이라는 형식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 비동기 방식에서는 클라이언트와 서버가 주로 JSON 형식의 데이터를 주고받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;스프링을 사용할 때도 이 방식이 자주 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 JSON이나 XML 등의 데이터를 보내면, 스프링이 자동으로 이 데이터를 자바 객체로 변환해서 처리할 수 있도록 도와준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 역할을 하는 것이 바로 `@RequestBody`와 `@ResponseBody`라는 어노테이션이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`@RequestBody`는 클라이언트에서 보낸 HTTP 요청 Body(JSON 등)을 자바 객체로 바꿔준다.&lt;/li&gt;
&lt;li&gt;`@ResponseBody`는 자바 객체를 다시 HTTP 응답 Body(JSON 등)으로 바꿔서 클라이언트에 보내준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 스프링 MVC는 비동기 통신을 더욱 쉽게 할 수 있도록 많은 편의 기능을 제공한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 이제 대략적인 흐름을 알았으니 본격적으로 `@RequestBody`에 대해서 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  @RequestBody&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청으로 넘어오는 body의 내용을&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt; HttpMessageConverter&lt;/a&gt;를 통해 자바 객체로 바꿔준다. (역직렬화)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청의 body 내용을 통째로 자바 객체로 변환해서 매핑된 메소드 파라미터로 전달해준다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 도대체 언제 바꿔주는 걸까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그전에 `@RequsetBody`가&amp;nbsp; Spring Web MVC 모듈에서 HTTP 요청이 오면 대략적으로 언제 동작하는지 알기 위해 Dispatcher Servlet의 흐름을 대강 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;544&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D1nbx/btsNmkD3Ybl/xqUaumapgJf2YwD0WFrCN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D1nbx/btsNmkD3Ybl/xqUaumapgJf2YwD0WFrCN0/img.png&quot; data-alt=&quot;source : 망나니개발자&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D1nbx/btsNmkD3Ybl/xqUaumapgJf2YwD0WFrCN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD1nbx%2FbtsNmkD3Ybl%2FxqUaumapgJf2YwD0WFrCN0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;841&quot; height=&quot;394&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;544&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;source : 망나니개발자&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 클라이언트의 요청을 디스패처 서블릿이 받음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 요청 정보를 통해 요청을 위임할 컨트롤러를 찾음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 요청을 컨트롤러로 위임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;4. 핸들러&amp;nbsp;어댑터가&amp;nbsp;컨트롤러로&amp;nbsp;요청을&amp;nbsp;위임함&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 비지니스 로직을 처리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 컨트롤러가 반환값을 반환함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 핸들러 어댑터가 반환값을 처리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8. 서버의 응답을 클라 이언트로 반환함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;갑자기 뭐지? 이걸 다 이해하라고?&quot;&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;절대 아니다 그러니 걱정할 필요는 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당장 Dispatcher Servlet을 전부 이해하라는 소리가 아니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 그냥 &quot;클라이언트의 요청을 Dispatcher Servlet이 요청을 처리할 컨트롤러를 찾아서 위임하고, 그 결과를 받아서 클라이언트로 반환한다&quot;는 정도만 이해하면된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(더 자세히 이해하고 싶다면 &lt;b&gt;&lt;a href=&quot;https://mangkyu.tistory.com/18&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이곳&lt;/a&gt;&lt;/b&gt;으로 가면 된다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 여기서 4번째 과정인 &quot;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;핸들러 어댑터가 컨트롤러로 요청을 위임함&lt;/span&gt;&lt;/b&gt;&quot;에만 집중하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1918&quot; data-origin-height=&quot;676&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlENDE/btsNrcx51yu/oX67xHezRAVonFOQXvVFA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlENDE/btsNrcx51yu/oX67xHezRAVonFOQXvVFA0/img.png&quot; data-alt=&quot;source : 망나니 개발자&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlENDE/btsNrcx51yu/oX67xHezRAVonFOQXvVFA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlENDE%2FbtsNrcx51yu%2FoX67xHezRAVonFOQXvVFA0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;795&quot; height=&quot;280&quot; data-origin-width=&quot;1918&quot; data-origin-height=&quot;676&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;source : 망나니 개발자&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 여기서 `@RequestBody`가 드디어 일을 시작한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말해 클라이언트에서 보낸 요청에 있는 body를 자바 객체로 바꿔주는 일을 바로 여기서한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@RequsetBody`는 HTTP 요청으로 같이 넘어오는 Header의 Content-type을 보고 어떤 `Converter`를 사용할지 정하기에 Content-type을 반드시 명시해야 한다.&amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Content-type은 따로 default 값이 없다. 그래서 프론트엔드와 협업할 때 종종 요청에 Content-Type을 명시하지 않아 에러가 발생하기도 한다)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  Content-Type이란?&lt;/b&gt;&lt;br /&gt;HTTP 요청이나 응답이 주고받는 데이터가 어떤 형식인지 명시하는 헤더이다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2624&quot; data-origin-height=&quot;1416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbyPAu/btsNnmBIael/zBmb2d9PWNVFFwBY9MvDe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbyPAu/btsNnmBIael/zBmb2d9PWNVFFwBY9MvDe0/img.png&quot; data-alt=&quot;naver 메인화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbyPAu/btsNnmBIael/zBmb2d9PWNVFFwBY9MvDe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbyPAu%2FbtsNnmBIael%2FzBmb2d9PWNVFFwBY9MvDe0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;664&quot; height=&quot;358&quot; data-origin-width=&quot;2624&quot; data-origin-height=&quot;1416&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;naver 메인화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;br /&gt;&lt;b&gt;  자주 사용하는 Content-type 종류&lt;/b&gt;&lt;br /&gt;- application/json : JSON 데이터 전송&lt;br /&gt;- application/x-www-form-urlencoded : 폼 데이터 전송 (HTML 폼 형식)&lt;br /&gt;- application/xml : XML 데이터 전송&lt;br /&gt;- text/plain : 단순 문자열 데이터를 보낼 때 사용&lt;br /&gt;- multipart/form-data : 파일 전송 (spring에서는 주로 @RequestPart와 함꼐 사용)&lt;br /&gt;- text/html : HTML 전송&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자 이제 Content-type을 대략적으로 알았으니 이제 좀 전에 말한 &quot;&lt;b&gt;Converter&lt;/b&gt;&quot;에 대해서 알아보자.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;일단 자세히 알아보기 전에 요약하자면 스프링에서는 위에서 말한 작업을 &lt;b&gt;HttpMessageConverter&lt;/b&gt;라는 친구가 자동으로 처리해준다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;기본적으로는 &lt;b&gt;Jackson&lt;/b&gt;이라는 라이브러리를 사용해서 JSON -&amp;gt; 객체(역직렬화) / 객체 -&amp;gt; JSON(직렬화)로 변환을 해준다.&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;그럼 여기서 말한 &lt;b&gt;HttpMeesageConverter&lt;/b&gt;는 무엇일까?&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  HttpMessageConverter&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;Spring 웹 모듈에는 &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HttpMessageConverter&lt;/a&gt;라는 인터페이스가 있다. (조금 뒤에서 보여드릴게요 )&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;이 인터페이스는 HTTP 요청의 바디와 응답의 body를 읽고 쓰는데 사용된다.&amp;nbsp; &amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;Spring은 다양한 미디어 타입에 맞는 여러 구현체를 기본으로 제공하며, 필요에 따라 개발자가 직접 구현하거나 커스터마이징할 수도 있다.&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이러한 구현체들은 기본적으로 `RestTemplate`이나 `RequestMappingHandlerAdapter`에 등록되어 있어,&amp;nbsp; 특별한 설정 없이도 편리하게 사용할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;  주요 HttpMessageConverter 구현체들&amp;nbsp;&lt;/h4&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;StringHttpMessageConverter&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;HTTP 요청이나 응답의 바디를 String으로 읽거나 쓸 수 있도록 도와준다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;기본적으로 모든 텍스트를 지원하면 응답 시&amp;nbsp; Content-Type은 text/plain으로 설정된다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;FormHttpMessageConverter&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;HTML 폼 데이터를 변환한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;기본적으로 application/x-ww-form-urlencoded 타입을 읽고 씁니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;ByteArrayHttpMessageConverter&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;HTTP 메시지를 바이너리 배열(byte[])로 변환한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;기본 application/octet-stream이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;MarshallingHttpMessageConverter&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;XML 데이터 처리를 위한 변환키로, Spring의 Marshaller와 Unmarshaller를 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;기본적으로 text/xml과 application/xml 미디어 타입을 지원한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;MappingJackson2HttpMessageConverter&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;Json 데이터를 변환하는 대표적인 구현체로, Jackson의 ObjectMapper를 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;기본적으로 application/json 미디어 타입을 지원한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;Jackson 애노테이션을 이용해 JSON 매핑을 커스터마이징할 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;846&quot; data-end=&quot;956&quot;&gt;커스텀 ObjectMapper를 주입하여 더욱 세밀한 제어가 가능하다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;Spring에서는 위의 &lt;b&gt;HttpMessageConverter&lt;/b&gt; 구현체들이 기본적으로 등록되어 있어, RestController를 사용할 때 별도의 설정 없이도 HTTP 요청과 응답의 데이터를 자동으로 변환해준다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 클라이언트가 JSON 데이터를 보내면, `MappingJackson2HttpMessageConverter`가 이를 자바 객체로 변환해주고, 서버가 자바 객체를 JSON으로 응답할 때 역시 같은 변환기를 사용한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;개발자는 필요한 경우, 특정 미디어 타입을 지원하도록 변환기를 커스터마이징하거나 새롭게 등록할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;이러한 유연성 덕분에 다양한 데이터 포맷과 복잡한 요구 사항에 쉽게 대응할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;그럼 &lt;b&gt;HttpMessageConverter&lt;/b&gt;는 이 많은 구현체들 중 어떻게 알맞은 컨버터 구현체를 골라서 역직렬화(JSON -&amp;gt; 객체)를 할 수 있을까?&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;956&quot; data-start=&quot;846&quot; data-ke-size=&quot;size16&quot;&gt;이전에 소개만 &lt;b&gt;HttpMessageConverter&lt;/b&gt;의&amp;nbsp;모든 구현체들은 아래 `interface`에 있는 메서드를 당연히 갖는다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;756&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9nwAv/btsNmuGrJuf/RCtLjdJEMV9MLAYhCJ38Fk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9nwAv/btsNmuGrJuf/RCtLjdJEMV9MLAYhCJ38Fk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9nwAv/btsNmuGrJuf/RCtLjdJEMV9MLAYhCJ38Fk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9nwAv%2FbtsNmuGrJuf%2FRCtLjdJEMV9MLAYhCJ38Fk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2020&quot; height=&quot;756&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;756&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`canRead`는 요청 body를 특정 타입으로 읽어들일 수 있는지 판단할 때 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(클라이언트가 @RequestBody로 객체를 받을 때 - 역직렬화)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`canWrite`는 객체를 응답 body로 쓸 수 있는지 판단할 때 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(컨트롤러가 반환한 객체를 @ResponseBody or ResponseEntity로 JSON/XML등으로 내보낼 때 - 직렬화)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 이제 위 내용을 바탕으로 대략적으로 역직렬화가 어떻게 동작하는지 알아보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 아래와 같은 요청을 보냈다고 가정해보자&lt;/p&gt;
&lt;pre id=&quot;code_1744879065223&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;POST /reservations HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json

{
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;date&quot;: &quot;2025-04-20&quot;,
  &quot;time&quot;: &quot;15:30:00&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1744879129317&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/reservations&quot;)
public class ReservationController {

    @PostMapping
    public ResponseEntity&amp;lt;Reservation&amp;gt; create(@RequestBody ReservationRequest request) {
      // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@RequestBody` 어노테이션이 있기에&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. DispatcherServlet이 요청을 받는다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. HandlerMapping -&amp;gt; `/reservations` url에 매핑된 `creat(...)` 메서드를 찾음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. HandlerAdapter 메서드를 실행하기 전 인자 해석 단계로 진입&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. RequestResponseBodyMethodProcessor&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@RequestBody`붙은 파라미터에 대해 등록된 &lt;b&gt;HttpMessageConverter&lt;/b&gt;들 중 `canRead(ReservationRequest.class, MediaType.APPLICATION_JSON)`이 `true`인 컨버터 선택&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1748&quot; data-origin-height=&quot;922&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d1DSX6/btsNpoTY2na/r8YOnlHyduVcByZSjedEQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d1DSX6/btsNpoTY2na/r8YOnlHyduVcByZSjedEQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d1DSX6/btsNpoTY2na/r8YOnlHyduVcByZSjedEQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd1DSX6%2FbtsNpoTY2na%2Fr8YOnlHyduVcByZSjedEQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;726&quot; height=&quot;383&quot; data-origin-width=&quot;1748&quot; data-origin-height=&quot;922&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 컨버터를 선택하는 코드는 일부이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`this.messageConverters`를 순회하면서 `canRead(...)`메서드를 통해 알맞은 컨버터 타입을 찾고있음을 알 수 있다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;컨버터의 순서는 &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식문서&lt;/a&gt;에서 MessageConverter Implementations 표에 있는 순서와 동일하다)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대개 객체라면 `MappingJackson2HttpMessageConverter`가 선택되어, 내부 `ObjectMapper`로 역직렬화(JSON -&amp;gt; ReservationRequest 객체 생성)를 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;a href=&quot;https://www.baeldung.com/jackson-object-mapper-tutorial&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ObjectMapper&lt;/a&gt;는 &lt;a href=&quot;https://github.com/FasterXML/jackson&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Jackson 라이브러리&lt;/a&gt;의 클래스이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;`spring-boot-starter-web`의존성만 추가해도 포함되는 라이브러리다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`objectMapper.readValue`를 호출해&amp;nbsp; JSON을 `ReservationRequest` 같은 자바타입으로 변환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 변환할 때 해당 객체는 반드시 기본 생성자(매개변수가 없는 &quot;비어있는&quot; 생성자)를 가지고 있어야한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 자바가 아무 생성자도 가지고 있지 않다면 컴파일러가 자동으로 기본 생성자를 만들어주기 때문에 신경쓸 필요가 없지만&amp;nbsp; 매개변수가 있는 생성자를 만들면&amp;nbsp; 명시적으로 기본 생성자를 만들어줘야한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그렇다면 왜 기본생성자를 요구할까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson 라이브러리는 객체를 역직렬화(deserialization)할 때 내부적으로 객체를 먼저 생성하고 그 다음에 리플렉션으로 필드를 채워 넣기 때문이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  Jackson의 역직렬화 과정&lt;br /&gt;&lt;br /&gt;1. 객체 생성&lt;br /&gt;Jackson은 대상 클래스의 기본 생성자를 호출하여 객체를 생선한다.&lt;br /&gt;기본 생성자가 없을 경우, @JsonCreator와 @JsonProperty를 사용하여 매개변수 있는 생성자를 통해 객체를 생성할 수 있다.&lt;br /&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1744893027845&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Person {
    private final String name;
    private final int age;

    @JsonCreator
    public Person(@JsonProperty(&quot;name&quot;) String name,
                  @JsonProperty(&quot;age&quot;) int age) {
        this.name = name;
        this.age = age;
    }
    // Getter methods...
}​&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;&lt;br /&gt;2. 필드 값 주입&lt;br /&gt;생성된 객체에 JSON의 각 필드 값을 주입한다. 이때, Jackson은 다음과 같은 방법을 사용한다.&lt;br /&gt;1. Setter 메서드 : 해당 필드에 대한 setter 메서드가 존재하면 이를 호출하여 값을 설정한다.&lt;br /&gt;2. 직접 필드 접근 : setter 메서드가 없을 경우, 리플렉션을 통해 필드에 직접 접근하여 값을 설정한다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  Jackson의 직렬화 과정&lt;br /&gt;&lt;br /&gt;직렬화 할 때는 이미 만들어진 객체에서 Jackson 내부적으로 getter 메서드를 호출해서 값을 읽고 JSON 문자열로 변환한다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;따라서 역질렬화 과정과 다르게 기본 생성자는 필요없고 getter만 있으면 된다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;만약 getter이 없다면 Jackson은 아무 필드도 읽지 못해서 빈 JSON이 나올 수 있다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;하지만 `@JsonProperty`나 `@JsonAutoDetect`등의 어노테이션을 쓰면 리플렉션으로 필드 값을 읽을 수 있도록 강제할 수 있다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1744892467442&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Person {
    @JsonProperty
    private String name;

    @JsonProperty
    private int age;
}​&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;➡ 이런 경우엔 getter 없어도 직렬화 가능!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 과정을 거치면 요청 Body에 있는 JSON이 객체로 변환되고 그것을 통해 내부 비즈니스 로직이 동작한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기본 생성자가 없는 경우도 있는데 이는 왜 역직렬화가 되는거죠?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson은 객체에 생성자가 하나만 있으면 그 생성자를 묵시적(@JsonCreator 없이)으로 &amp;lsquo;Creator&amp;rsquo;로 간주해서 디폴트 생성자 없이도 역직렬화해 준다.&lt;br /&gt;&lt;br /&gt;다만 이게 잘 동작하려면 다음 조건이 충족되어야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;582&quot; data-start=&quot;157&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;241&quot; data-start=&quot;157&quot;&gt;&lt;b&gt;생성자가 오직 하나&lt;/b&gt;여야 한다
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;241&quot; data-start=&quot;185&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;241&quot; data-start=&quot;185&quot;&gt;다른 파라미터 생성자가 하나라도 더 있으면 Jackson은 어느 쪽을 쓸지 몰라서 에러를 낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;403&quot; data-start=&quot;243&quot;&gt;&lt;b&gt;파라미터 이름이 JSON 필드 이름과 일치&lt;/b&gt;해야 한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;작성 예정&amp;gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #24292f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Controller에서 받을때 어노테이션 생략시&amp;nbsp;&lt;b&gt;@ModelAttribute가 &lt;/b&gt;기본값이므로&amp;nbsp;&lt;span style=&quot;color: #000000;&quot;&gt;@RequestBody를 사용하고자 하는 경우에는 반드시 기술해야 한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  Reference&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/jackson-deserialization&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/jackson-deserialization&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/jackson-object-mapper-tutorial&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/jackson-object-mapper-tutorial&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/FasterXML/jackson&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/FasterXML/jackson&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/18&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[Spring]&amp;nbsp;Dispatcher-Servlet(디스패처&amp;nbsp;서블릿)이란?&amp;nbsp;디스패처&amp;nbsp;서블릿의&amp;nbsp;개념과&amp;nbsp;동작&amp;nbsp;과정&lt;/a&gt;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>개발자성장기</author>
      <guid isPermaLink="true">https://html-jc.tistory.com/762</guid>
      <comments>https://html-jc.tistory.com/762#entry762comment</comments>
      <pubDate>Thu, 17 Apr 2025 21:55:59 +0900</pubDate>
    </item>
  </channel>
</rss>