<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>baby-t 님의 블로그</title>
    <link>https://baby-t.tistory.com/</link>
    <description>baby-t 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Tue, 2 Jun 2026 23:10:08 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>baby-t</managingEditor>
    <item>
      <title>[DB Deep Dive] 5. 내 쿼리의 성적표 EXPLAIN: Using filesort 제거와 Using where의 이해</title>
      <link>https://baby-t.tistory.com/187</link>
      <description>&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;1. 대용량 스파이크 부하 테스트: 옵티마이저를 깨워라&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 [상편]에서 우리는 데이터가 고작 43건일 때 MySQL 옵티마이저가 복합 인덱스를 무시하고 풀 테이블 스캔(ALL)을 때려버리는 영리한 배신을 목격했습니다. 인덱스의 진짜 위력을 증명하기 위해, 저는 &lt;b&gt;JMeter&lt;/b&gt;를 활용하여 운영 환경과 유사한 대용량 트래픽을 강제로 발생시키기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 데이터를 넣는 것을 넘어 서버의 비동기 처리(Async) 성능까지 함께 검증하기 위해, &lt;b&gt;Ramp-up(진입 시간)을 단 1초로 설정하고 10,000건의 주문 요청을 동시에 쏘는 스파이크(Spike) 부하 테스트&lt;/b&gt;를 세팅했습니다. 찰나의 순간에 쏟아지는 폭격을 Tomcat 스레드와 비동기 큐가 어떻게 버텨낼지 확인하기 위함이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;2. 예기치 못한 에러, 그러나 완벽한 TPS 측정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JMeter 발사 버튼을 누르고 찰나의 시간이 흐른 뒤 결과를 확인해보니, 예기치 못한 상황이 발생했습니다. 10,000건의 주문이 모두 비즈니스 로직 단에서 &lt;b&gt;&quot;잔액 부족&quot;&lt;/b&gt; 사유로 차단(실패)된 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zDGNf/dJMcacb7Wm7/fH4J5Wapp61mB7MGVImth1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zDGNf/dJMcacb7Wm7/fH4J5Wapp61mB7MGVImth1/img.png&quot; data-alt=&quot;홈페이지 거래 내역&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zDGNf/dJMcacb7Wm7/fH4J5Wapp61mB7MGVImth1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzDGNf%2FdJMcacb7Wm7%2FfH4J5Wapp61mB7MGVImth1%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;1159&quot; height=&quot;578&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;578&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;홈페이지 거래 내역&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #f8f9fa; border-left: 4px solid #6c757d; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 17px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  실패한 테스트일까? 오히려 완벽한 시스템 한계 측정!&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;주문은 비록 잔액 부족으로 튕겼지만, JMeter의 결과 지표 중 &lt;b&gt;&lt;code&gt;Throughput: 400.3/sec&lt;/code&gt;&lt;/b&gt;라는 아주 유의미한 수치를 얻어냈습니다. 1초 만에 10,000개의 요청이 쏟아졌음에도 서버가 터지지 않고(Error 0%), &lt;b&gt;초당 400건(TPS)&lt;/b&gt;의 속도로 꾸역꾸역 예외 처리를 해내며 비동기 큐를 안정적으로 비워냈다는 뜻입니다. 단일 서버 기준 훌륭한 방어력을 증명한 셈입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1549&quot; data-origin-height=&quot;127&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qdrcG/dJMcadaX7Xw/OA0HSMBC4ZWT87PZCt4mhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qdrcG/dJMcadaX7Xw/OA0HSMBC4ZWT87PZCt4mhK/img.png&quot; data-alt=&quot;JMeter 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qdrcG/dJMcadaX7Xw/OA0HSMBC4ZWT87PZCt4mhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqdrcG%2FdJMcadaX7Xw%2FOA0HSMBC4ZWT87PZCt4mhK%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;1549&quot; height=&quot;127&quot; data-origin-width=&quot;1549&quot; data-origin-height=&quot;127&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JMeter 결과&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;code&gt;orders&lt;/code&gt; 테이블에는 &lt;b&gt;제 user_id와 order_date가 박힌 물리적인 데이터가 10,000건 이상 완벽하게 INSERT(적재)&lt;/b&gt; 되었다는 사실입니다. 드디어 옵티마이저를 자극할 충분한 모수가 모였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;3. 짜릿한 성적표: type: ref와 사라진 filesort&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 2만 건 가까이 적재된 상태에서, 드디어 떨리는 마음으로 [상편]에서 날렸던 그 쿼리 앞에 다시 &lt;code&gt;EXPLAIN&lt;/code&gt;을 붙여 실행해 보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;749&quot; data-origin-height=&quot;68&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4ehjZ/dJMcaarR7gD/5oKEg3nwpmDFoUk0k15JVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4ehjZ/dJMcaarR7gD/5oKEg3nwpmDFoUk0k15JVk/img.png&quot; data-alt=&quot;실행계획 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4ehjZ/dJMcaarR7gD/5oKEg3nwpmDFoUk0k15JVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4ehjZ%2FdJMcaarR7gD%2F5oKEg3nwpmDFoUk0k15JVk%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;749&quot; height=&quot;68&quot; data-origin-width=&quot;749&quot; data-origin-height=&quot;68&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행계획 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #007bff;&quot; data-ke-size=&quot;size16&quot;&gt;✨ 최종 EXPLAIN 검증 결과 (데이터 누적 후)&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;type :&lt;/b&gt; &lt;span style=&quot;color: #007bff; font-weight: bold;&quot;&gt;ref&lt;/span&gt; (복합 인덱스 사용 성공!)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;key :&lt;/b&gt; idx_user_date&lt;/li&gt;
&lt;li&gt;&lt;b&gt;rows :&lt;/b&gt; 2 (필요한 소량의 데이터만 스캔 예측)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Extra :&lt;/b&gt; NULL &lt;span style=&quot;color: #28a745; font-weight: bold;&quot;&gt;(Using filesort 완벽 제거!)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 완벽한 대성공이었습니다. 데이터가 많아지자 옵티마이저는 풀 테이블 스캔을 포기하고 &lt;b&gt;드디어 우리가 설계한 복합 인덱스(idx_user_date)를 타기 시작했습니다 (&lt;code&gt;type: ref&lt;/code&gt;).&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 짜릿한 부분은 악명 높았던 &lt;b&gt;&lt;code&gt;Using filesort&lt;/code&gt;가 감쪽같이 사라졌다는 점&lt;/b&gt;입니다. 복합 인덱스의 순서를 &lt;code&gt;(user_id, order_date)&lt;/code&gt;로 묶어둔 덕분에, B+Tree 인덱스를 타는 순간 데이터가 이미 &lt;code&gt;order_date&lt;/code&gt; 순으로 정렬되어 있어 DB가 메모리 위에서 추가적인 정렬(Sorting)을 할 필요가 완벽히 사라진 것입니다. &lt;i&gt;(참고: SELECT * 로 엔티티 전체를 조회하여 Data Lookup이 발생했기에 커버링 인덱스(Using index) 대신 Extra가 비어있는 가장 건강한 상태가 출력되었습니다.)&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;4. [심화 트러블슈팅] 인덱스에 없는 조건을 추가하면 어떻게 될까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 완벽히 작동하는 것을 확인한 후, 저는 호기심이 생겼습니다. &lt;i&gt;&quot;방금 실패한 거래가 많았으니, &lt;b&gt;상태가 '실패'인 거래내역 20건만&lt;/b&gt; 가져오도록 WHERE 절 조건을 추가하면 어떻게 될까?&quot;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WHERE user_id = 1 AND status = '실패'&lt;/code&gt; 조건을 넣고 실행 계획을 까본 결과, &lt;code&gt;type&lt;/code&gt;은 여전히 &lt;code&gt;ref&lt;/code&gt;로 인덱스를 잘 타고 있었지만, &lt;b&gt;Extra 컬럼에 &lt;code&gt;Using where&lt;/code&gt;&lt;/b&gt;가 등장하고 &lt;b&gt;filtered 컬럼에 &lt;code&gt;50.00&lt;/code&gt;&lt;/b&gt;이 찍혔습니다. 이것은 성능 저하를 의미하는 걸까요?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;832&quot; data-origin-height=&quot;74&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvPnt6/dJMb990OyXk/ZkazdoCEOKADyMrTcK6261/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvPnt6/dJMb990OyXk/ZkazdoCEOKADyMrTcK6261/img.png&quot; data-alt=&quot;실행계획 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvPnt6/dJMb990OyXk/ZkazdoCEOKADyMrTcK6261/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvPnt6%2FdJMb990OyXk%2FZkazdoCEOKADyMrTcK6261%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;832&quot; height=&quot;74&quot; data-origin-width=&quot;832&quot; data-origin-height=&quot;74&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행계획 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ff9800; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 17px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  Using where와 카디널리티(Cardinality)의 미학&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 10px; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;우리의 인덱스에는 &lt;code&gt;status&lt;/code&gt;(상태) 정보가 없습니다. 따라서 스토리지 엔진은 &lt;code&gt;user_id&lt;/code&gt;만 보고 인덱스를 타서 데이터를 가져온 뒤, &lt;b&gt;MySQL 엔진 레벨에서 &lt;code&gt;status = '실패'&lt;/code&gt;가 아닌 데이터들을 직접 쳐내는 필터링 작업&lt;/b&gt;을 수행합니다. 이때 등장하는 메시지가 바로 &lt;b&gt;Using where&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&quot;그럼 복합 인덱스를 (user_id, status, order_date) 3개로 묶으면 Using where도 안 뜨고 더 빠른 거 아냐?&quot;&lt;/i&gt; 라고 생각할 수 있지만, 이는 실무적으로 좋은 선택이 아닙니다. &lt;code&gt;status&lt;/code&gt;(성공, 실패, 대기)처럼 값의 종류가 몇 개 없는 컬럼은 &lt;b&gt;카디널리티(Cardinality)가 극도로 낮아&lt;/b&gt; 인덱스 트리에 추가해 봤자 변별력이 떨어지고 용량 오버헤드만 커집니다. 차라리 지금처럼 &lt;code&gt;user_id&lt;/code&gt;로 1차 스캔 범위를 대폭 줄인 뒤, 나머지 데이터를 &lt;code&gt;Using where&lt;/code&gt;로 가볍게 필터링하게 두는 것이 &lt;b&gt;성능과 용량 사이의 완벽한 트레이드오프(Trade-off)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;5. 시리즈를 마치며: 인덱스는 마법이 아니라 '구조적 설계'다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B+Tree의 기본 원리를 다룬 1편, 커버링 인덱스와 물리 구조의 차이를 분석한 2편에 이어, JMeter 부하 테스트와 EXPLAIN으로 제 프로젝트의 쿼리 성능을 수치로 증명해 낸 3편 실전 튜닝기까지 대장정이 마무리되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 통해 &lt;b&gt;&quot;인덱스만 걸면 무조건 빠를 것이다&quot;&lt;/b&gt;라는 막연한 환상을 완벽하게 깰 수 있었습니다. 데이터의 모수(건수)에 따라 영리하게 노선을 변경하는 옵티마이저를 이해하고, 비동기 시스템의 TPS 한계를 뼈저리게 측정해 보았으며, &lt;code&gt;Using filesort&lt;/code&gt;를 없애기 위한 컬럼 순서 설계의 중요성, 그리고 카디널리티를 고려한 &lt;code&gt;Using where&lt;/code&gt; 필터링의 수용까지.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 성능 튜닝은 단순한 암기가 아니라, 철저한 통계적 검증과 구조적 설계의 산물이라는 것을 깨달은 값진 트러블슈팅 경험이었습니다.&lt;/p&gt;</description>
      <category>정리/DB</category>
      <category>DB</category>
      <category>인덱싱</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/187</guid>
      <comments>https://baby-t.tistory.com/187#entry187comment</comments>
      <pubDate>Thu, 21 May 2026 23:32:48 +0900</pubDate>
    </item>
    <item>
      <title>[DB Deep Dive] 4. 인덱스를 걸었는데 풀 스캔(ALL)이 뜬다고? (feat. 옵티마이저의 배신)</title>
      <link>https://baby-t.tistory.com/186</link>
      <description>&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;1. 시작하며: 완벽하다고 믿었던 내 복합 인덱스의 배신&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 자산 거래소 프로젝트를 진행하며 가장 데이터가 많이 누적될 테이블은 단연 &lt;b&gt;&lt;code&gt;orders (주문 내역)&lt;/code&gt;&lt;/b&gt; 테이블이었습니다. 유저가 자신의 과거 거래 리스트를 조회할 때, 시스템은 특정 유저의 ID를 조건으로 검색하고, 주문 일시를 기준으로 최신순 정렬 및 페이징 처리를 수행해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 대비하여 저는 데이터베이스 설계 단계에서부터 &lt;code&gt;user_id&lt;/code&gt;와 &lt;code&gt;order_date&lt;/code&gt;를 묶은 복합 인덱스(Multi-Column Index)인 &lt;b&gt;&lt;code&gt;idx_user_date&lt;/code&gt;&lt;/b&gt;를 생성해 두었습니다. 동등 조건으로 검색 범위를 좁히고, B+Tree 인덱스 구조를 통해 정렬 부하를 완벽히 제거하겠다는 정석적인 전략이었습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: monospace; line-height: 1.6;&quot;&gt;&lt;code&gt;// 가상 자산 주문 내역 조회를 위한 JPA Repository Repository 코드
public interface OrderRepository extends JpaRepository&amp;lt;Order, Long&amp;gt; {
    @Query(value = &quot;select o from Order o join fetch o.stock where o.user.id = :userId order by o.orderDate desc&quot;,
            countQuery = &quot;select count(o) from Order o where o.user.id = :userId&quot;)
    Page&amp;lt;Order&amp;gt; findAllByUserIdWithStock(@Param(&quot;userId&quot;) Long userId, Pageable pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관된 &lt;code&gt;stock(가상자산 종목)&lt;/code&gt; 데이터의 N+1 문제를 방지하기 위해 &lt;code&gt;join fetch&lt;/code&gt;까지 깔끔하게 적용했고, &lt;code&gt;Order&lt;/code&gt; 입장에서 &lt;code&gt;Stock&lt;/code&gt;은 &lt;code&gt;@ManyToOne&lt;/code&gt; 관계이므로 DB 레벨의 페이징 쿼리(LIMIT)도 안전하게 작동하는 무결점의 코드였습니다. 이제 내 인덱스가 의도대로 잘 작동하는지 &lt;b&gt;&lt;code&gt;EXPLAIN(실행 계획)&lt;/code&gt;&lt;/b&gt;을 통해 검증할 차례였습니다. &lt;b&gt;하지만, 모니터러링 화면에는 충격적인 성적표가 찍혔습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;2. 충격적인 실행 계획: 왜 ALL(풀 스캔)과 filesort가 뜰까?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;108&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oUpmC/dJMcajvxXao/3amBLKoYDCJP4TyWRDDMM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oUpmC/dJMcajvxXao/3amBLKoYDCJP4TyWRDDMM1/img.png&quot; data-alt=&quot;인덱스 검색 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oUpmC/dJMcajvxXao/3amBLKoYDCJP4TyWRDDMM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoUpmC%2FdJMcajvxXao%2F3amBLKoYDCJP4TyWRDDMM1%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;1140&quot; height=&quot;108&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;108&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;인덱스 검색 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 현황을 조회했을 때 기본키(PRIMARY)와 외래키 인덱스 외에 우리가 설계한 &lt;code&gt;idx_user_date&lt;/code&gt;가 선행 컬럼 &lt;code&gt;user_id&lt;/code&gt;(Seq 1), 후행 컬럼 &lt;code&gt;order_date&lt;/code&gt;(Seq 2)로 정교하게 물리 결합해 있는 것을 확인했습니다. 그러나 실제 조회 쿼리 앞에 &lt;code&gt;EXPLAIN&lt;/code&gt;을 붙여 실행한 결과는 끔찍했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;793&quot; data-origin-height=&quot;31&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmgs1s/dJMcahR0OGn/AzQbOHLqxPZX5ldWDyyNw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmgs1s/dJMcahR0OGn/AzQbOHLqxPZX5ldWDyyNw1/img.png&quot; data-alt=&quot;실행 계획 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmgs1s/dJMcahR0OGn/AzQbOHLqxPZX5ldWDyyNw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbmgs1s%2FdJMcahR0OGn%2FAzQbOHLqxPZX5ldWDyyNw1%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;793&quot; height=&quot;31&quot; data-origin-width=&quot;793&quot; data-origin-height=&quot;31&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행 계획 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff4c4c; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #dc3545;&quot; data-ke-size=&quot;size16&quot;&gt;  최초의 EXPLAIN 검증 결과&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;type :&lt;/b&gt; ALL (Table Full Scan)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;key :&lt;/b&gt; NULL (인덱스를 전혀 사용하지 않음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;rows :&lt;/b&gt; 43&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Extra :&lt;/b&gt; Using where; Using filesort&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명시적으로 복합 인덱스를 지정해 주었음에도 옵티마이저는 이를 철저히 무시한 채 테이블 전체를 뒤지는 &lt;b&gt;ALL 스캔&lt;/b&gt;을 선택했고, 인덱스의 정렬 구조를 쓰지 못해 메모리 버퍼에서 소팅 연산을 무겁게 수행하는 &lt;b&gt;Using filesort&lt;/b&gt; 경고까지 내뿜고 있었습니다. 기껏 설계한 인덱스 아키텍처가 완전히 무력화된 순간이었습니다.&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;3. 범인은 43건: 똑똑한 옵티마이저의 영리한 배신&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 몇 번이나 뒤지며 자책하던 중, &lt;b&gt;&lt;code&gt;rows: 43&lt;/code&gt;&lt;/b&gt;이라는 통계 수치에 시선이 멈췄습니다. 당시 개발 및 테스트 초기 단계였던 제 데이터베이스 테이블에는 고작 43건의 주문 데이터만 저장되어 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 MySQL의 핵심 두뇌인 &lt;b&gt;옵티마이저(Optimizer)의 반전 트리거&lt;/b&gt;를 깨달았습니다. 데이터베이스 엔진은 비용 기반 최적화(CBO) 모델을 따릅니다. 무조건 인덱스가 있다고 해서 타는 것이 아니라, &lt;b&gt;'인덱스를 타는 비용'과 '풀 스캔을 때리는 비용'을 저울질하여 더 저렴한 경로를 선택&lt;/b&gt;합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ff9800; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 17px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  인덱스 스캔의 숨겨진 비용, Data Lookup&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;인덱스를 사용한다는 것은 B+Tree 인덱스 노드를 타고 내려가 원하는 값을 찾은 뒤, 그곳에 저장된 PK 값을 들고 &lt;b&gt;다시 실제 데이터 페이지(디스크 블록)로 점프하는 랜덤 I/O(Data Lookup) 과정&lt;/b&gt;을 동반합니다. 데이터 모수가 수십 건 정도로 극도로 적을 때는, 인덱스 나무를 타는 수고와 디스크 점프를 반복하느니, 차라리 메모리에 한 번에 테이블 전체를 풀 스캔(ALL)으로 긁어와 직접 정렬(filesort)해 버리는 것이 컴퓨터 공학적으로 훨씬 빠릅니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 제가 인덱스를 잘못 설계한 것이 아니었습니다. &lt;b&gt;데이터가 너무 적어서 옵티마이저가 의도적으로 인덱스를 '패스'했던 것입니다.&lt;/b&gt; 면접 단골 질문인 &lt;i&gt;&quot;인덱스를 걸었는데 왜 실행 계획에 ALL이 뜰까요?&quot;&lt;/i&gt;에 대한 완벽한 실무적 정답을 제 눈으로 직접 목격한 셈이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;4. 디테일 타임: 내 테이블에 자동으로 생성된 인덱스들의 정체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 파악한 김에, &lt;code&gt;SHOW INDEX FROM orders;&lt;/code&gt; 결과 구조에 나타난 인덱스들의 정체와 데이터베이스 물리 계층의 연관 지식을 확실하게 정리하고 넘어가고자 합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; border: 1px solid #e1e4e8;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;background-color: #f8f9fa;&quot;&gt;
&lt;th style=&quot;padding: 12px; border: 1px solid #e1e4e8; text-align: left; font-weight: bold;&quot;&gt;Key_name&lt;/th&gt;
&lt;th style=&quot;padding: 12px; border: 1px solid #e1e4e8; text-align: left; font-weight: bold;&quot;&gt;Column_name&lt;/th&gt;
&lt;th style=&quot;padding: 12px; border: 1px solid #e1e4e8; text-align: left; font-weight: bold;&quot;&gt;인덱스 유형 및 물리 구조의 비밀&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;&lt;b&gt;PRIMARY&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;&lt;b&gt;클러스터드 인덱스 (Clustered Index)&lt;/b&gt;&lt;br /&gt;저는 이 구조를 명시적으로 선언한 적이 없습니다. 하지만 MySQL InnoDB 엔진은 테이블에 PK가 선언되는 순간, 해당 PK를 기준으로 데이터 행 전체를 물리적으로 정렬하여 B+Tree 사전 구조로 자동 빌드합니다. 최상위 성능을 보장하는 단 하나의 인덱스입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #fcfcfc;&quot;&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot; rowspan=&quot;2&quot;&gt;&lt;b&gt;idx_user_date&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;user_id (Seq 1)&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot; rowspan=&quot;2&quot;&gt;&lt;b&gt;넌클러스터드 복합 인덱스 (Composite Index)&lt;/b&gt;&lt;br /&gt;성능 최적화를 위해 컬럼 2개를 하나로 묶어 생성한 인덱스입니다. InnoDB 스토리지 엔진 특성상, 이 복합 인덱스의 리프 노드(Leaf Node)에는 &lt;code&gt;user_id&lt;/code&gt;와 &lt;code&gt;order_date&lt;/code&gt;뿐만 아니라 논리적 주소 역할을 수행할 &lt;b&gt;실제 PK(id) 값도 물리적으로 자동 포함&lt;/b&gt;됩니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #fcfcfc;&quot;&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;order_date (Seq 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;&lt;b&gt;FK9kccy...&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;stock_code&lt;/td&gt;
&lt;td style=&quot;padding: 12px; border: 1px solid #e1e4e8;&quot;&gt;&lt;b&gt;외래키 자동 인덱스&lt;/b&gt;&lt;br /&gt;JPA로 &lt;code&gt;@ManyToOne&lt;/code&gt; 관계를 맺으면 Hibernate가 DDL을 밀어 넣을 때 외래키 제약조건을 형성합니다. 이때 MySQL 엔진은 외래키를 활용한 조인 연산의 효율성을 보장하기 위해 물리적인 외래키용 인덱스를 내부적으로 자동 생성해 줍니다. (단, &lt;code&gt;user_id&lt;/code&gt;는 이미 복합 인덱스의 선행 컬럼으로 포함되었기에 중복 생성이 생략되었습니다.)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;5. 상편 마무리에 기하며: 진짜 무대를 위한 빌드업&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 인덱스의 진정한 성능과 실효성을 검증하기 위해서는, 옵티마이저가 무시할 수 없을 정도의 &lt;b&gt;대용량 데이터 환경(Production 레벨)을 강제로 구축&lt;/b&gt;해야 한다는 결론에 도달했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 곧바로 부하 테스트 툴인 &lt;b&gt;JMeter&lt;/b&gt;를 장전했습니다. 동시 접속 스레드 100개가 찰나의 순간인 1초 만에 10,000건의 주문 요청을 서버에 폭격하듯 때려 박는 '스파이크 부하 테스트'를 기획한 것입니다. 과연 제 Spring Boot 서버의 비동기 아키텍처는 이 대량의 압박 속에서 무사히 데이터를 적재할 수 있었을까요? 그리고 데이터가 10,000건 이상 쌓인 뒤 데이터베이스의 실행 계획은 어떻게 뒤바뀌었을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대용량 트래픽 폭격의 생생한 결과와, 마침내 베일을 벗은 &lt;b&gt;복합 인덱스의 진짜 위력(하편)&lt;/b&gt;에서 이어가도록 하겠습니다!&lt;/p&gt;</description>
      <category>정리/DB</category>
      <category>MYSQL</category>
      <category>실행계획</category>
      <category>인덱싱</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/186</guid>
      <comments>https://baby-t.tistory.com/186#entry186comment</comments>
      <pubDate>Thu, 21 May 2026 23:26:54 +0900</pubDate>
    </item>
    <item>
      <title>[DB Deep Dive] 3. &amp;quot;인덱스를 걸었는데 왜 안 탈까요?&amp;quot; EXPLAIN 실행 계획 분석과 튜닝</title>
      <link>https://baby-t.tistory.com/185</link>
      <description>&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;1. 시작하며: 내 쿼리는 정말 인덱스를 타고 있을까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 아무리 열심히 &lt;b&gt;B+Tree 복합 인덱스&lt;/b&gt;를 설계해 두었다고 해도, 쿼리를 잘못 작성하면 DB의 옵티마이저(Optimizer)는 인덱스를 쿨하게 무시하고 테이블 풀 스캔(Full Scan)을 때려버립니다. 대규모 트래픽 환경에서 이는 곧바로 장애로 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 백엔드 개발자는 쿼리를 작성한 후 반드시 &lt;b&gt;EXPLAIN(실행 계획)&lt;/b&gt; 명령어를 통해 옵티마이저의 속마음을 들여다보고 쿼리가 의도대로 작동하는지 검증해야 합니다. 수많은 실행 계획 컬럼 중, 실무에서 쿼리 튜닝을 할 때 가장 중요하게 쳐다보는 &lt;b&gt;핵심 컬럼들(type, Extra, rows)&lt;/b&gt;을 완벽하게 분석해 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;117&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FVigF/dJMcah5z3Xl/gtIBsRHGkBI1mOkTOVKLq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FVigF/dJMcah5z3Xl/gtIBsRHGkBI1mOkTOVKLq0/img.png&quot; data-alt=&quot;실행계획 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FVigF/dJMcah5z3Xl/gtIBsRHGkBI1mOkTOVKLq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFVigF%2FdJMcah5z3Xl%2FgtIBsRHGkBI1mOkTOVKLq0%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;1280&quot; height=&quot;117&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;117&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행계획 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;2. 튜닝의 핵심 지표: type 컬럼 (접근 방식)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;type&lt;/code&gt; 컬럼은 옵티마이저가 테이블의 레코드를 &lt;b&gt;어떤 방식(풀 스캔 vs 인덱스 스캔)으로 읽었는지&lt;/b&gt; 알려주는 가장 중요한 지표입니다. 여러 가지 타입이 있지만, 성능이 가장 좋은 순서대로 알아야 할 &lt;b&gt;4대장&lt;/b&gt;은 다음과 같습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f8f9fa; border: 1px solid #e1e4e8; padding: 20px; border-radius: 8px; margin: 20px 0;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #28a745;&quot; data-ke-size=&quot;size16&quot;&gt;  성능 최고: const (또는 eq_ref)&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 15px;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회 결과가 &lt;b&gt;반드시 1건&lt;/b&gt;임을 보장하는 최고의 접근 방식입니다.&lt;/li&gt;
&lt;li&gt;주로 &lt;code&gt;WHERE id = 5&lt;/code&gt; 처럼 &lt;b&gt;PK(Primary Key)&lt;/b&gt;나 &lt;b&gt;Unique Key&lt;/b&gt;를 동등 조건(=)으로 검색할 때 나타납니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #007bff;&quot; data-ke-size=&quot;size16&quot;&gt; &amp;zwj;♂️ 성능 좋음: ref&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 15px;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동등 조건(=)으로 검색하지만, PK나 Unique Key가 아닌 &lt;b&gt;일반 인덱스&lt;/b&gt;를 타서 결과가 여러 건일 수 있을 때 나타납니다.&lt;/li&gt;
&lt;li&gt;충분히 매우 빠른 레코드 조회 방식입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #fd7e14;&quot; data-ke-size=&quot;size16&quot;&gt;  성능 보통: range&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 15px;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스를 하나의 값이 아닌 &lt;b&gt;특정 범위&lt;/b&gt;로 스캔하는 방식입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;, &amp;gt;, IS NULL, BETWEEN, IN, LIKE&lt;/code&gt; 연산자를 사용할 때 주로 나타나며, 실무에서 가장 많이 보게 되는 준수한 성능의 타협점입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #dc3545;&quot; data-ke-size=&quot;size16&quot;&gt;  튜닝 대상 1순위: ALL (Table Full Scan)&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 끔찍한 단어입니다. 인덱스를 타지 못하고 &lt;b&gt;테이블을 처음부터 끝까지 전부 다 읽어버렸다&lt;/b&gt;는 뜻입니다.&lt;/li&gt;
&lt;li&gt;데이터가 적을 땐 괜찮지만, 대용량 테이블에서 &lt;code&gt;type: ALL&lt;/code&gt;이 뜨면 무조건 인덱스를 추가하거나 쿼리를 수정해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;3. 내 쿼리의 성적표: Extra 컬럼&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Extra&lt;/code&gt; 컬럼은 옵티마이저가 쿼리를 어떻게 가공하고 처리했는지에 대한 &lt;b&gt;내부적인 힌트&lt;/b&gt;를 제공합니다. 여기서 개발자를 울고 웃게 만드는 대표적인 메시지 2가지를 소개합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;✨ 축복의 메시지: Using index (커버링 인덱스)&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;지난 2편에서 배운 &lt;b&gt;커버링 인덱스(Covering Index)&lt;/b&gt;가 제대로 발동했다면, Extra 컬럼에 당당하게 &lt;code&gt;Using index&lt;/code&gt;가 출력됩니다! 데이터 파일을 읽기 위한 디스크 점프(Data Lookup) 없이, 메모리에 올라와 있는 &lt;b&gt;인덱스 트리만 읽어서 쿼리가 끝났다&lt;/b&gt;는 뜻으로, 엄청나게 빠른 속도를 보장합니다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff4c4c; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;⚠️ 경고 메시지: Using filesort&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;쿼리에 &lt;code&gt;ORDER BY&lt;/code&gt;가 들어있는데 정렬 기준이 인덱스를 타지 못할 때 발생합니다. DB가 데이터를 다 가져온 뒤 별도의 메모리 버퍼(Sort Buffer)에 올려두고 &lt;b&gt;직접 퀵 소트 등으로 정렬&lt;/b&gt;했다는 뜻으로 엄청난 부하를 일으킵니다. 복합 인덱스의 순서를 정렬 기준에 맞게 재설계하는 등의 튜닝이 시급합니다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #f8f9fa; border-left: 4px solid #6c757d; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  보너스 팁: 성능 감각을 키워주는 &lt;code&gt;rows&lt;/code&gt; 컬럼&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rows&lt;/code&gt; 컬럼은 옵티마이저가 쿼리 처리를 위해 &lt;b&gt;&quot;대략 몇 건의 데이터를 읽어야 할까?&quot;&lt;/b&gt;라고 예측한 수치입니다. 만약 &lt;code&gt;type&lt;/code&gt;이 인덱스를 잘 탔다고 나오더라도, 이 &lt;code&gt;rows&lt;/code&gt; 값이 전체 데이터 수에 육박할 정도로 지나치게 크다면 &lt;b&gt;비효율적인 스캔&lt;/b&gt;이 발생하고 있다는 뜻입니다. 무늬만 인덱스 스캔일 뿐 실질적인 성능은 풀 스캔과 다를 바 없으므로 튜닝을 의심해봐야 하는 아주 좋은 통찰력 지표입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;4. 면접 핵심: &quot;인덱스를 걸었는데 왜 ALL(풀 스캔)이 뜰까요?&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접에서 가장 자주 묻는 함정 질문입니다. 인덱스는 잘 만들어져 있지만, &lt;b&gt;개발자가 쿼리를 잘못 짜서 B+Tree 구조를 무력화시키는 3대 악마의 쿼리 패턴&lt;/b&gt;이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: monospace; line-height: 1.6;&quot;&gt;&lt;code&gt;-- 1. 인덱스 컬럼을 직접 가공(함수 사용)하는 경우
[BAD] SELECT * FROM USER WHERE YEAR(created_at) = 2024;
[GOOD] SELECT * FROM USER WHERE created_at &amp;gt;= '2024-01-01' AND created_at &amp;lt; '2025-01-01';
(B+Tree는 원본 데이터 기준으로 정렬되어 있습니다. 컬럼 자체에 함수를 씌우면 정렬 구조가 깨져 풀 스캔을 합니다.)

-- 2. LIKE 검색 시 와일드카드(%)가 앞에 있는 경우
[BAD] SELECT * FROM USER WHERE name LIKE '%길동';
[GOOD] SELECT * FROM USER WHERE name LIKE '홍%';
(영어 사전을 찾을 때 'ple'로 끝나는 단어를 찾는다고 상상해 보세요. 첫 글자를 모르면 무조건 처음부터 다 뒤져야 합니다.)

-- 3. 묵시적 형 변환이 일어나는 경우
[BAD] SELECT * FROM USER WHERE phone = 01012345678;  -- (phone이 VARCHAR일 때)
[GOOD] SELECT * FROM USER WHERE phone = '01012345678';
(문자열 컬럼에 숫자 타입으로 검색하면, DB가 몰래 문자열을 숫자로 형변환하는 함수를 씌워버려 인덱스를 타지 못합니다.)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;5. 3편을 마무리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 우리는 B+Tree의 원리(1편), Clustered와 Non-Clustered의 물리적 구조 차이와 커버링 인덱스(2편), 그리고 마지막으로 내가 짠 쿼리의 성능을 증명해 내는 EXPLAIN 실행 계획 분석(3편)까지 달려왔습니다. 이 세 가지 포스팅은 서로 유기적으로 연결되어 &lt;b&gt;&quot;데이터베이스의 병목을 구조적으로 분석하고 해결하는 백엔드 개발자&quot;&lt;/b&gt;라는 완벽한 서사를 만들어냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적 기초 체력을 기르셨으니, 이제부터는 &lt;b&gt;이 무기들을 실제 프로젝트 코드에 적용하고 장애를 해결한 생생한 트러블슈팅 경험&lt;/b&gt;으로 넘어가 볼 차례입니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;6. 추가 링크&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://zzang9ha.tistory.com/436#google_vignette&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://zzang9ha.tistory.com/436#google_vignette&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1779192375914&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;MySQL EXPLAIN 실행계획 마스터하기(feat. RealMySQL 8.0)&quot; data-og-description=&quot;  MySQL EXPLAIN 실행계획 마스터하기(feat. RealMySQL 8.0) 실행 계획(EXPLAIN) 이란? 대부분의 DBMS는 많은 데이터를 안전하고, 빠르게 저장 및 관리하는 것이 주목적이다. 이러한 목적을 달성하기 위해 사&quot; data-og-host=&quot;zzang9ha.tistory.com&quot; data-og-source-url=&quot;https://zzang9ha.tistory.com/436#google_vignette&quot; data-og-url=&quot;https://zzang9ha.tistory.com/436&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/s7q8U/dJMb87gb6Q8/fWyyqVvUKqQBKZHL1Mw9dk/img.png?width=640&amp;amp;height=480&amp;amp;face=0_0_640_480,https://scrap.kakaocdn.net/dn/iz5w2/dJMb9fZATgw/7KkPiypoO5AlV3NOLuktA1/img.png?width=640&amp;amp;height=480&amp;amp;face=0_0_640_480,https://scrap.kakaocdn.net/dn/cBDBX0/dJMb82eSAzF/Rt6mRFRTzBk6cVlKmGAcSK/img.png?width=2142&amp;amp;height=486&amp;amp;face=0_0_2142_486&quot;&gt;&lt;a href=&quot;https://zzang9ha.tistory.com/436#google_vignette&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://zzang9ha.tistory.com/436#google_vignette&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/s7q8U/dJMb87gb6Q8/fWyyqVvUKqQBKZHL1Mw9dk/img.png?width=640&amp;amp;height=480&amp;amp;face=0_0_640_480,https://scrap.kakaocdn.net/dn/iz5w2/dJMb9fZATgw/7KkPiypoO5AlV3NOLuktA1/img.png?width=640&amp;amp;height=480&amp;amp;face=0_0_640_480,https://scrap.kakaocdn.net/dn/cBDBX0/dJMb82eSAzF/Rt6mRFRTzBk6cVlKmGAcSK/img.png?width=2142&amp;amp;height=486&amp;amp;face=0_0_2142_486');&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;MySQL EXPLAIN 실행계획 마스터하기(feat. RealMySQL 8.0)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  MySQL EXPLAIN 실행계획 마스터하기(feat. RealMySQL 8.0) 실행 계획(EXPLAIN) 이란? 대부분의 DBMS는 많은 데이터를 안전하고, 빠르게 저장 및 관리하는 것이 주목적이다. 이러한 목적을 달성하기 위해 사&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;zzang9ha.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>정리/DB</category>
      <category>DB</category>
      <category>explain</category>
      <category>인덱스</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/185</guid>
      <comments>https://baby-t.tistory.com/185#entry185comment</comments>
      <pubDate>Tue, 19 May 2026 21:06:41 +0900</pubDate>
    </item>
    <item>
      <title>[DB Deep Dive] 2. Clustered vs Non-Clustered 인덱스의 차이와 InnoDB의 비밀</title>
      <link>https://baby-t.tistory.com/184</link>
      <description>&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;1. 시작하며: 인덱스, 디스크 위에는 어떻게 저장될까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 1편에서는 인덱스의 논리적인 자료구조인 B+Tree에 대해 알아보았습니다. 하지만 이 B+Tree가 실제 하드 디스크(물리적 공간) 위에 어떻게 배치되는지에 따라 데이터베이스의 아키텍처는 완전히 두 갈래로 나뉘게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 인덱스의 두 가지 핵심 물리적 구현체인 &lt;b&gt;Clustered Index(클러스터드 인덱스)&lt;/b&gt;와 &lt;b&gt;Non-Clustered Index(논클러스터드 인덱스)&lt;/b&gt;의 결정적인 차이를 알아보고, 실제 쿼리를 날릴 때 어떤 인덱스가 유리한지 성능을 비교해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;2. 영어 사전 vs 찾아보기 색인 (Clustered vs Non-Clustered)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 테이블에 인덱스를 거는 방식은 크게 두 가지 비유로 완벽하게 설명할 수 있습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f8f9fa; border: 1px solid #e1e4e8; padding: 20px; border-radius: 8px; margin: 20px 0;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #d73a49;&quot; data-ke-size=&quot;size16&quot;&gt;  Clustered Index (영어 사전 방식)&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 20px;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테이블의 실제 &lt;b&gt;데이터 레코드들을 물리적으로 재배열&lt;/b&gt;하여 정렬합니다.&lt;/li&gt;
&lt;li&gt;마치 영어 사전이 A부터 Z까지 순서대로 인쇄되어 있는 것과 같습니다. (리프 노드 = 실제 데이터)&lt;/li&gt;
&lt;li&gt;데이터 자체가 정렬되어 있으므로 &lt;b&gt;테이블당 딱 1개&lt;/b&gt;만 생성할 수 있습니다. (주로 PK 설정 시 자동 생성)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장점:&lt;/b&gt; 이미 정렬되어 있어 범위 검색 등 조회 속도가 압도적으로 빠릅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점:&lt;/b&gt; 중간에 새로운 데이터가 &lt;code&gt;INSERT&lt;/code&gt; 되면, 물리적인 순서를 맞추기 위해 뒤쪽 데이터들을 전부 뒤로 밀어내야 하는 엄청난 오버헤드가 발생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #0366d6;&quot; data-ke-size=&quot;size16&quot;&gt;  Non-Clustered Index (책 맨 뒤의 색인 방식)&lt;/p&gt;
&lt;ul style=&quot;line-height: 1.8; color: #444; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 데이터는 원래 있던 자리에 그대로 두고, &lt;b&gt;정렬된 별도의 인덱스 페이지(색인)&lt;/b&gt;를 따로 만듭니다.&lt;/li&gt;
&lt;li&gt;일반 전공 서적 맨 뒤에 있는 '찾아보기' 페이지와 같습니다.&lt;/li&gt;
&lt;li&gt;물리적인 제약이 없으므로 &lt;b&gt;테이블당 여러 개&lt;/b&gt;를 생성할 수 있습니다. (Secondary Index라고도 부름)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장점:&lt;/b&gt; 데이터를 물리적으로 이동시킬 필요가 없으므로 &lt;code&gt;INSERT&lt;/code&gt; 시의 오버헤드가 상대적으로 적습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점:&lt;/b&gt; 인덱스를 찾은 뒤, 다시 실제 데이터가 있는 곳으로 포인터를 타고 점프해서 가야 하므로 조회 속도는 Clustered보다 약간 느립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  면접관의 단골 꼬리 질문: &quot;조회 패턴에 따른 두 인덱스의 성능 차이는?&quot;&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 10px; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 단건 조회 (Point Query, &lt;code&gt;WHERE id = 5&lt;/code&gt;):&lt;/b&gt; 딱 하나의 데이터만 찾을 때는 둘 다 트리 구조를 한 번만 타고 내려가면 되므로 성능 차이가 거의 없습니다. 이럴 때는 삽입/수정 오버헤드가 적은 &lt;b&gt;논클러스터드 인덱스&lt;/b&gt;를 우선 고려하는 것이 좋습니다.&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 범위 조회 (Range Query, &lt;code&gt;WHERE id BETWEEN 10 AND 100&lt;/code&gt;):&lt;/b&gt; 여기서 압도적인 차이가 발생합니다! &lt;b&gt;클러스터드 인덱스&lt;/b&gt;는 데이터가 이미 물리적으로 정렬되어 있어 시작점부터 쭉 연속해서 읽어오면 끝입니다. 반면 논클러스터드 인덱스는 정렬되지 않은 원본 데이터를 찾기 위해 디스크 이곳저곳을 찌르는 랜덤 I/O가 발생하여 성능이 크게 떨어집니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;3. MySQL InnoDB만의 독특한 인덱스 아키텍처&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 아주 중요한 실무 지식이 등장합니다. 오라클(Oracle) 등 일반적인 DB는 Non-Clustered Index의 리프 노드에 &lt;b&gt;'실제 데이터가 있는 디스크 물리적 주소(ROWID)'&lt;/b&gt;를 저장합니다. 색인을 보고 몇 페이지 몇 번째 줄인지 바로 찾아가는 상식적인 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;MySQL의 핵심 엔진인 InnoDB는 리프 노드에 '물리적 주소' 대신 'Primary Key(PK) 값'을 저장&lt;/b&gt;합니다. 도대체 왜 한 번에 갈 수 있는 주소를 놔두고, 다시 PK를 검색하게 만드는 구조를 택했을까요?&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ff9800; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;⚠️ 데이터 이동의 나비효과를 막아라!&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;앞서 Clustered Index(PK)에 새로운 데이터가 끼어들면, 물리적 순서를 맞추기 위해 뒤쪽 데이터들이 밀려나면서 &lt;b&gt;'물리적 주소'가 전부 바뀐다&lt;/b&gt;고 했습니다. 만약 Non-Clustered Index가 물리적 주소를 들고 있었다면, 데이터가 이동할 때마다 수많은 Non-Clustered Index들을 싹 다 뒤져서 주소를 업데이트해야 하는 대참사가 벌어집니다. &lt;br /&gt;&lt;br /&gt;하지만 &lt;b&gt;PK 값&lt;/b&gt;을 들고 있으면, 데이터의 물리적 위치가 이사를 가더라도 PK 값 자체는 변하지 않으므로 Non-Clustered Index를 수정할 필요가 전혀 없어집니다. 즉, 조회 시 한 번 더 트리를 타야 하는 약간의 비용을 지불하고, &lt;b&gt;안정성과 유지보수성&lt;/b&gt;을 획득한 천재적인 아키텍처 설계입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;4. 궁극의 최적화 스킬: 커버링 인덱스 (Covering Index)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InnoDB의 구조를 배웠다면, 이제 쿼리 튜닝의 마법인 &lt;b&gt;커버링 인덱스(Covered Query)&lt;/b&gt;를 완벽하게 이해할 수 있습니다. 이를 이해하기 위해서는 먼저 '데이터 룩업'이라는 성능 저하의 주범을 알아야 합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff4c4c; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  데이터 룩업 (Data Lookup) 이란?&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;우리가 나이(age) 컬럼에 일반적인 단일 인덱스를 걸었다고 가정해 봅시다. &lt;code&gt;SELECT 이름(name) FROM USER WHERE 나이 = 23;&lt;/code&gt; 이라는 쿼리를 날리면, DB는 먼저 나이 인덱스를 뒤져서 '23'을 찾고 그곳에 적힌 &lt;b&gt;PK 값(id)&lt;/b&gt;을 얻어냅니다.&lt;br /&gt;&lt;br /&gt;하지만 쿼리에서 요구한 &lt;b&gt;'이름'&lt;/b&gt; 데이터는 나이 인덱스 페이지에 존재하지 않습니다. 결국 DB는 방금 얻은 PK 값을 들고, &lt;b&gt;실제 데이터가 있는 원본 테이블(클러스터드 인덱스)로 다시 한번 무거운 발걸음을 옮겨야 합니다.&lt;/b&gt; 이처럼 인덱스를 탄 후 원본 데이터를 찾으러 점프하는 과정을 '데이터 룩업'이라 부르며, 디스크 랜덤 I/O를 발생시켜 쿼리를 느리게 만드는 주범이 됩니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, &lt;b&gt;논클러스터드 인덱스 자체에 내가 찾고자 하는 데이터들을 처음부터 같이 넣어둘 순 없을까요?&lt;/b&gt; 이것이 바로 복합 인덱스를 활용한 커버링 인덱스의 핵심 아이디어입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: monospace; line-height: 1.6;&quot;&gt;&lt;code&gt;--   나이(age)와 이름(name)을 묶어서 복합 인덱스로 생성한 상태
SELECT 나이, 이름 FROM USER WHERE 나이 = 23;
&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;✨ 커버링 인덱스의 마법: 원본 테이블 방문 생략!&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;위 복합 인덱스 구조에서는 인덱스 리프 노드에 &lt;b&gt;[ 나이 | 이름 | id(PK) ]&lt;/b&gt;가 한 세트로 묶여서 정렬되어 저장됩니다. &lt;br /&gt;&lt;br /&gt;이 상태로 쿼리를 날리면, DB는 인덱스 노드 안에서 나이가 23인 레코드를 찾음과 동시에 &lt;b&gt;그 옆에 나란히 적혀있는 '홍길동'이라는 이름까지 통째로 읽어 들입니다.&lt;/b&gt; 쿼리가 필요로 하는 모든 컬럼이 인덱스에 다 있으니, 굳이 무거운 원본 테이블(클러스터드 인덱스)로 2차 방문(Data Lookup)을 하지 않고 &lt;b&gt;인덱스 안에서 상황을 종료&lt;/b&gt;해 버립니다. 이를 &lt;b&gt;커버링 인덱스&lt;/b&gt;라고 부르며, 디스크 I/O를 극적으로 줄여 성능을 몇 배나 끌어올리는 튜닝 기법입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;h4 style=&quot;margin-top: 30px; margin-bottom: 15px;&quot; data-ke-size=&quot;size20&quot;&gt; ️ 한 걸음 더: 복합 인덱스 확장과 내부 동작의 디테일&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 단계 더 깊은 시스템 내부의 동작 원리를 짚고 넘어가야 면접에서 확실한 차별점을 가져갈 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 3개 이상의 컬럼 확장: (나이, 이름, 생일)&lt;/b&gt;&lt;br /&gt;복합 인덱스는 2개뿐만 아니라 3개, 4개 이상의 컬럼도 하나로 묶을 수 있습니다. 만약 &lt;code&gt;(나이, 이름, 생일)&lt;/code&gt;로 인덱스를 생성하면 리프 노드는 &lt;b&gt;[ 나이 | 이름 | 생일 | id(PK) ]&lt;/b&gt; 구조를 갖게 됩니다. 이 상태에서 아래와 같이 쿼리를 날리면 완벽한 커버링 인덱스가 작동합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: monospace; line-height: 1.6;&quot;&gt;&lt;code&gt;SELECT 나이, 이름, 생일 FROM USER WHERE 나이 = 23;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 인덱스 컬럼의 부분 집합(Subset) 조회&lt;/b&gt;&lt;br /&gt;그렇다면 3개 컬럼이 묶인 인덱스 환경에서 딱 2개만 조회하면 어떻게 될까요? &lt;code&gt;SELECT 나이, 이름 FROM USER WHERE 나이 = 23;&lt;/code&gt; 쿼리를 실행하더라도 커버링 인덱스는 아주 잘 작동합니다. DB 엔진은 인덱스 노드 내부에서 필요한 데이터가 전부 포함되어 있다면(부분 집합 관계라면), 쓰이지 않는 '생일' 데이터가 존재하더라도 원본 테이블을 찾지 않고 인덱스 내부에서 조회를 끝내기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3) 쿼리 내비게이션 AI: 옵티마이저(Optimizer)의 역할&lt;/b&gt;&lt;br /&gt;쿼리를 보면 &lt;code&gt;FROM USER&lt;/code&gt;라고만 적혀있을 뿐, 어떤 인덱스를 사용하라고 명시하지 않았습니다. 그런데 DB는 어떻게 알고 인덱스로 바로 찾아가는 걸까요? 바로 데이터베이스 내부에 존재하는 &lt;b&gt;옵티마이저(Optimizer)&lt;/b&gt; 덕분입니다. 옵티마이저는 쿼리문과 테이블 통계 정보를 분석하여 &quot;이 쿼리는 원본 테이블 전체를 읽는 것보다 우리가 만들어 둔 복합 인덱스를 타는 게 비용이 가장 적게 들겠구나!&quot;라고 판단하여 &lt;b&gt;가장 최적의 인덱스 경로를 자동으로 선택&lt;/b&gt;해 줍니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ff9800; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  면접관을 감동시키는 결정적 팁&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;MySQL InnoDB의 논클러스터드 인덱스는 구조상 리프 노드에 &lt;b&gt;항상 PK 값을 기본적으로 포함&lt;/b&gt;하고 있습니다. 따라서 복합 인덱스를 따로 설계하지 않고 &lt;code&gt;(나이)&lt;/code&gt; 단일 인덱스만 걸려있더라도, &lt;code&gt;SELECT id, 나이 FROM USER WHERE 나이 = 23;&lt;/code&gt; 과 같이 PK와 인덱스 컬럼만 조회하는 쿼리는 추가적인 데이터 룩업이 일어나지 않는 &lt;b&gt;커버링 인덱스로 자동 작동&lt;/b&gt;합니다. 이 물리적 저장 구조의 특징과 옵티마이저의 탐색 흐름을 명확히 인지하고 쿼리를 설계하는 것이 시니어와 주니어를 가르는 핵심 기준입니다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>정리/DB</category>
      <category>DB</category>
      <category>인덱스</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/184</guid>
      <comments>https://baby-t.tistory.com/184#entry184comment</comments>
      <pubDate>Mon, 18 May 2026 21:21:44 +0900</pubDate>
    </item>
    <item>
      <title>[DB Deep Dive] 1. 내 쿼리는 왜 느릴까? 인덱스(Index)의 본질과 B+Tree가 선택받은 이유</title>
      <link>https://baby-t.tistory.com/183</link>
      <description>&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;1. 시작하며: 왜 내 쿼리는 데이터가 많아질수록 느려질까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 구축한 플랫폼이나 대규모 서비스를 운영하다 보면, 초기에는 순식간에 끝나던 조회(SELECT) 쿼리가 데이터가 10만 건, 100만 건을 넘어가는 순간 급격하게 느려지는 현상을 마주하게 됩니다. 데이터베이스의 모든 레코드를 처음부터 끝까지 싹 다 뒤지는 &lt;b&gt;전체 탐색(Full Scan)&lt;/b&gt;이 일어나기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 백엔드 개발자가 손에 쥐어야 할 가장 강력한 무기가 바로 &lt;b&gt;인덱스(Index)&lt;/b&gt;입니다. 오늘은 인덱스의 본질적인 개념과 장단점, 그리고 데이터베이스가 왜 하필 수많은 자료구조 중 &lt;b&gt;B+Tree&lt;/b&gt;를 선택했는지 그 내부 깊은 곳의 작동 원리를 파헤쳐 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;2. 인덱스(Index)의 본질과 양날의 검 (Overhead)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스는 쉽게 말해 &lt;b&gt;'책의 맨 뒤에 있는 색인(찾아보기)'&lt;/b&gt;과 같습니다. 수천 페이지짜리 전공 서적에서 특정 단어를 찾기 위해 첫 페이지부터 한 장씩 넘기는 바보 같은 짓을 하지 않듯, 데이터베이스도 데이터와 그 데이터가 저장된 물리적 위치를 묶은 별도의 자료구조를 만들어 두고 빠르게 찾아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스는 단순히 조회의 속도만 높이는 것이 아닙니다. &lt;code&gt;UPDATE&lt;/code&gt;나 &lt;code&gt;DELETE&lt;/code&gt; 연산을 수행할 때도 해당 대상을 먼저 '조회'해야 수정을 하든 삭제를 하든 할 수 있기 때문에, 전반적인 데이터 제어 성능이 함께 향상됩니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ff9800; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;⚠️ 세상에 공짜는 없다: 인덱스의 오버헤드&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 15px; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;인덱스를 무차별적으로 생성하면 DB가 터집니다. DBMS는 인덱스를 항상 &lt;b&gt;'최신의 정렬된 상태'&lt;/b&gt;로 유지해야 하므로, 데이터 변경이 일어날 때마다 다음과 같은 무거운 추가 연산(오버헤드)이 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; line-height: 1.8; color: #333; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;INSERT:&lt;/b&gt; 새로운 데이터에 대한 인덱스 노드를 새로 추가하고 정렬해야 함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DELETE:&lt;/b&gt; 데이터를 지울 때 인덱스를 완전히 삭제하는 게 아니라 &lt;b&gt;'사용하지 않음'&lt;/b&gt; 처리를 해둠. (인덱스 크기가 줄어들지 않음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UPDATE:&lt;/b&gt; 기존 인덱스를 '사용하지 않음' 처리하고, 갱신된 데이터에 대한 인덱스를 새로 추가함. (기존 데이터와 신규 데이터 오버헤드가 동시에 발생)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 수정과 삭제가 빈번한 컬럼에 인덱스를 남용하면, 실제 데이터는 10만 건인데 인덱스 찌꺼기는 100만 건이 쌓여 오히려 성능이 심각하게 저하되는 역효과를 낳게 됩니다. 따라서 인덱스는 &lt;b&gt;데이터의 규모가 크고, 중복도가 낮으며(정밀도가 높고), 삽입/변경보다 조회가 압도적으로 많은 컬럼&lt;/b&gt;에 신중하게 걸어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;3. 왜 해시 테이블(Hash Table)이 아니라 B-Tree 계열일까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 구현할 때 가장 먼저 떠오르는 자료구조는 단연 시간 복잡도 &lt;b&gt;O(1)&lt;/b&gt;을 자랑하는 &lt;b&gt;해시 테이블(Hash Table)&lt;/b&gt;일 것입니다. Key 값을 넣으면 단 한 번의 연산으로 원하는 가치를 찾아내니 완벽해 보입니다. 하지만 실제 데이터베이스 인덱스의 메인은 해시 테이블이 아닌 B-Tree 계열이 차지하고 있습니다. 이유가 무엇일까요?&lt;/p&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff4c4c; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  해시 테이블의 치명적인 한계: 부등호 연산의 불가능&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 10px; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;해시 함수는 값이 단 1만 달라져도 완전히 다른 해시 값을 생성합니다. 이는 &lt;b&gt;정확히 일치하는 등호(=) 연산에는 특화되어 있지만, 부등호(&amp;lt;, &amp;gt;) 연산이나 범위 검색에는 무용지물&lt;/b&gt;임을 뜻합니다.&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;우리가 개발하는 플랫폼에서 &lt;code&gt;WHERE 가격 &amp;gt;= 10000&lt;/code&gt; 이라거나 &lt;code&gt;WHERE 생성일자 BETWEEN A AND B&lt;/code&gt; 같은 범위 검색 쿼리를 날릴 때, 해시 테이블 인덱스는 정렬되어 있지 않기 때문에 혜택을 전혀 받지 못하고 결국 &lt;b&gt;Full Scan&lt;/b&gt;으로 돌아가게 됩니다. 데이터베이스의 탐색은 정렬을 기반으로 한 '범위 스캔'이 핵심이기에, 우리는 항상 정렬된 상태를 유지하는 &lt;b&gt;Tree 계열 자료구조&lt;/b&gt;를 사용해야 합니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;4. B-Tree vs B+Tree: 데이터베이스 최적화의 정수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이진트리를 확장하여 하나의 노드가 2개 이상의 자식 노드를 가질 수 있도록 균형을 맞춘 구조가 바로 &lt;b&gt;B-Tree&lt;/b&gt;입니다. 노드 내부의 데이터들이 항상 정렬된 상태를 유지하고, 자식 노드들이 부모의 값 범위를 나누어 가지기 때문에 탐색 속도가 &lt;b&gt;O(log N)&lt;/b&gt;으로 매우 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 단계 더 나아가 실제 현대의 RDBMS(예: MySQL InnoDB)가 채택한 구조는 B-Tree를 인덱스 전용으로 더욱 극대화한 &lt;b&gt;B+Tree 자료구조&lt;/b&gt;입니다. 두 구조의 결정적인 차이점은 크게 두 가지입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f8f9fa; border: 1px solid #e1e4e8; padding: 20px; border-radius: 8px; margin: 20px 0;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #0366d6;&quot; data-ke-size=&quot;size16&quot;&gt;[ B-Tree ]&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 20px; color: #444; line-height: 1.6;&quot; data-ke-size=&quot;size16&quot;&gt;- 모든 내부 노드와 리프 노드가 &lt;b&gt;Key와 함께 실제 데이터(Value)&lt;/b&gt;를 주렁주렁 들고 있음.&lt;/p&gt;
&lt;p style=&quot;margin-top: 0; font-size: 16px; font-weight: bold; color: #28a745;&quot; data-ke-size=&quot;size16&quot;&gt;[ B+Tree ]&lt;/p&gt;
&lt;ul style=&quot;list-style-type: none; padding-left: 0; line-height: 1.8; color: #444; margin-bottom: 0;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;- 내부 노드(Index Node)들은 오직 경로를 찾기 위한 &lt;b&gt;'Key(가이드)'&lt;/b&gt;만 가짐.&lt;/li&gt;
&lt;li&gt;- 실제 데이터(Value)는 오직 최하단의 &lt;b&gt;'리프 노드(Leaf Node)'&lt;/b&gt;에만 몰아서 저장됨.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #d73a49; font-weight: bold;&quot;&gt;-   결정적으로 리프 노드들끼리 LinkedList(InnoDB는 Double LinkedList)로 연결되어 있음!&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0; border-radius: 4px;&quot;&gt;
&lt;p style=&quot;margin-top: 0; font-size: 18px; font-weight: bold; color: #333;&quot; data-ke-size=&quot;size16&quot;&gt;  B+Tree가 인덱싱에 압도적으로 유리한 이유&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 10px; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째,&lt;/b&gt; 내부 노드가 무거운 실제 데이터를 들고 있지 않기 때문에 하나의 메모리 페이지에 훨씬 더 많은 인덱스 Key들을 촘촘하게 채워 넣을 수 있습니다. 이는 결과적으로 트리의 전체 높이(Depth)를 낮추어 &lt;b&gt;디스크 I/O 탐색 횟수를 획기적으로 줄여줍니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;margin-bottom: 0; color: #555;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째,&lt;/b&gt; 범위 검색 시 B-Tree는 다른 노드로 가기 위해 다시 부모 노드를 거쳐 위아래로 복잡하게 트래킹해야 하지만, B+Tree는 원하는 범위의 시작 리프 노드로 단 한번 내려간 뒤, &lt;b&gt;리프 노드 간 연결된 LinkedList를 타고 오른쪽으로 쓱 밀면서 순차 검색(Linear Scan)&lt;/b&gt;을 끝내버릴 수 있습니다. 이 차이가 대용량 데이터 범위 조회 시 성능을 수십 배 가르는 핵심 원리입니다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;5. 1편을 마무리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 데이터베이스 검색 최적화의 첫 단추인 인덱스의 기본 메커니즘과, 왜 모든 메이저 RDBMS가 &lt;b&gt;B+Tree&lt;/b&gt;라는 정교한 자료구조를 핵심 엔진으로 채택했는지 알아보았습니다. 면접관이 &quot;왜 해시 인덱스를 안 쓰고 B+Tree를 쓰나요?&quot;라고 묻는다면 이제 막힘없이 범위 검색과 디스크 I/O 효율성, LinkedList 스캔을 엮어서 당차게 대답할 수 있을 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자료구조라는 기본 뼈대를 이해했으니, 이제 다음 &lt;b&gt;2편&lt;/b&gt;에서는 이 B+Tree 아키텍처가 실제 디스크 공간 위에 어떤 형태로 물리적으로 배치되는지 알아보는 &lt;b&gt;Clustered Index(영어 사전) vs Non-Clustered Index(찾아보기 색인)&lt;/b&gt;의 치명적인 차이점과, 면접에서 90% 이상 출제되는 MySQL InnoDB만의 독특한 구조를 낱낱이 파헤쳐 보겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;margin-bottom: 15px;&quot; data-ke-size=&quot;size23&quot;&gt;6. 추가 링크&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/96&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://mangkyu.tistory.com/96&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1779106462802&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;[Database] 인덱스(index)란?&quot; data-og-description=&quot;1. 인덱스(Index)란? [ 인덱스(index)란? ] 인덱스란 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다. 만약 우리가 책에서 원하는 내&quot; data-og-host=&quot;mangkyu.tistory.com&quot; data-og-source-url=&quot;https://mangkyu.tistory.com/96&quot; data-og-url=&quot;https://mangkyu.tistory.com/96&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/WAPR4/dJMb9iIMoBY/0whD7raE1dS9PZNkQPlTh1/img.png?width=700&amp;amp;height=429&amp;amp;face=0_0_700_429,https://scrap.kakaocdn.net/dn/jBxCn/dJMb83Sop2t/hWZGPl3eCjq0LaKvZVGrNK/img.png?width=700&amp;amp;height=429&amp;amp;face=0_0_700_429,https://scrap.kakaocdn.net/dn/BITAn/dJMb81fX7dP/13npaevVsSjmKWaUj9kzSK/img.png?width=936&amp;amp;height=680&amp;amp;face=0_0_936_680&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/96&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://mangkyu.tistory.com/96&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/WAPR4/dJMb9iIMoBY/0whD7raE1dS9PZNkQPlTh1/img.png?width=700&amp;amp;height=429&amp;amp;face=0_0_700_429,https://scrap.kakaocdn.net/dn/jBxCn/dJMb83Sop2t/hWZGPl3eCjq0LaKvZVGrNK/img.png?width=700&amp;amp;height=429&amp;amp;face=0_0_700_429,https://scrap.kakaocdn.net/dn/BITAn/dJMb81fX7dP/13npaevVsSjmKWaUj9kzSK/img.png?width=936&amp;amp;height=680&amp;amp;face=0_0_936_680');&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;[Database] 인덱스(index)란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1. 인덱스(Index)란? [ 인덱스(index)란? ] 인덱스란 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다. 만약 우리가 책에서 원하는 내&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;mangkyu.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://code-lab1.tistory.com/217&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://code-lab1.tistory.com/217&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1779106467047&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;[자료구조] B-트리(B-Tree)란? B트리 그림으로 쉽게 이해하기, B트리 탐색, 삽입, 삭제 과정&quot; data-og-description=&quot;B- 트리란?보통 B 트리라고 하면 B- 트리를 의미한다. B 트리는 트리 자료구조의 일종으로 이진트리를 확장해 하나의 노드가 가질 수 있는 자식 노드의 최대 숫자가 2보다 큰 트리 구조이다. 이러&quot; data-og-host=&quot;code-lab1.tistory.com&quot; data-og-source-url=&quot;https://code-lab1.tistory.com/217&quot; data-og-url=&quot;https://code-lab1.tistory.com/217&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/AVqBj/dJMb9iaWzvu/COfiYcHuAEk91bbF6jCtF0/img.jpg?width=680&amp;amp;height=348&amp;amp;face=0_0_680_348,https://scrap.kakaocdn.net/dn/eGjoc/dJMb8WMu5uy/YgNhYpFDtl7kCgjoqZJesK/img.jpg?width=680&amp;amp;height=348&amp;amp;face=0_0_680_348,https://scrap.kakaocdn.net/dn/bGiSUb/dJMb8T943lL/vNTdovNngnTAPJzc2Sj2jk/img.png?width=824&amp;amp;height=381&amp;amp;face=0_0_824_381&quot;&gt;&lt;a href=&quot;https://code-lab1.tistory.com/217&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://code-lab1.tistory.com/217&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/AVqBj/dJMb9iaWzvu/COfiYcHuAEk91bbF6jCtF0/img.jpg?width=680&amp;amp;height=348&amp;amp;face=0_0_680_348,https://scrap.kakaocdn.net/dn/eGjoc/dJMb8WMu5uy/YgNhYpFDtl7kCgjoqZJesK/img.jpg?width=680&amp;amp;height=348&amp;amp;face=0_0_680_348,https://scrap.kakaocdn.net/dn/bGiSUb/dJMb8T943lL/vNTdovNngnTAPJzc2Sj2jk/img.png?width=824&amp;amp;height=381&amp;amp;face=0_0_824_381');&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;[자료구조] B-트리(B-Tree)란? B트리 그림으로 쉽게 이해하기, B트리 탐색, 삽입, 삭제 과정&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;B- 트리란?보통 B 트리라고 하면 B- 트리를 의미한다. B 트리는 트리 자료구조의 일종으로 이진트리를 확장해 하나의 노드가 가질 수 있는 자식 노드의 최대 숫자가 2보다 큰 트리 구조이다. 이러&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;code-lab1.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>정리/DB</category>
      <category>DB</category>
      <category>인덱스</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/183</guid>
      <comments>https://baby-t.tistory.com/183#entry183comment</comments>
      <pubDate>Mon, 18 May 2026 20:59:59 +0900</pubDate>
    </item>
    <item>
      <title>순수 Java로 WAS 구현 (9) - 프론트 컨트롤러에 Jackson 도입하기: 역직렬화와 리플렉션의 비밀</title>
      <link>https://baby-t.tistory.com/182</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 시작하며: GET 요청의 한계와 JSON 바디의 필요성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 우리는 &lt;code&gt;GET&lt;/code&gt; 방식을 통해 URL로 들어오는 요청을 프론트 컨트롤러로 동적 라우팅하는 데 성공했습니다. 하지만 실제 웹 서비스에서는 회원가입이나 로그인처럼 &lt;b&gt;보안이 중요하고 데이터 양이 많은 경우 무조건 &lt;code&gt;POST&lt;/code&gt; 방식을 사용&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;POST&lt;/code&gt; 방식은 데이터를 URL이 아닌 HTTP 메시지의 &lt;b&gt;Body(본문)&lt;/b&gt; 안에 안전하게 숨겨서 보냅니다. 최근에는 이 본문 데이터로 &lt;b&gt;JSON(JavaScript Object Notation)&lt;/b&gt; 포맷을 가장 많이 사용합니다. 이번 시간에는 클라이언트가 보낸 JSON 문자열을 자바 객체(DTO)로 변환해 주는 마법의 도구, &lt;b&gt;Jackson 라이브러리&lt;/b&gt;를 도입해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Jackson 라이브러리와 역직렬화(Deserialization)의 이해&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 짜기 전에, Jackson이 도대체 무슨 일을 하는지 핵심 개념 하나만 짚고 넘어가겠습니다. 바로 &lt;b&gt;'역직렬화(Deserialization)'&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;직렬화(Serialization):&lt;/b&gt; 자바 객체(DTO)를 네트워크로 전송하기 위해 JSON 문자열로 바꾸는 과정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;역직렬화(Deserialization):&lt;/b&gt; 네트워크를 통해 들어온 JSON 문자열을 다시 자바 객체(DTO)로 조립하는 과정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 POST 요청 바디에 &lt;code&gt;{&quot;email&quot;: &quot;test@gmail.com&quot;}&lt;/code&gt; 이라는 텍스트를 담아 보내면, &lt;b&gt;Jackson 라이브러리는 이를 읽고 &lt;code&gt;UserDTO&lt;/code&gt;라는 자바 객체로 역직렬화&lt;/b&gt;해 줍니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ffb347; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;Jackson과 자바 리플렉션(Reflection)&lt;/b&gt;&lt;br /&gt;Jackson 라이브러리가 역직렬화를 할 때 사용하는 핵심 기술이 바로 우리가 6편에서 배웠던 &lt;b&gt;리플렉션&lt;/b&gt;입니다. 구체적인 클래스 타입을 알지 못하더라도 리플렉션을 통해 동적으로 접근하여 &lt;b&gt;기본 생성자로 빈 깡통 객체를 먼저 생성한 후, Setter 등을 통해 값을 주입&lt;/b&gt;하게 됩니다. (이 사실을 꼭 기억해 주세요! 뒤에서 엄청난 에러와 마주하게 됩니다.)&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. 라이브러리 세팅 및 타겟 컨트롤러 준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론을 알아봤으니 본격적으로 &lt;code&gt;build.gradle&lt;/code&gt;에 Jackson의 핵심 모듈을 추가해 줍니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// JSON 파싱을 위한 Jackson 코어 라이브러리 (자바 프로젝트용)
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 회원가입 로직을 처리할 &lt;code&gt;UserController&lt;/code&gt;에 &lt;code&gt;UserDTO&lt;/code&gt;를 파라미터로 받는 &lt;code&gt;join&lt;/code&gt; 메서드를 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
package org.example.controller;
import org.example.dto.UserDTO;

public class UserController {
    // POST 요청을 받아 처리할 메서드
    public String join(UserDTO dto) {
        System.out.println(&quot;--- 회원가입 로직 실행 ---&quot;);
        System.out.println(&quot;이메일: &quot; + dto.getEmail());
        System.out.println(&quot;이름: &quot; + dto.getName());
        
        // 처리 결과를 JSON 형태의 문자열로 직접 조립하여 반환
        return &quot;{ \&quot;message\&quot;: \&quot;&quot; + dto.getName() + &quot;님 환영합니다!\&quot; }&quot;; 
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. DispatcherServlet 고도화: POST 분기 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 프론트 컨트롤러가 &lt;code&gt;POST&lt;/code&gt; 요청을 구별하여 Jackson에게 역직렬화를 지시하도록 로직을 수정합니다. 이 부분이 바로 &lt;b&gt;스프링의 &lt;code&gt;@PostMapping&lt;/code&gt;과 &lt;code&gt;@RequestBody&lt;/code&gt;가 탄생한 배경&lt;/b&gt;이기도 합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// DispatcherServlet.java 내부
String httpMethod = request.getMethod(); // &quot;GET&quot; or &quot;POST&quot;
String body = request.getBody();         // JSON 텍스트 (POST일 때만 존재)

String resultBody = &quot;&quot;;

if (httpMethod.equals(&quot;POST&quot;)) {
    //     핵심: Jackson 라이브러리의 ObjectMapper 출동!
    ObjectMapper objectMapper = new ObjectMapper();
    
    //     핵심: JSON 문자열(body)을 UserDTO 자바 객체로 역직렬화 (데이터 바인딩)
    UserDTO userDTO = objectMapper.readValue(body, UserDTO.class);

    // 리플렉션으로 메서드를 실행할 때, 변환된 DTO를 파라미터로 넘겨줌!
    Method method = clazz.getMethod(methodName, UserDTO.class);
    resultBody = (String) method.invoke(controllerInstance, userDTO);

} else if (httpMethod.equals(&quot;GET&quot;)) {
    // 파라미터가 없는 기존 GET 방식 로직
    Method method = clazz.getMethod(methodName);
    resultBody = (String) method.invoke(controllerInstance);
}

response.setBody(resultBody);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.   트러블슈팅: Jackson과 기본 생성자 에러의 비밀&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 이제 포스트맨(Postman)이나 개발자 도구를 이용해 POST 요청을 날려보겠습니다. 성공할 줄 알았는데 콘솔에 어마어마한 에러가 쏟아졌습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffe6e6; border-left: 4px solid #d32f2f; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;&lt;code&gt;com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `org.example.dto.UserDTO` (no Creators, like default constructor, exist)&lt;/code&gt;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 메시지를 직역하면 &lt;b&gt;&quot;UserDTO 객체를 만들 수 없어! 기본 생성자(default constructor)가 없잖아!&quot;&lt;/b&gt;라는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  왜 이런 에러가 났을까?&lt;/b&gt;&lt;br /&gt;앞서 2번 목차에서 Jackson은 &lt;b&gt;리플렉션&lt;/b&gt;을 통해 객체를 생성한다고 설명했습니다. 리플렉션은 &lt;code&gt;newInstance()&lt;/code&gt;를 호출하여 일단 파라미터가 없는 깡통 객체를 만들어야 하는데, 제 &lt;code&gt;UserDTO&lt;/code&gt;에는 파라미터가 있는 생성자만 존재하여 자바가 기본 생성자를 자동으로 만들어주지 않았습니다. 그 결과 Jackson이 깡통 객체를 만들지 못해 폭발해 버린 것입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt; ️ 해결 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 알았으니 해결은 아주 간단합니다. &lt;code&gt;UserDTO&lt;/code&gt;에 빈 껍데기 역할을 할 &lt;b&gt;기본 생성자&lt;/b&gt;를 명시적으로 추가해 줍니다. (스프링에서 DTO나 Entity를 만들 때 &lt;code&gt;@NoArgsConstructor&lt;/code&gt;가 필수인 이유가 바로 이 잭슨의 역직렬화 원리 때문입니다!)&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
public class UserDTO {
    String email;
    String name;
    String password;

    //     핵심: Jackson의 리플렉션을 위한 기본 생성자 필수 추가!
    public UserDTO() {} 

    // ... 파라미터 생성자, Getter, Setter 생략 ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 최종 테스트: 완벽하게 동작하는 나의 미니 스프링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그를 수정하고 다시 서버를 띄운 뒤, 클라이언트(브라우저 콘솔)에서 &lt;code&gt;fetch&lt;/code&gt; API를 통해 JSON 데이터를 담아 POST 요청을 날려보았습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[서버 인텔리제이 콘솔 출력]&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
/0:0:0:0:0:0:0:1와 연결 성공!
Start Line: POST /user/join HTTP/1.1
--- 회원가입 로직 실행 ---
이메일: test@gmail.com
이름: 스프링장인
비밀번호: 1234
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[클라이언트 브라우저 응답 확인]&lt;/h4&gt;
&lt;pre class=&quot;json&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
{ &quot;message&quot;: &quot;스프링장인님 환영합니다!&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 내부에서 클라이언트가 보낸 JSON이 DTO로 완벽히 변환되어 비즈니스 로직을 타고, 컨트롤러가 뱉어낸 응답이 다시 클라이언트에게 예쁘게 도착했습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 대장정을 마무리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로써 &lt;b&gt;'순수 Java로 WAS 프레임워크 만들기'&lt;/b&gt;라는 긴 여정이 막을 내렸습니다. 톰캣이 열어주는 소켓 연결부터, HTTP 메시지 파싱, 프론트 컨트롤러의 동적 라우팅, 그리고 Jackson을 이용한 데이터 바인딩까지 모두 직접 구현해 보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트를 통해 &lt;code&gt;@RestController&lt;/code&gt;, &lt;code&gt;@PostMapping&lt;/code&gt;, &lt;code&gt;@RequestBody&lt;/code&gt; 같은 스프링의 마법 같은 어노테이션들이 내부적으로 어떻게 동작하는지 그 원리를 바닥부터 뜯어볼 수 있었습니다. 이제 스프링 프레임워크를 사용할 때, 단순히 사용법을 암기하는 것이 아니라 그 이면에 숨겨진 '이유'를 알게 된 뜻깊은 시간이었습니다.&lt;/p&gt;</description>
      <category>정리/WAS</category>
      <category>Jackson</category>
      <category>Was</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/182</guid>
      <comments>https://baby-t.tistory.com/182#entry182comment</comments>
      <pubDate>Sat, 16 May 2026 18:11:02 +0900</pubDate>
    </item>
    <item>
      <title>순수 Java로 WAS 구현 (8) - 프론트 컨트롤러(DispatcherServlet) 직접 구현과 HTTP 응답 조립</title>
      <link>https://baby-t.tistory.com/181</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 시작하며: if-else 지옥에서 벗어나기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서 우리는 수많은 API 요청을 &lt;code&gt;if-else&lt;/code&gt;문으로 처리하는 것의 한계를 깨닫고, &lt;b&gt;프론트 컨트롤러(Front Controller) 패턴&lt;/b&gt;을 도입하기로 결정했습니다. 이번 시간에는 단 하나의 문지기 서블릿(DispatcherServlet)이 &lt;b&gt;리플렉션(Reflection)&lt;/b&gt;을 무기로 모든 요청을 알맞은 컨트롤러에 동적으로 분배하는 마법을 직접 코드로 구현해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 아키텍처 설계 및 HandlerMapping (매핑 사전) 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 프론트 컨트롤러인 &lt;code&gt;DispatcherServlet&lt;/code&gt; 클래스를 만듭니다. 이 문지기는 클라이언트가 요청한 URL(예: &lt;code&gt;/user&lt;/code&gt;)을 보고, 실제 메모리에 있는 어떤 자바 클래스(예: &lt;code&gt;org.example.controller.UserController&lt;/code&gt;)를 실행해야 할지 알아야 합니다. 이를 위해 &lt;b&gt;매핑 사전(Map)&lt;/b&gt;을 정의합니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
public class DispatcherServlet {
    // 스프링의 HandlerMapping과 같은 역할을 하는 매핑 사전
    private static final Map&amp;lt;String, String&amp;gt; handlerMapping = new HashMap&amp;lt;&amp;gt;();

    // 정적 초기화 블록 (Static Initialization Block)
    static {
        handlerMapping.put(&quot;user&quot;, &quot;org.example.controller.UserController&quot;);
    }
    
    // ... servlet 메서드 구현 (아래 참고)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #4a90e2; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;왜 static 블록을 사용할까? (자바 정적 초기화 블록)&lt;/b&gt;&lt;br /&gt;서버에 1만 명의 접속자가 몰릴 때마다 매번 사전에 &lt;code&gt;put&lt;/code&gt;을 한다면 엄청난 리소스 낭비입니다. &lt;code&gt;static {}&lt;/code&gt; 블록을 사용하면 톰캣(WAS)이 켜지면서 이 클래스가 메모리에 올라갈 때 &lt;b&gt;단 한 번만 초기화&lt;/b&gt;됩니다. 마치 식당 오픈 전에 메뉴판을 한 번만 걸어두는 것과 같은 이치이며, 실제 스프링 프레임워크 내부에서도 매우 빈번하게 사용되는 기법입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 타겟 컨트롤러 생성 및 리턴 타입의 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 위해 간단한 &lt;code&gt;UserController&lt;/code&gt;를 만듭니다. 여기서 핵심은 컨트롤러가 &lt;code&gt;HttpResponse&lt;/code&gt; 객체를 직접 만지는 것이 아니라, &lt;b&gt;순수한 데이터(String)만 반환&lt;/b&gt;하도록 역할을 철저히 분리(Separation of Concerns)하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
package org.example.controller;

public class UserController {
    // 뷰(View)의 이름이나, 클라이언트에게 보낼 순수 데이터를 반환합니다.
    public String login() {
        return &quot;Login Success!&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 동적 라우팅 구현 (리플렉션의 활용)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;DispatcherServlet&lt;/code&gt;의 핵심 로직을 구현합니다. 파싱된 URI를 쪼개어 매핑 사전에서 클래스를 찾고, 리플렉션으로 동적 실행을 합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
public static HttpResponse servlet(HttpRequest request) throws Exception {
    HttpResponse response = new HttpResponse();
    response.setVersion(request.getVersion());
    
    // 1. URI 분석 (예: /user/login)
    String[] tokens = request.getUri().split(&quot;/&quot;);
    if (tokens.length &amp;lt; 3) return null; // 잘못된 URL 방어
    
    String prefix = tokens[1]; // &quot;user&quot;
    String methodName = tokens[2]; // &quot;login&quot;
    
    // 2. 매핑 사전에서 패키지 풀 네임 찾기
    String className = handlerMapping.get(prefix);
    
    // 3. 리플렉션(Reflection)을 이용한 동적 실행!
    Class&amp;lt;?&amp;gt; clazz = Class.forName(className); // 설계도 훔쳐오기
    Object controllerInstance = clazz.getDeclaredConstructor().newInstance(); // 인스턴스 생성
    Method method = clazz.getMethod(methodName); // 메서드 찾기
    
    // 4. 실행 후 반환된 String(&quot;Login Success!&quot;)을 응답 바디에 담기
    String resultBody = (String) method.invoke(controllerInstance);
    response.setBody(resultBody);
    
    // 5. HTTP 상태 코드 기본 세팅
    response.setStatus_code(&quot;200&quot;);
    response.setReason_phrase(&quot;OK&quot;);

    return response;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff6b6b; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;트러블슈팅: Class.forName()의 깐깐함&lt;/b&gt;&lt;br /&gt;처음에는 &lt;code&gt;Class.forName(&quot;user&quot;)&lt;/code&gt;처럼 입력하면 될 줄 알았으나 에러가 발생했습니다. 리플렉션으로 클래스를 로드할 때는 반드시 &lt;code&gt;org.example.controller.UserController&lt;/code&gt; 처럼 &lt;b&gt;패키지 경로가 포함된 풀 네임(FQN)&lt;/b&gt;을 명시해야 함을 뼈저리게 배웠습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. HTTP 응답(Response) 객체 설계와 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 라우팅을 통해 컨트롤러를 실행하고 나면, 그 결과를 담아 브라우저로 쏴줄 데이터 바구니가 필요합니다. HTTP 응답 규격(Status Line, Header, Body)을 명확하게 매핑할 수 있도록 다음과 같이 &lt;code&gt;HttpResponse&lt;/code&gt; 객체를 설계하고 매인 로직에서 생성해 주었습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
package org.example.dto;

import java.util.HashMap;
import java.util.Map;

public class HttpResponse {
    private String version;         // HTTP/1.1 등
    private String status_code;     // 200, 404 등
    private String reason_phrase;   // OK, Not Found 등

    // 다중 헤더 관리를 위한 Map 구조 데이터 바구니
    private Map&amp;lt;String, String&amp;gt; headers = new HashMap&amp;lt;&amp;gt;();
    private String body;            // 변환된 데이터 본문

    public String getVersion() { return version; }
    public void setVersion(String version) { this.version = version; }

    public String getStatus_code() { return status_code; }
    public void setStatus_code(String status_code) { this.status_code = status_code; }

    public String getReason_phrase() { return reason_phrase; }
    public void setReason_phrase(String reason_phrase) { this.reason_phrase = reason_phrase; }

    public Map&amp;lt;String, String&amp;gt; getHeaders() { return headers; }
    public void addHeader(String key, String value) { this.headers.put(key, value); }

    public String getBody() { return body; }
    public void setBody(String body) { this.body = body; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. HTTP 응답(Response) 포맷 직접 조립하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에서 데이터 처리가 끝났다고 해서 &lt;code&gt;out.println(response)&lt;/code&gt;처럼 자바 객체를 브라우저에 그대로 던지면 안 됩니다. 브라우저는 자바의 주소값을 이해하지 못하며, 오직 &lt;b&gt;HTTP 규약(Start Line + Header + Empty Line + Body)에 맞는 순수 텍스트 문자열&lt;/b&gt;만 읽을 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;code&gt;HttpResponse&lt;/code&gt; 객체 내부에 스스로의 메타데이터를 HTTP 텍스트 규격 프로토콜에 완벽하게 맞춰 출력 스트림으로 밀어 넣어주는 &lt;code&gt;send()&lt;/code&gt; 메서드를 구현했습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// HttpResponse.java 내부
public void send(PrintWriter out) {
    // 1. Status Line 조립 (예: HTTP/1.1 200 OK)
    out.print(this.version + &quot; &quot; + this.status_code + &quot; &quot; + this.reason_phrase + &quot;\r\n&quot;);
    
    // 2. Response Headers 데이터 생성 (한글 깨짐 방지 및 Body 길이 세팅)
    out.print(&quot;Content-Type: text/plain;charset=UTF-8\r\n&quot;);
    if (this.body != null) {
        out.print(&quot;Content-Length: &quot; + this.body.getBytes().length + &quot;\r\n&quot;);
    }
    
    // 3. 헤더와 바디를 구분하는 빈 줄 (CRLF) 필수!
    out.print(&quot;\r\n&quot;);
    
    // 4. Message Body 발사
    if (this.body != null) {
        out.print(this.body);
    }
    
    out.flush(); // 스트림에 고인 데이터를 브라우저로 완전히 밀어내기!
}
&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;code&gt;RequestHandler&lt;/code&gt;가 이 기능을 이어받아 &lt;code&gt;response.send(out);&lt;/code&gt;을 단 한 줄 호출해 주면, 서버 내부에서 동적으로 뜯고 맛본 비즈니스 데이터 결과물이 프로토콜 텍스트로 탈바꿈하여 정상 송출되는 전체 통신 사이클이 비로소 완성됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 브라우저 개발자 도구(F12)로 결과 확인하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 띄우고 크롬 주소창에 &lt;code&gt;http://localhost:8080/user/login&lt;/code&gt;을 입력하니, 화면에 &lt;b&gt;&quot;Login Success!&quot;&lt;/b&gt;가 아주 예쁘게 출력되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;124&quot; data-origin-height=&quot;47&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B59MM/dJMcacXqqL4/hhZK2x4moIEZopjOB9Ro90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B59MM/dJMcacXqqL4/hhZK2x4moIEZopjOB9Ro90/img.png&quot; data-alt=&quot;크롬 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B59MM/dJMcacXqqL4/hhZK2x4moIEZopjOB9Ro90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB59MM%2FdJMcacXqqL4%2FhhZK2x4moIEZopjOB9Ro90%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;124&quot; height=&quot;47&quot; data-origin-width=&quot;124&quot; data-origin-height=&quot;47&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;크롬 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기서 끝낼 수 없죠! 개발자 도구(F12)의 Network 탭을 열어 우리가 조립한 패킷이 잘 도착했는지 뜯어보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZvzj2/dJMcagMkTRo/pZMDIrOACXqIle0zNPO9BK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZvzj2/dJMcagMkTRo/pZMDIrOACXqIle0zNPO9BK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZvzj2/dJMcagMkTRo/pZMDIrOACXqIle0zNPO9BK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZvzj2%2FdJMcagMkTRo%2FpZMDIrOACXqIle0zNPO9BK%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;431&quot; height=&quot;97&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 마무리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 프레임워크가 뒤에서 조용히 처리해 주던 &lt;code&gt;DispatcherServlet&lt;/code&gt;의 동적 라우팅과, &lt;code&gt;@ResponseBody&lt;/code&gt;가 해주는 HTTP 규약 변환 과정을 순수 자바로 밑바닥부터 구현해 냈습니다. 이로써 스프링 MVC 아키텍처의 핵심 심장부를 완벽히 이해하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지는 파라미터가 없는 단순한 &lt;code&gt;GET&lt;/code&gt; 요청을 처리했습니다. 다음 포스팅에서는 드디어 외부 라이브러리인 &lt;b&gt;Jackson&lt;/b&gt;을 도입하여, 클라이언트가 보내는 &lt;code&gt;POST&lt;/code&gt; 요청의 JSON 바디 데이터를 자바 객체(DTO)로 변환(데이터 바인딩)하는 과정을 다뤄보겠습니다!&lt;/p&gt;</description>
      <category>정리/WAS</category>
      <category>DispatcherServlet</category>
      <category>Was</category>
      <category>리플렉션</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/181</guid>
      <comments>https://baby-t.tistory.com/181#entry181comment</comments>
      <pubDate>Sat, 16 May 2026 07:54:17 +0900</pubDate>
    </item>
    <item>
      <title>순수 Java로 WAS 구현 (7) - 톰캣의 정체와 프론트 컨트롤러(Front Controller)의 탄생</title>
      <link>https://baby-t.tistory.com/180</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 서론: 100개의 API, 100개의 if문?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 우리는 클라이언트의 요청(HTTP 메시지)을 읽고, 파싱하고, 리플렉션이라는 마법의 도구까지 손에 넣었습니다. 하지만 막상 파싱된 URI(예: &lt;code&gt;/login&lt;/code&gt;, &lt;code&gt;/join&lt;/code&gt;, &lt;code&gt;/board&lt;/code&gt;)를 바탕으로 비즈니스 로직을 실행하려고 하니 막막해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 서버가 처리해야 할 API가 100개라면, &lt;code&gt;RequestHandler&lt;/code&gt; 안에 &lt;code&gt;if (uri.equals(&quot;/login&quot;)) { ... } else if (uri.equals(&quot;/join&quot;)) { ... }&lt;/code&gt; 처럼 100개의 분기문을 만들어야 할까요? 이는 유지보수 관점에서 끔찍한 일입니다. 이 문제를 과거의 선배 개발자들은 어떻게 해결했을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 서블릿(Servlet)과 톰캣(Tomcat)의 진짜 정체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 등장한 자바의 표준 규약이 바로 &lt;b&gt;서블릿(Servlet)&lt;/b&gt;입니다. 서블릿은 &lt;i&gt;'클라이언트의 요청을 처리하고 결과를 반환하는 자바 웹 프로그래밍 기술'&lt;/i&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거(CGI 시절)에는 요청이 올 때마다 무거운 '프로세스(Process)'를 통째로 복제해서 실행했기 때문에 사용자가 조금만 몰려도 서버가 뻗어버렸습니다. 이를 혁신하기 위해 &lt;b&gt;톰캣(Tomcat, 서블릿 컨테이너)&lt;/b&gt;이 등장합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #4a90e2; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;놀라운 사실: 우리가 만든 코드가 바로 '미니 톰캣'입니다!&lt;/b&gt;&lt;br /&gt;우리가 작성한 &lt;code&gt;WebApplicationServer&lt;/code&gt;가 소켓을 열어 연결을 받고, &lt;code&gt;RequestHandler&lt;/code&gt;를 스레드(Thread)로 실행시키는 구조 기억하시나요? 이것이 바로 톰캣이 동작하는 핵심 원리입니다. 톰캣은 프로세스 대신 가벼운 &lt;b&gt;스레드&lt;/b&gt;를 사용하여 서블릿 객체를 실행시킵니다.&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[면접 단골 질문] 톰캣은 스레드를 매번 새로 만들까요?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리의 코드는 현재 &lt;code&gt;new Thread().start()&lt;/code&gt;를 통해 매번 스레드를 생성합니다. 하지만 진짜 톰캣은 서버 메모리를 보호하기 위해 &lt;b&gt;스레드 풀(Thread Pool)&lt;/b&gt;을 사용합니다. 미리 일정 개수(기본 200개)의 스레드를 만들어 두고, 요청이 오면 쉬고 있는 스레드를 할당해 준 뒤 작업이 끝나면 다시 반납받는 방식을 사용하여 성능을 극대화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 서블릿의 매핑 지옥과 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 덕분에 성능 문제는 해결되었지만, 초창기 서블릿에는 여전히 치명적인 단점이 있었습니다. 바로 &lt;b&gt;1 URL = 1 서블릿 클래스&lt;/b&gt;라는 규칙 때문이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/login&lt;/code&gt;을 처리하는 LoginServlet, &lt;code&gt;/join&lt;/code&gt;을 처리하는 JoinServlet 등 URL이 늘어날 때마다 무수히 많은 클래스를 만들어야 했고, 이 연결 고리를 &lt;code&gt;web.xml&lt;/code&gt;이라는 설정 파일에 일일이 다 적어주어야 했습니다. (이를 매핑 지옥이라고 부릅니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 구원자, 프론트 컨트롤러(Front Controller) 패턴의 등장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매핑 지옥에 지친 개발자들은 아주 기발한 아키텍처 패턴을 고안해 냅니다. 바로 &lt;b&gt;프론트 컨트롤러(Front Controller) 패턴&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; text-align: center; font-weight: bold; margin-bottom: 15px;&quot;&gt;클라이언트 ➡️ [ 프론트 컨트롤러 (단 1개의 서블릿) ] ➡️ 각 Controller 메서드로 분배&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버의 입구에 &lt;b&gt;문지기 역할을 하는 단 하나의 대표 서블릿(DispatcherServlet)&lt;/b&gt;만 둡니다.&lt;/li&gt;
&lt;li&gt;모든 요청은 일단 이 문지기가 다 받습니다.&lt;/li&gt;
&lt;li&gt;문지기는 우리가 6편에서 배운 &lt;b&gt;리플렉션(Reflection)&lt;/b&gt; 기술을 활용하여, 들어온 URI를 보고 알맞은 일반 자바 클래스(Controller)의 메서드를 쏙쏙 찾아내어 동적으로 실행(Invoke)해 줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 바로 현재 전 세계에서 가장 많이 쓰이는 웹 프레임워크인 &lt;b&gt;Spring MVC의 핵심 동작 원리&lt;/b&gt;입니다. 개발자는 더 이상 서블릿을 만들거나 &lt;code&gt;web.xml&lt;/code&gt;을 건드릴 필요 없이, &lt;code&gt;@GetMapping(&quot;/login&quot;)&lt;/code&gt; 같은 어노테이션 하나만 달아주면 스프링의 프론트 컨트롤러가 알아서 찾아가는 마법이 여기서 시작된 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 다음 단계로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 목표가 아주 명확해졌습니다. 우리가 만들었던 &lt;code&gt;RequestHandler&lt;/code&gt;의 역할을 더욱 고도화하여, 스프링의 심장인 &lt;b&gt;DispatcherServlet(프론트 컨트롤러)&lt;/b&gt;을 직접 구현해 볼 차례입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 편에서 파싱해 둔 &lt;code&gt;HttpRequest&lt;/code&gt; 객체의 URI 정보를 꺼내고, 리플렉션을 이용해 알맞은 컨트롤러 메서드를 찾아 실행하는 동적 라우팅 시스템을 다음 포스팅에서 본격적으로 코딩해 보겠습니다!&lt;/p&gt;</description>
      <category>정리/WAS</category>
      <category>Was</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/180</guid>
      <comments>https://baby-t.tistory.com/180#entry180comment</comments>
      <pubDate>Fri, 15 May 2026 15:43:33 +0900</pubDate>
    </item>
    <item>
      <title>순수 Java로 WAS 구현 (6) - 프레임워크의 마법, 리플렉션(Reflection) 완벽 이해</title>
      <link>https://baby-t.tistory.com/179</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 리플렉션(Reflection)이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAS나 스프링(Spring) 프레임워크를 쓰다 보면 문득 궁금해집니다. &lt;i&gt;&quot;내가 만든 클래스나 메서드 이름을 프레임워크가 어떻게 알고 실행해 주는 걸까?&quot;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 해답이 바로 &lt;b&gt;리플렉션(Reflection)&lt;/b&gt;입니다. 리플렉션은 구체적인 클래스 타입을 알지 못해도, 런타임(실행 중)에 클래스의 이름만으로 해당 클래스의 정보(메서드, 타입, 변수 등)에 접근하고 객체를 생성하거나 메서드를 호출할 수 있게 해주는 자바의 강력한 API입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Class 객체를 가져오는 3가지 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리플렉션을 시작하려면 가장 먼저 해당 클래스의 메타데이터가 담긴 &lt;code&gt;Class&lt;/code&gt; 객체를 메모리(JVM 힙 영역)에서 가져와야 합니다. 상황에 따라 3가지 방법을 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// 예시로 사용할 타겟 클래스
public class User { ... }

// 1. 객체가 이미 생성되어 있을 때 (인스턴스 사용)
User user = new User();
Class&amp;lt;?&amp;gt; clazz1 = user.getClass();

// 2. 코딩할 때 클래스 타입을 정확히 알고 있을 때 (클래스 리터럴 사용)
Class&amp;lt;?&amp;gt; clazz2 = User.class;

// 3. 클래스 이름(문자열)만 알고 있을 때 (WAS나 프레임워크에서 가장 많이 사용!)
Class&amp;lt;?&amp;gt; clazz3 = Class.forName(&quot;org.example.User&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 동적으로 클래스 조작하기 (생성자, 필드, 메서드)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 &lt;code&gt;Class.forName()&lt;/code&gt;을 사용하여, 문자열만으로 객체를 생성하고 필드를 조작하며 메서드를 실행하는 실전 예시를 살펴보겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[타겟 클래스 준비]&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부러 &lt;code&gt;private&lt;/code&gt; 필드와 매개변수가 있는 메서드를 만들어 보았습니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
package org.example;

public class UserController {
    private String name = &quot;초기값&quot;;

    public void hello(String message) {
        System.out.println(name + &quot;님의 메시지: &quot; + message);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[리플렉션 실행 코드]&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        // 1. 클래스 정보 로드
        Class&amp;lt;?&amp;gt; clazz = Class.forName(&quot;org.example.UserController&quot;);

        // 2. 동적으로 생성자 가져와서 객체 초기화 (인스턴스 생성)
        Constructor&amp;lt;?&amp;gt; constructor = clazz.getDeclaredConstructor();
        Object controller = constructor.newInstance(); 
        // 결과: new UserController() 와 동일!

        // 3. 동적으로 private 필드 가져와서 강제로 값 조작하기
        Field nameField = clazz.getDeclaredField(&quot;name&quot;);
        nameField.setAccessible(true); //   private 접근 허용 (핵심!)
        nameField.set(controller, &quot;홍길동&quot;); 

        // 4. 동적으로 메서드 가져와서 실행(invoke)하기
        // &quot;hello&quot;라는 이름에, 파라미터로 String을 받는 메서드 찾기
        Method helloMethod = clazz.getDeclaredMethod(&quot;hello&quot;, String.class);
        
        // invoke(실행할 인스턴스, 넘겨줄 파라미터)
        helloMethod.invoke(controller, &quot;반갑습니다!&quot;); 
        // 출력 결과: 홍길동님의 메시지: 반갑습니다!
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ffb347; padding: 15px; margin-top: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;왜 private 필드까지 조작할까?&lt;/b&gt;&lt;br /&gt;스프링을 쓸 때 &lt;code&gt;@Autowired&lt;/code&gt;나 &lt;code&gt;@Value&lt;/code&gt; 어노테이션을 &lt;code&gt;private&lt;/code&gt; 변수 위에 달아도 의존성이 주입되는 것을 본 적이 있을 것입니다. 스프링이 바로 이 리플렉션의 &lt;code&gt;setAccessible(true)&lt;/code&gt;를 사용하여 강제로 값을 밀어 넣어주기 때문입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 왜 평소에는 사용하지 않을까? (리플렉션의 단점)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 마법 같은 기술임에도 불구하고, 일반적인 비즈니스 로직에서는 리플렉션 사용을 지양해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;컴파일 타임 에러 검출 불가:&lt;/b&gt; 클래스명이나 메서드명을 문자열로 넘기기 때문에 오타가 나도 컴파일(빌드) 시점에 잡히지 않고, 런타임에 서버가 터지게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 저하:&lt;/b&gt; 일반적인 메서드 호출보다 캡슐화 확인, 타입 검사 등의 추가 과정이 필요하므로 실행 속도가 느립니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;객체지향 파괴:&lt;/b&gt; &lt;code&gt;private&lt;/code&gt; 접근 제어자를 무시할 수 있어 캡슐화 원칙이 깨집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 다음 목표: WAS의 라우팅과 프론트 컨트롤러(Front Controller)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 리플렉션은 &lt;b&gt;'우리처럼 프레임워크나 WAS를 직접 만들 때만'&lt;/b&gt; 사용하는 강력한 무기입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서 클라이언트의 HTTP 요청을 &lt;code&gt;HttpRequest&lt;/code&gt; 객체로 파싱해 두었습니다. 이제 이 파싱된 URI(ex: &lt;code&gt;/login&lt;/code&gt;)를 보고 적절한 Controller 메서드를 동적으로 찾아 실행해야 합니다. 이를 구현하기 위해, 다음 포스팅에서는 Java Servlet의 개념과 &lt;b&gt;프론트 컨트롤러(Front Controller) 패턴&lt;/b&gt;에 대해 먼저 학습하고 코드로 적용해 보겠습니다.&lt;/p&gt;</description>
      <category>정리/WAS</category>
      <category>Was</category>
      <category>리플렉션</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/179</guid>
      <comments>https://baby-t.tistory.com/179#entry179comment</comments>
      <pubDate>Fri, 15 May 2026 14:29:20 +0900</pubDate>
    </item>
    <item>
      <title>순수 Java로 WAS 구현 (5) - HTTP 요청 파서(Parser) 구현과 3가지 트러블슈팅</title>
      <link>https://baby-t.tistory.com/178</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 아키텍처 설계: 점원과 번역가의 역할 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난번 HTTP 스펙 분석을 통해, 클라이언트가 보내는 요청이 결국 &lt;b&gt;'정해진 규칙을 가진 긴 문자열'&lt;/b&gt;이라는 것을 알게 되었습니다. 이제 이 문자열을 잘라서 의미 있는 자바 객체로 변환해 주는 파서(Parser)를 만들어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적인 코딩에 앞서, 객체지향적인 책임 분배를 위해 클래스 구조를 다음과 같이 설계했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;WebApplicationServer:&lt;/b&gt; 가게 입구(Port 8080)를 지키며 소켓 연결을 무한 대기합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RequestHandler:&lt;/b&gt; 손님이 오면 할당되는 '점원(Thread)'입니다. 입출력 스트림을 관리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HttpRequestParser:&lt;/b&gt; 문자열을 잘라주는 '번역가'입니다. (유틸리티 클래스로 활용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HttpRequest:&lt;/b&gt; 파싱된 결과(Method, URI, Header, Body)를 예쁘게 담을 '바구니(DTO)'입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 구현 1단계: Start Line (시작 줄) 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 보낸 첫 줄인 Start Line(예: &lt;code&gt;GET /index.html HTTP/1.1&lt;/code&gt;)을 읽어 들여 메서드, URI, 버전을 추출합니다. 문자열의 첫 번째 줄만 읽으면 되므로 &lt;code&gt;readLine()&lt;/code&gt;을 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// 1. Start Line 읽기
String startLine = in.readLine();
if (startLine == null) return null; // 비정상 종료 방어

// 공백을 기준으로 3토막 내기
String[] tokens = startLine.split(&quot; &quot;);

HttpRequest request = new HttpRequest();
request.setMethod(tokens[0].trim());
request.setUri(tokens[1].trim());
request.setVersion(tokens[2].trim());
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 구현 2단계: Header (헤더) 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더는 빈 줄(Empty Line)이 나올 때까지 여러 줄이 이어집니다. &lt;code&gt;while&lt;/code&gt; 문을 돌며 &lt;code&gt;필드명: 값&lt;/code&gt; 형태를 추출하여 &lt;code&gt;Map&lt;/code&gt;에 저장합니다.&lt;/p&gt;
&lt;pre class=&quot;processing&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// 2. Header 읽기
String headerLine;
// 빈 줄(&quot;&quot;)이 나올 때까지 무한 반복
while ((headerLine = in.readLine()) != null &amp;amp;&amp;amp; !headerLine.isEmpty()) {
    // 콜론을 기준으로 분리
    String[] headerTokens = headerLine.split(&quot;:&quot;, 2);
    request.addHeader(headerTokens[0].trim(), headerTokens[1].trim()); 
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 구현 3단계: Message Body (바디) 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바디는 헤더와 달리 끝을 알리는 기호가 없습니다. 따라서 헤더의 &lt;code&gt;Content-Length&lt;/code&gt;를 확인한 뒤, 정확히 그 길이만큼만 바이트 단위로 읽어와야 합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// 3. Body 읽기 (Content-Length가 있는 경우만)
if (request.getHeaders().containsKey(&quot;Content-Length&quot;)) {
    int contentLength = Integer.parseInt(request.getHeaders().get(&quot;Content-Length&quot;));
    char[] bodyBuffer = new char[contentLength];
    
    // 정확히 contentLength만큼만 읽어서 배열에 저장
    in.read(bodyBuffer, 0, contentLength);
    request.setBody(new String(bodyBuffer));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 핵심 트러블슈팅: 구현 시 반드시 고려해야 할 3가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파서를 구현하면서 마주했던 가장 중요한 기술적 판단 지점들을 정리해 보았습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #f0f7ff; border-left: 4px solid #4a90e2; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;고민 1. 헤더 파싱에도 Jackson(JSON) 라이브러리를 써야 할까?&lt;/b&gt;&lt;br /&gt;Jackson은 JSON 데이터를 파싱하는 도구입니다. 하지만 HTTP 헤더는 단순 텍스트 규약일 뿐 JSON이 아닙니다. &lt;b&gt;무거운 라이브러리 대신 순수 자바의 &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt;을 사용하는 것이 가볍고 설계상으로도 정석&lt;/b&gt;임을 확인했습니다. (Jackson은 나중에 Body의 JSON을 파싱할 때 활약할 예정입니다!)&lt;/div&gt;
&lt;div style=&quot;background-color: #fff0f0; border-left: 4px solid #ff6b6b; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;문제 2. split(&quot;:&quot;)의 치명적인 함정&lt;/b&gt;&lt;br /&gt;단순히 &lt;code&gt;split(&quot;:&quot;)&lt;/code&gt;을 쓰면 &lt;code&gt;Host: localhost:8080&lt;/code&gt; 같은 데이터가 3토막으로 깨집니다. &lt;b&gt;&lt;code&gt;split(&quot;:&quot;, 2)&lt;/code&gt;&lt;/b&gt;를 사용하여 아무리 콜론이 많아도 딱 2등분(키와 값)만 되도록 방어 로직을 구축했습니다.&lt;/div&gt;
&lt;div style=&quot;background-color: #fffaf0; border-left: 4px solid #ffb347; padding: 15px; margin-bottom: 15px; border-radius: 4px;&quot;&gt;  &lt;b&gt;문제 3. 바디를 readLine()으로 읽으면 안 되는 이유&lt;/b&gt;&lt;br /&gt;바디 데이터 끝에는 줄바꿈 기호(&lt;code&gt;\r\n&lt;/code&gt;)가 없을 수 있습니다. 이때 &lt;code&gt;readLine()&lt;/code&gt;을 쓰면 줄바꿈이 올 때까지 스레드가 영원히 대기하는 &lt;b&gt;무한 블로킹(Blocking)&lt;/b&gt;에 빠집니다. 반드시 &lt;code&gt;in.read()&lt;/code&gt;를 써서 정확한 길이만큼만 읽어야 합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 실전 테스트: 브라우저 연동 및 Ghost Connection 문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 진짜 크롬(Chrome) 브라우저를 열어 주소창에 &lt;code&gt;http://localhost:8080/test&lt;/code&gt;를 입력해 보았습니다. 콘솔에 브라우저가 보낸 날것의 데이터가 예쁘게 찍힙니다!&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #282c34; color: #abb2bf; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
서버 소켓 작동 중... 손님을 기다립니다.
/0:0:0:0:0:0:0:1와 연결 성공!
Start Line: GET /test HTTP/1.1
추출된 메서드: GET
추출된 URI: /test
--- 파싱된 헤더 목록 ---
Key: [User-Agent], Value: [Mozilla/5.0 ...]
Key: [Host], Value: [localhost:8080]
... (중략) ...
----------------------
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  돌발 에러 발생: NullPointerException&lt;/b&gt;&lt;br /&gt;테스트 도중 간헐적으로 다음과 같은 에러 로그가 찍혔습니다.&lt;/p&gt;
&lt;pre class=&quot;smali&quot; style=&quot;background-color: #ffe6e6; color: #d32f2f; padding: 10px; border-radius: 5px; font-size: 13px;&quot;&gt;&lt;code&gt;
Exception in thread &quot;Thread-1&quot; java.lang.NullPointerException: Cannot invoke &quot;HttpRequest.getMethod()&quot; because &quot;request&quot; is null
&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;'Ghost Connection'&lt;/b&gt; 특성 때문이었습니다. 파서가 &lt;code&gt;null&lt;/code&gt;을 반환할 때의 방어 로직을 &lt;code&gt;RequestHandler&lt;/code&gt;에 추가하여 해결했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f6f8fa; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;&lt;code&gt;
// RequestHandler.java 버그 픽스
HttpRequest request = HttpRequestParser.parse(in);

if (request == null) return; // 의미 없는 빈 연결은 즉시 종료!

System.out.println(&quot;추출된 메서드: &quot; + request.getMethod());
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 마무리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순수 자바만으로 복잡한 HTTP 요청을 &lt;code&gt;HttpRequest&lt;/code&gt; 객체로 완벽하게 변환해 냈습니다. 이 과정에서 라이브러리의 소중함과 동시에, 그 마법 같은 기능 뒤에 숨겨진 텍스트 처리와 예외 방어 로직의 본질을 이해할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 포스팅에서는 이렇게 파싱된 요청들을 적절한 비즈니스 로직으로 연결해 주는 &lt;b&gt;'리플렉션 기반의 동적 라우팅'&lt;/b&gt;을 다뤄보겠습니다.&lt;/p&gt;</description>
      <category>정리/WAS</category>
      <category>Was</category>
      <author>baby-t</author>
      <guid isPermaLink="true">https://baby-t.tistory.com/178</guid>
      <comments>https://baby-t.tistory.com/178#entry178comment</comments>
      <pubDate>Fri, 15 May 2026 13:47:33 +0900</pubDate>
    </item>
  </channel>
</rss>