<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>boradora</title>
    <link>https://boradora1.tistory.com/</link>
    <description>boradora의 개발 블로그</description>
    <language>ko</language>
    <pubDate>Tue, 11 Aug 2026 08:17:21 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>boradora</managingEditor>
    <image>
      <title>boradora</title>
      <url>https://tistory1.daumcdn.net/tistory/8650481/attach/fb44e1737ecf46928625944d94d9837d</url>
      <link>https://boradora1.tistory.com</link>
    </image>
    <item>
      <title>묵시적 커밋과 AUTO COMMIT | TRUNCATE와 DELETE 차이, 롤백 안 되는 자리</title>
      <link>https://boradora1.tistory.com/15</link>
      <description>&lt;!-- ══════════════════════ 이 글은 ══════════════════════
     COMMIT 을 안 쳤는데 이미 커밋돼 롤백이 안 되는 자리를 모았다.
     AUTO COMMIT 이 켜져 있는 도구들, DDL 이 트랜잭션을 끊는 묵시적 커밋,
     TRUNCATE 와 DELETE 가 갈리는 지점.

     ─ 티스토리 제목 (2026-08-03 확정) ─────────────────────
     묵시적 커밋과 AUTO COMMIT | TRUNCATE와 DELETE 차이, 롤백 안 되는 자리

     커버 이미지에 적힌 것과 같은 문장입니다. 커버는 `커버이미지/11-묵시적커밋.png`.
     아래 검색어가 제목 안에 들어가도록 잡았습니다.

     노리는 검색어  truncate delete 차이 / auto commit / 묵시적 커밋 / 롤백 안됨
     태그 후보      트랜잭션, 커밋, 롤백, TRUNCATE, DELETE, 데이터베이스, SQL
     ══════════════════════════════════════════════════ --&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;11-묵시적커밋.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAPPpm/dJMcabSHWMR/xXquJoaBFKPq6nbkNuLLxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAPPpm/dJMcabSHWMR/xXquJoaBFKPq6nbkNuLLxK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAPPpm/dJMcabSHWMR/xXquJoaBFKPq6nbkNuLLxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAPPpm%2FdJMcabSHWMR%2FxXquJoaBFKPq6nbkNuLLxK%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;3072&quot; height=&quot;2048&quot; data-filename=&quot;11-묵시적커밋.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&lt;/b&gt;&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;트랜잭션&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;AUTO COMMIT&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;묵시적 커밋&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;DDL&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;TRUNCATE&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;SQL 문법은 익숙해도 커밋이 정확히 언제 일어나는지 놓치기 쉽습니다. 그런데 데이터를 되돌릴 수 없게 만드는 사고는 대개 문법이 아니라 이쪽에서 납니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;BEGIN&lt;/code&gt;을 안 적고 &lt;code&gt;UPDATE&lt;/code&gt;를 실행하면 어떻게 될까요?&lt;/b&gt;&lt;br /&gt;대부분의 DBMS에서 그 문장은 &lt;b&gt;이미 확정된 상태&lt;/b&gt;입니다. &lt;code&gt;ROLLBACK&lt;/code&gt;을 칠 기회가 없습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WHERE&lt;/code&gt;를 빼고 &lt;code&gt;UPDATE&lt;/code&gt;를 날린 이야기는 실수담처럼 돌아다닙니다. 그게 왜 되돌릴 수 없는 사고가 되는지는 &lt;b&gt;AUTO COMMIT&lt;/b&gt;을 알아야 설명이 됩니다. 그리고 트랜잭션을 제대로 열었는데도 중간에 조용히 끊기는 경우가 있는데, 그것이 &lt;b&gt;묵시적 커밋&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AUTO COMMIT&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DELETE와 TRUNCATE는 둘 다 데이터를 삭제하지만, DML과 DDL이라는 점에서 근본적으로 다릅니다.&lt;/b&gt; 이름만 보면 &lt;code&gt;TRUNCATE&lt;/code&gt;가 &lt;code&gt;DELETE&lt;/code&gt;의 빠른 버전처럼 보이지만, 트랜잭션과 롤백, 트리거 동작까지 모두 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AUTO COMMIT은 각 SQL 문장이 끝날 때마다 자동으로 &lt;code&gt;COMMIT&lt;/code&gt;하는 설정입니다.&lt;/b&gt; 기본적으로 켜져 있으면 각 SQL 문장이 하나의 트랜잭션으로 처리되고, 실행이 성공적으로 끝나면 자동으로 &lt;code&gt;COMMIT&lt;/code&gt;됩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 540px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #5f5651; margin-bottom: 9px; font-weight: bold;&quot;&gt;BEGIN 없이 실행하면&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 210px; padding: 10px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #f2eae6; border: 1px solid #d9c4b8; border-radius: 7px; color: #5f5651;&quot;&gt;UPDATE students SET grade = 1;&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 120px; padding: 10px; text-align: center; font-size: 14px; background: #f2eae6; border: 1.5px solid #5f5651; border-radius: 7px; color: #5f5651; font-weight: bold;&quot;&gt;즉시 COMMIT&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #5f5651; line-height: 1.6;&quot;&gt;이 뒤에 &lt;code style=&quot;font-size: 14px;&quot;&gt;ROLLBACK&lt;/code&gt; 을 쳐도 되돌아오지 않습니다. 되돌릴 트랜잭션이 이미 없습니다&lt;/div&gt;
&lt;div style=&quot;margin: 18px 0 9px; font-size: 14px; color: #455148; font-weight: bold;&quot;&gt;BEGIN 으로 명시적으로 열면&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 10px 12px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #e7eaf1; border: 1px solid #bac3d8; border-radius: 7px; color: #52565f;&quot;&gt;BEGIN;&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 160px; padding: 10px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 7px;&quot;&gt;UPDATE ...&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 10px 12px; font-size: 14px; background: #e7f2e9; border: 1.5px solid #455148; border-radius: 7px; color: #455148; font-weight: bold;&quot;&gt;확인 후 결정&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #455148; line-height: 1.6;&quot;&gt;몇 행이 바뀌었는지 보고 &lt;code style=&quot;font-size: 14px;&quot;&gt;COMMIT&lt;/code&gt; 이나 &lt;code style=&quot;font-size: 14px;&quot;&gt;ROLLBACK&lt;/code&gt; 을 고를 수 있습니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DBMS마다 기본값과 트랜잭션 시작 방법이 다릅니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DBMS&lt;/th&gt;
&lt;th&gt;기본 AUTO COMMIT&lt;/th&gt;
&lt;th&gt;트랜잭션 시작&lt;/th&gt;
&lt;th&gt;DDL 실행하면&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BEGIN;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;트랜잭션 안에서 실행되고 &lt;b&gt;롤백됩니다&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL (InnoDB)&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;td&gt;&lt;code&gt;START TRANSACTION;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;묵시적 커밋&lt;/b&gt; (&lt;code&gt;ALTER&lt;/code&gt;&amp;middot;&lt;code&gt;CREATE&lt;/code&gt;&amp;middot;&lt;code&gt;DROP&lt;/code&gt;&amp;middot;&lt;code&gt;TRUNCATE&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;OFF&lt;/td&gt;
&lt;td&gt;DML을 시작하면 자동으로 열립니다&lt;/td&gt;
&lt;td&gt;DDL &lt;b&gt;전후로 자동 커밋&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;ON&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BEGIN TRANSACTION;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;트랜잭션 안에서 실행되고 &lt;b&gt;롤백됩니다&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Oracle만 기본 AUTO COMMIT이 OFF입니다.&lt;/b&gt; 그래서 Oracle을 쓰다 MySQL이나 PostgreSQL로 옮기면 실수가 자주 발생합니다. Oracle에서는 &lt;code&gt;UPDATE&lt;/code&gt; 후 &lt;code&gt;COMMIT&lt;/code&gt;을 해야 변경이 최종 확정되므로 &lt;code&gt;ROLLBACK&lt;/code&gt;으로 되돌릴 수 있습니다. 반면 MySQL이나 PostgreSQL은 기본 AUTO COMMIT이 켜져 있어, 명시적으로 트랜잭션을 시작하지 않았다면 문장이 성공적으로 실행되는 즉시 자동으로 &lt;code&gt;COMMIT&lt;/code&gt;됩니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;강의자료 표에는 PostgreSQL의 묵시적 커밋 칸에 &quot;DDL 실행&quot;이 적혀 있는데 괄호에는 &quot;대부분 트랜잭션 내 허용&quot;이 함께 적혀 있습니다. 두 설명이 서로 반대라서 여기서는 실제 동작인 &lt;b&gt;롤백 가능&lt;/b&gt; 쪽으로 적었습니다. &lt;code&gt;BEGIN; CREATE TABLE ...; ROLLBACK;&lt;/code&gt; 을 돌려보면 테이블이 사라지는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;묵시적 커밋&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;묵시적 커밋(implicit commit)은 &lt;code&gt;COMMIT&lt;/code&gt;을 적지 않았는데 DBMS가 커밋을 실행해 버리는 것입니다.&lt;/b&gt; 주로 DDL에서 일어납니다. MySQL과 Oracle에서는 테이블 구조를 변경하는 DDL은 진행 중인 트랜잭션을 자동으로 COMMIT합니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 580px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin-bottom: 12px;&quot;&gt;MySQL 에서 위에서 아래로 실행했다면&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 7px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 30px; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; text-align: right;&quot;&gt;1&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px 12px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #e7eaf1; border: 1px solid #bac3d8; border-radius: 6px; color: #52565f;&quot;&gt;START TRANSACTION;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 7px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 30px; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; text-align: right;&quot;&gt;2&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px 12px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;DELETE FROM orders WHERE id = 101;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 7px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 30px; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #5f5651; text-align: right; font-weight: 800;&quot;&gt;3&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px 12px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #f2eae6; border: 1.5px solid #5f5651; border-radius: 6px; color: #5f5651;&quot;&gt;CREATE INDEX idx_tmp ON orders (created_at);
&lt;div style=&quot;font-family: -apple-system,sans-serif; font-size: 14px; font-weight: bold; margin-top: 4px;&quot;&gt;&amp;larr; 여기서 2번이 묵시적으로 커밋됩니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 30px; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; text-align: right;&quot;&gt;4&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px 12px; font-family: Consolas,Menlo,monospace; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;ROLLBACK;
&lt;div style=&quot;font-family: -apple-system,sans-serif; font-size: 14px; color: #464d57; margin-top: 4px;&quot;&gt;오류 없이 실행되지만 &lt;b&gt;되돌릴 것이 없습니다&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 16px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;&lt;b&gt;삭제한 주문은 이미 COMMIT되어 되돌릴 수 없습니다.&lt;/b&gt; &lt;code style=&quot;font-size: 14px;&quot;&gt;ROLLBACK&lt;/code&gt; 이 아무 경고 없이 성공하기 때문에, 되돌렸다고 믿고 넘어가면 나중에 데이터가 왜 사라졌는지 원인을 찾느라 시간을 쓰게 됩니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러므로 &lt;b&gt;DML과 DDL을 같은 트랜잭션 안에서 실행하지 않는 것이 기본 원칙입니다.&lt;/b&gt; 스키마를 바꾸는 작업과 데이터를 바꾸는 작업은 따로 실행해야 합니다.&lt;/p&gt;
&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;묵시적 커밋과 묵시적 트랜잭션은 같은 말인가요?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;다른 말입니다. 트랜잭션의 어느 쪽 끝이 자동이냐가 갈립니다. &lt;b&gt;묵시적 커밋&lt;/b&gt;은 끝이 자동입니다. &lt;code style=&quot;font-size: 14px;&quot;&gt;COMMIT&lt;/code&gt; 을 적지 않았는데 커밋이 일어나며, 위에서 본 DDL 이 여기 해당합니다. &lt;b&gt;묵시적 트랜잭션&lt;/b&gt;은 반대로 시작이 자동입니다. &lt;code style=&quot;font-size: 14px;&quot;&gt;BEGIN&lt;/code&gt; 없이 트랜잭션이 열리는 대신 커밋은 직접 해줘야 하고, SQL Server 의 &lt;code style=&quot;font-size: 14px;&quot;&gt;SET IMPLICIT_TRANSACTIONS ON&lt;/code&gt; 이 이 동작을 켭니다. 이름은 비슷한데 방향이 반대라, 묵시적 트랜잭션을 켜는 쪽은 오히려 AUTO COMMIT 을 끄는 것에 가깝습니다.&lt;/div&gt;
&lt;/details&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DELETE와 TRUNCATE 차이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 함정이 &lt;code&gt;TRUNCATE&lt;/code&gt;에도 있습니다. 이름만 보면 &lt;code&gt;DELETE&lt;/code&gt;를 더 빠르게 수행하는 명령처럼 보이지만 분류가 다릅니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TRUNCATE&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;DELETE&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;분류&lt;/td&gt;
&lt;td&gt;DDL&lt;/td&gt;
&lt;td&gt;DML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;속도&lt;/td&gt;
&lt;td&gt;빠릅니다&lt;/td&gt;
&lt;td&gt;행마다 처리하므로 느립니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;롤백&lt;/td&gt;
&lt;td&gt;MySQL&amp;middot;Oracle에서는 &lt;b&gt;불가&lt;/b&gt; (자동 커밋)&lt;/td&gt;
&lt;td&gt;가능합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트리거&lt;/td&gt;
&lt;td&gt;실행되지 않습니다&lt;/td&gt;
&lt;td&gt;실행됩니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WHERE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;쓸 수 없습니다 (전체 삭제)&lt;/td&gt;
&lt;td&gt;조건을 걸 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트리거가 안 돈다는 점이 특히 위험합니다. 삭제될 때 이력을 남기거나 관련 데이터를 정리하는 트리거를 걸어뒀다면, &lt;code&gt;TRUNCATE&lt;/code&gt;로 삭제하면 &lt;b&gt;그 트리거는 전혀 실행되지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AUTO COMMIT은 대부분 켜져 있습니다.&lt;/b&gt; Oracle만 기본이 OFF라, DBMS를 옮길 때 여기서 실수가 자주 발생합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;묵시적 커밋은 DDL에서 일어납니다.&lt;/b&gt; MySQL과 Oracle에서는 &lt;code&gt;ALTER&lt;/code&gt;&amp;middot;&lt;code&gt;CREATE&lt;/code&gt;&amp;middot;&lt;code&gt;DROP&lt;/code&gt;&amp;middot;&lt;code&gt;TRUNCATE&lt;/code&gt;가 진행 중인 트랜잭션을 끝냅니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;ROLLBACK&lt;/code&gt;은 되돌릴 것이 없어도 성공합니다.&lt;/b&gt; 오류가 안 났다고 되돌아간 것은 아닙니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;TRUNCATE&lt;/code&gt;는 DDL입니다.&lt;/b&gt; 빠른 &lt;code&gt;DELETE&lt;/code&gt;가 아니라 롤백도 안 되고 트리거도 안 도는 다른 문장입니다&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 커밋은 내가 입력하지 않아도 일어날 수 있고, ROLLBACK은 되돌릴 것이 없어도 성공합니다. 그래서 &lt;b&gt;트랜잭션을 명시적으로 열고, 그 안에 DDL을 섞지 않습니다.&lt;/b&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MySQL 공식 문서: &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Statements That Cause an Implicit Commit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 공식 문서: &lt;a href=&quot;https://www.postgresql.org/docs/current/sql-begin.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;BEGIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 공식 문서: &lt;a href=&quot;https://www.postgresql.org/docs/current/sql-truncate.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;TRUNCATE&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/웹</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/15</guid>
      <comments>https://boradora1.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 7 Aug 2026 09:41:01 +0900</pubDate>
    </item>
    <item>
      <title>트랜잭션 격리 수준과 MVCC | ACID, WAL, Dirty Read와 Phantom Read</title>
      <link>https://boradora1.tistory.com/14</link>
      <description>&lt;!-- ══════════════════════ 이 글은 ══════════════════════
     트랜잭션이 무엇을 보장하고 그 보장이 어디서 깨지는지 정리했다.
     ACID 와 WAL, 동시에 접근할 때 생기는 세 가지 현상,
     격리 수준 네 단계가 각각 무엇을 막아주는지, PostgreSQL 의 MVCC.

     ─ 티스토리 제목 (2026-08-03 확정) ─────────────────────
     트랜잭션 격리 수준과 MVCC | ACID, WAL, Dirty Read와 Phantom Read

     커버 이미지에 적힌 것과 같은 문장입니다. 커버는 `커버이미지/10-트랜잭션과Lock.png`.
     아래 검색어가 제목 안에 들어가도록 잡았습니다.

     노리는 검색어  트랜잭션 격리 수준 / mvcc / phantom read / acid / wal
     태그 후보      트랜잭션, 격리수준, MVCC, ACID, PostgreSQL, 데이터베이스, 동시성
     ══════════════════════════════════════════════════ --&gt;
&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;10-트랜잭션과Lock.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FYbGX/dJMb998qT3R/JBlWB4PcG4G1caXNKA6xi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FYbGX/dJMb998qT3R/JBlWB4PcG4G1caXNKA6xi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FYbGX/dJMb998qT3R/JBlWB4PcG4G1caXNKA6xi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFYbGX%2FdJMb998qT3R%2FJBlWB4PcG4G1caXNKA6xi0%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;3072&quot; height=&quot;2048&quot; data-filename=&quot;10-트랜잭션과Lock.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;트랜잭션&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;ACID&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;격리 수준&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;MVCC&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;WAL&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;PostgreSQL&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;혼자 테스트할 때는 문제없던 코드도 사용자가 동시에 몰리면 예상치 못한 문제가 발생합니다. 같은 데이터를 여러 트랜잭션이 동시에 수정하면서 합계가 달라지거나, 방금 조회한 결과가 다시 조회했을 때 달라지는 현상이 생길 수 있습니다. 이처럼 여러 작업이 동시에 실행되는 환경에서는 데이터의 일관성을 어떻게 보장할지가 중요합니다. 트랜잭션은 이러한 문제를 포함해 데이터의 정확성을 보장하기 위한 기본 단위입니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;같은 조회를 두 번 했는데 결과가 다르면 버그일까요?&lt;/b&gt;&lt;br /&gt;격리 수준을 어디에 두었느냐에 따라 &lt;b&gt;정상 동작&lt;/b&gt;일 수 있습니다. 무엇까지 허용할지 정하는 것이 격리 수준입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL을 공부할 때는 쿼리를 하나씩 순서대로 실행하므로 동시성 문제를 체감하기 어렵습니다. 그런데 실제 서버에서는 같은 쿼리가 동시에 수십 개씩 들어오고, 그중 일부는 같은 행을 건드립니다. 이때 무엇을 어디까지 허용할지 정해 두지 않으면 계산은 전부 맞는데 결과만 틀리는 상황이 생깁니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ACID와 WAL&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트랜잭션은 하나로 묶여서 전부 성공하거나 전부 취소되어야 하는 작업 단위입니다.&lt;/b&gt; 계좌 이체가 출금과 입금 두 개로 나뉘어 있어도 둘 중 하나만 반영되면 안 되는 것과 같습니다. 트랜잭션이 지켜야 하는 네 가지 성질을 앞글자만 따서 ACID라고 합니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;속성&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Atomicity&lt;/b&gt;&lt;br /&gt;원자성&lt;/td&gt;
&lt;td&gt;모든 작업이 전부 수행되거나 전혀 수행되지 않습니다. 출금과 입금은 둘 다 성공하거나 둘 다 취소됩니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Consistency&lt;/b&gt;&lt;br /&gt;일관성&lt;/td&gt;
&lt;td&gt;트랜잭션 전후로 데이터베이스가 정해진 규칙을 만족하는 상태를 유지합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Isolation&lt;/b&gt;&lt;br /&gt;격리성&lt;/td&gt;
&lt;td&gt;동시에 실행되는 트랜잭션끼리 서로 간섭하지 않습니다. 이 글의 대부분이 여기에 해당합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Durability&lt;/b&gt;&lt;br /&gt;지속성&lt;/td&gt;
&lt;td&gt;커밋이 끝난 결과는 장애가 나도 남아 있습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 중 지속성을 구현하는 방식이 &lt;b&gt;WAL(Write-Ahead Logging)&lt;/b&gt;입니다. 이름 그대로 &lt;b&gt;데이터 파일을 수정하기 전에 변경 내용을 로그에 먼저 기록하는 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;BEGIN;
  UPDATE account SET balance = balance - 10000 WHERE id = 'A';
  -- 1. WAL 버퍼에 변경 내용을 먼저 기록
  -- 2. 메모리(Shared Buffer)의 페이지를 변경
  -- 3. 디스크 데이터 파일에는 아직 기록 안 함 (dirty page)
  UPDATE account SET balance = balance + 10000 WHERE id = 'B';
COMMIT;
-- COMMIT 로그를 WAL에 기록한 뒤 fsync 로 디스크에 동기화
-- 데이터 파일 반영은 이후 Checkpoint 시점에&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋 직후 서버가 꺼져도 데이터 파일에는 아직 반영이 안 되어 있을 수 있습니다. 그래도 WAL에는 기록이 남아 있기 때문에, 재기동할 때 로그를 순서대로 재실행해 복구합니다. 커밋되지 않은 트랜잭션은 되돌립니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;PostgreSQL의 &lt;code&gt;synchronous_commit&lt;/code&gt;을 &lt;code&gt;off&lt;/code&gt;로 두면 커밋 응답이 빨라지는 대신 장애 시 최대 200ms 분량이 사라질 수 있습니다. 성능과 안전 중 무엇을 택할지 정하는 설정입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동시성에서 생기는 세 가지 현상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;격리 수준을 이해하려면 &lt;b&gt;무엇을 막으려는 것인지&lt;/b&gt;부터 봐야 합니다. 트랜잭션이 겹칠 때 생기는 대표적인 이상 현상이 세 가지입니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 210px; border: 1px solid #d9c4b8; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;Dirty Read&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;아직 &lt;b&gt;커밋되지 않은 값&lt;/b&gt;을 읽었기 때문에, 상대가 롤백하면 존재한 적 없는 값을 읽게 됩니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 210px; border: 1px solid #d9c4b8; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;Non-Repeatable Read&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;&lt;b&gt;같은 행&lt;/b&gt;을 두 번 읽었는데 값이 달라집니다. 그 사이 다른 트랜잭션이 UPDATE&amp;middot;DELETE 했기 때문입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 210px; border: 1px solid #d9c4b8; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;Phantom Read&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;&lt;b&gt;같은 조건&lt;/b&gt;으로 두 번 조회했을 때 행의 개수가 달라집니다. 그 사이 조건에 맞는 행이 INSERT 됐기 때문입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Non-Repeatable Read와 Phantom Read를 헷갈리기 쉽습니다. &lt;b&gt;값이 변한 것이 Non-Repeatable Read, 조건에 맞는 행의 개수가 변한 것이 Phantom Read&lt;/b&gt;입니다. 실제로 세션 두 개를 띄워 보면 차이가 분명해집니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- Session 1
BEGIN;
SELECT COUNT(*) FROM orders WHERE amount &amp;gt;= 100;   -- 결과: 5

-- Session 2 (동시에)
BEGIN;
INSERT INTO orders(id, amount) VALUES (999, 150);
COMMIT;

-- Session 1 에서 같은 쿼리를 한 번 더
SELECT COUNT(*) FROM orders WHERE amount &amp;gt;= 100;   -- 결과는?&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Read Committed&lt;/b&gt;: 5에서 6으로 늘어납니다. 팬텀이 발생합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Repeatable Read&lt;/b&gt;: 5로 유지됩니다. 같은 트랜잭션 안에서는 같은 스냅샷을 보기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Serializable&lt;/b&gt;: Session 2의 INSERT가 대기하거나 충돌로 롤백되어 팬텀 자체가 생기지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;격리 수준 네 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;격리 수준은 위의 세 현상 중 무엇까지 허용할지 정하는 설정입니다.&lt;/b&gt; 엄격해질수록 정확해지고 느려집니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;격리 수준&lt;/th&gt;
&lt;th&gt;Dirty Read&lt;/th&gt;
&lt;th&gt;Non-Repeatable&lt;/th&gt;
&lt;th&gt;Phantom&lt;/th&gt;
&lt;th&gt;기본값인 DBMS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Read Uncommitted&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;거의 쓰지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Read Committed&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;PostgreSQL, Oracle, SQL Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Repeatable Read&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;원칙적 허용&lt;/td&gt;
&lt;td&gt;MySQL(InnoDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Serializable&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;차단&lt;/td&gt;
&lt;td&gt;성능 저하 주의&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표에서 Repeatable Read의 Phantom이 &quot;원칙적 허용&quot;인 이유가 있습니다. &lt;b&gt;ANSI 표준은 허용한다고 정의했지만 실제 DBMS는 구현에 따라 막습니다.&lt;/b&gt; 표준만 외우고 있으면 실제 동작과 어긋나기 때문에, 쓰는 DBMS의 구현을 확인해야 합니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DBMS&lt;/th&gt;
&lt;th&gt;기본값&lt;/th&gt;
&lt;th&gt;구현 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PostgreSQL&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Read Committed&lt;/td&gt;
&lt;td&gt;MVCC. Repeatable Read는 스냅샷 격리라 같은 트랜잭션 안에서 새 행을 보지 않습니다. 완전한 직렬성이 필요하면 Serializable(SSI)을 쓰고, 충돌 시 롤백됩니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;MySQL&lt;/b&gt;&lt;br /&gt;InnoDB&lt;/td&gt;
&lt;td&gt;Repeatable Read&lt;/td&gt;
&lt;td&gt;일반 SELECT는 같은 스냅샷을 보므로 팬텀이 사실상 없습니다. 다만 &lt;code&gt;FOR UPDATE&lt;/code&gt;나 범위 UPDATE&amp;middot;DELETE에서는 next-key lock으로 막아서 대기가 늘어날 수 있습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Oracle&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Read Committed&lt;/td&gt;
&lt;td&gt;MVCC로 문장 단위 일관성을 줍니다. Serializable은 충돌 시 &lt;code&gt;ORA-08177&lt;/code&gt;이 나므로 재시도가 필요합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;SQL Server&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Read Committed&lt;br /&gt;(락 기반)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;READ_COMMITTED_SNAPSHOT&lt;/code&gt;을 켜면 행 버전을 읽습니다. Serializable은 범위 잠금으로 팬텀을 막습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MVCC&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL과 MySQL InnoDB, Oracle이 공통으로 쓰는 방식이 &lt;b&gt;MVCC(Multi-Version Concurrency Control)&lt;/b&gt;입니다. &lt;b&gt;값을 덮어쓰지 않고 새 버전을 하나 더 만드는 방식&lt;/b&gt;이라고 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덮어쓰지 않기 때문에 이전 버전이 그대로 남아 있고, 읽는 쪽은 자기 트랜잭션이 시작될 때의 버전을 봅니다. 그래서 &lt;b&gt;읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않습니다.&lt;/b&gt; 조회 쿼리가 UPDATE를 기다리며 멈추는 일이 없는 이유가 여기 있습니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 540px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin-bottom: 10px;&quot;&gt;UPDATE 는 값을 고치지 않고 새 버전을 추가합니다&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; flex-wrap: wrap;&quot;&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #d1d5db; border-radius: 9px; padding: 12px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-bottom: 6px;&quot;&gt;구버전 (dead tuple 이 됨)&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #24292f;&quot;&gt;stock = 10&lt;br /&gt;xmin=100 &amp;nbsp; xmax=&lt;b&gt;205&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 26px; text-align: center; color: #6b7280; font-size: 16px;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1.5px solid #bbd9c1; border-radius: 9px; padding: 12px; background: #e7f2e9;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #455148; margin-bottom: 6px;&quot;&gt;신버전 (지금 보이는 값)&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #24292f;&quot;&gt;stock = 9&lt;br /&gt;xmin=&lt;b&gt;205&lt;/b&gt; &amp;nbsp; xmax=&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;xmin&lt;/code&gt; 은 이 행을 만든 트랜잭션 번호, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;xmax&lt;/code&gt; 는 이 행을 지우거나 고친 트랜잭션 번호입니다. 트랜잭션 번호 150번이 지금 조회하면 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;xmax=205&lt;/code&gt; 가 아직 자기보다 뒤이므로 구버전인 &lt;b&gt;stock = 10&lt;/b&gt; 을 봅니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남은 구버전을 &lt;b&gt;dead tuple&lt;/b&gt;이라고 하고, &lt;code&gt;VACUUM&lt;/code&gt;이 주기적으로 회수합니다. 여기서 실무에서 자주 겪는 문제가 나옵니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;오래 열려 있는 트랜잭션 하나가 테이블을 부풀립니다.&lt;/b&gt; VACUUM은 어떤 트랜잭션도 더 이상 보지 않는 구버전만 지울 수 있습니다. 트랜잭션 하나가 몇 시간째 열려 있으면 그동안 쌓인 구버전을 전부 지우지 못하고 테이블이 계속 커집니다. &lt;b&gt;트랜잭션을 열어 둔 채로 외부 API를 호출하거나 파일을 읽지 않아야 하는 이유&lt;/b&gt;가 여기 있습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL InnoDB는 구버전을 테이블이 아니라 별도의 &lt;b&gt;Undo Log&lt;/b&gt;에 두고 Purge Thread가 정리합니다. 구현은 다르지만 &lt;b&gt;버전을 저장하는 공간이 늘어나고, 긴 트랜잭션이 정리를 막는다&lt;/b&gt;는 비용은 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 &lt;b&gt;읽기와 쓰기 사이의 충돌&lt;/b&gt;입니다. MVCC가 이 부분을 없애 줍니다. 남는 것은 &lt;b&gt;쓰기끼리의 충돌&lt;/b&gt;인데, 같은 행을 두 트랜잭션이 동시에 고치려는 상황은 버전을 늘리는 것으로 해결되지 않습니다. 이쪽은 Lock으로 다루고, 분량이 있어서 &lt;b&gt;Lock과 데드락&lt;/b&gt;에서 따로 정리했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 성격별로 어떤 격리 수준을 쓰는지 정리해보겠습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;작업&lt;/th&gt;
&lt;th&gt;권장 격리 수준&lt;/th&gt;
&lt;th&gt;확인할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;단순 조회, 캐시 워밍업&lt;/td&gt;
&lt;td&gt;Read Committed&lt;/td&gt;
&lt;td&gt;읽는 값이 조금 달라져도 되는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보고서, 반복 집계&lt;/td&gt;
&lt;td&gt;Repeatable Read&lt;/td&gt;
&lt;td&gt;Phantom이 결과를 바꾸는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;계좌 이체, 재고 차감&lt;/td&gt;
&lt;td&gt;Serializable&lt;/td&gt;
&lt;td&gt;성능 저하와 충돌 재시도를 감당할 수 있는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장바구니, 좌석 예약&lt;/td&gt;
&lt;td&gt;Serializable&lt;/td&gt;
&lt;td&gt;충돌 시 재시도할 준비가 되어 있는지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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; 전부 Serializable로 두면 값은 안 틀리지만 충돌과 재시도가 늘어납니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표준과 실제 구현이 다릅니다.&lt;/b&gt; ANSI 표준은 Repeatable Read에서 Phantom을 허용하는데, PostgreSQL과 InnoDB는 스냅샷으로 실질적으로 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MVCC 덕분에 읽기와 쓰기가 서로를 막지 않습니다.&lt;/b&gt; 대신 구버전이 쌓이므로 긴 트랜잭션이 테이블을 부풀립니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WAL은 로그를 데이터 파일보다 먼저 씁니다.&lt;/b&gt; 커밋 직후 서버가 꺼져도 로그를 재실행해 복구할 수 있는 이유입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 트랜잭션이 겹치면 &lt;b&gt;Dirty &amp;middot; Non-Repeatable &amp;middot; Phantom&lt;/b&gt; 세 가지가 생기고 &amp;rarr; &lt;b&gt;격리 수준&lt;/b&gt;으로 어디까지 허용할지 정하며 &amp;rarr; &lt;b&gt;MVCC&lt;/b&gt;가 버전을 여러 개 두어 읽기 충돌을 없앱니다. 남은 쓰기 충돌은 Lock의 몫입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/transaction-iso.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Transaction Isolation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/mvcc-intro.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Concurrency Control (MVCC)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/wal-intro.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Write-Ahead Logging (WAL)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MySQL: &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;InnoDB Transaction Isolation Levels&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/데이터&amp;middot;통계</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/14</guid>
      <comments>https://boradora1.tistory.com/14#entry14comment</comments>
      <pubDate>Wed, 5 Aug 2026 08:51:54 +0900</pubDate>
    </item>
    <item>
      <title>PostgreSQL 쿼리 튜닝 | EXPLAIN ANALYZE, Buffers, Nested Loop&amp;middot;Hash&amp;middot;Merge</title>
      <link>https://boradora1.tistory.com/13</link>
      <description>&lt;!-- ══════════════════════ 이 글은 ══════════════════════
     느린 쿼리를 만났을 때 무엇부터 보는지 순서대로 정리했다.
     EXPLAIN ANALYZE 를 읽는 방향과 한 줄의 구성, Buffers, 노드 타입,
     Nested Loop·Hash·Merge 조인을 강제로 바꿔 실측한 비교, 인덱스가 안 통하는 패턴,
     쿼리 12개를 실제로 튜닝하며 세운 판단 기준, 엔진별 옵티마이저 차이.

     ─ 티스토리 제목 (2026-08-03 확정) ─────────────────────
     PostgreSQL 쿼리 튜닝 | EXPLAIN ANALYZE, Buffers, Nested Loop·Hash·Merge

     커버 이미지에 적힌 것과 같은 문장입니다. 커버는 `커버이미지/09-실행계획과튜닝.png`.
     아래 검색어가 제목 안에 들어가도록 잡았습니다.

     노리는 검색어  explain analyze 보는법 / postgresql 쿼리 튜닝 / buffers / nested loop hash join
     태그 후보      PostgreSQL, 쿼리튜닝, EXPLAIN, 실행계획, 인덱스, 데이터베이스, SQL
     ══════════════════════════════════════════════════ --&gt;
&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;09-실행계획과튜닝.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCgcs4/dJMcadpmNeg/HSMYzcWvlhEsbSabXAslx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCgcs4/dJMcadpmNeg/HSMYzcWvlhEsbSabXAslx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCgcs4/dJMcadpmNeg/HSMYzcWvlhEsbSabXAslx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCgcs4%2FdJMcadpmNeg%2FHSMYzcWvlhEsbSabXAslx1%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;3072&quot; height=&quot;2048&quot; data-filename=&quot;09-실행계획과튜닝.png&quot; data-origin-width=&quot;3072&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;EXPLAIN ANALYZE&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;실행계획&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Seq Scan&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;복합 인덱스&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Hash Join&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Materialized View&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;안티패턴&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;pg_stat_statements&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;인덱스를 걸었는데도 쿼리가 그대로 느릴 때가 있습니다. 인덱스가 없어서가 아니라 &lt;b&gt;만들어 둔 인덱스를 쿼리가 안 타서&lt;/b&gt; 생기는 일입니다. 탔는지 안 탔는지는 추측할 필요 없이 실행계획에 그대로 적혀 있습니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;이 쿼리가 왜 느린지 어떻게 알 수 있을까요?&lt;/b&gt;&lt;br /&gt;실행계획에서 &lt;b&gt;버려진 행의 개수&lt;/b&gt;를 먼저 확인합니다. 5만 행을 읽어 4만 행을 버리고 있다면 대부분의 비용이 불필요한 행을 읽는 데 사용되고 있는 것입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 쿼리를 고치는 순서는 정해져 있습니다. &lt;b&gt;어떤 쿼리가 느린지 찾고, 실행계획으로 왜 느린지 확인하고, 원인에 맞는 방법으로 고칩니다.&lt;/b&gt; 실행 계획을 확인하지 않고 인덱스를 추가하면 쓰기 비용만 증가하고 실행 계획은 그대로인 경우가 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실행계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행계획은 옵티마이저가 쿼리를 실행하기 전에 정한 실행 방법입니다.&lt;/b&gt; 어떤 순서로 테이블을 읽을지, 어떤 인덱스를 쓸지, 어떤 조인 방식을 택할지가 여기 적혀 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;명령&lt;/th&gt;
&lt;th&gt;하는 일&lt;/th&gt;
&lt;th&gt;주의&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;추정 계획만 보여줍니다. 쿼리를 실행하지 않습니다&lt;/td&gt;
&lt;td&gt;추정값이라 실제와 다를 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;실제로 실행하고&lt;/b&gt; 걸린 시간과 행 수를 측정합니다&lt;/td&gt;
&lt;td&gt;DML이면 실제로 반영됩니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메모리에서 읽었는지 디스크에서 읽었는지까지 보여줍니다&lt;/td&gt;
&lt;td&gt;캐시 히트율을 볼 때 씁니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;는 쿼리를 진짜로 실행합니다.&lt;/b&gt; &lt;code&gt;UPDATE&lt;/code&gt;나 &lt;code&gt;DELETE&lt;/code&gt;에 붙이면 데이터가 실제로 바뀝니다. DML을 측정할 때는 트랜잭션으로 감싸고 마지막에 롤백합니다.&lt;/div&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'DONE' WHERE id = 1;
ROLLBACK;                -- 측정만 하고 되돌립니다&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;플랜을 읽는 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플랜은 &lt;b&gt;트리&lt;/b&gt;입니다. &lt;code&gt;-&amp;gt;&lt;/code&gt; 로 들여쓴 줄이 자식이고, 자식이 먼저 실행됩니다. 그래서 &lt;b&gt;가장 깊이 들여쓴 줄부터 위로 올라가며&lt;/b&gt; 읽습니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 520px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin-bottom: 12px;&quot;&gt;실행 순서는 아래에서 위로, 결과는 위로 전달됩니다&lt;/div&gt;
&lt;div style=&quot;padding: 9px 12px; border-radius: 7px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-family: Consolas,Menlo,monospace; font-size: 14px; font-weight: bold;&quot;&gt;④ Limit &amp;nbsp;&lt;span style=&quot;font-weight: 400;&quot;&gt;100개만 반환&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;margin-left: 20px; margin-top: 5px; padding: 9px 12px; border-radius: 7px; background: #f6f8fa; border: 1px solid #d1d5db; color: #24292f; font-family: Consolas,Menlo,monospace; font-size: 14px;&quot;&gt;③ Sort &amp;nbsp;&lt;span style=&quot;color: #464d57;&quot;&gt;salary 내림차순&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;margin-left: 40px; margin-top: 5px; padding: 9px 12px; border-radius: 7px; background: #f6f8fa; border: 1px solid #d1d5db; color: #24292f; font-family: Consolas,Menlo,monospace; font-size: 14px;&quot;&gt;② Hash Join &amp;nbsp;&lt;span style=&quot;color: #464d57;&quot;&gt;departments &amp;middot; jobs 를 붙임&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;margin-left: 60px; margin-top: 5px; padding: 9px 12px; border-radius: 7px; background: #f2eae6; border: 1px solid #d9c4b8; color: #5f5651; font-family: Consolas,Menlo,monospace; font-size: 14px; font-weight: bold;&quot;&gt;① Seq Scan on employees &amp;nbsp;&lt;span style=&quot;font-weight: 400;&quot;&gt;여기가 문제입니다&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 줄에 들어 있는 것&lt;/h2&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;&gt;&lt;code&gt;Limit  (cost=2386.77..2387.02 rows=100 width=30)
       (actual time=28.278..28.296 rows=100 loops=1)&lt;/code&gt;&lt;/pre&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;th&gt;어떻게 보나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cost=A..B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A는 첫 행이 나올 때까지, B는 전부 끝날 때까지의 &lt;b&gt;추정&lt;/b&gt; 비용&lt;/td&gt;
&lt;td&gt;단위가 시간이 아니라 상대적인 값입니다. 플랜끼리 비교할 때만 씁니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rows&lt;/code&gt; (앞)&lt;/td&gt;
&lt;td&gt;몇 행이 나올지 &lt;b&gt;추정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;뒤의 실제 &lt;code&gt;rows&lt;/code&gt;와 비교합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;width&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;행 하나의 평균 바이트&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;로 바꾸면 이 값이 커집니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actual time=A..B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;실제로 걸린 밀리초&lt;/td&gt;
&lt;td&gt;&lt;b&gt;여기가 실측값입니다.&lt;/b&gt; 어느 노드에서 시간이 뛰는지 봅니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rows&lt;/code&gt; (뒤)&lt;/td&gt;
&lt;td&gt;실제로 나온 행 수&lt;/td&gt;
&lt;td&gt;추정과 몇 배 이상 벌어지면 &lt;code&gt;ANALYZE&lt;/code&gt;가 필요합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이 노드가 반복 실행된 횟수&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1이 아니면 시간을 곱해야 합니다.&lt;/b&gt; Nested Loop 안쪽에서 커집니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;loops&lt;/code&gt;를 놓치면 시간을 잘못 읽습니다.&lt;/b&gt; &lt;code&gt;actual time&lt;/code&gt;은 &lt;b&gt;1회 실행분&lt;/b&gt;입니다. &lt;code&gt;actual time=0.5..0.8 loops=3000&lt;/code&gt; 이라면 실제로 쓴 시간은 0.8ms가 아니라 &lt;b&gt;2,400ms&lt;/b&gt;입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사례로 읽어보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입사 1년 이내의 재직자를 연봉순 상위 100명 뽑는 쿼리입니다. 직원 테이블에 5만 행이 있고, 부서와 직무 테이블을 붙여서 조회합니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;SELECT e.employee_id, e.salary, d.department_name, j.job_title
  FROM employees e
  JOIN departments d ON d.department_id = e.department_id
  JOIN jobs        j ON j.job_id = e.job_id
 WHERE e.status = 'ACTIVE'
   AND e.hire_date &amp;gt;= CURRENT_DATE - INTERVAL '365 days'
 ORDER BY e.salary DESC
 LIMIT 100;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;튜닝 전&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[여기에 스크린샷 : q4_v1_before.png]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 아래 줄부터 올라가며 읽습니다. 가장 깊은 곳에 이 줄이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt;  Seq Scan on employees e  (cost=0.00..1960.00 rows=9551 width=24)
                             (actual time=0.018..21.028 rows=9567 loops=1)
      Filter: ((status = 'ACTIVE'::text)
               AND (hire_date &amp;gt;= (CURRENT_DATE - '365 days'::interval)))
      Rows Removed by Filter: 40433
      Buffers: shared hit=960&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 세 가지가 보입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Seq Scan&lt;/code&gt;입니다.&lt;/b&gt; 인덱스를 안 타고 employees 테이블을 처음부터 끝까지 읽었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Rows Removed by Filter: 40433&lt;/code&gt;&lt;/b&gt;. 조건에 안 맞아서 &lt;b&gt;버린 행이 4만 433개&lt;/b&gt;입니다. 통과한 것은 9,567개입니다. &lt;b&gt;5만 행을 읽어 5분의 1만 남았습니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이 노드에서 21.028ms를 썼습니다.&lt;/b&gt; 전체가 28.353ms이니 전체 실행 시간의 대부분이 이 노드에서 소비됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;Rows Removed by Filter&lt;/code&gt;가 크면 조건 컬럼에 인덱스를 적용할 수 있는지 확인합니다.&lt;/b&gt; 이 값은 &lt;b&gt;읽었지만 조건을 만족하지 않아 필터 단계에서 제거된 행 수&lt;/b&gt;입니다. 통과한 행보다 버린 행이 훨씬 많다면, 애초에 그 행들을 읽지 않을 방법이 있다는 뜻입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지 줄도 확인합니다. 위로 올라가면 Hash Join이 두 겹 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt;  Hash Join  (cost=8.40..2021.74 rows=9551 width=30)
               (actual time=0.223..26.226 rows=9567 loops=1)
      Hash Cond: (e.job_id = j.job_id)
...
-&amp;gt;  Hash  (cost=4.00..4.00 rows=200 width=12)
      Buckets: 1024  Batches: 1  Memory Usage: 17kB
      -&amp;gt;  Seq Scan on departments d  (rows=200)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;departments가 200행, jobs가 40행입니다. &lt;b&gt;이 정도로 작은 테이블은 &lt;code&gt;Seq Scan&lt;/code&gt;이 비용이 가장 낮게 계산되는 경우가 많습니다.&lt;/b&gt; 전부 메모리에 올려 해시 테이블을 만드는 편이 인덱스를 타는 것보다 빠릅니다. &lt;code&gt;Batches: 1&lt;/code&gt;이니 메모리에 다 들어갔고, 디스크로 넘치지도 않았습니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Seq Scan&lt;/code&gt;이 있다고 무조건 나쁜 것은 아닙니다. &lt;b&gt;작은 테이블이거나 전체의 상당 부분을 읽어야 하는 쿼리&lt;/b&gt;라면 순차 읽기가 더 빠릅니다. 문제가 되는 것은 읽은 행 대부분이&lt;b&gt;Filter 단계에서 제거되는&lt;/b&gt;경우입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무엇을 걸어야 하나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WHERE&lt;/code&gt; 조건이 &lt;code&gt;status&lt;/code&gt;와 &lt;code&gt;hire_date&lt;/code&gt; 두 개이고 &lt;b&gt;&lt;code&gt;AND&lt;/code&gt;로 묶여 있습니다.&lt;/b&gt; AND 조건이니 &lt;b&gt;복합 인덱스&lt;/b&gt;가 맞습니다. 여기에 &lt;code&gt;ORDER BY e.salary&lt;/code&gt;까지 있으니 &lt;code&gt;salary&lt;/code&gt;도 인덱스에 포함시킵니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_emp_hire_status_salary
    ON employees (hire_date, status, salary);

ANALYZE employees;   -- 통계를 갱신해야 플래너가 새 인덱스를 고려합니다&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;복합 인덱스는 등호 조건을 앞에, 범위 조건을 뒤에 둡니다.&lt;/b&gt; &lt;code&gt;status = 'ACTIVE'&lt;/code&gt;가 등호, &lt;code&gt;hire_date &amp;gt;=&lt;/code&gt;가 범위이니 원칙대로면 &lt;code&gt;(status, hire_date)&lt;/code&gt; 순입니다. 다만 &lt;code&gt;status&lt;/code&gt;는 값 종류가 적어 선택도가 낮고, 이 데이터에서는 날짜로 좁히는 폭이 더 컸습니다. &lt;b&gt;순서 원칙은 출발점이고, 최종 판단은 두 순서를 다 만들어 실행계획을 비교하는 것&lt;/b&gt;입니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;튜닝 후&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[여기에 스크린샷 : q4_v1_after.png]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 쿼리를 한 글자도 고치지 않았는데 플랜이 달라졌습니다. 가장 깊은 곳이 이렇게 바뀌었습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt;  Bitmap Heap Scan on employees e  (cost=262.55..1412.09 rows=9477 width=24)
      Recheck Cond: ((hire_date &amp;gt;= (CURRENT_DATE - '365 days'::interval))
                     AND (status = 'ACTIVE'))
      Heap Blocks: exact=907
      Buffers: shared hit=948
      -&amp;gt;  Bitmap Index Scan on idx_emp_hire_status_salary
            (cost=0.00..260.19 rows=9477)
            Index Cond: ((hire_date &amp;gt;= ...) AND (status = 'ACTIVE'))
            Buffers: shared hit=41&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바뀐 점을 하나씩 짚어보겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Seq Scan&lt;/code&gt;이 &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;으로 바뀌었습니다.&lt;/b&gt; 인덱스로 읽을 행의 위치를 먼저 찾고, 그 위치만 테이블에서 읽습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Rows Removed by Filter&lt;/code&gt;가 사라졌습니다.&lt;/b&gt; 조건에 맞는 행만 인덱스로 찾아 접근하므로 불필요한 행을 읽지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Filter&lt;/code&gt;가 &lt;code&gt;Recheck Cond&lt;/code&gt;로 바뀌었습니다.&lt;/b&gt; 비트맵이 페이지 단위로 뭉개질 수 있어서 읽은 뒤 조건을 한 번 더 확인합니다. &lt;code&gt;Heap Blocks: exact=907&lt;/code&gt;은 뭉개진 부분 없이 정확히 기록됐다는 뜻입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인덱스를 읽는 데 41 페이지를 읽어 접근할 힙 블록을 결정했습니다.&lt;/b&gt; 이것으로 테이블에서 읽을 907 블록을 정했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;튜닝 전&lt;/th&gt;
&lt;th&gt;튜닝 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;employees 접근 방식&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bitmap Heap Scan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;버린 행&lt;/td&gt;
&lt;td&gt;&lt;b&gt;40,433&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해당 노드 소요&lt;/td&gt;
&lt;td&gt;21.028 ms&lt;/td&gt;
&lt;td&gt;인덱스 스캔 포함해 크게 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buffers (전체)&lt;/td&gt;
&lt;td&gt;shared hit=963&lt;/td&gt;
&lt;td&gt;shared hit=951&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Execution Time&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;28.353 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;17.633 ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;28.353ms에서 17.633ms로 &lt;b&gt;약 38% 줄었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;남아 있는 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 짚고 갈 것이 있습니다. &lt;b&gt;&lt;code&gt;Sort&lt;/code&gt; 노드는 그대로 남아 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt;  Sort  (cost=1835.61..1859.30 rows=9477 width=30)
          (actual time=17.545..17.560 rows=100 loops=1)
      Sort Key: e.salary DESC
      Sort Method: top-N heapsort  Memory: 36kB&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스에 &lt;code&gt;salary&lt;/code&gt;를 넣었는데도 정렬이 사라지지 않았습니다. 이유는 &lt;b&gt;선두 컬럼에 범위 조건이 사용되면 인덱스 순서만으로 전체 정렬 순서를 보장할 수 없기&lt;/b&gt;때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;(hire_date, status, salary)&lt;/code&gt; 순서에서 &lt;code&gt;hire_date&lt;/code&gt;에 걸린 조건이 &lt;code&gt;&amp;gt;=&lt;/code&gt; 입니다. 등호가 아니라 범위이므로 &lt;b&gt;여러 날짜 구간이 결과에 포함되고, 각 구간 안에서만 &lt;code&gt;salary&lt;/code&gt;가 정렬돼 있습니다.&lt;/b&gt; 구간을 넘나들면 순서가 이어지지 않으니 인덱스가 전체 정렬 순서를 보장하지 못합니다. 그래서 데이터베이스는 별도의 &lt;code&gt;Sort&lt;/code&gt;를 붙입니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;복합 인덱스에서 범위 조건 뒤의 컬럼은 정렬에 쓸 수 없습니다.&lt;/b&gt; 앞에서 &quot;등호를 앞에, 범위를 뒤에&quot; 두라고 한 규칙이 여기서 한 번 더 나옵니다. &lt;code&gt;ORDER BY&lt;/code&gt;까지 인덱스로 해결하려면 &lt;b&gt;정렬 컬럼을 선두로 올려야&lt;/b&gt; 합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 인덱스를 이렇게 바꾸면 &lt;code&gt;Sort&lt;/code&gt; 노드가 사라집니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 정렬 컬럼을 선두로, 값 종류가 적은 status 는 부분 인덱스 조건으로
CREATE INDEX idx_emp_salary_hire
    ON employees (salary DESC, hire_date)
 WHERE status = 'ACTIVE';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;status&lt;/code&gt;는 값이 두 종류뿐이라 인덱스 컬럼으로 두면 좁혀지는 폭이 작습니다. &lt;b&gt;&lt;code&gt;WHERE&lt;/code&gt; 절로 옮겨 부분 인덱스로 만들면&lt;/b&gt; 인덱스 크기도 줄고, 선두 자리를 정렬 컬럼에 쓸 수 있습니다. 이 구성에서는 &lt;code&gt;Sort&lt;/code&gt;가 완전히 없어지고 약 &lt;b&gt;30배&lt;/b&gt;까지 빨라졌습니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;앞의 &lt;code&gt;Sort Method&lt;/code&gt;가 &lt;code&gt;top-N heapsort&lt;/code&gt;였던 덕에 그나마 부담이 작았습니다. &lt;code&gt;LIMIT 100&lt;/code&gt;이 있어 9,477개를 전부 정렬하지 않고 상위 100개만 유지했고, 메모리도 36kB만 썼습니다. &lt;code&gt;LIMIT&lt;/code&gt;이 없었다면 훨씬 무거워집니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;Sort Method&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;th&gt;판단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;top-N heapsort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT&lt;/code&gt;이 있어 상위 N개만 유지&lt;/td&gt;
&lt;td&gt;좋습니다. 전체 정렬을 피했습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quicksort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메모리 안에서 전체 정렬&lt;/td&gt;
&lt;td&gt;괜찮습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;external merge Disk: ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;메모리가 부족해 디스크에서 정렬&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;느립니다. &lt;code&gt;work_mem&lt;/code&gt;을 올리거나 정렬을 없애야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Buffers 읽기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;BUFFERS&lt;/code&gt; 옵션을 붙이면 각 노드가 &lt;b&gt;몇 페이지를 어디서 읽었는지&lt;/b&gt; 나옵니다. 페이지 하나는 기본 8kB입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;표기&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;th&gt;판단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared hit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메모리 캐시에서 읽음&lt;/td&gt;
&lt;td&gt;빠릅니다. 이 비율이 높아야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;디스크에서 읽음&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;느립니다. 반복 실행해도 크면 캐시가 부족한 것입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temp read / written&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;임시 파일 사용&lt;/td&gt;
&lt;td&gt;정렬이나 해시가 메모리에 안 들어갔다는 뜻입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사례는 튜닝 전후 모두 &lt;code&gt;hit&lt;/code&gt;만 있고 &lt;code&gt;read&lt;/code&gt;가 없습니다. 데이터가 이미 캐시에 다 올라와 있었다는 뜻입니다. &lt;b&gt;측정할 때는 두 번 이상 돌려 캐시가 채워진 뒤의 값을 보는 편&lt;/b&gt;이 실제 운영 상황에 가깝습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;노드 타입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플랜에 나오는 노드 이름만 알아도 어디가 문제인지 좁혀집니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;노드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;판단&lt;/th&gt;
&lt;th&gt;조치&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Index Only Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인덱스만으로 처리. 테이블 접근 없음&lt;/td&gt;
&lt;td&gt;가장 좋음&lt;/td&gt;
&lt;td&gt;커버링 인덱스가 걸린 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인덱스로 찾고 테이블에서 나머지를 읽음&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;td&gt;인덱스 순서가 유지됩니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bitmap Heap Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;비트맵을 만든 뒤 블록 순서로 접근&lt;/td&gt;
&lt;td&gt;양호&lt;/td&gt;
&lt;td&gt;대상 행이 많을 때 유리. &lt;b&gt;정렬은 유지되지 않습니다&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;테이블 전체를 순차로 읽음&lt;/td&gt;
&lt;td&gt;경우에 따라&lt;/td&gt;
&lt;td&gt;작은 테이블이면 정상. &lt;code&gt;Rows Removed by Filter&lt;/code&gt;가 크면 인덱스 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Hash Join&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;해시 테이블을 만들어 조인&lt;/td&gt;
&lt;td&gt;대용량에 적합&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Batches&lt;/code&gt;가 1보다 크면 디스크로 넘친 것&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Nested Loop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;바깥 행마다 안쪽을 반복 탐색&lt;/td&gt;
&lt;td&gt;소규모에 적합&lt;/td&gt;
&lt;td&gt;&lt;code&gt;loops&lt;/code&gt;가 커지면 급격히 느려집니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Merge Join&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;양쪽을 조인 키로 정렬한 뒤 맞물려 병합&lt;/td&gt;
&lt;td&gt;정렬돼 있을 때만&lt;/td&gt;
&lt;td&gt;아래에 &lt;code&gt;Sort&lt;/code&gt;가 붙으면 Hash Join보다 불리합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;결과를 정렬&lt;/td&gt;
&lt;td&gt;확인 필요&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Index Scan&lt;/code&gt;으로 순서를 얻으면 없앨 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;조인 방식 세 가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵티마이저는 조인 방식도 비용으로 고릅니다. SQL을 한 글자도 안 바꿔도 &lt;b&gt;양쪽 입력의 행 수, 조인 키 인덱스 유무, &lt;code&gt;work_mem&lt;/code&gt; 크기, 통계 정확도&lt;/b&gt;에 따라 Nested Loop &amp;middot; Hash Join &amp;middot; Merge Join 중 다른 것이 선택됩니다. 플랜에 찍힌 조인 노드가 왜 그것인지 알아야 &lt;code&gt;Batches&lt;/code&gt;나 &lt;code&gt;loops&lt;/code&gt; 같은 숫자를 읽을 수 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Nested Loop&lt;/th&gt;
&lt;th&gt;Hash Join&lt;/th&gt;
&lt;th&gt;Merge Join&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동작&lt;/td&gt;
&lt;td&gt;바깥(outer) 행 하나마다 안쪽(inner)을 반복 탐색&lt;/td&gt;
&lt;td&gt;작은 쪽으로 해시 테이블을 만들고(build) 큰 쪽을 흘리며 대조(probe)&lt;/td&gt;
&lt;td&gt;양쪽을 조인 키로 정렬한 뒤 지퍼처럼 맞물려 병합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비용&lt;/td&gt;
&lt;td&gt;O(N &amp;times; M). inner 조인 키에 인덱스가 있으면 N &amp;times; log M&lt;/td&gt;
&lt;td&gt;O(N + M). 해시 테이블 생성 비용이 한 번&lt;/td&gt;
&lt;td&gt;정렬돼 있으면 O(N + M), 아니면 정렬 비용 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;유리할 때&lt;/td&gt;
&lt;td&gt;outer 결과가 아주 적고 inner 조인 키에 인덱스가 있을 때&lt;/td&gt;
&lt;td&gt;양쪽이 다 크고 등가(=) 조인이며 해시가 메모리에 들어갈 때&lt;/td&gt;
&lt;td&gt;양쪽이 크고 조인 키로 이미 정렬(인덱스 순서)돼 있을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;불리할 때&lt;/td&gt;
&lt;td&gt;outer 행 수가 늘면 반복 횟수가 그대로 곱해집니다&lt;/td&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt;이 부족하면 배치가 쪼개져 디스크로 넘칩니다&lt;/td&gt;
&lt;td&gt;정렬 결과가 메모리를 넘기면 디스크 정렬로 떨어집니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가능한 조건&lt;/td&gt;
&lt;td&gt;등가 &amp;middot; 비등가 &amp;middot; 범위 조인 전부&lt;/td&gt;
&lt;td&gt;&lt;b&gt;등가(=) 조인만&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;등가 및 범위 비교 (정렬 순서 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;플랜 표기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Nested Loop&lt;/code&gt; 아래 &lt;code&gt;loops=N&lt;/code&gt;인 Index Scan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Hash Join&lt;/code&gt; + &lt;code&gt;Hash (Buckets/Batches/Memory Usage)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Merge Join&lt;/code&gt; + 양쪽 &lt;code&gt;Sort&lt;/code&gt; 또는 Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;튜닝 지점&lt;/td&gt;
&lt;td&gt;outer를 줄이는 필터, inner 조인 키 인덱스&lt;/td&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt;을 올려 &lt;code&gt;Batches: 1&lt;/code&gt; 유지&lt;/td&gt;
&lt;td&gt;조인 키 인덱스로 &lt;code&gt;Sort&lt;/code&gt; 노드 자체를 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 방식을 강제로 바꿔가며 같은 쿼리를 측정해보면 숫자가 이렇게 갈립니다. 주문 9,590건과 주문상품 28,815건을 조인해 매출을 합산하는 쿼리이고, &lt;code&gt;enable_hashjoin&lt;/code&gt;&amp;middot;&lt;code&gt;enable_mergejoin&lt;/code&gt;&amp;middot;&lt;code&gt;enable_nestloop&lt;/code&gt;를 꺼서 방식을 지정했습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SET enable_hashjoin = off;
SET enable_mergejoin = off;     -- 둘 다 끄면 Nested Loop 만 남습니다
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_hashjoin; RESET enable_mergejoin;&lt;/code&gt;&lt;/pre&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조인 방식&lt;/th&gt;
&lt;th&gt;Execution Time&lt;/th&gt;
&lt;th&gt;Buffers (hit)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hash Join (옵티마이저 기본 선택)&lt;/td&gt;
&lt;td&gt;6.1 ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;291&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested Loop 강제&lt;/td&gt;
&lt;td&gt;&lt;b&gt;3.7 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;3,758&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge Join 강제&lt;/td&gt;
&lt;td&gt;5.6 ms&lt;/td&gt;
&lt;td&gt;348&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;Nested Loop가 시간은 제일 짧은데 Buffers는 12.9배입니다.&lt;/b&gt; 이 데이터는 전부 &lt;code&gt;shared_buffers&lt;/code&gt;에 올라와 있어서 페이지를 3,758번 읽어도 전부 메모리 적중이라 빨라 보입니다. 디스크를 읽는 환경이었다면 그 반복 액세스가 그대로 랜덤 I/O 비용이 됩니다. 실행 시간만으로 조인 방식을 비교하면 &lt;br /&gt;메모리 캐시의 영향을 구분할 수 없습니다.가 이 표에 그대로 나옵니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵티마이저가 Hash Join을 고른 것은 실행시간이 아니라 읽을 페이지 수를 기준으로 계산했기 때문입니다. 플랜에서 조인 노드를 볼 때 확인할 것은 방식 이름이 아니라 &lt;b&gt;그 아래 붙은 숫자&lt;/b&gt;입니다. Nested Loop면 &lt;code&gt;loops&lt;/code&gt;, Hash Join이면 &lt;code&gt;Batches&lt;/code&gt;와 &lt;code&gt;Memory Usage&lt;/code&gt;, Merge Join이면 &lt;code&gt;Sort&lt;/code&gt; 노드의 유무입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인덱스를 무력화하는 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사례는 인덱스가 없어서 안 탄 경우였습니다. 그런데 &lt;b&gt;인덱스가 있는데도 안 타는&lt;/b&gt; 경우가 더 자주 생깁니다. &lt;b&gt;인덱스는 컬럼의 값이 그대로 있을 때만 쓸 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컬럼에 함수를 씌우는 경우&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 인덱스를 못 씁니다 : 컬럼에 함수를 적용했습니다
WHERE YEAR(created_at) = 2026

-- 인덱스를 씁니다 : 컬럼은 그대로 두고 범위로 바꿉니다
WHERE created_at &amp;gt;= '2026-01-01'
  AND created_at &amp;lt;  '2027-01-01'&lt;/code&gt;&lt;/pre&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;&gt;&lt;code&gt;CREATE INDEX idx_users_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'a@b.com';   -- 인덱스 사용&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입이 다른 값을 비교하는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;user_id&lt;/code&gt;가 &lt;code&gt;INT&lt;/code&gt;인데 문자열로 비교하면, 데이터베이스가 컬럼 쪽을 변환하면서 인덱스를 못 씁니다. &lt;b&gt;따옴표 하나 때문에 전체 스캔이 일어납니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;WHERE user_id = '12345'    -- 타입 변환이 일어나 인덱스를 못 씁니다
WHERE user_id = 12345      -- 인덱스를 씁니다&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LIKE 앞에 와일드카드가 붙는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree 인덱스는 &lt;b&gt;앞에서부터 정렬돼 있어서&lt;/b&gt; 앞부분이 정해져야 범위를 좁힐 수 있습니다. 앞에 &lt;code&gt;%&lt;/code&gt;가 붙으면 시작점을 정할 수 없어 전부 읽습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;WHERE name LIKE '%길동%'    -- B-Tree 인덱스 사용 불가
WHERE name LIKE '홍%'       -- 앞이 고정이라 인덱스 사용 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간 검색이 꼭 필요하면 &lt;code&gt;pg_trgm&lt;/code&gt; 확장에 GIN 인덱스를 씁니다. 문자열을 세 글자 단위로 쪼개 색인하기 때문에 가운데 검색에도 인덱스가 걸립니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING GIN (name gin_trgm_ops);

SELECT * FROM users WHERE name LIKE '%길동%';   -- 이제 인덱스를 씁니다&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;앞이 고정된 &lt;code&gt;LIKE&lt;/code&gt;인데도 인덱스를 안 타는 경우가 있습니다.&lt;/b&gt; &lt;code&gt;text&lt;/code&gt; 컬럼의 기본 B-Tree 인덱스는 &lt;b&gt;데이터베이스 로케일이 &lt;code&gt;C&lt;/code&gt;가 아니면 &lt;code&gt;LIKE&lt;/code&gt;에 쓰이지 않습니다.&lt;/b&gt; 로케일에 따라 문자 정렬 순서가 달라져서 인덱스 순서와 &lt;code&gt;LIKE&lt;/code&gt;가 기대하는 순서가 어긋나기 때문입니다. &lt;code&gt;en_US.UTF-8&lt;/code&gt; 환경에서 실제로 겪었고, &lt;code&gt;text_pattern_ops&lt;/code&gt;를 지정해 해결했습니다.&lt;/div&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 로케일과 무관하게 LIKE 접두사 검색에 쓰이는 인덱스
CREATE INDEX idx_emp_email_pattern
    ON employees (lower(email) text_pattern_ops);

SELECT * FROM employees WHERE lower(email) LIKE 'user123%';&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;인덱스를 만들었는데 플랜이 그대로라면 &lt;b&gt;opclass와 로케일&lt;/b&gt;을 확인해봐야 합니다. &lt;code&gt;SHOW lc_collate;&lt;/code&gt; 로 현재 로케일을 볼 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OR 조건에 복합 인덱스를 거는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사례는 &lt;code&gt;AND&lt;/code&gt;였기 때문에 복합 인덱스가 맞았습니다. &lt;b&gt;&lt;code&gt;OR&lt;/code&gt;은 반대입니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;WHERE department_id = 10
   OR job_id IN (3, 4, 5)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;(department_id, job_id)&lt;/code&gt; 복합 인덱스는 &lt;b&gt;앞 컬럼으로 좁힌 뒤 그 안에서 뒤 컬럼을 보는&lt;/b&gt; 구조입니다. &lt;code&gt;OR&lt;/code&gt;은 둘 중 하나만 맞아도 되니 앞 컬럼으로 좁히는 것 자체가 성립하지 않습니다. &lt;b&gt;각 컬럼에 단일 인덱스를 따로 만들면&lt;/b&gt; 플래너가 두 인덱스를 각각 탐색한 뒤 &lt;code&gt;BitmapOr&lt;/code&gt;로 합칩니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_emp_department_id ON employees (department_id);
CREATE INDEX idx_emp_job_id        ON employees (job_id);
ANALYZE employees;

-- 플랜에 이렇게 나옵니다
-- Bitmap Heap Scan on employees
--   -&amp;gt;  BitmapOr
--         -&amp;gt;  Bitmap Index Scan on idx_emp_department_id
--         -&amp;gt;  Bitmap Index Scan on idx_emp_job_id&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 &lt;b&gt;&lt;code&gt;AND&lt;/code&gt;는 복합 인덱스 하나, &lt;code&gt;OR&lt;/code&gt;는 단일 인덱스 여러 개&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;OR&lt;/code&gt;를 &lt;code&gt;UNION ALL&lt;/code&gt;로 바꾸는 재작성은 결과를 틀리게 만들 수 있습니다.&lt;/b&gt; 두 조건을 각각 조회해 이어 붙이면 &lt;b&gt;양쪽 조건에 모두 걸리는 행이 두 번 세어집니다.&lt;/b&gt; 같은 데이터에서 실제로 19건이 중복 집계됐습니다. 성능 문제가 아니라 &lt;b&gt;정확성 문제&lt;/b&gt;입니다. &lt;code&gt;UNION&lt;/code&gt;으로 바꿔 중복을 제거할 수는 있지만, 인덱스만 갖추면 원본 &lt;code&gt;OR&lt;/code&gt;가 알아서 &lt;code&gt;BitmapOr&lt;/code&gt;로 처리되므로 재작성 자체가 필요하지 않습니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;쿼리 구조에서 생기는 문제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SELECT *&lt;/h3&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;&gt;&lt;code&gt;SELECT * FROM users WHERE email = 'a@b.com';        -- 테이블 접근 발생
SELECT id, name FROM users WHERE email = 'a@b.com'; -- Index Only Scan 가능&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;NOT IN 과 NULL&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;NOT IN&lt;/code&gt;의 목록에 &lt;code&gt;NULL&lt;/code&gt;이 하나라도 있으면 결과가 항상 비어 있습니다.&lt;/b&gt; &lt;code&gt;NULL&lt;/code&gt;과의 비교 결과가 참도 거짓도 아닌 &lt;code&gt;UNKNOWN&lt;/code&gt;이라, 조건 전체가 참이 되지 못하기 때문입니다. 에러가 안 나고 조용히 빈 결과만 나오기 때문에 원인을 찾기 어렵습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- customer_id 에 NULL 이 하나라도 있으면 결과가 항상 0건
SELECT * FROM users
 WHERE id NOT IN (SELECT customer_id FROM orders);

-- NOT EXISTS 는 NULL 이 있어도 정상 동작하고 대체로 더 빠릅니다
SELECT * FROM users u
 WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = u.id);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DISTINCT 남용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과에 중복이 보인다고 &lt;code&gt;DISTINCT&lt;/code&gt;를 붙이면 &lt;b&gt;중복이 왜 생겼는지를 덮어버립니다.&lt;/b&gt; 대부분 JOIN 때문에 행이 불어난 것이고, 존재 여부만 확인하면 되는 경우라면 &lt;code&gt;EXISTS&lt;/code&gt;가 맞습니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;-- 중복 원인을 덮는 방식
SELECT DISTINCT s.name
  FROM students s JOIN enrollments e ON e.student_id = s.id;

-- 존재 여부만 확인 (행이 불어나지 않습니다)
SELECT name FROM students s
 WHERE EXISTS (SELECT 1 FROM enrollments e WHERE e.student_id = s.id);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;큰 OFFSET&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지네이션을 &lt;code&gt;OFFSET&lt;/code&gt;으로 구현하면 &lt;b&gt;뒤로 갈수록 느려집니다.&lt;/b&gt; 건너뛸 행을 읽고 나서 버리는 방식이라, 10,000페이지를 보려면 100,000행을 읽은 뒤 버립니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 페이지가 뒤로 갈수록 선형으로 느려집니다
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;

-- 마지막으로 본 id 부터 이어 읽습니다 (Keyset / Cursor 방식)
SELECT * FROM orders WHERE id &amp;gt; :last_id ORDER BY id LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;Keyset 방식은 특정 페이지로 바로 건너뛸 수 없다는 제약이 있습니다. 무한 스크롤에는 잘 맞고, 페이지 번호를 눌러 이동하는 화면에는 안 맞습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;N+1&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록 100건을 조회한 뒤 각 건마다 상세를 한 번씩 더 조회하면 쿼리가 101번 나갑니다. 개별 쿼리는 전부 빠른데 합치면 느린 형태라, 실행계획만 봐서는 찾기 어렵습니다. 쿼리 &lt;b&gt;개수&lt;/b&gt;를 세어 봐야 보입니다. JOIN이나 &lt;code&gt;IN&lt;/code&gt; 한 번으로 묶습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제로 12개를 튜닝해보고 알게 된 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;e커머스 스키마(주문 9,590건 &amp;middot; 주문상품 28,815건 &amp;middot; 고객 3,000건)에 분석 쿼리 12개를 짜고 문항마다 &lt;b&gt;튜닝 전 실행계획 &amp;rarr; 병목 지목 &amp;rarr; 개선 &amp;rarr; 튜닝 후 실행계획&lt;/b&gt; 순서로 측정했습니다. 가장 크게 배운 것은 &lt;b&gt;인덱스로 해결되는 쿼리가 절반도 안 된다&lt;/b&gt;는 것이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 판단 기준부터 정리해야 했습니다. 이 규모는 테이블 전체가 &lt;code&gt;shared_buffers&lt;/code&gt;에 올라가서 &lt;code&gt;Seq Scan&lt;/code&gt;도 밀리초 안에 끝나고, 같은 쿼리를 두 번 돌려도 실행시간이 &amp;plusmn;20% 흔들립니다. 그래서 실행시간은 참고로만 두고 아래 순서로 봤습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;순위&lt;/th&gt;
&lt;th&gt;볼 것&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Buffers: shared hit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;실제로 읽은 페이지 수. 튜닝 효과가 가장 정직하게 찍힙니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;스캔 노드 변화&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;이 &lt;code&gt;Index Only Scan&lt;/code&gt;으로 바뀌었는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Heap Fetches&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Index Only Scan&lt;/code&gt;에서 0이면 힙 접근이 완전히 사라진 것&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sort Method&lt;/code&gt; &amp;middot; &lt;code&gt;Memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;정렬이 없어졌는가, 정렬 메모리가 줄었는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Rows Removed by Filter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;읽고 나서 버린 행. 클수록 인덱스가 필요하다는 신호&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Execution Time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;참고용. 이것만 보고 결론 내지 않습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스가 통한 쿼리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건에 맞는 행이 전체의 일부일 때는 인덱스가 그대로 먹혔습니다. &lt;b&gt;부분 인덱스&lt;/b&gt;로 인덱스에 담을 행을 미리 걸러 두고, &lt;code&gt;INCLUDE&lt;/code&gt;로 함께 읽는 컬럼을 얹어 &lt;b&gt;커버링 인덱스&lt;/b&gt;를 만드는 조합입니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_orders_sales_ts
    ON ecom.orders (order_ts)
    INCLUDE (order_id, customer_id, coupon_code)   -- 힙 접근을 없애려고 얹습니다
 WHERE order_status IN ('paid','shipped','delivered');   -- 9,590행 &amp;rarr; 7,241행만 색인

ANALYZE ecom.orders;   -- 통계를 갱신해야 옵티마이저가 새 인덱스를 고릅니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 놓치기 쉬운 것이 하나 있습니다. &lt;b&gt;쿼리의 &lt;code&gt;WHERE&lt;/code&gt; 절을 부분 인덱스 정의와 같은 모양으로 써야 그 인덱스가 선택됩니다.&lt;/b&gt; 인덱스에 &lt;code&gt;order_status IN ('paid','shipped','delivered')&lt;/code&gt;라고 적어 놓고 쿼리에서는 &lt;code&gt;order_status != 'cancelled'&lt;/code&gt;처럼 다르게 쓰면 결과가 같아도 인덱스를 안 탑니다. 옵티마이저가 두 조건이 같다는 것을 증명하지 못하기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스로 안 되는 쿼리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월별 매출 집계처럼 &lt;b&gt;조건이 &lt;code&gt;order_status&lt;/code&gt; 하나뿐이고 대상이 전체의 75%&lt;/b&gt;인 쿼리가 있었습니다. 부분 인덱스를 만들고 &lt;code&gt;enable_seqscan = off&lt;/code&gt;로 강제해봤더니 &lt;code&gt;Index Scan&lt;/code&gt; 쪽이 오히려 느렸습니다. 읽을 양을 못 줄이는데 인덱스를 한 번 더 거치는 비용만 붙기 때문입니다. &lt;b&gt;선택도가 높으면 &lt;code&gt;Seq Scan&lt;/code&gt;이 정답&lt;/b&gt;이고, 이때는 쿼리를 고쳐야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 병목은 &lt;code&gt;count(DISTINCT o.order_id)&lt;/code&gt;였습니다. 주문 1건이 주문상품 개수만큼 늘어나니 중복을 제거해야 하는데, &lt;code&gt;DISTINCT&lt;/code&gt;가 붙으면 &lt;code&gt;HashAggregate&lt;/code&gt;를 못 쓰고 &lt;code&gt;Sort&lt;/code&gt; + &lt;code&gt;GroupAggregate&lt;/code&gt;로 떨어집니다. 21,768행을 1.6MB 메모리로 정렬하고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 튜닝 전 : 조인 결과를 한 번에 GROUP BY &amp;rarr; Sort 1,619kB
SELECT date_trunc('month', o.order_ts)::date AS order_month,
       count(DISTINCT o.order_id) AS order_cnt,
       sum(oi.line_total)         AS revenue
  FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
 WHERE o.order_status IN ('paid','shipped','delivered')
 GROUP BY 1;

-- 튜닝 후 : 주문 단위로 먼저 접고, 월 단위로 한 번 더 접습니다
WITH order_amount AS (
    SELECT o.order_id,
           date_trunc('month', o.order_ts)::date AS order_month,
           sum(oi.line_total) AS amount
      FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
     WHERE o.order_status IN ('paid','shipped','delivered')
     GROUP BY o.order_id, 2          -- 여기서 중복이 사라집니다
)
SELECT order_month, count(*) AS order_cnt, sum(amount) AS revenue
  FROM order_amount
 GROUP BY order_month;               -- count(*) 라 Sort 가 필요 없습니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;count(DISTINCT)&lt;/code&gt;가 &lt;code&gt;count(*)&lt;/code&gt;로 바뀌면서 &lt;code&gt;Sort&lt;/code&gt; 노드가 통째로 사라지고 &lt;code&gt;HashAggregate&lt;/code&gt; 두 단계로 처리됐습니다. 지금 규모에서는 31.9ms에서 25.6ms로 20% 정도 줄었을 뿐이지만, 정렬이 &lt;code&gt;work_mem&lt;/code&gt;을 넘기면 &lt;code&gt;external merge Disk&lt;/code&gt;로 떨어지므로 데이터가 커질수록 격차가 벌어지는 구조입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;쿼리를 고쳐서 얻은 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 없이 쿼리 구조만 바꿔서 가장 크게 줄어든 것들입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;바꾼 것&lt;/th&gt;
&lt;th&gt;어떻게&lt;/th&gt;
&lt;th&gt;효과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2단계 집계&lt;/td&gt;
&lt;td&gt;&lt;code&gt;count(DISTINCT)&lt;/code&gt;를 주문 단위로 접어 &lt;code&gt;count(*)&lt;/code&gt;로&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sort&lt;/code&gt; 1.6MB 소멸&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;조인 순서&lt;/td&gt;
&lt;td&gt;집계&amp;middot;&lt;code&gt;HAVING&lt;/code&gt;을 먼저 끝내고 마지막에 조인&lt;/td&gt;
&lt;td&gt;조인 대상 600행 &amp;rarr; 12행, 5.6ms &amp;rarr; 0.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자기조인 제거&lt;/td&gt;
&lt;td&gt;같은 테이블 두 번 읽기를 &lt;code&gt;min(...) OVER (PARTITION BY ...)&lt;/code&gt; 윈도우 함수로&lt;/td&gt;
&lt;td&gt;198 &amp;rarr; 38 buffers (81% 감소)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IN&lt;/code&gt; &amp;rarr; &lt;code&gt;JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서브쿼리를 조인으로 바꿔 옵티마이저가 조인 순서를 고르게&lt;/td&gt;
&lt;td&gt;31.2ms &amp;rarr; 4.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;함수 인라인&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LANGUAGE plpgsql&lt;/code&gt; &amp;rarr; &lt;code&gt;LANGUAGE sql IMMUTABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;598 &amp;rarr; 379 buffers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 인라인은 예상 밖이었습니다. 0으로 나눠도 에러가 안 나는 안전 나눗셈 함수를 &lt;code&gt;plpgsql&lt;/code&gt;로 만들어 뒀는데, 행마다 함수 호출이 SPI를 거치면서 Buffers가 60% 가까이 늘어나 있었습니다. 본문이 &lt;code&gt;SELECT&lt;/code&gt; 한 줄뿐인 함수는 &lt;code&gt;LANGUAGE sql&lt;/code&gt;로 선언하면 &lt;b&gt;옵티마이저가 본문을 쿼리 안으로 펼쳐 넣어 호출 자체가 사라집니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE OR REPLACE FUNCTION ecom.f_safe_div(numer numeric, denom numeric)
RETURNS numeric
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$      -- plpgsql 이 아니라 sql
  SELECT CASE WHEN denom IS NULL OR denom = 0 THEN 0 ELSE numer / denom END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;튜닝이 안 되는 쿼리도 있습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12개 중 2개는 무엇을 해도 숫자가 안 움직였습니다.&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;code&gt;WHERE qty_on_hand &amp;lt; reorder_point&lt;/code&gt;처럼 &lt;b&gt;컬럼끼리 비교&lt;/b&gt;하는 조건은 일반 B-Tree 인덱스로는 처리할 수 없습니다. 비교 대상이 상수값이 아니라 두 컬럼의 값이라 인덱스로 탐색 범위를 만들 수 없기 때문입니다. 이 조건을 그대로 &lt;br /&gt;WHERE 절에 넣은 부분 인덱스를 만드는 방법이 있지만, 테이블이 600행뿐이라 옵티마이저는 &lt;code&gt;Seq Scan&lt;/code&gt;을 선택했습니다. 값이 아니라 두 컬럼의 관계라서 색인할 키가 없기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;쿠폰 사용 영향 분석&lt;/b&gt;: 전체 주문을 다 집계하는 쿼리라 읽을 양을 줄일 방법이 없었습니다. 인덱스도 재작성도 안 통하는 유형이고, 여기서부터는 &lt;b&gt;미리 계산해 두는 것&lt;/b&gt; 말고 답이 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 문항이 그 답이었습니다. 일자별 매출을 조회할 때마다 조인하고 집계하는 대신 &lt;b&gt;Materialized View&lt;/b&gt;로 결과를 저장해 뒀습니다. 조회 쪽 플랜에서 &lt;code&gt;Hash Join&lt;/code&gt;과 &lt;code&gt;HashAggregate&lt;/code&gt;가 통째로 없어지고 3.6ms &amp;middot; 379 buffers가 &lt;b&gt;0.06ms &amp;middot; 4 buffers&lt;/b&gt;로 바뀌었습니다. 12개 중 개선 폭이 가장 컸습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE MATERIALIZED VIEW ecom.mv_daily_gmv AS
SELECT date_trunc('day', o.order_ts)::date AS sales_day,
       count(DISTINCT o.order_id)          AS order_cnt,
       sum(oi.line_total)                  AS gmv
  FROM ecom.orders o JOIN ecom.order_items oi ON oi.order_id = o.order_id
 WHERE o.order_status IN ('paid','shipped','delivered')
 GROUP BY 1;

-- CONCURRENTLY 갱신을 쓰려면 UNIQUE 인덱스가 반드시 있어야 합니다
CREATE UNIQUE INDEX ux_mv_daily_gmv_day ON ecom.mv_daily_gmv (sales_day);
ANALYZE ecom.mv_daily_gmv;   -- MV 도 테이블이라 통계가 필요합니다&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;Materialized View는 자동으로 갱신되지 않는 스냅샷입니다.&lt;/b&gt; &lt;code&gt;REFRESH&lt;/code&gt;를 걸기 전까지 데이터가 그 시점에 멈춰 있습니다. &lt;code&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt;를 쓰면 갱신 중에도 조회가 안 잠기지만 &lt;code&gt;UNIQUE&lt;/code&gt; 인덱스가 없으면 실행 자체가 실패하고, 일반 &lt;code&gt;REFRESH&lt;/code&gt;보다 느립니다. 갱신 주기를 정할 때는 &lt;b&gt;지표가 확정되는 단위&lt;/b&gt;와 &lt;b&gt;사용자가 언제 보는지&lt;/b&gt;를 같이 봅니다. 일자별 매출은 하루 단위로 확정되고 리포트는 오후에 열리므로 매일 15시 1회로 잡았습니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;12개 결과&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;쿼리&lt;/th&gt;
&lt;th&gt;병목&lt;/th&gt;
&lt;th&gt;적용한 개선&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;th&gt;Buffers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지난 한 달 매출&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt; 후 77% 폐기&lt;/td&gt;
&lt;td&gt;부분 + 커버링 인덱스&lt;/td&gt;
&lt;td&gt;6.7 &amp;rarr; 5.0 ms&lt;/td&gt;
&lt;td&gt;376 &amp;rarr; 291&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;월별 AOV&lt;/td&gt;
&lt;td&gt;&lt;code&gt;count(DISTINCT)&lt;/code&gt; Sort 1.6MB&lt;/td&gt;
&lt;td&gt;2단계 집계&lt;/td&gt;
&lt;td&gt;31.9 &amp;rarr; 25.6 ms&lt;/td&gt;
&lt;td&gt;376 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;카테고리 Top 10&lt;/td&gt;
&lt;td&gt;주문상품 전량이 3단 조인 입력&lt;/td&gt;
&lt;td&gt;인덱스 재사용 + 필터 CTE 분리&lt;/td&gt;
&lt;td&gt;20.4 &amp;rarr; 15.6 ms&lt;/td&gt;
&lt;td&gt;324 &amp;rarr; 327&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;제품 매출 랭킹&lt;/td&gt;
&lt;td&gt;순위 확정 전 600행 전부 조인&lt;/td&gt;
&lt;td&gt;집계 &amp;rarr; 랭킹 &amp;rarr; 상위 20만 조인&lt;/td&gt;
&lt;td&gt;18.8 &amp;rarr; 17.2 ms&lt;/td&gt;
&lt;td&gt;383 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;고객 RFM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;count(DISTINCT)&lt;/code&gt; Sort 1.8MB&lt;/td&gt;
&lt;td&gt;2단계 집계&lt;/td&gt;
&lt;td&gt;23.8 &amp;rarr; 22.7 ms&lt;/td&gt;
&lt;td&gt;376 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30일 내 재구매율&lt;/td&gt;
&lt;td&gt;같은 테이블 2회 스캔 + 73% 폐기&lt;/td&gt;
&lt;td&gt;윈도우 함수 + 복합 부분 인덱스&lt;/td&gt;
&lt;td&gt;15.4 &amp;rarr; 5.7 ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;198 &amp;rarr; 38&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재고 임계치 미달&lt;/td&gt;
&lt;td&gt;컬럼 대 컬럼 비교&lt;/td&gt;
&lt;td&gt;부분 인덱스 (미선택이 정상)&lt;/td&gt;
&lt;td&gt;0.21 &amp;rarr; 0.26 ms&lt;/td&gt;
&lt;td&gt;12 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리뷰 효자상품&lt;/td&gt;
&lt;td&gt;12행 뽑으려고 600행 조인&lt;/td&gt;
&lt;td&gt;집계 &amp;middot; &lt;code&gt;HAVING&lt;/code&gt; 선행&lt;/td&gt;
&lt;td&gt;&lt;b&gt;5.6 &amp;rarr; 0.7 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;28 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쿠폰 영향 분석&lt;/td&gt;
&lt;td&gt;전량 집계&lt;/td&gt;
&lt;td&gt;조회 시 계산하는 대신 Materialized View로 미리 집계&lt;/td&gt;
&lt;td&gt;18.8 &amp;rarr; 19.9 ms&lt;/td&gt;
&lt;td&gt;376 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상위 1% 고객 매출&lt;/td&gt;
&lt;td&gt;테이블 2회 읽기 + 정렬 3회&lt;/td&gt;
&lt;td&gt;조인 제거 + &lt;code&gt;IN&lt;/code&gt;&amp;rarr;&lt;code&gt;JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;31.2 &amp;rarr; 4.3 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;752 &amp;rarr; 803&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;안전 나눗셈 함수&lt;/td&gt;
&lt;td&gt;&lt;code&gt;plpgsql&lt;/code&gt; 호출 오버헤드&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LANGUAGE sql&lt;/code&gt; 인라인&lt;/td&gt;
&lt;td&gt;소폭 단축&lt;/td&gt;
&lt;td&gt;&lt;b&gt;598 &amp;rarr; 379&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일자별 매출&lt;/td&gt;
&lt;td&gt;열 때마다 조인 + 집계&lt;/td&gt;
&lt;td&gt;Materialized View&lt;/td&gt;
&lt;td&gt;&lt;b&gt;3.6 &amp;rarr; 0.06 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;379 &amp;rarr; 4&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;Buffers가 늘었는데 시간이 줄어든 경우(상위 1% 고객 매출)도 있습니다. 정렬 세 번을 없애는 대신 인덱스 페이지를 더 읽은 것이고, 이때는 시간 쪽이 맞는 판단입니다. &lt;b&gt;지표 하나를 절대 기준으로 두면 이런 경우를 놓칩니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;느린 쿼리 찾아내기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 패턴을 알아도 &lt;b&gt;어떤 쿼리가 느린지&lt;/b&gt;부터 알아야 고칠 수 있습니다. PostgreSQL은 &lt;code&gt;pg_stat_statements&lt;/code&gt; 확장이 실행된 쿼리의 통계를 모아 줍니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;SELECT ROUND(mean_exec_time::NUMERIC, 2)  AS avg_ms,
       calls,
       ROUND(total_exec_time::NUMERIC, 0) AS total_ms,
       ROUND(shared_blks_hit::NUMERIC
             / NULLIF(shared_blks_hit + shared_blks_read, 0) * 100, 1) AS cache_hit_pct,
       LEFT(query, 100) AS query_snippet
  FROM pg_stat_statements
 ORDER BY mean_exec_time DESC
 LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 정렬 기준을 무엇으로 두느냐가 중요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;mean_exec_time&lt;/code&gt;&lt;/b&gt;: 한 번 실행에 오래 걸리는 쿼리입니다. 개별 화면이 느린 원인입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;total_exec_time&lt;/code&gt;&lt;/b&gt;: 누적으로 서버 시간을 가장 많이 쓴 쿼리입니다. &lt;b&gt;한 번은 빠른데 초당 수천 번 실행되는 쿼리&lt;/b&gt;가 여기서 잡힙니다. 전체 부하를 줄이려면 이쪽부터 봅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;cache_hit_pct&lt;/code&gt;&lt;/b&gt;: 낮으면 디스크를 읽고 있다는 뜻입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;튜닝 전후를 비교하려면 통계를 초기화한 뒤 같은 구간을 측정합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;SELECT pg_stat_statements_reset();&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;엔진마다 다른 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 PostgreSQL 기준입니다. 그런데 프로젝트가 바뀌면 MySQL이나 Oracle을 만나고, 같은 SQL인데 플랜이 다르게 나오거나 &lt;b&gt;PostgreSQL에서 쓰던 방법이 아예 없는&lt;/b&gt; 경우가 생깁니다. 네 엔진의 옵티마이저가 어디서 갈리는지 정리했습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL (InnoDB)&lt;/th&gt;
&lt;th&gt;Oracle&lt;/th&gt;
&lt;th&gt;SQL Server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;통계 수집&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANALYZE&lt;/code&gt;, autovacuum이 자동 갱신. 기본 100버킷 히스토그램&lt;/td&gt;
&lt;td&gt;인덱스 카디널리티 샘플링. 8.0부터 히스토그램&lt;/td&gt;
&lt;td&gt;히스토그램 &amp;middot; 확장통계 &amp;middot; 동적 샘플링&lt;/td&gt;
&lt;td&gt;자동 생성 &amp;middot; 자동 갱신. 밀도 벡터 + 히스토그램&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쓸 수 있는 조인&lt;/td&gt;
&lt;td&gt;Nested Loop &amp;middot; Hash &amp;middot; Merge&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Merge Join 없음.&lt;/b&gt; Hash Join은 8.0.18부터&lt;/td&gt;
&lt;td&gt;세 가지 전부, 병렬 실행 지원&lt;/td&gt;
&lt;td&gt;세 가지 전부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;힌트&lt;/td&gt;
&lt;td&gt;&lt;b&gt;공식 힌트 없음.&lt;/b&gt; &lt;code&gt;enable_*&lt;/code&gt; 파라미터나 &lt;code&gt;pg_hint_plan&lt;/code&gt; 확장&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/*+ ... */&lt;/code&gt;, &lt;code&gt;USE INDEX&lt;/code&gt; / &lt;code&gt;FORCE INDEX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/*+ ... */&lt;/code&gt;. 실무에서 널리 사용&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OPTION (...)&lt;/code&gt; 절, Plan Guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;계획 캐시&lt;/td&gt;
&lt;td&gt;캐시하지 않고 매번 재계산 (&lt;code&gt;PREPARE&lt;/code&gt; 시 generic plan)&lt;/td&gt;
&lt;td&gt;캐시하지 않음&lt;/td&gt;
&lt;td&gt;공유 풀에 캐시. Adaptive Plan으로 실행 중 변경&lt;/td&gt;
&lt;td&gt;재사용이 강해 파라미터 스니핑 발생. Query Store로 이력 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;계획 고정&lt;/td&gt;
&lt;td&gt;통계와 인덱스로 유도하는 방법뿐&lt;/td&gt;
&lt;td&gt;힌트로 유도&lt;/td&gt;
&lt;td&gt;SQL Plan Baseline&lt;/td&gt;
&lt;td&gt;Plan Guide &amp;middot; Query Store 강제 계획&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행계획 확인&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; (8.0.18+), &lt;code&gt;EXPLAIN FORMAT=JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXPLAIN PLAN&lt;/code&gt; + &lt;code&gt;DBMS_XPLAN.DISPLAY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;실제 실행 계획(Ctrl+M), &lt;code&gt;SET STATISTICS IO ON&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 엔진 모두 &lt;b&gt;비용 기반 옵티마이저(CBO)를&lt;/b&gt; 사용합니다. &amp;nbsp;통계를 바탕으로 각 실행 경로의 비용을 계산해 가장 비용이 낮은 계획을 선택합니다. 따라서 통계가 실제 데이터와 어긋나면 실행계획도 달라질 수 있습니다. 인덱스를 만든 뒤 ANALYZE를 함께 실행하는 이유가 여기에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔진마다 가장 큰 차이는 &lt;span&gt;&lt;b&gt;실행계획을 얼마나 직접 제어할 수 있는지&lt;/b&gt;&lt;/span&gt;입니다. Oracle과 SQL Server는 힌트나 Plan Baseline 같은 기능으로 특정 실행계획을 강제로 사용할 수 있습니다. 반면 PostgreSQL은 공식 힌트를 제공하지 않으므로, 인덱스를 만들거나 ANALYZE로 통계를 갱신해 옵티마이저가 원하는 실행계획을 선택하도록 유도하는 방식이 일반적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 조인 방식을 비교할 때 사용한 enable_hashjoin = off도 실행계획을 확인하기 위한 &lt;span&gt;&lt;b&gt;실험용 설정&lt;/b&gt;&lt;/span&gt;입니다. 세션 전체에 적용되는 옵션이라 운영 환경에서 특정 쿼리의 실행계획을 제어하는 용도로 사용하지는 않습니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;MySQL은 오랫동안 Nested Loop만 있었고 8.0.18에서야 Hash Join이 들어왔습니다. Merge Join은 지금도 없습니다. 이 글의 실습처럼 &lt;b&gt;대량 집계와 조인이 많은 분석 쿼리&lt;/b&gt;라면 MySQL에서는 같은 SQL이 Nested Loop로 풀려 반복 액세스가 커질 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 쿼리를 만났을 때의 순서입니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; display: flex; align-items: center; gap: 8px;&quot;&gt;
&lt;div style=&quot;flex: 1; padding: 14px 10px; text-align: center; border-radius: 10px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 15px; font-weight: bold;&quot;&gt;① 찾기
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;pg_stat_statements&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 20px; text-align: center; color: #6b7280;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 14px 10px; text-align: center; border-radius: 10px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 15px; font-weight: bold;&quot;&gt;② 원인 보기
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;EXPLAIN ANALYZE&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 20px; text-align: center; color: #6b7280;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 14px 10px; text-align: center; border-radius: 10px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 15px; font-weight: bold;&quot;&gt;③ 고치기
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;쿼리 or 인덱스&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 20px; text-align: center; color: #6b7280;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 14px 10px; text-align: center; border-radius: 10px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 15px; font-weight: bold;&quot;&gt;④ 재측정
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;reset 후 비교&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;네 단계를 다 돌렸는데도 느리다면 쿼리 문제가 아니라 &lt;b&gt;테이블 크기 문제&lt;/b&gt;일 수 있습니다. 그때부터는 파티셔닝으로 읽을 대상을 줄이거나, 읽기 복제본으로 부하를 나누거나, 샤딩까지 가는 선택이 남습니다. 모두 시스템 구조에 영향을 주는 변경이라 &lt;b&gt;실무에서 마주치는 DB 트레이드오프&lt;/b&gt;에서 따로 정리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플랜에서 볼 곳과 인덱스를 무력화하는 패턴을 정리해보겠습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;플랜에서 볼 곳&lt;/th&gt;
&lt;th&gt;이상하면&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt; + &lt;code&gt;Rows Removed by Filter&lt;/code&gt;가 큼&lt;/td&gt;
&lt;td&gt;그 조건 컬럼에 인덱스 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추정 &lt;code&gt;rows&lt;/code&gt;와 실제 &lt;code&gt;rows&lt;/code&gt;가 몇 배 차이&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANALYZE&lt;/code&gt; 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loops&lt;/code&gt;가 1이 아님&lt;/td&gt;
&lt;td&gt;&lt;code&gt;actual time&lt;/code&gt;에 곱해서 실제 시간 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sort Method: external merge Disk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt; 부족&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Hash&lt;/code&gt;의 &lt;code&gt;Batches&lt;/code&gt;가 1보다 큼&lt;/td&gt;
&lt;td&gt;해시가 디스크로 넘친 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Buffers&lt;/code&gt;의 &lt;code&gt;read&lt;/code&gt;가 큼&lt;/td&gt;
&lt;td&gt;디스크를 읽고 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;안티패턴&lt;/th&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;고치는 방법&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;컬럼에 함수 적용&lt;/td&gt;
&lt;td&gt;인덱스 무력화&lt;/td&gt;
&lt;td&gt;범위 조건으로 바꾸거나 함수 기반 인덱스 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;전송량 증가, 커버링 인덱스 불가&lt;/td&gt;
&lt;td&gt;필요한 컬럼만 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타입 불일치&lt;/td&gt;
&lt;td&gt;암묵적 변환으로 인덱스 무력화&lt;/td&gt;
&lt;td&gt;같은 타입으로 비교&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LIKE '%키워드%'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;시작점을 못 정해 전체 스캔&lt;/td&gt;
&lt;td&gt;prefix 검색으로 바꾸거나 &lt;code&gt;pg_trgm&lt;/code&gt; GIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OR&lt;/code&gt;에 복합 인덱스&lt;/td&gt;
&lt;td&gt;앞 컬럼으로 좁힐 수 없음&lt;/td&gt;
&lt;td&gt;단일 인덱스를 각각 만들어 &lt;code&gt;BitmapOr&lt;/code&gt; 유도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N+1&lt;/td&gt;
&lt;td&gt;쿼리 개수가 행 수만큼 늘어남&lt;/td&gt;
&lt;td&gt;JOIN이나 &lt;code&gt;IN&lt;/code&gt;으로 한 번에&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT IN&lt;/code&gt; + &lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;결과가 항상 비어 있음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT EXISTS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DISTINCT&lt;/code&gt; 남용&lt;/td&gt;
&lt;td&gt;중복 원인을 덮음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXISTS&lt;/code&gt;로 존재 여부만 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;큰 &lt;code&gt;OFFSET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;뒤 페이지일수록 느려짐&lt;/td&gt;
&lt;td&gt;Keyset(Cursor) 방식&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 실행계획은 &lt;b&gt;예측과 실측을 나란히 보여주는 표&lt;/b&gt;입니다. &lt;code&gt;Rows Removed by Filter&lt;/code&gt;가 크거나 추정과 실제 행 수가 크게 벌어진 노드가 문제의 시작점입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/using-explain.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Using EXPLAIN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-bitmap-scans.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Combining Multiple Indexes (Bitmap Scan)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/pgstatstatements.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;pg_stat_statements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/pgtrgm.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;pg_trgm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;실행계획 시각화: &lt;a href=&quot;https://explain.dalibo.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;explain.dalibo.com&lt;/a&gt; (&lt;code&gt;FORMAT JSON&lt;/code&gt; 결과를 붙여넣으면 트리로 보여줍니다)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/데이터&amp;middot;통계</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/13</guid>
      <comments>https://boradora1.tistory.com/13#entry13comment</comments>
      <pubDate>Tue, 4 Aug 2026 09:04:37 +0900</pubDate>
    </item>
    <item>
      <title>SQL JOIN 실수 패턴 | LEFT JOIN과 WHERE, 중복 행, NOT IN NULL, EXISTS</title>
      <link>https://boradora1.tistory.com/12</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yaIbu/dJMcafOoPEd/K7OeHiJ6ONAa4d302J6Kx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yaIbu/dJMcafOoPEd/K7OeHiJ6ONAa4d302J6Kx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yaIbu/dJMcafOoPEd/K7OeHiJ6ONAa4d302J6Kx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyaIbu%2FdJMcafOoPEd%2FK7OeHiJ6ONAa4d302J6Kx0%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;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;LEFT JOIN&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;NULL&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;EXISTS&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;NOT IN&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;JOIN 문법은 금방 익숙해지는데, 결과 행 수가 예상과 다르게 나오는 일은 계속 생깁니다. 오류가 안 나고 결과만 조용히 틀리기 때문에 찾기도 어렵습니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt;을 걸어놓고 &lt;code&gt;WHERE&lt;/code&gt;로 조건을 하나 붙였는데, 왜 결과가 줄어들까요?&lt;/b&gt;&lt;br /&gt;그 순간 &lt;code&gt;LEFT JOIN&lt;/code&gt;이 &lt;code&gt;INNER JOIN&lt;/code&gt;으로 바뀌었기 때문입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문법이 틀린 것이 아니라 &lt;b&gt;같은 문법이 다른 결과를 내는 자리&lt;/b&gt;가 몇 군데 있습니다. 전부 실행은 잘 되고 오류도 없어서, 알고 있지 않으면 그냥 지나갈 수 밖에 없죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ON 절과 WHERE 절&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 학생을 보여주되 CS101 성적만 함께 보고 싶다고 해보겠습니다. 수강하지 않은 학생도 목록에는 있어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;-- 의도와 다른 결과
SELECT s.name, e.score
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
WHERE e.course_id = 1;      -- 수강 안 한 학생이 사라집니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수강하지 않은 학생은 &lt;code&gt;e.course_id&lt;/code&gt;가 &lt;code&gt;NULL&lt;/code&gt;입니다. 그런데 &lt;code&gt;WHERE&lt;/code&gt;는 &lt;b&gt;조인이 끝난 뒤&lt;/b&gt;에 행을 거르므로, &lt;code&gt;NULL = 1&lt;/code&gt;이 참이 아니라서 그 학생들이 통째로 빠집니다. &lt;code&gt;LEFT JOIN&lt;/code&gt;으로 남겨 둔 행이 &lt;code&gt;WHERE&lt;/code&gt; 절에서 다시 제거된 것입니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;-- 의도대로
SELECT s.name, e.score
FROM students s
LEFT JOIN enrollments e
       ON e.student_id = s.id
      AND e.course_id = 1;   -- 조인 조건에 넣습니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON&lt;/code&gt;에 넣으면 &lt;b&gt;조인하는 과정에서&lt;/b&gt; 조건이 적용됩니다. CS101을 안 들은 학생은 짝을 못 찾을 뿐 행은 남고, &lt;code&gt;score&lt;/code&gt;만 &lt;code&gt;NULL&lt;/code&gt;로 채워집니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 580px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 12px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 250px;&quot;&gt;
&lt;div style=&quot;font-size: 15px; font-weight: bold; color: #455148; margin-bottom: 9px;&quot;&gt;ON 절에 넣으면&lt;/div&gt;
&lt;div style=&quot;padding: 9px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 7px; margin-bottom: 5px;&quot;&gt;조인하면서 조건을 함께 본다&lt;/div&gt;
&lt;div style=&quot;text-align: center; color: #6b7280; font-size: 15px; line-height: 1.2; margin: 2px 0;&quot;&gt;▼&lt;/div&gt;
&lt;div style=&quot;padding: 9px; text-align: center; font-size: 14px; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 7px; color: #455148; font-weight: bold;&quot;&gt;짝이 없으면 NULL 로 채워 남긴다&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #455148; margin-top: 9px; line-height: 1.6;&quot;&gt;LEFT JOIN 이 그대로 유지됩니다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 250px;&quot;&gt;
&lt;div style=&quot;font-size: 15px; font-weight: bold; color: #5f5651; margin-bottom: 9px;&quot;&gt;WHERE 절에 넣으면&lt;/div&gt;
&lt;div style=&quot;padding: 9px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 7px; margin-bottom: 5px;&quot;&gt;조인을 먼저 끝낸다&lt;/div&gt;
&lt;div style=&quot;text-align: center; color: #6b7280; font-size: 15px; line-height: 1.2; margin: 2px 0;&quot;&gt;▼&lt;/div&gt;
&lt;div style=&quot;padding: 9px; text-align: center; font-size: 14px; background: #f2eae6; border: 1px solid #d9c4b8; border-radius: 7px; color: #5f5651; font-weight: bold;&quot;&gt;NULL 인 행이 조건에 걸려 지워진다&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #5f5651; margin-top: 9px; line-height: 1.6;&quot;&gt;결과가 INNER JOIN 과 같아집니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 16px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;&lt;b&gt;일부러 INNER JOIN 처럼 쓰고 싶을 때는 &lt;code style=&quot;font-size: 14px;&quot;&gt;WHERE&lt;/code&gt; 가 맞습니다.&lt;/b&gt; OUTER JOIN 결과를 유지하면서 조건을 걸고 싶을 때만 &lt;code style=&quot;font-size: 14px;&quot;&gt;ON&lt;/code&gt; 에 넣습니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;예외가 하나 있습니다. &lt;code&gt;WHERE e.id IS NULL&lt;/code&gt; 처럼 &lt;b&gt;짝이 없는 행만 찾는 경우&lt;/b&gt;는 &lt;code&gt;WHERE&lt;/code&gt;에 두는 것이 맞습니다. 수강한 적 없는 학생만 뽑는 패턴이고, 이걸 Anti-Join 이라고 부릅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과 행 수 예측&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JOIN을 걸기 전에 &lt;b&gt;몇 행이 나올지 먼저 세어보는 습관&lt;/b&gt;이 실수를 많이 막아줄 수 있습니다. A가 5행, B가 3행이고 그중 2건이 매칭될 때 종류별로 이렇게 나오게 됩니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JOIN 종류&lt;/th&gt;
&lt;th&gt;결과 행 수&lt;/th&gt;
&lt;th&gt;&lt;code&gt;NULL&lt;/code&gt;이 들어가는 칸&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INNER JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2행 (매칭된 것만)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5행 (A 전체)&lt;/td&gt;
&lt;td&gt;B 컬럼 (3행이 &lt;code&gt;NULL&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RIGHT JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3행 (B 전체)&lt;/td&gt;
&lt;td&gt;A 컬럼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL OUTER JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6행 (5 + 3 &amp;minus; 2)&lt;/td&gt;
&lt;td&gt;양쪽 비매칭 컬럼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CROSS JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;15행&lt;/b&gt; (5 &amp;times; 3)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anti-Join (&lt;code&gt;NOT EXISTS&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;3행 (5 &amp;minus; 2)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON&lt;/code&gt; 조건을 빠뜨리면 &lt;code&gt;CROSS JOIN&lt;/code&gt;이 됩니다. 5행과 3행이면 15행이라 눈에 띄지만, &lt;b&gt;10만 행과 10만 행이면 100억 행&lt;/b&gt;이 됩니다. JOIN 조건이 빠지면 결과 행 수가 기하급수적으로 늘어나 실행 시간이 크게 증가합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1:N 조인의 중복&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고치는 방법은 두 가지입니다. &lt;b&gt;서브쿼리에서 먼저 집계한 뒤 조인&lt;/b&gt;하거나, 값이 하나뿐이라면 &lt;code&gt;DISTINCT&lt;/code&gt;로 거릅니다. 합계를 계산하는 상황이라면 앞쪽이 맞습니다. &lt;code&gt;DISTINCT&lt;/code&gt;는 실제로 같은 금액의 주문이 여러 건일 경우 그것까지 하나로 합쳐 버릴 수 있기 때문입니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap;&quot;&gt;
&lt;div style=&quot;flex: 1 1 150px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; margin-bottom: 7px;&quot;&gt;orders (1건)&lt;/div&gt;
&lt;div style=&quot;padding: 9px; text-align: center; font-size: 14px; background: #e7eaf1; border: 1px solid #bac3d8; border-radius: 6px; color: #52565f;&quot;&gt;주문 101 &amp;middot; 5만원&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; margin-bottom: 7px;&quot;&gt;order_items (3건)&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 4px;&quot;&gt;키보드&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 4px;&quot;&gt;마우스&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;모니터&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 180px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #5f5651; margin-bottom: 7px;&quot;&gt;JOIN 결과 (3행)&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f2eae6; border: 1px solid #d9c4b8; border-radius: 6px; margin-bottom: 4px; color: #5f5651;&quot;&gt;5만원&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f2eae6; border: 1px solid #d9c4b8; border-radius: 6px; margin-bottom: 4px; color: #5f5651;&quot;&gt;5만원&lt;/div&gt;
&lt;div style=&quot;padding: 8px; text-align: center; font-size: 14px; background: #f2eae6; border: 1px solid #d9c4b8; border-radius: 6px; color: #5f5651;&quot;&gt;5만원&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 16px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;여기서 &lt;code style=&quot;font-size: 14px;&quot;&gt;SUM(o.amount)&lt;/code&gt; 를 하면 &lt;b&gt;5만원이 아니라 15만원&lt;/b&gt;이 나옵니다. 주문 금액이 상품 개수만큼 복사됐기 때문입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고치는 방법은 두 가지입니다. &lt;b&gt;서브쿼리에서 먼저 집계한 뒤 조인&lt;/b&gt;하거나, 값이 하나뿐이라면 &lt;code&gt;DISTINCT&lt;/code&gt;로 거릅니다. 합계를 내는 상황이라면 앞쪽이 맞습니다. &lt;code&gt;DISTINCT&lt;/code&gt;는 같은 금액이 진짜로 여러 건일 때 그것까지 지워버립니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NOT IN의 NULL 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 결과가 조금 어긋나는 정도가 아니라 &lt;b&gt;아예 빈 집합이 나옵니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 서브쿼리 결과에 NULL 이 하나라도 있으면 항상 0건
SELECT name FROM students
WHERE id NOT IN (SELECT student_id FROM enrollments);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;enrollments.student_id&lt;/code&gt;에 &lt;code&gt;NULL&lt;/code&gt;이 한 건이라도 있으면 결과가 무조건 비어버립니다. &lt;code&gt;NOT IN&lt;/code&gt;은 내부적으로 &lt;code&gt;id &amp;lt;&amp;gt; 1 AND id &amp;lt;&amp;gt; 2 AND id &amp;lt;&amp;gt; NULL&lt;/code&gt; 처럼 풀리는데, &lt;code&gt;NULL&lt;/code&gt;과의 비교는 참도 거짓도 아닌 &lt;b&gt;알 수 없음&lt;/b&gt;이 되고, &lt;code&gt;AND&lt;/code&gt;로 묶이면 전체가 참이 될 수 없기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;-- NOT EXISTS 로 바꾸면 NULL 이 있어도 정상 동작
SELECT s.name
FROM students s
WHERE NOT EXISTS (
    SELECT 1 FROM enrollments e WHERE e.student_id = s.id
);&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;&lt;code&gt;NOT EXISTS&lt;/code&gt;를 기본으로 사용하는 습관이 안전합니다.&lt;/b&gt; &lt;code&gt;NOT IN&lt;/code&gt;은 비교 대상에 &lt;code&gt;NULL&lt;/code&gt;이 하나라도 포함되면 예상과 다른 결과가 나올 수 있습니다. 비교 대상이 &lt;code&gt;NOT NULL&lt;/code&gt;임을 확실히 알 때만 &lt;code&gt;NOT IN&lt;/code&gt;을 사용하는 것이 좋습니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;EXISTS와 Semi-Join&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;90점 이상 받은 과목이 하나라도 있는 학생&quot;을 찾는다고 해보겠습니다. JOIN으로 짜면 중복이 생깁니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;-- 90점 이상 과목 수만큼 학생 이름이 반복됩니다
SELECT DISTINCT s.name
FROM students s
JOIN enrollments e ON e.student_id = s.id
WHERE e.score &amp;gt;= 90;

-- EXISTS: 하나라도 있으면 참. 중복이 생기지 않습니다
SELECT s.name
FROM students s
WHERE EXISTS (
    SELECT 1 FROM enrollments e
    WHERE e.student_id = s.id AND e.score &amp;gt;= 90
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오른쪽 테이블의 존재 여부만 확인하고 값은 가져오지 않는 조인&lt;/b&gt;을 Semi-Join이라고 합니다. &lt;code&gt;EXISTS&lt;/code&gt;와 &lt;code&gt;IN&lt;/code&gt;은 많은 DBMS에서 옵티마이저가 이런 형태로 최적화합니다. 이 경우 조건을 만족하는 행을 하나 찾으면 추가 탐색을 생략할 수 있어 불필요한 작업을 줄일 수 있습니다. 또한 결과 자체가 존재 여부만 판단하므로 &lt;code&gt;DISTINCT&lt;/code&gt;를 따로 사용할 필요가 없습니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;IN&lt;/code&gt;은 비교 대상이 명확하고 목록이 작을 때 읽기 쉽습니다. 반면 서브쿼리 결과가 크거나 바깥 쿼리의 값을 참조하는 상관 서브쿼리라면 &lt;code&gt;EXISTS&lt;/code&gt;가 더 적합한 경우가 많습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&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;code&gt;LEFT JOIN&lt;/code&gt; + &lt;code&gt;WHERE&lt;/code&gt; 우측 조건 = &lt;code&gt;INNER JOIN&lt;/code&gt;&lt;/b&gt;. OUTER를 유지하려면 조건을 &lt;code&gt;ON&lt;/code&gt;에 넣습니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;짝이 없는 행만 찾을 때는 &lt;code&gt;WHERE ... IS NULL&lt;/code&gt;이 맞습니다.&lt;/b&gt; 이건 Anti-Join 패턴입니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;ON&lt;/code&gt;을 빠뜨리면 &lt;code&gt;CROSS JOIN&lt;/code&gt;입니다.&lt;/b&gt; 10만 &amp;times; 10만이면 쿼리가 끝나지 않습니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;1:N 조인 뒤의 집계는 부풀려집니다.&lt;/b&gt; 서브쿼리에서 먼저 집계하고 조인합니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;NOT IN&lt;/code&gt;은 &lt;code&gt;NULL&lt;/code&gt; 하나에 결과가 통째로 비어버립니다.&lt;/b&gt; &lt;code&gt;NOT EXISTS&lt;/code&gt;를 기본으로 씁니다&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;
&lt;b&gt;한 줄 정리&lt;/b&gt;:
&lt;code&gt;ON&lt;/code&gt;은 &lt;b&gt;조인할 대상을 고르는 조건&lt;/b&gt;이고,
&lt;code&gt;WHERE&lt;/code&gt;는 &lt;b&gt;조인이 끝난 결과를 거르는 조건&lt;/b&gt;입니다.
이 순서만 기억해도 &lt;code&gt;LEFT JOIN&lt;/code&gt;, &lt;code&gt;NULL&lt;/code&gt;, 결과 행 수가 왜 달라지는지 대부분 이해할 수 있습니다.
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/queries-table-expressions.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PostgreSQL Documentation — Table Expressions (JOIN)&lt;/a&gt;&lt;/li&gt;

&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/functions-subquery.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PostgreSQL Documentation — Subquery Expressions (EXISTS · IN)&lt;/a&gt;&lt;/li&gt;

&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/functions-comparison.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PostgreSQL Documentation — Comparison Functions and Operators (NULL)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/데이터&amp;middot;통계</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/12</guid>
      <comments>https://boradora1.tistory.com/12#entry12comment</comments>
      <pubDate>Mon, 3 Aug 2026 22:54:37 +0900</pubDate>
    </item>
    <item>
      <title>PostgreSQL 인덱스 설계 | 선택도, 복합 인덱스 순서, 커버링 인덱스, 쓰기 비용</title>
      <link>https://boradora1.tistory.com/11</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXgxxk/dJMcajpt0km/r8yGpzQQ7LTmrBufEAN6j1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXgxxk/dJMcajpt0km/r8yGpzQQ7LTmrBufEAN6j1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXgxxk/dJMcajpt0km/r8yGpzQQ7LTmrBufEAN6j1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXgxxk%2FdJMcajpt0km%2Fr8yGpzQQ7LTmrBufEAN6j1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;인덱스&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;B-Tree&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;선택도&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;복합 인덱스&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;커버링 인덱스&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;PostgreSQL&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;쿼리가 느릴 때 가장 먼저 나오는 답이 인덱스를 걸라는 것입니다. 그런데 인덱스를 만들면 조회는 빨라지지만 &lt;b&gt;쓰기가 느려지고 저장 공간이 늘어납니다.&lt;/b&gt; 어느 컬럼에 걸어야 하고 어디까지 걸어도 되는지 정리해보겠습니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;인덱스를 걸었는데 왜 더 느려졌을까요?&lt;/b&gt;&lt;br /&gt;조건에 맞는 행이 전체의 20%를 넘으면 인덱스를 타도 순차 읽기와 속도가 비슷해집니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수백만 행짜리 테이블에서 조건 하나로 행을 찾는다고 해봅시다. 인덱스가 없으면 데이터베이스는 첫 행부터 마지막 행까지 전부 확인합니다. 행이 두 배가 되면 걸리는 시간도 두 배가 되는 &lt;b&gt;O(N)&lt;/b&gt; 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 있으면 탐색 횟수가 &lt;b&gt;O(log N)&lt;/b&gt;으로 줄어듭니다. B-Tree는 노드 하나에 수백 개의 자식이 붙기 때문에, &lt;b&gt;행이 수백 배로 늘어나야 탐색 단계가 하나 늘어납니다.&lt;/b&gt; 그래서 100만 행이든 1억 행이든 몇 단계 안에 도달합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제로 재보면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5만 행짜리 직원 테이블에서 이메일로 한 명을 찾는 쿼리를 직접 측정해봤습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM employees
 WHERE lower(email) = 'user1234@corp.com';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인덱스가 없을 때&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;322&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qGSv6/dJMcafgtz6Q/U9Fb96NiFOKKglM6KVhpf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qGSv6/dJMcafgtz6Q/U9Fb96NiFOKKglM6KVhpf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qGSv6/dJMcafgtz6Q/U9Fb96NiFOKKglM6KVhpf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqGSv6%2FdJMcafgtz6Q%2FU9Fb96NiFOKKglM6KVhpf1%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;1228&quot; height=&quot;322&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;322&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Seq Scan on employees  (cost=0.00..1657.00 rows=250 width=28)
                       (actual time=13.944..13.946 rows=1 loops=1)
  Filter: (lower(email) = 'user1234@corp.com'::text)
  Rows Removed by Filter: 50000
  Buffers: shared hit=907

Execution Time: 13.968 ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1건을 찾으려고 5만 건을 읽고 버렸습니다.&lt;/b&gt; &lt;code&gt;Rows Removed by Filter: 50000&lt;/code&gt;이 그 숫자입니다. 이 한 줄이 인덱스가 필요하다는 신호입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 만들고 같은 쿼리를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_employees_lower_email ON employees (lower(email));
ANALYZE employees;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;1172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwqlH5/dJMcai5djYz/ITzQ7a61AIbqRzDv1vrgP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwqlH5/dJMcai5djYz/ITzQ7a61AIbqRzDv1vrgP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwqlH5/dJMcai5djYz/ITzQ7a61AIbqRzDv1vrgP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwqlH5%2FdJMcai5djYz%2FITzQ7a61AIbqRzDv1vrgP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1584&quot; height=&quot;1172&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;1172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Index Scan using idx_employees_lower_email on employees
    (cost=0.41..8.43 rows=1 width=28)
  Index Cond: (lower(email) = 'user1234@corp.com'::text)
  Buffers: shared read=3

Execution Time: 0.090 ms&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 540px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 14px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 150px; font-size: 14px; color: #464d57;&quot;&gt;인덱스 없음
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #5f5651;&quot;&gt;Seq Scan &amp;middot; O(N)&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1; height: 30px; border-radius: 5px; background: #f2eae6; border: 1px solid #d9c4b8; color: #5f5651; font-size: 14px; text-align: center; line-height: 30px;&quot;&gt;5만 행을 읽고 49,999행을 버림&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #5f5651;&quot;&gt;13.968 ms&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 150px; font-size: 14px; color: #464d57;&quot;&gt;인덱스 있음
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #455148;&quot;&gt;Index Scan &amp;middot; O(log N)&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1; display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 12%; height: 30px; border-radius: 5px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 14px; text-align: center; line-height: 30px;&quot;&gt;3 페이지&lt;/div&gt;
&lt;div style=&quot;flex: 1;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #455148;&quot;&gt;0.090 ms&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;약 155배 차이입니다. 읽은 페이지 수는 907에서 3으로 줄었습니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;여기서 인덱스를 &lt;code&gt;email&lt;/code&gt;에 걸면 안 됩니다.&lt;/b&gt; 조건이 &lt;code&gt;lower(email)&lt;/code&gt;이라 컬럼 값이 그대로 쓰이지 않기 때문에, &lt;code&gt;email&lt;/code&gt; 인덱스는 타지 않습니다. &lt;b&gt;함수를 적용한 결과에 인덱스를 걸어야&lt;/b&gt; 위처럼 &lt;code&gt;Index Cond&lt;/code&gt;로 잡힙니다. 이런 인덱스를 &lt;b&gt;함수 기반 인덱스&lt;/b&gt;라고 합니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인덱스를 걸면 느려지는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지만 보면 모든 컬럼에 인덱스를 걸면 될 것 같습니다. 그렇게 하지 않는 이유는 &lt;b&gt;조회가 빨라지는 만큼 쓰기가 느려지고 저장 공간이 늘어나기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1.5px solid #bbd9c1; border-radius: 11px; padding: 16px; background: #e7f2e9;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #455148;&quot;&gt;빨라짐&lt;/div&gt;
&lt;div style=&quot;font-size: 19px; font-weight: 800; margin: 4px 0 6px; color: #455148;&quot;&gt;조회&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;WHERE&lt;/code&gt; &amp;middot; &lt;code style=&quot;background: none; padding: 0;&quot;&gt;JOIN&lt;/code&gt; &amp;middot; &lt;code style=&quot;background: none; padding: 0;&quot;&gt;ORDER BY&lt;/code&gt; 가 빨라집니다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1px solid #d9c4b8; border-radius: 11px; padding: 16px; background: #f2eae6;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #5f5651;&quot;&gt;느려짐&lt;/div&gt;
&lt;div style=&quot;font-size: 19px; font-weight: 800; margin: 4px 0 6px; color: #5f5651;&quot;&gt;쓰기&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;INSERT&lt;/code&gt; &amp;middot; &lt;code style=&quot;background: none; padding: 0;&quot;&gt;UPDATE&lt;/code&gt; &amp;middot; &lt;code style=&quot;background: none; padding: 0;&quot;&gt;DELETE&lt;/code&gt; 마다 인덱스를 재정렬합니다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1px solid #d9c4b8; border-radius: 11px; padding: 16px; background: #f2eae6;&quot;&gt;
&lt;div style=&quot;font-size: 14px; color: #5f5651;&quot;&gt;늘어남&lt;/div&gt;
&lt;div style=&quot;font-size: 19px; font-weight: 800; margin: 4px 0 6px; color: #5f5651;&quot;&gt;저장 공간&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;인덱스도 디스크를 차지합니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;EXPLAIN&lt;/code&gt;은 조회 쪽만 보여주기 때문에, 직접 &lt;code&gt;INSERT&lt;/code&gt; 시간을 재봤습니다. 같은 테이블에 2만 행을 넣는 시간입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스 구성&lt;/th&gt;
&lt;th&gt;INSERT 20,000행&lt;/th&gt;
&lt;th&gt;인덱스 총 크기&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기본키와 UNIQUE만&lt;/td&gt;
&lt;td&gt;220 ms&lt;/td&gt;
&lt;td&gt;8.77 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;튜닝 인덱스 7개 추가&lt;/td&gt;
&lt;td&gt;&lt;b&gt;433 ms (1.97배)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;22.43 MB&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 일곱 개를 더 만들었더니 &lt;b&gt;쓰기가 두 배 가까이 느려졌습니다.&lt;/b&gt; 그리고 &lt;b&gt;테이블 본체가 약 7.5MB인데 인덱스 총량이 22.43MB&lt;/b&gt;로 본체보다 커졌습니다. 디스크 사용량의 대부분이 데이터가 아니라 인덱스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문이 초당 수백 건씩 들어오는 테이블과, 하루에 한 번 적재하고 종일 조회만 하는 테이블은 인덱스 전략이 반대입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;쓰기 중심 (OLTP)&lt;/th&gt;
&lt;th&gt;읽기 중심 (OLAP)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;예&lt;/td&gt;
&lt;td&gt;주문, 결제, 로그 적재&lt;/td&gt;
&lt;td&gt;대시보드, 리포트, 집계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인덱스 수&lt;/td&gt;
&lt;td&gt;꼭 필요한 것만&lt;/td&gt;
&lt;td&gt;조회 패턴마다 넉넉히&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기준&lt;/td&gt;
&lt;td&gt;쓰기 지연을 얼마나 감수할 수 있는가&lt;/td&gt;
&lt;td&gt;조회가 얼마나 자주 반복되는가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;조회에 한 번도 쓰이지 않는 인덱스는 쓰기만 느리게 만듭니다.&lt;/b&gt; 조회가 빨라지는 효과는 없는데 &lt;code&gt;INSERT&lt;/code&gt;마다 갱신되고 디스크도 차지합니다. 뒤에서 이런 인덱스를 찾아내는 방법을 정리하겠습니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;B-Tree의 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 인덱스는 &lt;b&gt;B-Tree(Balanced Tree)&lt;/b&gt;입니다. 모든 리프 노드가 같은 깊이에 있어서 어떤 값을 찾든 탐색 단계 수가 같고, 그래서 O(log N)이 보장됩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 22px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; justify-content: center; margin-bottom: 6px;&quot;&gt;
&lt;div style=&quot;padding: 9px 22px; border-radius: 7px; background: #e7eaf1; border: 1.5px solid #bac3d8; color: #52565f; font-weight: bold; font-size: 14px;&quot;&gt;Root&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: center; color: #6b7280; font-size: 16px; line-height: 1;&quot;&gt;▼&lt;/div&gt;
&lt;div style=&quot;display: flex; justify-content: center; gap: 12px; margin: 6px 0;&quot;&gt;
&lt;div style=&quot;padding: 9px 20px; border-radius: 7px; background: #f6f8fa; border: 1px solid #d1d5db; color: #464d57; font-size: 14px;&quot;&gt;Branch&lt;/div&gt;
&lt;div style=&quot;padding: 9px 20px; border-radius: 7px; background: #f6f8fa; border: 1px solid #d1d5db; color: #464d57; font-size: 14px;&quot;&gt;Branch&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: center; color: #6b7280; font-size: 16px; line-height: 1;&quot;&gt;▼&lt;/div&gt;
&lt;div style=&quot;display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 6px;&quot;&gt;
&lt;div style=&quot;padding: 9px 12px; border-radius: 7px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 14px;&quot;&gt;Leaf&lt;/div&gt;
&lt;div style=&quot;color: #6b7280; font-size: 14px;&quot;&gt;&amp;harr;&lt;/div&gt;
&lt;div style=&quot;padding: 9px 12px; border-radius: 7px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 14px;&quot;&gt;Leaf&lt;/div&gt;
&lt;div style=&quot;color: #6b7280; font-size: 14px;&quot;&gt;&amp;harr;&lt;/div&gt;
&lt;div style=&quot;padding: 9px 12px; border-radius: 7px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 14px;&quot;&gt;Leaf&lt;/div&gt;
&lt;div style=&quot;color: #6b7280; font-size: 14px;&quot;&gt;&amp;harr;&lt;/div&gt;
&lt;div style=&quot;padding: 9px 12px; border-radius: 7px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-size: 14px;&quot;&gt;Leaf&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 16px; padding-top: 14px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;&lt;b&gt;Root&lt;/b&gt;는 탐색의 시작점, &lt;b&gt;Branch&lt;/b&gt;는 어느 쪽으로 내려갈지 안내하는 중간 노드, &lt;b&gt;Leaf&lt;/b&gt;는 실제 키 값과 테이블 행의 물리 위치(&lt;code style=&quot;background: none; padding: 0;&quot;&gt;ctid&lt;/code&gt;)를 가지고 있습니다. 리프끼리 &lt;b&gt;이중 연결 리스트&lt;/b&gt;로 이어져 있어서 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;BETWEEN&lt;/code&gt; 같은 범위 조회가 빠릅니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 실무에 바로 걸리는 것이 &lt;b&gt;키 크기&lt;/b&gt;입니다. 키가 작을수록 노드 하나에 더 많은 항목이 들어가고, 그만큼 트리 깊이가 얕아져 탐색이 빨라집니다. 기본키로 UUID(16바이트)를 쓸지 BIGINT(8바이트)를 쓸지가 인덱스 크기에 그대로 반영됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컬럼 타입을 열어 두면 인덱스가 커집니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 이유로 &lt;b&gt;인덱스를 걸 컬럼의 길이를 정해 두는 편이 낫습니다.&lt;/b&gt; 아래처럼 식별자 컬럼을 &lt;code&gt;TEXT&lt;/code&gt;로 열어 두는 설계가 자주 보입니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE query_logs (
  id       SERIAL PRIMARY KEY,
  user_id  TEXT,          -- 길이 제한이 없습니다
  ...
);
CREATE INDEX idx_user ON query_logs (user_id);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL만 놓고 보면 &lt;code&gt;TEXT&lt;/code&gt;와 &lt;code&gt;VARCHAR(n)&lt;/code&gt;은 저장 방식이 같아서 타입만으로 공간을 더 쓰지는 않습니다. 문제는 &lt;b&gt;길이를 막아 두지 않으면 긴 값이 그대로 인덱스 키로 들어간다&lt;/b&gt;는 점입니다. 키가 길어지면 노드에 담기는 항목이 줄고 트리가 깊어집니다. 값 하나가 인덱스 행 크기 한계(약 2,704바이트)를 넘으면 그 행은 아예 인덱스에 들어가지 못하고 에러가 납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL은 여기서 차이가 더 큽니다. &lt;code&gt;TEXT&lt;/code&gt; 컬럼에는 인덱스를 통째로 걸 수 없어서 &lt;code&gt;INDEX(user_id(20))&lt;/code&gt;처럼 앞 몇 글자만 잡는 prefix 인덱스를 써야 하고, 그만큼 선택도가 떨어집니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 값의 최대 길이를 예측해 자리수를 정해 둡니다
user_id  VARCHAR(64)  NOT NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;실제로 들어올 값의 최대 길이를 먼저 확인하고, 여유를 조금 둔 자리수로 잡습니다. 사번이나 UUID 문자열처럼 형식이 정해진 값이라면 자리수도 같이 정해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Fill Factor&lt;/b&gt;는 인덱스 페이지를 몇 퍼센트까지 채울지 정하는 값으로 기본값이 90입니다. 10%를 비워 두는 이유는 나중에 값이 들어올 자리를 남겨 페이지 분할을 줄이기 위해서입니다. UPDATE가 잦은 테이블이라면 이 값을 낮춰 분할 횟수를 더 줄일 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_orders_status ON orders (status)
  WITH (fillfactor = 70);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;B-Tree 말고 다른 인덱스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree가 기본값이지만 데이터 형태에 따라 더 맞는 구조가 따로 있습니다. JSON 컬럼이나 좌표 데이터에 B-Tree를 걸면 인덱스를 만들어도 타지 않습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;구조&lt;/th&gt;
&lt;th&gt;맞는 용도&lt;/th&gt;
&lt;th&gt;지원&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;B-Tree&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;균형 트리&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;LIKE 'abc%'&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;모든 DBMS 기본&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Hash&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;해시 테이블&lt;/td&gt;
&lt;td&gt;등호 비교만. 범위 조회 불가&lt;/td&gt;
&lt;td&gt;PostgreSQL, MySQL(제한적)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;GIN&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;역색인&lt;/td&gt;
&lt;td&gt;JSONB, 배열, 전문검색&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;GiST&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;일반화 검색 트리&lt;/td&gt;
&lt;td&gt;지리정보(PostGIS), 범위 타입, 벡터 유사도&lt;/td&gt;
&lt;td&gt;PostgreSQL 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;BRIN&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;블록 범위의 최소&amp;middot;최대&lt;/td&gt;
&lt;td&gt;시간순으로 쌓이는 대용량 로그&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Bitmap&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;비트 배열&lt;/td&gt;
&lt;td&gt;고유값이 적은 컬럼 여러 개를 조합할 때&lt;/td&gt;
&lt;td&gt;Oracle (DW용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Clustered&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;데이터를 물리적으로 정렬&lt;/td&gt;
&lt;td&gt;범위 스캔, 기본키 기반 접근&lt;/td&gt;
&lt;td&gt;SQL Server, MySQL InnoDB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;BRIN은 인덱스 크기가 B-Tree의 수백분의 일 수준입니다. 대신 물리적으로 정렬돼 쌓인 데이터에서만 효과가 나기 때문에, 시간순으로 적재되는 로그 테이블이 대표적인 대상입니다.&lt;/p&gt;
&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;역인덱스는 무엇을 뒤집은 것일까?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;&lt;b&gt;대응 방향이 반대입니다.&lt;/b&gt; B-Tree는 &lt;b&gt;행 하나에 키 하나&lt;/b&gt;를 대응시킵니다. &lt;code style=&quot;background: none; padding: 0;&quot;&gt;tags = '{db, sql, index}'&lt;/code&gt; 라는 배열이 들어 있으면, B-Tree는 그 배열 전체를 키 하나로 봅니다. 그래서 배열이 정확히 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;{db, sql, index}&lt;/code&gt; 인 행은 찾을 수 있지만, &lt;b&gt;&quot;sql 이 들어 있는 행&quot;은 찾을 수 없습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;역인덱스(Inverted Index)는 &lt;b&gt;키 하나에 그 키를 포함한 행 목록&lt;/b&gt;을 대응시킵니다. 값을 쪼개서 조각마다 항목을 만들고, 각 항목에 행 위치를 모아 둡니다.&lt;br /&gt;&lt;br /&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;db&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr; [3번 행, 7번 행, 12번 행]&lt;/code&gt;&lt;br /&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;sql&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr; [3번 행, 21번 행]&lt;/code&gt;&lt;br /&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;index&amp;nbsp;&amp;rarr; [3번 행, 9번 행]&lt;/code&gt;&lt;br /&gt;&lt;br /&gt;이제 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;sql&lt;/code&gt; 로 검색하면 3번과 21번 행만 읽으면 됩니다. 조건이 두 개면 각 목록의 교집합이나 합집합을 구합니다. &lt;b&gt;&quot;역&quot;이라는 이름은 행에서 값을 찾는 방향을 값에서 행을 찾는 방향으로 뒤집었다는 뜻&lt;/b&gt;입니다.&lt;/div&gt;
&lt;/details&gt;&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;GIN 인덱스는 어떻게 동작할까?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;GIN은 &lt;b&gt;Generalized Inverted Index&lt;/b&gt;의 줄임말입니다. 앞의 역인덱스를 &lt;b&gt;특정 데이터 타입에 묶지 않고 일반화한 것&lt;/b&gt;이라 이름에 Generalized가 붙었습니다. 값을 어떻게 쪼갤지만 타입별로 정해주면 같은 구조를 그대로 씁니다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;쪼개는 방식이 타입마다 다릅니다.&lt;/b&gt;&lt;br /&gt;배열은 원소 하나하나가 항목이 되고, JSONB는 키와 값이 항목이 됩니다. 전문검색은 단어 단위로 쪼개고, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;pg_trgm&lt;/code&gt; 은 &lt;b&gt;세 글자씩&lt;/b&gt; 쪼갭니다. &lt;code style=&quot;background: none; padding: 0;&quot;&gt;'user123'&lt;/code&gt; 이라면 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;use&lt;/code&gt;, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;ser&lt;/code&gt;, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;er1&lt;/code&gt;, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;r12&lt;/code&gt;, &lt;code style=&quot;background: none; padding: 0;&quot;&gt;123&lt;/code&gt; 같은 조각이 항목이 됩니다. 그래서 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;LIKE '%ser%'&lt;/code&gt; 처럼 가운데를 검색해도 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;ser&lt;/code&gt; 항목의 행 목록을 꺼내 후보를 좁힐 수 있습니다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;내부는 두 겹입니다.&lt;/b&gt; 항목들은 B-Tree로 정렬해 관리하고, 각 항목에 붙은 행 목록은 개수가 적으면 배열로, 많아지면 별도의 B-Tree로 보관합니다. 같은 값이 수만 행에 나타나도 목록 탐색이 느려지지 않게 하려는 구조입니다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;대가는 쓰기 비용입니다.&lt;/b&gt; 값 하나를 넣을 때 항목이 여러 개 생기므로 인덱스 갱신량이 그만큼 많아집니다. &lt;code style=&quot;background: none; padding: 0;&quot;&gt;'user123'&lt;/code&gt; 한 건에 조각 여러 개가 들어갑니다. 그래서 GIN에는 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;fastupdate&lt;/code&gt; 옵션이 있어 새 항목을 대기 목록에 모아 두고 나중에 합칩니다. 다만 합쳐지기 전까지는 조회할 때 그 대기 목록도 훑어야 해서 &lt;b&gt;조회가 일시적으로 느려질 수 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;마지막으로 GIN은 &lt;b&gt;후보를 좁혀 주는 역할까지만&lt;/b&gt; 합니다. &lt;code style=&quot;background: none; padding: 0;&quot;&gt;ser&lt;/code&gt; 조각을 가진 행이라고 해서 실제로 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;'%ser%'&lt;/code&gt; 에 맞는다는 보장은 없기 때문에, 행을 읽은 뒤 조건을 한 번 더 확인합니다. 실행계획에 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;Recheck Cond&lt;/code&gt; 가 나오는 이유가 여기 있습니다.&lt;/div&gt;
&lt;/details&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 컬럼에 걸 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 걸 컬럼을 고르는 기준이 &lt;b&gt;선택도(Selectivity)&lt;/b&gt;입니다. &lt;b&gt;조건 하나로 전체의 몇 분의 몇까지 좁혀지는가&lt;/b&gt;를 재는 값입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;선택도 = 고유값 수 &amp;divide; 전체 행 수

-- 전체 5만 행일 때
gender  : 고유값 2개      &amp;rarr;      2 &amp;divide; 50,000 &amp;asymp; 0.00004   거의 0
email   : 고유값 50,000개 &amp;rarr; 50,000 &amp;divide; 50,000 = 1         1에 가까움&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;이 용어는 문헌마다 뒤집혀 쓰입니다. 위 공식으로는 &lt;b&gt;1에 가까울수록 좋은 인덱스&lt;/b&gt;입니다. 반대로 &quot;조건을 만족하는 행의 비율&quot;을 선택도라 부르는 글에서는 &lt;b&gt;0에 가까울수록 좋다&lt;/b&gt;고 씁니다. 부르는 이름보다 &lt;b&gt;조건 하나로 몇 행이 남는지&lt;/b&gt;를 보면 헷갈리지 않습니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 12px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 240px; border: 1px solid #d9c4b8; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 14px;&quot;&gt;고유값 2개 &amp;middot; 인덱스 효과 낮음&lt;/div&gt;
&lt;div style=&quot;padding: 14px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 15px; color: #24292f; margin-bottom: 6px;&quot;&gt;gender&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;값이 M과 F 두 개뿐이라 조건 하나로 절반밖에 걸러내지 못합니다. 절반을 읽을 바에는 전체를 순차로 읽는 편이 빠를 수 있어서, 옵티마이저가 인덱스를 건너뛰기도 합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 240px; border: 1.5px solid #bbd9c1; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 10px 14px; font-weight: bold; font-size: 14px;&quot;&gt;고유값이 행 수만큼 &amp;middot; 인덱스 효과 큼&lt;/div&gt;
&lt;div style=&quot;padding: 14px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 15px; color: #24292f; margin-bottom: 6px;&quot;&gt;email&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;값이 거의 전부 다르기 때문에 조건 하나로 행 하나까지 좁혀집니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;조건이 전체의 20%를 넘게 남기면 인덱스를 타도 이득이 거의 없습니다.&lt;/b&gt; 이메일 도메인이 gmail인 직원을 찾는 쿼리로 재봤더니, 선택도가 25%인 구간에서 &lt;b&gt;Bitmap Index Scan(2.73ms)과 Seq Scan(2.83ms)이 거의 같았습니다.&lt;/b&gt; 읽어야 할 행이 그만큼 많으면 인덱스를 거쳐 흩어진 위치를 찾아가는 비용이 순차 읽기와 비슷해집니다.&lt;/div&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;code&gt;WHERE&lt;/code&gt;에 자주 등장하는 컬럼&lt;/b&gt;을 먼저 봅니다. 쿼리 로그를 뒤져 실제로 어떤 조건이 반복되는지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외래키 컬럼에는 반드시 인덱스를 겁니다.&lt;/b&gt; JOIN 성능뿐 아니라 부모 행을 지우거나 고칠 때 하는 참조 검사에도 쓰입니다. 없으면 자식 테이블을 전부 훑습니다.&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;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;복합 인덱스의 컬럼 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컬럼 두 개 이상을 묶은 인덱스를 &lt;b&gt;복합 인덱스&lt;/b&gt;라고 합니다. 여기서는 &lt;b&gt;어떤 컬럼을 앞에 두느냐에 따라 성능이 갈립니다.&lt;/b&gt; 규칙은 하나입니다. &lt;b&gt;등호 조건을 앞에, 범위 조건을 뒤에&lt;/b&gt; 둡니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT * FROM orders
 WHERE status = 'A'
   AND created_at &amp;gt; '2024-01-01';&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 12px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1.5px solid #bbd9c1; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 10px 14px; font-weight: bold; font-size: 14px;&quot;&gt;INDEX (status, created_at)&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;&lt;code style=&quot;background: none; padding: 0;&quot;&gt;status = 'A'&lt;/code&gt; 로 범위를 먼저 좁힌 뒤, 그 안에서 날짜 범위를 훑습니다. 읽는 양이 두 단계에 걸쳐 줄어듭니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1px solid #d9c4b8; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 14px;&quot;&gt;INDEX (created_at, status)&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;날짜 범위로 넓게 잡아 놓고 그 안에서 &lt;code style=&quot;background: none; padding: 0;&quot;&gt;status&lt;/code&gt; 를 하나씩 걸러냅니다. 먼저 읽어야 하는 양이 훨씬 많습니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;범위 조건을 앞에 두면 그 뒤 컬럼은 정렬 순서를 잃어서 인덱스로 좁히지 못하고 필터로만 쓰입니다. 등호를 앞에 두는 이유가 여기 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커버링 인덱스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 타고 행을 찾아도 작업이 한 번 더 남습니다. 인덱스에는 키 값과 행의 위치만 있기 때문에, 나머지 컬럼을 읽으려면 테이블(Heap)에 접근해야 합니다. 이 추가 접근을 &lt;b&gt;Heap Fetch&lt;/b&gt;라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;커버링 인덱스는 쿼리가 필요로 하는 모든 컬럼을 인덱스 안에 넣어 테이블 접근을 없앤 인덱스입니다.&lt;/b&gt; PostgreSQL과 SQL Server에서는 &lt;code&gt;INCLUDE&lt;/code&gt; 절로 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 일반 인덱스 : 인덱스를 탄 뒤 테이블을 한 번 더 읽습니다
CREATE INDEX idx_email ON users (email);
SELECT id, name FROM users WHERE email = 'a@b.com';
-- Index Scan using idx_email  +  Heap Fetch

-- 커버링 인덱스 : 반환할 컬럼까지 인덱스에 담습니다
CREATE INDEX idx_email_cover ON users (email) INCLUDE (id, name);
SELECT id, name FROM users WHERE email = 'a@b.com';
-- Index Only Scan using idx_email_cover&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;INCLUDE&lt;/code&gt;에 넣은 컬럼은 탐색에는 쓰이지 않고 반환에만 쓰입니다. 그래서 트리 깊이를 늘리지 않으면서 테이블 접근만 없앨 수 있습니다. 적용됐는지 확인하려면 실행계획에서 &lt;code&gt;Heap Fetches&lt;/code&gt; 값을 봅니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name FROM users WHERE email = 'a@b.com';

Index Only Scan using idx_email_cover on users
  Heap Fetches: 0        &amp;larr; 테이블 접근 0번&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;Heap Fetches&lt;/code&gt;가 0이 아니면 Index Only Scan이 반쪽만 동작한 것입니다.&lt;/b&gt; PostgreSQL은 해당 행이 모든 트랜잭션에 보여도 되는지를 Visibility Map으로 판단하는데, 이 정보가 낡아 있으면 결국 테이블을 확인합니다. &lt;code&gt;VACUUM&lt;/code&gt;이 오래 실행되지 않았는지부터 확인하면 됩니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;안 쓰는 인덱스 찾아내기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스는 만들 때보다 지울 때 판단이 어렵습니다. PostgreSQL은 인덱스가 몇 번 쓰였는지를 &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;에 기록하기 때문에, 감이 아니라 횟수로 판단할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 사용 횟수가 적은 인덱스부터 (제거 후보)
SELECT indexrelname AS index_name,
       relname      AS table_name,
       idx_scan     AS scan_count,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
  FROM pg_stat_user_indexes
 ORDER BY idx_scan ASC
 LIMIT 20;

-- 한 번도 안 쓰인 인덱스 (기본키 제외)
SELECT indexrelname, relname,
       pg_size_pretty(pg_relation_size(indexrelid))
  FROM pg_stat_user_indexes
 WHERE idx_scan = 0
   AND indexrelname NOT LIKE 'pk_%'
 ORDER BY pg_relation_size(indexrelid) DESC;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;idx_scan&lt;/code&gt;이 0이면 통계를 모으기 시작한 뒤로 한 번도 쓰이지 않았다는 뜻입니다. 크기가 큰 것부터 제거를 검토하면 쓰기 비용과 디스크를 함께 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 오래 쓰면 삭제와 갱신이 쌓여 실제 데이터보다 부풀어 오릅니다. 이때는 재구성을 하는데, &lt;code&gt;CONCURRENTLY&lt;/code&gt;를 붙이면 &lt;b&gt;재구성 중에도 읽기와 쓰기가 계속 됩니다.&lt;/b&gt; 다만 완전히 잠금이 없는 것은 아니고, 마지막에 인덱스를 교체하는 짧은 순간에는 배타적 잠금이 필요합니다. PostgreSQL 12부터 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;REINDEX INDEX CONCURRENTLY idx_orders_cust_date;
REINDEX TABLE CONCURRENTLY orders;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL에서는 &lt;code&gt;information_schema.STATISTICS&lt;/code&gt;의 &lt;code&gt;CARDINALITY&lt;/code&gt;로 각 인덱스의 고유값 추정치를 확인합니다. 값이 낮으면 선택도가 낮다는 뜻입니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;SELECT TABLE_NAME, INDEX_NAME, CARDINALITY
  FROM information_schema.STATISTICS
 WHERE TABLE_SCHEMA = 'mydb'
 ORDER BY CARDINALITY ASC;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 걸기 전에 확인할 것을 순서대로 정리해보겠습니다.&lt;/p&gt;
&lt;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; 쓰기가 많으면 인덱스 하나가 늘어날 때마다 INSERT가 느려집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복합 인덱스라면 등호 조건이 앞에 있는가.&lt;/b&gt; 순서만 바꿔도 읽는 양이 달라집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외래키에 인덱스가 있는가.&lt;/b&gt; 없으면 JOIN과 참조 검사에서 전체 스캔이 일어납니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지금 있는 인덱스 중 안 쓰이는 것은 없는가.&lt;/b&gt; &lt;code&gt;idx_scan = 0&lt;/code&gt;이면 조회에는 안 쓰이면서 쓰기만 느리게 만드는 상태입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 인덱스를 걸면 &lt;b&gt;조회가 빨라지고 쓰기가 느려집니다.&lt;/b&gt; 몇 개까지 걸어도 되는지는 그 테이블에 &lt;code&gt;INSERT&lt;/code&gt;가 얼마나 들어오는지로 정해집니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/indexes.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-index-only-scans.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/monitoring-stats.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;The Statistics Collector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MySQL: &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/optimization-indexes.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Optimization and Indexes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/데이터&amp;middot;통계</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/11</guid>
      <comments>https://boradora1.tistory.com/11#entry11comment</comments>
      <pubDate>Thu, 30 Jul 2026 10:32:13 +0900</pubDate>
    </item>
    <item>
      <title>데이터 모델링 핵심 정리 | 정규화, 외래키, ERD, PostgreSQL 설계</title>
      <link>https://boradora1.tistory.com/10</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;데이터모델링핵심정리.png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RfjCQ/dJMcag7ral3/bkHywMGeUKkTv5Rlk92ok1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RfjCQ/dJMcag7ral3/bkHywMGeUKkTv5Rlk92ok1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RfjCQ/dJMcag7ral3/bkHywMGeUKkTv5Rlk92ok1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRfjCQ%2FdJMcag7ral3%2FbkHywMGeUKkTv5Rlk92ok1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-filename=&quot;데이터모델링핵심정리.png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;데이터 모델링&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;외래키&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;정규화&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;교차 테이블&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;ERD&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;PostgreSQL&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;SQL 문법은 비교적 금방 익힐 수 있습니다. 하지만 테이블을 &lt;b&gt;어디까지 나눌지, 부모를 삭제했을 때 자식 데이터를 어떻게 처리할지, 컬럼 타입을 무엇으로 선택할지&lt;/b&gt; 같은 설계는 훨씬 오래 고민하게 됩니다. 데이터가 쌓인 뒤에는 바꾸기 어렵기 때문이죠.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;고객을 삭제하면 그 고객의 주문은 어떻게 되어야 할까요?&lt;/b&gt;&lt;br /&gt;같이 지울지, 삭제를 막을지, 주문은 남기고 고객만 비울지를 설계할 때 정해 둡니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CREATE TABLE&lt;/code&gt; 문법과 &lt;code&gt;SELECT&lt;/code&gt; 기본 형태는 여기서 다루지 않습니다. 대신 &lt;b&gt;설계 단계에서 실제로 결정해야 하는 것들&lt;/b&gt;을 순서대로 정리해보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테이블을 나누는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고객 이름과 주문 내역을 한 테이블에 같이 담으면 세 가지 문제가 생깁니다. &lt;b&gt;이상 현상(Anomaly)&lt;/b&gt;이라고 부르고, 정규화는 이 셋을 막기 위한 규칙입니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #d9c4b8; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;삽입 이상&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;아직 주문한 적 없는 고객을 등록하려는데 주문 정보 칸을 비워 둘 수 없어 넣지 못합니다. &lt;b&gt;넣고 싶은 것만 넣을 수 없는 상태&lt;/b&gt;입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #d9c4b8; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;삭제 이상&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;마지막 주문 한 건을 취소했더니 그 고객의 이름과 연락처까지 함께 사라집니다. &lt;b&gt;지우고 싶지 않은 것까지 지워지는 상태&lt;/b&gt;입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1.5px solid #d7b9c5; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f1e6eb; color: #5f5157; padding: 10px 14px; font-weight: bold; font-size: 15px;&quot;&gt;갱신 이상&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;고객이 이메일을 바꿨는데 주문 50건 중 30건만 고쳐졌습니다. &lt;b&gt;어느 값이 맞는지 알 수 없는 상태&lt;/b&gt;가 되어 가장 위험합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 이상 현상의 원인은 같습니다. &lt;b&gt;성격이 다른 정보가 한 테이블에 섞여 있기 때문&lt;/b&gt;입니다. 고객은 &lt;code&gt;customers&lt;/code&gt;에, 주문은 &lt;code&gt;orders&lt;/code&gt;에 나눠 담고 주문 쪽에서 고객을 가리키게 하면 셋 다 사라집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;외래키를 걸 때 정하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외래키를 선언하면 데이터베이스가 &lt;b&gt;참조 무결성&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;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;동작&lt;/th&gt;
&lt;th&gt;쓰는 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON DELETE CASCADE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;부모를 지우면 자식도 함께 삭제&lt;/td&gt;
&lt;td&gt;주문을 지우면 주문 항목도 같이 사라져야 할 때. &lt;b&gt;주문 없이 존재할 이유가 없는 데이터&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON DELETE RESTRICT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;자식이 있으면 부모 삭제를 차단&lt;/td&gt;
&lt;td&gt;카테고리를 지우기 전에 그 안의 상품을 먼저 정리하게 만들 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON DELETE SET NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;부모를 지우면 자식의 외래키를 &lt;code&gt;NULL&lt;/code&gt;로&lt;/td&gt;
&lt;td&gt;담당 직원이 퇴사해도 고객 정보는 남기고 담당자만 비울 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON DELETE NO ACTION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RESTRICT와 거의 같고 검사 시점이 다름&lt;/td&gt;
&lt;td&gt;Oracle과 SQL Server의 기본값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON UPDATE CASCADE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;부모 키가 바뀌면 자식도 따라 변경&lt;/td&gt;
&lt;td&gt;학번 같은 자연키가 바뀔 때. &lt;b&gt;애초에 대체키를 쓰는 편이 낫습니다&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE order_items (
  order_id   BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  quantity   INT    NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL,
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (order_id)   REFERENCES orders(order_id)     ON DELETE CASCADE,
  FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE RESTRICT
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 테이블 안에서도 옵션이 갈립니다. 주문이 사라지면 그 주문 항목은 남을 이유가 없으니 &lt;code&gt;CASCADE&lt;/code&gt;이고, 상품은 주문 기록이 남아 있는 한 함부로 지우면 안 되니 &lt;code&gt;RESTRICT&lt;/code&gt;입니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;CASCADE&lt;/code&gt;는 연쇄됩니다.&lt;/b&gt; 부모의 부모까지 이어져 있으면 한 번의 &lt;code&gt;DELETE&lt;/code&gt;로 예상보다 많은 행이 사라집니다. 삭제 대신 &lt;code&gt;deleted_at&lt;/code&gt; 컬럼을 두고 지운 것으로 표시만 하는 방식(소프트 삭제)을 쓰는 경우가 많은 이유가 여기 있습니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관계와 교차 테이블&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관계는 &lt;b&gt;한쪽 행이 반대쪽 행 몇 개와 연결되는지&lt;/b&gt;로 세 가지로 나뉩니다. 이 중 실제로 설계 판단이 필요한 것은 N:M입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;N:M 관계는 외래키만으로 표현할 수 없어서 가운데 테이블이 반드시 들어갑니다.&lt;/b&gt; 주문 하나에 상품이 여러 개 담기고 같은 상품이 여러 주문에 들어가는 구조가 그렇습니다. &lt;code&gt;order_items&lt;/code&gt; 같은 &lt;b&gt;교차 테이블&lt;/b&gt;을 두면 다대다가 &lt;b&gt;1:N 두 개&lt;/b&gt;로 풀립니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 520px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; flex-wrap: wrap;&quot;&gt;
&lt;div style=&quot;flex: 1 1 130px; padding: 14px 10px; text-align: center; border-radius: 9px; background: #e7eaf1; border: 1.5px solid #bac3d8; color: #52565f; font-weight: bold; font-size: 15px;&quot;&gt;orders
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;PK order_id&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 44px; text-align: center; color: #6b7280; font-size: 14px;&quot;&gt;1 &amp;rarr; N&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 150px; padding: 14px 10px; text-align: center; border-radius: 9px; background: #f6f8fa; border: 1px dashed #9aa2ae; color: #24292f; font-weight: bold; font-size: 15px;&quot;&gt;order_items
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;PK (order_id, product_id)&lt;br /&gt;quantity &amp;middot; unit_price&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 44px; text-align: center; color: #6b7280; font-size: 14px;&quot;&gt;N &amp;larr; 1&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 130px; padding: 14px 10px; text-align: center; border-radius: 9px; background: #e7eaf1; border: 1.5px solid #bac3d8; color: #52565f; font-weight: bold; font-size: 15px;&quot;&gt;products
&lt;div style=&quot;font-size: 13px; font-weight: 400; color: #464d57; margin-top: 3px;&quot;&gt;PK product_id&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.75;&quot;&gt;가운데 교차 테이블에는 &lt;b&gt;그 연결에만 붙는 정보&lt;/b&gt;를 함께 담습니다. 수량과 &lt;b&gt;구매 시점 단가&lt;/b&gt;가 여기 들어가는데, 상품 가격은 나중에 바뀌어도 지난 주문의 결제 금액은 바뀌면 안 되기 때문입니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;교차 테이블의 기본키는 두 외래키를 묶은 &lt;b&gt;복합키&lt;/b&gt;로 두거나, 별도의 일련번호를 두는 방식 중에 고릅니다. 복합키로 두면 같은 조합이 두 번 들어가는 것을 데이터베이스가 막아 줍니다.&lt;/p&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;조회 방향이 양쪽 다 있다면 인덱스도 양방향으로 만들어 둡니다. 수강 테이블이라면 &lt;code&gt;INDEX(student_id, course_id)&lt;/code&gt;와 &lt;code&gt;INDEX(course_id, student_id)&lt;/code&gt;를 함께 두는 식입니다. 학생으로도 찾고 과목으로도 찾기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;약한 엔티티&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;약한 엔티티는 자기만의 기본키가 없어서 부모 없이는 구분되지 않는 테이블입니다.&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;복합 기본키&lt;/b&gt;를 만듭니다.&lt;/li&gt;
&lt;li&gt;부모가 사라지면 존재할 이유가 없으므로 &lt;b&gt;&lt;code&gt;ON DELETE CASCADE&lt;/code&gt;를 겁니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정규화를 어디까지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정규화 단계는 여러 단계로 나뉘지만, 내용은 전부 &lt;b&gt;한 테이블에 성격이 다른 정보를 섞지 말라&lt;/b&gt;는 말입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;정규형&lt;/th&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;없애는 것&lt;/th&gt;
&lt;th&gt;예&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1NF&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;한 칸에 값 하나만&lt;/td&gt;
&lt;td&gt;반복 그룹, 다중값 속성&lt;/td&gt;
&lt;td&gt;&lt;code&gt;과목 = 'DB, 네트워크'&lt;/code&gt; 를 두 행으로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;2NF&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1NF + 부분 함수 종속 제거&lt;/td&gt;
&lt;td&gt;복합키의 일부에만 딸린 속성&lt;/td&gt;
&lt;td&gt;(학번, 과목코드)가 키인데 학생 이름은 학번에만 딸림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;3NF&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2NF + 이행적 종속 제거&lt;/td&gt;
&lt;td&gt;키가 아닌 속성끼리의 종속&lt;/td&gt;
&lt;td&gt;학번 &amp;rarr; 학과코드 &amp;rarr; 학과명 사슬을 끊음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;BCNF&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;3NF + 모든 결정자가 후보키&lt;/td&gt;
&lt;td&gt;후보키가 아닌 결정자&lt;/td&gt;
&lt;td&gt;3NF를 만족해도 남는 예외를 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 보통 &lt;b&gt;3NF 또는 BCNF까지&lt;/b&gt; 적용합니다. 더 나눌수록 중복은 줄지만 조회할 때 이어 붙일 테이블이 늘어나 JOIN 비용이 커지기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 조회가 아주 잦은 곳에서는 일부러 중복을 남기는 &lt;b&gt;반정규화&lt;/b&gt;를 검토합니다. 다만 순서가 있습니다. &lt;b&gt;정규화가 기본이고, 반정규화는 측정한 근거를 갖고 되돌리는 선택&lt;/b&gt;입니다. 처음부터 반정규화된 구조로 시작하면 갱신 이상을 애플리케이션이 전부 떠안게 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개념 &amp;middot; 논리 &amp;middot; 물리 세 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델링은 한 번에 끝나지 않고 &lt;b&gt;추상적인 것에서 구체적인 것으로&lt;/b&gt; 세 단계를 거칩니다. 각 단계에서 정하는 것이 다르고, 앞 단계를 건너뛰면 뒤에서 되돌아오게 됩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; display: flex; align-items: stretch; gap: 8px;&quot;&gt;
&lt;div style=&quot;flex: 1; border: 1px solid #e5e7eb; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 10px 12px; font-weight: bold; font-size: 15px;&quot;&gt;① 개념적&lt;/div&gt;
&lt;div style=&quot;padding: 13px; font-size: 14px; color: #464d57; line-height: 1.8;&quot;&gt;요구사항에서 &lt;b&gt;명사를 뽑아&lt;/b&gt; 엔티티를 찾습니다.&lt;br /&gt;관계와 카디널리티를 정하고 ERD를 그립니다.&lt;br /&gt;DBMS와 무관합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 18px; align-self: center; text-align: center; color: #6b7280;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1; border: 1px solid #e5e7eb; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 10px 12px; font-weight: bold; font-size: 15px;&quot;&gt;② 논리적&lt;/div&gt;
&lt;div style=&quot;padding: 13px; font-size: 14px; color: #464d57; line-height: 1.8;&quot;&gt;엔티티를 테이블로 옮깁니다.&lt;br /&gt;PK와 FK를 정하고 &lt;b&gt;정규화를 적용&lt;/b&gt;합니다.&lt;br /&gt;아직 DBMS와 무관합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 18px; align-self: center; text-align: center; color: #6b7280;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1; border: 1.5px solid #bbd9c1; border-radius: 11px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 10px 12px; font-weight: bold; font-size: 15px;&quot;&gt;③ 물리적&lt;/div&gt;
&lt;div style=&quot;padding: 13px; font-size: 14px; color: #464d57; line-height: 1.8;&quot;&gt;쓰는 DBMS에 맞춥니다.&lt;br /&gt;&lt;b&gt;데이터 타입과 인덱스&lt;/b&gt;를 정합니다.&lt;br /&gt;여기서 제품별 차이가 나옵니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입을 정할 때 걸리는 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물리 설계에서 자주 실수하는 세 가지가 있습니다. 전부 나중에 바꾸기 어려운 항목입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;금액은 &lt;code&gt;NUMERIC(10,2)&lt;/code&gt;&lt;/b&gt;로 둡니다. &lt;code&gt;FLOAT&lt;/code&gt;이나 &lt;code&gt;DOUBLE&lt;/code&gt;은 이진 부동소수점이라 &lt;code&gt;0.1 + 0.2&lt;/code&gt;가 정확히 &lt;code&gt;0.3&lt;/code&gt;이 되지 않습니다. 합계가 1원씩 어긋나는 정산 문제가 여기서 나옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;시각은 &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;&lt;/b&gt;로 둡니다. 타임존 없이 저장하면 서버 지역이 바뀌거나 사용자가 해외에 있을 때 시각이 어긋납니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문자열은 최대 길이를 정해 둡니다.&lt;/b&gt; PostgreSQL에서 &lt;code&gt;TEXT&lt;/code&gt;와 &lt;code&gt;VARCHAR(n)&lt;/code&gt;은 저장 방식이 같지만, 인덱스를 걸 컬럼이라면 길이를 막아 두는 편이 낫습니다. 긴 값이 그대로 인덱스 키가 되면 트리가 깊어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계할 때 명시적으로 정하고 넘어가야 하는 것들입니다. 기본값에 맡기면 나중에 문제가 됩니다.&lt;/p&gt;
&lt;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;code&gt;CASCADE&lt;/code&gt;), 막을지(&lt;code&gt;RESTRICT&lt;/code&gt;), 비울지(&lt;code&gt;SET NULL&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;N:M은 교차 테이블로 풀고, 그 연결에만 붙는 값을 함께 담습니다.&lt;/b&gt; 구매 시점 단가가 대표적입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정규화는 3NF까지 가고, 반정규화는 측정한 뒤에 합니다.&lt;/b&gt; 순서를 바꾸면 갱신 이상을 코드가 떠안습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;금액은 &lt;code&gt;NUMERIC&lt;/code&gt;, 시각은 &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;.&lt;/b&gt; 데이터가 쌓인 뒤에는 바꾸기 어렵습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;카디널리티는 업무 규칙으로 정합니다.&lt;/b&gt; 지금 하나인 것과 앞으로도 하나여야 하는 것은 다릅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 모델링에서 어려운 것은 문법이 아니라 &lt;b&gt;되돌리기 어려운 결정들&lt;/b&gt;입니다. 삭제 옵션, 정규화 수준, 컬럼 타입, 카디널리티 네 가지는 설계할 때 근거를 갖고 정해 둡니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/ddl-constraints.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Constraints (외래키 옵션)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/datatype-numeric.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Numeric Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL: &lt;a href=&quot;https://www.postgresql.org/docs/current/datatype-datetime.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Date/Time Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ERD 작성 도구: &lt;a href=&quot;https://dbdiagram.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;dbdiagram.io&lt;/a&gt; (DBML 문법으로 텍스트를 쓰면 ERD가 그려집니다)&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/10</guid>
      <comments>https://boradora1.tistory.com/10#entry10comment</comments>
      <pubDate>Thu, 30 Jul 2026 08:48:36 +0900</pubDate>
    </item>
    <item>
      <title>ERD와 SQL 기초: 데이터 모델링부터 JOIN까지</title>
      <link>https://boradora1.tistory.com/9</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dXQxDD/dJMb99NXnil/Nhe3vCsOTodayVBYnWoOck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dXQxDD/dJMb99NXnil/Nhe3vCsOTodayVBYnWoOck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dXQxDD/dJMb99NXnil/Nhe3vCsOTodayVBYnWoOck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdXQxDD%2FdJMb99NXnil%2FNhe3vCsOTodayVBYnWoOck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;ERD&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;SQL&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;DBeaver&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;데이터 모델을 설계한 뒤에는 테이블 관계를 ERD로 문서화하고, SQL로 필요한 데이터를 조회합니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;고객 이름과 주문 상품처럼 여러 테이블에 나뉜 데이터를 함께 조회하려면 어떻게 할까요?&lt;/b&gt;&lt;br /&gt;JOIN은 공통 키를 기준으로 여러 테이블의 데이터를 결합합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본키와 외래키로 테이블을 분리하면 데이터 중복과 불일치를 줄일 수 있습니다. 조회 시에는 필요한 데이터를 결합해야 합니다. 예를 들어 고객 이름은 &lt;code&gt;customers&lt;/code&gt;에, 주문 상품은 &lt;code&gt;orders&lt;/code&gt;에 있으므로 두 테이블을 연결하는 조건이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 설계를 표현하는 ERD와 데이터를 조회하는 SQL의 기본 문법을 정리합니다. 문법 자체보다 &lt;b&gt;쿼리가 처리되는 순서&lt;/b&gt;를 이해하는 것이 중요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ERD&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ERD(Entity-Relationship Diagram)는 테이블과 테이블 사이의 관계를 시각적으로 표현한 설계 문서입니다.&lt;/b&gt; 테이블을 생성하기 전에 데이터 구조를 검토할 수 있으며, 구현 전 변경 비용을 줄이는 데 도움이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ERD는 &lt;b&gt;개발&amp;middot;기획 등 여러 역할이 동일한 데이터 구조를 공유하는 기준&lt;/b&gt;이기도 합니다. 개발자는 테이블 구조로, 기획자는 업무 흐름으로 해석할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;표기 요소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ERD는 개체, 속성, 관계, 카디널리티로 구성됩니다. 각 요소는 데이터 대상, 세부 정보, 연결 방식, 관계의 수를 나타냅니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1.5px solid #474a52; border-radius: 11px; padding: 15px; background: #e7eaf1;&quot;&gt;
&lt;div style=&quot;font-size: 16px; font-weight: bold; color: #52565f;&quot;&gt;개체 (Entity)&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin: 6px 0 8px; line-height: 1.6;&quot;&gt;테이블이 될 대상&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #52565f; font-weight: bold;&quot;&gt;고객, 주문, 상품&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #e5e7eb; border-radius: 11px; padding: 15px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 16px; font-weight: bold;&quot;&gt;속성 (Attribute)&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin: 6px 0 8px; line-height: 1.6;&quot;&gt;개체가 갖는 정보. 테이블의 열이 됩니다&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold;&quot;&gt;고객의 이름, 이메일&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #e5e7eb; border-radius: 11px; padding: 15px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 16px; font-weight: bold;&quot;&gt;관계선 (Relationship)&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin: 6px 0 8px; line-height: 1.6;&quot;&gt;개체 사이를 잇는 선&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold;&quot;&gt;고객이 주문을 생성&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #e5e7eb; border-radius: 11px; padding: 15px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 16px; font-weight: bold;&quot;&gt;카디널리티&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin: 6px 0 8px; line-height: 1.6;&quot;&gt;관계의 개수 표기&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold; font-family: Consolas,Menlo,monospace;&quot;&gt;1, N&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개체는 테이블, 속성은 열로 구현됩니다. ERD를 기준으로 &lt;code&gt;CREATE TABLE&lt;/code&gt; 문을 작성할 수 있어 설계와 구현의 대응 관계를 확인하기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제로 그려보면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고객&amp;middot;주문 구조에 상품 테이블을 추가한 예시입니다. 주문 하나에는 여러 상품이 포함될 수 있고, 하나의 상품은 여러 주문에 포함될 수 있으므로 연결 테이블 &lt;code&gt;order_items&lt;/code&gt;를 둡니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 840px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 22px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: flex-start; gap: 0;&quot;&gt;
&lt;div style=&quot;flex: 0 0 172px; border: 1.5px solid #474a52; border-radius: 9px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 8px 11px; font-weight: bold; font-size: 15px; font-family: Consolas,Menlo,monospace;&quot;&gt;customers&lt;/div&gt;
&lt;div style=&quot;padding: 9px 11px; font-size: 14px; line-height: 1.9;&quot;&gt;
&lt;div&gt;&lt;b&gt;PK&lt;/b&gt; customer_id&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;name&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;email&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;joined_at&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 76px; padding-top: 38px; text-align: center;&quot;&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold; font-family: Consolas,Menlo,monospace; color: #24292f;&quot;&gt;1 &amp;nbsp;:&amp;nbsp; N&lt;/div&gt;
&lt;div style=&quot;height: 0; border-top: 1.5px solid #374151; margin: 5px 4px 0;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px; line-height: 1.4;&quot;&gt;주문한다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 172px; border: 1.5px solid #474a52; border-radius: 9px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 8px 11px; font-weight: bold; font-size: 15px; font-family: Consolas,Menlo,monospace;&quot;&gt;orders&lt;/div&gt;
&lt;div style=&quot;padding: 9px 11px; font-size: 14px; line-height: 1.9;&quot;&gt;
&lt;div&gt;&lt;b&gt;PK&lt;/b&gt; order_id&lt;/div&gt;
&lt;div&gt;&lt;b&gt;FK&lt;/b&gt; customer_id&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;ordered_at&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 76px; padding-top: 38px; text-align: center;&quot;&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold; font-family: Consolas,Menlo,monospace; color: #24292f;&quot;&gt;1 &amp;nbsp;:&amp;nbsp; N&lt;/div&gt;
&lt;div style=&quot;height: 0; border-top: 1.5px solid #374151; margin: 5px 4px 0;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px; line-height: 1.4;&quot;&gt;담는다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 176px; border: 1.5px dashed #6b7280; border-radius: 9px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f6f8fa; color: #24292f; padding: 8px 11px; font-weight: bold; font-size: 15px; font-family: Consolas,Menlo,monospace;&quot;&gt;order_items&lt;/div&gt;
&lt;div style=&quot;padding: 9px 11px; font-size: 14px; line-height: 1.9;&quot;&gt;
&lt;div&gt;&lt;b&gt;PK&lt;/b&gt; item_id&lt;/div&gt;
&lt;div&gt;&lt;b&gt;FK&lt;/b&gt; order_id&lt;/div&gt;
&lt;div&gt;&lt;b&gt;FK&lt;/b&gt; product_id&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;quantity&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 76px; padding-top: 38px; text-align: center;&quot;&gt;
&lt;div style=&quot;font-size: 14px; font-weight: bold; font-family: Consolas,Menlo,monospace; color: #24292f;&quot;&gt;N &amp;nbsp;:&amp;nbsp; 1&lt;/div&gt;
&lt;div style=&quot;height: 0; border-top: 1.5px solid #374151; margin: 5px 4px 0;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px; line-height: 1.4;&quot;&gt;가리킨다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 172px; border: 1.5px solid #474a52; border-radius: 9px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 8px 11px; font-weight: bold; font-size: 15px; font-family: Consolas,Menlo,monospace;&quot;&gt;products&lt;/div&gt;
&lt;div style=&quot;padding: 9px 11px; font-size: 14px; line-height: 1.9;&quot;&gt;
&lt;div&gt;&lt;b&gt;PK&lt;/b&gt; product_id&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;name&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;price&lt;/div&gt;
&lt;div style=&quot;color: #464d57;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 16px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;점선으로 그린 &lt;code style=&quot;font-size: 14px;&quot;&gt;order_items&lt;/code&gt; 가 주문과 상품의 다대다를 &lt;b&gt;1:N 두 개&lt;/b&gt;로 풀어주는 연결 테이블입니다. &lt;b&gt;FK&lt;/b&gt; 가 붙은 열이 옆 테이블의 &lt;b&gt;PK&lt;/b&gt; 를 가리킵니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;ERD 도구에서는 관계선 끝이 갈라진 형태로 표시되기도 합니다. 이를 &lt;b&gt;까마귀발 표기법(Crow's Foot)&lt;/b&gt;이라고 하며, 갈라진 쪽은 여러 개(N), 선 하나로 끝나는 쪽은 하나(1)를 의미합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;읽는 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ERD를 검토할 때는 다음 항목을 순서대로 확인합니다. 다른 사람이 작성한 ERD를 이해하거나 설계를 점검할 때 동일하게 적용할 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&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;/li&gt;
&lt;li&gt;&lt;b&gt;카디널리티가 1:1, 1:N, N:M 중 무엇인가.&lt;/b&gt; 실제 업무 상황과 맞는 관계인지 검토합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카디널리티는 요구사항 변경에 영향을 크게 받습니다. 예를 들어 고객과 주소를 1:1로 모델링하면 여러 배송지를 저장해야 하는 요구가 생겼을 때 구조를 변경해야 합니다. 관계는 현재 데이터가 아니라 &lt;b&gt;업무 규칙&lt;/b&gt;을 기준으로 정합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SQL&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SQL(Structured Query Language)은 관계형 데이터베이스의 구조와 데이터를 정의&amp;middot;조회&amp;middot;조작하는 표준 언어입니다.&lt;/b&gt; PostgreSQL, MySQL, Oracle 등 DBMS마다 세부 문법은 다르지만 &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt; 같은 핵심 구문은 공통으로 사용됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네 가지 분류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 문장은 대상에 따라 네 가지로 분류합니다. 데이터베이스 구조, 데이터, 권한, 트랜잭션 제어가 기준입니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 8px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 18px; font-weight: 800;&quot;&gt;DDL&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin: 2px 0 7px;&quot;&gt;Data Definition Language&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; line-height: 1.6;&quot;&gt;테이블 &lt;b&gt;구조&lt;/b&gt;를 정의합니다&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #8250df; margin-top: 7px;&quot;&gt;CREATE ALTER DROP&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1.5px solid #474a52; border-radius: 10px; padding: 14px; background: #e7eaf1;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 18px; font-weight: 800; color: #52565f;&quot;&gt;DML&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin: 2px 0 7px;&quot;&gt;Data Manipulation Language&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; line-height: 1.6;&quot;&gt;&lt;b&gt;데이터&lt;/b&gt;를 조회하고 조작합니다&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #8250df; margin-top: 7px;&quot;&gt;SELECT INSERT UPDATE DELETE&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 18px; font-weight: 800;&quot;&gt;DCL&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin: 2px 0 7px;&quot;&gt;Data Control Language&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; line-height: 1.6;&quot;&gt;&lt;b&gt;권한&lt;/b&gt;을 관리합니다&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #8250df; margin-top: 7px;&quot;&gt;GRANT REVOKE&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 190px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 18px; font-weight: 800;&quot;&gt;TCL&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin: 2px 0 7px;&quot;&gt;Transaction Control Language&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; line-height: 1.6;&quot;&gt;작업을 &lt;b&gt;확정하거나 취소&lt;/b&gt;합니다&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #8250df; margin-top: 7px;&quot;&gt;COMMIT ROLLBACK&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습에서는 DML, 특히 &lt;code&gt;SELECT&lt;/code&gt;를 가장 자주 사용합니다. 데이터 조회는 애플리케이션과 운영 환경에서 반복적으로 수행되는 작업입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SELECT&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SELECT&lt;/code&gt;는 조회할 열과 대상 테이블을 지정하는 구문입니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT name, email
FROM ecom.customers;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 쿼리는 &lt;code&gt;customers&lt;/code&gt; 테이블에서 &lt;code&gt;name&lt;/code&gt;과 &lt;code&gt;email&lt;/code&gt; 열을 조회합니다. &lt;code&gt;ecom.&lt;/code&gt;은 &lt;b&gt;스키마 이름&lt;/b&gt;입니다. 스키마는 데이터베이스 객체를 논리적으로 구분하고, 이름 충돌과 권한 관리를 지원합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SELECT *&lt;/code&gt;는 모든 열을 조회합니다. 운영 코드에서는 필요한 열만 명시하는 편이 좋습니다. 스키마 변경에 따른 결과 변화를 줄이고, 불필요한 데이터 전송을 피할 수 있습니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 18px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: flex-start; gap: 5px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 9px; border-radius: 7px; background: #e7eaf1; color: #52565f; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;SELECT name&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px;&quot;&gt;무엇을&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 9px; border-radius: 7px; background: #e7f2e9; color: #455148; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;FROM customers&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px;&quot;&gt;어디서&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 9px; border-radius: 7px; background: #f2eae6; color: #5f5651; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;WHERE ...&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px;&quot;&gt;어떤 행만&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 9px; border-radius: 7px; background: #e9e6f2; color: #55515f; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;ORDER BY ...&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px;&quot;&gt;어떤 순서로&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 9px; border-radius: 7px; background: #f1eee7; color: #514d46; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;LIMIT 10&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px;&quot;&gt;몇 개만&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;세미콜론(&lt;code&gt;;&lt;/code&gt;)은 SQL 문장의 끝을 나타냅니다. 도구 설정에 따라 단일 문장은 생략할 수 있지만, 여러 문장을 실행할 때는 구분자로 필요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WHERE로 조건 걸기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WHERE&lt;/code&gt;는 조건에 맞는 행만 조회하도록 필터링합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT name, email
FROM ecom.customers
WHERE joined_at &amp;gt;= '2025-01-01';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 쿼리는 2025년 1월 1일 이후에 가입한 고객을 조회합니다. 비교 연산자(&lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;)와 논리 연산자(&lt;code&gt;AND&lt;/code&gt;, &lt;code&gt;OR&lt;/code&gt;, &lt;code&gt;NOT&lt;/code&gt;)를 조합할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열과 날짜 리터럴은 &lt;b&gt;홑따옴표&lt;/b&gt;로 감쌉니다. PostgreSQL에서 겹따옴표는 식별자에 사용하므로 값에 사용하면 오류가 발생할 수 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;쓰는 법&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;범위&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BETWEEN 10 AND 20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 이상 20 이하 (양 끝 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;목록&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IN ('서울', '부산')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;둘 중 하나와 같음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;부분 일치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIKE '김%'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;김으로 시작 (&lt;code&gt;%&lt;/code&gt;는 아무 글자 여러 개)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빈 값&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IS NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;값이 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;= NULL&lt;/code&gt;은 NULL 값을 비교하는 방식으로 사용할 수 없습니다.&lt;/b&gt; &lt;code&gt;NULL&lt;/code&gt;과의 비교 결과는 참이나 거짓이 아닌 UNKNOWN이므로 &lt;b&gt;조회 결과가 0건이 될 수 있습니다&lt;/b&gt;. NULL 값을 찾을 때는 &lt;code&gt;IS NULL&lt;/code&gt;, NULL이 아닌 값을 찾을 때는 &lt;code&gt;IS NOT NULL&lt;/code&gt;을 사용합니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해석되는 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL의 작성 순서와 논리적 처리 순서는 다릅니다. 처리 순서를 이해하면 별칭과 집계 관련 오류를 분석하는 데 도움이 됩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 600px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 6px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;FROM&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #f2eae6; border: 1px solid #d9c4b8; color: #5f5651; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;WHERE&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #f1eee7; border: 1px solid #d8cfba; color: #514d46; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;GROUP BY&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #e7eaf1; border: 1.5px solid #474a52; color: #52565f; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;SELECT&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #e9e6f2; border: 1px solid #c1b8d9; color: #55515f; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;ORDER BY&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 14px; height: 0; border-top: 1.5px solid #374151; position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; padding: 9px 12px; border-radius: 7px; background: #f6f8fa; border: 1px solid #d1d5db; color: #24292f; font-family: Consolas,Menlo,monospace; font-size: 15px; font-weight: bold;&quot;&gt;LIMIT&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.7;&quot;&gt;&lt;b&gt;SELECT&lt;/b&gt; 가 네 번째로 처리되는 것이 중요합니다. &lt;code style=&quot;font-size: 14px;&quot;&gt;SELECT&lt;/code&gt; 에서 붙인 별칭은 그때까지 존재하지 않으므로 &lt;code style=&quot;font-size: 14px;&quot;&gt;WHERE&lt;/code&gt; 에서 쓸 수 없고, &lt;code style=&quot;font-size: 14px;&quot;&gt;ORDER BY&lt;/code&gt; 에서는 쓸 수 있습니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JOIN&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JOIN은 공통 열을 기준으로 두 개 이상의 테이블을 하나의 결과 집합으로 결합하는 구문입니다.&lt;/b&gt; 정규화로 분리한 데이터는 조회 목적에 따라 JOIN으로 결합합니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;SELECT c.name, o.item
FROM ecom.customers c
JOIN ecom.orders o
  ON c.customer_id = o.customer_id;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;customers&lt;/code&gt; 뒤의 &lt;code&gt;c&lt;/code&gt;와 &lt;code&gt;orders&lt;/code&gt; 뒤의 &lt;code&gt;o&lt;/code&gt;는 &lt;b&gt;별칭&lt;/b&gt;입니다. 두 테이블에 &lt;code&gt;customer_id&lt;/code&gt;처럼 같은 이름의 열이 있으면 &lt;code&gt;c.&lt;/code&gt;, &lt;code&gt;o.&lt;/code&gt;로 소속 테이블을 명시해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON&lt;/code&gt;에는 &lt;b&gt;테이블을 연결할 조건&lt;/b&gt;을 지정합니다. 위 쿼리는 같은 &lt;code&gt;customer_id&lt;/code&gt;를 가진 행을 결합하므로, 고객 정보는 해당 고객의 주문 건수만큼 결과에 나타납니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 660px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; gap: 14px; align-items: flex-start;&quot;&gt;
&lt;div style=&quot;flex: 1 1 200px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; margin-bottom: 7px;&quot;&gt;customers&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148;&quot;&gt;customer_id&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;name&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148; font-weight: bold;&quot;&gt;1&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;보라&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148; font-weight: bold;&quot;&gt;2&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;도라&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 14px; color: #464d57; margin-bottom: 7px;&quot;&gt;orders&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148;&quot;&gt;customer_id&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;item&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148; font-weight: bold;&quot;&gt;1&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;키보드&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; color: #455148; font-weight: bold;&quot;&gt;1&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 7px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;마우스&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: center; margin: 14px 0 12px;&quot;&gt;
&lt;div style=&quot;display: inline-block; font-family: Consolas,Menlo,monospace; font-size: 14px; color: #455148; background: #e7f2e9; border: 1px solid #bbd9c1; border-radius: 6px; padding: 5px 12px;&quot;&gt;ON c.customer_id = o.customer_id&lt;/div&gt;
&lt;div style=&quot;color: #6b7280; font-size: 18px; line-height: 1.2; margin-top: 8px;&quot;&gt;▼&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; margin-bottom: 7px;&quot;&gt;JOIN 결과&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #e7eaf1; border: 1px solid #bac3d8; border-radius: 6px; color: #52565f;&quot;&gt;name&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; font-weight: bold; background: #e7eaf1; border: 1px solid #bac3d8; border-radius: 6px; color: #52565f;&quot;&gt;item&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;보라&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;키보드&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;보라&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 0; padding: 8px 4px; text-align: center; font-size: 14px; background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 6px;&quot;&gt;마우스&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 12px; padding-top: 12px; border-top: 1px dashed #e5e7eb; font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;보라의 이름은 주문이 두 건이므로 두 행에 나타납니다. 도라는 주문이 없으므로 INNER JOIN 결과에 포함되지 않습니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JOIN의 종류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문이 없는 고객까지 조회해야 한다면 다른 JOIN 유형을 사용해야 합니다. JOIN 유형은 어느 테이블의 행을 결과에 유지할지에 따라 구분됩니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;종류&lt;/th&gt;
&lt;th&gt;남는 행&lt;/th&gt;
&lt;th&gt;쓰는 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INNER JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;양쪽에 짝이 있는 행만&lt;/td&gt;
&lt;td&gt;기본값. &lt;code&gt;JOIN&lt;/code&gt;만 적으면 이것입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;왼쪽 테이블은 전부, 오른쪽은 짝이 있을 때만&lt;/td&gt;
&lt;td&gt;주문이 없는 고객도 목록에 넣을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RIGHT JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;오른쪽 테이블은 전부&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LEFT&lt;/code&gt;로 순서를 바꿔 쓰는 경우가 많습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL OUTER JOIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;양쪽 전부&lt;/td&gt;
&lt;td&gt;어느 쪽에만 있는 데이터를 찾아낼 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 주문이 없는 고객도 함께 조회
SELECT c.name, o.item
FROM ecom.customers c
LEFT JOIN ecom.orders o
  ON c.customer_id = o.customer_id;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LEFT JOIN을 사용하면 도라도 결과에 포함되고, 주문이 없는 경우 &lt;code&gt;item&lt;/code&gt;은 &lt;code&gt;NULL&lt;/code&gt;입니다. 후속 집계나 계산에서는 &lt;b&gt;&lt;code&gt;NULL&lt;/code&gt; 값을 고려해야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실습 환경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습에서는 PostgreSQL을 데이터베이스로, DBeaver를 클라이언트 도구로 사용합니다. PostgreSQL은 데이터를 저장하고 SQL을 처리하는 &lt;b&gt;서버&lt;/b&gt;이며, DBeaver는 서버에 접속해 SQL을 실행하고 결과를 표시하는 &lt;b&gt;클라이언트&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL&lt;/b&gt;: 오픈소스 관계형 DBMS입니다. 표준 SQL 지원이 넓어 학습과 실무 환경에서 많이 사용됩니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DBeaver&lt;/b&gt;: 여러 DBMS에 연결할 수 있는 데이터베이스 클라이언트입니다. ERD 생성 기능으로 테이블 관계를 확인할 수 있습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;오류 메시지에는 원인 분석에 필요한 정보가 포함됩니다.&lt;/b&gt; SQL 오류는 대개 오류 위치와 토큰을 알려줍니다. &lt;code&gt;column &quot;x&quot; does not exist&lt;/code&gt;는 열 이름을, &lt;code&gt;relation &quot;y&quot; does not exist&lt;/code&gt;는 테이블 이름과 스키마 접두어를 확인해야 한다는 의미입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ERD&lt;/b&gt;: 개체&amp;middot;속성&amp;middot;관계선&amp;middot;카디널리티 네 가지로 그립니다. 개체가 테이블, 속성이 열이 되니 그림과 코드가 그대로 대응합니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SQL 분류&lt;/b&gt;: 구조는 DDL, 데이터는 DML, 권한은 DCL, 확정과 취소는 TCL&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SELECT&lt;/b&gt;: 적는 순서와 처리 순서가 다릅니다. &lt;code&gt;FROM&lt;/code&gt; &amp;rarr; &lt;code&gt;WHERE&lt;/code&gt; &amp;rarr; &lt;code&gt;SELECT&lt;/code&gt; &amp;rarr; &lt;code&gt;ORDER BY&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JOIN&lt;/b&gt;: 짝이 있는 행만 볼 때는 &lt;code&gt;INNER&lt;/code&gt;, 한쪽을 전부 남길 때는 &lt;code&gt;LEFT&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NULL&lt;/b&gt;: &lt;code&gt;= NULL&lt;/code&gt;은 조용히 0건을 돌려줍니다. &lt;code&gt;IS NULL&lt;/code&gt;을 씁니다&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: ERD로 데이터 구조를 설계하고, DDL로 테이블을 생성합니다. &lt;code&gt;SELECT&lt;/code&gt;와 &lt;code&gt;WHERE&lt;/code&gt;로 데이터를 조회하고, &lt;code&gt;JOIN&lt;/code&gt;으로 여러 테이블의 데이터를 결합합니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL 공식 문서: &lt;a href=&quot;https://www.postgresql.org/docs/current/queries.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Queries (SELECT &amp;middot; JOIN)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 공식 문서: &lt;a href=&quot;https://www.postgresql.org/docs/current/functions-comparison.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Comparison Functions and Operators&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL 공식 문서: &lt;a href=&quot;https://www.postgresql.org/docs/current/ddl-schemas.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Schemas&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;DBeaver: &lt;a href=&quot;https://dbeaver.com/docs/dbeaver/Database-Structure-Diagrams/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ERD 기능 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 서비스 규모에서 관계형 데이터베이스의 한계를 검토하고, 벡터 DB 같은 대안을 선택하는 기준을 정리합니다.&lt;/p&gt;</description>
      <category>소프트웨어 개발/데이터&amp;middot;통계</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/9</guid>
      <comments>https://boradora1.tistory.com/9#entry9comment</comments>
      <pubDate>Tue, 28 Jul 2026 12:15:22 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 비동기 처리| 이벤트 루프, Promise, async/await, Fetch</title>
      <link>https://boradora1.tistory.com/8</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xfX6U/dJMb99ULwEm/qcKbfQA2poiyWuD40oa5h0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xfX6U/dJMb99ULwEm/qcKbfQA2poiyWuD40oa5h0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xfX6U/dJMb99ULwEm/qcKbfQA2poiyWuD40oa5h0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxfX6U%2FdJMb99ULwEm%2FqcKbfQA2poiyWuD40oa5h0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;이벤트 루프&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Promise&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;async / await&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Fetch&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;JSON&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;모듈&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;서버가 하는 일의 대부분은 기다리는 것입니다. DB 응답을 기다리고, 외부 API 응답을 기다리고, 모델 추론 결과를 기다립니다. 그 기다림을 어떻게 다루느냐가 서버가 버티는 요청 수를 결정합니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;비동기와 모듈은 직무를 가리지 않고 필요한 부분입니다.&lt;/b&gt;&lt;br /&gt;화면을 만들든 서버를 만들든, 무언가를 기다렸다가 결과를 받아 처리하는 일은 똑같이 일어납니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript를 브라우저에서 화면 움직이는 언어로만 알고 있으면 이 부분이 왜 중요한지 잘 안 보입니다. 그런데 &lt;b&gt;비동기 처리와 모듈 시스템은 언어를 옮겨도 그대로 따라오는 개념&lt;/b&gt;입니다. Python의 &lt;code&gt;asyncio&lt;/code&gt;나 Java의 &lt;code&gt;CompletableFuture&lt;/code&gt;로도 같은 문제를 풀 수 있습니다. JavaScript는 그 개념이 문법에 가장 노골적으로 드러나 있어서 처음 이해하기에 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 글에서는 &lt;b&gt;화면을 다루는 부분은 빼고, 서버에서 데이터를 주고받을 때 쓰는 것&lt;/b&gt;을 중심으로 정리했습니다. DOM 조작이나 브라우저 저장소는 다루지 않겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 정리한 HTTP 요청과 응답을 이번엔 코드로 직접 주고받아 보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 비동기인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JavaScript는 한 번에 하나의 작업만 실행하는 싱글 스레드입니다. &lt;/b&gt;만약 DB 조회나 외부 API 호출처럼 시간이 오래 걸리는 작업을 끝날 때까지 기다린다면, 그 동안 다른 요청은 처리하지 못합니다. 서버에서 오래 걸리는 작업 대부분은 CPU 연산이 아니라 &lt;b&gt;I/O를 기다리는 시간&lt;/b&gt;입니다. 비동기 처리는 이런 작업을 요청한 뒤 기다리지 않고 다른 작업을 먼저 수행하며, 작업이 완료되면 이벤트 루프가 다시 실행 순서를 조정해 이어서 처리합니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-bottom: 6px;&quot;&gt;동기: 앞 작업이 끝나야 다음 작업이 시작됩니다&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px; margin-bottom: 6px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 A&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 5;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px; margin-bottom: 6px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 B&lt;/div&gt;
&lt;div style=&quot;flex: 2;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 3;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 C&lt;/div&gt;
&lt;div style=&quot;flex: 4;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 1;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: right; font-size: 12px; color: #5f5651; margin-top: 6px; font-family: Consolas,Menlo,monospace;&quot;&gt;총 600ms&lt;/div&gt;
&lt;div style=&quot;height: 1px; background: #e5e7eb; margin: 20px 0;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-bottom: 6px;&quot;&gt;비동기: 기다리는 동안 다음 요청을 받습니다&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px; margin-bottom: 6px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 A&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 5;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px; margin-bottom: 6px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 B&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 5;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 3px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 90px; font-size: 12px; color: #464d57; line-height: 26px;&quot;&gt;요청 C&lt;/div&gt;
&lt;div style=&quot;flex: 2; height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px;&quot;&gt;DB 대기 200ms&lt;/div&gt;
&lt;div style=&quot;flex: 5;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: right; font-size: 12px; color: #455148; margin-top: 6px; font-family: Consolas,Menlo,monospace;&quot;&gt;총 200ms&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기 방식은 요청마다 대시 시간이 누적되지만, 비동기 방식은 기다리는 &lt;b&gt;시간을 겹쳐 사용해 다른 요청을 처리&lt;/b&gt;합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이벤트 루프&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 기다리던 작업이 끝났을 때 결과는 어떻게 돌아올까요. 이 흐름을 관리하는 것이 &lt;b&gt;이벤트 루프&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 620px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; gap: 12px; align-items: stretch;&quot;&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px solid #bac3d8; border-radius: 10px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 8px 12px; font-weight: bold; font-size: 13px;&quot;&gt;① 콜 스택&lt;/div&gt;
&lt;div style=&quot;padding: 12px; font-size: 13px; color: #464d57; line-height: 1.7;&quot;&gt;지금 실행 중인 코드가 쌓이는 곳. &lt;b&gt;한 번에 하나만&lt;/b&gt; 처리합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 26px; align-self: center; text-align: center; color: #6b7280; font-size: 16px;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px solid #d9c4b8; border-radius: 10px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f2eae6; color: #5f5651; padding: 8px 12px; font-weight: bold; font-size: 13px;&quot;&gt;② 백그라운드&lt;/div&gt;
&lt;div style=&quot;padding: 12px; font-size: 13px; color: #464d57; line-height: 1.7;&quot;&gt;DB 조회&amp;middot;네트워크 요청&amp;middot;타이머를 &lt;b&gt;대신 기다려 주는 곳&lt;/b&gt;. 여기서 시간이 흐릅니다.&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 26px; align-self: center; text-align: center; color: #6b7280; font-size: 16px;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px solid #bbd9c1; border-radius: 10px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 8px 12px; font-weight: bold; font-size: 13px;&quot;&gt;③ 큐&lt;/div&gt;
&lt;div style=&quot;padding: 12px; font-size: 13px; color: #464d57; line-height: 1.7;&quot;&gt;끝난 작업의 후속 코드가 줄을 서는 곳.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding: 12px; border: 1px dashed #bac3d8; border-radius: 10px; background: #f6f8fa; text-align: center; font-size: 13px; color: #464d57;&quot;&gt;&lt;b&gt;이벤트 루프&lt;/b&gt;: 콜 스택이 비면 큐에서 하나를 꺼내 스택으로 올립니다. 이 확인을 계속 반복합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 비동기의 핵심을 이해할 수 있습니다. &lt;b&gt;콜 스택이 비어야 큐의 작업이 들어옵니다.&lt;/b&gt; 비동기는 시다리는 시간을 다른 요청 처리에 사용할 순 있지만, CPU가 수행하는 연산은 끝날 때까지 콜 스택을 점유하므로 다른 작업을 처리할 수 없습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;순서 따라가 보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 루프를 코드로 확인해봅시다. 아래가 어떤 순서로 찍힐지 먼저 예상해 보세요.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;console.log(&quot;1&quot;);
setTimeout(() =&amp;gt; console.log(&quot;2&quot;), 0);      // 0초인데도 바로 안 나옵니다
Promise.resolve().then(() =&amp;gt; console.log(&quot;3&quot;));
console.log(&quot;4&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 &lt;code&gt;1 &amp;rarr; 4 &amp;rarr; 3 &amp;rarr; 2&lt;/code&gt; 입니다.&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 1px solid #e5e7eb; border-radius: 12px; padding: 18px 16px; margin: 48px 0; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; gap: 12px; padding-bottom: 14px; border-bottom: 1px dashed #e5e7eb;&quot;&gt;
&lt;div style=&quot;flex: 0 0 28px; height: 28px; border-radius: 50%; background: #52565f; color: #fff; font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center;&quot;&gt;1&lt;/div&gt;
&lt;div style=&quot;flex: 1; font-size: 15px; color: #24292f;&quot;&gt;콜 스택에서 &lt;code&gt;console.log(&quot;1&quot;)&lt;/code&gt; 을 바로 실행합니다.
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 3px;&quot;&gt;출력: &lt;b&gt;1&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 12px; padding: 14px 0; border-bottom: 1px dashed #e5e7eb;&quot;&gt;
&lt;div style=&quot;flex: 0 0 28px; height: 28px; border-radius: 50%; background: #52565f; color: #fff; font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center;&quot;&gt;2&lt;/div&gt;
&lt;div style=&quot;flex: 1; font-size: 15px; color: #24292f;&quot;&gt;&lt;code&gt;setTimeout&lt;/code&gt; 과 &lt;code&gt;Promise.then&lt;/code&gt; 은 &lt;b&gt;지금 실행하지 않고 넘깁니다.&lt;/b&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 3px;&quot;&gt;둘 다 큐에서 대기. 이때 &lt;code&gt;console.log(&quot;4&quot;)&lt;/code&gt; 가 실행됩니다 &amp;nbsp;&amp;rarr; 출력: &lt;b&gt;4&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 12px; padding: 14px 0; border-bottom: 1px dashed #e5e7eb;&quot;&gt;
&lt;div style=&quot;flex: 0 0 28px; height: 28px; border-radius: 50%; background: #455148; color: #fff; font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center;&quot;&gt;3&lt;/div&gt;
&lt;div style=&quot;flex: 1; font-size: 15px; color: #24292f;&quot;&gt;콜 스택이 비었습니다. 큐에서 꺼낼 차례인데, &lt;b&gt;Promise 쪽 줄이 먼저입니다.&lt;/b&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 3px;&quot;&gt;출력: &lt;b&gt;3&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 12px; padding-top: 14px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 28px; height: 28px; border-radius: 50%; background: #5f5651; color: #fff; font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center;&quot;&gt;4&lt;/div&gt;
&lt;div style=&quot;flex: 1; font-size: 15px; color: #24292f;&quot;&gt;Promise 줄이 다 비면 그제야 타이머 줄을 처리합니다.
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 3px;&quot;&gt;출력: &lt;b&gt;2&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 보면 2가 먼저 출력될 것 같지만, &lt;b&gt;&lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; 은 &quot;지금 실행&quot;이 아니라 &quot;나중에 실행해 달라&quot;&lt;/b&gt;고 등록만 합니다. &lt;b&gt;Promise 쪽 줄이 타이머 줄보다 우선&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Promise&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 어떤 문제를 푸는지부터 보겠습니다. 함수가 DB 조회를 시작했다고 해봅시다. 결과는 200ms 뒤에나 나옵니다. 그럼 이 함수는 &lt;i&gt;지금 당장&lt;/i&gt; 무엇을 반환해야 할까요. 빈손으로 돌려보낼 수도 없고, 200ms 동안 붙잡아 둘 수도 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 나온 답이 &lt;b&gt;&quot;결과를 담을 빈 상자를 먼저 준다&quot;&lt;/b&gt;입니다. 그 상자가 Promise입니다. 호출한 쪽은 일단 상자를 받아 들고 자기 할 일을 합니다. 나중에 결과가 채워지면 그때 꺼내 쓰면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Promise는 비동기 작업의 결과를 나중에 알려주겠다고 약속하는 객체입니다.&lt;/b&gt; 결과는 아직 없습니다. 언젠가 성공하거나 실패한다는 사실만 먼저 받아 쥐는 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태는 세 가지이고, 한 번 정해지면 바뀌지 않습니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0; align-items: stretch;&quot;&gt;
&lt;div style=&quot;flex: 1 1 160px; border: 1px solid #d8cfba; border-radius: 11px; padding: 16px; background: #f1eee7;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 15px; color: #514d46;&quot;&gt;Pending&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 4px 0 6px;&quot;&gt;대기&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; line-height: 1.6;&quot;&gt;시작은 했고 아직 끝나지 않은 상태&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 160px; border: 1px solid #bbd9c1; border-radius: 11px; padding: 16px; background: #e7f2e9;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 15px; color: #455148;&quot;&gt;Fulfilled&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 4px 0 6px;&quot;&gt;이행&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; line-height: 1.6;&quot;&gt;성공. 결과값을 들고 있습니다&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 160px; border: 1px solid #d7b9c5; border-radius: 11px; padding: 16px; background: #f1e6eb;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 15px; color: #5f5157;&quot;&gt;Rejected&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 4px 0 6px;&quot;&gt;거부&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; line-height: 1.6;&quot;&gt;실패. 에러를 들고 있습니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 만들 때는 &lt;code&gt;resolve&lt;/code&gt;와 &lt;code&gt;reject&lt;/code&gt;를 받습니다. 성공하면 &lt;code&gt;resolve&lt;/code&gt;, 실패하면 &lt;code&gt;reject&lt;/code&gt;를 부릅니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const myPromise = new Promise((resolve, reject) =&amp;gt; {
  // 시간이 걸리는 작업
  if (성공) resolve(값);     // &amp;rarr; .then() 으로 감
  else      reject(에러);    // &amp;rarr; .catch() 로 감
});

myPromise
  .then(value =&amp;gt; console.log(value))
  .catch(error =&amp;gt; console.log(error));&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;resolve&lt;/code&gt;는 &lt;code&gt;return&lt;/code&gt;이 아닙니다.&lt;/b&gt; 상태와 결과값만 정해 둘 뿐, 함수의 실행 흐름을 끊지 않습니다. &lt;code&gt;resolve()&lt;/code&gt; 아래에 코드를 두면 그 코드도 실행됩니다. 끊고 싶으면 &lt;code&gt;return resolve(값)&lt;/code&gt;처럼 직접 &lt;code&gt;return&lt;/code&gt;을 써야 합니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;체인을 만들 때는 return이 필요합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 작업 세 개를 순서대로 실행해야 하면 &lt;code&gt;.then()&lt;/code&gt;을 이어 붙입니다. 이때 &lt;b&gt;안에서 &lt;code&gt;return&lt;/code&gt;을 빼먹으면 순서가 보장되지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;step1()
  .then(value =&amp;gt; { return step2(value); })   // return 필수
  .then(value =&amp;gt; { return step3(value); })
  .then(value =&amp;gt; console.log(value))
  .catch(error =&amp;gt; console.log(error));       // 앞의 어느 단계에서 터져도 여기로&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 &lt;code&gt;return&lt;/code&gt;이 있어야 하는지 짚고 가겠습니다. &lt;code&gt;.then()&lt;/code&gt;은 &lt;b&gt;앞 단계가 돌려준 값을 다음 단계로 넘겨주는&lt;/b&gt; 역할을 합니다. 이때 넘어온 것이 Promise면, 그게 끝날 때까지 기다렸다가 다음 &lt;code&gt;.then()&lt;/code&gt;을 실행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;return&lt;/code&gt;을 빼면 넘어가는 값이 &lt;code&gt;undefined&lt;/code&gt;가 됩니다. &lt;code&gt;step2()&lt;/code&gt;는 시작만 된 채 아무도 기다려주지 않고, 다음 &lt;code&gt;.then()&lt;/code&gt;이 바로 실행됩니다. 그래서 &lt;code&gt;step3&lt;/code&gt;이 &lt;code&gt;step2&lt;/code&gt;보다 먼저 끝나는 일이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.then()&lt;/code&gt; 안에 &lt;code&gt;.then()&lt;/code&gt;을 중첩하는 방식은 피합니다. 들여쓰기가 깊어지고 에러 처리가 어려워집니다. 이 문제를 문법 차원에서 없앤 것이 다음에 나올 &lt;code&gt;async&lt;/code&gt;와 &lt;code&gt;await&lt;/code&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;async와 await&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promise를 사용하면 비동기 작업을 처리할 수 있지만, &lt;b&gt;&lt;code&gt;.then()&lt;/code&gt;&lt;/b&gt; 이 계속 이어지면 코드가 길어지고 읽기 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 나온 것이 async와 await입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;await&lt;/code&gt;는 &lt;b&gt;Promise&lt;/b&gt;가 끝날 때까지 기다렸다가 결과값을 꺼내 줍니다.&lt;/b&gt; 덕분에 비동기 코드를 위에서 아래로 자연스럽게 작성할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// .then() 체인
function getUser() {
  return fetchUser()
    .then(res =&amp;gt; res.json())
    .then(user =&amp;gt; fetchOrders(user.id));
}

// async / await : 같은 동작
async function getUser() {
  const res  = await fetchUser();
  const user = await res.json();
  return await fetchOrders(user.id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙은 두 가지입니다. &lt;code&gt;await&lt;/code&gt;는 &lt;code&gt;async&lt;/code&gt;가 붙은 함수 안에서만 쓸 수 있고, &lt;code&gt;async&lt;/code&gt; 함수는 &lt;b&gt;무엇을 반환하든 항상 Promise를 반환합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function getNumber() {
  return 42;                    // 숫자를 반환한 것 같지만
}

const result = getNumber();
console.log(result);            // Promise { 42 }   &amp;larr; 숫자가 아닙니다

const value = await getNumber();
console.log(value);             // 42               &amp;larr; await 를 붙여야 꺼내집니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;async&lt;/code&gt; 함수를 부를 때는 &lt;code&gt;await&lt;/code&gt;가 거의 항상 붙어 다닙니다. 이 규칙 하나만 잡고 있으면 바로 아래 함정도 같이 풀립니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기다리는 동안 서버는 뭘 하나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 헷갈리기 쉽습니다. &lt;code&gt;await&lt;/code&gt;를 만나면 그 자리에서 멈춘다고 했는데, 그럼 서버 전체가 멈추는 걸까요. 아닙니다. 멈추는 것은 &lt;b&gt;그 함수 하나뿐&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 정확히 말하면 이렇습니다. &lt;code&gt;await&lt;/code&gt;를 만나는 순간 JavaScript는 &lt;b&gt;그 함수의 남은 부분을 &quot;결과 오면 이어서 할 일&quot;로 접어서 큐에 맡겨 두고&lt;/b&gt;, 콜 스택에서 빠져나옵니다. 스택이 비었으니 그 사이에 들어온 다른 요청을 처리할 수 있습니다. 그러다 결과가 도착하면, 접어뒀던 나머지가 큐에서 꺼내져 멈췄던 자리부터 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 본 이벤트 루프가 하는 일이 바로 이겁니다. &lt;code&gt;await&lt;/code&gt;는 &lt;b&gt;문법만 동기처럼 보일 뿐 하는 일은 여전히 비동기&lt;/b&gt;입니다. 읽기 쉬우라고 모양만 바꾼 것이지, 서버를 멈춰 세우는 게 아닙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.catch()&lt;/code&gt; 대신 &lt;code&gt;try / catch&lt;/code&gt;를 씁니다. 동기 코드와 같은 방식이라 읽기 편합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function getUser(id) {
  try {
    const res  = await fetch(`/api/users/${id}`);
    const user = await res.json();
    return user;
  } catch (error) {
    console.error(&quot;사용자 조회 실패:&quot;, error);
    throw error;             // 삼키지 말고 위로 올립니다
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;await&lt;/code&gt;를 빼먹으면 조용히 망가집니다.&lt;/b&gt; &lt;code&gt;await&lt;/code&gt; 없이 부르면 결과값이 아니라 Promise 객체가 그대로 들어옵니다. 에러도 &lt;code&gt;try / catch&lt;/code&gt;에 안 잡힙니다. &lt;code&gt;undefined&lt;/code&gt;가 찍히는데 원인을 못 찾겠다면 &lt;code&gt;await&lt;/code&gt;부터 확인해 보세요.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동시에 실행하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;await&lt;/code&gt;를 줄줄이 쓰면 앞의 것이 끝나야 다음이 시작됩니다. &lt;b&gt;서로 관계없는 작업이라면 이건 시간 낭비입니다.&lt;/b&gt; 백엔드에서 외부 API 여러 개를 부를 때 특히 그렇습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 순차 : 300ms (100 + 100 + 100)
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

// 병렬 : 100ms (셋을 동시에 시작하고 다 끝나기를 기다림)
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 520px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-bottom: 8px;&quot;&gt;순차 &lt;code style=&quot;background: none; padding: 0; color: #464d57;&quot;&gt;await&lt;/code&gt; 세 번&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px; margin-bottom: 16px;&quot;&gt;
&lt;div style=&quot;flex: 1; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;A 100ms&lt;/div&gt;
&lt;div style=&quot;flex: 1; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;B 100ms&lt;/div&gt;
&lt;div style=&quot;flex: 1; height: 26px; border-radius: 4px; background: #f2eae6; border: 1px solid #d9c4b8; font-size: 11px; color: #5f5651; text-align: center; line-height: 26px;&quot;&gt;C 100ms&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 74px; font-size: 12px; color: #5f5651; line-height: 26px; text-align: right; font-family: Consolas,Menlo,monospace;&quot;&gt;300ms&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-bottom: 8px;&quot;&gt;&lt;code style=&quot;background: none; padding: 0; color: #464d57;&quot;&gt;Promise.all&lt;/code&gt; 로 병렬&lt;/div&gt;
&lt;div style=&quot;display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1;&quot;&gt;
&lt;div style=&quot;height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px; margin-bottom: 4px;&quot;&gt;A 100ms&lt;/div&gt;
&lt;div style=&quot;height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px; margin-bottom: 4px;&quot;&gt;B 100ms&lt;/div&gt;
&lt;div style=&quot;height: 26px; border-radius: 4px; background: #e7f2e9; border: 1px solid #bbd9c1; font-size: 11px; color: #455148; text-align: center; line-height: 26px;&quot;&gt;C 100ms&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 2;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 74px; font-size: 12px; color: #455148; line-height: 26px; text-align: right; font-family: Consolas,Menlo,monospace;&quot;&gt;100ms&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;메서드&lt;/th&gt;
&lt;th&gt;동작&lt;/th&gt;
&lt;th&gt;쓰는 곳&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;하나라도 실패하면 즉시 전체 실패&lt;/td&gt;
&lt;td&gt;전부 성공해야 의미 있을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.allSettled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;실패해도 끝까지 기다리고 결과를 모두 반환&lt;/td&gt;
&lt;td&gt;일부 실패를 감수할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Promise.race&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가장 먼저 끝난 하나만 반환&lt;/td&gt;
&lt;td&gt;타임아웃 걸 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;외부 API 세 곳 중 하나가 죽어도 나머지는 보여줘야 하는 상황이라면 &lt;code&gt;Promise.allSettled&lt;/code&gt;가 맞습니다. &lt;code&gt;Promise.all&lt;/code&gt;을 쓰면 하나 때문에 전부 날아갑니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Fetch로 API 호출하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;fetch()&lt;/code&gt;는 서버에 HTTP 요청을 보내는 함수입니다.&lt;/b&gt; 비동기라서 Promise를 반환합니다. 2편에서 표로 봤던 요청과 응답이 여기서는 코드로 나옵니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function loadData() {
  const response = await fetch(&quot;https://api.example.com/data&quot;);
  const data     = await response.json();
  console.log(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;await&lt;/code&gt;가 두 번 나오는 이유가 있습니다. 첫 번째는 &lt;b&gt;응답 헤더가 도착할 때까지&lt;/b&gt;, 두 번째는 &lt;b&gt;본문을 다 받아 JSON으로 파싱할 때까지&lt;/b&gt; 기다립니다. 2편에서 본 헤더와 바디가 따로 오는 구조가 여기 그대로 드러납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;404가 나도 catch로 안 갑니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 &lt;code&gt;fetch&lt;/code&gt;의 가장 헷갈리는 동작입니다.&lt;/p&gt;
&lt;div style=&quot;border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px; background: #e7f2e9; border-bottom: 1px solid #e5e7eb;&quot;&gt;
&lt;div style=&quot;flex: 0 0 120px; font-weight: bold; font-size: 14px; color: #455148;&quot;&gt;성공으로 침&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 220px; font-size: 15px; color: #24292f;&quot;&gt;서버가 응답을 보내기만 하면 전부
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 2px;&quot;&gt;&lt;code&gt;404 Not Found&lt;/code&gt;, &lt;code&gt;500 Internal Server Error&lt;/code&gt; 포함&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px; background: #f1e6eb;&quot;&gt;
&lt;div style=&quot;flex: 0 0 120px; font-weight: bold; font-size: 14px; color: #5f5157;&quot;&gt;실패로 침&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 220px; font-size: 15px; color: #24292f;&quot;&gt;응답 자체가 오지 못한 경우
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 2px;&quot;&gt;네트워크 끊김, DNS 조회 실패, CORS 차단&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버가 &quot;그런 주소 없다&quot;고 답한 것도 서버는 제대로 답한 것&lt;/b&gt;이라는 관점입니다. 그래서 상태 코드는 &lt;code&gt;response.ok&lt;/code&gt;로 직접 봐야 합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);

  if (!res.ok) {                                  // 이 줄이 없으면 404를 그냥 지나칩니다
    throw new Error(`요청 실패: ${res.status}`);   // res.status 에 404, 500 이 들어옵니다
  }

  return await res.json();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;2편에서 본 상태 코드가 &lt;code&gt;res.status&lt;/code&gt;에 그대로 들어옵니다. 4xx면 내가 보낸 요청을, 5xx면 상대 서버를 의심하세요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;POST로 보낼 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본값은 GET입니다. 데이터를 보내려면 옵션을 넘깁니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const res = await fetch(&quot;/api/users&quot;, {
  method: &quot;POST&quot;,
  headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },   // 바디 형식을 알려줍니다
  body: JSON.stringify({ name: &quot;보라도라&quot;, role: &quot;backend&quot; })
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;body&lt;/code&gt;에 객체를 그대로 넣으면 안 됩니다. 네트워크로는 &lt;b&gt;문자열만 오갑니다.&lt;/b&gt; 그래서 &lt;code&gt;JSON.stringify()&lt;/code&gt;로 바꿔서 실어 보내고, 받는 쪽은 &lt;code&gt;JSON.parse()&lt;/code&gt;로 되돌립니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;함수&lt;/th&gt;
&lt;th&gt;방향&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JSON.stringify(obj)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;객체 &amp;rarr; 문자열&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'{&quot;name&quot;:&quot;보라도라&quot;}'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JSON.parse(str)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;문자열 &amp;rarr; 객체&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ name: &quot;보라도라&quot; }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;res.json()&lt;/code&gt;이 이 &lt;code&gt;JSON.parse&lt;/code&gt;를 대신 해줍니다. 응답이 JSON이 아니면 파싱에 실패하며 에러가 발생합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모듈&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 늘어나면 파일을 나누게 됩니다. &lt;b&gt;모듈은 파일마다 독립된 공간을 주고, 필요한 것만 &lt;code&gt;export&lt;/code&gt;와 &lt;code&gt;import&lt;/code&gt;로 주고받게 하는 방식입니다.&lt;/b&gt; ES6부터 표준입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// math.js : 내보내기
export function add(a, b) {
  return a + b;
}

// main.js : 가져오기
import { add } from './math.js';
const result = add(2, 3);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;파일 분리와 모듈의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전처럼 &lt;code&gt;&amp;lt;script src=&quot;file.js&quot;&amp;gt;&lt;/code&gt;로 파일만 나누면 &lt;b&gt;모든 파일이 전역 공간을 같이 씁니다.&lt;/b&gt; 파일은 분리했지만 변수는 한 통에 담겨 있는 셈이라, 이름이 겹치면 그대로 충돌합니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 12px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1px solid #d7b9c5; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #f1e6eb; color: #5f5157; padding: 9px 14px; font-weight: bold; font-size: 14px;&quot;&gt;일반 외부 JS &amp;nbsp;전역 공유&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-family: Consolas,Menlo,monospace; font-size: 13px; line-height: 1.7; color: #24292f;&quot;&gt;&lt;span style=&quot;color: #464d57;&quot;&gt;// a.js&lt;/span&gt;&lt;br /&gt;const count = 10;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #464d57;&quot;&gt;// b.js&lt;/span&gt;&lt;br /&gt;const count = 20; &lt;span style=&quot;color: #5f5157;&quot;&gt;&amp;larr; 충돌&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1px solid #bbd9c1; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 9px 14px; font-weight: bold; font-size: 14px;&quot;&gt;모듈 &amp;nbsp;파일마다 격리&lt;/div&gt;
&lt;div style=&quot;padding: 14px; font-family: Consolas,Menlo,monospace; font-size: 13px; line-height: 1.7; color: #24292f;&quot;&gt;&lt;span style=&quot;color: #464d57;&quot;&gt;// a.js&lt;/span&gt;&lt;br /&gt;const count = 10;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #464d57;&quot;&gt;// b.js&lt;/span&gt;&lt;br /&gt;const count = 20; &lt;span style=&quot;color: #455148;&quot;&gt;&amp;larr; 문제 없음&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;일반 외부 JS&lt;/th&gt;
&lt;th&gt;모듈&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;스코프&lt;/td&gt;
&lt;td&gt;전역 공유&lt;/td&gt;
&lt;td&gt;파일마다 격리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이름 충돌&lt;/td&gt;
&lt;td&gt;위험 (같으면 버그)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소통 방식&lt;/td&gt;
&lt;td&gt;전역 변수를 통해 간접적으로&lt;/td&gt;
&lt;td&gt;&lt;code&gt;import&lt;/code&gt; / &lt;code&gt;export&lt;/code&gt;로 명시적으로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 시점&lt;/td&gt;
&lt;td&gt;태그를 만나는 즉시&lt;/td&gt;
&lt;td&gt;문서를 다 읽은 뒤 (defer)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Named와 Default&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내보내는 방식은 두 가지입니다. 섞어 쓸 수도 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;문법&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Named&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;export const foo = ...&lt;/code&gt;&lt;br /&gt;&lt;code&gt;import { foo } from './m.js'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;여러 개 내보낼 수 있고 이름이 명확합니다. 대신 이름을 정확히 써야 합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Default&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;export default foo&lt;/code&gt;&lt;br /&gt;&lt;code&gt;import 아무이름 from './m.js'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가져올 때 이름을 자유롭게 붙일 수 있습니다. 대신 한 파일에 하나만 가능합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// 섞어 쓰기 : 주요 기능은 default, 보조 기능은 named
export const helper = () =&amp;gt; {};
export default mainFunction;

import mainFunction, { helper } from './module.js';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 중 어느 쪽을 쓸지는 &lt;b&gt;파일의 역할에 따라 결정&lt;/b&gt;하면 됩니다. 로그인만 처리하거나 데이터베이스 연결처럼 하나의 대표 기능을 담은 파일이라면 Default, 반면 날짜 변환, 문자열 처리 등 여러 함수나 변수를 함께 관리하는 파일이라면 Named 를 사용하는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 데이터를 다룰 때 기준으로 골라 담으면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비동기가 벌어주는 건 &lt;b&gt;기다림(I/O)&lt;/b&gt;입니다. CPU를 오래 사용하는 작업은 &lt;code&gt;async&lt;/code&gt;를 붙여도 다른 요청을 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await&lt;/code&gt;를 줄줄이 쓰면 그 시간이 전부 더해지기 때문에, 서로 상관없는 호출이라면 &lt;b&gt;&lt;code&gt;Promise.all&lt;/code&gt;&lt;/b&gt;로 함께 실행하세요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetch&lt;/code&gt;는 HTTP 404나 500을 받아도 성공적으로 응답을 받은 것으로 처리합니다. &lt;code&gt;res.ok&lt;/code&gt;로 직접 확인해야 합니다.&lt;/li&gt;
&lt;li&gt;네트워크로는 문자열만 오갑니다. 보낼 땐 &lt;code&gt;JSON.stringify&lt;/code&gt;, 받을 땐 &lt;code&gt;res.json()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;모듈은 파일을 나누는 게 아니라 &lt;b&gt;공간을 나눕니다.&lt;/b&gt; 그래서 같은 이름을 써도 안 부딪힙니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: 서버는 기다리는 시간이 대부분이고 &amp;rarr; &lt;b&gt;이벤트 루프&lt;/b&gt;가 그 시간을 겹쳐 쓰게 해주며 &amp;rarr; &lt;b&gt;Promise&lt;/b&gt;와 &lt;b&gt;async/await&lt;/b&gt;로 그 흐름을 코드에 적고 &amp;rarr; &lt;b&gt;fetch&lt;/b&gt;와 &lt;b&gt;JSON&lt;/b&gt;으로 주고받고 &amp;rarr; &lt;b&gt;모듈&lt;/b&gt;로 파일을 나눕니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MDN: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Using_promises&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Promise 사용하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MDN: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Fetch API 사용하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MDN: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Modules&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;JavaScript 모듈&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;W3Schools: &lt;a href=&quot;https://www.w3schools.com/js/js_async.asp&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;JS Async&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>소프트웨어 개발/웹</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/8</guid>
      <comments>https://boradora1.tistory.com/8#entry8comment</comments>
      <pubDate>Sun, 26 Jul 2026 20:56:38 +0900</pubDate>
    </item>
    <item>
      <title>웹 개발을 위한 네트워크 기초</title>
      <link>https://boradora1.tistory.com/7</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c7Vv3i/dJMcacquWP4/vLSsr0ZCVqkwtBlKycqlE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c7Vv3i/dJMcacquWP4/vLSsr0ZCVqkwtBlKycqlE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c7Vv3i/dJMcacquWP4/vLSsr0ZCVqkwtBlKycqlE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc7Vv3i%2FdJMcacquWP4%2FvLSsr0ZCVqkwtBlKycqlE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;TCP/IP&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;HTTP&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;DNS&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;IP 주소&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;URL&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;HTTPS&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;주소창에 주소를 치고 엔터를 누르면 1초도 안 돼서 페이지가 뜹니다. 그 짧은 사이에 컴퓨터들끼리 어떤 대화가 오가는지, 순서대로 따라가 보려고 합니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;주소창에 URL을 입력하고 엔터를 누르면 무슨 일이 일어날까요?&lt;/b&gt;&lt;br /&gt;이 질문 하나를 따라가다 보면 네트워크 기초를 이해할 수 있습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘은 AI에게 시켜서 코드를 만드는 바이브 코딩이 흔해졌습니다. 덕분에 화면 하나 띄우는 일은 확실히 빨라졌지만 막상 에러가 나면 어디부터 봐야 할지 막막할 때가 있습니다. 404와 500이 무엇이 다른지, 주소창의 &lt;code&gt;https&lt;/code&gt;는 어떤 역할을 하는지조차 헷갈리는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 개발은 보통 HTML부터 배웁니다. 하지만 그 HTML 파일이 어떻게 서버에서 브라우저까지 전달되는지는 쉽게 지나치곤 합니다. 프론트엔드든 백엔드든 결국 우리가 만드는 것은 네트워크를 통해 오가는 데이터입니다. 그래서 이번 글에서는 웹 개발을 위해 꼭 알아야할 네트워크 기초를 정리해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인터넷과 웹&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인터넷은 전 세계 컴퓨터를 연결한 물리적인 통신망입니다.&lt;/b&gt; 케이블, 광섬유, 공유기, 라우터 같은 장비로 이루어진 실제 네트워크를 말합니다. 웹, 이메일, 파일 전송 모두는 이 인터넷 위에서 동작하는 서비스입니다. 즉,&lt;b&gt; 웹(WWW)&lt;/b&gt;은 인터넷 자체가 아니라 인터넷을 이용하는 여러 서비스 중 하나입니다.&lt;/p&gt;
&lt;div style=&quot;border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 18px; margin: 48px 0; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .08em; color: #464d57; margin-bottom: 10px;&quot;&gt;서비스 (Application)&lt;/div&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 8px;&quot;&gt;
&lt;div style=&quot;flex: 1 1 130px; text-align: center; padding: 12px 8px; border: 1.5px solid #474a52; border-radius: 9px; background: #e7eaf1; color: #52565f; font-weight: bold; font-size: 15px;&quot;&gt;웹 (WWW)
&lt;div style=&quot;font-weight: 400; font-size: 12px; color: #464d57; margin-top: 3px;&quot;&gt;HTTP&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 130px; text-align: center; padding: 12px 8px; border: 1px solid #e5e7eb; border-radius: 9px; background: #f6f8fa; font-weight: 600; font-size: 15px;&quot;&gt;이메일
&lt;div style=&quot;font-weight: 400; font-size: 12px; color: #464d57; margin-top: 3px;&quot;&gt;SMTP &amp;middot; IMAP&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 130px; text-align: center; padding: 12px 8px; border: 1px solid #e5e7eb; border-radius: 9px; background: #f6f8fa; font-weight: 600; font-size: 15px;&quot;&gt;파일 전송
&lt;div style=&quot;font-weight: 400; font-size: 12px; color: #464d57; margin-top: 3px;&quot;&gt;FTP&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 130px; text-align: center; padding: 12px 8px; border: 1px solid #e5e7eb; border-radius: 9px; background: #f6f8fa; font-weight: 600; font-size: 15px;&quot;&gt;원격 접속
&lt;div style=&quot;font-weight: 400; font-size: 12px; color: #464d57; margin-top: 3px;&quot;&gt;SSH&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;text-align: center; color: #6b7280; font-size: 18px; line-height: 1.2; margin: 10px 0 8px;&quot;&gt;▼&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .08em; color: #464d57; margin-bottom: 10px;&quot;&gt;인프라 (Infrastructure)&lt;/div&gt;
&lt;div style=&quot;padding: 16px; border: 1px solid #d1d5db; border-radius: 9px; background: #f6f8fa; text-align: center;&quot;&gt;
&lt;div style=&quot;font-weight: bold; font-size: 16px;&quot;&gt;인터넷&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 4px;&quot;&gt;케이블 &amp;middot; 라우터 &amp;middot; 스위치 &amp;middot; 컴퓨터&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;인터넷은 깔려 있는 망이고, 웹은 그 위에서 도는 서비스 중 하나입니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;인터넷 (Internet)&lt;/th&gt;
&lt;th&gt;웹 (World Wide Web)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;정체&lt;/td&gt;
&lt;td&gt;컴퓨터를 연결한 통신망 (&lt;b&gt;인프라&lt;/b&gt;)&lt;/td&gt;
&lt;td&gt;정보를 공유하는 공간 (&lt;b&gt;서비스&lt;/b&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구성&lt;/td&gt;
&lt;td&gt;케이블, 라우터, 스위치 (하드웨어)&lt;/td&gt;
&lt;td&gt;HTML, HTTP, URL, 브라우저 (소프트웨어)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기준 계층&lt;/td&gt;
&lt;td&gt;하위 계층 (인터넷&amp;middot;전송 계층)&lt;/td&gt;
&lt;td&gt;최상위 계층 (응용 계층)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관계&lt;/td&gt;
&lt;td&gt;웹을 포함한 모든 서비스의 기반&lt;/td&gt;
&lt;td&gt;인터넷 위 수많은 서비스 중 하나&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;흐름을 보면 이렇습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금의 인터넷이 어떻게 만들어졌는지도 간단히 짚고 가겠습니다.&lt;/p&gt;
&lt;div style=&quot;border-left: 2px solid #e5e7eb; margin: 48px 0 48px 8px; padding-left: 20px;&quot;&gt;
&lt;div style=&quot;position: relative; margin-bottom: 18px;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -27px; top: 6px; width: 11px; height: 11px; border-radius: 50%; background: #9ca3af;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #464d57;&quot;&gt;1960년대&lt;/div&gt;
&lt;div style=&quot;font-weight: 600;&quot;&gt;미국 국방부 ARPANET 프로젝트&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57;&quot;&gt;떨어진 연구소 컴퓨터를 연결해 본 것이 시작&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: relative; margin-bottom: 18px;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -27px; top: 6px; width: 11px; height: 11px; border-radius: 50%; background: #9ca3af;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #464d57;&quot;&gt;1970~80년대&lt;/div&gt;
&lt;div style=&quot;font-weight: 600;&quot;&gt;TCP/IP 프로토콜 개발&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57;&quot;&gt;서로 다른 망을 하나로 묶는 공통 규칙이 생김&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: relative; margin-bottom: 18px;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -27px; top: 6px; width: 11px; height: 11px; border-radius: 50%; background: #474a52;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #474a52;&quot;&gt;1990년대&lt;/div&gt;
&lt;div style=&quot;font-weight: 600;&quot;&gt;WWW 등장 &amp;rarr; 대중화&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57;&quot;&gt;1989년 팀 버너스리가 www를, 1991년 HTML을 만듦&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: relative;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -27px; top: 6px; width: 11px; height: 11px; border-radius: 50%; background: #9ca3af;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #464d57;&quot;&gt;2000년대~&lt;/div&gt;
&lt;div style=&quot;font-weight: 600;&quot;&gt;모바일 &amp;middot; 클라우드 &amp;middot; IoT&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57;&quot;&gt;연결되는 기기의 종류가 폭발적으로 늘어남&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;웹을 이루는 세 가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹은 크게 세 가지로 이루어져 있습니다. 어떻게 보낼지, 어디로 보낼지, 무엇을 보낼지입니다. 앞의 두 가지가 이번 글의 내용이고, 마지막 HTML은 다음 글에서 정리하겠습니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 10px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px solid #e5e7eb; border-radius: 11px; padding: 16px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;어떻게 보낼까&lt;/div&gt;
&lt;div style=&quot;font-size: 20px; font-weight: 800; margin: 4px 0 6px; letter-spacing: -.02em;&quot;&gt;HTTP&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;브라우저와 서버가 대화하는 규칙&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px solid #e5e7eb; border-radius: 11px; padding: 16px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;어디로 보낼까&lt;/div&gt;
&lt;div style=&quot;font-size: 20px; font-weight: 800; margin: 4px 0 6px; letter-spacing: -.02em;&quot;&gt;URL&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #464d57; line-height: 1.6;&quot;&gt;자원이 어디 있는지 가리키는 주소&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 170px; border: 1px dashed #d1d5db; border-radius: 11px; padding: 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;무엇을 보낼까&lt;/div&gt;
&lt;div style=&quot;font-size: 20px; font-weight: 800; margin: 4px 0 6px; letter-spacing: -.02em; color: #6b7280;&quot;&gt;HTML&lt;/div&gt;
&lt;div style=&quot;font-size: 14px; color: #6b7280; line-height: 1.6;&quot;&gt;다음 글에서 다룹니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인터넷을 이루는 세 가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 인터넷이라는 망은 무엇으로 만들어져 있을까요. 크게 세 가지입니다.&lt;/p&gt;
&lt;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;b&gt;클라이언트&lt;/b&gt;(노트북, 스마트폰), 응답하는 쪽이 &lt;b&gt;서버&lt;/b&gt;입니다. 각각 IP 주소를 받아 식별됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전송 매체&lt;/b&gt;: 신호가 실제로 지나가는 길. 광케이블&amp;middot;LAN선 같은 유선과 Wi-Fi&amp;middot;5G 같은 무선이 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네트워크 장비&lt;/b&gt;: 데이터를 다음 목적지로 넘겨주는 중계기. &lt;b&gt;라우터&lt;/b&gt;가 서로 다른 네트워크를 잇고 경로를 정하며, &lt;b&gt;스위치&lt;/b&gt;가 한 네트워크 안의 기기들을 연결합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;통신을 계층으로 나눈 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로토콜은 데이터를 주고받기 위해 미리 정해둔 규칙입니다.&lt;/b&gt; 그런데 이 규칙을 하나로 묶어버리면 문제가 생기게 됩니다. 전기 신호를 보내는 방식, 데이터를 목적지까지 전달하는 방식, 오류를 복구하는 방식이 전부 얽혀 있으면 한 부분만 바뀌어도 전체를 수정해야 하기 때문입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 기능을 &lt;b&gt;계층(Layer)&lt;/b&gt;으로 나눴습니다. 각 계층은 자신의 역할만 수행하고, 다른 계층의 동작 방식은 신경 쓰지 않습니다. &lt;b&gt;계층 구조를 이해하기 위해 사용하는 것이 OSI 7계층&lt;/b&gt;이고, &lt;b&gt;실제 인터넷은 TCP/IP 4계층&lt;/b&gt;을 기반으로 동작합니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;계층&lt;/th&gt;
&lt;th&gt;하는 일&lt;/th&gt;
&lt;th&gt;대표 프로토콜&lt;/th&gt;
&lt;th&gt;데이터 단위(PDU)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;사용자 앱과 직접 대화&lt;/td&gt;
&lt;td&gt;HTTP, DNS, FTP, SMTP&lt;/td&gt;
&lt;td&gt;Message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;목적지까지 정확하게 전달&lt;/td&gt;
&lt;td&gt;TCP, UDP&lt;/td&gt;
&lt;td&gt;Segment / Datagram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internet&lt;/td&gt;
&lt;td&gt;최적 경로를 찾고 IP 주소 부여&lt;/td&gt;
&lt;td&gt;IP, ICMP&lt;/td&gt;
&lt;td&gt;Packet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network Access&lt;/td&gt;
&lt;td&gt;케이블&amp;middot;랜카드로 비트 단위 전송&lt;/td&gt;
&lt;td&gt;Ethernet, Wi-Fi&lt;/td&gt;
&lt;td&gt;Frame &amp;rarr; Bit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 아래 Network Access 계층은 OSI 모델의 물리 계층과 데이터 링크 계층을 하나로 합친 것입니다. 유선 &lt;b&gt;Ethernet&lt;/b&gt;과 무선의 &lt;b&gt;Wi-Fi(IEEE 802.11)&lt;/b&gt;가 여기에 속하며, 데이터를 실제 케이블이나 무선 신호를 통해 전송하는 역할을 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;층마다 붙는 헤더, 캡슐화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터는 위에서 아래로 내려가면서 각 층의 &lt;b&gt;헤더(Header)&lt;/b&gt;가 하나씩 붙는데, 이 과정을 캡슐화라고 합니다. 계층마다 헤더가 달라지기 때문에 데이터를 부르는 이름도 달라지는데, 이 이름을 &lt;b&gt;PDU&lt;/b&gt;라고 합니다. 받는 쪽에서는 아래 계층부터 헤더를 하나씩 제거하면서 원래 데이터만 남게 됩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 520px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 18px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 118px; font-size: 13px; color: #464d57;&quot;&gt;Application&lt;/div&gt;
&lt;div style=&quot;flex: 1; display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 1; padding: 9px; text-align: center; border-radius: 6px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 14px; font-weight: bold;&quot;&gt;데이터&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 13px; color: #24292f;&quot;&gt;Message&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 118px; font-size: 13px; color: #464d57;&quot;&gt;Transport&lt;/div&gt;
&lt;div style=&quot;flex: 1; display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;TCP&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px; text-align: center; border-radius: 6px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 14px; font-weight: bold;&quot;&gt;데이터&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 13px; color: #24292f;&quot;&gt;Segment&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px; margin-bottom: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 118px; font-size: 13px; color: #464d57;&quot;&gt;Internet&lt;/div&gt;
&lt;div style=&quot;flex: 1; display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;IP&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;TCP&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px; text-align: center; border-radius: 6px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 14px; font-weight: bold;&quot;&gt;데이터&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 13px; color: #24292f;&quot;&gt;Packet&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; align-items: center; gap: 10px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 118px; font-size: 13px; color: #464d57;&quot;&gt;Network Access&lt;/div&gt;
&lt;div style=&quot;flex: 1; display: flex; gap: 4px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;Ethernet&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;IP&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 66px; padding: 9px 4px; text-align: center; border-radius: 6px; background: #f6f8fa; border: 1px solid #d1d5db; font-size: 12px; color: #464d57;&quot;&gt;TCP&lt;/div&gt;
&lt;div style=&quot;flex: 1; padding: 9px; text-align: center; border-radius: 6px; background: #e7eaf1; border: 1px solid #bac3d8; color: #52565f; font-size: 14px; font-weight: bold;&quot;&gt;데이터&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 92px; text-align: right; font-family: Consolas,Menlo,monospace; font-size: 13px; color: #24292f;&quot;&gt;Frame&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding-top: 12px; border-top: 1px dashed #e5e7eb; text-align: center; font-size: 13px; color: #464d57; font-family: Consolas,Menlo,monospace;&quot;&gt;▼ 마지막엔 0과 1의 전기 신호 (Bit) 로 케이블에 실립니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;fs-note&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;보내는 쪽은 헤더를 붙이며 내려가고, 받는 쪽은 벗기며 올라갑니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;IP 주소와 MAC 주소&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크에서는 IP 주소와 MAC 주소를 함께 사용합니다. IP 주소는 어느 네트워크에 연결되어 있는지를 타나내는 &lt;b&gt;논리적 주소&lt;/b&gt;이고, MAC 주소는 장치 자체를 식별하는 &lt;b&gt;물리적 주소&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;IP 주소&lt;/b&gt;는 인터넷에서 장치를 식별하는 &lt;b&gt;논리적&lt;/b&gt; 주소입니다. 네트워크가 바뀌면 IP 주소도 바뀔 수 있습니다.&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;IPv4&lt;/b&gt;: &lt;code&gt;192.168.0.1&lt;/code&gt;처럼 점으로 구분된 32비트 주소입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;IPv6&lt;/b&gt;: &lt;code&gt;2001:0db8:85a3:0000:0000:8a2e:0370:7334&lt;/code&gt;처럼 콜론으로 구분된 128비트 주소로, IPv4 주소 부족 문제를 해결하기 위해 만들어졌습니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;b&gt;MAC 주소&lt;/b&gt;는 랜카드에 부여된 &lt;b&gt;영구히 기록된 물리적&lt;/b&gt; &lt;b&gt;주소&lt;/b&gt;입니다. 일반적으로 48비트, 16진수 12자리로 표기하고, 네트워크가 바뀌어도 값이 변하지 않습니다.&amp;nbsp;&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;IP 주소&lt;/th&gt;
&lt;th&gt;MAC 주소&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;성격&lt;/td&gt;
&lt;td&gt;논리적 주소 (소프트웨어가 부여)&lt;/td&gt;
&lt;td&gt;물리적 주소 (하드웨어에 기록)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;변경&lt;/td&gt;
&lt;td&gt;네트워크가 바뀌면 같이 바뀜&lt;/td&gt;
&lt;td&gt;바뀌지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;계층&lt;/td&gt;
&lt;td&gt;Internet Layer&lt;/td&gt;
&lt;td&gt;Network Access Layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;공인 IP와 사설 IP&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집에 있는 노트북, 폰, TV가 각각 전 세계에서 유일한 주소를 가질 필요는 없습니다. 그래서 주소를 두 겹으로 씁니다. 집 안에서는 사설 IP를 쓰다가, 밖으로 나갈 때 공유기가 하나의 공인 IP로 바꿔 주는 방식입니다. 이 변환 기술을 &lt;b&gt;NAT&lt;/b&gt;이라고 합니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 220px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; background: #f6f8fa;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .06em; color: #464d57; margin-bottom: 8px;&quot;&gt;우리 집 안 (LAN) &amp;middot; 사설 IP&lt;/div&gt;
&lt;div style=&quot;display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px;&quot;&gt;&lt;span&gt;노트북&lt;/span&gt;&lt;span style=&quot;font-family: Consolas,Menlo,monospace; color: #464d57;&quot;&gt;192.168.0.5&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px;&quot;&gt;&lt;span&gt;스마트폰&lt;/span&gt;&lt;span style=&quot;font-family: Consolas,Menlo,monospace; color: #464d57;&quot;&gt;192.168.0.6&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px;&quot;&gt;&lt;span&gt;TV&lt;/span&gt;&lt;span style=&quot;font-family: Consolas,Menlo,monospace; color: #464d57;&quot;&gt;192.168.0.7&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 12px; border: 1.5px solid #474a52; border-radius: 10px; background: #e7eaf1; color: #52565f; font-weight: bold; font-size: 14px;&quot;&gt;공유기
&lt;div style=&quot;font-size: 12px; font-weight: 400;&quot;&gt;NAT&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;color: #6b7280; font-size: 16px; margin-top: 4px;&quot;&gt;&amp;rarr;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; background: #fff;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .06em; color: #464d57; margin-bottom: 8px;&quot;&gt;인터넷 (WAN) &amp;middot; 공인 IP&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 16px; font-weight: bold;&quot;&gt;203.0.113.7&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 6px; line-height: 1.6;&quot;&gt;밖에서 보이는 우리 집 대표 주소 하나. 세 기기가 이 주소를 나눠 씁니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows는 &lt;code&gt;ipconfig /all&lt;/code&gt;, macOS는 &lt;code&gt;ifconfig&lt;/code&gt;를 터미널에 입력해보면 확인해볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ ifconfig en0

en0: flags=8863&amp;lt;UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST&amp;gt; mtu 1500
	ether ba:91:a7:**:**:**                    &amp;larr; MAC 주소 (물리)
	inet 192.168.219.46 netmask 0xffffff00     &amp;larr; 사설 IP (논리)
	status: active&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ether&lt;/code&gt; 옆이 랜카드에 박혀 있는 &lt;b&gt;MAC 주소&lt;/b&gt;이고, &lt;code&gt;inet&lt;/code&gt; 옆이 공유기가 나눠준 &lt;b&gt;사설 IP&lt;/b&gt;입니다. 이 사설 IP는 카페 와이파이로 옮기면 바뀌지만, MAC 주소는 그대로입니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;여기서 한 번 더&lt;/b&gt;: 이 상태로 브라우저에 &quot;what is my ip&quot;를 검색해 보세요. 거기 뜨는 숫자가 &lt;b&gt;공인 IP&lt;/b&gt;입니다. 터미널에서 본 &lt;code&gt;192.168.x.x&lt;/code&gt;와 값이 전혀 다를 텐데, 그 사이에서 변환을 해주고 있는 것이 바로 NAT입니다.&lt;/div&gt;
&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;사설 IP는 왜 하필 192.168로 시작할까?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;아무 주소나 내부용으로 쓰면 진짜 공인 IP와 겹치기 때문에, 처음부터 &lt;b&gt;내부 전용으로 떼어 둔 대역&lt;/b&gt;이 있습니다. &lt;code&gt;10.0.0.0&lt;/code&gt;~, &lt;code&gt;172.16.0.0&lt;/code&gt;~, &lt;code&gt;192.168.0.0&lt;/code&gt;~ 세 덩어리입니다.&lt;br /&gt;&lt;br /&gt;이 중 &lt;code&gt;192.168&lt;/code&gt;로 시작하는 대역이 가장 작아서 가정용 공유기 기본값으로 굳어졌습니다. 큰 회사나 학교에서는 기기가 많아 &lt;code&gt;10.&lt;/code&gt;으로 시작하는 대역을 쓰는 경우가 많습니다.&lt;/div&gt;
&lt;/details&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;URL 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;URL은 자원이 어디에 있는지를 알려주는 주소 규약입니다.&lt;/b&gt; 그냥 한 줄처럼 보이지만, 뜯어보면 다섯 조각으로 나뉩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 480px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 18px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;display: flex; align-items: flex-start; gap: 5px;&quot;&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 8px; border-radius: 7px; background: #e7eaf1; color: #52565f; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;https://&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; margin-top: 6px;&quot;&gt;프로토콜&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 8px; border-radius: 7px; background: #e7f2e9; color: #455148; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;www.example.com&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; margin-top: 6px;&quot;&gt;도메인&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 8px; border-radius: 7px; background: #f2eae6; color: #5f5651; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;:443&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; margin-top: 6px;&quot;&gt;포트&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 8px; border-radius: 7px; background: #e9e6f2; color: #55515f; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;/blog/post.html&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; margin-top: 6px;&quot;&gt;경로&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 0 0 auto; text-align: center;&quot;&gt;
&lt;div style=&quot;padding: 10px 8px; border-radius: 7px; background: #f1e6eb; color: #5f5157; font-weight: bold; font-family: Consolas,Menlo,monospace; font-size: 15px;&quot;&gt;?id=1&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; margin-top: 6px;&quot;&gt;쿼리&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&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;프로토콜&lt;/b&gt;: 어떤 방식으로 통신할지 (&lt;code&gt;http&lt;/code&gt; / &lt;code&gt;https&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도메인&lt;/b&gt;: 서버의 위치. DNS가 IP 주소로 바꿔 줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;포트&lt;/b&gt;: 서버의 어느 문으로 들어갈지. HTTP는 80, HTTPS는 443이 기본이라 보통 생략합니다.&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;key=value&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;맨 뒤의 쿼리, 즉 물음표(&lt;code&gt;?&lt;/code&gt;) 뒤에 붙는 부분이 GET 방식이 데이터를 실어 보내는 자리입니다. 뒤에서 한 번 더 다루겠습니다.&lt;/p&gt;
&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;포트 번호 80과 443은 누가 정한 걸까?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 16px; letter-spacing: 0px;&quot;&gt;포트 번호는 IANA라는 기관에서 등록&amp;middot;관리합니다. HTTP는 80번, HTTPS는 443번처럼 서비스별 기본 포트가 정해져 있어, 브라우저는 URL에 포트 번호가 없으면 이를 자동으로 사용합니다.&lt;/span&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다만 강제 규칙은 아니므로 서버는 다른 포트도 사용할 수 있습니다. 예: &lt;/span&gt;&lt;span&gt;&lt;a href=&quot;https://example.com:8443&quot;&gt;https://example.com:8443&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이름을 주소로 바꾸는 DNS&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 &lt;code&gt;naver.com&lt;/code&gt;을 기억하지 &lt;code&gt;223.130.192.247&lt;/code&gt;을 외우지 않습니다. 하지만 컴퓨터는 IP 주소로만 통신합니다. 이 간극을 메우는 것이 &lt;b&gt;DNS(Domain Name System)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DNS는 사람이 읽는 도메인 이름을 컴퓨터가 쓰는 IP 주소로 번역해 주는 시스템입니다.&lt;/b&gt; 브라우저는 URL에 접속하기 전, 먼저 DNS를 통해 해당 도메인의 IP 주소를 찾습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;nslookup&lt;/code&gt; 명령어로 직접 확인할 수 있습니다. Windows와 macOS 모두 같습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;$ nslookup naver.com

Server:		203.248.252.2
Address:	203.248.252.2#53

Non-authoritative answer:
Name:	naver.com
Address: 223.130.192.248
Name:	naver.com
Address: 223.130.200.219
Name:	naver.com
Address: 223.130.200.236
Name:	naver.com
Address: 223.130.192.247&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위쪽 &lt;code&gt;Server&lt;/code&gt;는 DNS 조회를 처리한 서버의 주소이고, 아래쪽 &lt;code&gt;Address&lt;/code&gt;는 &lt;code&gt;naver.com&lt;/code&gt;의 IP입니다. IP가 여러개인 이유는 트래픽을 분산하기 위해 서버를 여러 대 운영하기 때문이며, 접속을 할 때마다 다른 IP로 연결될 수 있습니다.&lt;/p&gt;
&lt;details style=&quot;margin: 56px 0;&quot;&gt;
&lt;summary style=&quot;cursor: pointer; font-size: 16px; font-weight: bold; color: #24292f; line-height: 1.6;&quot;&gt;&lt;span style=&quot;font-weight: 800; color: #1a1a1a; margin-right: 10px; padding: 0 3px; background: linear-gradient(transparent 58%,#ffe27a 58%);&quot;&gt;궁금!&lt;/span&gt;DNS 서버는 누가 관리할까?&lt;/summary&gt;
&lt;div style=&quot;margin-top: 12px; font-size: 15px; line-height: 1.8; color: #3d4450;&quot;&gt;최상위인 &lt;b&gt;루트 DNS&lt;/b&gt;는 ICANN이라는 국제기구가 총괄하고, 실제 서버는 전 세계 12개 기관이 나눠 운영합니다. 그 아래 &lt;code&gt;.com&lt;/code&gt;, &lt;code&gt;.kr&lt;/code&gt; 같은 최상위 도메인은 각각 담당 기관이 따로 있습니다. &lt;code&gt;.kr&lt;/code&gt;은 한국인터넷진흥원(KISA)이 맡고 있습니다.&lt;br /&gt;&lt;br /&gt;우리가 평소에 질의하는 것은 이 서버들이 아니라, 통신사가 자동으로 물려주는 DNS 서버입니다. 구글의 &lt;code&gt;8.8.8.8&lt;/code&gt;이나 클라우드플레어의 &lt;code&gt;1.1.1.1&lt;/code&gt;로 직접 바꿔 쓸 수도 있는데, 이걸 바꾸면 접속 속도가 달라지기도 합니다.&lt;/div&gt;
&lt;/details&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TCP와 UDP&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주소를 찾았다면 이제 데이터를 보낼 차례입니다. Transport 계층에는 성격이 다른 프로토콜 TCP와 UDP가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TCP&lt;/b&gt;는 &lt;b&gt;연결 지향적&lt;/b&gt;입니다. 데이터를 보내기 전에 양쪽이 먼저 연결을 확립하는 &lt;b&gt;3-Way Handshake&lt;/b&gt;를 거칩니다.&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 560px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;position: relative; height: 292px;&quot;&gt;
&lt;div style=&quot;position: absolute; left: 75px; top: 0px; width: 130px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;클라이언트&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 355px; top: 0px; width: 130px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;서버&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 139px; top: 42px; height: 208px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 419px; top: 42px; height: 208px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 100px; width: 280px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 79px; width: 280px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;SYN (연결 요청)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 152px; width: 280px; height: 0; border-top: 1.5px dashed #6b7280;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #6b7280;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 131px; width: 280px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;SYN + ACK (수락 응답 + 연결 요청)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 204px; width: 280px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 140px; top: 183px; width: 280px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;ACK (연결 확립)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 75px; top: 250px; width: 130px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;클라이언트&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 355px; top: 250px; width: 130px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;서버&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 12px; padding: 9px; border-radius: 8px; background: #e7f2e9; border: 1px solid #bbd9c1; color: #455148; text-align: center; font-size: 14px; font-weight: bold;&quot;&gt;연결 확립: 이제 데이터를 주고받습니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;TCP는 연결을 맺은 뒤 데이터 유실 시 재전송하고, 전달 순서도 보장합니다. 대신 확인 절차가 있어 UDP보다 느리고 오버헤드가 큽니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;반대로 &lt;/span&gt;&lt;b&gt;&lt;span&gt;UDP&lt;/span&gt;&lt;/b&gt;&lt;span&gt;는 비연결형입니다. 별도 합의 없이 데이터를 보내며, 수신 여부나 순서를 보장하지 않습니다. 그만큼 가볍고 빠르지만 데이터 유실이나 순서 변경은 직접 처리해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;TCP&lt;/th&gt;
&lt;th&gt;UDP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;연결&lt;/td&gt;
&lt;td&gt;연결 지향 (핸드셰이크 O)&lt;/td&gt;
&lt;td&gt;비연결형 (핸드셰이크 X)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;신뢰성&lt;/td&gt;
&lt;td&gt;높음 (재전송&amp;middot;순서 보장)&lt;/td&gt;
&lt;td&gt;낮음 (유실돼도 그대로)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;속도&lt;/td&gt;
&lt;td&gt;느리고 오버헤드 큼&lt;/td&gt;
&lt;td&gt;빠르고 가벼움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쓰는 곳&lt;/td&gt;
&lt;td&gt;웹(HTTP), 파일 전송(FTP), 이메일(SMTP)&lt;/td&gt;
&lt;td&gt;실시간 스트리밍, 온라인 게임&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;영상 통화에서 한 프레임이 유실되더라도 그냥 넘어가는 편이 낫습니다. 그 프레임 하나를 재전송받느라 통화가 1초 밀리면 더 불편하기 때문입니다. 이러한 실시간 통신에는 UDP가 적합한 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 내 컴퓨터의 네트워크 연결과 열린 포트는 다음 명령어로 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows는 &lt;code&gt;netstat -an&lt;/code&gt;, macOS는 &lt;code&gt;lsof -i TCP -P -n&lt;/code&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ lsof -i TCP -P -n

COMMAND     PID   FD   TYPE   NAME
rapportd    668   10u  IPv4   TCP *:62753 (LISTEN)
Google      921   45u  IPv4   TCP 192.168.219.46:51234-&amp;gt;142.250.76.100:443 (ESTABLISHED)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LISTEN&lt;/code&gt;은 그 포트를 열어두고 요청을 기다리는 상태입니다. &lt;code&gt;ESTABLISHED&lt;/code&gt;는 실제로 연결이 맺어져 데이터를 주고받는 상태입니다. 두 번째 줄은 내 사설 IP가 구글 서버의 &lt;code&gt;443&lt;/code&gt;번 포트에 연결된 것을 보여 줍니다, 즉 TCP의 3-Way Handshake가 끝난 상태입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTTP 요청과 응답&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DNS로 주소를 찾고, TCP로 연결도 맺었다면 이제 서버에 원하는 자원들 달라고 말할 차례입니다. 이때 사용하는 규칙이 바로 HTTP입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTTP는 브라우저와 서버가 자원(HTML, CSS, JS, 이미지)을 주고받기로 약속한 규칙입니다.&lt;/b&gt; 클라이언트가 &lt;b&gt;요청&lt;/b&gt;을 보내면 서버가 &lt;b&gt;응답&lt;/b&gt;을 돌려주는 구조입니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 12px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1px solid #bac3d8; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7eaf1; color: #52565f; padding: 9px 14px; font-weight: bold; font-size: 14px;&quot;&gt;요청 (Request) &amp;nbsp;클라이언트 &amp;rarr; 서버&lt;/div&gt;
&lt;div style=&quot;padding: 12px 14px;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Request Line&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; margin-bottom: 10px;&quot;&gt;GET /index.html HTTP/1.1&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Headers&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; margin-bottom: 10px;&quot;&gt;Host: example.com&lt;br /&gt;User-Agent: Mozilla/5.0&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Body&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; color: #6b7280;&quot;&gt;서버로 보낼 데이터 (GET은 보통 비어 있음)&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 250px; border: 1px solid #bbd9c1; border-radius: 12px; overflow: hidden;&quot;&gt;
&lt;div style=&quot;background: #e7f2e9; color: #455148; padding: 9px 14px; font-weight: bold; font-size: 14px;&quot;&gt;응답 (Response) &amp;nbsp;서버 &amp;rarr; 클라이언트&lt;/div&gt;
&lt;div style=&quot;padding: 12px 14px;&quot;&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Status Line&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; margin-bottom: 10px;&quot;&gt;HTTP/1.1 200 OK&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Headers&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px; margin-bottom: 10px;&quot;&gt;Content-Type: text/html&lt;br /&gt;Server: nginx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57;&quot;&gt;Body&lt;/div&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-size: 13px;&quot;&gt;&amp;lt;html&amp;gt;...&amp;lt;/html&amp;gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청과 응답 모두 &lt;b&gt;첫 줄 &amp;middot; 헤더 &amp;middot; 바디&lt;/b&gt;의 세 부분으로 되어 있습니다. 첫 줄에는 요청의 목적이나 응답 결과, 헤더에는 부가 정보, 바디에는 실제 데이터가 담깁니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메서드: 무엇을 하고 싶은지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 첫 줄의 메서드는 요청 목적을 나타냅니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;GET&lt;/th&gt;
&lt;th&gt;POST&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;목적&lt;/td&gt;
&lt;td&gt;자원을 &lt;b&gt;조회&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;자원을 &lt;b&gt;생성&amp;middot;수정&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 위치&lt;/td&gt;
&lt;td&gt;URL 뒤 쿼리스트링에 노출&lt;/td&gt;
&lt;td&gt;Body에 담김&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;예&lt;/td&gt;
&lt;td&gt;검색 결과 보기, 게시글 읽기&lt;/td&gt;
&lt;td&gt;회원가입, 글쓰기, 로그인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;이 외에 PUT, PATCH, DELETE, HEAD, OPTIONS 등이 있으며, REST API에서 자주 사용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태 코드: 결과가 어땠는지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 첫 줄에는 &lt;b&gt;상태 코드&lt;/b&gt;가 붙습니다. 앞자리 숫자만으로도 응답 결과를 대략 알 수 있습니다.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 8px; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;flex: 1 1 100px; border-radius: 10px; padding: 12px 10px; background: #f1eee7; border: 1px solid #d8cfba;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 18px; color: #514d46;&quot;&gt;1xx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 2px 0 4px;&quot;&gt;정보&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; line-height: 1.5;&quot;&gt;처리 중&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 100px; border-radius: 10px; padding: 12px 10px; background: #e7f2e9; border: 1px solid #bbd9c1;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 18px; color: #455148;&quot;&gt;2xx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 2px 0 4px;&quot;&gt;성공&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; line-height: 1.5;&quot;&gt;200 OK&lt;br /&gt;201 Created&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 100px; border-radius: 10px; padding: 12px 10px; background: #e7eaf1; border: 1px solid #bac3d8;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 18px; color: #52565f;&quot;&gt;3xx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 2px 0 4px;&quot;&gt;리다이렉션&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; line-height: 1.5;&quot;&gt;304 Not Modified&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 100px; border-radius: 10px; padding: 12px 10px; background: #f2eae6; border: 1px solid #d9c4b8;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 18px; color: #5f5651;&quot;&gt;4xx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 2px 0 4px;&quot;&gt;클라이언트 잘못&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; line-height: 1.5;&quot;&gt;400 Bad Request&lt;br /&gt;404 Not Found&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 100px; border-radius: 10px; padding: 12px 10px; background: #f1e6eb; border: 1px solid #d7b9c5;&quot;&gt;
&lt;div style=&quot;font-family: Consolas,Menlo,monospace; font-weight: 800; font-size: 18px; color: #5f5157;&quot;&gt;5xx&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; font-weight: bold; margin: 2px 0 4px;&quot;&gt;서버 잘못&lt;/div&gt;
&lt;div style=&quot;font-size: 12px; color: #464d57; line-height: 1.5;&quot;&gt;500 Internal&lt;br /&gt;Server Error&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;404와 500을 구분하면 디버깅이 빨라집니다.&lt;/b&gt; 404는 요청한 주소가 없다는 뜻이므로&amp;nbsp;&lt;b&gt;내가 보낸 URL&lt;/b&gt;을 확인하고, 500은 서버 내부의 오류이므로 &lt;b&gt;서버 로그&lt;/b&gt;를 봐야 합니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;웹 서버&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청을 받아 HTML&amp;middot;CSS&amp;middot;JS&amp;middot;이미지를 돌려주는 프로그램을 웹 서버라고 합니다. 대표적으로 다음 세 가지를 많이 사용합니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;웹 서버&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apache&lt;/td&gt;
&lt;td&gt;1995년 등장. 오픈소스이고 모든 운영체제에서 동작합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;td&gt;이벤트 기반 비동기 처리로 가볍고 빠릅니다. 지금 가장 많이 쓰입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft IIS&lt;/td&gt;
&lt;td&gt;Windows 전용. Windows 생태계에 최적화돼 있습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HTTP와 HTTPS&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTTPS는 HTTP에 SSL/TLS 암호화를 적용한 것&lt;/b&gt;입니다. 다만 모든 정보가 암호화되는 것은 아니므로, 무엇이 보호되고 드러나는지 알아둘 필요가 있습니다.&lt;/p&gt;
&lt;div style=&quot;border: 1px solid #dfc7d1; border-radius: 12px; overflow: hidden; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px; background: #f3eaee; border-bottom: 1px solid #dfc7d1;&quot;&gt;
&lt;div style=&quot;flex: 0 0 96px; font-weight: bold; font-size: 14px; color: #5c4e53;&quot;&gt;공개됨&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; font-size: 15px; color: #24292f;&quot;&gt;목적지 주소: &lt;b&gt;도메인과 IP&lt;/b&gt;
&lt;div style=&quot;font-size: 13px; color: #64575c; margin-top: 2px;&quot;&gt;어떤 사이트에 접속했는지는 드러납니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px; background: #d7b9c5;&quot;&gt;
&lt;div style=&quot;flex: 0 0 96px; font-weight: bold; font-size: 14px; color: #453b3f;&quot;&gt;암호화됨&lt;/div&gt;
&lt;div style=&quot;flex: 1 1 200px; font-size: 15px; color: #2b2225;&quot;&gt;요청&amp;middot;응답의 &lt;b&gt;헤더와 바디 전부&lt;/b&gt;
&lt;div style=&quot;font-size: 13px; color: #5a4e53; margin-top: 2px;&quot;&gt;그 안에서 무엇을 주고받았는지는 가려집니다&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘날 HTTPS는 웹의 기본 표준입니다. 적용하지 않으면 Chrome 같은 브라우저에서 &quot;안전하지 않음&quot; 경고가 표시될 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;엔터를 누르면 일어나는 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 처음의 질문으로 돌아가겠습니다. 주소창에 URL을 입력하고 엔터를 누르면, 브라우저는 다음 순서로 동작합니다.&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;overflow-x: auto; margin: 48px 0;&quot;&gt;
&lt;div style=&quot;min-width: 720px; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px 16px; background: #fff;&quot;&gt;
&lt;div style=&quot;position: relative; height: 560px;&quot;&gt;
&lt;div style=&quot;position: absolute; left: 20px; top: 0px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;사용자&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 190px; top: 0px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;브라우저&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 390px; top: 0px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;DNS 서버&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 580px; top: 0px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;웹 서버&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 79px; top: 42px; height: 463px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 249px; top: 42px; height: 463px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 449px; top: 42px; height: 463px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 639px; top: 42px; height: 463px; background: #c5c9f2; width: 2px;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 80px; top: 100px; width: 170px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 80px; top: 79px; width: 170px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;URL 입력 (www.example.com)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 146px; width: 200px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 125px; width: 200px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;DNS 요청 (www.example.com)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 192px; width: 200px; height: 0; border-top: 1.5px dashed #6b7280;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #6b7280;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 171px; width: 200px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;IP 주소 응답 (93.184.216.34)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 238px; width: 390px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 217px; width: 390px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;TCP 연결 수립 요청 (3-Way Handshake)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 284px; width: 390px; height: 0; border-top: 1.5px dashed #6b7280;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #6b7280;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 263px; width: 390px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;TCP 연결 수립 승인 (3-Way Handshake)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 330px; width: 390px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; right: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 309px; width: 390px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;HTTP 요청 전송 (GET / HTTP/1.1)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 376px; width: 390px; height: 0; border-top: 1.5px dashed #6b7280;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #6b7280;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 355px; width: 390px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;HTTP 응답 (HTML / CSS / JS)&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 400px; width: 44px; height: 28px; border-top: 1.5px solid #374151; border-right: 1.5px solid #374151; border-bottom: 1.5px solid #374151; border-radius: 0 8px 8px 0;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 250px; top: 423px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 60px; top: 398px; width: 184px; text-align: right; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;HTML / CSS / JS 렌더링&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 80px; top: 470px; width: 170px; height: 0; border-top: 1.5px solid #374151;&quot;&gt;
&lt;div style=&quot;position: absolute; left: -1px; top: -5px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 9px solid #374151;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 80px; top: 449px; width: 170px; text-align: center; font-size: 12.5px; line-height: 1.4; color: #24292f;&quot;&gt;웹 페이지 표시&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 20px; top: 505px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;사용자&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 190px; top: 505px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;브라우저&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 390px; top: 505px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;DNS 서버&lt;/div&gt;
&lt;div style=&quot;position: absolute; left: 580px; top: 505px; width: 120px; height: 42px; line-height: 42px; text-align: center; font-size: 14px; border: 1px solid #c5c9f2; border-radius: 6px; background: #eef1fd; font-weight: 600; color: #24292f;&quot;&gt;웹 서버&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;직접 확인&lt;/b&gt;: 크롬에서 &lt;b&gt;F12 &amp;rarr; Network 탭&lt;/b&gt;을 열고 아무 사이트나 들어가 보세요. 요청 하나를 클릭하면 지금까지 이야기한 Request Header, Response Header, 상태 코드를 확인할 수 있습니다.&amp;nbsp;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 상황에 따라 무엇을 고르면 되는지 정리하겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;TCP vs UDP&lt;/b&gt;: 정확한 전달이 중요하면 TCP, 실시간성이 중요하면 UDP.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HTTP vs HTTPS&lt;/b&gt;: 실제 서비스라면 HTTPS. 로그인&amp;middot;결제가 있다면 필수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GET vs POST&lt;/b&gt;: 조회는 GET, 값을 바꾸거나 생성&amp;middot;전송할 떄는 POST. ( 민감한 정보는 POST여부와 관계없이 반드시 HTTPS로 보호)&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: URL을 입력하면 &amp;rarr; &lt;b&gt;DNS&lt;/b&gt;가 IP를 찾고 &amp;rarr; &lt;b&gt;TCP&lt;/b&gt;가 연결을 맺고 &amp;rarr; &lt;b&gt;HTTP&lt;/b&gt;로 요청과 응답을 주고받아 &amp;rarr; 브라우저가 화면에 그립니다. 웹 통신은 이 다섯 단계입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MDN: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Overview&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP 개요&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MDN: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Glossary/DNS&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;DNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;W3Schools: &lt;a href=&quot;https://www.w3schools.com/tags/ref_httpmethods.asp&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Methods&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;roadmap.sh: &lt;a href=&quot;https://roadmap.sh/frontend-beginner&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Frontend Beginner Roadmap&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이렇게 받아온 HTML&amp;middot;CSS&amp;middot;JavaScript가 각각 무슨 일을 하는지 정리해보겠습니다.&lt;/p&gt;</description>
      <category>소프트웨어 개발/웹</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/7</guid>
      <comments>https://boradora1.tistory.com/7#entry7comment</comments>
      <pubDate>Sun, 26 Jul 2026 15:54:40 +0900</pubDate>
    </item>
    <item>
      <title>[개발 기초 정리] HTML&amp;middot;CSS&amp;middot;JavaScript</title>
      <link>https://boradora1.tistory.com/5</link>
      <description>&lt;p class=&quot;fs-date&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span class=&quot;label&quot;&gt;작업일&lt;/span&gt;&lt;b&gt;2026. 07&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;768&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rtK7k/dJMcajwmfGa/g40BBsGNBrkvhmXChq8cO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rtK7k/dJMcajwmfGa/g40BBsGNBrkvhmXChq8cO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rtK7k/dJMcajwmfGa/g40BBsGNBrkvhmXChq8cO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrtK7k%2FdJMcajwmfGa%2Fg40BBsGNBrkvhmXChq8cO1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;768&quot; height=&quot;512&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;fs-chips&quot;&gt;&lt;span class=&quot;fs-chip&quot;&gt;HTML&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;CSS&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;JavaScript&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;박스 모델&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;Flexbox / Grid&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;DOM&lt;/span&gt; &lt;span class=&quot;fs-chip&quot;&gt;이벤트&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;fs-lead&quot; data-ke-size=&quot;size16&quot;&gt;웹페이지는 HTML, CSS, JavaScript가 함께 구성합니다. HTML은 문서 구조를 만들고, CSS는 화면 표현을 정의하며, JavaScript는 사용자 입력과 상태 변화에 대응합니다. 프론트엔드의 기본 구성을 정리했습니다.&lt;/p&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄로 먼저&lt;/b&gt;: &lt;b&gt;HTML&lt;/b&gt;은 문서 구조, &lt;b&gt;CSS&lt;/b&gt;는 화면 표현, &lt;b&gt;JavaScript&lt;/b&gt;는 동작과 상태 처리를 담당합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 개발을 시작하면 이 셋을 거의 동시에 배우게 됩니다. 그래서 &lt;i&gt;각 기술의 역할 범위&lt;/i&gt;가 헷갈리기 쉽습니다. 역할을 구분해두면 React 같은 프레임워크를 배울 때도 도움이 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML: 문서의 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTML(HyperText Markup Language)은 태그로 문서의 구조를 정의하는 마크업 언어입니다.&lt;/b&gt; 프로그래밍 언어가 아니라 &quot;이건 제목이고 저건 문단이다&quot;라고 &lt;i&gt;표시(markup)&lt;/i&gt;해주는 언어입니다. 브라우저는 이 표시를 읽어 화면을 그립니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요소와 속성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML의 기본 단위는 &lt;b&gt;요소(Element)&lt;/b&gt;입니다. 시작 태그, 내용, 끝 태그로 이루어집니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;h1&amp;gt;가장 큰 제목&amp;lt;/h1&amp;gt;
&amp;lt;p&amp;gt;평범한 문단입니다.&amp;lt;/p&amp;gt;
&amp;lt;br&amp;gt;                    &amp;lt;!-- 내용이 없는 요소는 끝 태그도 없습니다 --&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소 안에 요소를 &lt;b&gt;중첩&lt;/b&gt;할 수 있고, &lt;b&gt;속성(Attribute)&lt;/b&gt;으로 추가 정보를 줍니다. 속성은 항상 시작 태그 안에 들어갑니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;a href=&quot;https://example.com&quot;&amp;gt;링크&amp;lt;/a&amp;gt;
&amp;lt;img src=&quot;cat.jpg&quot; alt=&quot;고양이 사진&quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;alt&lt;/code&gt;는 이미지를 표시할 수 없을 때 대체로 제공할 텍스트입니다. 스크린 리더도 이 값을 읽으므로 접근성과 SEO에 영향을 줍니다. 의미 있는 이미지는 작성하는 것이 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;id와 class&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;속성&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;개수&lt;/th&gt;
&lt;th&gt;주 용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;고유 식별자&lt;/td&gt;
&lt;td&gt;문서에 &lt;b&gt;하나만&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;특정 요소를 콕 집을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;공유 가능한 이름&lt;/td&gt;
&lt;td&gt;&lt;b&gt;여러 개&lt;/b&gt; 가능&lt;/td&gt;
&lt;td&gt;같은 스타일을 묶을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문서의 기본 뼈대&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;              &amp;lt;!-- HTML5 문서라는 선언 --&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
  &amp;lt;head&amp;gt;                     &amp;lt;!-- 화면에 안 보이는 문서 정보 --&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;title&amp;gt;페이지 제목&amp;lt;/title&amp;gt;   &amp;lt;!-- 브라우저 탭에 뜨는 이름 --&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;                     &amp;lt;!-- 실제로 화면에 보이는 내용 --&amp;gt;
    &amp;lt;h1&amp;gt;안녕하세요&amp;lt;/h1&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할별 태그&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;분류&lt;/th&gt;
&lt;th&gt;대표 태그&lt;/th&gt;
&lt;th&gt;쓰임&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;텍스트&lt;/td&gt;
&lt;td&gt;&lt;code&gt;h1~h6&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;span&lt;/code&gt;, &lt;code&gt;strong&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;제목&amp;middot;문단&amp;middot;강조&amp;middot;링크&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;목록&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ul&lt;/code&gt;, &lt;code&gt;ol&lt;/code&gt;, &lt;code&gt;li&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;순서 없는/있는 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;표&lt;/td&gt;
&lt;td&gt;&lt;code&gt;table&lt;/code&gt;, &lt;code&gt;thead&lt;/code&gt;, &lt;code&gt;tbody&lt;/code&gt;, &lt;code&gt;tr&lt;/code&gt;, &lt;code&gt;th&lt;/code&gt;, &lt;code&gt;td&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;행과 열 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;폼&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;사용자 입력받기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;미디어&lt;/td&gt;
&lt;td&gt;&lt;code&gt;img&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이미지&amp;middot;영상&amp;middot;소리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시맨틱&lt;/td&gt;
&lt;td&gt;&lt;code&gt;header&lt;/code&gt;, &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;section&lt;/code&gt;, &lt;code&gt;footer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;의미를 가진 구획&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;시맨틱 태그를 쓰는 이유&lt;/b&gt;: &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;만으로도 화면은 구성할 수 있습니다. 다만 &lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;를 사용하면 검색엔진과 스크린 리더가 콘텐츠의 역할을 더 정확히 해석할 수 있습니다. &lt;b&gt;화면은 같아도 문서의 의미 정보가 달라집니다.&lt;/b&gt;&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Block과 Inline&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소마다 기본 배치 방식이 있습니다. 이를 알아야 &lt;code&gt;width&lt;/code&gt;와 &lt;code&gt;height&lt;/code&gt;의 적용 결과를 예측할 수 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Block 요소&lt;/th&gt;
&lt;th&gt;Inline 요소&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;배치&lt;/td&gt;
&lt;td&gt;혼자 한 줄 차지 (세로로 쌓임)&lt;/td&gt;
&lt;td&gt;다른 요소와 한 줄에 나란히&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크기&lt;/td&gt;
&lt;td&gt;가로 폭을 최대로&lt;/td&gt;
&lt;td&gt;내용 크기만큼만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;width / height&lt;/td&gt;
&lt;td&gt;적용됨&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무시됨&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;예&lt;/td&gt;
&lt;td&gt;&lt;code&gt;div&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;h1&lt;/code&gt;, &lt;code&gt;ul&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;span&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;strong&lt;/code&gt;, &lt;code&gt;img&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;주의&lt;/b&gt;: &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; 같은 inline 요소에는 &lt;code&gt;width: 200px&lt;/code&gt;이 적용되지 않습니다. 크기를 지정하려면 &lt;code&gt;display: block&lt;/code&gt; 또는 &lt;code&gt;inline-block&lt;/code&gt;으로 변경해야 합니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS: 보이는 모습&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CSS(Cascading Style Sheets)는 HTML 요소를 화면에 어떻게 보여줄지 정의하는 스타일시트 언어입니다.&lt;/b&gt; 색, 글꼴, 간격, 위치, 레이아웃이 전부 CSS의 영역입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문법과 적용 방법&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;p {                    /* 선택자: 누구에게 */
  color: red;          /* 속성: 값 */
  text-align: center;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용 방법은 세 가지입니다. 일반적으로는 스타일과 구조를 분리하기 위해 &lt;b&gt;외부 파일(External)&lt;/b&gt; 방식을 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;   &amp;lt;!-- 권장 --&amp;gt;
&amp;lt;style&amp;gt; p { color: red; } &amp;lt;/style&amp;gt;         &amp;lt;!-- 문서 안에 --&amp;gt;
&amp;lt;p style=&quot;color:red;&quot;&amp;gt;&amp;hellip;&amp;lt;/p&amp;gt;                &amp;lt;!-- 태그에 직접, 지양 --&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;선택자: 누구에게 스타일을 줄까&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택자&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;요소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;모든 p 태그&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#header { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id가 header인 요소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;class&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.menu { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;class가 menu인 요소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자식&lt;/td&gt;
&lt;td&gt;&lt;code&gt;div &amp;gt; p { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;div의 &lt;b&gt;바로 아래&lt;/b&gt; p&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;후손&lt;/td&gt;
&lt;td&gt;&lt;code&gt;div p { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;div 안의 &lt;b&gt;모든&lt;/b&gt; p&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;의사 클래스&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a:hover { }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;마우스를 올린 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;우선순위: 스타일이 안 먹을 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 규칙이 같은 요소를 가리키면 충돌합니다. 이때 &lt;b&gt;명시도(Specificity)가 높은 쪽이 이깁니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;인라인 스타일  &amp;gt;  id  &amp;gt;  class &amp;middot; 속성 &amp;middot; 의사클래스  &amp;gt;  요소&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;명시도가 높은 규칙이 우선합니다. &lt;b&gt;class 선택자 하나는 요소 선택자 여러 개보다 우선합니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;명시도가 같으면 &lt;b&gt;나중에 작성한 규칙&lt;/b&gt;이 적용됩니다. 이것이 &quot;Cascading&quot;의 의미입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;&lt;code&gt;!important&lt;/code&gt;는 예외적으로만 사용합니다.&lt;/b&gt; 우선순위 문제를 임시로 덮을 수 있어 유지보수가 어려워집니다. &lt;b&gt;먼저 선택자와 적용 순서를 확인하는 편이 좋습니다.&lt;/b&gt;&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;박스 모델&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS 레이아웃의 기본 개념입니다. 모든 HTML 요소는 네 영역으로 구성됩니다.&lt;/p&gt;
&lt;div style=&quot;margin: 48px 0; padding: 22px; border-radius: 14px; background: #f2eae6; border: 1px solid #d9c4b8;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .08em; color: #5f5651; margin-bottom: 10px;&quot;&gt;margin &amp;nbsp;바깥 간격&lt;/div&gt;
&lt;div style=&quot;padding: 20px; border: 2px solid #52565f; border-radius: 10px; background: #e7eaf1;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .08em; color: #52565f; margin-bottom: 10px;&quot;&gt;border &amp;nbsp;테두리&lt;/div&gt;
&lt;div style=&quot;padding: 20px; border-radius: 8px; background: #e7f2e9; border: 1px solid #bbd9c1;&quot;&gt;
&lt;div style=&quot;font-size: 12px; letter-spacing: .08em; color: #455148; margin-bottom: 10px;&quot;&gt;padding &amp;nbsp;안쪽 여백&lt;/div&gt;
&lt;div style=&quot;padding: 24px 16px; border-radius: 6px; background: #fff; border: 1px solid #d1d5db; text-align: center;&quot;&gt;
&lt;div style=&quot;font-weight: bold; font-size: 16px; color: #24292f;&quot;&gt;content&lt;/div&gt;
&lt;div style=&quot;font-size: 13px; color: #464d57; margin-top: 4px;&quot;&gt;실제 내용이 들어가는 자리&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;margin-top: 14px; padding-top: 12px; border-top: 1px dashed #d9c4b8; font-size: 13px; color: #5f5651; text-align: center;&quot;&gt;&lt;code style=&quot;background: none; padding: 0; color: #5f5651;&quot;&gt;width&lt;/code&gt; 는 맨 안쪽 content 의 너비만 가리킵니다&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;fs-callout warn&quot;&gt;&lt;b&gt;계산 시 주의&lt;/b&gt;: 기본값에서 &lt;code&gt;width&lt;/code&gt;는 &lt;b&gt;content 영역만&lt;/b&gt;의 너비입니다. &lt;code&gt;width: 300px; padding: 25px;&lt;/code&gt;이면 실제 차지하는 너비는 &lt;code&gt;300 + 25&amp;times;2 = 350px&lt;/code&gt;가 됩니다. &lt;br /&gt;&lt;br /&gt;보통은 &lt;code&gt;box-sizing: border-box;&lt;/code&gt;를 사용합니다. padding과 border를 width에 포함하므로 지정한 값으로 전체 너비를 관리할 수 있습니다.&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;position: 위치 잡기&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;동작&lt;/th&gt;
&lt;th&gt;자주 쓰는 곳&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;static&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;기본값. 문서 흐름 그대로&lt;/td&gt;
&lt;td&gt;거의 지정하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;relative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;원래 위치 기준으로 살짝 이동 (자리는 유지)&lt;/td&gt;
&lt;td&gt;미세 조정, absolute의 기준점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;absolute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가장 가까운 위치 지정 조상 기준 (문서 흐름에서 빠짐)&lt;/td&gt;
&lt;td&gt;배지, 툴팁, 닫기 버튼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fixed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;화면 기준 고정 (스크롤해도 안 움직임)&lt;/td&gt;
&lt;td&gt;플로팅 버튼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sticky&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;스크롤하다 특정 지점에서 고정&lt;/td&gt;
&lt;td&gt;상단 헤더, 목차&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Flexbox와 Grid&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거에는 &lt;code&gt;float&lt;/code&gt;로 레이아웃을 구성했지만, 현재는 Flexbox와 Grid를 주로 사용합니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Flexbox&lt;/th&gt;
&lt;th&gt;Grid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;차원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1차원&lt;/b&gt; (가로 &lt;i&gt;또는&lt;/i&gt; 세로)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2차원&lt;/b&gt; (행 &lt;i&gt;과&lt;/i&gt; 열)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;잘하는 것&lt;/td&gt;
&lt;td&gt;한 줄 안에서 정렬&amp;middot;간격 배분&lt;/td&gt;
&lt;td&gt;페이지 전체를 격자로 나누기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쓰는 곳&lt;/td&gt;
&lt;td&gt;내비게이션 바, 카드 안 요소 정렬&lt;/td&gt;
&lt;td&gt;전체 레이아웃, 갤러리, 대시보드&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* Flexbox: 가운데 정렬 */
.container {
  display: flex;
  justify-content: center;   /* 메인 축(가로) 정렬 */
  align-items: center;       /* 교차 축(세로) 정렬 */
}

/* Grid: 2열 레이아웃 */
.layout {
  display: grid;
  grid-template-columns: 1fr 2fr;   /* 1:2 비율로 두 열 */
  gap: 20px;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;둘은 함께 사용할 수 있습니다. 예를 들어 &lt;b&gt;페이지 레이아웃에는 Grid, 내부 요소 정렬에는 Flexbox&lt;/b&gt;를 사용할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript: 페이지의 동작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JavaScript는 웹페이지의 동작과 로직을 구현하는 프로그래밍 언어입니다.&lt;/b&gt; HTML 콘텐츠와 CSS 스타일을 변경하고, 사용자 입력에 반응할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 세 가지 성격을 알아두면 쉽게 이해할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&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;i&gt;비동기&lt;/i&gt;가 중요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변수: var, let, const&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;키워드&lt;/th&gt;
&lt;th&gt;스코프&lt;/th&gt;
&lt;th&gt;재선언&lt;/th&gt;
&lt;th&gt;재할당&lt;/th&gt;
&lt;th&gt;지금 쓰나?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;var&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;함수&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;❌ 쓰지 않습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;let&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;블록 &lt;code&gt;{ }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;⭕ 값이 바뀔 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;const&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;블록 &lt;code&gt;{ }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;⭕ &lt;b&gt;기본값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;권장 방식&lt;/b&gt;: 기본적으로 &lt;code&gt;const&lt;/code&gt;를 사용하고, 재할당이 필요할 때만 &lt;code&gt;let&lt;/code&gt;을 사용합니다. 새 코드에서는 &lt;code&gt;var&lt;/code&gt;를 피하는 편이 좋습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;호이스팅(Hoisting)&lt;/b&gt;은 JS가 변수 선언을 스코프 맨 위로 끌어올리는 동작입니다. &lt;code&gt;var&lt;/code&gt;는 값이 없는 채로 올라가서 선언 전에 접근해도 에러 대신 &lt;code&gt;undefined&lt;/code&gt;가 나옵니다. 이게 조용한 버그의 원인이 되며,&amp;nbsp;&lt;code&gt;let&lt;/code&gt;&amp;middot;&lt;code&gt;const&lt;/code&gt;가 권장되는 이유 중 하나입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터 타입&lt;/h3&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;원시형 (Primitive)&lt;/th&gt;
&lt;th&gt;객체형 (Object)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;종류&lt;/td&gt;
&lt;td&gt;number, string, boolean, null, undefined&lt;/td&gt;
&lt;td&gt;object, array, function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장 방식&lt;/td&gt;
&lt;td&gt;값 자체를 저장&lt;/td&gt;
&lt;td&gt;값이 있는 &lt;b&gt;주소&lt;/b&gt;를 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;변경&lt;/td&gt;
&lt;td&gt;불변&lt;/td&gt;
&lt;td&gt;가변&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;객체를 복사한 뒤 원본 변경이 함께 반영되는 문제가 생길 수 있습니다. 객체 변수는 값 자체가 아니라 참조를 복사하기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DOM: HTML을 객체로 다루기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DOM(Document Object Model)은 브라우저가 HTML 문서를 트리 구조의 객체로 표현한 것입니다.&lt;/b&gt; JavaScript는 DOM을 통해 페이지 콘텐츠와 상태를 변경합니다. 즉 JavaScript는 HTML 소스 자체가 아니라 브라우저가 구성한 DOM을 조작합니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// ① 요소를 찾고
const el = document.getElementById(&quot;demo&quot;);

// ② 내용을 바꾸고
el.innerHTML = &quot;안녕하세요&quot;;

// ③ 스타일도 바꿉니다
el.style.color = &quot;red&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소를 찾는 방법은 여러 가지입니다. CSS 선택자를 사용할 수 있는 &lt;code&gt;querySelector()&lt;/code&gt;가 자주 쓰입니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;document.getElementById(&quot;demo&quot;);      // id로
document.querySelector(&quot;.menu&quot;);      // CSS 선택자로 (첫 번째 하나)
document.querySelectorAll(&quot;.menu&quot;);   // CSS 선택자로 (전부)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이벤트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클릭, 입력, 스크롤처럼 페이지에서 발생하는 일이 &lt;b&gt;이벤트&lt;/b&gt;입니다. 이벤트 등록에는 여러 방법이 있지만, 일반적으로 &lt;b&gt;&lt;code&gt;addEventListener&lt;/code&gt;&lt;/b&gt;를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 비권장: HTML에 JS를 섞으면 관리가 안 됩니다 --&amp;gt;
&amp;lt;button onclick=&quot;doSomething()&quot;&amp;gt;클릭&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 권장: 핸들러를 여러 개 달 수 있고, HTML과 완전히 분리됩니다
const btn = document.getElementById(&quot;myBtn&quot;);

btn.addEventListener(&quot;click&quot;, () =&amp;gt; {
  document.getElementById(&quot;demo&quot;).innerHTML = &quot;눌렸습니다!&quot;;
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이벤트 전파&lt;/b&gt;: &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; 안의 &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;을 클릭하면 이벤트는 요소 계층을 따라 전달됩니다. 전달 방향에는 두 가지가 있습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;버블링 (Bubbling)&lt;/th&gt;
&lt;th&gt;캡처링 (Capturing)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방향&lt;/td&gt;
&lt;td&gt;자식 &amp;rarr; 부모 (안에서 밖으로)&lt;/td&gt;
&lt;td&gt;부모 &amp;rarr; 자식 (밖에서 안으로)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정&lt;/td&gt;
&lt;td&gt;&lt;b&gt;기본값&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;addEventListener(t, fn, true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;활용도&lt;/td&gt;
&lt;td&gt;높음 (이벤트 위임의 원리)&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;code&gt;event.preventDefault()&lt;/code&gt;: 태그의 기본 동작을 막습니다. &lt;b&gt;폼 제출 시 새로고침되는 걸 막을 때&lt;/b&gt; 거의 항상 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event.stopPropagation()&lt;/code&gt;: 부모로 전파되는 걸 중단합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비동기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript는 싱글 스레드로 실행됩니다. 네트워크 요청처럼 시간이 걸리는 작업을 동기적으로 처리하면 UI 응답이 지연될 수 있습니다. 이런 작업을 기다리는 동안 다른 코드를 실행하도록 처리하는 방식이 &lt;b&gt;비동기&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 처리 방식은 다음과 같이 발전해 왔습니다.&lt;/p&gt;
&lt;table class=&quot;fs-table&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;세대&lt;/th&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1세대&lt;/td&gt;
&lt;td&gt;Callback&lt;/td&gt;
&lt;td&gt;중첩이 깊어지면 코드 흐름을 파악하기 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2세대 (ES6)&lt;/td&gt;
&lt;td&gt;Promise&lt;/td&gt;
&lt;td&gt;상태를 객체로 만들어 &lt;code&gt;.then()&lt;/code&gt; / &lt;code&gt;.catch()&lt;/code&gt;로 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3세대 (ES8)&lt;/td&gt;
&lt;td&gt;async / await&lt;/td&gt;
&lt;td&gt;비동기 코드를 동기 코드처럼 읽을 수 있음. &lt;b&gt;현재 널리 사용&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Promise&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;Pending&lt;/b&gt;: 아직 진행 중&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Fulfilled&lt;/b&gt;: 성공, 결과값이 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Rejected&lt;/b&gt;: 실패, 에러가 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// async/await: Promise를 훨씬 읽기 쉽게 쓴 것
async function getData() {
  const res  = await fetch(&quot;https://api.example.com/data&quot;);
  const data = await res.json();
  console.log(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;셋이 함께 일하는 모습&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼을 클릭하면 문구가 바뀌는 예제입니다. HTML, CSS, JavaScript가 각각 무엇을 처리하는지 확인할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- HTML : 무엇이 있는가 --&amp;gt;
&amp;lt;button id=&quot;btn&quot;&amp;gt;클릭&amp;lt;/button&amp;gt;
&amp;lt;p id=&quot;msg&quot; class=&quot;greeting&quot;&amp;gt;안녕하세요&amp;lt;/p&amp;gt;

&amp;lt;!-- CSS : 어떻게 보이는가 --&amp;gt;
&amp;lt;style&amp;gt;
  .greeting { color: navy; font-size: 20px; }
&amp;lt;/style&amp;gt;

&amp;lt;!-- JavaScript : 어떻게 움직이는가 --&amp;gt;
&amp;lt;script&amp;gt;
  document.getElementById(&quot;btn&quot;).addEventListener(&quot;click&quot;, () =&amp;gt; {
    document.getElementById(&quot;msg&quot;).innerHTML = &quot;반갑습니다!&quot;;
  });
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML이 버튼과 문단을 만들고, CSS가 색과 크기를 정의하며, JavaScript가 클릭 이벤트에 따라 내용을 변경합니다. &lt;b&gt;세 파일로 분리해도 동일하게 동작합니다.&lt;/b&gt; 역할을 분리하면 코드 관리가 쉬워집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;div class=&quot;fs-callout&quot;&gt;&lt;b&gt;한 줄 정리&lt;/b&gt;: &lt;b&gt;HTML은 구조, CSS는 표현, JavaScript는 동작과 로직을 담당합니다.&lt;/b&gt; 역할을 분리하는 것이 유지보수 가능한 프론트엔드 코드의 기본입니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모든 태그와 속성을 외울 필요는 없습니다. 문법은 필요할 때 문서를 참고하면 됩니다. 대신 &lt;b&gt;block과 inline의 차이&lt;/b&gt;, &lt;b&gt;박스 모델의 계산 방식&lt;/b&gt;, &lt;b&gt;DOM의 개념&lt;/b&gt;은 반드시 이해해두는 것이 좋습니다. 이 세 가지를 기준으로 나머지 개념을 확장해 나갈 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MDN Web Docs: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTML&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTML&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/CSS&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CSS&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;JavaScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3schools.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;W3Schools&lt;/a&gt;: 예제를 바로 돌려볼 수 있어 처음 배울 때 좋습니다&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://roadmap.sh/frontend&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;roadmap.sh: Frontend&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;fs-note&quot; data-ke-size=&quot;size16&quot;&gt;앞 글에서는 이 파일들이 서버에서 브라우저까지 어떻게 도착하는지(HTTP&amp;middot;DNS&amp;middot;TCP/IP) 정리했습니다.&lt;/p&gt;</description>
      <category>소프트웨어 개발/웹</category>
      <author>boradora</author>
      <guid isPermaLink="true">https://boradora1.tistory.com/5</guid>
      <comments>https://boradora1.tistory.com/5#entry5comment</comments>
      <pubDate>Thu, 23 Jul 2026 09:45:24 +0900</pubDate>
    </item>
  </channel>
</rss>