<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>끄적끄적</title>
    <link>https://pawmi.tistory.com/</link>
    <description>이것저것 끄적대는 블로그</description>
    <language>ko</language>
    <pubDate>Wed, 1 Jul 2026 00:15:51 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>코리이</managingEditor>
    <image>
      <title>끄적끄적</title>
      <url>https://tistory1.daumcdn.net/tistory/5568990/attach/0eb07e2c8cf74d909dc4a569b9525e07</url>
      <link>https://pawmi.tistory.com</link>
    </image>
    <item>
      <title>선착순 티켓팅 개발기 2 (성능 테스트)</title>
      <link>https://pawmi.tistory.com/29</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;들어가며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pawmi.tistory.com/28&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 포스팅&lt;/a&gt;에서는 대학교 축제의 티켓팅 시스템을 안정적으로 운영하기 위해 어떤 아키텍처를 선택했는지에 대해 이야기했습니다. 특히, 동시 트래픽이 몰리는 순간에도 장애 없이 대응할 수 있도록 SQS 기반의 비동기 처리 구조, Redis Writeback 전략 등의 다양한 전략에 대해 설명드렸습니다. 이번 글에서는 설계한 아키텍쳐를 바탕으로 실제 티켓팅 전에 미리 성능을 검증하기 위해 진행했던 테스트 과정에 대해 공유드리고자 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 테스트 아키텍쳐 설계&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1779&quot; data-origin-height=&quot;889&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Qr5m1/btsNSxba062/vBJZsptmMkgoKdlztFjKM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Qr5m1/btsNSxba062/vBJZsptmMkgoKdlztFjKM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Qr5m1/btsNSxba062/vBJZsptmMkgoKdlztFjKM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQr5m1%2FbtsNSxba062%2FvBJZsptmMkgoKdlztFjKM0%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;1779&quot; height=&quot;889&quot; data-origin-width=&quot;1779&quot; data-origin-height=&quot;889&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;성능 테스트를 시작하기에 앞서, 먼저 어떤 도구를 사용할지 결정해야 했습니다. 이전에 사용해본 도구로는 JMeter와 nGrinder가 있었습니다. 하지만 JMeter는 스크립트 작성 및 관리가 복잡하고 확장성 면에서 인프라 자동화와 잘 맞지 않는 한계가 있었으며 nGrinder는 스크립트를 Groovy로 작성할 수 있어 유연성이 좋지만, 높은 트래픽을 내기 위해선 여러 인스턴스를 수동으로 분산 구성해야 하는 번거로움이 존재했습니다. 이러한 이유로, 이번 프로젝트에서는 경량화된 CLI 환경, 스크립트 기반 작성, 인프라 자동화와의 궁합이 뛰어난 &lt;b&gt;&lt;a href=&quot;https://k6.io/&quot; data-end=&quot;502&quot; data-start=&quot;482&quot;&gt;k6&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;k6 모니터링은 지속적인 데이터 축적이 필요한 용도는 아니었고, 테스트가 진행되는 동안만 일시적으로 성능 지표를 확인하면 충분했습니다. 때문에 비용 부담이 적고 구성도 간단한 &lt;b&gt;Grafana&lt;/b&gt;를 선택하게 되었습니다. 또한, k6는 Grafana Labs에서 만든 도구이기 때문에, Grafana와의 통합이 매우 자연스럽고 설정도 간편했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6 인프라는 Terraform 을 활용해 자동화 했습니다. 테스트가 필요한 시점에만 인프라를 동적으로 구성하고, 테스트가 끝난 후에는 리소스를 정리할 수 있도록 설계했습니다. 비용 효율성을 고려해 EC2 Spot Instance를 사용하였고, k6 실행 환경과 Grafana 모니터링 환경을 분리함으로써, 부하 테스트 도중 모니터링 인프라가 영향을 받아 중단되는 문제를 방지할 수 있었습니다. 이 구조&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&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;테스트하기 위해 작성한 k6 스크립트들은 s3 에 업로드한 후 terraform 으로 인프라 구성 시 파일을 download 하여 시나리오 수정시에도 쉽고 빠르게 대응이 가능했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 테스트 계획 수립 및 수행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 성능 테스트의 목표는 다음과 같았습니다. peak 기준 30,000 RPS 이상의 상황, p95 응답 시간이 500ms 이하인 상태에서 &lt;b&gt;1) API 서버의 CPU/Memory 사용률을 50% 이하로 유지할 수 있는 인스턴스 스펙과 개수&lt;/b&gt; &lt;b&gt;2)이를 처리할 수 있는 데이터베이스 인스턴스의 타입과 개수&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;테스트는 티켓팅의 특성상 특정 시점에 유저들이 일제히 '구매' 버튼을 누르는 순간적인 트래픽 급증이 발생하기 때문에, 이를 시뮬레이션하는 테스트(Spike Test)가 핵심이었습니다. 다만, 티켓의 종류에 따라 두 가지 서로 시나리오가 존재했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;426&quot; data-start=&quot;256&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;356&quot; data-start=&quot;256&quot;&gt;&lt;b&gt;무료 티켓의 경우&lt;/b&gt;, 유저가 별도의 결제 과정 없이 버튼만 누르면 되기 때문에 결제가 이루어 질 때&lt;b&gt; Think Time이 사실상 없거나 매우 짧습니다.&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;426&quot; data-start=&quot;357&quot;&gt;반면 &lt;b&gt;유료 티켓&lt;/b&gt;은 결제 정보를 입력해야 하기 때문에, 주문 생성부터 결제 사이에 &lt;b&gt;Think Time이 발생&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이전 포스팅에서 설명한 대로 무료 티켓인 경우에는 Redis 에 재고를 미리 적재해 놓았기 때문에 구성도 조금 달라질 수 있었습니다. 아래 사진은 실제로 스펙을 지속적으로 조정해가면 얻은 테스트 결과의 일부입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-11 오후 2.04.35.png&quot; data-origin-width=&quot;1880&quot; data-origin-height=&quot;424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JHY1u/btsNS4T52yg/eiOKea4jwmqPoq6V9ku5Hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JHY1u/btsNS4T52yg/eiOKea4jwmqPoq6V9ku5Hk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JHY1u/btsNS4T52yg/eiOKea4jwmqPoq6V9ku5Hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJHY1u%2FbtsNS4T52yg%2FeiOKea4jwmqPoq6V9ku5Hk%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;1880&quot; height=&quot;424&quot; data-filename=&quot;스크린샷 2025-05-11 오후 2.04.35.png&quot; data-origin-width=&quot;1880&quot; data-origin-height=&quot;424&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-filename=&quot;스크린샷 2025-05-11 오후 2.54.55.png&quot; data-origin-width=&quot;2284&quot; data-origin-height=&quot;612&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crLGVb/btsNSaVhSIb/OPqXvqdEdrvZxUFasfxIr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crLGVb/btsNSaVhSIb/OPqXvqdEdrvZxUFasfxIr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crLGVb/btsNSaVhSIb/OPqXvqdEdrvZxUFasfxIr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrLGVb%2FbtsNSaVhSIb%2FOPqXvqdEdrvZxUFasfxIr1%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;2284&quot; height=&quot;612&quot; data-filename=&quot;스크린샷 2025-05-11 오후 2.54.55.png&quot; data-origin-width=&quot;2284&quot; data-origin-height=&quot;612&quot;/&gt;&lt;/span&gt;&lt;/figure&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;테스트하면서 여러가지 병목이 존재했는데 그 중 &lt;b&gt;Redis lock&lt;/b&gt; 에 대한 이야기를 소개해보고자 합니다. 무료 티켓 시나리오에 대한 성능 테스트를 진행하던 중, &lt;b&gt;p95 latency가 무려 3초&lt;/b&gt;에 달하는 비정상적인 수치를 확인할 수 있었습니다.&amp;nbsp;특히나 &lt;b&gt;max latency 를 확인해보니 60초(1분)&lt;/b&gt; 이라는 굉장히 높은 수치를 기록하고 있었습니다. 이로 인해 &lt;b&gt;HTTP 요청의 실패율도 함께 증가&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-filename=&quot;edited_redis lock 병목-latency.png&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;335&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eldKEb/btsNTHqpkLH/qiNrYKAAPsLaqaMetrZrhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eldKEb/btsNTHqpkLH/qiNrYKAAPsLaqaMetrZrhk/img.png&quot; data-alt=&quot;참고: peak rps 는 27.3k/5 = 5.4 k 입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eldKEb/btsNTHqpkLH/qiNrYKAAPsLaqaMetrZrhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeldKEb%2FbtsNTHqpkLH%2FqiNrYKAAPsLaqaMetrZrhk%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;2740&quot; height=&quot;335&quot; data-filename=&quot;edited_redis lock 병목-latency.png&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;335&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;참고: peak rps 는 27.3k/5 = 5.4 k 입니다.&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;&lt;b&gt;Datadog APM&lt;/b&gt;을 통해 살펴본 결과, &lt;b&gt;Redis Lock 처리 로직에서 병목이 발생하고 있음&lt;/b&gt;을 확인할 수 있었습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_redlis lock 병목.png&quot; data-origin-width=&quot;2238&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbcSme/btsNTlON5As/mXvYy9sJ00KOXyk15euX9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbcSme/btsNTlON5As/mXvYy9sJ00KOXyk15euX9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbcSme/btsNTlON5As/mXvYy9sJ00KOXyk15euX9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbcSme%2FbtsNTlON5As%2FmXvYy9sJ00KOXyk15euX9k%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;2238&quot; height=&quot;100&quot; data-filename=&quot;edited_redlis lock 병목.png&quot; data-origin-width=&quot;2238&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제가 되었던 로직은, 주문 요청 시 해당 상품의 재고를 확인하기 위해 아래와 같이 방식으로 &lt;b&gt;상품 id 단위로 Lock을 거는 구조&lt;/b&gt; 때문이였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746940678566&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;lock (상품 id)
  재고 개수 확인
  유저별 최대 구매 제한 개수 검증
  재고 개수 차감(count)
unlock (상품 id)&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;, 모두 동일한 Lock을 기다리게 되어 응답 지연이 누적될 수밖에 없습니다. 무료 티켓은 특히 특정 상품에 트래픽이 집중되는 경향이 강했기 때문에, &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;상품별 재고 id 를 Redis의 List 구조로 미리 적재&lt;/b&gt;해두는 방식을 사용했습니다. 이 방식에서는 유저의 요청이 들어올 때 &lt;b&gt;Redis 리스트에서 `&lt;/b&gt;pop&lt;b&gt;` 을 수행하여 재고를 하나 꺼내는 방식&lt;/b&gt;으로 처리합니다. 만약 리스트가 비어 있다면 null이 반환되므로, 그 시점에서 &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;유저별 최대 구매 수량 제한&lt;/b&gt;입니다. 이 검증은 사용자마다 상태가 다르기 때문에 Lock이 필요했고, 이전과 달리 &lt;b&gt;상품 id + 유저 id 조합을 기준&lt;/b&gt;으로 Lock을 걸도록 구조를 변경했습니다. 이렇게 되면 &lt;b&gt;서로 다른 사용자의 요청 간에는 Lock 충돌 없이 병렬 처리&lt;/b&gt;가 가능해집니다&lt;/p&gt;
&lt;pre id=&quot;code_1746941287973&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;lock (상품 id, 유저 id)
  pop 재고
  유저별 구매 제한 검증
unlock (상품 id, 유저 id)&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;이 구조로 개선한 후 동일한 조건으로 다시 성능 테스트를 진행한 결과, latency 는 확연히 줄어들었고, 처리 가능 RPS는 약 2배가량 증가하는 효과를 확인할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2024-09-02 오후 5.36.03.png&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;167&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eJmgJn/btsNTm1gdfh/kypna75O5SScnkPMj0KNJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eJmgJn/btsNTm1gdfh/kypna75O5SScnkPMj0KNJk/img.png&quot; data-alt=&quot;참고: peak rps 는 23.8k/2 = 11.9 k 입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eJmgJn/btsNTm1gdfh/kypna75O5SScnkPMj0KNJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeJmgJn%2FbtsNTm1gdfh%2Fkypna75O5SScnkPMj0KNJk%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;1584&quot; height=&quot;167&quot; data-filename=&quot;edited_스크린샷 2024-09-02 오후 5.36.03.png&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;167&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;참고: peak rps 는 23.8k/2 = 11.9 k 입니다.&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;Redis lock 문제 외로도 slow query 문제, sqs 메시지 전송 성능 문제, aurora DNS 기반 라운드로빈 이슈로 인한 부하 쏠림 문제 등의 병목이 존재하여 이를 해결했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 티켓팅 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영 환경에서는 API 서버의 CPU 사용률이 예상보다 약간 높게 올라가 최대 50% 수준까지 도달했지만, 장애 없이 안정적으로 트래픽을 처리하며 마무리할 수 있었습니다. Worker 서버는 SQS로부터 메시지를 하나씩 소비하는 구조이기 때문에, 자연스럽게 처리량이 제한되며 CPU 사용률 역시 50% 내외로 안정적으로 유지되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;task2.png&quot; data-origin-width=&quot;3044&quot; data-origin-height=&quot;1150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7NtLN/btsNR32JGcB/Rw6PhgmX8KkRCxqHvTeJjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7NtLN/btsNR32JGcB/Rw6PhgmX8KkRCxqHvTeJjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7NtLN/btsNR32JGcB/Rw6PhgmX8KkRCxqHvTeJjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7NtLN%2FbtsNR32JGcB%2FRw6PhgmX8KkRCxqHvTeJjk%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;3044&quot; height=&quot;1150&quot; data-filename=&quot;task2.png&quot; data-origin-width=&quot;3044&quot; data-origin-height=&quot;1150&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;RDB 측면에서도 Writer 인스턴스(instance-1)는 티켓팅 처리에서 직접적으로 격리되어 있었기 때문에 트래픽이 몰린 상황에서도 안정적인 CPU 사용량을 보였습니다. 아래 그래프에서 일시적으로 사용량이 상승한 구간은 실제로 API 서버가 DB를 사용한 시점이 아니라, 티켓팅 트래픽이 몰린 후 Worker가 DB 저장 작업을 수행하는 구간이며, 이 또한 &lt;b&gt;CPU 35%를 넘지 않는 수준&lt;/b&gt;에서 안정적으로 처리되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;db.png&quot; data-origin-width=&quot;1782&quot; data-origin-height=&quot;671&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ebUeMA/btsNSy9aC0m/PDd5568PaB4BftI5NVjcr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ebUeMA/btsNSy9aC0m/PDd5568PaB4BftI5NVjcr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ebUeMA/btsNSy9aC0m/PDd5568PaB4BftI5NVjcr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FebUeMA%2FbtsNSy9aC0m%2FPDd5568PaB4BftI5NVjcr1%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;1782&quot; height=&quot;671&quot; data-filename=&quot;db.png&quot; data-origin-width=&quot;1782&quot; data-origin-height=&quot;671&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;RPS 1200+, 동시접속 10,000+&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;이번 포스팅에서는 성능 테스트를 통해 아키텍처의 병목을 사전에 식별하고 개선한 과정, 그리고 실제 수천 명의 동시 접속자 속에서 티켓팅을 성공적으로 마무리한 운영 결과를 공유드렸습니다. 사전에 병목지점을 파악하고 개선했던 것이 실제 운영에서 큰 장애 없이 안정적으로 시스템을 유지할 수 있었던 핵심 요인이었습니다. 이 포스팅을 통해 티켓팅에 대해 고민하시고 있는 분들에게 도움이 되셨으면 합니다.&lt;/p&gt;</description>
      <category>개발/기타</category>
      <category>아키텍쳐</category>
      <category>후기</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/29</guid>
      <comments>https://pawmi.tistory.com/29#entry29comment</comments>
      <pubDate>Sat, 1 Feb 2025 16:46:35 +0900</pubDate>
    </item>
    <item>
      <title>선착순 티켓팅 개발기 1 (아키텍처 설계)</title>
      <link>https://pawmi.tistory.com/28</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;들어가며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봄과 가을은 대학교 축제가 활발하게 열리는 시기입니다. 축제의 하이라이트는 단연 연예인 초청 공연일 텐데요. 하지만 한정된 공연장 좌석에 비해 많은 학생이 공연을 관람하고 싶어 하기 때문에, 학생회와 학교 측에서는 티켓 분배에 신경을 많이 씁니다. 당시 회사에서도 여러 대학교와 협업을 진행하던 중, 특히 H 대학교에서 선착순 방식으로 티켓을 분배하고 싶다는 요청이 들어왔습니다.&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;1. 학교 재학생(12,000명)만 티켓팅이 가능할 것&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. 티켓을 구매한 학생들에게 입장용 QR 코드를 제공할 것&lt;/p&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: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;선착순으로 티켓이 분배&lt;/b&gt;에 대해 자세히 다뤄보겠습니다. 특히, 같은 시기 다른 대학교에서 티켓팅 장애가 발생해 학교 사이에서 이슈화되면서, 학생회 측에서도 &lt;b&gt;장애 없는 티켓팅 시스템&lt;/b&gt;을 강하게 요구했습니다. 내부적으로도 이번 설계를 잘 마무리하면 내년(글을 쓰는 시점에서는 올해)에도 다른 학교의 티켓팅을 진행할 수 있고, 앱 홍보 효과도 기대할 수 있다는 의견이 모였습니다. 이에 따라, 아키텍처를 개선하는 작업을 진행하게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;선착순 티켓팅 아키텍처 설계&lt;/h3&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/PjM9E/btsMAI5NnA8/MaBoJUXBOuFjAKB5JEV8v0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PjM9E/btsMAI5NnA8/MaBoJUXBOuFjAKB5JEV8v0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;932&quot; data-filename=&quot;스크린샷 2025-03-03 오전 1.22.02.png&quot; width=&quot;395&quot; style=&quot;width: 46.2705%; margin-right: 10px;&quot; data-widthpercent=&quot;46.81&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PjM9E/btsMAI5NnA8/MaBoJUXBOuFjAKB5JEV8v0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPjM9E%2FbtsMAI5NnA8%2FMaBoJUXBOuFjAKB5JEV8v0%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;1980&quot; height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o6oEW/btsMy4WkDD6/vceL5On5sfgJahlmTlPDkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o6oEW/btsMy4WkDD6/vceL5On5sfgJahlmTlPDkK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1354&quot; data-origin-height=&quot;561&quot; data-filename=&quot;edited_스크린샷 2025-03-03 오전 1.21.26.png&quot; width=&quot;451&quot; height=&quot;348&quot; data-widthpercent=&quot;53.19&quot; style=&quot;width: 52.5667%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o6oEW/btsMy4WkDD6/vceL5On5sfgJahlmTlPDkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo6oEW%2FbtsMy4WkDD6%2FvceL5On5sfgJahlmTlPDkK%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;1354&quot; height=&quot;561&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상대기실(Virtual Waiting Room) 시스템을 우선적으로 고려했습니다. 티켓팅을 해본 경험이 있다면 한 번쯤 접해봤을 가능성이 높은 방식인데요. 이는 구매 페이지에 접근할 수 있는 사용자 수를 제한하여 서버 부하를 방지하는 시스템입니다. 유명한 솔루션으로는 &lt;a href=&quot;https://www.stclab.com/netfunnel&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;넷퍼넬&lt;/a&gt;이 있고 AWS 에서도 쉽게 클라우드에 구축할 수 있도록 &lt;a href=&quot;https://aws.amazon.com/ko/solutions/implementations/virtual-waiting-room-on-aws/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;템플릿&lt;/a&gt;으로 제공하고 있습니다. 가상대기실을 사용하면 사용자가 티켓팅 페이지에 접속시 순차적으로 들어온 일정 수의 사용자만 구매 페이지로 입장할 수 있도록 조절하고 다른 사용자들은 대기열에 배치시켜&amp;nbsp;&lt;b&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;p data-ke-size=&quot;size16&quot;&gt;다만, 기존의 가상대기실 솔루션들은 비용이 상당히 높았고, 우리 회사가 지속적으로 사용할 시스템도 아니었기 때문에 도입이 어려웠습니다. 그렇다면 AWS를 활용해 직접 구축하는 방법도 고려할 수 있었지만, 이번 티켓팅 대상이 약 20,000명이고, 재학생이 많은 학교도 최대 6만 명 이내라는 점을 감안하면, 가상대기실이라는 복잡한 인프라를 설계하고 유지보수하는 것은 비효율적이라고 판단했습니다.&lt;/p&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;기존 인프라의 Scale-Up/Scale-Out 전략을 활용&lt;/b&gt;하는 방향으로 접근했고, &lt;b&gt;Redis + Queue 기반의 구조를 적용&lt;/b&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;1926&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3zWpw/btsMAtOJPNE/cMyvAlDAUPNtAodbq9NPV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3zWpw/btsMAtOJPNE/cMyvAlDAUPNtAodbq9NPV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3zWpw/btsMAtOJPNE/cMyvAlDAUPNtAodbq9NPV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3zWpw%2FbtsMAtOJPNE%2FcMyvAlDAUPNtAodbq9NPV0%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;1926&quot; height=&quot;838&quot; data-origin-width=&quot;1926&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 각 구성요소를 어떻게 적용했는지 살펴보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Queue 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;티켓팅 시스템을 구축하면서 고려했던 Queue 옵션은 Redis Pub/Sub, Kafka, RabbitMQ, SQS(or + SNS) 가 있었습니다.&lt;/p&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: #333333; text-align: start;&quot;&gt;Redis Pub/Sub 은 다른 무엇보다&lt;span&gt;&amp;nbsp;&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;Kafka 의 경우 요즘 가장 많이 사용되는 메시지 시스템이기 때문에 먼저 검토해봤습니다. 하지만 단순 스터디에서만 사용해봤을 뿐 실무 적용 경험이 거의 없었고, 팀원들도 익숙하지 않았습니다. 무엇보다 이번 티켓팅 시스템에서는 대규모 데이터 스트리밍이나 복잡한 메시지 브로커 기능까지 필요하지 않은데 추가적인 비용이 많이 들고, 유지보수 부담도 크기 때문에 배제하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RabbitMQ의 경우, 실무에서 여러 번 적용해 본 경험이 있고 팀원들도 익숙한 메시지 시스템입니다. 하지만 직접 구축해야 한다는 점에서 Kafka와 동일한 유지보수 부담이 발생하는 문제가 있었습니다. 또한, RabbitMQ를 사용할 바에는 차라리 Kafka를 사용하는 것이 낫다고 판단했기 때문에 최종적으로 후보에서 제외했습니다.&lt;/p&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;가장 간단하고 운영 부담이 적은 AWS SQS&lt;/b&gt;를 선택했습니다. SNS와의 결합도 고려했지만, 추가적인 인프라 구축이 필요하고 이벤트 관련 개발이 추가로 들어가야 한다는 점 때문에 사용하지 않기로 결정했습니다. 무엇보다 SQS는 별도의 관리 없이 무제한 트래픽을 안정적으로 처리할 수 있으며, 구축 비용도 무료(메시지 전송량에 대해서만 과금)라 비용 부담이 적습니다. 또한, 다른 AWS 서비스와의 연계가 용이하고, 팀원들도 이미 익숙해 있어 &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;SQS 에는 두가지 유형이 존재합니다. 우선 FIFO Queue 가 존재합니다. 하지만 FIFO Queue 는 TPS 제한이 크기 때문에 트래픽을 직접적으로 받아줘야 하는 구성에서는 맞지 않는다고 판단했습니다. 그래서 저희는 Standard Queue 를 선택했습니다.&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;SQS를 사용할 때 가장 주의해야 할 점은 &lt;b&gt;메시지의 순서가 보장되지 않는다&lt;/b&gt;는 것입니다. 물론 FIFO Queue 를 사용하면 메시지의 순서를 보장할 수 있지만 이 유형은 TPS 제한이 존재하여 저희와는 맞지 않는다고 판단했습니다. 그렇기 때문에 저희 서비스 같은 경우에 한 사용자가 티켓팅을 성공한 후 취소하는 경우, 상황에 따라 취소 요청이 먼저 처리되고, 성공 후처리가 나중에 수행되는 문제가 발생할 수 있습니다. 만약 취소가 먼저 처리되면, 후처리하는 도중 티켓에 취소 요청이 들어와 로직이 실패하는 상황이 생길 수 있습니다.&lt;/p&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;재시도(retry) 횟수를 설정하여 해결&lt;/b&gt;할 수 있습니다. 이를 위해 &lt;b&gt;SQS의 Retry Count 설정을 활용&lt;/b&gt;하여, &lt;b&gt;일정 횟수 초과 시 DLQ(Dead Letter Queue)로 메시지를 이동&lt;/b&gt;하도록 구성했습니다. 이를 통해 &lt;b&gt;일시적인 오류로 인해 메시지가 유실되지 않도록 하면서도, 지속적으로 실패하는 요청은 별도로 처리할 수 있도록 설계&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_jUbPB.png&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMkwoJ/btsMBGM5zDC/UQa6qZkbIWV5m6a9kzxDhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMkwoJ/btsMBGM5zDC/UQa6qZkbIWV5m6a9kzxDhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMkwoJ/btsMBGM5zDC/UQa6qZkbIWV5m6a9kzxDhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMkwoJ%2FbtsMBGM5zDC%2FUQa6qZkbIWV5m6a9kzxDhk%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;702&quot; height=&quot;164&quot; data-filename=&quot;edited_jUbPB.png&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;164&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Api 서버와 Worker 의 역할 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 한 대의 서버에서 API 요청 처리와 데이터 저장까지 모든 작업을 수행하는 구조였습니다. 물론 이런 구조는 트래픽이 많지 않을 때는 큰 문제가 없습니다. 다만 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;API 서버에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;바로 DB에 Write 작업을 수행&lt;/b&gt;한다면 트래픽이 몰리는 상황에서는&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1923&quot; data-origin-height=&quot;493&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bph432/btsMHQYf46e/EtLNkh58Dno67HgIDjkao1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bph432/btsMHQYf46e/EtLNkh58Dno67HgIDjkao1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bph432/btsMHQYf46e/EtLNkh58Dno67HgIDjkao1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbph432%2FbtsMHQYf46e%2FEtLNkh58Dno67HgIDjkao1%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;1923&quot; height=&quot;493&quot; data-origin-width=&quot;1923&quot; data-origin-height=&quot;493&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;API 서버는 요청을 검증한 후, SQS에 메시지만 추가하는 역할&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;nbsp;유저 요청을 검증한 후 SQS에 메시지를 추가하는 역할만 수행하며, 데이터베이스에 직접 쓰는 작업은 하지 않도록 설계했습니다. 이렇게 하면 API 서버는 scale out 이 쉬워져 트래픽이 몰려도 빠르게 요청을 처리할 수 있고, 데이터 저장 과정에서 발생할 수 있는 지연을 최소화할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 Worker 서버가 SQS에서 메시지를 읽어와 데이터베이스에 저장하는 방식으로 동작하게 되었습니다. 이 구조를 적용하면서 API 서버의 부하를 줄이고, 트래픽이 급증하는 상황에서도 안정적으로 요청을 처리할 수 있는 환경을 만들 수 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주문 상태 확인을 위한 Redis Writeback 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 서버와 Worker 서버를 분리하면서 고민해야 했던 부분 중 하나는 &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;처음 고려했던 방법은 웹소켓(WebSocket)을 활용하는 방식이었습니다. 주문이 완료될 때 서버에서 실시간으로 유저에게 알림을 보내는 방식이었지만, 동시접속자가 많을 경우 소켓 연결을 관리하는 것이 매우 어렵고 서버 부하도 커지는 문제가 있었습니다. 특히, 티켓팅과 같은 환경에서는 한 번에 수천~수만 명이 접속하기 때문에 안정적인 연결을 유지하는 것이 쉽지 않을거라 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 폴링(Polling) 방식을 선택할 수 밖에 없었습니다. 하지만 이를 위해서는 어딘가에 주문 내역을 저장하고 있어야 하는데 앞에서 이야기 했듯이 데이터베이스 저장 로직이 api 서버에 들어가는 것은 부하가 크기 때문에, &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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2642&quot; data-origin-height=&quot;675&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUS2ha/btsMI6lwSRf/agzKIBj5kN18xRCEmfYrW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUS2ha/btsMI6lwSRf/agzKIBj5kN18xRCEmfYrW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUS2ha/btsMI6lwSRf/agzKIBj5kN18xRCEmfYrW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUS2ha%2FbtsMI6lwSRf%2FagzKIBj5kN18xRCEmfYrW0%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;2642&quot; height=&quot;675&quot; data-origin-width=&quot;2642&quot; data-origin-height=&quot;675&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;627&quot; data-end=&quot;888&quot;&gt;
&lt;li data-start=&quot;627&quot; data-end=&quot;697&quot;&gt;API 서버에서 SQS에 메시지를 전달하기 전에, Redis에 주문 상태 확인에 필요한 최소한의 데이터를 저장합니다. 이 때 주문 완료를 확인한 후에는 해당 데이터가 더 이상 필요 없으므로,&lt;span&gt;&amp;nbsp;&lt;/span&gt;TTL(Time-To-Live)을 짧게 설정하여 자동으로 삭제되도록 구성했습니다.&lt;/li&gt;
&lt;li data-start=&quot;698&quot; data-end=&quot;738&quot;&gt;유저가 폴링을 통해 API를 호출하면 Redis에서 주문 상태를 조회합니다. (주문 진행중)&lt;/li&gt;
&lt;li data-start=&quot;739&quot; data-end=&quot;792&quot;&gt;주문이 완료되면 Worker가 데이터를 처리한 후, Redis의 상태를 업데이트합니다.&lt;/li&gt;
&lt;li data-start=&quot;739&quot; data-end=&quot;792&quot;&gt;worker 가 redis 의 상태를 업데이트 하면 유저는 주문 완료가 됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 &lt;b&gt;Writeback 캐싱 전략&lt;/b&gt;을 활용한 것으로, 유저가 빠르게 주문 상태를 확인할 수 있도록 하면서도 &lt;b&gt;DB 부하를 최소화&lt;/b&gt;할 수 있는 구조입니다. 특히, 트래픽이 집중되는 상황에서도 효율적인 주문 상태 관리를 가능하게 해주었습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;무료 티켓 재고 관리를 위한 Redis 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 요구사항 중에는 무료티켓과 유료 티켓 두 종류가 존재했습니다. 특히나 무료 티켓이 더 많은 상황이였기 때문에 이를 효율적으로 처리할 수 있다면 트래픽을 받기 쉬워질 것이라 생각했습니다. 그래서 Redis 에 미리 티켓 재고를 저장해두고 티켓을 하나씩 선착순으로 가져갈 수 있도록 개발했습니다. 핵심 개념은 간단합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;463&quot; data-start=&quot;286&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;355&quot; data-start=&quot;286&quot;&gt;티켓 재고를 Redis에 저장해두고, 유저가 티켓팅을 시도할 때마다 Redis에서 즉시 차감하도록 했습니다.&lt;/li&gt;
&lt;li data-end=&quot;407&quot; data-start=&quot;356&quot;&gt;티켓 재고가 0이 되면 더 이상 유저가 티켓을 확보할 수 없도록 차단합니다.&lt;/li&gt;
&lt;li data-end=&quot;463&quot; data-start=&quot;408&quot;&gt;성공적으로 티켓을 확보한 유저만 SQS를 통해 후속 프로세스로 전달되도록 했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 활용하면 재고가 없는 상황에서는 굳이 SQS 에 메시지를 전달하지 않아 Worker 의 성능을 극대화할 수 있었습니다. 또한 유저들도 즉각적으로 응답을 받을 수 있기 때문에 유저친화적이기도 합니다. 물론&amp;nbsp;이중 검증(Double Checking) 메커니즘도 적용하여 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Worker 에서 다시 한 번 더 검증을 수행하여 데이터 정합성을 유지할 수 있도록 했습니다.&lt;/span&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1926&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkjvyf/btsMJfigajZ/DBKUbW5jGNhIKrxpKkupl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkjvyf/btsMJfigajZ/DBKUbW5jGNhIKrxpKkupl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkjvyf/btsMJfigajZ/DBKUbW5jGNhIKrxpKkupl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdkjvyf%2FbtsMJfigajZ%2FDBKUbW5jGNhIKrxpKkupl0%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;1926&quot; height=&quot;838&quot; data-origin-width=&quot;1926&quot; data-origin-height=&quot;838&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;a href=&quot;https://pawmi.tistory.com/29&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;</description>
      <category>개발/기타</category>
      <category>아키텍쳐</category>
      <category>후기</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/28</guid>
      <comments>https://pawmi.tistory.com/28#entry28comment</comments>
      <pubDate>Thu, 30 Jan 2025 18:45:29 +0900</pubDate>
    </item>
    <item>
      <title>[도서 리뷰] 컴퓨터 밑바닥의 비밀</title>
      <link>https://pawmi.tistory.com/27</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_9537.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;2855&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdwbpc/btsHv7C2bUm/0VtNg7g2yfcnzk4a3OdLQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdwbpc/btsHv7C2bUm/0VtNg7g2yfcnzk4a3OdLQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdwbpc/btsHv7C2bUm/0VtNg7g2yfcnzk4a3OdLQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcdwbpc%2FbtsHv7C2bUm%2F0VtNg7g2yfcnzk4a3OdLQ0%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;569&quot; height=&quot;403&quot; data-filename=&quot;IMG_9537.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;2855&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;&lt;a href=&quot;https://www.yes24.com/Product/Goods/125299750?pid=123487&amp;amp;cosemkid=go17107581468846387&amp;amp;gad_source=1&amp;amp;gclid=Cj0KCQjw6auyBhDzARIsALIo6v-s4-jCBj8OLXotFVk0VFSxgAI_DTBUKLsdhzrz4-g8AndsrLFh36saAoJ9EALw_wcB&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[컴퓨터 밑바닥의 비밀]&lt;/a&gt;&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-ke-size=&quot;size16&quot;&gt;개발자라면 CS 지식은 지속적해서 공부하는 것이 매우 중요하다고 생각한다. 특히나 컴퓨터 내부가 어떻게 돌아가는지를 이해해야 동시성과 같은 어려운 개발 상황에 처하게 되었을 때 문제 해결법에 대한 힌트를 쉽게 얻을 수 있을 것이다.&amp;nbsp;이 책은 제목 그대로 컴퓨터가 어떻게 돌아가는지에 대해서 설명해주는 책으로 개인적으로는 CS 관련 책 중 깊이도 있고 이해 하기도 쉽게 적어놓은 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 번역이 깔끔하게 잘 되어있다는 점이 가장 큰 장점이 아닐까 싶다. 가끔 기술 번역서의 번역이 잘못되어 있을 때 책을 읽는 것 자체가 어려운 경우가 있는데 전부 다 읽었을 때도 그런 느낌은 전혀 들지 않았다. 특히 CS 관련된 책의 경우 아무래도 기술적인 내용이 많기 때문에 중간중간 툭툭 끊길 수가 있는데 개인적으로는 끊기는 느낌 없이 쉽게 읽혔었다. 하지만 그렇다고 해서 깊이 없이 얕게 알려주는 책은 아니다.&amp;nbsp; 책을 전부 읽었을 때의 느낌은 이론적인 내용은 정말 꾹꾹 모두 눌러 담았구나 하는 생각이 주를 이루었다. 특히나 간단한 코드와 그림과 함께 어떤 식으로 작동하는지를 한번씩 추가해 주니 조금 더 이해하기 쉽고 컴퓨터가 이렇게 작동하고 있구나를 깨달을 수 있었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;책의 첫 장에서는 컴파일러에 대해서 이야기해준다. 어쨌든 컴퓨터의 가장 기초는 기계어로 번역되어야 하는 점은 모두 알고 있을 것이다. 그 때 우리가 고수준의 언어로 만든 프로그램이 컴파일러를 통해 어떻게  어셈블리어로 변환되고 링커가 이를 어떻게 이어주는지에 대해서 그림과 함께 알려준다. 그 이후에는 우리가 개발하면서 가장 많이 맞닥뜨리는 부분인 프로세스와 쓰레드에 대해서 알려준다. 멀티 프로세스 부터 이에 대한 한계 그로 인해 태어난 스레드 등에 대한 설명을 이어나간다. 3장에서는 메모리와 관련된 이야기를 한다. 특히나 메모리에 대한 설명을 이어가다 보니 프로세스영역에서 힙 영역과 함께 연관지어서 설명을 한다. 이 때 가상메모리에 대해 엄청난 찬사를 이어나가는 것이 재밌었던 점이다. 4장은 CPU 관련 이야기이다. 초반에 회로에 대한 이야기가 나와서 조금 어지럽긴 했지만 옛날 생각이 나면서 흥미롭기도 했다. 가장 재미있었던 부분은 CISC 와 RISC 가 서로 경쟁하면서 시장을 점유해가는 이야기였으며 최신 MAC 의 ARM CPU 에 대한 이야기를 하면서 미래는 어떻게 될지 정말 모를 것 같다는 생각이 들었다. 5장은 캐시에 대한 이야기를 한다. 캐시를 이야기하면서 캐시의 동기화에 대해서 많은 이야기를 해줘서 재미있게 읽었었다. 마지막 파트는 입출력에 대한 이야기이다. 결국 모든건 파일로 연결된다는 이야기를 하고 DMA 에 대한 이야기도 하는데 흥미롭게 읽었던 것 같다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;책을 전부 읽었을 때, 이전에 배웠던 내용들이 새록새록 기억에 떠올랐다. 물론 이 책 자체가 두껍지는 않은 만큼 모든 내용을 깊게 다루기는 어려울 수 있다. 만약 더 궁금한 점이 있다면 한가지 파트만 깊게 파는 책을 보거나 하면 좋을 것 같았다. 특히 아무래도 실습관련된 내용은 없으니 추가로 관련 내용을 직접 실습해보면서 공부하는 것이 가장 도움이 될 것으로 생각된다. 특히나 OS 관련 실습의 경우 요즘엔 유투브 같은 곳에서 무료로 볼 수 있으니 한번쯤은 해보는 것이 좋을 것 같다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;오랜만에 CS 관련 서적을 읽으면서 재밌다는 생각을 하게 만들어준 책이였다. 그렇기 때문에 만약 CS 를 잊어버린 개발자라면 한번쯤 돌아보기 매우 좋은 책인 것 같아서 만약 주변 개발자가 도서 추천을 해달라고 한다면 이 책을 추천해줄 것 같다.&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;해당 컨텐츠는 길벗 출판사로부터 책을 제공받아 작성된 서평입니다.&lt;/blockquote&gt;</description>
      <category>리뷰/도서리뷰</category>
      <category>도서</category>
      <category>리뷰</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/27</guid>
      <comments>https://pawmi.tistory.com/27#entry27comment</comments>
      <pubDate>Mon, 20 May 2024 21:47:16 +0900</pubDate>
    </item>
    <item>
      <title>[유데미(Udemy)] 화이트 해킹 101: 윤리적 해킹 기초부터 배우기 후기</title>
      <link>https://pawmi.tistory.com/26</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-28 오전 11.37.04.png&quot; data-origin-width=&quot;2002&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkHa58/btsGYMtNnPO/HRHKUfvgszGt1KS8xJpTQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkHa58/btsGYMtNnPO/HRHKUfvgszGt1KS8xJpTQ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkHa58/btsGYMtNnPO/HRHKUfvgszGt1KS8xJpTQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkHa58%2FbtsGYMtNnPO%2FHRHKUfvgszGt1KS8xJpTQ1%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;2002&quot; height=&quot;378&quot; data-filename=&quot;스크린샷 2024-04-28 오전 11.37.04.png&quot; data-origin-width=&quot;2002&quot; data-origin-height=&quot;378&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;&lt;a href=&quot;https://www.udemy.com/course/learn-ethical-hacking-from-scratch-korean/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[화이트&amp;nbsp;해킹&amp;nbsp;101:&amp;nbsp;윤리적&amp;nbsp;해킹&amp;nbsp;기초부터&amp;nbsp;배우기!]&lt;/a&gt;&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-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발할 때 반드시 적용해야 할 부분 중 하나가 시큐어 코딩이다. 물론 기본적인 옵션인 CORS, XSS &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;등을 구현하는 것은 어렵지 않지만, &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;이러한 해킹이 가능한 원리에 대해서 &lt;/span&gt;항상 궁금했었다. 이 Udemy 강의는 필자와 같이 해킹에 대해서 잘 알지 못하는 사람들이 처음 접해도 쉽게 따라갈 수 있도록 만든 기초강의이다. &lt;/span&gt;&lt;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;물론 강의가 기초 강의임에도 시간이 상당히 길었기 때문에 가볍게 들으려고 했던 필자의 기대와는 달리, 강의를 듣는 데에는 꽤 많은 개인 시간이 필요했었다. &lt;span style=&quot;color: #000000; text-align: start;&quot;&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;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;강의는 초반에 실습환경을 구축하는 것부터 시작한다. 이때 설치해야 하는 파일이 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;VM 이미지이기 때문에 파일 용량이 매우 크다는 점을 생각해야 한다. 참고로 &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; 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;&lt;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;그 이후부터 한 챕터씩 강의를 알려주기 시작한다. 이때 무선 어댑터가 없다면 초반에 네트워크 세션의 &lt;span style=&quot;color: #2d2f31; text-align: left;&quot;&gt;WEP 해킹 파트와 &lt;span style=&quot;color: #2d2f31; text-align: left;&quot;&gt;WPA와 WPA 2 해킹&lt;span&gt; 파트는 진행할 수 없다는 것이 조금 아쉽긴 했다. 이 강의 때문에 구매하기는 애매하고 실제로 어떤 제품을 구매해야 할지 솔직히 감이 잘 잡히지 않았었다. 다만 이 강의 이후에는 크게 필요 없는 파트들이 진행되므로 이후 챕터부터 진행해도 무방하다. 필자의 경우 뭔가 구매해서 해보고 싶어 인터넷 사이트를 뒤져보다가 시간을 꽤 보냈는데 해커가 되고 싶은 것도 아닌데 너무 많은 시간을 소비한 것이 아닌가 하는 생각이 들었다. 만약 해킹으로 진로를 정한 분들이 아니라면 이 파트는 과감히 뛰어넘을 것을 추천하고 싶다. 그 이후 연결 후 공격 등은 실습과 함께하기 때문에 더 재밌게 진행할 수 있다. 지금 필자도 이 파트를 열심히 듣고 있는데 사전 지식이 충분히 있지 않은데도 지금까지는 막힘없이 잘 듣고 있다. 또한 직접 눈과 손으로 해킹이 이루어지는 것을 확인할 수 있으니 이전에 궁금했던 부분들이 조금씩 풀리기도 했다.&lt;/span&gt;&lt;/span&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;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;아직 강의를 듣고는 있지만 일단 가장 마음에 들었던 부분은 VMWare를 활용해 강의에서 필요로 하는 모든 라이브러리 들이 설치된 이미지를 제공해 준다는 것이다. 필자의 경우 해킹에 대한 지식이 거의 없기 때문에 더더욱 환경 세팅이 편한 것이 좋았었다. 물론 가상화 프로그램을 사용해 보지 않았다면 이 또한 허들일 수 있겠지만 아마도 개발자라면 이번 기회에 한 번 배워보는 것도 좋을 것으로 생각한다. 또한 이 강의의 또 다른 좋은 점은 대부분의 시간이 실습하는 데에 맞춰져 있다는 것이다. 개인적으로는 직접 만져보고 실행시켜 봐야 더 이해를 할 수 있다고 생각하는 편이라서 실습 위주의 강의를 더 선호하는 편이긴 하다.&lt;/span&gt;&lt;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; 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;&lt;span style=&quot;background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;일단 이것저것 실습하기 편하기도 하고 Udemy 강의는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&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;div&gt;
&lt;div style=&quot;color: #000000;&quot; data-testid=&quot;conversation-turn-27&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;해당 콘텐츠는 유데미로부터 강의 쿠폰을 제공받아 작성되었습니다.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>리뷰/강의리뷰</category>
      <category>리뷰</category>
      <category>온라인강의</category>
      <category>유데미</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/26</guid>
      <comments>https://pawmi.tistory.com/26#entry26comment</comments>
      <pubDate>Sun, 28 Apr 2024 14:14:36 +0900</pubDate>
    </item>
    <item>
      <title>[NestJS] nestia 를 활용해서 가독성 있는 코드 만들기</title>
      <link>https://pawmi.tistory.com/25</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 필자가 속해있는 회사에서 &lt;a href=&quot;https://nestia.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;nestia&lt;/a&gt; 를 사용하도록  서버 리펙토링을 완료했다. 참고로 nestia 란 라이브러리는 Nestjs 를 조금 더 쉽게 쓸 수 있도록 해주며 성능적으로도 훨씬 빠르게 만들어 주는 라이브러리로, 한국에 있는 개발자 분이 개발한 멋진 라이브러리다. 이전 포스팅에서 nestia sdk 에 대해서 간단하게는 남겼지만 모노레포 지원이 잘 안되는 이슈로 이는 적용 못했지만 굳이 sdk 를 쓰지 않더라도 가독성 및 생산성 면에서 훨씬 좋아지는 경험을 했기에 이번에 포스팅을 남겨보고자 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DTO 를 Interface 로 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 class-transformer, class-validator 를 활용했을 때의 코드를 우선 확인해보자. 아래 코드가 왜 나오는지 이해하기 위해서는 nodejs 의 typescript 의 경우 javascript 로 컴파일 후 이 빌드된 js 파일을 활용한다는 것을 알아야 한다. 여기서 js 의 경우 &quot;동적타입언어&quot; 이기 때문에 &lt;code&gt;{ name: 'pawmi' }&lt;/code&gt; 라는 json 이 들어오기 전에 name 이 string 인지, number 인지 bool 인지 등 &quot;미리 파악 할 수 없다&quot; 는 문제가 존재한다. 따라서 이를 위해 class-validator 에서는 &lt;code&gt;@IsString()&lt;/code&gt; 라는 데코레이터를 활용해서 &quot;이 프로퍼티가 string 이야&quot; 라는 표식을 해주고 이 표식을 가져와서 class-validator 가 검증해주는 구조로 작동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 대부분의 스타트업 백엔드 개발자라면 API 문서로 &lt;a href=&quot;https://swagger.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;swagger&lt;/a&gt; 라는 문서를 활용할 것이라 생각한다. 이를 직접적으로 다 만들어주는 것은 어려우니 다른 대부분의 언어에서도 그렇듯이 데코레이터(python)나 어노테이션(java), 혹은 주석(go, express 등)을 활용해서 자동으로 swagger 문서를 생성하도록 설정해주는 라이브러리를 활용하게 된다.&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;이 때문에 아래와 같이 데코레이터가 덕지덕지 달린 DTO 가 만들어지게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1709452872694&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class ChangeChatRoomSettingCommand implements ICommand {
    @IsString()
    @IsUUID('4')
    @ApiProperty()
    readonly roomId: string;

    @IsString()
    @ApiProperty()
    readonly title: string;

    @IsString()
    @IsOptional()
    @ApiProperty({ type: 'string', nullable: true })
    readonly description?: string | null;

    @IsBoolean()
    @ApiProperty()
    readonly onAirDrop: boolean;

    @IsBoolean()
    @ApiProperty()
    readonly isPriceOpened: boolean;

    @IsArray()
    @ArrayMaxSize(10)
    @Length(1, 29, { each: true)
    @ApiProperty({ type: [String] })
    readonly tags: string[];
    
    // ...
}&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;정말 복잡하고 가독성도 떨어진다. 또한 dto 는 정말 &quot;DTO&quot; 로 수정이 최대한 안이루어져야 하므로 개인적으로는 &quot;interface&quot; 로 정의되는게 맞다고 보는데 이는 라이브러리 특성상 어쩔 수 없이 class 로 강제되면서 typescript 를 제대로 활용할 수 없도록 하는 단점이 존재한다. 그럼 이제 이를 nestia (typia) 로 수정하면 아래처럼 깔끔하게 인터페이스만 정의한 코드를 만들 수 있다. nestia 에서는 swagger 를 데코레이터가 아니라 ts comilier api 를 활용해 명령어를 통해 swagger.json 파일을 제너레이팅 할 수 있도록 도와준다.&lt;/p&gt;
&lt;pre id=&quot;code_1709453283150&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Command extends ICommand {
    readonly roomId: string &amp;amp; tags.Format&amp;lt;'uuid'&amp;gt;;
    
    readonly title: string &amp;amp; tags.MaxLength&amp;lt;30&amp;gt;;

    readonly description?: (string &amp;amp; tags.MaxLength&amp;lt;200&amp;gt;) | null;

    readonly onAirDrop: boolean;

    readonly isPriceOpened: boolean;

    readonly tags: Array&amp;lt;string &amp;amp; tags.MaxLength&amp;lt;30&amp;gt;&amp;gt; &amp;amp; tags.MaxItems&amp;lt;10&amp;gt;;
}&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;아래는 두 코드를 비교한 스크린샷이다. (코드와는 조금 다를 수 있지만 그냥 보기에도 확연히 가독성이 뛰어남을 확인할 수 있다. 또한 class-validator 를 사용할 때는 쓰기 어려웠던 union 타입 등도 적용이 가능해서 조금 더 typescript 스럽게 쓸 수 있게 되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-03 오후 5.15.58.png&quot; data-origin-width=&quot;3360&quot; data-origin-height=&quot;1290&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dOG76Y/btsFm8SqHuK/CsdRMkoRVFKeKzZQMlPfI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dOG76Y/btsFm8SqHuK/CsdRMkoRVFKeKzZQMlPfI1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dOG76Y/btsFm8SqHuK/CsdRMkoRVFKeKzZQMlPfI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdOG76Y%2FbtsFm8SqHuK%2FCsdRMkoRVFKeKzZQMlPfI1%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;3360&quot; height=&quot;1290&quot; data-filename=&quot;스크린샷 2024-03-03 오후 5.15.58.png&quot; data-origin-width=&quot;3360&quot; data-origin-height=&quot;1290&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Api Router 에 존재하는 swagger decorator 삭제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nestjs 를 사용할때 기본 response 를 사용하는 사람은 드물 것으로 생각된다. 특히 에러코드를 커스텀하거나 페이지네이션의 동일 스트럭처를 미리 정해놓고 리턴하는 방식을 쓰지 않을까 싶다. 하지만 nestjs swagger 에서 이를 활용하려면 기본적인 방식으로는 해결하기 어렵다. 방금 든 예시 뿐만 아니라 여러가지 이유로 인해서 결국 nestjs swagger 를 활용한다면 어떤 방식으로든 swagger 데코레이터를 커스텀하게 될 것이라 생각한다. 필자의 회사에서는 초기에 아래처럼 커스텀 모델의 정의해서 스키마에 추가할 수 있도록 설정해서 사용중에 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1709455151049&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Put('rooms/:roomId')
@UseGuards(JwtGuard)
@HttpCode(200)
// 아래부터 swagger 코드
@ApiOperation({description: &quot;채팅방 정보를 변경한다.&quot;})
@ApiExtraModels(ChatRoomResponse)
@ApiSwaggerResponse(ChatRoomResponse)
@ApiParam({name: 'roomId'})
async postChangeRoom(/* params */) {
    /* logics */
}&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;위에서 만약 api query 가 함께 더 많은 것들이 추가된다면 &lt;code&gt;@ApiQuery&lt;/code&gt; 와 같은 데코레이터가 더 많이 추가될 수 있다. 이제 이를 nestia 를 활용해서 모든 swagger 코드를 제거하면 아래와 같이 가독성도 좋은 코드가 만들어진다.&lt;/p&gt;
&lt;pre id=&quot;code_1709455167993&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 채팅방 정보를 변경한다.
 */
@Put('rooms/:roomId')
@UseGuards(JwtGuard)
@HttpCode(200)
async postChangeRoom(/* params */) {
    /* logics */
}&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;위에서 단순히 한개의 controller 라우트 코드만을 만들었지만 개발하다보면 api 갯수가 100 여개를 넘기는 일은 흔한 일이 될 것이다. 위에서 라이브러리만 하나 추가해도 보기 힘들었던 controller 코드들이 싹 사라지도록 만들 수 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에 nestia 는 sdk 를 편하게 만들어주는 라이브러리, 혹은 기존 nestjs 보다 빠른 라이브러리라고만 생각해서 마이그레이션 할 생각이 별로 없었다. sdk 는 모노레포 방식에서 사용하기가 조금 어려운 감이 있었고 현재 swagger 를 사용하는데 프런트에 추가적인 뭔가를 줘서 리펙토링 시키는 것은 너무 나간 이야기였다. 또 validating 관련 성능면에서 아직까지는 이슈가 없었기 때문에 현재 잘 쓰고 있는 것을 옮길 필요가 없었다. 하지만 swagger 를 직접 만드는 것에 대한 피로감, dto 클래스를 굳이 선언해서 확장이 어렵게 만드는 코드들 등 개발시에 피로할 수 있는 부분을 해결할 수 있지 않을까 해서 적용해 봤었는데 확실히 생산성이나 가독성 면에서 훨씬 좋아지는 효과가 있었다. 앞으로도 개발할 때는 이 라이브러리를 적극 활용해서 개발하지 않을까 싶다.&lt;/p&gt;</description>
      <category>개발/js &amp;amp; ts &amp;amp; node.js</category>
      <category>Nestia</category>
      <category>nestjs</category>
      <category>TypeScript</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/25</guid>
      <comments>https://pawmi.tistory.com/25#entry25comment</comments>
      <pubDate>Sun, 3 Mar 2024 18:18:54 +0900</pubDate>
    </item>
    <item>
      <title>[유데미(Udemy)] Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기 후기</title>
      <link>https://pawmi.tistory.com/23</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷 2024-02-14 오후 7.49.46.png&quot; data-origin-width=&quot;1800&quot; data-origin-height=&quot;387&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgaMUn/btsEP3DTEMx/FIbFgvLzJtUlNzwJnHriB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgaMUn/btsEP3DTEMx/FIbFgvLzJtUlNzwJnHriB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgaMUn/btsEP3DTEMx/FIbFgvLzJtUlNzwJnHriB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgaMUn%2FbtsEP3DTEMx%2FFIbFgvLzJtUlNzwJnHriB1%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;1800&quot; height=&quot;387&quot; data-filename=&quot;edited_스크린샷 2024-02-14 오후 7.49.46.png&quot; data-origin-width=&quot;1800&quot; data-origin-height=&quot;387&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;&lt;a href=&quot;https://www.udemy.com/course/java-multi-threading/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기]&lt;/a&gt;&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-ke-size=&quot;size16&quot;&gt;개발할 때 자주 찾아보고 어려워하는 부분이 동시성과 병렬성 부분이다. 특히나 처음 개발을 접하는 사람들은 이 두 용어 자체를 헷갈려 할 정도이며 최근 트랜드는 하나의 서버가 아닌 scale out 을 통해 여러 서버를 사용하기도 한다. 물론 이 강의는 이 부분을 다룬 강의는 아니며 오로지 하나의 프로그램에서 &lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Thread 사용법을 알려주는 강의&lt;/b&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;필자는 Typescript 를 메인 언어로 다루고 있어 정확히는 잘 모르지만, Java 를 사용하는 대부분의 개발자들은 이미 Spring (boot) 를 통해 추상화된 상태로 쓰레드를 사용하고 있기 때문에 Thread 를 다룰 일이 크게 없다고 생각할 수 있다. 그러나 Spring 을 처음 공부할때 나오는 ThreadLocal, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;쓰레드 풀 등에 대해서는 한 번쯤 들어봤을 것이다. 물론 이 강의는 Spring 에서 사용하는 여러 쓰레드 기법을 설명하지는 않고 &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;span style=&quot;color: #333333; text-align: start;&quot;&gt;강의는 우선 Java 에서 Thread 를 만들고 실행시키는 방법에 대해서 설명한다. 그 후에 Latency 와 Throughput 에 대한 차이를 설명하면서 이미지처리를 병렬적으로 빠르게 처리하는 방법(Latency 최적화), 쓰레드 풀을 활용해 한번에 HTTP 요청을 받는 방법(Throughput 최적화) 에 대해 100 줄 정도의 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;간단한&lt;/span&gt; 자바 코드를 활용해 설명한다. 이후에는 여러 쓰레드가 동시에 작업을 진행할 때 데이터 동기화를 어떻게 해야 하는지에 대해 여러 파트에 걸쳐 설명한다. 그 때 나오는 개념이 자바 내부에서 힙과 스택이 사용되는 방식, 세마포어, synchronized, &lt;span style=&quot;background-color: #ffffff; color: #2d2f31; text-align: left;&quot;&gt;ReentrantLock 등 개발할 때 직접적으로는 잘 사용하지 않지만 공부할 때는 한번쯤 사용했을 법한 문법이다. 그 후 최근들어 개발자들 사이에서 관심이 많아지고 있는 Blocking IO 와 Non-Blocking IO 에 대한 이야기를 하면서 Spring 에서 주로 사용하는&amp;nbsp; 톰켓 방식의 Thread per Task(Request) 모델, Netty 등에서 사용하는 방식인 Event Loop 에 대해서 설명해준다. 마지막으로는 자바 21 에서 최신으로 등장한 Virtual Thread 에 대한 개념을 간단한 예제와 함께 설명하고 강의는 끝이 난다.&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #2d2f31; text-align: left;&quot;&gt;강의를 들으면서 예전에 학교에서 OS 를 수강했을 때의 기억이 났다. OS 를 배울 때도 프로세스와 쓰레드 그리고 세마포어와 락 등을 활용하면서 어려워했던 기억이 있었다. 하지만 이 강의는 그 때의 강의와는 다르게 어려운 개념보다는 &lt;b&gt;쉬운 개념을 위주&lt;/b&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;강의 주제는 어려운 주제이지만 내용 자체가 어렵지 않기 때문에 병렬성과 동시성에 대해서 막연한 두려움을 가지고 있거나 한번 공부해보고 싶은 개발자가 있다면 수강하면 좋을 것 같다. 짧은 시간 안에 개념을 간단하게 짚고 넘어갈 수 있기 때문에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;자바 개발자가 아니더라도&lt;span&gt;&amp;nbsp;한 번쯤 수강해본다면&lt;/span&gt;&lt;/span&gt; 정말 좋은 강의라고 생각한다. 참고로 필자도 Typescript 위주로 개발하지만 괜찮다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;할인할때 구매하면 Udemy 강의는 합리적으로 구매할 수 있기 때문에 강의 수강에 대해 추천할 수 있을 것 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;해당 콘텐츠는 유데미로부터 강의 쿠폰을 제공받아 작성되었습니다.&lt;/blockquote&gt;</description>
      <category>리뷰/강의리뷰</category>
      <category>java</category>
      <category>리뷰</category>
      <category>백엔드</category>
      <category>온라인강의</category>
      <category>유데미</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/23</guid>
      <comments>https://pawmi.tistory.com/23#entry23comment</comments>
      <pubDate>Wed, 14 Feb 2024 21:38:54 +0900</pubDate>
    </item>
    <item>
      <title>분산락 추상화 시키기</title>
      <link>https://pawmi.tistory.com/22</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2935&quot; data-origin-height=&quot;1362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d0ybmk/btsEaNAXas6/uKkpFEXyq0l2Blh8D5N7Hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d0ybmk/btsEaNAXas6/uKkpFEXyq0l2Blh8D5N7Hk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d0ybmk/btsEaNAXas6/uKkpFEXyq0l2Blh8D5N7Hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd0ybmk%2FbtsEaNAXas6%2FuKkpFEXyq0l2Blh8D5N7Hk%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;737&quot; height=&quot;342&quot; data-origin-width=&quot;2935&quot; data-origin-height=&quot;1362&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;작년 초에 선착순 NFT 판매 이벤트를 개발해야 한 적이 있었다. 당시 선착순 이벤트의 경우 Redis 에서 분산락 알고리즘으로 제공하는 &lt;a href=&quot;https://redis.io/docs/manual/patterns/distributed-locks/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;redlock&lt;/a&gt; 을 활용해서 개발하는 경우가 대부분이였으며 정보도 많았었다. 당연히 필자 또한 빠르게 개발하기 위해 이를 활용했었다. 그러나 &lt;span style=&quot;color: #374151; text-align: start;&quot;&gt;이벤트가 종료된 후에는&lt;/span&gt; 선착순 판매가 아닌 단순히 상품(NFT)을 열어두고 판매하는 상황이 대부분이게 되었다. (사실 이벤트 때도 많은 인원수가 몰리지 않아 실망했던 기억이 있다.) &lt;span style=&quot;color: #374151; text-align: start;&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;요구사항 자체는 간단했다.&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기존 비즈니스 로직은 건드리지 않을 것&lt;/li&gt;
&lt;li&gt;언제든 선착순 이벤트로 바뀔 시 빠른 시간 내에 변경 가능하도록 설계할 것&lt;/li&gt;
&lt;/ol&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-ke-size=&quot;size16&quot;&gt;우선 기존에 사용하던 로직 자체를 추상화시킬 필요가 있었다.&amp;nbsp; 실제 회사에서 쓰는 코드를 가져올 수는 없으니 예시 코드를 한번 들여다 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1706524023877&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class OrderService {
    constructor(
        private readonly redlock: RedlockManager,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        const lock = await this.redlock.acquire(productId);
        try {
            // logics ...

        } finally {
            // lock 해제
            await this.redlock.release(lock);
        }
    }
}&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;이 때 RedLockManager 의 경우 Redis 의 lock 을 사용하기 위해 간단하게 만들어둔 모듈이라고 생각하면 된다. 이 때 redlock 의 구현체로는 &lt;a href=&quot;https://github.com/mike-marcacci/node-redlock&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;node-redlock&lt;/a&gt; 을 사용했다. 현재 로직에는 redis 가 직접적으로 관여하고 있기 때문에 이를 인터페이스로 바꿔서 수정하기로 했다. 이 때 고민스러웠던 부분은 Lock 이라는 형태로 추상화할건지, 특정 도메인에 포함된 채로 Lock 을 추상화할건지에 대한 것이였다. 결과론적으로 말하자면 Lock 도 어떻게 보면 &lt;b&gt;비즈니스 로직중에 하나&lt;/b&gt; 라는 생각이 들어 도메인에 포함된 Lock 이지만 어떤 인프라적인 요소를 사용할지만 추상화하기로 결정했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Lock 추상화 이후 변화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 위의 코드 중에 일단 Lock 사용 형태를 바꿀 필요가 있었다. 우선적으로 try ~ finally 를 외부로 빼는 작업을 하면 될 것 같다는 생각을 했다. 실제로 node-redlock 사용법 중에는 &lt;code&gt;using&lt;/code&gt; 메서드를 사용하면 비즈니스 로직을 lock 으로 wrapping 한 형태로 사용할 수 있다. 또한 회사 프로젝트 에서 Transaction 같은 부분도 비슷한 방식으로 명시적으로 사용하고 있어 이를 추상화 시키면 될 것 같다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 Lock 의 인터페이스 코드는 아래와 같은 형태로 개발했다.&lt;/p&gt;
&lt;pre id=&quot;code_1706525384719&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;abstract class IProductOrderLocker {
    abstract lock&amp;lt;T&amp;gt;(productId: string, work: () =&amp;gt; T | Promise&amp;lt;T&amp;gt;): Promise&amp;lt;T&amp;gt;;
}&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;그리고 이 구현체에서 직접 Redis 를 사용하도록 개발했으며 이는 Infra Layer 에 위치해서 Domain Layer 에는 영향을 끼치지 않도록 만들었다.&lt;/p&gt;
&lt;pre id=&quot;code_1706525636115&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class RedLockProductOrderLocker implements IProductOrderLocker {
    constructor(
        private readonly redlock: Redlock
    ) {
    }

    async lock&amp;lt;T&amp;gt;(productId: string, work: () =&amp;gt; (Promise&amp;lt;T&amp;gt; | T)): Promise&amp;lt;T&amp;gt; {
        const lock = await this.redlock.acquire(productId);
        try {
            return await work();
        } finally {
            await this.redlock.release(lock);
        }
    }
}&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;그러면 이제 이 Lock 은 추상화되 형태로 비즈니스 로직에서 직접 사용할 수 있게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1706525850463&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class OrderService {
    constructor(
        private readonly productOrderLocker: IProductOrderLocker,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        await this.productOrderLocker.lock(productId, async () =&amp;gt; {
            // logics ....
        });
    }
}&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;즉 이제 도메인에는 Redis 를 이용해 Lock 을 거는지, MySql 의 Lock 을 거는지 혹은 또다른 무언가를 이용해 Lock 을 거는지 모르는 상태가 되었으며 logic 의 형태 또한 변경하지 않고 그대로 사용할 수 있게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MySQL Lock 으로 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lock 자체를 추상화 했으므로 MySQL Lock 으로 변경하는건 단순히 인터페이스를 구현만 해주면 되었다. 이 또한 Infra Layer 에서 구현하면 된다. 하지만 MySQL 을 사용할 때 두 가지 고민이 있었는데 &lt;b&gt;Pessimistic Lock&lt;/b&gt; 을 활용할지 &lt;b&gt;Named Lock&lt;/b&gt; 을 활용할지에 대한 고민이였다. 결론적으로는 &lt;b&gt;Pessimistic Lock&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;을 활용하기로 했는데 그 이유는 현재 로직에서 product 의 경우 &lt;b&gt;항상 존재&lt;/b&gt; 하는 레코드이면서 unique 하게 되므로 정확히 한 개의 record 에만 lock 을 걸게되어 다른 이슈로 퍼질 위험이 적어지기 때문이다.&amp;nbsp; 물론 그렇지 않은 경우에는 NamedLock 을 활용해서 다시 구현체만 만들면 된다.&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 style=&quot;color: #333333; text-align: start;&quot;&gt;이를 구현한 코드는 Typeorm 을 활용하면 아래와 같이 구현할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1706527810799&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class MySqlProductOrderLocker implements IProductOrderLocker {
    constructor(
        private readonly manager: TypeOrmManager,
        private readonly tx: Transaction,
    ) {
    }

    async lock&amp;lt;T&amp;gt;(productId: string, work: () =&amp;gt; (Promise&amp;lt;T&amp;gt; | T)): Promise&amp;lt;T&amp;gt; {
        return await this.tx.withTransaction(async () =&amp;gt; {
            await this.manager.getEntityManager()
                .createQueryBuilder(ProductEntity, 'product')
                .select('product.id')
                .where(`product.id = :id`, { id: productId })
                .setLock('pessimistic_write')
                .getOne();

            return await work();
        });
    }
}&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;혹시나 tx 로 감싸지지 않은 경우에서는 Lock 이 제대로&amp;nbsp; 작동하지 않을 수 있으므로 transaction 을 열어서 사용하고 있는 것을 확인할 수 있다. 여기서 참고로 필자의 회사에서는 transaction in transaction 인 경우 하나의 transaction 으로 사용하도록 ITransaction 을 따로 구현해서 사용하고 있다.&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;b&gt;변화가 없게&lt;/b&gt;&amp;nbsp;된다.&lt;/p&gt;
&lt;pre id=&quot;code_1706528091829&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class OrderService {
    constructor(
        private readonly productOrderLocker: IProductOrderLocker,
    ) {
    }

    public async order(command: Command) {
        const { productId } = command;

        // lock 획득
        await this.productOrderLocker.lock(productId, async () =&amp;gt; {
            // logics ....
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Nest App 에서 알맞은 구현체 주입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Lock 이 두 가지 구현체를 가지게 되었으므로 단순히 Nest App 에서 사용할 인프라를 잘 정해주면 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1706528261334&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;providers: [
    // redis 를 사용할 경우
    {
        provide: IProductOrderLocker,
        inject: [Redlock],
        useFactory: (redlock: Redlock) =&amp;gt; new RedLockProductOrderLocker(redlock),
    },

    // mysql 를 사용할 경우
    {
        provide: IProductOrderLocker,
        inject: [TypeOrmManager, TypeOrmTransaction],
        useFactory: (manager: TypeOrmManager, transaction: Transaction) =&amp;gt; new MySqlProductOrderLocker(manager, transaction),
    },
]&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;이제 구현이 모두 완료되었으므로 평상시에는 MySql 을 활용해서 Lock 을 이용하다가 특별한 이벤트가 생기면 Redis 를 올리고 단순히 Lock 형태만 Redis 를 활용한 Lock 으로 바꿔껴 주기만 하면 되므로 앞으로 여러 상황에 빠르게 대비할 수 있게 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이 파트는 이전에 작성한 &lt;a href=&quot;https://pawmi.tistory.com/20&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Repository 추상화&lt;/a&gt; 의 주제와 비슷한 맥락을 하고 있다. 결국 도메인에 인프라가 침투하는 것을 막아 변화에 빠르게 대응할 수 있는 코드를 작성하는 것이다. 초반 Redis 를 활용해 개발했을 때 아무래도 실무에서 Redis lock 을 처음 사용하다보니 이런 부분을 놓치고 있었다는 것을 알고 많이 반성하는 계기가 되기도 했다. Redis 를 사용하는 분산락 자체가 요즘에는 어려운 개념이 아니게 되었지만 작은 회사에서 굳이 Redis 를 사용할 필요가 없다고 느껴 이런 방법으로 개발해서 추후 Redis 를 써도 대응할 수 있도록 하는 것이 좋은 개발이 아닐까 하는 생각도 들었다.&lt;/p&gt;</description>
      <category>개발/js &amp;amp; ts &amp;amp; node.js</category>
      <category>mysql</category>
      <category>Redis</category>
      <category>TypeScript</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/22</guid>
      <comments>https://pawmi.tistory.com/22#entry22comment</comments>
      <pubDate>Mon, 29 Jan 2024 20:56:00 +0900</pubDate>
    </item>
    <item>
      <title>Chai 예외 검증 커스터마이징 (with Mocha)</title>
      <link>https://pawmi.tistory.com/21</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;68747470733a2f2f7777772e706172616469676d616469676974616c2e636f6d2f77702d636f6e74656e742f75706c6f6164732f323031372f30322f312e706e67.png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OVEPt/btsDGqgvN3o/NcjyPPGfQDzwYG109kXeK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OVEPt/btsDGqgvN3o/NcjyPPGfQDzwYG109kXeK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OVEPt/btsDGqgvN3o/NcjyPPGfQDzwYG109kXeK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOVEPt%2FbtsDGqgvN3o%2FNcjyPPGfQDzwYG109kXeK1%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;617&quot; height=&quot;217&quot; data-filename=&quot;68747470733a2f2f7777772e706172616469676d616469676974616c2e636f6d2f77702d636f6e74656e742f75706c6f6164732f323031372f30322f312e706e67.png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;217&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;size16&quot;&gt;이 포스팅에서는 커스텀한 에러를 던지고 있으며 그 형태는 아래와 같다고 가정하자.&lt;/p&gt;
&lt;pre id=&quot;code_1705832960289&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class CustomError extends Error {
    name: string;
    message: string;
    code: string;
    stack?: string;
    
    constructor(code: string, message: string, name?: string, stack?: string) {
        super(message);
        this.name = name || code;
        this.message = message;
        this.code = code;
        if (stack) {
            this.stack = stack;
        } else {
            Error.captureStackTrace(this, this.constructor);
        }
    }
}

export const isCustomError = (error: any): error is CustomError =&amp;gt; error &amp;amp;&amp;amp; error.name &amp;amp;&amp;amp; error.message &amp;amp;&amp;amp; error.code;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Chai 예외 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chai 에는 기본적인 예외케이스를 테스트 할 수 있도록 &lt;code&gt;throw()&lt;/code&gt; 메서드를 제공하고 있으며 추가적으로 에러를 커스텀 했으므로 내부에 code 가 존재하는지 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1705753590023&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', () =&amp;gt; {
    // given
    const sut = Wallet.create(/* ... */);
    
    // when
    // then
    expect(() =&amp;gt; sut.withdraw(amount))
        .throw()
        .which.has.deep.include({
            code: 'ERROR_023',
            message: 'withdrawal amount is more than total amount',
        });
});&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;하지만 Promise 예외인 경우에는 어떻게 테스트 할 수 있을까 고민해볼 필요가 있다. 실제로 typescript 테스트를 하면 동기 테스트도 많이 하지만 비동기 테스트할 일이 정말 많이 생긴다. 다만 아쉽지만 Promise 예외의 경우 chai 에서 기본적으로는 제공하지 않아 플러그인을 활용해야 한다. 필자의 경우 처음에는 &lt;a href=&quot;https://github.com/domenic/chai-as-promised&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;chai-as-promised&lt;/a&gt; 라는 chai 플러그인을 활용해 비동기 함수의 예외케이스를 테스트해 왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 함수 예외 또한 에러를 커스텀 했으므로 내부에 code 가 존재하는지 직접 확인해야 할 필요가 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1705833641260&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import * as chai from 'chai';
import * as chaiPromise from 'chai-as-promised';
chai.use(chaiPromise);

it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', async () =&amp;gt; {
    // given
    const sut = WithdrawCommandHandler(/* constructor */);
    
    // when
    // then
    await expect(sut.handle(command)).to.eventually.be.rejected.which.has.deep.include({
    	code: 'ERROR_023',
        message: 'withdrawal amount is more than total amount',
    });
});&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;최근까지는 위와 같은 방식으로 커스텀한 에러를 테스트해 왔다. 하지만 chai-as-promised 라이브러리는 마지막 커밋이 2017 년으로 7년 이상 방치된 플러그인이라는 문제가 존재했다. 그 뿐만 아니라 개인적으로 위의 코드를 보면 커스텀한 에러를 파악하기 위해서 여러가지 메서드 체이닝을 써야 해서 가독성이 부족한 부분이 존재한다고 생각했다.&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;헬퍼 함수를 만들기 전 chai plugin 형태로 만들지, 단순 함수로 만들지 고민했었다. 하지만 plugin 형태로 만들고 관리하는게 더 어려울 것이라고 생각이 들어 헬퍼 함수를 만들어서 사용했다.&amp;nbsp; 초반에 만든 헬퍼 함수는 아래와 같다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1705834277358&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 함수 실행 시 catch 에서 에러를 잡아 리턴하는 형태
export function actCustomErrorThrow(action: () =&amp;gt; any): CustomError {
    try {
        action();
    } catch (error: any) {
        if (isCustomError(error)) {
            return error;
        }
    }

    throw new Error('Unknown Error');
}&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_1705834519923&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', () =&amp;gt; {
    // given
    const sut = Wallet.create(/* ... */);
    
    // when
    const actual = actCustomErrorThrow(
    	() =&amp;gt; sut.withdraw(amount)
    );
    
    // then
    expect(actual.code).to.equal('ERROR_023');
    expect(actual.message).to.equal('withdrawal amount is more than total amount');
});&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;확실히 위처럼 테스트 코드를 작성하면 Given-When-Then 패턴에 맞도록 테스트 코드 가독성이 올라갈 수 있다. 현재는 동기 함수 테스트 코드를 리펙터링 했지만 비동기 함수 리펙터링을 한 코드를 보면 더 깔끔하고 가독성 있는 코드가 되었음을 파악할 수 있다. 헬퍼함수의 경우 Promise 를 받도록 수정만 하고 Async 라는 네이밍만 붙혔기 때문에 생략한다.&lt;/p&gt;
&lt;pre id=&quot;code_1705834788917&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', async () =&amp;gt; {
    // given
    const sut = WithdrawCommandHandler(/* constructor */);
    
    // when
    const actual = await actAsyncCustomErrorThrow(
    	() =&amp;gt; sut.handle(command)
    );
    
    // then
    expect(actual.code).to.equal('ERROR_023');
    expect(actual.message).to.equal('withdrawal amount is more than total amount');
});&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;.to.eventually.be.rejected.which.has.deep.include&quot; 의 미친듯한 메서드 체이닝을 하지 않고 깔끔하게 equal 로만 상태 테스트를 진행할 수 있다는 점, chai-as-promised 라는 오래되고 커밋도 없는 라이브러리 의존성이 사라져서 마음 한 편의 짐을 덜을 수 있다는 점, Given-When-Then 패턴을 깔끔하게 사용할 수 있다는 점 등 이전 코드보다 훨씬 가독성 있는 테스트 코드가 만들어 졋다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Try Catch 를 활용한 헬퍼 사용시 주의점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Custom Error 헬퍼에는 한가지 문제점이 존재한다. 이에 대한 설명은 아래 블로그로 대신한다.&lt;/p&gt;
&lt;figure id=&quot;og_1705835125159&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Jest로 Error 검증시 catch 보다는 expect&quot; data-og-description=&quot;Jest를 통한 테스트를 작성하다보면 Exception에 대한 검증을 작성해야할 때가 있다. 이럴때 보통 2가지 방법 중 하나를 선택한다. try ~ catch expect.rejects.toThrowError 실제 코드로는 다음과 같다. // try ~ c&quot; data-og-host=&quot;jojoldu.tistory.com&quot; data-og-source-url=&quot;https://jojoldu.tistory.com/656&quot; data-og-url=&quot;https://jojoldu.tistory.com/656&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bUu2vj/hyU8V9BFNJ/g85BcKkFZ0KkqiYyZcYi91/img.png?width=800&amp;amp;height=239&amp;amp;face=0_0_800_239,https://scrap.kakaocdn.net/dn/c272dE/hyU8UpkHNH/DmXGkX1sSSMsxegFg3fyuK/img.png?width=800&amp;amp;height=239&amp;amp;face=0_0_800_239,https://scrap.kakaocdn.net/dn/bwXh6L/hyU8RlOYdQ/VD0KkrYkrJTi7uf5QWlkyK/img.png?width=1561&amp;amp;height=775&amp;amp;face=0_0_1561_775&quot;&gt;&lt;a href=&quot;https://jojoldu.tistory.com/656&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://jojoldu.tistory.com/656&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bUu2vj/hyU8V9BFNJ/g85BcKkFZ0KkqiYyZcYi91/img.png?width=800&amp;amp;height=239&amp;amp;face=0_0_800_239,https://scrap.kakaocdn.net/dn/c272dE/hyU8UpkHNH/DmXGkX1sSSMsxegFg3fyuK/img.png?width=800&amp;amp;height=239&amp;amp;face=0_0_800_239,https://scrap.kakaocdn.net/dn/bwXh6L/hyU8RlOYdQ/VD0KkrYkrJTi7uf5QWlkyK/img.png?width=1561&amp;amp;height=775&amp;amp;face=0_0_1561_775');&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;Jest로 Error 검증시 catch 보다는 expect&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Jest를 통한 테스트를 작성하다보면 Exception에 대한 검증을 작성해야할 때가 있다. 이럴때 보통 2가지 방법 중 하나를 선택한다. try ~ catch expect.rejects.toThrowError 실제 코드로는 다음과 같다. // try ~ c&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;jojoldu.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mocha 대신 Jest 를 사용했지만 위의 에러 헬퍼 함수를 활용하면 동일한 문제가 발생할 수 있다. 가장 큰 문제는 아마 커스텀 에러가 아니라 다른 에러 발생시 제대로 된 에러를 판단하기 어렵다는게 아닐까 싶다.&amp;nbsp;그래서 에러 헬퍼 함수를 테스트 코드에서만 사용하는 에러를 하나 만들어서 stack 과 에러가 제대로 찍히도록 만들었다.&lt;/p&gt;
&lt;pre id=&quot;code_1705835359372&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class UnknownTestError extends Error {
    constructor(
        name: string,
        message: string,
        stack?: string,
    ) {
        super(message);
        this.name = name;
        this.message = message;
        if (stack) {
            this.stack = stack;
        } else {
            Error.captureStackTrace(this, this.constructor);
        }
    }
}

export function actCustomErrorThrow(action: () =&amp;gt; any): CustomError {
    try {
        action();
    } catch (error: any) {
        if (isCustomError(error)) {
            return error;
        }
        
        // 예상한 에러가 아니라면 에러를 던지되 name, message, stack 을 덮어씌운다.
        throw new UnknownTestError(
            error.name,
            error.message,
            error.stack,
        );
    }
    
    // action 이 제대로 실행되었다면 에러를 예상했지만 잘 실행된 것이므로 에러를 다시 던진다.
    throw new Error('error was not thrown');
}&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;h3 data-ke-size=&quot;size23&quot;&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;nbsp;&lt;/p&gt;</description>
      <category>개발/js &amp;amp; ts &amp;amp; node.js</category>
      <category>chai</category>
      <category>Mocha</category>
      <category>TypeScript</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/21</guid>
      <comments>https://pawmi.tistory.com/21#entry21comment</comments>
      <pubDate>Sun, 21 Jan 2024 20:18:05 +0900</pubDate>
    </item>
    <item>
      <title>Repository 의 추상화</title>
      <link>https://pawmi.tistory.com/20</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;layers.png&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;363&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4fuqW/btsCxcwFF9p/V6pkkPfglZk6KIpwgNByQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4fuqW/btsCxcwFF9p/V6pkkPfglZk6KIpwgNByQ1/img.png&quot; data-alt=&quot;Repository Design Pattern &amp;amp;amp;mdash;&amp;amp;amp;nbsp; https://codingsight.com/entity-framework-antipattern-repository/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4fuqW/btsCxcwFF9p/V6pkkPfglZk6KIpwgNByQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4fuqW%2FbtsCxcwFF9p%2FV6pkkPfglZk6KIpwgNByQ1%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;1128&quot; height=&quot;363&quot; data-filename=&quot;layers.png&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;363&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Repository Design Pattern &amp;amp;mdash;&amp;amp;nbsp; https://codingsight.com/entity-framework-antipattern-repository/&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;이번 포스팅에서는 조금 오래된 개념인 Repository 패턴, 그 중에서 추상화에 대한 이야기를 해보고자 한다. 원래 이전부터 쓸까 했지만 이제는 많은 개발자들이 대부분 이 개념을 인지하고 있다고 생각해서 건너뛰었었다. 하지만 최근에 어떤 개발자와 이야기 할 때 아래와 같은 대화를 나눈적이 있었다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 96.5107%; height: 44px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 36px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 36px;&quot;&gt;A&amp;nbsp; &amp;nbsp; &amp;nbsp; : Nest에서 TypeOrm 0.2 에서 TypeOrm 0.3 으로 마이그레이션하기 어려워요.&lt;br /&gt;필자&amp;nbsp; : 아 connection 이 datasource 로 바뀌어서 조금 달라지긴 했더라고요.&lt;br /&gt;A&amp;nbsp; &amp;nbsp; &amp;nbsp; : 특히나 TypeOrm 0.2 @EntityRepository 가 삭제되어서 마이그레이션 할 때 고려할 부분이 많아요.&lt;br /&gt;필자&amp;nbsp; : 혹시 TypeOrm 에서 제공하는 Repository 를 도메인에서 직접 사용해서 그런건가요?&lt;br /&gt;A&amp;nbsp; &amp;nbsp; &amp;nbsp; : 네 일반적으로 사용하는 방식처럼 사용하죠.&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;실제로 여러 블로그들을 찾아봐도 Repository 패턴을 이야기 할때 &quot;DB 를 추상화&quot;해서 쓰기 위해 사용한다고 한다. 하지만 &quot;Typeorm EntityRepository&quot; 라고 구글에 검색만 해봐도 도메인이 TypeOrm 에 어마무시하게 종속적이도록 개발하고 있는걸 확인할 수 있다. 또한 이는 Nest.js 를 쓰는 개발자뿐만 아니라 Spring 으로 가면 더하면 더했지 덜하지는 않다. 만약 본인이 &quot;spring-data-jpa&quot; 를 사용해서 JpaRepository 를 도메인에 사용하고 있다면 동일한 문제를 발생시키고 있다고 생각하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 Repository 를 추상화 시키지 않고 그대로 사용하는 것이 잘못된 개발이라는 뜻은 아니다. 실제로 여러 실력있는 개발자들은 추상화 시키지 않아도 잘 개발하고 오히려 KISS 법칙을 지켜야 한다고 한다. 과한 추상화는 독약이라고 말하는 것이다. 즉, 필자가 말하고자 하는 부분도 &quot;잘못되었다&quot;가 아니라 &quot;Repository 패턴을 사용해서 DB 레이어를 추상화 시켰다&quot; 라고 부 르는 것이 애매하다는 것이다. (JPA, TypeORM &amp;asymp;&amp;nbsp; DB 라고 생각하기 때문)&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;그러면 필자는 Repository 패턴을 어떻게 사용하고 있을까 하면 도메인의 Repository 자체는 interface 그 이상 그 이하도 아니다. 특별히 외부 라이브러리에 종속적이지도 않으며 이 Repository 의 구현은 Infra layer 에서 상속받아 구현한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;종속적인 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 일반적으로 사용하는 TypeOrm 에 종속적인 Repository 부터 생각해보자. 이 포스팅에서는 0.3 을 사용하고 있으므로 참고 바란다.&lt;/p&gt;
&lt;pre id=&quot;code_1703328518941&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { DataSource, Repository } from 'typeorm';
import { ChatRoom } from '../entity/ChatRoom';

export class ChatRoomRepository extends Repository&amp;lt;ChatRoom&amp;gt;{
    constructor(dataSource: DataSource) {
        super(ChatRoom, dataSource.createEntityManager());
    }
}&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;그리고 이 Repository 를 Service 에서 inject 받아서 처리한다.&lt;/p&gt;
&lt;pre id=&quot;code_1703328574261&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class ChatRoomService {
    constructor(private readonly chatRoomRepository: ChatRoomRepository) {}

    async createRandomRoom(dto: { ownerId: string }): Promise&amp;lt;string&amp;gt; {
        const roomId = v4().toString();
        await this.chatRoomRepository.insert({
            id: roomId,
            title: faker.random.alpha(30),
            ownerId: dto.ownerId,
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        return roomId
    }
}&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;이때 위 코드는 TypeOrm Repository 에 지정되어 있는 insert 를 직접 사용하고 있지만 아래처럼 도메인 모델 패턴도 많이 활용한다.&lt;/p&gt;
&lt;pre id=&quot;code_1703328705487&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class ChatRoomService {
    constructor(private readonly chatRoomRepository: ChatRoomRepository) {}

    async createRandomRoom(dto: { ownerId: string }): Promise&amp;lt;string&amp;gt; {
        // room 을 생성하고 저장
        const room = createChatRoom({
            id: v4().toString(),
            title: faker.random.alpha(30),
            owner: createUser({
                id: dto.ownerId,
                name: faker.name.fullName(),
                profileUrl: faker.image.imageUrl(),
            }),
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        await this.chatRoomRepository.save(room);

        return room.id;
    }
}&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;위의 코드는 생각한대로 잘 수행된다. 하지만 이 때 만약 TypeOrm 이 0.4 로 마이그레이션 되면서 typeorm 의 Repository 가 deprecated 되고 datasource 에서 직접 사용해야 하도록 스펙이 변경되었다고 가정하자. 그렇게 된다면 Service 레이어에서 사용하는 모든 Repository 코드들을 찾아 다시 바꿔줘야 하는 작업을 수행해야 한다. 즉, 이번에 이야기 나눈 0.2-&amp;gt;0.3 으로 업데이트 될 때 발생한 경우와 비슷하다는 이야기이다. 혹은 극단적으로 이번에는 TypeOrm 이 갑자기 개발 중단을 선언했다고 가정해보자. 그렇게 되면 ORM 자체를 Prisma 등으로 바꾸는 작업을 진행하면서 정말 많은 도메인 로직들을 하나하나 바꿔야 한다. 그렇기 때문에 Repository 를 다른 프레임워크에 독립적으로 작성할 필요가 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Repository 추상화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository 를 추상화 하는 방법은 간단하다. 특히나 TypeOrm 같이 특정 프레임워크에 종속적이지 않도록 개발하도록 되어 있다면 더 쉽다. 설명하기 전에 코드부터 보자&lt;/p&gt;
&lt;pre id=&quot;code_1703330745316&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Domain Layer

export interface ChatRoomRepository {
    create(room: ChatRoom): Promise&amp;lt;void&amp;gt;;
    update(room: ChatRoom): Promise&amp;lt;void&amp;gt;;
}

export class ChatRoomService {
    constructor(
        private readonly chatRoomRepository: ChatRoomRepository
    ) {}

    async createRandomRoom(dto: { ownerId: string }): Promise&amp;lt;string&amp;gt; {
        const room = createChatRoom({
            id: v4().toString(),
            title: faker.random.alpha(30),
            owner: createUser({
                id: dto.ownerId,
                name: faker.name.fullName(),
                profileUrl: faker.image.imageUrl(),
            }),
            description: faker.random.alpha(200),
            lastMessage: faker.random.alpha(200),
        });
        await this.chatRoomRepository.create(room);

        return roomId;
    }
}&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;이제 위 코드는 TypeOrm 의 종속성에서 벗어났으면 Repository 는 단순 interface 로 infra layer 에서는 이를 상속받아 구현해주면 된다. 가장 간단하게 만든 infra 구현코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1703330810769&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class TypeOrmChatRoomRepository implements ChatRoomRepository {
    constructor(private readonly dataSource: DataSource) {}

    get repository(): Repository&amp;lt;ChatRoomDataEntity&amp;gt; {
        return this.dataSource.getRepository(ChatRoomDataEntity);
    }

    // 구현
    async create(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        await this.repository.insert(plainToInstance(ChatRoomDataEntity, room));
    }

    // 구현
    async update(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        await this.repository.save(plainToInstance(ChatRoomDataEntity, room));
    }
}&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;즉 domain layer 에서는 TypeOrm 의 Repsitory 는 사용하지 않았고 infra Layer 에서 TypeOrm 의 Repository 를 사용하는 코드로 바뀌었다. 만약 앞에서 가정한 같은 이슈(TypeOrm Repository 가 deprecated 됨) 가 발생하면 어떻게 변경하면 될까? 다들 알겠지만 &lt;code&gt;TypeOrmChatRoomRepository&lt;/code&gt; 의 코드만 변경해주면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1703331207402&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class TypeOrmChatRoomRepository implements ChatRoomRepository {
    constructor(private readonly dataSource: DataSource) {}

    async create(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        await this.dataSource.manager.insert(ChatRoomDataEntity, plainToInstance(ChatRoomDataEntity, room));
    }

    async update(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        await this.dataSource.manager.save(ChatRoomDataEntity, plainToInstance(ChatRoomDataEntity, room));
    }
}&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;코드를 확인해보면 알겠지만 도메인 로직은 전혀 건드리지 않고 InfraLayer 에 있는 typeorm 의 repository 를 사용하지 않도록 변경했다. TypeOrm 대신 다른 Orm 을 쓰고 싶다면 어떨까? 이 또한 Repository 구현부만 변경해주면 된다. 즉, Repository 를 추상화 시킴으로써 도메인은 외부의 환경(DB, ORM 등) 에서 독립적으로 작동할 수 있도록 설계되었다는 점이다.&lt;/p&gt;
&lt;pre id=&quot;code_1703331583954&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class PrismaChatRoomRepository implements ChatRoomRepository {
    constructor(prismaClient: PrismaClient) {
    }

    async create(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        // 구현
    }

    async update(room: ChatRoom): Promise&amp;lt;void&amp;gt; {
        // 구현
    }
}&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;현재 예시를 필자가 가장 많이 사용하는 Typescript 예시로 들었지만 이것이 JAVA/Spring 으로 가더라도 동일한 설계 이론을 적용할 수 있다. &quot;JPA Repository 를 직접 inject 받아 쓰지 말고 추상화&quot; 하면 된다. 예를 들면 아래와 같은 형식이 될 것 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1703332556964&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Domain Layer
public interface ChatRoomRepository {
    void create(ChatRoom room);
}

// Infra Layer
@Repository
public class JpaChatRoomRepository extends SimpleJpaRepository&amp;lt;ChatRoom, Long&amp;gt; implements ChatRoomRepository
    @Override
    public void create(ChatRoom room) {
        super.save(room);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository 추상화를 하는 것이 무조건적으로 옳다고 할 수는 없다. 특히나 빠르게 개발해야 하는데 Repository 를 추상화해서 개발하게 되면 개발 속도가 현저히 줄어들 가능성도 생긴다. 하지만 필자의 경우에도 나름 최근에 TypeOrm 0.2 에서 0.3 으로 마이그레이션 했을 때 매우 손쉽게 진행한 경험도 있고 해서 개인적으로는 더 선호하는 개발 방식이다. 이 블로그를 보고 나서 &quot;아 Repository 는 무조건적으로 추상화해야 하는 구나&quot; 라고 생각하는 것이 아니라 내가 개발하는 앱이 &quot;Repository 를 추상화 했을 때 얻을 수 있는 이득이 얼마나 있을까&quot; 를 한번쯤 고민해봤으면 좋겠다.&lt;/p&gt;</description>
      <category>개발/js &amp;amp; ts &amp;amp; node.js</category>
      <category>nestjs</category>
      <category>typeorm</category>
      <category>TypeScript</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/20</guid>
      <comments>https://pawmi.tistory.com/20#entry20comment</comments>
      <pubDate>Sat, 23 Dec 2023 21:15:39 +0900</pubDate>
    </item>
    <item>
      <title>[MySQL] 커서 기반 페이지네이션 처리</title>
      <link>https://pawmi.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드를 개발하다보면 가장 흔하게 처리해야 하는 부분중 하나가 페이지네이션 부분이다. 그 중에서도 요즘에는 커서 기반의 페이지네이션 처리를 많이 수행하며 단순히 sequence 로만 처리하는 것이 아니라 여러 조건하에서 정렬할 필요가 있다. 그 때  커서 페이지네이션 처리를 하다보면 중복된 커서 데이터에 의해 특정 레코드를 건너띄는 경우가 자주 발생하며 이를 처리할 필요가 있다. 이번 포스팅에서는 이런 문제에 대한 예시와 해결방법들에 대해서 정리해볼 생각이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;들어가기 전에&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;들어가기 전에 테스트를 할 테이블을 정의했다.  상품이라는 테이블이 있으며 &lt;code&gt;seq&lt;/code&gt; 를 PK 로 가지고 있으며 &lt;code&gt;price&lt;/code&gt; 와 &lt;code&gt;created_at&lt;/code&gt; 을 인덱스로 가지면서 이를 활용해 상품을 최신순 혹은 가격순으로 정렬하고자 한다. 또한 테스트를 위해 대략 500만개 레코드를 임의로 insert 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-12-10 오전 1.41.24.png&quot; data-origin-width=&quot;758&quot; data-origin-height=&quot;682&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RzVDi/btsBKjBAI2G/jhy4XuF6CPcOxTimOYcnAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RzVDi/btsBKjBAI2G/jhy4XuF6CPcOxTimOYcnAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RzVDi/btsBKjBAI2G/jhy4XuF6CPcOxTimOYcnAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRzVDi%2FbtsBKjBAI2G%2Fjhy4XuF6CPcOxTimOYcnAK%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;409&quot; height=&quot;368&quot; data-filename=&quot;스크린샷 2023-12-10 오전 1.41.24.png&quot; data-origin-width=&quot;758&quot; data-origin-height=&quot;682&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커서 기반 페이지네이션 (PK 기준 정렬)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커서 기반 페이지네이션은 아마 대부분은 어떻게 사용하는지 알 것으로 예상된다. 그래서 자세히 정리하지 않고 간단한 사용법 위주로만 정리했다. 페이지네이션을 위해 선택할 수 있는 가장 쉬운 칼럼은 당연히 PK 인 seq 일 것이다. 이 seq 기반으로 정렬할 예정이고 id = '7b55815a-96b3-11ee-912f-1234567890a' 를 커서로 가지고 50개의 레코드를 가져오는 내림차순정렬을 한다고 해보자.&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;id = '7b55815a-96b3-11ee-912f-1234567890' a 인 seq 찾기&lt;/li&gt;
&lt;li&gt;찾은 seq 보다 낮은 레코드 중 50 개를 select 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 SQL 쿼리로 작성하면 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1702143275389&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM blog_product product
WHERE product.seq &amp;lt; (
    SELECT last_product.seq 
    FROM blog_product last_product 
    WHERE last_product.id = '7b55815a-96b3-11ee-912f-1234567890a'
)
ORDER BY product.seq DESC
LIMIT 50
;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 나올 이야기에서도 기본적인 페이지네이션 뼈대는 위의 SQL 코드와 동일하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커서 기반 페이지네이션 (PK 외 기준 정렬)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히는 Unique Key 가 아니라 그 외적인 칼럼을 기준으로 테이블을 정렬해서 나타내야 할 때를 의미한다. 기본적인 코드는 위에서 본 것과 마찬가지로 동일하게 작성할 수 있으면 '가격순' 으로 정렬한다고 가정해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1702143836683&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM blog_product product
WHERE product.price &amp;lt; (
    SELECT last_product.price
    FROM blog_product last_product
    WHERE last_product.id = '7b55815a-96b3-11ee-912f-1234567890a'
)
ORDER BY product.price DESC
LIMIT 50
;&lt;/code&gt;&lt;/pre&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;우선 cursor 다음은 어떤 레코드가 나와야 하는지 확인하면 id = 'a51fd392-96b2-11ee-912f-1234567890a' 인 레코드부터 차례로 나와야 하며 이 레코드는 price = 1473 을 가지고 있다.&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-filename=&quot;스크린샷 2023-12-10 오전 2.49.25.png&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7CRFs/btsBG365XDb/6lXSCGqbagTGLK5oaEZNh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7CRFs/btsBG365XDb/6lXSCGqbagTGLK5oaEZNh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7CRFs/btsBG365XDb/6lXSCGqbagTGLK5oaEZNh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7CRFs%2FbtsBG365XDb%2F6lXSCGqbagTGLK5oaEZNh0%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;1612&quot; height=&quot;430&quot; data-filename=&quot;스크린샷 2023-12-10 오전 2.49.25.png&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;430&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 위에서 작성한 SQL 쿼리를 실행해보면 price 가 1472 인 레코드부터 차례로 나오게 된다. 실제로 쿼리 자체가 커서의 price = 1473 이므로 &lt;code&gt;product.price &amp;lt; 1473&lt;/code&gt; 란 조건에 의해 price 가 1472 인 레코드 부터 나오는 것이다.&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-filename=&quot;스크린샷 2023-12-10 오전 2.46.16.png&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;364&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/POXtk/btsBFFFXZeH/9iu8joW7zpQk0hkanpRTz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/POXtk/btsBFFFXZeH/9iu8joW7zpQk0hkanpRTz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/POXtk/btsBFFFXZeH/9iu8joW7zpQk0hkanpRTz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPOXtk%2FbtsBFFFXZeH%2F9iu8joW7zpQk0hkanpRTz1%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;1458&quot; height=&quot;364&quot; data-filename=&quot;스크린샷 2023-12-10 오전 2.46.16.png&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;364&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, PK 를 기준으로 페이지네이션을 한 경우 Unique 한 값이기 때문에 딱 그 레코드 뒤부터 검색이 되지만 Unique 하지 않은 컬럼을 기준으로 페이지네이션을 한 경우 중복된 값에 의해 건너뛸 수 있게 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결방안 1 - custom cursor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 페이지네이션 해결방안으로 가장 많이 나오는 방법 중 하나가 unqiue 한 custom cursor 를 만들어 이 커서 기반으로 정렬시키는 것이다. 위에서 일단 price 기준으로 정렬하기 위해서는 price 가 나와서 정렬할 수 있도록 하는 unique 한 커서값이 나오게 만들면 된다. 현재 테이블을 보면 seq 혹은 id 가 unique 하지만 seq 가 선택하기 쉬우므로 이를 활용하도록 하고 seq 와 price 는 현재 테이블에서 10자리를 넘기기 어려우므로 이를 활용해 &lt;code&gt;CONCAT(LPAD(price, 10, 0), &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;LPAD(seq, 10, 0))&lt;/span&gt;&lt;/code&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt; 를 커서로 갖도록 하면 중복된 레코드를 건너뛰지 않고 페이지네이션을 구현할 수 있다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;id = '7b55815a-96b3-11ee-912f-1234567890'&lt;span&gt;&amp;nbsp; 인 레코드의 seq 는 5249026 이므로 개발중의 커서는 위의 공식에 의해 &lt;code&gt;00000014730005249026&lt;/code&gt;&amp;nbsp;이 나오므로 이 커서를 활용해 페이지네이션을 진행하면 된다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1702198926762&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM blog_product product
WHERE CONCAT(LPAD(product.price, 10, '0'), LPAD(product.seq, 10, '0')) &amp;lt; '00000014730005249026'
ORDER BY product.price DESC, product.seq DESC
LIMIT 50
;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행해보면 결과 자체는 잘 나온다. 하지만 이 커스텀 커서에는 큰 문제점이 하나 존재하는 데 &quot;Where 절에 Function 을 사용하면 인덱스를 타지 못한다&quot; 라는 점이다.&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-filename=&quot;스크린샷 2023-12-10 오후 6.27.53.png&quot; data-origin-width=&quot;1718&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eb8dmj/btsBDMeHCTZ/cXBPWoio5vNazkXfKVu0hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eb8dmj/btsBDMeHCTZ/cXBPWoio5vNazkXfKVu0hk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eb8dmj/btsBDMeHCTZ/cXBPWoio5vNazkXfKVu0hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feb8dmj%2FbtsBDMeHCTZ%2FcXBPWoio5vNazkXfKVu0hk%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;1718&quot; height=&quot;286&quot; data-filename=&quot;스크린샷 2023-12-10 오후 6.27.53.png&quot; data-origin-width=&quot;1718&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 쿼리를 돌려보면 500 만개의 레코드 밖에 없는데도 대략 2분의 시간이 걸린다는 것을 알 수 있다. 그렇기 때문에 커스텀 커서를 사용해서 페이지네이션을 한다면 실제로 쿼리를 돌려보고 성능을 체크한 다음 사용해보길 권하고 싶다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결방안 2 -  custom cursor 를 새로운 컬럼으로 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결방안 1 의 가장 큰 문제점은 인덱스를 타지 못하게 cursor 를 지정했다는 점이다. 그러면 저 함수 부분을 처음부터 컬럼에 지정해서 집어 넣을 수도 있다. 물론 새로운 컬럼이 생기는 문제는 생기지만 Where 절에 함수를 사용하지 않으므로 cursor 기반으로 빠른 검색이 가능할 것이다. 아래는 실제로 값을 집어넣고 계산을 해 본 결과이다. 확실히 인덱스를 타니 시간이 50ms 정도로 확 줄어든 것을 확인할 수 있다.&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-filename=&quot;스크린샷 2023-12-10 오후 6.28.27.png&quot; data-origin-width=&quot;1706&quot; data-origin-height=&quot;290&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rLflO/btsBGwIFqv2/GNhURrpZzfcJ0wlhZY1Iik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rLflO/btsBGwIFqv2/GNhURrpZzfcJ0wlhZY1Iik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rLflO/btsBGwIFqv2/GNhURrpZzfcJ0wlhZY1Iik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrLflO%2FbtsBGwIFqv2%2FGNhURrpZzfcJ0wlhZY1Iik%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;1706&quot; height=&quot;290&quot; data-filename=&quot;스크린샷 2023-12-10 오후 6.28.27.png&quot; data-origin-width=&quot;1706&quot; data-origin-height=&quot;290&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결방안 3 - PK 와 OR 절 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 해결방안 2 의 경우 새로운 컬럼을 단순히 조회를 위해서 추가해야 한다는 문제점을 확인할 수 있다. 만약 위에서 created_at 을 기준으로 페이지네이션 하고 싶을 수도 있고 price 를 기준으로 페이지네이션을 해야 할 수도 있다. 그럴때마다 새로운 컬럼을 추가하는 것은 큰 비용이 들며 추후에 필요 없어진 경우에는 의미 없는 컬럼이 존재하는 문제점을 지니고 있을지도 모른다.&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 사용중에는 이 데이터베이스의 인덱스 특징을 이용하면서 개발을 자주 한다. MySQL 의 경우 간단하게 정리해보면 &quot;인덱스를 탈때 마지막에는 항상 PK 를 사용해서 조회&quot; 하는 특징이 사용된다. 아래처럼 간략하게만 정리해 보았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-12-10 오후 6.39.12.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4FdC2/btsBF13gLg2/osrU92UND0xQPhvldpZQK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4FdC2/btsBF13gLg2/osrU92UND0xQPhvldpZQK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4FdC2/btsBF13gLg2/osrU92UND0xQPhvldpZQK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4FdC2%2FbtsBF13gLg2%2FosrU92UND0xQPhvldpZQK1%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;1666&quot; height=&quot;498&quot; data-filename=&quot;스크린샷 2023-12-10 오후 6.39.12.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 인덱스가 걸린 칼럼과 PK 컬럼을 &quot;동시에&quot; 활용해서 페이지네이션 한다면 빠른 시간안에 원하는 방식으로 페이지네이션 할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1702202751598&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM blog_product product
WHERE product.price &amp;lt;= 1473
AND (product.price &amp;lt; 1473 OR product.seq &amp;lt; 5249026)
ORDER BY product.price DESC, product.seq DESC
LIMIT 50
;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 OR 절을 사용하기 때문에 인덱스에 걸리지 않을 것 같다고 생각할 수 있겠지만 PK 를 활용하게 되면 인덱스에 잘 걸리는 것을 확인할 수 있다. 아래의 스크린샷은 Explain 을 써서 확인할 결과와 실제 실행시켰을 때 걸린 시간이며 40 ms 안에 해결이 되는 것을 확인 할 수 있다. (물론 subquery 나 cursor 의 칼럼을 구하기 위해 쿼리를 두번 사용하게 되면 시간이 조금 더 걸릴 수도 있다.)&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-filename=&quot;스크린샷 2023-12-10 오후 7.06.55.png&quot; data-origin-width=&quot;2974&quot; data-origin-height=&quot;150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcFPgO/btsBFoR8ko3/KAhiVS1TyL5XHbSiB5jJE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcFPgO/btsBFoR8ko3/KAhiVS1TyL5XHbSiB5jJE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcFPgO/btsBFoR8ko3/KAhiVS1TyL5XHbSiB5jJE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcFPgO%2FbtsBFoR8ko3%2FKAhiVS1TyL5XHbSiB5jJE0%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;2974&quot; height=&quot;150&quot; data-filename=&quot;스크린샷 2023-12-10 오후 7.06.55.png&quot; data-origin-width=&quot;2974&quot; data-origin-height=&quot;150&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-12-10 오후 7.07.49.png&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cB2oBZ/btsBFqoSfZD/AWl9CQOMQSpbFyb9UnBAAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cB2oBZ/btsBFqoSfZD/AWl9CQOMQSpbFyb9UnBAAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cB2oBZ/btsBFqoSfZD/AWl9CQOMQSpbFyb9UnBAAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcB2oBZ%2FbtsBFqoSfZD%2FAWl9CQOMQSpbFyb9UnBAAk%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;1482&quot; height=&quot;334&quot; data-filename=&quot;스크린샷 2023-12-10 오후 7.07.49.png&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;/figure&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;커서 기반 페이지네이션 처리는 이제 백엔드 개발자의 기본 소양 중 하나인 것 같다. 하지만 가끔씩 실수로 인해서 기획자 혹은 QA 담당자에게 &quot;이전 결과가 안보여요&quot; 하는 버그를 발생시키기도 한다. 이미 많은 블로그들에서도 정리되어 있는데 개인적으로는 OR 절을 더 많이 사용하는데 커스텀 커서를 대부분 추천하는 분위기라서 아직은 잘 이해되지 않는다. 현재 앱에서는 잘 돌아가니 이를 사용하다가 문제가 생길 가능성이 보일 시에 바꿔도 늦지 않을 것 같다.&lt;/p&gt;</description>
      <category>개발/데이터베이스</category>
      <category>mysql</category>
      <author>코리이</author>
      <guid isPermaLink="true">https://pawmi.tistory.com/19</guid>
      <comments>https://pawmi.tistory.com/19#entry19comment</comments>
      <pubDate>Sun, 10 Dec 2023 19:15:08 +0900</pubDate>
    </item>
  </channel>
</rss>