<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>REFUGE HUB</title>
    <link>https://story86025.tistory.com/</link>
    <description>복잡한 코딩의 세계에서 길을 잃지 않고, AI(Codex,Gemini,Cluade)와 함께 차근차근 결과물을 만들어내는 '든든한 베이스캠프'</description>
    <language>ko</language>
    <pubDate>Sun, 24 May 2026 07:23:32 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>이음(Ieum)</managingEditor>
    <image>
      <title>REFUGE HUB</title>
      <url>https://tistory1.daumcdn.net/tistory/8581762/attach/2173d0a8e4824e33aa81a3fbec2a8dd0</url>
      <link>https://story86025.tistory.com</link>
    </image>
    <item>
      <title>30. 인터넷이 끊겨도 앱은 어떻게 버텨야 할까: 큐와 동기화 입문</title>
      <link>https://story86025.tistory.com/60</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편은 1차 연재의 마지막 편이다. 여기까지 오면서 우리는 바이브코딩으로 체크리스트 앱을 만들 때, 단순히 화면을 만드는 것을 넘어 저장, 수정, 삭제, 재정렬, 자동 저장, 서버 응답 반영, 임시 항목, retry 같은 흐름까지 조금씩 다뤄왔다. 마지막에 이 이야기를 오프라인과 동기화로 묶는 이유는 단순하다. 앞에서 배운 개념들이 결국 여기에서 한 번 더 만나기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 &quot;오프라인&quot;이라는 말이 갑자기 크게 느껴질 수 있다. 하지만 조금 더 현실적으로 보면, 꼭 비행기 모드나 지하철 안 같은 극단적인 상황만 뜻하는 것은 아니다. 네트워크가 잠깐 흔들리거나, 서버 응답이 느려지거나, 저장이 바로 안 되는 순간도 모두 비슷한 감각으로 이어진다. 즉, 오프라인과 동기화는 특별한 고급 기능이라기보다 &lt;b&gt;앱이 흔들릴 때 무엇을 지금 처리하고 무엇을 나중으로 미룰지 정하는 문제&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 마지막 정리라는 마음으로, 지금까지 만들었던 흐름이 왜 큐와 동기화라는 개념으로 모이게 되는지, &quot;지금 화면에 먼저 반영할 것&quot;과 &quot;나중에 서버에 보낼 것&quot;은 어떻게 나눠서 봐야 하는지, 그리고 초보자라면 가장 단순한 첫 큐 구조를 어떻게 잡는 편이 좋은지를 차근차근 정리해보겠다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;인터넷이 잠깐 끊기면 저장이 전부 멈춘 것처럼 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 처리할 로컬 변경과 나중에 보낼 서버 작업을 분리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;즉시 반영할 것과 보류할 것을 먼저 나눈다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;네트워크가 돌아오면 무엇부터 다시 보내야 할지 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패한 작업 목록을 따로 들고 있지 않다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;큐라는 개념으로 미뤄진 작업을 관리하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다시 연결되자마자 상태가 더 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;큐를 순서 없이 보내거나, 이미 처리한 작업과 새 작업을 구분하지 못한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 순서를 지키는 단순한 큐부터 시작한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오프라인 대응을 붙였더니 코드가 갑자기 무거워졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo 데이터, UI 상태, 대기 작업을 한 덩어리로 보고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터, 화면 상태, 큐를 서로 다른 층위로 분리해서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;오프라인 대응의 핵심은 &quot;인터넷이 없어도 전부 다 하겠다&quot;가 아니라, &quot;지금 할 일&quot;과 &quot;나중에 동기화할 일&quot;을 구분해서 앱이 덜 흔들리게 만드는 데 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 마지막 단계에서 오프라인과 동기화를 봐야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 연재를 따라왔다면 이미 여러 번 비슷한 문제를 봤다. 저장 중 상태를 잠가야 했고, 실패 시 rollback도 생각해야 했고, 오래된 응답이 최신 화면을 덮어쓰지 못하게 requestId도 봤고, 낙관적 생성에서는 tmp id와 진짜 id를 연결해야 했다. 이 모든 문제는 겉으로는 조금씩 달라 보여도 공통 질문이 하나 있다. &lt;b&gt;&quot;지금 이 변화는 이미 확정된 것인가, 아니면 아직 어딘가에 전달되어야 하는가?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오프라인과 동기화는 바로 이 질문을 더 분명하게 보게 만든다. 인터넷이 불안정하거나 잠깐 끊기면, 어떤 변화는 지금 화면에 먼저 반영할 수 있고 어떤 변화는 서버에 나중에 보내야 한다. 이 구분이 없으면 사용자는 방금 체크한 항목이 정말 저장된 것인지, 잠깐 화면에서만 바뀐 것인지 알 수 없고, 개발자도 무엇을 먼저 보내야 할지 금방 헷갈리게 된다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;지금까지 다룬 개념이 왜 오프라인 단계에서 다시 중요해질까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;앞에서 다룬 개념&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;오프라인 단계에서 다시 중요한 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 화면에는 먼저 보여주되, 나중에 서버와 맞춰야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패한 작업을 그냥 버리지 않고 다시 보낼지 정해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;requestId와 최신 응답&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;뒤늦게 온 결과가 현재 상태를 망치지 않게 해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목과 tmp id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 서버가 모르는 항목을 화면에 먼저 둘 수 있기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 오프라인 대응은 완전히 새로운 주제가 아니라, 지금까지 쌓인 개념이 한 번 더 모여서 &quot;지금 반영할 것&quot;과 &quot;나중에 맞출 것&quot;을 구분하는 단계라고 보는 편이 훨씬 정확하다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;오프라인 대응은 거창한 별도 기능이라기보다, 지금까지 만든 저장 흐름에 &quot;나중에 동기화&quot;라는 시간이 하나 더 붙는 단계에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;지금 반영할 것&quot;과 &quot;나중에 보낼 것&quot;을 나누는 감각&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 가장 중요한 출발점은 이것이다. 앱 안에는 서로 다른 종류의 정보가 있다. 어떤 것은 지금 당장 화면에 보여주기 위해 반영해도 되고, 어떤 것은 서버가 살아나면 그때 보내도 된다. 이 둘을 구분하지 않으면 오프라인 대응은 거의 항상 과하게 복잡해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크리스트 앱으로 보면 보통 아래처럼 생각할 수 있다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;&quot;지금 반영&quot;과 &quot;나중 동기화&quot;를 나눠보면 이런 느낌이다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;지금 반영할 수 있는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;나중에 서버로 보내도 되는 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크 박스가 눌린 화면 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;done 변경 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에 임시 항목을 먼저 보여주기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 항목 생성 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬된 화면 순서&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;order 변경 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;화면에 보인 것 = 서버도 이미 안다&quot;라고 가정하지 않는 것이다. 예를 들어 사용자가 체크를 바꾸는 즉시 UI에서 완료 표시를 바꿔줄 수는 있다. 하지만 네트워크가 끊겨 있으면 서버는 아직 그 사실을 모른다. 그래서 앱은 &quot;지금 화면엔 바뀌어 있지만, 아직 서버로는 못 보낸 작업&quot;을 따로 기억하고 있어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 감각이 생기면 오프라인 대응도 갑자기 덜 막막해진다. 모든 걸 완벽하게 실시간으로 맞추려 하기보다, &lt;b&gt;로컬에서 먼저 반영하고 나중에 서버로 보낼 작업을 모아두는 구조&lt;/b&gt;로 보이기 때문이다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;오프라인 대응의 첫걸음은 &quot;모든 걸 지금 서버와 맞추겠다&quot;가 아니라 &quot;로컬 상태와 서버로 보낼 작업을 분리해서 들고 가겠다&quot;에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;큐는 정확히 무엇이고 왜 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 여기서 큐라는 개념이 등장한다. 큐는 어렵게 말하면 &quot;나중에 처리할 작업을 순서대로 모아두는 줄&quot;에 가깝다. 지금은 바로 서버로 보내지 못하거나, 보내도 확정까지 기다려야 하는 작업들을 잠시 쌓아두는 곳이라고 보면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 오프라인 상태에서 사용자가 할 일을 하나 추가하고, 기존 항목을 완료 처리하고, 또 다른 항목의 text를 수정했다고 해보자. 이 세 동작은 모두 화면에는 반영될 수 있지만, 서버는 아직 모를 수 있다. 그러면 앱은 이 동작들을 큐에 순서대로 넣어두고, 연결이 돌아왔을 때 하나씩 보낼 수 있어야 한다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;{

id: &quot;q-1710000000001&quot;,
type: &quot;create&quot;,
todoId: &quot;tmp-1710000000001&quot;,
payload: {
text: &quot;주말 장보기&quot;
},
createdAt: 1710000000001
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조를 보면 감이 온다. 큐 항목은 &quot;현재 어떤 작업이 대기 중인가&quot;를 설명하는 데이터다. todo 자체와는 다르다. todo는 화면에 보여줄 실제 항목이고, queue item은 나중에 서버로 보낼 일감이다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;todo와 queue item은 역할이 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;todo&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;queue item&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;의미&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면과 로컬 상태에 존재하는 실제 항목&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;나중에 서버에 보낼 작업 정보&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;text, done, order&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;type, todoId, payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;왜 필요한가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자에게 지금 상태를 보여주기 위해&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;네트워크 복구 후 어떤 작업부터 보낼지 기억하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 큐는 &quot;실패한 저장을 다시 시도하는 버튼&quot;보다 한 단계 더 넓은 개념이다. 실패한 항목을 남겨두는 것도 결국 큐와 닿아 있다. 왜냐하면 retry도 결국 &quot;이 작업을 나중에 다시 보낸다&quot;는 뜻이기 때문이다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;큐는 데이터를 또 하나 복제하는 곳이 아니라, 서버로 보내지 못한 작업을 순서대로 기억해두는 목록에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let appState = {

todos: [],
queue: [],
isSyncing: false,
syncError: &quot;&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 queue item은 보통 이런 정도면 시작할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function createQueueItem(type, todoId, payload) {

return {
id: &quot;q-&quot; + Date.now() + &quot;-&quot; + Math.random().toString(16).slice(2),
type,
todoId,
payload,
createdAt: Date.now()
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 type에는 &quot;create&quot;, &quot;update&quot;, &quot;delete&quot; 같은 값이 들어갈 수 있고, payload에는 text나 done 같은 필요한 데이터가 들어갈 수 있다. todoId는 어떤 항목과 연결된 작업인지 알게 해준다. 생성의 경우에는 tmp id를 들고 갈 수도 있다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음 버전에서 큐와 함께 보기 좋은 상태들&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;queue&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 서버에 못 보냈거나 다시 보내야 할 작업 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSyncing&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 큐를 서버와 맞추는 중인지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;syncError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동기화 전체에 대한 실패 안내&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 queue를 todos 안에 섞지 않는 것이다. todo는 사용자에게 보여줄 현재 상태이고, queue는 아직 끝나지 않은 네트워크 작업 목록이다. 이 둘을 한 객체 안에 전부 밀어 넣기 시작하면 설명도 디버깅도 훨씬 어려워진다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;처음 버전의 큐는 거대한 동기화 엔진이 아니라, &quot;나중에 서버로 보낼 일감 목록&quot; 정도로 이해해도 충분하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;재연결 후 동기화는 어떤 순서로 흘러야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 마지막으로 가장 중요한 흐름을 보자. 인터넷이 다시 살아났다고 해서 queue 안의 작업을 무작정 한꺼번에 보내기 시작하면 오히려 더 꼬일 수 있다. 초보자에게는 &lt;b&gt;처음 버전에서는 순서대로 하나씩 보내는 구조&lt;/b&gt;가 가장 설명하기 쉽고 안정적이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 queue에 작업이 있는지 본다.&lt;/li&gt;
&lt;li&gt;이미 동기화 중이면 새로 시작하지 않는다.&lt;/li&gt;
&lt;li&gt;가장 앞의 작업 하나를 서버로 보낸다.&lt;/li&gt;
&lt;li&gt;성공하면 queue에서 제거하고, 필요한 로컬 교체를 반영한다.&lt;/li&gt;
&lt;li&gt;실패하면 멈추고 syncError를 남긴다.&lt;/li&gt;
&lt;li&gt;성공한 경우 다음 작업으로 넘어간다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function processQueue() {

if (appState.isSyncing) return;
if (appState.queue.length === 0) return;

appState.isSyncing = true;
appState.syncError = &quot;&quot;;

try {
while (appState.queue.length &amp;gt; 0) {
const job = appState.queue[0];
const result = await sendQueueItem(job);

  applyServerResult(job, result);
  appState.queue.shift();

  persistLocalState();
  renderApp();
}

} catch (error) {
appState.syncError = &quot;동기화에 실패했습니다. 다시 시도해 주세요.&quot;;
} finally {
appState.isSyncing = false;
renderApp();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 첫 버전에서 좋은 이유는 명확하다. 무엇을 먼저 보냈는지, 어디서 멈췄는지, 실패하면 어떤 작업이 아직 남아 있는지 설명하기 쉽기 때문이다. 반대로 처음부터 여러 작업을 병렬로 한꺼번에 보내기 시작하면, 응답 순서와 상태 반영이 한꺼번에 복잡해진다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 동기화 흐름은 왜 단순한 순차 처리부터 보는 편이 좋을까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순차 처리&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 입문자에게 유리한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 번에 하나만 보냄&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 순서가 단순해서 디버깅이 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 바로 멈춤&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어느 작업에서 막혔는지 설명하기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공할 때마다 큐 제거&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;남은 작업과 끝난 작업이 분명하게 나뉜다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 한 가지를 더 기억하면 좋다. 동기화는 로컬 todos를 없애고 서버에서 다시 다 받는 것과는 조금 다르다. 처음에는 &quot;큐에 남아 있던 작업을 서버에 전달하고, 그 결과로 필요한 로컬 보정을 한다&quot; 정도로 생각해도 충분하다. 특히 생성 작업은 tmp id를 서버 id로 교체하는 식의 반영이 여기서 다시 중요해진다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;재연결 후 동기화의 첫 버전은 &quot;하나씩, 순서대로, 실패하면 멈춘다&quot;만으로도 충분히 의미가 있다. 복잡한 병렬 처리보다 설명 가능한 흐름이 먼저다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오프라인과 큐를 붙이기 시작하면 구조가 풍부해지는 대신, 몇 가지 실수도 아주 자주 반복된다. 대부분은 todo, uiState, queue를 같은 층위로 보거나, 큐 순서를 너무 가볍게 볼 때 생긴다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;queue 없이 todos만 믿고 나중에 다시 저장하려 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇을 서버에 보내야 하는지 기준이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;미뤄진 작업과 현재 데이터 상태를 분리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;나중에 보낼 일은 queue로 따로 들고 가는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;queue item 안에 UI 상태까지 다 넣는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 갑자기 너무 무거워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터, 화면 상태, 동기화 작업을 한데 섞었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;queue는 서버에 보낼 작업 정보에 집중시키는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재연결되자마자 모든 큐를 병렬로 보낸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 순서가 꼬여 설명이 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서 너무 복잡한 동기화를 시도했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입문자에게는 순차 처리부터가 훨씬 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패한 queue item을 조용히 버린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 왜 값이 안 맞는지 알 수 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패를 설명하거나 재시도할 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전이라도 실패 시 queue를 유지하고 syncError를 남기는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;tmp id를 서버 id로 바꾸는 단계를 동기화에서 빼먹는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오프라인 중 생성된 항목이 나중에도 수정, 삭제에서 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 응답 반영 규칙이 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동기화 단계에서도 tmp -&amp;gt; server 교체가 필요하다는 점을 잊지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;queue를 메모리에서만 들고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새로고침하면 미뤄진 작업이 사라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;로컬 상태 보존을 빼먹었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;작은 연습 앱이라도 queue 자체를 로컬에 저장할지 고민하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 마지막 실수는 생각보다 중요하다. 오프라인 대응을 붙였는데 queue를 메모리에만 두면, 앱을 새로고침하는 순간 &quot;나중에 보내려던 일&quot;이 전부 사라질 수 있기 때문이다. 그래서 작은 연습 앱이라도 개념을 이해하는 목적이라면 queue 자체도 localStorage 같은 곳에 저장해볼 가치가 있다. 물론 규모가 커지면 다른 저장소도 고민할 수 있지만, 입문 단계에서는 개념을 몸에 익히는 것이 먼저다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;오프라인 대응은 &quot;네트워크가 없을 때도 되게 하자&quot;보다 &quot;지금 하지 못한 일을 어디에 안전하게 보관하고, 나중에 어떤 순서로 처리할지&quot;를 분명히 하는 데 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 마지막 편에서 꼭 가져가면 좋은 건 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 오프라인 대응은 모든 걸 즉시 서버와 맞추는 문제가 아니라 로컬 상태와 나중 작업을 분리해서 보는 문제라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 큐는 거창한 기술보다 &quot;나중에 보낼 일감 목록&quot;으로 이해하는 편이 훨씬 쉽다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 처음 버전은 하나씩, 순서대로, 실패하면 멈추는 구조만으로도 충분히 의미 있고 설명 가능하다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 1차 연재의 마무리다. 이 흐름을 끝까지 따라왔다면, 이제 바이브코딩으로 만드는 앱을 &quot;겉으로만 되는 화면&quot;이 아니라 &quot;상태와 저장 흐름을 조금은 설명할 수 있는 도구&quot;로 보는 감각이 생겼을 것이다. 그 감각이 붙기 시작하면, 다음에는 어떤 도구를 쓰더라도 훨씬 덜 흔들리게 된다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>offlinefirst입문</category>
      <category>syncqueue</category>
      <category>동기화큐</category>
      <category>오프라인대응</category>
      <category>재연결동기화</category>
      <category>큐설계</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/60</guid>
      <comments>https://story86025.tistory.com/60#entry60comment</comments>
      <pubDate>Thu, 21 May 2026 17:09:22 +0900</pubDate>
    </item>
    <item>
      <title>29. 저장 실패한 항목을 왜 바로 없애면 아쉬울까: retry 가능한 임시 항목 설계</title>
      <link>https://story86025.tistory.com/59</link>
      <description>&lt;article&gt;&lt;header&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;문제는 사용감입니다. 사용자는 분명 방금 입력했고, 화면에도 잠깐 보였던 항목이 네트워크 실패 한 번으로 완전히 사라지면 꽤 불안하게 느낄 수 있습니다. 특히 문장이 길거나, 여러 개를 연속으로 추가하던 중이거나, 네트워크가 불안정한 환경이라면 더 그렇습니다. 그래서 어느 시점부터는 &quot;실패했으면 그냥 없앤다&quot;보다 &lt;b&gt;&quot;실패한 항목을 남겨두고 다시 시도할 수 있게 만든다&quot;&lt;/b&gt;는 선택지가 훨씬 현실적으로 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 실패한 임시 항목을 바로 지우는 방식이 어떤 상황에서는 충분하지만 어떤 상황에서는 아쉬워지는지, retry 가능한 임시 항목은 어떤 상태를 들고 있어야 하는지, 그리고 입문자라면 처음에 어떤 규칙부터 세워두는 편이 가장 덜 흔들리는지를 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가한 항목이 실패와 함께 사라져서 허탈하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 임시 항목을 무조건 제거하는 규칙만 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 항목을 남길지 지울지 기준부터 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재시도 버튼을 붙였는데 상태가 더 복잡해졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending, failed, retrying 상태를 구분하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목의 생애주기를 상태로 나눠 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 실패 항목이 재시도 후 두 개처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 failed 항목을 교체하지 않고 새로 append 했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재시도 성공 시에도 replace가 핵심인지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 항목이 일반 항목처럼 보여서 더 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;failed 상태를 화면에서 구분해 보여주지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 단위 syncStatus와 UI 표시 기준을 같이 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;낙관적 생성에서 실패한 항목을 어떻게 처리할지는 기술보다 사용자 신뢰와 더 가까운 문제다. 그래서 &quot;지울지&quot;, &quot;남길지&quot;, &quot;다시 시도하게 할지&quot;의 기준이 먼저 필요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 실패한 임시 항목 처리가 중요한가&lt;/li&gt;
&lt;li&gt;바로 제거와 실패 상태 유지 중 무엇을 고를까&lt;/li&gt;
&lt;li&gt;retry 가능한 임시 항목은 어떤 상태를 가져야 할까&lt;/li&gt;
&lt;li&gt;재시도 흐름은 어떻게 움직일까&lt;/li&gt;
&lt;li&gt;failed 항목 UI는 어떻게 보여줘야 할까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 실패한 임시 항목 처리가 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 실패는 수정 실패와 느낌이 조금 다릅니다. 수정은 원래 있던 항목이 그대로 남아 있기 때문에, 실패해도 사용자는 &quot;바뀌지 않았구나&quot; 정도로 받아들이는 경우가 많습니다. 하지만 생성은 막 생겨난 항목이 대상입니다. 사용자는 방금 무언가를 만들었다고 느끼는데, 실패와 함께 그 항목이 흔적도 없이 사라지면 체감이 훨씬 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 자동 저장이나 낙관적 추가가 붙은 앱에서는 이 감각이 더 강해집니다. 화면에는 분명 보였고, 사용자는 이미 &quot;내가 추가했다&quot;고 느꼈는데, 네트워크 문제나 서버 오류 한 번으로 그 항목이 사라지면 앱 전체에 대한 신뢰도도 같이 흔들릴 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;왜 생성 실패는 수정 실패보다 더 민감하게 느껴질 수 있을까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자가 느끼는 체감&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 실패&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 값이 남아 있어서 &quot;저장이 안 됐구나&quot;로 이해하기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 실패 후 즉시 제거&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;내가 방금 만든 것이 사라졌다&quot;는 감각이 생기기 쉽다&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;생성 실패는 &quot;새 값이 반영되지 않았다&quot;보다 &quot;막 만든 것이 사라졌다&quot;처럼 느껴지기 쉬워서, 실패 후 처리 방식이 사용자 신뢰에 더 크게 영향을 줄 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;실패한 임시 항목 처리의 두 가지 기본 선택&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;아쉬운 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 바로 제거&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 단순하고 목록이 깔끔하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 입력이 한순간에 사라진 것처럼 느껴질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 상태로 유지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력값을 잃지 않고 재시도 버튼을 줄 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending, failed 같은 상태와 UI 규칙이 더 필요하다&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;b&gt;바로 제거가 비교적 맞는 경우&lt;/b&gt;&lt;br /&gt;입력값이 매우 짧고, 다시 입력해도 부담이 작고, 네트워크 실패가 드문 단순한 개인 도구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패 상태 유지가 더 맞는 경우&lt;/b&gt;&lt;br /&gt;입력 노력이 조금이라도 있고, 사용자가 같은 내용을 다시 쓰게 만드는 것이 거칠게 느껴질 수 있는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실패 상태 유지는 기술적으로 더 무거워 보이지만, 사용자가 적어도 &quot;내 입력을 앱이 기억하고 있다&quot;는 느낌을 받을 수 있다는 장점이 있습니다. 반대로 바로 제거는 구현은 쉬워도 사용감이 더 거칠 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현실적인 기준&lt;/b&gt;&lt;br /&gt;입력값을 다시 만드는 비용이 작으면 제거도 괜찮지만, 사용자가 &quot;다시 써야 한다&quot;고 느끼는 순간부터는 실패 상태 유지와 retry가 훨씬 자연스러워질 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;retry 가능한 임시 항목은 어떤 상태를 가져야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패한 항목을 남겨두기로 했다면, 이제는 상태를 더 분명하게 나눠야 합니다. 이전 글에서 tmp id가 임시 항목을 추적하는 연결 고리라고 했는데, retry가 붙으면 id만으로는 부족합니다. 지금 이 항목이 &lt;b&gt;저장 중인지&lt;/b&gt;, &lt;b&gt;실패 상태인지&lt;/b&gt;, &lt;b&gt;이미 서버와 동기화됐는지&lt;/b&gt;를 같이 설명할 값이 필요해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 가장 단순한 첫 구조는 아래 정도면 충분한 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function createTempTodo(text) {

return {
id: &quot;tmp-&quot; + Date.now() + &quot;-&quot; + Math.random().toString(16).slice(2),
text,
done: false,
order: getNextOrder(),
syncStatus: &quot;pending&quot;,
errorMessage: &quot;&quot;,
retryCount: 0
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;retry 가능한 임시 항목에 처음부터 두기 좋은 값&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;필드&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목 추적용 tmp id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 시 어느 항목을 교체할지 찾기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;syncStatus&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;pending&quot;, &quot;failed&quot;, &quot;synced&quot; 같은 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 항목이 어떤 단계인지 UI가 알 수 있게 하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;errorMessage&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 안내 문구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자에게 왜 멈췄는지 설명하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retryCount&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재시도 횟수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금이 첫 실패인지 반복 실패인지 구분하는 데 도움을 줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 syncStatus는 특히 중요합니다. 화면에서 일반 항목과 pending 항목, failed 항목을 구분해 보여주는 기준이 되기 때문입니다. 입문자에게는 pending과 failed만 있어도 충분하지만, 나중에는 retrying 같은 상태를 더 둘 수도 있습니다. 다만 첫 버전에서는 너무 잘게 쪼개기보다 &lt;b&gt;pending -&amp;gt; failed -&amp;gt; 성공 시 교체&lt;/b&gt; 정도만 분명해도 좋습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;retry가 들어오는 순간 임시 항목은 단순한 &quot;가짜 항목&quot;이 아니라, 저장 상태를 가진 항목이 된다. 그래서 syncStatus 같은 명확한 단계 값이 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;재시도 흐름은 어떻게 움직일까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 retry 흐름을 보겠습니다. 핵심은 어렵지 않습니다. &lt;b&gt;failed 항목을 다시 pending으로 바꾸고&lt;/b&gt;, &lt;b&gt;같은 text로 다시 요청을 보내고&lt;/b&gt;, &lt;b&gt;성공하면 tmp 항목을 서버 항목으로 교체하고&lt;/b&gt;, &lt;b&gt;실패하면 다시 failed로 돌린다&lt;/b&gt;는 흐름입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 아래처럼 단계로 나눠 보는 편이 가장 이해하기 쉽습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자가 retry 버튼을 누른다.&lt;/li&gt;
&lt;li&gt;해당 tmp 항목의 syncStatus를 &quot;failed&quot;에서 &quot;pending&quot;으로 바꾼다.&lt;/li&gt;
&lt;li&gt;errorMessage를 비우고 retryCount를 올린다.&lt;/li&gt;
&lt;li&gt;같은 text로 서버 생성 요청을 다시 보낸다.&lt;/li&gt;
&lt;li&gt;성공하면 tmp 항목을 서버 응답 항목으로 교체한다.&lt;/li&gt;
&lt;li&gt;실패하면 다시 &quot;failed&quot; 상태로 되돌린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function retryCreateTodo(tempId) {

const target = todos.find(todo =&amp;gt; todo.id === tempId);
if (!target) return;
if (target.syncStatus !== &quot;failed&quot;) return;

todos = todos.map(todo =&amp;gt;
todo.id === tempId
? {
...todo,
syncStatus: &quot;pending&quot;,
errorMessage: &quot;&quot;,
retryCount: todo.retryCount + 1
}
: todo
);

renderTodos();

try {
const savedTodo = await createTodoOnServer(target.text);

todos = todos.map(todo =&amp;gt;
  todo.id === tempId
    ? savedTodo
    : todo
);

} catch (error) {
todos = todos.map(todo =&amp;gt;
todo.id === tempId
? {
...todo,
syncStatus: &quot;failed&quot;,
errorMessage: &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;
}
: todo
);
} finally {
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 가장 중요한 점은 두 가지입니다. 첫째, retry도 새로운 append가 아니라 &lt;b&gt;같은 tmp 항목을 기준으로 상태를 바꾸고 마지막에 교체&lt;/b&gt;한다는 점입니다. 둘째, 실패한 뒤에도 입력값은 사라지지 않고 같은 항목 안에 남아 있기 때문에, 사용자는 다시 입력할 필요 없이 retry만 누르면 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;retry 흐름에서 중요한 구분&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;핵심 동작&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;failed -&amp;gt; pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;tmp 항목 -&amp;gt; 서버 항목 교체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending -&amp;gt; failed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 중요한 이유는 retry가 단순한 버튼 하나가 아니라, &lt;b&gt;실패한 항목의 생애주기를 한 번 더 돌리는 것&lt;/b&gt;이기 때문입니다. 이 감각이 잡히면 retry 버튼이 왜 별도의 상태 설계를 요구하는지도 훨씬 이해하기 쉬워집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;retry는 새로운 생성이 아니라, 실패 상태였던 같은 tmp 항목을 다시 pending 흐름에 올려놓는 일에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;failed 항목 UI는 어떻게 보여줘야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;retry 구조를 만들었다면 화면에서도 이 항목이 일반 항목과 다르게 보이는 편이 좋습니다. 그렇지 않으면 사용자는 왜 이 항목만 저장이 안 됐는지, 지금 누를 수 있는 행동이 무엇인지 알기 어렵습니다. 특히 failed 항목은 &quot;이미 목록에 있지만 아직 완전히 저장된 것은 아닌 상태&quot;라는 점이 중요합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;failed 항목에서 화면이 알려주면 좋은 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;보여주면 좋은 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 실패&quot; 같은 짧은 배지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;일반 항목과 상태를 구분해 보여줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry 버튼&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 다음 행동을 바로 알 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 또는 닫기 버튼&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 직접 이 임시 항목을 정리할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 중 표시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry 후 다시 저장 중인지 알 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 처음부터 너무 많은 동작을 허용하지 않는 편이 좋습니다. 예를 들면 failed 항목에서는 우선 &lt;b&gt;retry와 제거&lt;/b&gt;만 열어두고, 수정이나 드래그 재정렬은 잠깐 막아두는 편이 훨씬 단순합니다. 이렇게 해야 &quot;지금 이 항목은 확정된 일반 항목이 아니라 실패 상태의 임시 항목&quot;이라는 규칙이 화면에서도 분명해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 렌더링 기준은 이렇게 잡을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function getTodoUiMode(todo) {

if (todo.syncStatus === &quot;pending&quot;) {
return &quot;pending&quot;;
}

if (todo.syncStatus === &quot;failed&quot;) {
return &quot;failed&quot;;
}

return &quot;normal&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 failed 모드에서는 일반 액션 일부를 막고 retry 쪽을 더 분명하게 보여주는 식입니다. 이런 UI는 기술적으로 복잡하기보다, &lt;b&gt;현재 이 항목이 어떤 종류의 항목인지 사용자에게 설명하는 역할&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁&lt;/b&gt;&lt;br /&gt;실패한 임시 항목은 &quot;조용히 남아 있는 일반 항목&quot;처럼 보이게 두기보다, retry가 필요하다는 점이 한눈에 보이게 만드는 편이 훨씬 덜 헷갈린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;retry 구조를 붙이기 시작하면 기능은 친절해지지만, 동시에 자주 나오는 실수도 꽤 분명합니다. 대부분은 임시 항목과 최종 항목의 경계를 다시 흐리게 볼 때 생깁니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 무조건 제거만 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 방금 쓴 입력을 잃어버린 느낌을 받는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 후 유지라는 선택지를 고려하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 노력이 조금이라도 크면 failed 유지도 고려하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry도 append처럼 처리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재시도 성공 후 같은 항목이 또 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 항목을 재사용하지 않고 새 생성처럼 다뤘다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry는 같은 tmp 항목을 다시 pending으로 올리는 흐름으로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 후 syncStatus를 안 지운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 저장된 항목이 계속 실패 또는 대기 항목처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;교체 단계가 완전히 끝나지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 시 tmp 항목을 서버 항목으로 완전히 교체하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;failed 항목도 일반 항목처럼 편집, 재정렬, 체크를 다 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조 설명이 급격히 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 retry와 제거 위주로 제한하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retryCount 같은 상태를 늘렸는데 어디서 쓰는지 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태만 복잡해지고 UI는 그대로다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태와 화면 규칙이 연결되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음에는 syncStatus와 errorMessage 정도만으로도 충분하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retry 도중 또 retry를 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 tmp 항목에 요청이 겹쳐 더 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 상태에서의 잠금 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 항목은 retry 버튼을 비활성화하는 편이 더 안전하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 마지막 실수는 retry를 넣기 시작하면 자주 보입니다. 사용자는 불안해서 버튼을 여러 번 누를 수 있고, 그 순간 같은 tmp 항목에 여러 생성 요청이 겹치기 쉽습니다. 그래서 failed -&amp;gt; pending으로 바뀌는 순간에는 다시 retry를 잠깐 잠그는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;retry 기능이 이상해질 때는 서버 요청보다 &quot;같은 tmp 항목이 지금 pending인지 failed인지&quot;를 UI와 코드가 똑같이 보고 있는지부터 다시 확인하는 편이 훨씬 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패한 임시 항목을 어떻게 다룰지는 작은 선택처럼 보이지만, 실제로는 앱의 인상에 꽤 큰 영향을 줍니다. 바로 지워버리면 구조는 단순하지만 사용자는 입력을 잃은 느낌을 받을 수 있고, 실패 상태로 남기면 더 친절해지지만 그만큼 상태 규칙도 더 분명해야 합니다. 그래서 retry를 붙인다는 건 버튼 하나 추가가 아니라, &lt;b&gt;임시 항목의 생애주기를 더 길게 보는 일&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. 첫째, 낙관적 생성에서는 tmp id가 단순한 꼼수가 아니라 실패와 성공을 잇는 추적 기준이라는 점. 둘째, retry는 새로운 생성이 아니라 같은 failed 항목을 다시 pending 흐름으로 올리는 일이라는 점. 셋째, retry를 붙인다면 failed 항목은 일반 항목과 같은 것으로 보지 말고, 화면에서도 분명히 다른 상태로 보여주는 편이 훨씬 안정적이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>failed상태</category>
      <category>pending상태</category>
      <category>Retry</category>
      <category>syncStatus</category>
      <category>실패항목유지</category>
      <category>재시도UI</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/59</guid>
      <comments>https://story86025.tistory.com/59#entry59comment</comments>
      <pubDate>Tue, 19 May 2026 18:00:17 +0900</pubDate>
    </item>
    <item>
      <title>28. 새 항목은 보이는데 왜 수정과 삭제가 꼬일까: 임시 id와 진짜 id 연결하기</title>
      <link>https://story86025.tistory.com/56</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 서버가 돌려준 응답을 어떤 기준으로 클라이언트 상태에 반영할지 정리했습니다. 여기까지 오면 바로 다음으로 자주 나오는 문제가 있습니다. 바로 &quot;생성&quot;입니다. 수정은 기존 항목이 이미 있으니 id도 있고, 어느 항목을 바꿀지 기준도 분명합니다. 그런데 새 항목을 만들 때는 상황이 조금 다릅니다. &lt;b&gt;아직 서버가 준 진짜 id가 없는데, 화면에는 먼저 보여주고 싶을 때&lt;/b&gt;가 생기기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 낙관적 업데이트를 붙이기 시작하면 이 문제가 더 잘 보입니다. 사용자가 추가 버튼을 누르는 즉시 목록에 항목이 보이게 만들고 싶은데, 그 순간 서버 응답은 아직 오지 않았습니다. 그러면 화면에는 뭔가 &quot;임시 항목&quot;이 먼저 생겨야 합니다. 그리고 나중에 서버가 진짜 id와 최종 저장본을 돌려주면, 그 임시 항목을 제대로 갈아끼워야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 구조가 조금만 흐리면 금방 이상한 일이 생깁니다. 항목이 두 개로 보이거나, 화면에는 보이는데 수정이나 삭제가 실패하거나, 정렬이 이상하게 흔들리거나, 저장 성공 후에도 tmp id가 남아서 다음 요청이 꼬이는 식입니다. 이번 글에서는 왜 생성이 수정보다 더 까다로운지, 임시 id가 정확히 무슨 역할을 하는지, 그리고 초보자 기준에서는 어떤 방식으로 임시 항목을 서버가 준 진짜 항목으로 바꾸는 편이 가장 덜 흔들리는지를 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 항목이 두 개로 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목을 &quot;교체&quot;하지 않고 서버 응답 항목을 그냥 &quot;추가&quot;했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 성공 시 append가 아니라 replace가 필요한지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성은 됐는데 수정이나 삭제가 실패한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면에 남아 있는 id가 서버가 아는 진짜 id가 아니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답에서 받은 최종 id로 갈아끼우는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목은 보이는데 뭔가 불안하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 저장 확정 전인 임시 항목을 일반 항목과 같은 것으로 보고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 상태를 구분해서 보여주는 편이 좋은지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때 목록이 더 이상해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목을 제거할지, 에러 상태로 남길지 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서는 실패 시 어떤 복구를 할지 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;낙관적 생성에서는 화면에 먼저 뜬 항목이 최종 저장본이 아닐 수 있다. 그래서 &quot;임시 항목을 서버가 확정한 항목으로 교체하는 단계&quot;가 꼭 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 생성은 수정보다 더 까다로울까&lt;/li&gt;
&lt;li&gt;임시 id는 정확히 왜 필요한가&lt;/li&gt;
&lt;li&gt;서버 id로 바꾸지 않으면 어떤 일이 생기나&lt;/li&gt;
&lt;li&gt;가장 쉬운 두 가지 흐름: 응답 후 추가 vs 낙관적 추가&lt;/li&gt;
&lt;li&gt;낙관적 생성의 핵심: 임시 항목을 서버 항목으로 &quot;교체&quot;하기&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 생성은 수정보다 더 까다로울까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 기존 항목이 이미 있다는 점에서 출발합니다. id도 있고, text도 있고, done도 있고, order도 있습니다. 그래서 사용자가 수정 버튼을 누르면 &quot;어느 항목을 바꿀지&quot;가 비교적 분명합니다. 서버 저장으로 넘어가도 같은 id를 기준으로 응답을 덮어쓰거나 교체하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 생성은 조금 다릅니다. 서버에 요청을 보내기 전까지는 아직 그 항목이 &quot;진짜로 존재한다&quot;고 보기 어렵기 때문입니다. 그런데 사용자 경험을 좋게 만들려면 요청 직후 화면에 바로 보이게 하고 싶을 수 있습니다. 이때부터 임시 항목이라는 개념이 등장합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;생성과 수정이 체감상 다른 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;수정&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;생성&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;시작점&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 존재하는 항목이 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 서버에 존재하지 않는 항목이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 id를 그대로 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최종 id는 서버가 줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 UI&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 항목을 바로 바꿔 보여주기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목을 먼저 만들어 보여줘야 할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 후 처리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 항목을 최신 응답으로 교체하거나 병합한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목을 진짜 항목으로 바꿔 끼워야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 생성이 더 까다로운 이유는 &quot;항목 하나 더 넣기&quot; 때문이 아니라, &lt;b&gt;존재가 아직 확정되지 않은 항목을 먼저 화면에 보여줄 수 있기 때문&lt;/b&gt;입니다. 이 차이를 이해하면 왜 임시 id와 교체 단계가 필요한지도 훨씬 또렷해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정은 기존 항목을 다듬는 문제이고, 생성은 &quot;아직 확정되지 않은 항목을 잠깐 보여줄 것인가&quot;까지 함께 다루는 문제다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;임시 id는 정확히 왜 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 임시 id를 처음 보면 이런 생각을 하기 쉽습니다. &quot;어차피 나중에 서버가 진짜 id를 줄 텐데, 굳이 지금도 id가 있어야 하나?&quot; 그런데 낙관적 생성에서는 꽤 자주 필요합니다. 화면에 뜬 임시 항목도 일단은 목록 안의 한 줄이기 때문입니다. 렌더링할 때도 구분이 필요하고, 나중에 서버 응답이 왔을 때 &quot;어느 임시 항목을 진짜 항목으로 바꿀지&quot; 찾는 기준도 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 임시 id는 &quot;서버가 아는 최종 id&quot;라기보다 &lt;b&gt;클라이언트가 잠깐 들고 가는 추적용 꼬리표&lt;/b&gt;에 가깝습니다. 낙관적 UI에서는 이 꼬리표가 있어야 나중에 응답과 임시 항목을 연결할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function createTempTodo(text) {

return {
id: &quot;tmp-&quot; + Date.now() + &quot;-&quot; + Math.random().toString(16).slice(2),
text,
done: false,
order: getNextOrder(),
isPending: true
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 tmp- 같은 접두사를 붙이는 이유도 단순합니다. 서버 id와 눈으로도 구분하기 쉽고, 숫자 id와 우연히 섞여서 헷갈릴 가능성을 줄일 수 있기 때문입니다. 입문자 단계에서는 이런 단순한 구분이 꽤 도움이 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;임시 id가 필요한 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링 구분&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록 안에서 항목 하나를 식별할 수 있어야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 연결&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;나중에 서버 응답이 오면 어느 임시 항목을 교체할지 찾아야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 상태 표시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 저장 중인 항목인지 화면에서 구분해 보여줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;임시 id는 서버를 위한 값이 아니라, 서버 응답이 오기 전까지 클라이언트가 임시 항목을 잃어버리지 않기 위한 연결 고리다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 id로 바꾸지 않으면 어떤 일이 생기나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 가장 자주 터지는 문제를 보겠습니다. 임시 항목을 서버 응답 항목으로 제대로 바꾸지 않으면, 겉보기에는 추가가 된 것 같은데 그 다음 단계에서 계속 어색해집니다. 왜냐하면 화면은 tmp id를 가진 항목을 보고 있는데, 서버는 진짜 id를 가진 항목을 저장해두고 있기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;tmp id를 제대로 갈아끼우지 않으면 생길 수 있는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 생기나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목이 두 개로 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목은 그대로 두고 서버 응답 항목을 따로 append 했다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정이나 삭제가 실패한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클라이언트는 tmp id를 보내고, 서버는 그 id를 모른다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬이나 선택 상태가 이상해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목처럼 보이는데 실제 식별자는 서로 다르다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 표시가 안 사라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;todos = [...todos, tempTodo];

renderTodos();

const savedTodo = await createTodoOnServer(result.value);

todos = [...todos, savedTodo];
renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉보기에는 문제 없어 보이지만, 실제로는 tempTodo도 남고 savedTodo도 따로 들어갑니다. 즉, &quot;하나를 바꿔 끼운다&quot;가 아니라 &quot;하나를 더 추가한다&quot;가 되어버립니다. 그래서 생성 성공 뒤에는 append보다 replace가 훨씬 더 중요해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;낙관적 생성에서 가장 중요한 동사는 append가 아니라 replace에 가깝다. 임시 항목을 남겨두고 서버 항목을 더하는 순간부터 모든 게 조금씩 어긋나기 시작한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 쉬운 두 가지 흐름: 응답 후 추가 vs 낙관적 추가&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;응답 후 추가와 낙관적 추가의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 후 추가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 단순하고 tmp id가 필요 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 눈에는 반응이 느리게 느껴질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 추가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;즉시 반응해서 빠르게 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목, 교체, 실패 복구 규칙이 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 이 비교가 특히 중요합니다. 왜냐하면 &quot;빠르게 보이게 만들고 싶은 욕심&quot; 때문에 바로 낙관적 추가로 가기 쉽기 때문입니다. 하지만 구조를 처음 잡는 단계에서는 응답 후 추가부터 먼저 안정화해도 충분히 의미가 있습니다. 그 다음에 낙관적 추가를 붙이면 왜 tmp id가 필요해지는지도 더 쉽게 이해됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 응답 후 추가는 아주 단순하게 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function addTodoAfterResponse(rawText) {

const result = validateTodoText(rawText);

if (!result.ok) {
uiState.formError = &quot;내용을 입력해 주세요.&quot;;
renderTodos();
return;
}

uiState.isSaving = true;
uiState.formError = &quot;&quot;;
renderTodos();

try {
const savedTodo = await createTodoOnServer(result.value);
todos = [...todos, savedTodo];
} catch (error) {
uiState.formError = &quot;추가 저장에 실패했습니다.&quot;;
} finally {
uiState.isSaving = false;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자에게 가장 현실적인 기준&lt;/b&gt;&lt;br /&gt;생성 흐름이 아직 헷갈린다면 응답 후 추가부터 먼저 만들고, 사용감이 너무 느리다고 느껴질 때 그다음 단계로 낙관적 추가를 붙이는 편이 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;낙관적 생성의 핵심: 임시 항목을 서버 항목으로 &quot;교체&quot;하기&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;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function addTodoOptimistically(rawText) {

const result = validateTodoText(rawText);

if (!result.ok) {
uiState.formError = &quot;내용을 입력해 주세요.&quot;;
renderTodos();
return;
}

const tempTodo = createTempTodo(result.value);

todos = [...todos, tempTodo];
uiState.formError = &quot;&quot;;
renderTodos();

try {
const savedTodo = await createTodoOnServer(result.value);

todos = todos.map(todo =&amp;gt;
  todo.id === tempTodo.id
    ? savedTodo
    : todo
);

} catch (error) {
todos = todos.filter(todo =&amp;gt; todo.id !== tempTodo.id);
uiState.formError = &quot;추가 저장에 실패했습니다.&quot;;
} finally {
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 꼭 눈여겨볼 부분은 두 군데입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;성공 시에는 map으로 교체한다&lt;/b&gt;&lt;br /&gt;tmp id를 가진 항목을 서버가 준 savedTodo로 바꿉니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패 시에는 remove 규칙을 쓴다&lt;/b&gt;&lt;br /&gt;첫 버전에서는 임시 항목을 제거하고 에러만 보여줘도 충분한 경우가 많습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 더 발전된 버전으로 가면 실패한 임시 항목을 바로 지우지 않고 &quot;다시 시도&quot; 상태로 남겨둘 수도 있습니다. 하지만 입문자 기준에서는 우선 교체 흐름이 분명한 편이 더 중요합니다. 생성 성공 후 tmp id가 남지 않고, 서버가 준 진짜 id로 바뀌는 것. 이 감각이 먼저 잡혀야 이후 재시도나 오프라인 큐 같은 개념도 덜 흔들립니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;낙관적 생성 흐름을 단계로 보면 이렇게 정리된다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 일이 일어나나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력값 검증 후 tempTodo를 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에 tempTodo를 먼저 넣고 화면에 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 요청을 보낸다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 시 tempTodo를 savedTodo로 교체한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;5&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 tempTodo를 제거하거나 실패 상태로 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;낙관적 생성에서 가장 중요한 순간은 &quot;서버가 성공 응답을 보냈을 때&quot;가 아니라, &quot;그 응답을 임시 항목 자리에 정확히 교체하는 순간&quot;에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임시 id와 서버 id를 연결하는 단계에서는 비슷한 실수가 반복해서 나옵니다. 대부분은 임시 항목과 최종 항목의 경계를 흐리게 볼 때 생깁니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;tmp id 없이 낙관적 생성부터 시도한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 왔을 때 어느 항목을 바꿔야 하는지 애매하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목을 추적할 연결 고리가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 생성이라면 tmp id 같은 추적 기준부터 둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 후 append만 하고 replace를 안 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목이 두 개처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 항목이 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 성공 시에는 교체가 핵심이라고 보는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;tmp id를 숫자로 아무렇게나 만든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 숫자 id와 구분이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시와 최종을 눈으로 구분하기 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;tmp- 접두사처럼 명확한 구분을 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공했는데도 isPending 같은 상태를 안 지운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 저장된 항목이 계속 임시 항목처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답으로 최종 항목 전환이 완전히 끝나지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 시에는 임시 상태가 같이 정리되어야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 임시 항목 정리 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면에 반쯤 저장된 것 같은 항목이 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공과 실패 후 처리 경계가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 제거 또는 실패 표시 중 하나를 먼저 고른다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;pending 항목도 일반 항목처럼 수정, 삭제, 정렬을 다 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 서버가 확정하지 않은 항목에 여러 동작이 겹친다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 상태 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서는 pending 항목의 동작을 일부 잠그는 편이 훨씬 안전하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 마지막 실수는 생각보다 자주 나옵니다. 아직 서버 응답도 안 왔는데 사용자가 그 임시 항목을 다시 수정하거나 끌어서 순서를 바꾸기 시작하면, 다음 단계 설명이 훨씬 어려워집니다. 그래서 입문자에게는 pending 상태의 항목은 처음엔 일부 동작을 잠깐 막아두는 편이 더 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;낙관적 생성이 이상할 때는 서버 요청보다도 &quot;임시 항목을 언제 만들고, 언제 최종 항목으로 교체하고, 실패 시 어떻게 치울지&quot;의 경계가 흐린 경우가 더 많다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 흐름은 수정 흐름보다 조금 더 까다롭습니다. 이미 있는 항목을 다듬는 게 아니라, 아직 확정되지 않은 항목을 먼저 화면에 보여줄지 말지부터 결정해야 하기 때문입니다. 그래서 임시 id와 서버가 주는 진짜 id를 연결하는 과정이 생기고, 이 과정을 제대로 설명하지 않으면 같은 항목이 두 개로 보이거나 이후 수정과 삭제가 꼬이기 쉬워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 낙관적 생성에서는 tmp id가 단순한 편법이 아니라 임시 항목을 추적하는 연결 고리라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 생성 성공 후에는 새 항목을 더하는 것보다 임시 항목을 서버 응답 항목으로 교체하는 흐름이 더 중요하다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, pending 항목은 일반 항목과 완전히 같지 않기 때문에 처음 버전에서는 일부 동작을 잠깐 잠가두는 편이 더 안정적이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 생성, 수정, 삭제, 저장, 서버 응답 반영까지 꽤 입체적으로 다룰 수 있는 단계에 들어옵니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;실패한 임시 항목을 바로 없애는 대신 &quot;재시도&quot; 가능한 상태로 남겨둘지 말지를 어떤 기준으로 판단하면 좋은지&lt;/b&gt;, 그리고 retry 버튼이 들어오는 순간 상태 구조가 어떻게 달라지는지를 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>create응답처리</category>
      <category>tmpid</category>
      <category>낙관적생성</category>
      <category>임시id</category>
      <category>임시항목교체</category>
      <category>진짜id연결</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/56</guid>
      <comments>https://story86025.tistory.com/56#entry56comment</comments>
      <pubDate>Thu, 14 May 2026 18:00:43 +0900</pubDate>
    </item>
    <item>
      <title>27. 저장은 성공했는데 왜 화면 값이 또 바뀔까: 서버 응답 반영 기준</title>
      <link>https://story86025.tistory.com/55</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 localStorage 저장과 서버 저장이 왜 체감상 다르게 느껴지는지, 그리고 비동기 저장에서는 &quot;기다림&quot;, &quot;실패&quot;, &quot;중복 요청&quot;, &quot;최신 응답&quot; 같은 개념이 왜 중요해지는지를 정리했습니다. 여기까지 오면 바로 다음 질문이 따라옵니다. &lt;b&gt;&quot;그래서 서버가 응답을 보내면, 나는 그 값을 화면에 어떻게 반영해야 하지?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 이 질문이 생각보다 크게 와닿습니다. 분명 내가 보낸 값은 &quot;주말 장보기&quot;였는데, 저장이 끝난 뒤 화면에는 &quot;주말 장보기 &quot;가 아니라 공백이 정리된 값이 보일 수도 있고, 어떤 경우에는 서버가 id나 updatedAt 같은 값을 새로 붙여서 돌려줄 수도 있기 때문입니다. 즉, 저장 요청이 성공했다고 끝이 아니라 &lt;b&gt;성공한 뒤 무엇을 최종본으로 볼 것인가&lt;/b&gt;가 또 하나의 단계가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 저장 성공 후에도 화면 값이 달라질 수 있는지, 내가 보낸 값과 서버가 돌려준 값이 왜 다를 수 있는지, 그리고 초보자라면 어떤 기준으로 클라이언트 상태를 갱신하는 편이 가장 덜 흔들리는지를 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장은 성공했는데 화면 값이 내가 보낸 값과 조금 다르다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 저장 과정에서 값을 정리하거나 보강해서 돌려줬을 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 성공 후 최종 기준을 무엇으로 볼지 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성은 됐는데 id가 없거나 이상하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 최종 id를 부여했는데 클라이언트가 자기 값을 계속 쓰고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 응답은 서버가 돌려준 최종 항목을 반영하는 쪽이 더 안전하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 후 일부 값은 남고 일부 값은 바뀌어 화면이 애매하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답을 반영하는 규칙이 일관되지 않다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 교체인지, 부분 병합인지 기준을 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제는 성공했는데 클라이언트 목록이 다시 살아난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 제거와 서버 최종 응답 반영 기준이 섞였다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동작별로 응답 반영 규칙을 분리해서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;서버 저장에서는 &quot;무엇을 보냈는가&quot;만큼이나 &quot;서버가 최종적으로 무엇을 인정해서 돌려줬는가&quot;도 중요하다. 그래서 성공 후 상태 갱신 기준이 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 저장 성공 후에도 화면 값이 달라질 수 있을까&lt;/li&gt;
&lt;li&gt;내가 보낸 값과 서버가 돌려준 값은 왜 다를까&lt;/li&gt;
&lt;li&gt;초보자에게 가장 안전한 기본 원칙: 최종 저장본은 서버 응답을 우선해서 본다&lt;/li&gt;
&lt;li&gt;응답 반영 방식은 크게 두 가지다: 전체 교체와 부분 병합&lt;/li&gt;
&lt;li&gt;생성, 수정, 삭제는 응답을 반영하는 방식이 조금씩 다르다&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 저장 성공 후에도 화면 값이 달라질 수 있을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 &quot;내가 보낸 값 = 저장된 값&quot;처럼 느껴지는 경우가 많습니다. 작은 localStorage 예제에서는 대체로 그 감각이 맞습니다. 내가 넣은 값을 그대로 저장하고, 그대로 다시 읽기 때문입니다. 하지만 서버 저장에서는 이 감각이 항상 맞지는 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 단순히 값을 받아 적는 역할만 하지 않을 수 있습니다. 공백을 정리할 수도 있고, 시간 값을 새로 붙일 수도 있고, 고유 id를 부여할 수도 있고, 어떤 필드를 표준 형식으로 정리해서 보낼 수도 있습니다. 즉, 사용자가 보낸 값은 &quot;저장 요청 후보&quot;에 가깝고, 서버가 돌려준 값은 &quot;최종 저장본&quot;에 더 가까울 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;저장 성공 후에도 값이 달라질 수 있는 대표 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 달라질 수 있나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자열 앞뒤 공백&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 trim 처리한 값을 최종 저장본으로 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createdAt, updatedAt&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 저장 시각을 기준으로 값을 새로 붙일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 시 서버가 최종 식별자를 정해서 돌려줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가 계산 필드&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 상태값, 정렬 기준값, 버전값 등을 함께 줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 서버 저장에서는 &quot;저장 요청을 성공적으로 보냈다&quot;와 &quot;내가 가진 화면 상태가 최종 저장본과 일치한다&quot;를 같은 말로 보면 안 되는 경우가 있습니다. 중간에 서버가 한 번 더 값을 다듬을 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장에서는 요청값이 최종값이 아니라, 서버가 받아들인 뒤 확정해서 돌려준 응답값이 최종값이 되는 경우가 적지 않다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내가 보낸 값과 서버가 돌려준 값은 왜 다를까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 조금 더 구체적으로 보겠습니다. 입문자에게는 &quot;왜 굳이 서버 응답을 또 받아서 써야 하지?&quot;라는 질문이 자연스럽습니다. 그냥 내가 보낸 값을 성공으로 보고 유지하면 되지 않나 싶기 때문입니다. 실제로 아주 단순한 API라면 그렇게 해도 큰 문제가 없는 경우가 있습니다. 하지만 조금만 기능이 늘어나면 서버 응답을 무시하는 쪽이 오히려 더 불안할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 새 항목을 만들 때를 생각해보면 이해가 쉽습니다. 클라이언트는 임시 id를 만들 수도 있지만, 서버는 실제 저장 구조에 맞는 최종 id를 부여할 수 있습니다. 이때 계속 임시 id만 믿고 가면 이후 수정, 삭제, 정렬에서 서버가 아는 항목과 클라이언트가 아는 항목이 달라질 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;서버 응답을 무시하면 왜 불안해질 수 있을까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;응답을 무시했을 때 생길 수 있는 문제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 최종 id를 놓쳐 이후 작업이 꼬일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 정리한 text나 updatedAt을 놓칠 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 삭제 성공 여부를 잘못 해석할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 모든 API가 항상 전체 객체를 응답으로 돌려주는 것은 아닙니다. 어떤 API는 성공 여부만 주고 끝날 수도 있습니다. 그래서 중요한 건 &quot;무조건 응답 전체를 믿는다&quot;가 아니라, &lt;b&gt;API 계약상 무엇이 최종 진실인지 분명히 정해두는 것&lt;/b&gt;입니다. 다만 입문자 기준으로는 전체 객체나 최신 값을 응답으로 주는 구조라면, 그 응답을 최종본으로 반영하는 편이 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;서버 응답을 왜 보느냐의 핵심은 &quot;혹시 다른 값이 왔나?&quot;보다 &quot;최종 저장본을 누가 결정하나?&quot;에 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;pre class=&quot;json&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;{

&quot;id&quot;: 101,
&quot;text&quot;: &quot;주말 장보기&quot;,
&quot;done&quot;: false,
&quot;updatedAt&quot;: &quot;2026-03-10T12:34:56Z&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우에는 내가 보낸 payload를 계속 화면에 유지하는 것보다, 응답으로 온 객체를 해당 항목 자리에 넣는 쪽이 더 안정적입니다. 이렇게 해야 text 정리 여부, updatedAt 갱신, 기타 서버가 보강한 값까지 한 번에 맞출 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;입문자에게 기본 원칙으로 쓰기 좋은 방식&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;기본으로 생각하기 좋은 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 후 서버가 새 객체를 돌려줌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 객체를 목록에 넣는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 후 서버가 최신 객체를 돌려줌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;해당 id 항목을 응답 객체로 교체한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 후 서버가 성공만 알려줌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 계약에 맞춰 삭제 성공을 확정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 원칙은 왜 좋으냐면, &quot;내가 보낸 값&quot;과 &quot;서버가 받아들인 값&quot; 중 어느 쪽이 최종 기준인지 매번 고민하지 않게 해주기 때문입니다. 서버가 객체를 돌려주는 구조라면, 초보자에게는 그 객체를 최종 저장본으로 보는 편이 훨씬 명확합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 안전한 첫 기준&lt;/b&gt;&lt;br /&gt;서버가 최신 객체를 응답으로 주는 구조라면, 성공 후에는 그 객체를 최종 저장본으로 보고 클라이언트 상태를 맞추는 편이 가장 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;p data-ke-size=&quot;size16&quot;&gt;전체 교체는 말 그대로 해당 항목을 응답 객체 전체로 바꾸는 방식입니다. 부분 병합은 기존 항목 위에 응답값만 덮어쓰는 방식입니다. 둘 다 쓸 수 있지만, 초보자에게는 먼저 전체 교체를 이해하는 편이 더 쉽습니다. 왜냐하면 &quot;최종 저장본은 서버 응답&quot;이라는 기준과 가장 잘 맞기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function replaceTodoByResponse(todoId, savedTodo) {

todos = todos.map(todo =&amp;gt;
todo.id === todoId
? savedTodo
: todo
);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부분 병합은 이렇게 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function mergeTodoByResponse(todoId, savedTodo) {

todos = todos.map(todo =&amp;gt;
todo.id === todoId
? { ...todo, ...savedTodo }
: todo
);
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;전체 교체와 부분 병합의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 교체&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답이 최종본이라는 기준이 명확하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클라이언트 전용 임시 필드가 섞여 있으면 같이 날아갈 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;부분 병합&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 항목에 붙어 있던 다른 값까지 유지하기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;응답이 &quot;이 항목의 최신 완전본&quot;이라면 전체 교체가 가장 명확하고, 응답이 일부 필드만 담고 있다면 병합이 필요할 수 있다. 중요한 건 매번 같은 기준을 쓰는 것이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;h3 data-ke-size=&quot;size23&quot;&gt;1. 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성은 보통 서버가 새 id나 시간 값을 붙여서 돌려줄 수 있기 때문에, 응답 객체를 그대로 목록에 넣는 쪽이 훨씬 자연스럽습니다. 특히 낙관적 생성에서 임시 id를 썼다면, 성공 후에는 서버가 돌려준 최종 항목으로 바꿔 끼워야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 보통 같은 id를 가진 항목을 응답 객체로 교체하거나 병합하는 식입니다. 초보자 기준에서는 서버가 완전한 항목을 돌려준다면 교체가 더 읽기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 삭제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제는 응답에 실제 항목 객체가 다시 오는 경우보다 성공 여부만 오는 경우가 많습니다. 그래서 이 경우에는 &quot;응답으로 교체&quot;가 아니라 &quot;삭제 성공을 확정&quot;하는 흐름으로 보는 편이 맞습니다. 낙관적 삭제였다면 실패 시 이전 snapshot으로 복구할 수도 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;동작별로 응답 반영 포인트가 조금 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;초보자에게 무난한 반영 방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답으로 온 새 객체를 목록에 반영&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id와 시간 같은 최종값이 서버에서 확정될 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 id 항목을 응답 객체로 교체하거나 병합&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리된 text, updatedAt 등 최종 저장값을 맞출 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 여부에 따라 삭제 확정 또는 복구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 객체가 없더라도 저장 성공은 확정해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &quot;서버 응답을 믿는다&quot;는 말도 동작마다 조금씩 다른 모양을 가질 수 있습니다. 생성과 수정은 응답 객체 자체가 중요하고, 삭제는 성공 여부와 최종 확정 시점이 더 중요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;생성, 수정, 삭제는 모두 저장이지만 응답을 화면에 반영하는 방법까지 같을 필요는 없다. 중요한 건 동작별로 기준이 분명해야 한다는 점이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 응답 반영 규칙을 다루기 시작하면 상태 관리가 한 단계 더 정교해집니다. 동시에 자주 나오는 실수도 꽤 분명합니다. 대부분은 &quot;내가 보낸 값&quot;과 &quot;서버가 확정한 값&quot;의 경계를 흐리게 볼 때 나옵니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 왔는데도 로컬 nextTodos만 계속 믿는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id, updatedAt, 정리된 text가 어긋날 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최종 저장본 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 최신 객체를 돌려주면 그 값을 우선 반영하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목을 전체 교체해야 할지 병합해야 할지 매번 다르게 처리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 기능은 잘 맞고 어떤 기능은 값이 빠진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 반영 기준이 일관되지 않다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;API가 돌려주는 구조에 맞춰 반영 규칙을 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 응답의 새 id를 무시한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이후 수정이나 삭제 요청이 이상하게 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버와 클라이언트가 서로 다른 항목 id를 보고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성 응답은 최종 식별자를 맞추는 단계로 보는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 도착 순서를 생각하지 않고 무조건 반영한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 응답이 최신 상태를 덮어쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;requestId 같은 최신성 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 반영 전 최신 요청 여부를 한 번 더 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클라이언트 전용 임시 필드까지 전체 교체로 날려버린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 상태나 임시 선택 상태가 예상과 다르게 사라질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 데이터와 UI 임시 상태가 같은 객체 안에 섞여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;UI 임시 상태는 별도 상태로 분리하는 편이 훨씬 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제도 생성이나 수정처럼 응답 객체 반영만 생각한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 성공 시점 확정과 rollback 기준이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동작별 응답 의미를 구분하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제는 성공 여부와 복구 흐름을 더 우선해서 보는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 점점 기능이 많아질수록 더 중요해집니다. 서버 응답으로 전체 교체를 하려면 서버 데이터와 UI 임시 상태를 애초에 다른 층위로 보는 습관이 필요합니다. 예를 들어 editingId, isSorting 같은 값이 todo 객체 안에 섞여 있다면 전체 교체를 할 때마다 같이 흔들릴 수 있습니다. 그래서 이 시점에서 다시 한 번 &quot;todos는 실제 데이터, uiState는 화면 상태&quot;라는 구분이 중요해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;서버 응답 반영이 이상할 때는 응답 자체보다 &quot;이 값을 어디까지 최종 진실로 볼 것인가&quot;가 아직 코드에 분명히 안 잡혀 있는 경우가 더 많다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장 구조를 한 단계 더 이해하려면, 요청을 보내는 것만큼이나 응답을 어떻게 받아들이느냐도 중요합니다. 내가 보낸 값이 전부라고 생각하면 작은 예제에서는 버틸 수 있어도, 실제 저장 구조가 붙기 시작하면 금방 흔들립니다. 반대로 &quot;서버가 돌려준 최종본을 어떤 기준으로 반영할 것인가&quot;가 분명해지면, 생성과 수정과 삭제 흐름도 훨씬 덜 헷갈리게 정리됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 서버 저장에서는 요청값과 최종 저장값이 항상 같지 않을 수 있다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 서버가 최신 객체를 응답으로 준다면 그 값을 최종 저장본으로 우선 반영하는 편이 입문자에게 가장 안전하다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 전체 교체와 부분 병합은 둘 다 가능하지만, 중요한 건 매번 같은 기준으로 일관되게 쓰는 것이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 저장 구조는 &quot;요청을 보낸다&quot;를 넘어서 &quot;최종 저장본을 무엇으로 본다&quot;까지 설명할 수 있는 단계로 들어갑니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;클라이언트에서 임시 id를 써서 먼저 화면에 보여준 항목을 서버가 돌려준 진짜 id와 어떻게 자연스럽게 연결할지&lt;/b&gt;, 즉 생성 흐름에서 자주 나오는 &quot;임시 항목&quot; 처리 문제를 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>API응답처리</category>
      <category>updatedAt</category>
      <category>부분병합</category>
      <category>서버응답반영</category>
      <category>전체교체</category>
      <category>클라이언트상태갱신</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/55</guid>
      <comments>https://story86025.tistory.com/55#entry55comment</comments>
      <pubDate>Tue, 12 May 2026 17:00:22 +0900</pubDate>
    </item>
    <item>
      <title>26. 지금 저장됐는지 어떻게 보여줄까: &amp;quot;저장 중&amp;quot;, &amp;quot;저장됨&amp;quot;, &amp;quot;실패&amp;quot; 상태 설계하기</title>
      <link>https://story86025.tistory.com/49</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 저장을 붙이고 나면, 기능 자체보다 더 예민하게 느껴지는 것이 하나 있습니다. 바로 &lt;b&gt;&quot;지금 저장된 건가?&quot;&lt;/b&gt; 하는 감각입니다. 저장 버튼이 있는 구조에서는 누르고 끝났다는 느낌이 비교적 분명합니다. 하지만 자동 저장은 다릅니다. 사용자는 그냥 입력만 했는데, 앱이 뒤에서 알아서 저장을 시도합니다. 그래서 오히려 더 자주 불안해질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 중요한 건 저장 로직만이 아닙니다. &lt;b&gt;저장 상태를 어떻게 보여주느냐&lt;/b&gt;도 거의 같은 비중으로 중요해집니다. 입력 직후에는 아직 저장 전일 수 있고, debounce 대기 중일 수 있고, 실제 요청을 보내는 중일 수도 있고, 이미 저장이 끝났을 수도 있고, 실패했을 수도 있습니다. 그런데 화면이 이 차이를 제대로 설명해주지 않으면 사용자는 &quot;방금 입력한 게 사라지는 건 아닌가?&quot;, &quot;네트워크가 느린 건가?&quot;, &quot;저장 안 된 건가?&quot; 같은 불안을 느끼기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 &quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot; 같은 문구가 왜 단순한 장식이 아니라 저장 흐름의 일부인지, 초보자라면 상태를 몇 단계로 나눠 보면 좋은지, 그리고 어떤 순간에 어떤 문구를 보여줘야 덜 불안하고 덜 시끄러운 UI가 되는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장은 되는데 왠지 불안하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 지금 어느 단계인지 알 수 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 흐름을 단계별 문구로 분리해서 보여주는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장됨&quot;이 너무 자주 떠서 오히려 거슬린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 문구를 바꿔야 할 순간과 유지해야 할 순간이 섞여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문구 전환 타이밍을 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했는데도 아무 변화가 없어 더 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveError 같은 상태가 화면에 제대로 반영되지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 상태도 별도 UI 상태로 다루는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 직후인데도 바로 &quot;저장됨&quot;처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce 대기 중과 실제 저장 완료를 구분하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;아직 저장 전&quot;과 &quot;저장 완료&quot;를 다른 상태로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;자동 저장 UI에서 중요한 건 문구를 많이 보여주는 것이 아니라, 사용자가 지금 &quot;저장 전&quot;, &quot;저장 중&quot;, &quot;저장 완료&quot;, &quot;실패&quot; 중 어디에 있는지 덜 헷갈리게 만드는 것이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 저장 상태 문구가 생각보다 중요할까&lt;/li&gt;
&lt;li&gt;&quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot;는 사실 서로 다른 단계다&lt;/li&gt;
&lt;li&gt;초보자에게 가장 무난한 최소 상태 모델&lt;/li&gt;
&lt;li&gt;상태 문구는 언제 바뀌어야 자연스러울까&lt;/li&gt;
&lt;li&gt;상태를 보여주는 UI는 어디에 두는 게 좋을까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;자동 저장 구조에서 상태 문구가 중요한 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;문구가 없으면&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;문구가 있으면&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 직후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장됐는지 아닌지 감이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 예정&quot; 또는 &quot;변경됨&quot; 같은 단서로 현재 상태를 이해할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 전송 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱이 멈춘 건지, 기다리는 건지 알기 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 중&quot;으로 현재 대기 상태를 설명할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아무 반응이 없어 더 불안해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패와 재시도 필요성을 분명히 알려줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;자동 저장 UI에서 상태 문구는 장식이 아니라 &quot;지금 내가 안심해도 되는가&quot;를 알려주는 설명 장치에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot;는 사실 서로 다른 단계다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 가장 자주 놓치는 부분은 여기입니다. &quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot;를 그냥 서로 다른 문구라고만 생각하는 경우가 많습니다. 하지만 실제로는 문구의 차이가 아니라 &lt;b&gt;상태 단계의 차이&lt;/b&gt;입니다. 즉, UI 텍스트가 다를 뿐이 아니라, 각각 다른 의미를 가진 상태입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 분해해보면 자동 저장은 생각보다 더 세분화될 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;자동 저장 UI에서 자주 쓰는 상태 단계&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;보여줄 수 있는 예시 문구&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;idle&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 특별한 변화가 없는 평소 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시 없음 또는 최근 저장 시각만 조용히 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dirty&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력은 바뀌었지만 아직 저장은 시작되지 않은 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;변경됨&quot;, &quot;저장 대기 중&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 요청을 보내고 응답을 기다리는 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 중&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saved&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;가장 최근 변경이 성공적으로 저장된 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장됨&quot;, &quot;방금 저장됨&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;error&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 시도는 했지만 실패한 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 실패&quot;, &quot;다시 시도해 주세요&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 특히 중요한 건 dirty 상태입니다. 많은 초보자 UI에서는 이 상태가 빠져 있습니다. 입력이 바뀐 직후인데도 바로 &quot;저장 중&quot; 또는 &quot;저장됨&quot;처럼 보여버리면, debounce 대기 중인지 실제 요청이 끝난 건지 구분이 흐려집니다. 즉, dirty는 소리 없이 지나가는 단계 같지만 자동 저장 UI에서는 꽤 중요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;&quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot;는 문구 차이가 아니라 상태 단계 차이다. 그래서 문구를 바꾸기 전에 상태 정의부터 분명해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
autoSaveTimer: null,
isDirty: false,
isSaving: false,
saveError: &quot;&quot;,
lastSavedAt: null
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 조금 풀어서 보면 이렇습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음 버전에서 이 정도면 충분한 상태들&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태 이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떤 문구와 연결되기 쉬운가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isDirty&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력은 바뀌었지만 아직 저장 완료는 아님&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;변경됨&quot;, &quot;저장 대기 중&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청을 보내고 기다리는 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 중&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 안내 문구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 실패&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;lastSavedAt&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막 저장 성공 시각&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;방금 저장됨&quot;, &quot;1분 전 저장됨&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 saveError는 문자열을 그대로 둘 수도 있고, 단순히 불리언처럼 써도 됩니다. 하지만 입문자에게는 문구를 바로 담는 쪽이 오히려 이해하기 쉬운 경우가 많습니다. lastSavedAt은 꼭 처음부터 시각 포맷을 복잡하게 다룰 필요는 없습니다. 지금은 &quot;성공 저장이 있었다&quot;를 보여주는 기준 정도로만 봐도 충분합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 구조는 단순할수록 좋다&lt;/b&gt;&lt;br /&gt;자동 저장 UI는 상태를 세밀하게 쪼개기보다, &quot;아직 저장 전&quot;, &quot;저장 중&quot;, &quot;실패&quot;, &quot;방금 저장됨&quot; 정도만 분명해도 체감이 크게 좋아진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;입니다. 같은 &quot;저장됨&quot;이라도 너무 빨리 바뀌면 아직 요청도 안 나간 것처럼 느껴질 수 있고, 너무 오래 남으면 다시 입력을 시작했는데도 이미 끝난 일처럼 보일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 기준으로는 아래 순서가 가장 설명하기 쉽습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;입력 시작&lt;/b&gt;&lt;br /&gt;isDirty를 true로 바꾸고, 이전 saveError는 지운다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;debounce 대기 중&lt;/b&gt;&lt;br /&gt;&quot;변경됨&quot; 또는 &quot;저장 대기 중&quot;을 보여줄 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실제 요청 시작&lt;/b&gt;&lt;br /&gt;isSaving을 true로 바꾸고 &quot;저장 중&quot;을 보여준다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성공&lt;/b&gt;&lt;br /&gt;isSaving을 false로 만들고, isDirty를 false로 만들고, lastSavedAt을 갱신한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패&lt;/b&gt;&lt;br /&gt;isSaving은 false로 바꾸고, saveError를 채우고, isDirty는 유지할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;자동 저장 UI 문구 전환의 기본 흐름&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순간&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;보여줄 수 있는 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 이 타이밍이 자연스러운가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 직후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;변경됨&quot;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 실제 저장은 안 됐다는 걸 알려줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 전송 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 중&quot;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 기다려야 하는 단계임을 설명할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 직후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장됨&quot;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;방금 반영됐다는 안심을 줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 직후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장 실패&quot;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금은 안심할 수 없는 상태임을 분명히 알려준다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 같은 문구를 오래 붙잡고 있지 않는 것입니다. 예를 들어 사용자가 새로 입력을 시작했는데도 여전히 &quot;저장됨&quot;이 그대로 남아 있으면, 지금 입력한 새 값까지 저장된 것처럼 착각할 수 있습니다. 그래서 입력이 다시 시작되면 성공 문구보다 현재 변경 상태를 우선하는 편이 자연스럽습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;상태 문구는 &quot;정확한 문구&quot;보다 &quot;정확한 타이밍&quot;이 더 중요할 때가 많다. 새 입력이 시작되면 이전 성공 문구보다 현재 변경 상태를 우선해서 보여주는 편이 훨씬 자연스럽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태를 보여주는 UI는 어디에 두는 게 좋을까&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;/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;br /&gt;수정 중인 항목 바로 아래나 옆에 두면 지금 입력과 가장 잘 연결된다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;화면 상단 또는 하단의 상태 줄&lt;/b&gt;&lt;br /&gt;전체 앱 기준의 저장 상태를 한 줄로 보여주기에 좋다&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;저장 상태 UI를 둘 때 자주 쓰는 위치&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;위치&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;잘 맞는 상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창 아래&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 중인 한 항목과 직접 연결된 저장 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목이 많아지면 문구가 여러 군데 흩어질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 상단 또는 하단 상태 줄&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱 전체 기준의 저장 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 입력과 연결된 상태인지 약해질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 우선 한 군데만 정해서 일관되게 쓰는 편이 더 낫습니다. 처음부터 토스트, 인라인 메시지, 상단 배너를 모두 섞으면 &quot;어떤 상태는 어디서 보여주는가&quot;가 다시 헷갈리기 쉽습니다. 작은 앱에서는 입력창 아래 한 줄 또는 화면 하단 상태 줄 하나만으로도 충분히 안정적인 설명이 가능합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁&lt;/b&gt;&lt;br /&gt;자동 저장 상태는 알림처럼 크게 외치기보다, 사용자가 입력하고 있는 곳 가까이에서 조용히 설명해주는 편이 훨씬 덜 거슬리고 더 이해하기 쉽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 상태 UI를 붙이기 시작하면 기능은 더 친절해 보이지만, 동시에 몇 가지 흔한 실수도 같이 나타납니다. 대부분은 &quot;상태를 세분화하지 않았거나&quot;, &quot;보여주는 타이밍이 어긋난 경우&quot;입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 직후인데 바로 &quot;저장됨&quot;처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 요청도 안 나갔는데 이미 끝난 느낌을 준다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dirty 상태가 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 직후와 저장 완료를 다른 상태로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장됨&quot; 문구가 너무 오래 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새로 입력한 값까지 저장된 것처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 입력 시작 시 상태 문구 전환이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 다시 시작되면 dirty 상태를 우선한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했는데도 아무 메시지가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 조용히 저장된 줄 알기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveError 상태가 화면에 연결되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패는 숨기기보다 명확하게 보여주는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 문구를 너무 크게 띄운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장이 자꾸 사용자 행동을 끊는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;조용한 상태 안내가 과한 경고처럼 변했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장 상태는 작은 상태 줄처럼 두는 편이 자연스럽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce를 썼는데도 저장 중 문구가 계속 깜빡인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 저장 전 대기 상태와 저장 중 상태를 구분하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dirty와 isSaving이 같은 문구로 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;변경됨&quot;과 &quot;저장 중&quot;을 나눠서 보는 편이 훨씬 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 시각을 계속 갱신하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장됨&quot;이 언제 기준인지 감이 안 잡힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;lastSavedAt 같은 기준이 없거나 안 갱신된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막 성공 저장 시각이나 기준 텍스트를 하나 두는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 첫 번째와 다섯 번째 실수는 같이 묶여서 나오는 경우가 많습니다. debounce를 붙였는데도 저장 상태가 어색한 이유는 대개 &quot;입력이 바뀐 상태&quot;와 &quot;요청을 보내는 상태&quot;를 같은 문구로 보여주기 때문입니다. 사용자는 이 둘을 꽤 다르게 느낍니다. 그래서 상태 문구를 설계할 때도 저장 로직처럼 단계 구분이 필요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;자동 저장 UI가 어색할 때는 문구 자체보다 &quot;지금이 입력 후 대기인지, 실제 저장 중인지, 이미 성공했는지&quot;를 상태가 정말 분리해서 설명하고 있는지부터 보는 편이 훨씬 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 저장 UI에서 &quot;저장 중&quot;, &quot;저장됨&quot;, &quot;실패&quot; 같은 문구는 사소한 문장처럼 보일 수 있습니다. 하지만 실제로는 저장 구조를 사용자에게 번역해주는 역할을 합니다. 입력 직후, debounce 대기 중, 실제 요청 중, 성공, 실패를 구분해서 보여주기 시작하면 앱은 훨씬 덜 불안하게 느껴지고, 사용자는 지금 무엇을 기다려야 하는지 더 쉽게 이해할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 상태 문구는 장식이 아니라 현재 저장 단계를 설명하는 기능이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, dirty, saving, saved, error 같은 상태를 최소한으로 나눠 보는 편이 자동 저장 UI를 훨씬 자연스럽게 만든다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 좋은 자동 저장 UI는 시끄럽게 많이 말하는 것이 아니라, 필요한 순간에만 현재 단계를 분명하게 보여주는 쪽에 더 가깝다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 저장 기능은 단순히 &quot;동작한다&quot;를 넘어서, 사용자가 안심할 수 있게 설명하는 단계로 들어갑니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;서버 저장 응답에 id, updatedAt, 정리된 text 같은 값이 함께 돌아올 때 클라이언트 상태를 어떤 기준으로 갱신해야 하는지&lt;/b&gt;, 즉 &quot;서버를 신뢰하는 업데이트&quot; 감각을 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>autosaveUI</category>
      <category>lastSavedAt</category>
      <category>UX상태표시</category>
      <category>상태문구설계</category>
      <category>저장상태UI</category>
      <category>저장중저장됨실패</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/49</guid>
      <comments>https://story86025.tistory.com/49#entry49comment</comments>
      <pubDate>Thu, 7 May 2026 18:00:50 +0900</pubDate>
    </item>
    <item>
      <title>25. 입력할 때마다 저장하면 왜 더 불편할까: 바이브코딩 debounce 입문</title>
      <link>https://story86025.tistory.com/48</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서는 저장 요청이 겹칠 때 먼저 보낸 응답이 나중에 도착해서 화면을 다시 망가뜨리는 문제, 즉 race condition을 다뤘습니다. 여기까지 오면 자연스럽게 다음 질문이 나옵니다. &lt;b&gt;&quot;그럼 요청이 겹치지 않게 덜 자주 보내면 되지 않을까?&quot;&lt;/b&gt; 실제로 자동 저장을 붙여보면 많은 사람이 바로 이 고민에 닿습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 &quot;입력할 때마다 바로 저장하면 더 안전한 것 아닌가?&quot;라고 느끼기 쉽습니다. 하지만 실제 사용감은 꼭 그렇지 않습니다. 글자 하나 입력할 때마다 요청이 나가면 저장 중 표시가 계속 깜빡이고, 응답이 겹치고, 네트워크가 조금만 느려도 화면이 불안하게 느껴집니다. 사용자는 &quot;자동 저장&quot;을 원하지, &quot;매 타이핑마다 요청 보내기&quot;를 원하는 건 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 자주 쓰는 방식이 &lt;b&gt;debounce&lt;/b&gt;입니다. 이름은 낯설 수 있지만 뜻은 단순합니다. 입력이 멈춘 뒤 잠깐 기다렸다가, 그때 한 번만 저장을 보내는 방식입니다. 이번 글에서는 debounce가 왜 자동 저장에서 자주 쓰이는지, 무엇을 해결해주고 무엇은 여전히 따로 챙겨야 하는지, 그리고 초보자 기준에서는 어떤 상태를 들고 가면 가장 덜 흔들리는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력할 때마다 요청을 보내니 저장 상태가 계속 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 사용 흐름보다 요청 빈도가 지나치게 높다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 멈춘 뒤 한 번만 저장하게 만드는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장인데 오히려 더 버벅인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 이벤트마다 무거운 저장 흐름이 실행된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce로 요청 횟수를 줄이는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce를 넣었는데도 가끔 예전 값이 돌아온다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 수는 줄었지만 오래된 응답 처리 규칙은 여전히 필요하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;requestId 같은 최신 요청 기준이 남아 있는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창을 닫았는데 몇 초 뒤 저장이 뒤늦게 실행된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예약된 timer를 정리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 종료 시 timer를 취소하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;debounce는 자동 저장을 없애는 기술이 아니라, 사용자가 입력하는 리듬에 맞춰 저장 요청 빈도를 정리해주는 기술에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 입력할 때마다 바로 저장하면 금방 불편해질까&lt;/li&gt;
&lt;li&gt;debounce는 정확히 무엇인가&lt;/li&gt;
&lt;li&gt;debounce는 무엇을 해결하고, 무엇은 못 해결할까&lt;/li&gt;
&lt;li&gt;자동 저장은 어떤 상태를 들고 가야 할까&lt;/li&gt;
&lt;li&gt;가장 단순한 첫 흐름: 입력 중지 후 잠깐 기다렸다가 저장&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 입력할 때마다 바로 저장하면 금방 불편해질까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 자동 저장을 붙일 때 자주 나오는 생각은 이것입니다. &quot;입력값이 바뀔 때마다 그냥 바로 저장하면 가장 최신 상태가 계속 유지되겠지.&quot; 얼핏 합리적으로 들립니다. 하지만 실제 입력 흐름은 저장 흐름보다 훨씬 빠릅니다. 사용자는 한 단어를 완성하기 전까지 여러 글자를 연속으로 입력하고, 지우고, 다시 쓰기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &quot;장보기&quot;를 &quot;주말 장보기&quot;로 고치는 상황을 생각해보면, 실제 사용자는 몇 글자를 연속으로 입력할 뿐입니다. 그런데 입력마다 저장 요청을 보내면 앱 입장에서는 &quot;ㅈ&quot;, &quot;주&quot;, &quot;주말&quot;, &quot;주말 &quot;, &quot;주말 장&quot;, &quot;주말 장보&quot;, &quot;주말 장보기&quot;처럼 아주 많은 중간 상태를 다 저장하려고 하게 됩니다. 사용자 의도는 마지막 값 하나인데, 앱은 모든 중간값에 반응하는 셈입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;입력마다 저장을 보내면 왜 금방 불편해질까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자 입장&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;앱 입장에서 실제로 벌어지는 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그냥 한 문장을 입력 중이다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;짧은 시간 안에 여러 저장 요청 후보가 생긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 생각을 정리하는 중이다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간값까지 실제 저장 대상으로 취급할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠르게 타이핑하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청이 겹치고 race condition 가능성도 커진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 입력마다 저장을 보내는 방식은 &quot;더 자주 저장하니 더 안전할 것 같다&quot;는 직관과 달리, 실제로는 &lt;b&gt;불필요한 요청 수를 늘리고 저장 상태를 더 흔들리게 만들 수 있습니다.&lt;/b&gt; 그래서 자동 저장은 &quot;입력 이벤트가 발생했다&quot;와 &quot;지금 저장해도 되는 순간이다&quot;를 같은 의미로 보지 않는 편이 중요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;자동 저장에서 중요한 건 &quot;얼마나 자주 저장하느냐&quot;보다 &quot;사용자의 입력 흐름을 얼마나 잘 끊어서 저장하느냐&quot;에 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;debounce는 정확히 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;debounce를 아주 단순하게 설명하면 이렇습니다. &lt;b&gt;이벤트가 연속으로 들어올 때, 마지막 입력 이후 일정 시간 동안 추가 입력이 없으면 그때 한 번만 실행하는 방식&lt;/b&gt;입니다. 자동 저장 문맥에서는 &quot;입력이 멈춘 뒤 잠깐 기다렸다가 저장한다&quot;로 받아들이면 거의 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 debounce 시간이 700ms라고 해보겠습니다. 사용자가 계속 타이핑 중이면 타이머는 계속 뒤로 밀립니다. 그리고 사용자가 손을 멈추고 700ms 동안 추가 입력이 없을 때, 그제야 저장 요청을 한 번 보냅니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;debounce를 시간 흐름으로 보면 이런 느낌이다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;시간 흐름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 일이 일어나나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 글자 입력&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 timer를 700ms 뒤로 예약한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;바로 다음 글자 입력&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 timer를 취소하고 다시 700ms 뒤로 예약한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;계속 입력 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;timer는 계속 뒤로 밀린다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 멈춘 뒤 700ms 경과&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그때 저장 요청을 한 번 보낸다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 자동 저장과 잘 맞는 이유는 분명합니다. 사용자가 &quot;한 덩어리의 입력&quot;을 끝낸 시점을 기다렸다가 저장하게 만들기 때문입니다. 그래서 입력마다 요청을 보내는 것보다 훨씬 안정적이고, 사용감도 더 자연스럽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드로 보면 보통 이런 형태가 가장 익숙합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function scheduleAutoSave() {

if (uiState.autoSaveTimer) {
clearTimeout(uiState.autoSaveTimer);
}

uiState.autoSaveTimer = setTimeout(() =&amp;gt; {
runAutoSave();
}, 700);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 두 줄입니다. 이전 timer가 있으면 &lt;b&gt;clearTimeout&lt;/b&gt;으로 취소하고, 마지막 입력 기준으로 새 timer를 다시 잡는다는 점입니다. 이게 debounce의 가장 기본적인 형태입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;debounce는 &quot;입력할 때마다 실행&quot;이 아니라 &quot;입력이 잠깐 멈춘 뒤 한 번만 실행&quot;으로 리듬을 바꾸는 방식이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;debounce는 무엇을 해결하고, 무엇은 못 해결할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 꼭 짚고 넘어가야 할 점이 있습니다. debounce는 꽤 유용하지만 만능은 아닙니다. 요청 횟수를 줄여주고, 입력마다 저장을 보내는 불편을 줄여주고, race condition 가능성도 어느 정도 낮춰줍니다. 하지만 &lt;b&gt;비동기 저장에서 생기는 모든 문제를 대신 해결해주지는 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;debounce가 잘하는 일과 못 하는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;해결에 도움 되는가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력마다 요청이 너무 자주 나가는 문제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 멈춘 뒤 한 번만 실행하게 만들기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중 저장 상태가 계속 깜빡이는 문제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 시작 시점 자체가 줄어든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오래된 응답이 최신 화면을 덮어쓰는 문제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;부분적으로만&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 수를 줄이지만, 겹친 응답 자체를 완전히 없애지는 못한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 실패 시 rollback&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;되돌리기 규칙은 별도로 설계해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최신 요청 식별&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;requestId 같은 최신성 기준은 여전히 필요할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, debounce는 &quot;요청을 덜 자주 보내게 만드는 도구&quot;에 가깝고, &quot;돌아온 응답 중 무엇을 믿을지 정하는 도구&quot;는 아닙니다. 그래서 이전 글에서 다룬 requestId 같은 최신 요청 구분 전략이 여전히 같이 필요할 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;debounce는 요청 수를 줄여주지만, 최신 요청과 오래된 응답을 구분하는 문제까지 대신 해결해주지는 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자동 저장은 어떤 상태를 들고 가야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 저장을 붙이기 시작하면 기존 편집 상태 위에 몇 가지 상태가 더 필요해집니다. 입문자에게는 이 부분이 꽤 중요합니다. 왜냐하면 자동 저장은 &quot;입력 중&quot;, &quot;저장 예약 중&quot;, &quot;저장 중&quot;, &quot;실패&quot;를 구분해서 봐야 하기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
autoSaveTimer: null,
autoSaveDelay: 700,
isSaving: false,
saveError: &quot;&quot;,
latestRequestId: 0,
lastSavedText: &quot;&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;자동 저장에서 자주 쓰는 상태들&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태 이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;autoSaveTimer&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예약된 저장 timer&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 입력이 들어오면 이전 예약을 취소하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;autoSaveDelay&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;얼마나 기다린 뒤 저장할지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 리듬과 저장 빈도를 조절하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 저장 요청이 진행 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 동작과 저장 상태 표시를 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;latestRequestId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;가장 최신 요청 번호&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오래된 응답을 무시하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;lastSavedText&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막으로 성공 저장된 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 내용을 또 저장하지 않게 돕기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 lastSavedText는 자동 저장에서 꽤 유용합니다. 사용자가 입력했다가 다시 원래 값으로 돌려놨다면, 굳이 똑같은 저장 요청을 반복해서 보낼 필요가 없을 수 있기 때문입니다. 물론 실제 앱마다 기준은 달라질 수 있지만, 초보자에게는 &quot;마지막으로 성공한 값&quot;을 하나 기억해두는 발상이 자동 저장 흐름을 이해하는 데 꽤 도움이 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;자동 저장은 입력창 하나만 보는 기능이 아니다. 예약 상태, 저장 중 상태, 최신 요청 기준, 마지막 저장 성공값까지 같이 봐야 훨씬 안정된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;입력값이 바뀌면 editDraft를 갱신한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기존 autoSaveTimer가 있으면 취소한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새 timer를 예약한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일정 시간 동안 추가 입력이 없으면 runAutoSave를 실행한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실행 직전 검증하고, lastSavedText와 같은 값이면 건너뛴다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;요청을 보내고, requestId로 최신 응답만 반영한다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleEditInput(value) {

uiState.editDraft = value;
uiState.saveError = &quot;&quot;;

scheduleAutoSave();
renderTodos();
}

function scheduleAutoSave() {
if (uiState.autoSaveTimer) {
clearTimeout(uiState.autoSaveTimer);
}

uiState.autoSaveTimer = setTimeout(() =&amp;gt; {
runAutoSave();
}, uiState.autoSaveDelay);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 자동 저장 실행은 이렇게 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function runAutoSave() {

if (uiState.editingId === null) return;

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
uiState.saveError = &quot;내용을 확인해 주세요.&quot;;
renderTodos();
return;
}

if (result.value === uiState.lastSavedText) return;

const requestId = ++uiState.latestRequestId;
uiState.isSaving = true;
uiState.saveError = &quot;&quot;;
renderTodos();

const nextTodos = todos.map(todo =&amp;gt;
todo.id === uiState.editingId
? { ...todo, text: result.value }
: todo
);

try {
const savedTodos = await saveTodosToServer(nextTodos);

if (requestId !== uiState.latestRequestId) return;

todos = savedTodos;
uiState.lastSavedText = result.value;

} catch (error) {
if (requestId !== uiState.latestRequestId) return;

uiState.saveError = &quot;자동 저장에 실패했습니다.&quot;;

} finally {
if (requestId === uiState.latestRequestId) {
uiState.isSaving = false;
renderTodos();
}
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 편집을 취소하거나 종료할 때는 예약된 timer도 같이 정리해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function stopEditing() {

if (uiState.autoSaveTimer) {
clearTimeout(uiState.autoSaveTimer);
uiState.autoSaveTimer = null;
}

uiState.editingId = null;
uiState.editDraft = &quot;&quot;;
uiState.saveError = &quot;&quot;;

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이 흐름이 초보자에게 특히 좋은 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 도움이 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 수가 크게 줄어든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력마다 보내지 않고 멈춘 뒤 한 번만 보내기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 설명 가능하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력, 예약, 실제 저장, 종료 정리가 분명하게 나뉜다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 글의 requestId 구조와도 자연스럽게 이어진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 수를 줄이면서 오래된 응답 무시까지 같이 적용할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 현실적인 첫 원칙&lt;/b&gt;&lt;br /&gt;자동 저장을 처음 붙일 때는 &quot;마지막 입력 이후 잠깐 기다렸다가 한 번 저장&quot;하는 구조만 안정화해도 체감이 크게 좋아진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;debounce를 붙이기 시작하면 저장 요청은 줄지만, 동시에 또 다른 실수도 자주 보이기 시작합니다. 특히 timer 정리와 최신 요청 처리에서 많이 흔들립니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;clearTimeout 없이 setTimeout만 계속 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce처럼 보이지만 실제로는 여러 저장이 그대로 실행된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 예약을 취소하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 예약 전에 기존 timer를 먼저 지우는 편이 기본이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 종료 후 timer를 안 지운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창을 닫았는데도 뒤늦게 자동 저장이 돈다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;종료 시 예약 정리가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소, 저장 완료, 화면 전환 시 timer 정리까지 같이 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce를 넣었으니 requestId는 필요 없다고 생각한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청은 줄었는데 오래된 응답 문제는 가끔 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 빈도 문제와 응답 최신성 문제를 같은 것으로 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;debounce와 requestId는 서로 다른 문제를 푼다고 보는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 저장된 값과 같은데도 매번 다시 저장한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불필요한 요청이 계속 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;lastSavedText 같은 비교 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막 성공 저장값과 비교해볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving을 이유로 아예 새 예약을 막아버린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 계속 수정해도 마지막 값이 늦게 반영되거나 빠질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;새 입력 예약&quot;과 &quot;현재 요청 진행&quot;을 같은 것으로 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 중이어도 최신 draft를 위한 새 예약은 필요할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 메시지를 계속 남겨둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 정상 입력인데도 계속 실패한 것처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 입력이나 성공 저장 이후 상태 정리가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 다시 바뀌면 saveError를 비우는 편이 자연스럽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 자동 저장을 처음 붙일 때 꽤 자주 나옵니다. 저장 중이니까 아무것도 하지 않게 만들면 간단해 보이지만, 실제로는 사용자가 그 사이에 입력한 더 최신 값이 반영될 기회를 놓칠 수 있습니다. 그래서 자동 저장에서는 &quot;중복 클릭 잠금&quot;과 &quot;최신 입력 예약&quot;을 조금 다르게 볼 필요가 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;debounce를 붙였는데도 자동 저장이 이상하면, 저장 요청 함수 자체보다 &quot;timer를 언제 만들고 언제 지우는가&quot;를 먼저 다시 보는 편이 훨씬 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 저장에서 debounce를 이해하기 시작하면, 저장은 더 이상 &quot;입력 이벤트마다 곧바로 실행되는 일&quot;이 아니라 &quot;입력이 잠깐 멈춘 뒤 한 번 정리해서 실행되는 일&quot;로 보이기 시작합니다. 이 차이가 생기면 저장 요청 수가 줄고, 화면도 덜 흔들리고, race condition을 다룰 때도 한층 더 구조적으로 볼 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, debounce는 사용자의 입력 리듬에 맞춰 저장 시점을 늦추는 방식이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, debounce는 요청 수를 줄여주지만 최신 응답 판단까지 대신하진 않기 때문에 requestId 같은 기준이 여전히 중요하다는 점. 셋째, 자동 저장에서는 timer, isSaving, saveError, lastSavedText 같은 상태를 같이 들고 가야 훨씬 안정적이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 저장 구조는 단순한 버튼 저장을 넘어, 사용자의 입력 흐름까지 고려하는 단계로 올라옵니다. 다음 글에서는 이 흐름을 이어서, &lt;b&gt;자동 저장이 들어오면 &quot;저장됨&quot;, &quot;저장 중&quot;, &quot;실패&quot; 같은 상태 문구를 언제 어떤 기준으로 보여줘야 덜 불안한지&lt;/b&gt;, 즉 저장 상태를 사용자에게 설명하는 UI 흐름을 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>Autosave</category>
      <category>clearTimeout</category>
      <category>debounce</category>
      <category>settimeout</category>
      <category>입력지연저장</category>
      <category>자동저장</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/48</guid>
      <comments>https://story86025.tistory.com/48#entry48comment</comments>
      <pubDate>Tue, 5 May 2026 18:00:17 +0900</pubDate>
    </item>
    <item>
      <title>24. 먼저 보낸 저장이 나중에 도착하면 왜 꼬일까: 바이브코딩 race condition 입문</title>
      <link>https://story86025.tistory.com/45</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 서버 응답을 기다리지 않고 화면을 먼저 바꾸는 &quot;낙관적 업데이트&quot;를 다뤘습니다. 여기까지 오면 바로 다음 문제가 보입니다. 화면은 빨라졌는데, 가끔 저장 결과가 이상하게 되돌아가는 경우입니다. 분명 마지막에 &quot;주말 장보기&quot;로 바꿨는데, 잠시 뒤 예전 값인 &quot;장보기&quot;로 다시 돌아가는 식입니다. 처음 보면 꽤 당황스럽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 코드 한 줄이 틀려서라기보다, &lt;b&gt;여러 요청이 겹쳐 있을 때 어느 응답이 최종값이 되어야 하는지 규칙이 없어서&lt;/b&gt; 생기는 경우가 많습니다. 특히 자동 저장, 연속 수정, 빠른 토글, 낙관적 업데이트가 붙기 시작하면 같은 항목에 대한 요청이 짧은 시간 안에 여러 번 나갈 수 있습니다. 그리고 네트워크 응답은 보낸 순서대로 꼭 돌아오지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 먼저 보낸 요청보다 나중에 도착한 응답이 더 위험할 수 있는지, &quot;race condition&quot;을 입문자 기준으로 어떻게 이해하면 되는지, 그리고 가장 단순한 첫 방어선으로 requestId를 두는 방식이 왜 유용한지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막에 수정한 값이 다시 예전 값으로 돌아간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;늦게 도착한 오래된 응답이 최신 화면을 덮어썼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;지금 가장 최신 요청이 무엇인지&quot;를 따로 추적하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 잠금을 넣었는데도 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving은 중복 클릭을 막아도, 겹친 응답 순서까지 해결하지는 못한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 순서를 판단하는 별도 기준이 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장을 붙였더니 더 자주 이상해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;짧은 시간에 여러 요청이 연속으로 나가면서 응답 도착 순서가 엇갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장일수록 requestId나 취소 전략이 더 중요해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트는 빨라졌는데 실패 시 설명이 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최신 요청인지 오래된 요청인지 구분 없이 rollback이 섞였다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;rollback도 최신 요청 기준으로만 하도록 설계하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;비동기 저장에서는 &quot;무엇을 보냈는가&quot;만큼이나 &quot;어떤 응답이 마지막으로 도착했는가&quot;가 중요하다. 그래서 최신 요청을 구분하는 기준이 꼭 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 먼저 보낸 요청보다 늦게 온 응답이 더 위험할까&lt;/li&gt;
&lt;li&gt;&quot;race condition&quot;을 체크리스트 앱으로 이해해보자&lt;/li&gt;
&lt;li&gt;isSaving만으로는 왜 충분하지 않을까&lt;/li&gt;
&lt;li&gt;가장 쉬운 첫 방어: requestId로 최신 요청만 유효하게 만들기&lt;/li&gt;
&lt;li&gt;&quot;취소&quot;와 &quot;무시&quot;는 무엇이 다를까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 먼저 보낸 요청보다 늦게 온 응답이 더 위험할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기 저장에 익숙할 때는 &quot;먼저 보낸 요청이 먼저 끝나겠지&quot;라고 자연스럽게 생각하기 쉽습니다. 하지만 네트워크 요청은 그렇게 단순하지 않습니다. 먼저 보냈어도 늦게 도착할 수 있고, 나중에 보낸 요청이 더 빨리 돌아올 수도 있습니다. 이게 문제가 되는 이유는, 앱이 응답 도착 순서를 그대로 믿으면 오래된 값이 최신 값을 덮어쓸 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 같은 항목을 아주 짧은 시간 안에 두 번 수정했다고 해보겠습니다. 첫 번째 저장 요청은 &quot;장보기&quot;를 &quot;주말 장보기&quot;로 바꾸는 요청이고, 두 번째 저장 요청은 다시 &quot;주말 장보기&quot;를 &quot;토요일 장보기&quot;로 바꾸는 요청입니다. 사용자는 마지막에 &quot;토요일 장보기&quot;를 입력했으니 그게 최종이라고 느낍니다. 그런데 응답은 다르게 돌아올 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;먼저 보낸 요청이 늦게 돌아오면 생길 수 있는 상황&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순서&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 일이 일어났나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자가 기대하는 값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 A: &quot;주말 장보기&quot; 저장 요청 전송&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 진행 중&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 B: &quot;토요일 장보기&quot; 저장 요청 전송&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최종적으로는 이 값이 남아야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 B가 먼저 도착해 화면이 &quot;토요일 장보기&quot;가 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;여기까지는 자연스럽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 A가 나중에 도착해 다시 &quot;주말 장보기&quot;로 덮어쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;에 있습니다. 그래서 비동기 저장에서는 &quot;성공했으니 반영한다&quot;만으로는 부족하고, &quot;이 응답이 아직도 최신 요청에 해당하는가&quot;까지 같이 봐야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;비동기 요청에서는 성공 응답도 항상 반영하면 안 될 수 있다. 오래된 성공 응답은 오히려 현재 상태를 망가뜨릴 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;race condition&quot;을 체크리스트 앱으로 이해해보자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;race condition&quot;이라는 말이 처음 나오면 꽤 어렵게 느껴질 수 있습니다. 하지만 지금 단계에서는 아주 단순하게 이해해도 충분합니다. &lt;b&gt;여러 작업이 동시에 달리고 있을 때, 누가 먼저 끝나느냐에 따라 최종 결과가 달라지는 상황&lt;/b&gt; 정도로 받아들이면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크리스트 앱에서는 이게 저장 요청끼리 겹칠 때 자주 드러납니다. 예를 들어 자동 저장이 켜져 있거나, 사용자가 아주 빠르게 같은 항목을 여러 번 바꾸거나, 완료 체크를 연속으로 누르는 상황이 그렇습니다. 각각의 요청은 모두 정당하게 보이지만, 응답이 뒤섞여 돌아오면 최종 결과가 사용자의 마지막 의도와 다를 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;체크리스트 앱에서 race condition이 잘 보이는 순간들&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 겹치기 쉬운가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장 중 빠르게 여러 글자를 수정한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목에 대한 요청이 짧은 시간 안에 여러 번 나간다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크를 빠르게 on, off 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막 상태보다 먼저 보낸 요청이 늦게 와서 덮어쓸 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트로 화면은 먼저 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 응답이 늦게 섞이면 현재 UI와 과거 응답이 충돌할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, race condition은 꼭 거대한 시스템에서만 생기는 문제가 아닙니다. 작은 체크리스트 앱에서도 비동기 요청이 겹치기 시작하면 충분히 등장합니다. 그래서 이 개념을 너무 거창하게 보기보다, &lt;b&gt;&quot;마지막 사용자 의도를 누가 지켜줄 것인가&quot;&lt;/b&gt;라는 질문으로 바꿔서 보는 편이 훨씬 이해가 쉽습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;race condition은 코드가 어려워서 생기는 문제가 아니라, 동시에 달리는 요청들 사이에서 &quot;누가 최종 우선권을 가지는가&quot;가 아직 정해지지 않았을 때 생기는 문제에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;isSaving만으로는 왜 충분하지 않을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 이런 반응이 자연스럽습니다. &quot;그럼 저장 중에는 버튼을 막으면 되지 않나?&quot; 실제로 isSaving은 매우 중요합니다. 중복 클릭을 막고, 같은 저장 버튼이 연속으로 눌리는 문제를 줄이는 데 큰 도움이 됩니다. 하지만 race condition까지 해결해주지는 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 isSaving은 주로 &lt;b&gt;지금 새 행동을 막을 것인가&lt;/b&gt;를 다루는 상태이지, &lt;b&gt;돌아온 응답이 최신 것인가&lt;/b&gt;를 판단하는 상태는 아니기 때문입니다. 특히 자동 저장이나 입력 중 저장처럼 사용자가 계속 값을 바꾸는 구조에서는 단순히 버튼 잠금만으로 해결이 안 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;isSaving이 해주는 일과 못 해주는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;도움이 되는가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼 연속 클릭 막기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중에는 다시 저장 행동을 잠글 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오래된 응답 무시하기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 자체가 최신 요청인지 아닌지를 구분해주지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장 여러 번 겹칠 때 최신 값 지키기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보통 부족하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마지막 요청 기준을 따로 가져야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, isSaving은 여전히 필요하지만 그것만으로는 충분하지 않습니다. 저장 중 잠금은 &quot;지금 또 눌러도 되는가&quot;를 보는 기준이고, race condition 방어는 &quot;이 응답을 지금 반영해도 되는가&quot;를 보는 기준입니다. 둘은 서로 다른 질문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;isSaving은 행동 잠금이고, race condition 방어는 응답 우선순위 문제다. 둘 다 필요하지만 같은 역할은 아니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 쉬운 첫 방어: requestId로 최신 요청만 유효하게 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 가장 먼저 이해하기 좋은 방법은 각 저장 요청에 번호를 붙이는 방식입니다. 보통 requestId, saveId, sequence 같은 이름을 씁니다. 핵심은 간단합니다. &lt;b&gt;요청을 보낼 때마다 숫자를 하나씩 올리고, 응답이 돌아왔을 때 그 숫자가 아직 최신인지 확인하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let saveState = {

latestRequestId: 0
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 요청을 보낼 때는 현재 값을 하나 올립니다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function getNextRequestId() {

saveState.latestRequestId += 1;
return saveState.latestRequestId;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 요청마다 자기 requestId를 들고 갑니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function saveEditSafely(nextTodos) {

const requestId = getNextRequestId();

uiState.isSaving = true;
uiState.saveError = &quot;&quot;;

try {
const savedTodos = await saveTodosToServer(nextTodos);

if (requestId !== saveState.latestRequestId) {
  return;
}

todos = savedTodos;

} catch (error) {
if (requestId !== saveState.latestRequestId) {
return;
}

uiState.saveError = &quot;저장 중 문제가 생겼습니다.&quot;;

} finally {
if (requestId === saveState.latestRequestId) {
uiState.isSaving = false;
renderTodos();
}
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은 아주 분명합니다. 늦게 온 응답이라도 그 requestId가 최신이 아니면 그냥 무시합니다. 즉, &quot;성공한 응답이냐 아니냐&quot;보다 먼저 &quot;아직도 이 응답이 최신 요청에 해당하느냐&quot;를 묻는 셈입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;requestId 방식이 하는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 의미인가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 A 보냄, requestId = 1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 기준 최신 요청은 1이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 B 보냄, requestId = 2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이제 최신 요청 기준은 2로 바뀐다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 A가 늦게 옴&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;1 !== 2 이므로 오래된 응답이라 무시한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 B가 옴&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;2 === 2 이므로 현재 최신 요청으로 인정하고 반영한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서 이 방식이 좋은 이유는 복잡한 취소 기능 없이도 &quot;최신 요청이 이긴다&quot;는 규칙을 분명하게 만들 수 있기 때문입니다. 아직 네트워크 요청을 완전히 취소하지는 못해도, 적어도 오래된 응답이 최신 화면을 다시 덮어쓰는 일은 크게 줄일 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 쉬운 첫 방어선&lt;/b&gt;&lt;br /&gt;요청마다 번호를 붙이고, 응답이 돌아왔을 때 그 번호가 아직 최신인지 확인하는 방식은 race condition을 처음 이해하기에 가장 단순하고 효과적인 출발점이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;취소&quot;와 &quot;무시&quot;는 무엇이 다를까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지를 더 구분해두면 좋습니다. 오래된 요청을 &lt;b&gt;무시&lt;/b&gt;하는 것과, 아예 이전 요청을 &lt;b&gt;취소&lt;/b&gt;하는 것은 같은 말이 아닙니다. 처음에는 둘 다 &quot;예전 요청을 더 이상 안 쓰게 한다&quot;처럼 보여서 섞이기 쉬운데, 역할은 분명히 다릅니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;&quot;무시&quot;와 &quot;취소&quot;는 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;취소&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;뜻&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 와도 현재 화면에는 반영하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;애초에 이전 요청을 멈추려 시도한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구현 난도&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비교적 낮은 편&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;조금 더 높은 편&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입문자 첫 단계로 적합한가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상황에 따라 나중 단계로 보는 편이 무난하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, requestId 방식은 &quot;무시&quot;에 가깝습니다. 예전 요청이 실제 네트워크에서 계속 끝까지 돌더라도, 화면에는 최신 요청 기준으로만 반영하는 방식입니다. 반면 &quot;취소&quot;는 이전 요청 자체를 가능한 한 멈추게 하는 쪽입니다. 브라우저 환경에서는 예를 들어 AbortController 같은 도구를 쓰는 방식이 여기에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 보통 무시 전략부터 이해하는 편이 더 낫습니다. 왜냐하면 지금 단계에서 중요한 건 네트워크를 정교하게 제어하는 것보다, &lt;b&gt;오래된 응답이 최신 화면을 덮어쓰지 못하게 하는 규칙&lt;/b&gt;을 먼저 갖추는 것이기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;처음에는 &quot;이전 요청을 완벽히 없애는 것&quot;보다 &quot;예전 응답이 지금 화면을 망치지 못하게 하는 것&quot;부터 분명히 해도 충분하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;race condition을 처음 다룰 때 자주 나오는 실수는 꽤 비슷합니다. 대부분은 &quot;응답은 다 성공이니까 다 반영해도 되겠지&quot;라는 직관에서 시작됩니다. 하지만 비동기 저장에서는 그 직관이 자주 틀립니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 성공이면 무조건 todos에 반영한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 응답이 최신 상태를 덮어쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 최신 요청인지 확인하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;requestId 같은 기준으로 최신 여부를 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving만 있으면 충분하다고 생각한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 저장이나 겹친 수정에서는 여전히 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;행동 잠금과 응답 우선순위 문제를 섞어 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving과 requestId는 서로 다른 역할로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;rollback도 오래된 요청 기준으로 실행한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 화면이 갑자기 예전 snapshot으로 돌아간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 처리에도 최신 요청 여부 검사가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;복구도 최신 요청에 대해서만 실행하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;snapshot을 같은 참조로만 잡아둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;복구했는데도 이미 바뀐 값이 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;백업이 실제 복사가 아니라 같은 객체를 가리켰다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;배열과 객체를 분명하게 복사해두는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 순서를 의식하지 않고 테스트한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;평소엔 되다가 가끔만 이상해 보여 원인 추적이 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;겹친 요청 시나리오를 일부러 만들어보지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠른 연속 수정이나 자동 저장 상황을 직접 테스트해본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 기능에 같은 낙관적 전략을 곧바로 적용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;토글은 괜찮은데 삭제나 생성에서 복구가 갑자기 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능별 복구 난도 차이를 무시했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;단순한 동작부터 단계적으로 적용하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 세 번째 실수는 지난 글의 낙관적 업데이트와 이번 글의 race condition이 연결되는 지점이라 더 중요합니다. 오래된 요청의 실패 응답이 나중에 도착했는데, 그걸 기준으로 rollback을 실행해 버리면 현재 최신 화면까지 같이 망가질 수 있습니다. 그래서 성공 응답만 최신 여부를 확인하는 것이 아니라, 실패 처리와 복구도 같은 기준으로 묶어야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;race condition은 성공 응답만의 문제가 아니다. 실패 처리와 rollback도 &quot;이 요청이 아직 최신인가&quot;를 같이 보지 않으면 오히려 더 큰 혼란을 만들 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 저장이 겹치기 시작하면 앱은 더 이상 &quot;저장했다, 안 했다&quot; 두 가지만으로 설명되지 않습니다. 이제는 어떤 요청이 최신인지, 어떤 응답을 믿어야 하는지, 실패했을 때 어느 상태로 돌아갈지까지 같이 봐야 합니다. 처음엔 낯설게 느껴질 수 있지만, 이 기준이 생기는 순간부터 저장 구조가 한층 더 안정적으로 보이기 시작합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, race condition은 거대한 시스템에서만 생기는 문제가 아니라 작은 체크리스트 앱에서도 충분히 생길 수 있다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, isSaving은 필요하지만 그것만으로는 응답 우선순위를 해결해주지 못한다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, requestId처럼 최신 요청을 구분하는 기준을 두면 오래된 응답이 현재 화면을 덮어쓰는 문제를 크게 줄일 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 저장 구조는 단순한 성공과 실패를 넘어서, &quot;어떤 응답이 최종 진실인가&quot;까지 설명할 수 있는 단계로 올라옵니다. 다음 글에서는 이 흐름을 이어서, &lt;b&gt;입력할 때마다 바로 저장 요청을 보내면 왜 금방 불편해지는지&lt;/b&gt;, 그리고 자동 저장에서 자주 나오는 &quot;debounce&quot;를 초보자 기준에서 쉽게 풀어보겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>racecondition</category>
      <category>requestId</category>
      <category>비동기버그</category>
      <category>오래된응답</category>
      <category>응답순서문제</category>
      <category>저장충돌</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/45</guid>
      <comments>https://story86025.tistory.com/45#entry45comment</comments>
      <pubDate>Thu, 30 Apr 2026 17:00:49 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 22편: 서버 저장 버전 다듬기 - 재시도와 롤백 붙이기</title>
      <link>https://story86025.tistory.com/64</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 편에서 체크리스트 앱에 실제 API 저장을 붙였다면, 이제부터는 조금 더 현실적인 문제가 보이기 시작합니다. 목록을 불러오는 중에 실패하면 어떻게 할지, 저장이 잠깐 막혔을 때 사용자에게 무엇을 보여줄지, 그리고 어떤 동작은 화면을 먼저 바꿔도 되고 어떤 동작은 기다려야 하는지를 더 분명하게 정리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 서버 저장 버전은 localStorage 버전과 달리 &quot;실패했을 때 어디로 돌아갈지&quot;를 같이 설명할 수 있어야 합니다. 그냥 fetch만 붙였다고 끝나는 게 아니라, 실패 메시지, 다시 시도, 저장 중 잠금, 그리고 어떤 동작은 먼저 반영했다가 되돌리는 구조까지 같이 붙어야 비로소 앱이 안정적으로 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 지난 편의 API 저장 최소 버전을 기준으로, &lt;b&gt;초기 로드 실패 시 다시 불러오기&lt;/b&gt;, &lt;b&gt;체크 완료는 먼저 반영했다가 실패 시 롤백&lt;/b&gt;, &lt;b&gt;추가와 삭제는 여전히 보수적으로 확정&lt;/b&gt;하는 흐름까지 붙여보겠습니다. 이 단계까지 오면 이제 체크리스트 앱도 &quot;저장되는 화면&quot;에서 &quot;실패까지 설명하는 화면&quot;으로 한 단계 올라오게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드가 실패해도 화면이 멈추지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;loadingError와 다시 불러오기 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록을 못 읽었을 때 사용자에게 다시 시도할 길을 남기는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크 완료는 눌렀을 때 먼저 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 상태를 백업하고 실패 시 롤백하는 구조가 붙는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠르게 보이게 만드는 것보다 어디로 되돌릴지가 먼저 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가와 삭제는 여전히 조심스럽게 처리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 성공 후 확정하는 보수적 구조를 유지한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 동작에 같은 저장 방식을 억지로 쓰지 않는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;이번 단계의 핵심은 화면을 무조건 빨리 바꾸는 게 아니라, 실패했을 때 어디까지 되돌릴지까지 같이 설명할 수 있는 저장 구조를 만드는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 이제는 실패와 재시도까지 붙여봐야 하는가&lt;/li&gt;
&lt;li&gt;이번 편에서는 어디까지 만들 것인가&lt;/li&gt;
&lt;li&gt;HTML 수정: 상태 박스와 다시 불러오기 버튼 추가&lt;/li&gt;
&lt;li&gt;CSS 수정: 저장 중 항목과 재시도 버튼이 눈에 띄게 보이기&lt;/li&gt;
&lt;li&gt;JavaScript 수정: 로드 재시도, 체크 완료 롤백, 보수적인 추가와 삭제&lt;/li&gt;
&lt;li&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/li&gt;
&lt;li&gt;직접 눌러보며 확인할 것&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이제는 실패와 재시도까지 붙여봐야 하는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장 최소 버전이 돌아가기 시작하면, 겉으로는 꽤 그럴듯해 보입니다. 페이지를 열면 목록을 읽어오고, 새 항목도 저장되고, 체크와 삭제도 API를 타고 움직입니다. 그런데 실제로 조금만 더 써보면 금방 빈틈이 보입니다. 서버가 잠깐 죽어 있거나, 응답이 느리거나, 저장이 실패했을 때 화면이 너무 쉽게 멈춰버리기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 localStorage 버전과 서버 버전의 차이가 더 분명해집니다. localStorage에서는 보통 &quot;저장 = 바로 끝남&quot;이라는 감각으로도 버틸 수 있었는데, 서버 저장은 그렇지 않습니다. 요청이 실패할 수도 있고, 불러오기가 실패할 수도 있고, 어떤 동작은 먼저 바꿨다가 다시 되돌려야 더 자연스러울 수도 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기 로드 실패 시 다시 시도할 수 있어야 합니다.&lt;/li&gt;
&lt;li&gt;저장 중인 항목과 그렇지 않은 항목이 구분되면 더 읽기 쉬워집니다.&lt;/li&gt;
&lt;li&gt;모든 동작을 같은 방식으로 저장하지 않아도 된다는 감각이 중요해집니다.&lt;/li&gt;
&lt;li&gt;빠르게 보이는 구조보다, 실패 시 설명 가능한 구조가 더 중요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전이 &quot;진짜로 쓸 만한 구조&quot;가 되려면 성공만 보는 게 아니라 실패와 다시 시도, 되돌리기까지 같이 보여줄 수 있어야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 어디까지 만들 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편도 범위를 분명하게 잘라두겠습니다. &quot;실패 처리&quot;를 붙인다고 해서 수정, 검색, 정렬, 우선순위, 마감일까지 전부 새 구조로 갈아엎지 않겠습니다. 지금 단계에서는 아래 네 가지만 붙이면 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기 로드 실패 시 다시 불러오기 버튼을 둡니다.&lt;/li&gt;
&lt;li&gt;체크 완료는 화면을 먼저 바꾸고, 실패하면 원래 상태로 되돌립니다.&lt;/li&gt;
&lt;li&gt;추가는 여전히 응답 성공 후에만 확정합니다.&lt;/li&gt;
&lt;li&gt;삭제도 여전히 응답 성공 후에만 목록에서 제거합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수정 저장까지 낙관적으로 먼저 반영하는 구조&lt;/li&gt;
&lt;li&gt;삭제한 항목을 일정 시간 안에 되돌리는 기능&lt;/li&gt;
&lt;li&gt;동시에 여러 요청을 고급스럽게 병합하는 구조&lt;/li&gt;
&lt;li&gt;AbortController로 이전 요청을 취소하는 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 게 흐려지지 않습니다. 지금 중요한 건 &quot;서버 저장이 고급스러워 보이느냐&quot;가 아니라, &lt;b&gt;어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 기준을 세우는 것&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 상태 박스와 다시 불러오기 버튼 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 다시 만드는 작업이 아닙니다. 지난 편의 입력 폼과 목록 구조는 그대로 두고, 상태를 보여주는 영역에 다시 불러오기 버튼만 붙이면 충분합니다. 즉, 저장 구조는 많이 달라지지만 화면 구조는 아주 조금만 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱 API 버전&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;header class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Retry And Rollback Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          서버 저장 버전에 재시도와 롤백 흐름을 붙여서 더 안정적으로 다듬는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/header&amp;gt;

      &amp;lt;section class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 롤백 흐름 점검하기&quot;
            &amp;gt;
            &amp;lt;select
              id=&quot;priorityInput&quot;
              class=&quot;priority-select&quot;
              aria-label=&quot;우선순위 선택&quot;
            &amp;gt;
              &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
              &amp;lt;option
                value=&quot;medium&quot;
                selected
              &amp;gt;
                보통
              &amp;lt;/option&amp;gt;
              &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
            &amp;lt;/select&amp;gt;
            &amp;lt;input
              id=&quot;dueDateInput&quot;
              type=&quot;date&quot;
              class=&quot;due-date-input&quot;
              aria-label=&quot;마감일 선택&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/section&amp;gt;

      &amp;lt;section
        class=&quot;status-panel&quot;
        aria-live=&quot;polite&quot;
      &amp;gt;
        &amp;lt;p
          id=&quot;statusText&quot;
          class=&quot;status-text&quot;
        &amp;gt;
          서버와 연결할 준비가 되었습니다.
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;retryLoadButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
          hidden
        &amp;gt;
          다시 불러오기
        &amp;lt;/button&amp;gt;
      &amp;lt;/section&amp;gt;

      &amp;lt;section
        class=&quot;list-section&quot;
        aria-label=&quot;할 일 목록&quot;
      &amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;retryLoadButton&lt;/b&gt;입니다. 이 버튼 하나가 들어오면서 앱은 이제 단순히 &quot;실패했다&quot;를 보여주는 수준을 넘어서, 사용자가 바로 다음 행동을 취할 수 있는 구조로 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작아 보이지만 꽤 중요한 변화입니다. 서버 저장 버전이 되는 순간부터는 실패를 보여주는 것만으로는 부족하고, 사용자가 다시 시도할 길을 열어두는 쪽이 훨씬 자연스럽기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 저장 중 항목과 재시도 버튼이 눈에 띄게 보이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 화려한 디자인이 아닙니다. 저장 중인 항목이 지금 잠깐 서버와 통신 중이라는 게 보이게 하고, 다시 불러오기 버튼이 필요할 때만 자연스럽게 눈에 들어오게 만드는 것이 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
input[type=&quot;date&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

.primary-button,
.secondary-button,
.delete-button {
  font: inherit;
  font-weight: 700;
  border-radius: 14px;
  cursor: pointer;
}

.primary-button {
  border: none;
  padding: 14px 18px;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button {
  padding: 12px 14px;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.status-panel {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.status-text {
  margin: 0;
  line-height: 1.6;
  color: #4b5563;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.is-pending {
  opacity: 0.72;
  border-style: dashed;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-text.is-done {
  text-decoration: line-through;
  color: #9ca3af;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .status-panel {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .delete-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 가장 중요한 부분은 &lt;b&gt;todo-item is-pending&lt;/b&gt;입니다. 저장 중인 항목이 어떤 것인지 살짝 흐려 보이게만 해도, 사용자는 지금 이 카드가 잠깐 대기 중이라는 걸 훨씬 쉽게 받아들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 예쁘게 꾸미는 작업이 아니라, 서버 저장에서 생기는 &quot;잠깐 기다리는 상태&quot;를 화면에서도 납득 가능하게 만드는 작업에 가깝습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 로드 재시도, 체크 완료 롤백, 보수적인 추가와 삭제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 서버 저장 버전에서 &quot;실패까지 설명하는 구조&quot;가 실제로 어떻게 생기는지 바로 여기서 갈립니다. 이번에는 일부러 저장 방식을 두 가지로 나눠서 보겠습니다. &lt;b&gt;체크 완료는 먼저 바꾸고 실패 시 되돌리고, 추가와 삭제는 성공 후 확정&lt;/b&gt;하는 방식입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 상태와 함수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;loadingError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드 실패 메시지를 따로 가진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다시 불러오기 버튼이 언제 보여야 하는지 판단하는 기준이 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;savingTodoIds&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 저장 중인 항목만 따로 기억한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록 전체를 잠그지 않고, 해당 항목만 대기 상태처럼 보이게 할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;handleToggle&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크를 먼저 반영한 뒤 실패하면 되돌린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 글에서 롤백 구조가 가장 잘 보이는 지점이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retryLoadButton&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드 실패 뒤 바로 다시 시도할 수 있게 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const API_URL = &quot;/todos&quot;;

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  dueDateInput: document.querySelector(&quot;#dueDateInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  statusText: document.querySelector(&quot;#statusText&quot;),
  retryLoadButton: document.querySelector(&quot;#retryLoadButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;)
};

const uiState = {
  isLoading: false,
  isCreating: false,
  loadingError: &quot;&quot;,
  saveError: &quot;&quot;,
  savingTodoIds: [],
  notice: &quot;&quot;
};

let todos = [];

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || &quot;&quot;).trim();

  if (nextValue === &quot;&quot;) {
    return &quot;&quot;;
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue) ? nextValue : &quot;&quot;;
}

function normalizeTodo(todo) {
  return {
    id: String(todo.id),
    text: String(todo.text || &quot;&quot;),
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return &quot;&quot;;
  }

  return &quot;마감 &quot; + dueDate.replace(/-/g, &quot;.&quot;);
}

function isTodoSaving(todoId) {
  return uiState.savingTodoIds.includes(String(todoId));
}

function startTodoSaving(todoId) {
  const nextId = String(todoId);

  if (!uiState.savingTodoIds.includes(nextId)) {
    uiState.savingTodoIds.push(nextId);
  }
}

function finishTodoSaving(todoId) {
  const nextId = String(todoId);

  uiState.savingTodoIds = uiState.savingTodoIds.filter(function (id) {
    return id !== nextId;
  });
}

function clearTransientMessages() {
  uiState.loadingError = &quot;&quot;;
  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
}

function setFormDisabled(disabled) {
  elements.input.disabled = disabled;
  elements.priorityInput.disabled = disabled;
  elements.dueDateInput.disabled = disabled;
  elements.submitButton.disabled = disabled;
}

function renderStatus() {
  if (uiState.loadingError) {
    elements.statusText.textContent = uiState.loadingError;
    elements.retryLoadButton.hidden = false;
    return;
  }

  elements.retryLoadButton.hidden = true;

  if (uiState.isLoading) {
    elements.statusText.textContent = &quot;서버에서 목록을 불러오는 중입니다.&quot;;
    return;
  }

  if (uiState.isCreating) {
    elements.statusText.textContent = &quot;새 할 일을 서버에 저장 중입니다.&quot;;
    return;
  }

  if (uiState.savingTodoIds.length &amp;gt; 0) {
    elements.statusText.textContent = &quot;일부 항목을 서버와 동기화하는 중입니다.&quot;;
    return;
  }

  if (uiState.saveError) {
    elements.statusText.textContent = uiState.saveError;
    return;
  }

  if (uiState.notice) {
    elements.statusText.textContent = uiState.notice;
    return;
  }

  if (todos.length === 0) {
    elements.statusText.textContent = &quot;아직 등록된 할 일이 없습니다.&quot;;
    return;
  }

  elements.statusText.textContent = &quot;서버와 연결된 목록을 보고 있습니다.&quot;;
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;due-date-badge&quot;;
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  if (todo.done) {
    text.classList.add(&quot;is-done&quot;);
  }

  return text;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const checkbox = document.createElement(&quot;input&quot;);
  const deleteButton = document.createElement(&quot;button&quot;);
  const dueDateBadge = createDueDateBadge(todo);
  const isPending = isTodoSaving(todo.id);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (isPending) {
    item.classList.add(&quot;is-pending&quot;);
  }

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = uiState.isLoading || uiState.isCreating || isPending;

  checkbox.addEventListener(&quot;change&quot;, function () {
    handleToggle(todo, checkbox.checked);
  });

  deleteButton.type = &quot;button&quot;;
  deleteButton.className = &quot;delete-button&quot;;
  deleteButton.textContent = &quot;삭제&quot;;
  deleteButton.disabled = uiState.isLoading || uiState.isCreating || isPending;

  deleteButton.addEventListener(&quot;click&quot;, function () {
    handleDelete(todo.id);
  });

  meta.append(createPriorityBadge(todo));

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  actions.append(deleteButton);
  left.append(checkbox, content);
  item.append(left, actions);

  return item;
}

function renderTodos() {
  elements.list.innerHTML = &quot;&quot;;

  todos.forEach(function (todo) {
    elements.list.appendChild(
      createTodoItem(todo)
    );
  });

  renderStatus();
}

async function loadTodosFromServer() {
  uiState.isLoading = true;
  uiState.loadingError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  renderStatus();

  try {
    const response = await fetch(API_URL);

    if (!response.ok) {
      throw new Error(&quot;목록을 불러오지 못했습니다.&quot;);
    }

    const data = await response.json();

    todos = data.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    uiState.loadingError = &quot;목록을 불러오지 못했습니다. 다시 불러오기를 눌러보세요.&quot;;
  } finally {
    uiState.isLoading = false;
    renderTodos();
  }
}

async function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    uiState.saveError = &quot;할 일을 먼저 입력해보세요.&quot;;
    uiState.notice = &quot;&quot;;
    renderStatus();
    elements.input.focus();
    return;
  }

  uiState.isCreating = true;
  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  setFormDisabled(true);
  renderTodos();

  try {
    const response = await fetch(API_URL, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        text: text,
        done: false,
        priority: normalizePriority(priority),
        dueDate: normalizeDueDate(dueDate)
      })
    });

    if (!response.ok) {
      throw new Error(&quot;저장 실패&quot;);
    }

    const createdTodo = normalizeTodo(await response.json());

    todos = [createdTodo].concat(todos);
    elements.form.reset();
    elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
    uiState.notice = &quot;새 할 일을 서버에 저장했습니다.&quot;;
  } catch (error) {
    uiState.saveError = &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    uiState.isCreating = false;
    setFormDisabled(false);
    renderTodos();
    elements.input.focus();
  }
}

async function handleToggle(todo, nextDone) {
  if (uiState.isLoading || uiState.isCreating || isTodoSaving(todo.id)) {
    return;
  }

  const previousTodos = todos;
  const nextTodos = todos.map(function (currentTodo) {
    if (currentTodo.id === todo.id) {
      return {
        ...currentTodo,
        done: nextDone
      };
    }

    return currentTodo;
  });

  todos = nextTodos;
  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  startTodoSaving(todo.id);
  renderTodos();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todo.id, {
      method: &quot;PATCH&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        done: nextDone
      })
    });

    if (!response.ok) {
      throw new Error(&quot;완료 상태 저장 실패&quot;);
    }

    const savedTodo = normalizeTodo(await response.json());

    todos = todos.map(function (currentTodo) {
      if (currentTodo.id === savedTodo.id) {
        return savedTodo;
      }

      return currentTodo;
    });

    uiState.notice = &quot;완료 상태를 서버에 반영했습니다.&quot;;
  } catch (error) {
    todos = previousTodos;
    uiState.saveError = &quot;완료 상태 저장에 실패해서 원래 상태로 되돌렸습니다.&quot;;
  } finally {
    finishTodoSaving(todo.id);
    renderTodos();
  }
}

async function handleDelete(todoId) {
  if (uiState.isLoading || uiState.isCreating || isTodoSaving(todoId)) {
    return;
  }

  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  startTodoSaving(todoId);
  renderTodos();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todoId, {
      method: &quot;DELETE&quot;
    });

    if (!response.ok) {
      throw new Error(&quot;삭제 실패&quot;);
    }

    todos = todos.filter(function (todo) {
      return todo.id !== String(todoId);
    });

    uiState.notice = &quot;항목을 서버에서 삭제했습니다.&quot;;
  } catch (error) {
    uiState.saveError = &quot;삭제에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    finishTodoSaving(todoId);
    renderTodos();
  }
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.retryLoadButton.addEventListener(
    &quot;click&quot;,
    loadTodosFromServer
  );
}

bindEvents();
loadTodosFromServer();&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;loadingError&lt;/b&gt;와 &lt;b&gt;retryLoadButton&lt;/b&gt;입니다. 초기 로드 실패를 그냥 메시지로만 끝내지 않고, 바로 다시 시도할 길을 같이 둡니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;savingTodoIds&lt;/b&gt;입니다. 전체 목록을 잠그지 않고, 지금 저장 중인 항목만 흐리게 보여줄 수 있게 해줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;handleToggle&lt;/b&gt;입니다. 이번 글에서 롤백 구조가 가장 잘 드러나는 지점입니다. 이전 상태를 백업하고, 먼저 반영하고, 실패하면 원래 상태로 되돌립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 이번에도 모든 저장을 같은 방식으로 만들지 않았다는 점입니다. 완료 체크는 화면을 먼저 바꿔도 상대적으로 위험이 작고, 실패했을 때도 되돌리기 비교적 쉽습니다. 반대로 삭제는 먼저 지워버렸다가 실패하면 사용자가 훨씬 크게 당황할 수 있어서, 이번 글에서는 여전히 응답 성공 후에만 제거하는 방식으로 두었습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 모든 동작을 빠르게 보이게 만드는 게 아니라, 어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 편이 더 안전한지 구분하는 구조를 만드는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 &quot;서버 저장이 된다&quot;를 넘어서 &quot;실패와 롤백까지 설명한다&quot;는 쪽으로 가기 때문에, 범위를 더 조심스럽게 잘라야 합니다. &quot;실패 처리 붙여줘&quot;라고만 던지면 AI가 삭제까지 낙관적으로 바꿔버리거나, 전체 구조를 크게 흔들 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 체크리스트 앱은 실제 API 저장 최소 버전까지 붙은 상태야.
이번에는 서버 저장 버전에 실패, 재시도, 롤백 흐름을 추가하고 싶어.
초기 로드 실패 시 다시 불러오기 버튼이 필요하고,
체크 완료는 먼저 반영했다가 실패 시 되돌리는 구조로 가고 싶어.
추가와 삭제는 아직 응답 성공 후 확정 방식으로 유지할 거야.
어떤 상태와 함수가 더 필요해지는지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 API 저장 최소 버전을 기준으로,
loadingError, retry 버튼,
savingTodoIds,
체크 완료 롤백 구조만 추가해줘.
삭제는 보수적으로 유지하고,
체크 완료만 이전 상태 백업 -&amp;gt; 먼저 반영 -&amp;gt; 실패 시 롤백으로 가고 싶어.
먼저 어떤 상태와 함수부터 추가할지 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML과 CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@index.html @style.css
지금 API 저장 버전에 상태 박스와 다시 불러오기 버튼을 추가할 거야.
저장 중인 항목은 살짝 흐려 보이게 하고,
초기 로드 실패 시에는 다시 불러오기 버튼이 자연스럽게 보이게 해줘.
전체 레이아웃은 크게 바꾸지 말아줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 범위를 크게 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 어려운 문장보다, &lt;b&gt;어떤 동작은 낙관적으로, 어떤 동작은 보수적으로 유지할지&lt;/b&gt;를 같이 적는 습관에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 이 단계를 맡길 때는 &quot;실패 처리 붙여줘&quot;보다 &quot;체크 완료만 롤백, 삭제는 보수적 유지, 로드 실패는 재시도 버튼 추가&quot;처럼 경계를 같이 주는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/li&gt;
&lt;li&gt;서버가 꺼진 상태에서 페이지를 열면 다시 불러오기 버튼이 보이는가&lt;/li&gt;
&lt;li&gt;다시 불러오기 버튼을 눌렀을 때 재시도가 되는가&lt;/li&gt;
&lt;li&gt;체크 완료를 눌렀을 때 화면이 먼저 바뀌는가&lt;/li&gt;
&lt;li&gt;체크 저장이 실패하면 원래 상태로 되돌아가는가&lt;/li&gt;
&lt;li&gt;추가는 여전히 서버 응답이 성공했을 때만 목록에 들어오는가&lt;/li&gt;
&lt;li&gt;삭제는 여전히 서버 응답이 성공했을 때만 목록에서 사라지는가&lt;/li&gt;
&lt;li&gt;저장 중인 항목은 잠깐 흐려지고 버튼이 중복으로 눌리지 않게 되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 동작이 안정적이라면 체크포인트를 남기고 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;git status
git add .
git commit -m &quot;체크리스트 앱 서버 저장 재시도와 롤백 추가&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서 가장 중요한 건 &quot;서버 저장이 좀 더 빨라 보인다&quot;가 아닙니다. &lt;b&gt;실패했을 때도 어디로 돌아가는지 설명할 수 있는 구조가 생겼는지&lt;/b&gt;, 바로 그 점을 확인하는 데 있습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 한 일은 거창하지 않습니다. 상태 박스 옆에 다시 불러오기 버튼 하나를 붙였고, 체크 완료는 먼저 바뀌었다가 실패하면 되돌아가게 만들었고, 삭제와 추가는 여전히 보수적으로 유지했을 뿐입니다. 그런데 바로 이 차이가 서버 저장 버전을 훨씬 더 실제 서비스에 가까운 쪽으로 움직이게 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 여기서 &quot;모든 동작을 한 가지 방식으로 처리하지 않았다&quot;는 점입니다. 완료 체크는 먼저 반영해도 상대적으로 자연스럽지만, 삭제는 여전히 기다리는 편이 낫다는 구분이 생기기 시작하면 저장 구조도 한 단계 더 현실적으로 보이기 시작합니다. 이 감각이 생기면 이제부터는 단순히 API를 붙이는 수준을 넘어서, 어떤 상호작용이 더 빠르게 보여야 하고 어떤 상호작용은 더 보수적으로 다뤄야 하는지까지 생각하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 localStorage 중심 시즌과 서버 저장 입문 시즌이 꽤 또렷하게 구분됩니다. 버튼을 누르면 바로 끝나는 저장에서, 요청하고 기다리고 실패하면 되돌리는 저장으로 감각이 바뀌었기 때문입니다. 작은 체크리스트 앱이지만, 바로 이런 차이에서부터 실제 서비스 구조가 시작됩니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/실습</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/64</guid>
      <comments>https://story86025.tistory.com/64#entry64comment</comments>
      <pubDate>Tue, 28 Apr 2026 18:00:51 +0900</pubDate>
    </item>
    <item>
      <title>23. 화면을 먼저 바꿔도 될까: 바이브코딩에서 낙관적 업데이트와 되돌리기</title>
      <link>https://story86025.tistory.com/42</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 localStorage 저장과 서버 저장이 왜 전혀 다르게 느껴지는지, 그리고 비동기 저장에서는 &quot;저장 중&quot;, &quot;성공&quot;, &quot;실패&quot;를 따로 봐야 한다는 이야기를 정리했습니다. 여기까지 오면 거의 바로 다음 질문이 따라옵니다. &lt;b&gt;&quot;그럼 서버 응답을 기다리지 말고 화면부터 먼저 바꾸면 안 될까?&quot;&lt;/b&gt; 실제로 이 생각은 꽤 자연스럽습니다. 저장이 느리게 느껴지는 순간, 사용자는 일단 화면만이라도 빨리 반응하길 기대하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 자주 등장하는 방식이 &lt;b&gt;낙관적 업데이트&lt;/b&gt;입니다. 이름은 조금 어렵게 들릴 수 있지만 뜻은 단순합니다. &quot;아마 저장이 성공할 거라고 보고, 화면을 먼저 바꾼다&quot;는 접근입니다. 사용자는 더 빠르게 느끼고, 앱은 즉시 반응하는 것처럼 보입니다. 그런데 여기에는 반드시 따라붙는 조건이 하나 있습니다. &lt;b&gt;실패하면 되돌릴 수 있어야 한다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 낙관적 업데이트가 매력적으로 느껴지는지, 어떤 기능은 이 방식을 써도 괜찮고 어떤 기능은 조금 더 조심해야 하는지, 그리고 초보자 기준에서는 &quot;화면을 먼저 바꾸는 것&quot;보다 &quot;실패 시 어디까지 되돌릴지&quot;를 먼저 정하는 편이 왜 훨씬 중요한지를 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 바로 바뀌는데 가끔 저장이 실패한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답 전에 UI를 먼저 반영하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 되돌릴 이전 상태를 따로 들고 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱은 빨라졌는데 실패 후 상태가 더 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 반영은 했지만 롤백 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;먼저 바꾸기&quot;보다 &quot;실패 시 복구&quot;를 먼저 설계한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 기능은 먼저 반영해도 괜찮은데 어떤 건 더 위험하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능마다 되돌리기 난도와 실패 비용이 다르다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;단순 토글부터 시작하고, 삭제나 생성은 더 조심해서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠르게 보이게 만들려다 중복 요청과 꼬임이 늘었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving이나 pending 상태 없이 여러 요청이 겹쳤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트여도 저장 중 상태 잠금은 그대로 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;낙관적 업데이트의 핵심은 화면을 먼저 바꾸는 데 있지 않다. 실패했을 때 어디까지, 어떤 순서로 되돌릴지를 먼저 갖추는 데 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 화면을 먼저 바꾸고 싶어질까&lt;/li&gt;
&lt;li&gt;낙관적 업데이트는 정확히 무엇인가&lt;/li&gt;
&lt;li&gt;어떤 기능부터 낙관적으로 해보는 편이 좋을까&lt;/li&gt;
&lt;li&gt;되돌리기는 무엇을 저장해둬야 가능한가&lt;/li&gt;
&lt;li&gt;가장 안전한 첫 흐름: 백업 -&amp;gt; 화면 반영 -&amp;gt; 요청 -&amp;gt; 실패 시 복구&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 화면을 먼저 바꾸고 싶어질까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장을 붙이면 앱이 갑자기 한 박자 느리게 느껴질 때가 있습니다. 버튼을 눌렀는데 바로 결과가 안 보이고, 저장 중이라는 상태가 잠깐 이어지기 때문입니다. 특히 체크리스트 앱처럼 단순한 동작에서는 사용자가 더 예민하게 느낄 수 있습니다. 완료 체크 하나 바꾸는 데도 잠깐 기다려야 한다면 앱이 둔하게 느껴지기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 개발자는 자연스럽게 &quot;어차피 성공할 가능성이 높다면 화면은 먼저 바꿔도 되지 않을까?&quot;라는 생각을 하게 됩니다. 이 발상 자체는 이상하지 않습니다. 실제로 많은 앱이 작은 동작에서는 이 방식을 씁니다. 예를 들어 체크 박스를 눌렀을 때 바로 체크 표시가 바뀌고, 뒤에서 서버 저장을 처리하는 식입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;왜 낙관적 업데이트가 매력적으로 보일까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자 입장&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 더 좋아 보이나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼을 눌렀다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과가 바로 보여야 반응이 빠르다고 느낀다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크를 바꿨다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크 표시가 즉시 바뀌면 앱이 가볍게 느껴진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 저장을 눌렀다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;&quot;앱이 즉시 반응하는 것처럼 보이게 만들고 싶은 욕구&quot;&lt;/b&gt;에서 나옵니다. 다만 여기서 꼭 같이 봐야 하는 것이 있습니다. &lt;b&gt;정말 항상 성공한다고 가정해도 되는가&lt;/b&gt; 하는 문제입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;낙관적 업데이트는 &quot;빨라 보이고 싶은 욕구&quot;에서 출발한다. 그래서 도입 이유는 분명하지만, 실패했을 때 감당할 구조가 없으면 오히려 더 불안해질 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;응답 후 확정 방식과 낙관적 업데이트의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;흐름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 후 확정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 -&amp;gt; 요청 -&amp;gt; 성공 시 화면 반영&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 처리와 설명이 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체감상 느리게 느껴질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 -&amp;gt; 화면 먼저 반영 -&amp;gt; 요청 -&amp;gt; 실패 시 복구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;즉시 반응해서 빠르게 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;되돌리기 설계가 반드시 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 완료 체크를 생각해보겠습니다. 응답 후 확정 방식이라면 사용자가 버튼을 누른 뒤 잠깐 기다리고, 서버가 성공하면 그때 done 값이 바뀝니다. 낙관적 업데이트라면 버튼을 누르는 즉시 done을 먼저 바꾸고 화면도 바로 갱신합니다. 그리고 뒤에서 서버에 저장 요청을 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설명만 들으면 낙관적 업데이트가 무조건 더 좋아 보일 수 있습니다. 그런데 여기서 꼭 따라붙는 질문이 있습니다. &lt;b&gt;&quot;만약 실패하면?&quot;&lt;/b&gt; 이 질문에 답이 준비되어 있어야 낙관적 업데이트는 의미가 생깁니다. 그렇지 않으면 빨라 보이는 대신 상태가 더 불안해질 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;낙관적 업데이트는 화면을 먼저 바꾸는 기술이 아니라, &quot;실패 시 복구&quot;까지 포함한 저장 방식이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 기능부터 낙관적으로 해보는 편이 좋을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 가장 중요한 질문 중 하나는 이것입니다. &quot;모든 저장에 낙관적 업데이트를 다 써도 될까?&quot; 보통은 그렇지 않습니다. 기능마다 실패했을 때의 부담과 되돌리기 난도가 다르기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자 기준에서는 &lt;b&gt;되돌리기가 단순한 기능부터&lt;/b&gt; 낙관적 업데이트를 시도하는 편이 좋습니다. 예를 들면 개인용 체크리스트에서 완료 체크 토글은 비교적 시도해볼 만합니다. 이전 상태가 true였는지 false였는지만 기억하면 다시 되돌리기 쉽기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음엔 어떤 기능부터 낙관적으로 보기 쉬울까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;기능&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음 시도 난도&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크 토글&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낮은 편&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 done 값만 기억하면 되돌리기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;간단한 텍스트 수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 text를 기억해야 하고 편집창 처리도 같이 봐야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 항목 생성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간 이상&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 id와 서버가 돌려주는 id를 맞추는 흐름이 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;조심할 편&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 항목을 다시 정확한 자리로 복구해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;여러 사람이 함께 보는 데이터 변경&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;더 조심할 편&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;내 화면만 먼저 바꾸는 것이 곧바로 진실이 아닐 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 낙관적 업데이트를 처음 배울 때는 &quot;어떤 기능부터 해보는 게 덜 위험한가&quot;를 같이 보는 편이 좋습니다. 모든 걸 한꺼번에 빠르게 보이게 만들려 하기보다, 되돌리기 쉬운 토글부터 익히는 편이 훨씬 현실적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자에게 가장 무난한 출발점&lt;/b&gt;&lt;br /&gt;낙관적 업데이트는 완료 체크 같은 단순 토글부터 시도하는 편이 좋다. 생성과 삭제는 복구 규칙이 더 분명해진 뒤에 붙이는 편이 안전하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌리기는 무엇을 저장해둬야 가능한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 업데이트의 핵심은 빠르게 보이는 것이 아니라 되돌릴 수 있는 것입니다. 그래서 먼저 봐야 할 질문은 &quot;어떤 이전 상태를 저장해둬야 rollback이 가능한가&quot;입니다. 여기서 rollback은 실패했을 때 원래 상태로 되돌리는 흐름을 뜻합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완료 체크 토글처럼 단순한 경우에는 이전 item 하나만 저장해도 될 수 있습니다. 하지만 삭제나 재정렬처럼 목록 전체가 흔들리는 동작은 이전 리스트 전체를 들고 있는 편이 더 쉬울 때가 많습니다. 초보자에게는 이 구분이 꽤 중요합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;rollback을 위해 무엇을 백업하면 좋을까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;되돌릴 때 필요한 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;초보자에게 더 쉬운 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크 토글&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 done 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;해당 항목만 기억하기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;텍스트 수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 text 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;해당 항목의 원래 text를 보관하기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 전 항목과 위치 정보&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음에는 리스트 전체 snapshot을 두는 편이 더 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 order 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음에는 리스트 전체 snapshot이 이해하기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 가장 무난한 첫 구조는 대개 아래처럼 이전 상태 snapshot을 하나 두는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

isSaving: false,
saveError: &quot;&quot;,
pendingSnapshot: null
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 낙관적 반영 전에는 이전 todos를 복사해둡니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function cloneTodos(source) {

return source.map(todo =&amp;gt; ({ ...todo }));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;복사&quot;입니다. 단순히 &lt;b&gt;const prevTodos = todos&lt;/b&gt;처럼 같은 참조를 잡아두면, 이후 변경이 같은 객체에 반영돼서 rollback이 제대로 안 될 수 있습니다. 특히 객체 배열에서는 이 부분을 한 번은 꼭 의식해야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;rollback은 &quot;실패하면 알아서 되돌리기&quot;가 아니다. 먼저 무엇을 백업해둘지 정하지 않으면 되돌릴 재료 자체가 없다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 안전한 첫 흐름: 백업 -&amp;gt; 화면 반영 -&amp;gt; 요청 -&amp;gt; 실패 시 복구&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 초보자가 처음 시도하기 가장 무난한 낙관적 업데이트 흐름을 보겠습니다. 여기서는 완료 체크 토글처럼 비교적 단순한 동작을 기준으로 생각하면 이해가 쉽습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&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;화면과 todos를 먼저 next 상태로 반영한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버에 저장 요청을 보낸다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성공하면 snapshot을 지운다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패하면 이전 snapshot으로 되돌린다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;마지막에 isSaving을 끄고 렌더링한다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;nix&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function toggleTodoOptimistic(todoId) {

if (uiState.isSaving) return;

const prevTodos = cloneTodos(todos);
const nextTodos = todos.map(todo =&amp;gt;
todo.id === todoId
? { ...todo, done: !todo.done }
: todo
);

uiState.isSaving = true;
uiState.saveError = &quot;&quot;;
uiState.pendingSnapshot = prevTodos;

todos = nextTodos;
renderTodos();

try {
const savedTodos = await saveTodosToServer(nextTodos);

todos = savedTodos;
uiState.pendingSnapshot = null;

} catch (error) {
todos = uiState.pendingSnapshot || prevTodos;
uiState.saveError = &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
uiState.pendingSnapshot = null;
} finally {
uiState.isSaving = false;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은 두 가지입니다. 첫째, 화면을 먼저 바꾸더라도 무작정 바꾸는 게 아니라 이전 상태를 꼭 백업해둔다는 점입니다. 둘째, 성공과 실패가 같은 마무리 단계로 섞이지 않고 서로 다른 결과를 분명히 가진다는 점입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이 흐름이 입문자에게 특히 좋은 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 도움이 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반응이 즉시 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크 박스 같은 가벼운 동작은 훨씬 빠르게 느껴진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 복구가 가능하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 상태를 이미 들고 있기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 비교적 단순하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;생성이나 삭제보다 rollback 설명이 훨씬 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 안전한 첫 원칙&lt;/b&gt;&lt;br /&gt;낙관적 업데이트를 처음 붙일 때는 화면을 먼저 바꾸는 속도보다, 실패했을 때 이전 상태를 정확히 되돌릴 수 있는지를 먼저 확인하는 편이 훨씬 중요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 업데이트를 붙일 때는 눈에 보이는 속도 향상 때문에 구조적인 위험을 놓치기 쉽습니다. 아래 실수들은 특히 반복해서 자주 보이는 편입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 상태 백업 없이 화면부터 바꾼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때 되돌릴 방법이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;rollback 재료를 안 들고 갔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 반영 전에는 꼭 snapshot부터 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;빠르게 보이기&quot;만 보고 모든 기능에 한꺼번에 적용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제, 생성, 재정렬까지 실패 처리 규칙이 한꺼번에 꼬인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능별 되돌리기 난도를 무시했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음엔 토글처럼 단순한 동작부터 시도한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 없이 연속 클릭을 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 요청과 늦게 온 응답 때문에 상태가 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 잠금이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;낙관적 업데이트여도 저장 중 잠금은 그대로 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;snapshot을 같은 참조로만 들고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;복구했는데도 이미 바뀐 값이 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;깊지 않더라도 최소한의 복사가 필요하다는 점을 놓쳤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;배열과 객체를 명시적으로 복사하는 편이 안전하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했는데도 성공한 것처럼 편집창을 닫는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 저장됐다고 믿기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 후 유지해야 할 UI 상태가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시에는 초안 유지와 에러 안내를 같이 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 오면 무조건 성공 처리만 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패나 부분 실패를 놓친다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 검사를 충분히 안 했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;낙관적 업데이트가 불안하게 느껴질 때는 &quot;화면을 먼저 바꿔서&quot;가 아니라, &quot;실패 시 어디로 돌아갈지&quot;와 &quot;요청이 겹치면 누가 마지막 결과인지&quot;를 아직 코드가 분명히 모르기 때문인 경우가 많다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 업데이트는 서버 저장을 더 &quot;빠르게 보이게&quot; 만드는 방식이지만, 실제로는 그보다 더 많은 책임을 요구합니다. 화면을 먼저 바꾸는 대신 실패했을 때 되돌릴 수 있어야 하고, 저장 중 상태도 막아야 하고, 어떤 기능부터 적용할지 신중하게 골라야 합니다. 그래서 초보자에게는 단순히 &quot;더 고급 기술&quot;이라기보다 &lt;b&gt;저장 흐름과 상태 복구를 같이 생각하게 만드는 단계&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. &lt;br /&gt;첫째, 낙관적 업데이트는 화면을 먼저 바꾸는 것보다 rollback 구조를 먼저 갖추는 것이 핵심이라는 점.&lt;br /&gt;둘째, 모든 기능에 한꺼번에 적용하기보다 완료 체크 같은 단순 토글부터 시작하는 편이 훨씬 안전하다는 점. &lt;br /&gt;셋째, snapshot, isSaving, saveError 같은 상태를 같이 가져가야 비로소 &quot;빠르게 보이면서도 설명 가능한&quot; 구조가 된다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기까지 오면 이제 저장은 단순한 버튼 클릭이 아니라 성공, 실패, 복구까지 포함한 흐름으로 보이기 시작합니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;요청이 겹칠 때 먼저 보낸 응답이 나중에 도착하면 어떤 값이 최종이 되어야 하는지&lt;/b&gt;, 즉 race condition과 늦게 도착한 응답 문제를 초보자 기준에서 차근차근 풀어보겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>optimisticupdate</category>
      <category>rollback</category>
      <category>낙관적업데이트</category>
      <category>빠른반응UI</category>
      <category>서버저장UI</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/42</guid>
      <comments>https://story86025.tistory.com/42#entry42comment</comments>
      <pubDate>Tue, 28 Apr 2026 17:00:40 +0900</pubDate>
    </item>
    <item>
      <title>[번외-1] 매번 다시 설명하지 마세요: 대화 맥락 관리와 반복 지침 정리법</title>
      <link>https://story86025.tistory.com/63</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기초편과 확장편을 따라오면서, 이제 어떤 요청이 좋은지에 대한 감은 어느 정도 잡혔을 겁니다. 문제는 그다음입니다. 처음 한두 번은 괜찮은데, 대화가 길어지기 시작하면 어느 순간부터 AI가 자꾸 예전 맥락을 붙잡고 있거나, 이미 정리한 기준을 다시 묻거나, 이번 단계와 상관없는 방향으로 답을 넓혀버리는 순간이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때 많은 사람이 &amp;ldquo;아까 분명 말했는데 왜 또 이러지?&amp;rdquo;라는 답답함을 느낍니다. 그런데 이건 꼭 AI가 멍청해서라기보다, &lt;b&gt;긴 대화 안에서 무엇을 계속 유지해야 하고 무엇은 이번 작업에서만 잠깐 필요한지&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;을 익히는 데 있습니다. 이 감각이 붙으면, AI와의 작업은 훨씬 덜 즉흥적으로 바뀝니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로는 무엇이 부족한가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;먼저 정리할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI가 예전에 하던 설명을 또 반복한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 목표와 이전 목표가 분리되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 단계 목표를 한 문장으로 다시 고정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;매번 같은 설명 스타일과 제약을 다시 적는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침이 따로 정리되어 있지 않다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 지침을 짧은 템플릿으로 묶는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대화가 길어질수록 답이 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;계속 끌고 갈 정보와 새로 시작할 정보가 섞여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;인수인계용 요약을 만들고 새 대화로 넘긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;긴 대화를 잘 운영한다는 것은 대화를 오래 끄는 일이 아니라, 반복해서 유지할 기준과 이번 작업에만 필요한 맥락을 분리해서 관리하는 일에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 대화 맥락 관리가 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이브 코딩을 조금만 해보면, 문제는 좋은 한 번의 프롬프트를 쓰는 데서 끝나지 않는다는 걸 금방 느끼게 됩니다. 처음에는 &amp;ldquo;검색 기능 추가해줘&amp;rdquo;, &amp;ldquo;이 에러를 고쳐줘&amp;rdquo;, &amp;ldquo;이 코드 설명해줘&amp;rdquo;처럼 개별 요청 하나하나가 중요해 보입니다. 그런데 실제 작업은 그렇게 딱 끊기지 않습니다. 검색 기능을 붙이다가 필터와 충돌하고, 그걸 고치다가 summary 문구를 다시 손보게 되고, 그다음에는 테스트와 정리까지 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실제 작업은 하나의 멋진 요청보다 &lt;b&gt;여러 개의 작은 요청이 이어지는 흐름&lt;/b&gt;에 가깝습니다. 이때 공통 기준이 따로 없으면, 매번 말투와 출력 형식, 제약, 우선순위를 처음부터 다시 적어야 합니다. 반대로 현재 작업 요약이 없으면, 방금까지 무슨 문제를 다루고 있었는지도 흐려지기 쉽습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반복 지침이 없으면 매번 같은 기준을 다시 써야 합니다.&lt;/li&gt;
&lt;li&gt;현재 작업 요약이 없으면 예전 목표와 지금 목표가 섞이기 쉽습니다.&lt;/li&gt;
&lt;li&gt;대화가 길어진다고 해서 무조건 맥락이 좋아지는 것은 아닙니다.&lt;/li&gt;
&lt;li&gt;오히려 오래된 지시와 새 지시가 뒤섞이면 답이 더 흔들릴 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 특히 자주 겪는 문제는 두 가지입니다. 하나는 &lt;b&gt;같은 말을 너무 자주 반복하게 되는 피로&lt;/b&gt;이고, 다른 하나는 &lt;b&gt;이미 지나간 작업 단계의 맥락이 현재 작업에 계속 끼어드는 문제&lt;/b&gt;입니다. 예를 들어 지금은 검색 기능만 다루고 싶은데, 예전에 이야기했던 저장 기능이나 리팩터링 방향이 계속 답변에 섞이는 식입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;좋은 맥락 관리는 정보를 많이 쌓는 일보다, 어떤 정보는 계속 유지하고 어떤 정보는 이번 단계에서만 쓰고 버릴지 구분하는 일에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 어디까지 다룰 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 &amp;ldquo;대화 운영&amp;rdquo;을 다루지만, 너무 넓게 잡으면 금방 추상적인 이야기로 흘러가기 쉽습니다. 그래서 이번에도 범위를 먼저 잘라두는 편이 좋습니다. 이번 편에서는 &lt;b&gt;반복 지침 정리&lt;/b&gt;, &lt;b&gt;현재 작업 맥락 관리&lt;/b&gt;, &lt;b&gt;대화가 꼬였을 때 새 대화로 넘기는 방법&lt;/b&gt; 이 세 가지를 중심으로 보겠습니다.&lt;/p&gt;
&lt;p data-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;/li&gt;
&lt;li&gt;자주 쓰는 반복 지침을 짧은 텍스트로 정리하는 법&lt;/li&gt;
&lt;li&gt;대화가 길어졌을 때 이어받기용 요약을 만드는 법&lt;/li&gt;
&lt;li&gt;새 대화로 넘기는 기준과 실전 템플릿&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 글에서는 아래 내용은 깊게 다루지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도구별 메뉴 위치나 세부 기능 설명&lt;/li&gt;
&lt;li&gt;팀 단위 문서 운영 방식&lt;/li&gt;
&lt;li&gt;버전 관리 시스템 전체 설계&lt;/li&gt;
&lt;li&gt;기업용 워크플로우나 협업 정책&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 단계에서 중요한 것은 거창한 시스템을 만드는 일이 아닙니다. &lt;b&gt;혼자 작업하는 입문자가 AI와의 긴 대화를 덜 흔들리게 관리하는 기본 습관&lt;/b&gt;을 익히는 쪽이 먼저입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;코딩 초보에게 설명해줘&amp;rdquo;, &amp;ldquo;어려운 용어는 풀어서 설명해줘&amp;rdquo;, &amp;ldquo;전체 재작성 말고 최소 수정 위주로 해줘&amp;rdquo; 같은 문장은 매번 거의 같은 의미로 반복됩니다. 반면 &amp;ldquo;지금은 검색 기능을 붙이는 중이고, 완료 보기에서만 결과가 이상하다&amp;rdquo; 같은 문장은 이번 단계에서만 중요합니다. 이 둘을 구분하지 않으면, 대화가 길어질수록 매번 같은 말은 계속 길어지고, 지금 필요한 정보는 오히려 묻히게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;반복 지침과 현재 작업 맥락은 성격이 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇이 들어가나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;언제 바뀌나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;반복 지침&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;말투, 설명 수준, 출력 형식, 우선순위, 금지사항&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코딩 초보 기준 설명, 전체 재작성 금지, 테스트 순서 포함&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자주 안 바뀐다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;현재 작업 맥락&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 만들고 있는 기능, 현재 문제, 관련 파일, 최근 수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 기능 점검 중, script.js의 renderTodos 관련 문제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;작업이 바뀔 때마다 바뀐다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;이어받기 요약&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 상태를 새 대화로 넘길 때 필요한 핵심만 추린 메모&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료된 기능, 남은 문제, 다음 단계, 바뀐 파일&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;짧은 정리&lt;/b&gt;&lt;br /&gt;반복 지침은 &amp;ldquo;어떻게 일할지&amp;rdquo;에 관한 것이고, 현재 작업 맥락은 &amp;ldquo;지금 무엇을 다루는지&amp;rdquo;에 관한 것입니다. 둘을 나눠두면 대화가 훨씬 덜 꼬입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;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;/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;p data-ke-size=&quot;size16&quot;&gt;예를 들어 할 일 앱 같은 개인 학습 프로젝트에서는 아래 정도로 묶어둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[반복 지침]

너는 코딩 초보에게 설명하는 웹 개발 튜터다.

어려운 용어는 바로 쉬운 말로 풀어서 설명할 것

한 번에 너무 많은 기술을 도입하지 말 것

지금 단계에서는 이해하기 쉬운 구조를 우선할 것

전체 재작성보다 현재 구조를 유지한 최소 수정안을 우선할 것

불확실한 내용은 단정하지 말고 전제를 먼저 구분할 것

답변은 가능하면 아래 순서로 정리할 것

무엇을 하려는지 짧게 요약

바뀌는 파일 또는 관련 함수

필요한 코드 또는 수정 포인트

왜 이렇게 바꾸는지 설명

내가 직접 확인할 테스트 항목&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지침은 기능 요청, 디버깅 요청, 설명 요청 거의 모든 상황에서 공통으로 쓸 수 있습니다. 그래서 매번 &amp;ldquo;초보자 기준으로 설명해줘&amp;rdquo;, &amp;ldquo;어려운 말은 풀어줘&amp;rdquo;, &amp;ldquo;전체 갈아엎지 말아줘&amp;rdquo;를 다시 길게 적을 필요가 줄어듭니다.&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;ldquo;톤은 친근하게&amp;rdquo;, &amp;ldquo;설명은 자세히&amp;rdquo;, &amp;ldquo;코드는 짧게&amp;rdquo;, &amp;ldquo;예시는 많이&amp;rdquo;, &amp;ldquo;리팩터링도 해주고&amp;rdquo;, &amp;ldquo;테스트도 해주고&amp;rdquo; 식으로 계속 늘어나기 쉽습니다. 그런데 그렇게 되면 기준끼리 충돌하는 경우가 생깁니다. 예를 들어 아주 짧게 답해달라고 하면서 동시에 매우 자세히 설명해달라고 하면 답이 흔들릴 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 반복 지침은 &lt;b&gt;정말 자주 반복하게 되는 기준만 남기고, 현재 작업에만 필요한 조건은 작업 맥락 쪽으로 보내는 편&lt;/b&gt;이 낫습니다. 즉, &amp;ldquo;이건 항상 중요하다&amp;rdquo; 싶은 것만 남기는 방식이 좋습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;반복 지침은 모든 걸 담는 만능 문서가 아니라, 자주 잊기 쉬운 공통 기준을 짧게 고정하는 메모에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금은 검색 기능만 고치고 싶은데 저장 기능이나 배포 얘기가 자꾸 섞인다.&lt;/li&gt;
&lt;li&gt;같은 제약을 여러 번 말했는데도 자꾸 전체 구조를 다시 짜려 한다.&lt;/li&gt;
&lt;li&gt;예전 코드 상태를 기준으로 설명해서 현재 파일 구조와 안 맞는 답이 나온다.&lt;/li&gt;
&lt;li&gt;내가 읽어야 할 이전 대화가 너무 길어져 지금 문제의 초점이 흐려진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때 억지로 계속 끌고 가면, &amp;ldquo;아까 말한 건 잊고 지금 것만 봐줘&amp;rdquo; 같은 문장이 점점 길어집니다. 반면 새 대화로 넘기면, &lt;b&gt;지금 필요한 정보만 추려서 다시 출발할 수 있다&lt;/b&gt;는 장점이 있습니다. 중요한 건 대화를 버리는 게 아니라, 필요한 핵심만 추려서 넘기는 것입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이럴 때는 새 대화가 더 낫다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;그대로 이어갈 때 생기기 쉬운 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;새 대화로 넘기면 좋은 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;작업 단계가 크게 바뀌었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이전 목표와 현재 목표가 섞이기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 단계 목표만 남기고 깔끔하게 시작할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 제약을 계속 반복하게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;프롬프트가 길어지고 핵심이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침과 현재 맥락만 다시 붙여 간결하게 운영할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 코드 상태가 섞여 답이 어긋난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 파일 기준과 안 맞는 수정안이 나온다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해하기 쉬운 지점&lt;/b&gt;&lt;br /&gt;새 대화를 연다고 해서 이전 작업이 끊기는 것은 아닙니다. 핵심만 요약해서 넘기면, 오히려 지금 필요한 문제를 더 선명하게 다룰 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이어받기용 요약은 이렇게 만들면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 대화로 넘길 때 중요한 것은 &amp;ldquo;지금까지 다 했던 얘기를 다시 다 쓰는 것&amp;rdquo;이 아닙니다. 그렇게 하면 새 대화의 장점이 줄어듭니다. 핵심은 &lt;b&gt;현재 작업을 다시 이어가는 데 필요한 최소한의 정보만 남기는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자라면 이어받기용 요약에 아래 여섯 가지 정도가 들어가면 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무엇을 만들고 있는지&lt;/li&gt;
&lt;li&gt;현재까지 정상 동작하는 기능&lt;/li&gt;
&lt;li&gt;지금 남아 있는 문제&lt;/li&gt;
&lt;li&gt;관련 파일이나 함수&lt;/li&gt;
&lt;li&gt;반드시 유지해야 할 제약&lt;/li&gt;
&lt;li&gt;다음 단계 목표&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 손으로 직접 써도 좋고, AI에게 지금까지 대화를 기준으로 짧게 요약해달라고 부탁해도 됩니다. 중요한 건 너무 길게 만들지 않는 것입니다. &lt;b&gt;새 대화 첫 메시지에 붙였을 때 한 번에 훑히는 정도&lt;/b&gt;가 가장 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;지금까지 대화를 기준으로

새 대화에서 바로 이어갈 수 있게 아래 형식으로만 짧게 요약해줘.

현재 만들고 있는 것

완료된 기능

지금 남아 있는 문제

관련 파일 또는 함수

꼭 유지해야 할 제약

다음 단계 목표

15줄 안쪽으로 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 할 일 앱 검색 기능 작업을 새 대화로 넘길 때는 이런 식이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업 요약]

HTML, CSS, JavaScript로 할 일 앱을 만드는 중

추가, 삭제, 완료 체크, 전체 삭제, 필터까지는 정상 동작

검색 기능을 붙였고 기본 검색도 동작함

[현재 문제]

완료 보기 상태에서 검색 후 마지막 항목 삭제 시
summary 문구와 빈 상태 메시지 갱신이 어긋남

[관련 파일]

script.js

renderTodos, updateSummary, getFilteredTodos, createDeleteButton

[유지할 제약]

코딩 초보 기준 설명

현재 구조 최대한 유지

전체 재작성 금지

최소 수정 위주

[다음 단계 목표]

summary와 빈 상태 메시지 갱신 문제만 수정

수정 후 테스트 순서까지 정리&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;h3 data-ke-size=&quot;size23&quot;&gt;새 대화 첫 메시지는 이렇게 구성하면 편하다&lt;/h3&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[반복 지침]

너는 코딩 초보에게 설명하는 웹 개발 튜터다.
어려운 용어는 풀어서 설명하고,
전체 재작성보다 최소 수정안을 우선해줘.
답변은 요약 &amp;rarr; 관련 파일/함수 &amp;rarr; 수정 포인트 &amp;rarr; 설명 &amp;rarr; 테스트 순서로 정리해줘.

[현재 작업 요약]

HTML, CSS, JavaScript 할 일 앱

추가, 삭제, 완료 체크, 전체 삭제, 필터 정상 동작

검색 기능은 기본 동작하지만
완료 보기 + 검색 + 삭제 조합에서 summary 갱신 문제 있음

관련 파일은 script.js

현재 구조 유지가 중요함

[이번 요청]
이 문제의 가장 가능성 큰 원인 3개와
먼저 확인할 함수 흐름,
그리고 필요한 최소 수정 포인트만 제안해줘.&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이어받기용 요약은 지난 대화를 모두 저장하는 문서가 아니라, 다음 대화에서 바로 써먹을 핵심만 남기는 작업 메모에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;맥락 관리에서 자주 하는 실수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 문제가 되나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 바꾸면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침과 현재 작업을 한 문단에 다 넣는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 공통 기준이고 무엇이 이번 단계 정보인지 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침과 현재 작업 요약을 분리해서 쓴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침을 너무 길게 만든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;매번 붙이는 의미가 줄고, 기준끼리 충돌할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정말 자주 반복되는 기준만 남긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대화가 꼬였는데도 끝까지 이어가려 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오래된 목표와 현재 목표가 계속 섞인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이어받기 요약을 만들고 새 대화로 넘긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 대화에서 너무 많은 이전 내용을 다 붙인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 대화의 장점이 사라지고 핵심이 다시 묻힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 작업에 필요한 핵심만 10~15줄 안팎으로 요약한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;반복 지침을 고정해두고도 현재 목표를 안 적는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI는 공통 기준은 알지만 지금 뭘 해야 하는지 흐릴 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/li&gt;
&lt;li&gt;반복 지침이 너무 길어지지 않았는가&lt;/li&gt;
&lt;li&gt;이번 단계 목표를 한 문장으로 적었는가&lt;/li&gt;
&lt;li&gt;이전 대화가 현재 문제를 오히려 흐리게 만들고 있지는 않은가&lt;/li&gt;
&lt;li&gt;새 대화로 넘길 만한 시점인지 한 번 점검했는가&lt;/li&gt;
&lt;li&gt;이어받기 요약에 완료된 기능, 남은 문제, 제약, 다음 단계가 들어 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 체크리스트는 거창한 규칙이 아닙니다. 긴 대화가 조금씩 흐려질 때 다시 중심을 잡기 위한 점검표에 가깝습니다. 한두 번 익숙해지면, 작업 방식이 꽤 달라집니다. 예전에는 같은 말을 여러 번 반복했다면, 이제는 공통 기준과 현재 작업을 나눠서 훨씬 선명하게 운영할 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;짧은 정리&lt;/b&gt;&lt;br /&gt;맥락 관리의 핵심은 많이 적는 데 있지 않습니다. 계속 남길 것과 지금만 필요한 것을 나누고, 대화가 무거워지면 과감히 새 출발할 줄 아는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글까지 오면, 처음에 생각했던 &amp;ldquo;프롬프트를 잘 쓰는 법&amp;rdquo;이라는 주제가 꽤 다르게 보일 수 있습니다. 실제로 중요한 건 그럴듯한 한 문장을 만드는 일이 아니었습니다. 역할을 정하고, 요청 구조를 잡고, 작업을 나누고, 현재 상태를 전달하고, 결과를 검토하고, 디버깅 질문을 정리하고, 마지막으로 &lt;b&gt;긴 대화를 어떻게 덜 흔들리게 운영할지&lt;/b&gt;까지 이어졌기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 바이브 코딩에서 도움이 되는 사람은 특별한 비밀 문장을 가진 사람이 아니라, &lt;b&gt;무엇을 고정하고 무엇을 바꿔야 하는지 구분할 줄 아는 사람&lt;/b&gt;에 더 가깝습니다. 반복 지침은 따로 묶고, 현재 작업 맥락은 짧게 요약하고, 대화가 꼬이면 새 대화로 넘길 줄 아는 습관. 이 정도만 잡혀도 AI와의 작업은 훨씬 덜 불안해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/63</guid>
      <comments>https://story86025.tistory.com/63#entry63comment</comments>
      <pubDate>Tue, 28 Apr 2026 00:00:03 +0900</pubDate>
    </item>
    <item>
      <title>[Chat GPT 5.5] ChatGPT 맞춤형 지침 공유: 맥락을 놓치지 않고, 무조건 수긍하지 않게 만드는 설정</title>
      <link>https://story86025.tistory.com/85</link>
      <description>&lt;article&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatGPT를 사용하다 보면 답변 자체는 그럴듯하지만, 막상 자세히 보면 &lt;b&gt;이전 대화의 맥락을 놓치거나&lt;/b&gt;, 사용자가 지적했을 때 별다른 검토 없이 &lt;b&gt;&amp;ldquo;맞습니다&amp;rdquo;라고 바로 수긍하는 경우&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 긴 대화를 이어가거나 조건이 많은 작업을 맡길 때 이런 문제가 자주 느껴집니다. 처음에 정해둔 조건을 잊어버리거나, 이미 알려준 정보를 다시 묻거나, 확실하지 않은 내용을 단정적으로 말하는 경우도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 ChatGPT를 조금 더 실무적으로 쓰기 위해 개인 맞춤형 지침을 정리해봤습니다. 핵심은 단순합니다.&lt;/p&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;ChatGPT는 기본적으로 친절하게 답변하려는 경향이 강합니다. 이 장점이 때로는 단점이 되기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 &amp;ldquo;이거 틀린 거 아닌가요?&amp;rdquo;라고 말하면, 실제로 틀렸는지 검토하기보다 바로 &amp;ldquo;맞습니다. 제가 착각했습니다.&amp;rdquo;라고 답하는 경우가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 사용자의 지적이 항상 맞는 것은 아닙니다. 어떤 경우에는 사용자의 지적이 맞고, 어떤 경우에는 일부만 맞으며, 어떤 경우에는 오히려 기존 답변이 맞을 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 맞춤형 지침에는 다음 원칙을 넣었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 지적해도 자동으로 수긍하지 않기&lt;/li&gt;
&lt;li&gt;기존 답변과 사용자의 지적을 근거와 함께 비교하기&lt;/li&gt;
&lt;li&gt;틀렸으면 수정하고, 맞으면 분명하게 설명하기&lt;/li&gt;
&lt;li&gt;불확실하면 단정하지 않기&lt;/li&gt;
&lt;li&gt;이전 대화의 조건과 맥락을 계속 반영하기&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;제가 사용하는 ChatGPT 맞춤형 지침&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 내용을 ChatGPT의 맞춤형 지침에 넣으면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;Role:
한국어 존댓말로 답하는 실무형 조력자로 행동합니다. 사용자의 목적을 먼저 파악하고, 맥락을 유지하며, 근거 있는 답변을 제공합니다.

# Personality
간결하고 직접적으로 답합니다. 불필요한 서론, 자기소개, 과한 공감, 감탄, 상투적인 마무리 질문은 피합니다. 사용자를 무조건 따라가지 말고, 필요하면 정중하지만 분명하게 반박합니다.

# Goal
사용자의 요청을 현재 대화 맥락, 이미 제공된 조건, 첨부자료, 이전 지적사항까지 반영해 정확하고 실행 가능한 결과로 완성합니다. 이미 제공된 정보는 다시 묻지 않습니다.

# Success criteria
최종 답변은 다음 조건을 만족해야 합니다.
- 첫 문장부터 결론 또는 핵심 분석을 제시합니다.
- 사실, 추정, 해석, 권고를 구분합니다.
- 확실하지 않은 내용은 단정하지 않고 불확실성을 밝힙니다.
- 최신성이 중요한 정보는 확인 후 답합니다.
- 링크, 문서, 이미지, 스크린샷, 파일은 실제로 확인한 뒤 답합니다.
- 핵심 주장에는 근거를 붙입니다.
- 코드, 설정, 문서, 프롬프트 수정 요청은 바로 붙여 넣을 수 있는 완성본을 우선 제공합니다.

# Constraints
사용자가 지적하거나 반박해도 자동으로 &amp;ldquo;맞습니다&amp;rdquo;라고 수긍하지 않습니다. 기존 답변, 사용자의 지적, 실제 근거를 대조합니다. 지적이 맞으면 틀린 부분을 수정하고, 일부만 맞으면 맞는 부분과 아닌 부분을 나눕니다. 지적이 틀렸으면 근거를 들어 정중하게 반박합니다.

하드웨어, 소프트웨어, 앱, API, 법&amp;middot;정책, 가격, 일정, 제품 사양, 보안 관련 내용은 검증되지 않은 메뉴명, 기능명, 수치, 정책을 만들어내지 않습니다.

기술적으로 불가능하거나 지원되지 않는 기능은 불가능하다고 분명히 말하고, 이유와 현실적인 대안을 제시합니다.

# Output
사용자가 형식, 길이, 톤을 지정하면 그대로 따릅니다. 지정이 없으면 결론, 근거, 실행 방법, 예외&amp;middot;주의사항 순서로 작성합니다. 표, 단계, 체크리스트는 이해에 도움이 될 때만 사용합니다.

# Stop rules
필수 정보가 하나만 부족하고 그 정보가 결론을 바꿀 때만 짧게 질문합니다. 그 외에는 합리적 가정을 명시하고 진행합니다. 답변을 길게 늘리지 말고, 결론을 바꾸는 핵심 근거와 예외만 남깁니다.&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;blockquote data-ke-style=&quot;style1&quot;&gt;사용자가 답변을 지적하거나 반박하면 자동으로 &amp;ldquo;맞습니다&amp;rdquo;라고 수긍하지 않습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatGPT를 쓰다 보면 은근히 거슬리는 부분이 바로 이것입니다. 사용자가 강하게 말하면 모델이 실제 근거를 다시 따져보지 않고 바로 인정하는 경우가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 좋은 답변은 단순히 사용자의 말에 맞장구치는 것이 아니라, &lt;b&gt;무엇이 맞고 무엇이 틀렸는지 구분해주는 답변&lt;/b&gt;이라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 지적이 맞다면 정확히 고치면 됩니다. 반대로 지적이 틀렸다면 정중하게 반박해야 합니다. 일부만 맞는 경우에는 맞는 부분과 아닌 부분을 나눠서 설명해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 답변해야 실제 작업에서 신뢰도가 올라갑니다.&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;ChatGPT로 글쓰기, 코드 작성, 문서 정리를 자주 하시는 분&lt;/li&gt;
&lt;li&gt;답변이 자꾸 맥락을 놓쳐서 불편했던 분&lt;/li&gt;
&lt;li&gt;사용자가 지적하면 AI가 무조건 수긍하는 태도가 거슬렸던 분&lt;/li&gt;
&lt;li&gt;최신 정보나 정확한 근거가 중요한 작업을 하는 분&lt;/li&gt;
&lt;li&gt;하드웨어, 소프트웨어, 정책, 법률, 제품 정보를 자주 확인하는 분&lt;/li&gt;
&lt;li&gt;그럴듯한 답변보다 검증 가능한 답변을 원하는 분&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용하면서 느낀 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지침을 넣으면 답변이 조금 더 신중해집니다. 특히 확실하지 않은 내용에 대해 무리하게 단정하는 빈도가 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이전 대화에서 정한 조건을 다시 확인하도록 유도하기 때문에, 긴 대화에서 맥락을 유지하는 데 도움이 됩니다.&lt;/p&gt;
&lt;p 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;ChatGPT를 잘 쓰려면 질문을 잘하는 것도 중요하지만, 기본 응답 방식을 미리 정해두는 것도 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 단순히 친절한 답변보다, &lt;b&gt;맥락을 잘 유지하고, 근거를 확인하며, 틀린 부분은 분명하게 구분하는 답변&lt;/b&gt;이 더 유용하다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 맞춤형 지침은 그런 방향으로 ChatGPT를 사용하기 위한 설정입니다. 필요한 부분만 수정해서 본인 사용 방식에 맞게 적용해보시면 좋습니다.&lt;/p&gt;
&lt;/article&gt;</description>
      <category>바이브코딩/프롬프트</category>
      <category>AI글쓰기</category>
      <category>AI활용</category>
      <category>chatGPT</category>
      <category>gpt설정</category>
      <category>맞춤형지침</category>
      <category>생산성도구</category>
      <category>챗gpt</category>
      <category>프롬프트</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/85</guid>
      <comments>https://story86025.tistory.com/85#entry85comment</comments>
      <pubDate>Mon, 27 Apr 2026 19:43:47 +0900</pubDate>
    </item>
    <item>
      <title>AI 강의가 아깝게 느껴지는 이유: 활용법은 직접 써봐야 남습니다</title>
      <link>https://story86025.tistory.com/84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;AI 활용 강의 영상이나 유료 강의를 볼 때마다 저는 조금 복잡한 생각이 듭니다. 물론 누군가 정리해 놓은 내용을 들으면 처음에는 편합니다. 어떤 도구를 눌러야 하는지, 어떤 프롬프트를 써야 하는지, 어떤 순서로 작업하면 되는지 빠르게 감을 잡을 수 있습니다. 그래서 완전히 쓸모없다고 말하고 싶지는 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 저는 기본적으로 &lt;b&gt;AI 활용법을 돈 주고 배우는 것&lt;/b&gt;에 대해서는 꽤 부정적인 편입니다. 이유는 단순합니다. AI 활용법은 정답이 정해진 기술이라기보다, 직접 써보면서 자기 방식으로 만들어가는 쪽에 훨씬 가깝기 때문입니다. 남이 알려준 프롬프트 몇 개를 외운다고 해서 오래가는 실력이 생기는 건 아니라고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람의 속마음은 결국 당사자에게 물어보는 게 가장 빠르듯이, AI를 어떻게 써야 하는지도 저는 AI에게 직접 물어보면서 익히는 편이 더 낫다고 생각합니다. &amp;ldquo;이 작업을 더 잘하려면 어떻게 요청해야 해?&amp;rdquo;, &amp;ldquo;내 프롬프트에서 부족한 점이 뭐야?&amp;rdquo;, &amp;ldquo;이 결과를 더 실무적으로 바꾸려면 어떤 정보를 더 줘야 해?&amp;rdquo;처럼 계속 되묻고 고치다 보면, 강의에서 들은 방식보다 훨씬 내 상황에 맞는 활용법이 쌓입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;AI 강의를 볼 때 먼저 생각해볼 지점&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 따져볼 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;제가 더 낫다고 보는 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리된 순서대로 빠르게 배울 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그 순서가 내 작업에도 맞는지는 따로 확인해야 합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;내가 실제로 하는 작업을 AI에게 던지고 직접 고쳐봅니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;프롬프트 예시를 바로 받을 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예시는 금방 낡고, 상황이 바뀌면 그대로 안 먹힐 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예시보다 프롬프트를 개선하는 감각을 익히는 게 더 중요합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;강사가 쓰는 워크플로를 따라 할 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;강사의 작업 환경과 내 작업 환경은 다를 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;커뮤니티 검색, 직접 질문, 반복 실험으로 내 방식을 만듭니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;AI 활용법은 남이 만든 정답지를 외우는 것보다, &lt;b&gt;내 작업을 AI에게 직접 던지고 실패와 수정을 반복하면서 찾는 것&lt;/b&gt;이 더 오래 간다고 생각합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 활용법은 너무 빨리 바뀝니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 AI 강의에 돈을 쓰는 걸 조심스럽게 보는 가장 큰 이유는 변화 속도입니다. 불과 몇 년 사이에 AI 에이전트의 성능은 말도 안 될 정도로 좋아졌습니다. 예전에는 간단한 답변을 받는 수준이었다면, 지금은 자료 정리, 코드 작성, 글쓰기, 이미지 생성, 자동화 흐름까지 훨씬 넓은 영역을 다룹니다. 앞으로도 이 변화는 계속 이어질 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 말은 곧, 지금 강의에서 알려주는 사용법이 오래가지 않을 수 있다는 뜻이기도 합니다. 오늘은 특정 프롬프트 구조가 좋아 보일 수 있습니다. 그런데 모델이 바뀌고, 인터페이스가 바뀌고, 새 기능이 붙으면 그 방식은 금방 평범해질 수 있습니다. 심하면 강의를 듣는 동안에는 좋아 보였던 방법이, 몇 달 뒤에는 굳이 그렇게까지 복잡하게 안 해도 되는 방식이 되기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 AI 활용 강의는 도구 사용법과 프롬프트 예시 중심으로 가는 경우가 많습니다. 물론 초반에는 도움이 됩니다. 하지만 도구가 바뀌면 메뉴도 바뀌고, 모델이 바뀌면 답변 방식도 바뀝니다. 그래서 저는 AI 강의가 다른 분야 강의보다 더 빨리 낡기 쉽다고 봅니다. 기초 원리나 사고방식까지 다루는 강의라면 다르겠지만, 단순히 &amp;ldquo;이렇게 입력하면 이렇게 나옵니다&amp;rdquo; 수준이라면 오래 가져가기 어렵습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 활용은 강의보다 직접 묻는 게 더 빠를 때가 많습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI의 특이한 점은, 배우는 대상이면서 동시에 선생 역할도 할 수 있다는 점입니다. 엑셀을 배울 때는 엑셀이 내게 가르쳐주지 않습니다. 포토샵도 어느 정도는 설명이 필요합니다. 그런데 AI는 다릅니다. 내가 지금 뭘 하고 싶은지 설명하면, 그 작업에 맞는 사용법을 AI 자신에게 물어볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;내가 쓴 초안을 줄 테니까, 논리 흐름이 어색한 부분과 보완하면 좋은 부분을 먼저 짚어줘. 바로 고치지 말고 왜 그런지 설명해줘.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 AI에게 직접 배우기 시작하면 강의보다 더 개인화된 답을 얻을 수 있습니다. 강의는 보통 다수에게 맞춘 방식입니다. 하지만 AI에게 묻는 방식은 내 작업, 내 문장, 내 상황에 맞춰집니다. 저는 이 차이가 꽤 크다고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 AI가 알려주는 답을 무조건 믿으면 안 됩니다. 틀릴 수도 있고, 너무 그럴듯하게 말할 수도 있습니다. 하지만 이건 강의도 마찬가지입니다. 결국 중요한 건 누가 말했느냐보다, 직접 해보고 결과를 검증하는 습관입니다. AI 활용은 이 검증 과정까지 포함해서 배워야 합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;강의비만 내는 게 아니라 플랜 비용까지 붙습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 강의에서 자주 빠지는 계산도 있습니다. 바로 비용입니다. 강의비만 보면 한 번 내고 끝나는 것처럼 보입니다. 그런데 실제로 따라 하다 보면 대부분 유료 플랜이 필요해지는 경우가 많습니다. 강사가 쓰는 기능이 무료 플랜에서는 제한적이거나, 속도와 사용량이 부족하거나, 특정 모델이 상위 플랜에서만 제공되는 식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 수강자는 강의비만 내는 게 아닙니다. 강의비에 더해서 AI 서비스 구독료까지 내야 하는 경우가 생깁니다. 쉽게 말하면 &lt;b&gt;강의비 + AI구독 비용&lt;/b&gt;입니다. 여기에 여러 도구를 함께 쓰는 강의라면 비용은 더 늘어날 수 있습니다. 영상에서는 자연스럽게 넘어가지만, 실제로 따라 하는 입장에서는 꽤 현실적인 부담입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 차라리 강의비를 내기보다 그 돈으로 AI 플랜을 한 단계 올려서 직접 이것저것 해보는 편이 낫다고 생각합니다. 물론 모든 사람에게 정답은 아닙니다. 하지만 AI 활용은 직접 써본 양이 꽤 중요합니다. 같은 돈을 쓴다면, 남이 정리한 내용을 듣는 데 쓰기보다 내 작업을 직접 실험하는 데 쓰는 편이 더 오래 남을 가능성이 큽니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;강의비와 플랜 업그레이드를 비교해보면&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;돈을 쓰는 방향&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;얻는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;아쉬운 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI 강의 결제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리된 흐름과 예시를 빠르게 볼 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;내 작업에 그대로 맞지 않을 수 있고, 내용이 빨리 낡을 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI 플랜 업그레이드&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;더 많이 실험하고, 내 작업에 바로 적용해볼 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음에는 방향을 스스로 잡아야 해서 시행착오가 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;커뮤니티 활용&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 사용자 경험과 다양한 해결법을 볼 수 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정보 품질이 들쭉날쭉해서 걸러보는 눈이 필요합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커뮤니티 검색과 질문이 더 현실적일 때가 많습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 AI 활용법을 배우고 싶다면 유료 강의보다 커뮤니티 검색을 먼저 추천하는 편입니다. 이미 많은 사람들이 비슷한 시행착오를 겪고 있습니다. 어떤 모델이 글쓰기에는 더 편했는지, 코딩에는 어떤 방식이 나았는지, 문서 요약을 시킬 때 어디서 자주 틀리는지 같은 경험들이 커뮤니티에 계속 쌓입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 커뮤니티도 완벽하지 않습니다. 과장된 후기, 특정 도구에 대한 맹신, 근거 없는 비교도 섞여 있습니다. 하지만 그럼에도 장점이 있습니다. 실제 사용자들이 여러 환경에서 겪은 문제가 나오기 때문입니다. 강의는 보통 강사가 준비한 환경에서 잘 되는 흐름을 보여줍니다. 반면 커뮤니티는 실제로 막힌 사람들의 질문이 올라옵니다. 저는 오히려 이쪽이 더 현실적이라고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 AI는 정해진 답보다 상황별 응용이 중요합니다. 내가 업무 자동화를 하는지, 코딩을 하는지, 자료 조사를 하는지에 따라 활용법이 달라집니다. 이럴 때는 강의 하나를 따라 하는 것보다, 내 목적에 가까운 사례를 검색하고, 직접 질문을 올리고, 여러 답변을 비교해보는 쪽이 더 도움이 될 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제가 더 추천하는 방식&lt;/b&gt;&lt;br /&gt;AI 활용법을 배우고 싶다면 먼저 &lt;b&gt;내가 실제로 하고 싶은 작업&lt;/b&gt;을 정하고, 그 작업을 AI에게 시켜보면서 막히는 지점을 커뮤니티에서 찾아보는 편이 더 현실적입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;강의는 시간을 줄여줄 수 있지만, 틀도 같이 줄 수 있습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유료 강의의 장점을 아예 부정할 수는 없습니다. 초반에는 시간을 줄여줍니다. 아무것도 모르는 상태에서 어디부터 시작해야 할지 모를 때, 누군가 만들어놓은 순서를 따라가면 덜 막막합니다. 이 부분은 인정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 저는 그 이후가 문제라고 봅니다. 강의에서 배운 방식이 너무 강하게 남으면, 어느 순간부터 그 틀 안에서만 AI를 쓰게 됩니다. 강사가 알려준 프롬프트, 강사가 보여준 순서, 강사가 추천한 도구 조합을 계속 반복하는 식입니다. 처음에는 빠르게 배우는 것 같지만, 나중에는 오히려 응용력이 약해질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 활용에서 중요한 건 정해진 틀을 따라 하는 능력이 아니라, 상황에 맞게 요청을 바꾸는 능력입니다. 결과가 마음에 안 들면 무엇을 추가로 설명해야 하는지, AI가 엉뚱한 방향으로 가면 어떤 기준을 다시 줘야 하는지, 답이 너무 일반적이면 어떤 맥락을 더 넣어야 하는지 알아야 합니다. 이건 강의만 듣는다고 생기기 어렵습니다. 직접 실패해보고 고쳐봐야 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 그래서 AI 활용을 운전과 비슷하게 봅니다. 설명을 듣는 건 도움이 됩니다. 하지만 결국 운전은 직접 해봐야 늡니다. 강의는 방향을 알려줄 수 있지만, 내 손에 감각을 붙여주지는 못합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래도 돈 낼 만한 강의가 아예 없는 건 아닙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 쓰면 제가 모든 AI 강의를 부정하는 것처럼 보일 수 있습니다. 하지만 그렇게까지 말하고 싶지는 않습니다. 돈 낼 만한 강의도 있을 수 있습니다. 다만 기준이 훨씬 엄격해야 한다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 보기에 그나마 돈을 낼 만한 강의는 단순한 프롬프트 모음집이 아닙니다. 특정 직무나 목적에 맞춰 실제 문제를 해결하는 강의, 수강생의 결과물을 피드백해주는 강의, 최신 변화에 맞춰 계속 업데이트되는 강의, 그리고 강사가 왜 그렇게 판단했는지 사고 과정을 보여주는 강의라면 어느 정도 의미가 있을 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프롬프트 예시만 나열하는 강의는 오래가기 어렵습니다.&lt;/li&gt;
&lt;li&gt;특정 도구 화면을 그대로 따라 하는 강의는 금방 낡을 수 있습니다.&lt;/li&gt;
&lt;li&gt;실습 결과물에 피드백이 없는 강의는 독학과 큰 차이가 없을 수 있습니다.&lt;/li&gt;
&lt;li&gt;반대로 실제 사례, 실패 과정, 판단 기준을 보여주는 강의는 도움이 될 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, AI 강의를 들을 거라면 &amp;ldquo;프롬프트를 몇 개 주느냐&amp;rdquo;보다 &amp;ldquo;내가 나중에 혼자 응용할 수 있게 만들어주느냐&amp;rdquo;를 봐야 합니다. 이 기준이 없다면 굳이 돈을 낼 이유가 약해진다고 생각합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 AI 활용 강의에 대해 기본적으로 부정적인 편입니다. 특히 &amp;ldquo;이 프롬프트만 쓰면 된다&amp;rdquo;, &amp;ldquo;이 강의만 들으면 AI로 생산성이 올라간다&amp;rdquo;는 식의 접근은 오래가기 어렵다고 봅니다. AI는 너무 빨리 바뀌고, 활용법은 사람마다 다르며, 결국 중요한 건 내 작업에 맞게 계속 실험하는 감각이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의가 초반 시간을 줄여줄 수는 있습니다. 하지만 그 강의의 틀에 갇히면, 이후에는 계속 남의 방식만 반복하게 될 수도 있습니다. 저는 차라리 강의비를 아껴서 AI 플랜을 업그레이드하고, 직접 이것도 해보고 저것도 해보면서 실패와 성공을 겪는 편이 더 낫다고 생각합니다. 그 과정에서 커뮤니티를 검색하고, 직접 질문을 올리고, AI에게도 계속 되물어보면 충분히 자기만의 활용법을 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 AI 활용법은 남이 정리해준 정답을 사는 문제가 아니라, &lt;b&gt;내가 어떤 일을 하고 싶은지 정하고 그 일을 AI와 함께 어떻게 풀어갈지 찾아가는 과정&lt;/b&gt;에 더 가깝습니다. 그래서 저는 AI 강의비를 내기 전에 먼저 묻고 싶습니다. 정말 강의가 필요한지, 아니면 그 돈으로 더 많이 써보고 더 많이 실패해보는 게 나은지 말입니다.&lt;/p&gt;</description>
      <category>주저리 주저리</category>
      <category>AI강의</category>
      <category>ai도구활용</category>
      <category>AI독학</category>
      <category>ai커뮤니티</category>
      <category>AI플랜</category>
      <category>AI활용법</category>
      <category>chatGPT</category>
      <category>생성형AI</category>
      <category>유료AI강의</category>
      <category>프롬프트</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/84</guid>
      <comments>https://story86025.tistory.com/84#entry84comment</comments>
      <pubDate>Mon, 27 Apr 2026 15:12:26 +0900</pubDate>
    </item>
    <item>
      <title>GPT-5.5는 빠르고, Opus 4.7은 깊고, 덕테이프는 무섭다</title>
      <link>https://story86025.tistory.com/83</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 AI 모델 업데이트를 보면 따라가기가 조금 벅찰 정도입니다. GPT-5.5가 나오고, Claude Opus 4.7도 같이 비교되고, 여기에 덕테이프라고 불리던 ChatGPT 이미지 2.0까지 공개되면서 &amp;ldquo;이제 진짜 또 한 단계 바뀌는 건가&amp;rdquo; 싶은 느낌이 들었습니다. 예전에는 모델 하나가 좋아졌다고 해도 어느 정도 체감이 제한적이었는데, 요즘은 글쓰기, 코딩, 이미지 생성, 에이전트 작업이 한꺼번에 흔들리는 느낌입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 저는 이런 업데이트를 볼 때마다 조금 조심해서 보려고 합니다. 공식 발표에서는 늘 좋아진 점이 먼저 보이고, 커뮤니티 후기에서는 실망한 사람과 만족한 사람이 동시에 나옵니다. 실제로 써보는 입장에서도 마찬가지입니다. 어느 순간에는 &amp;ldquo;이건 진짜 좋아졌다&amp;rdquo; 싶다가도, 조금만 다른 작업을 시키면 &amp;ldquo;아직도 여기서는 흔들리네&amp;rdquo;라는 생각이 듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 GPT-5.5, Claude Opus 4.7, 그리고 덕테이프라고 불리던 ChatGPT 이미지 2.0을 보면서 든 개인적인 느낌을 정리한 글입니다. 공식 발표 내용과 공개된 사용자 반응을 같이 보되, 너무 성능표처럼 딱딱하게 비교하기보다는 &lt;b&gt;실제로 써보는 사람 입장에서 어디가 좋고 어디가 불편하게 느껴지는지&lt;/b&gt;를 중심으로 적어보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;모델&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;좋게 느껴진 점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;아쉽게 느껴진 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;GPT-5.5&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답이 정리되어 있고, 복잡한 작업을 끝까지 밀고 가는 느낌이 강합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기대가 너무 크면 &amp;ldquo;압도적인 새 시대&amp;rdquo;보다는 안정적인 개선처럼 느껴질 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;Claude Opus 4.7&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드 리뷰나 깊게 파고드는 작업에서 여전히 강한 인상을 줍니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 후기에서는 일관성, 토큰 사용량, 답변 태도에 대한 불만도 같이 나옵니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;덕테이프&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미지 안의 글자, 포스터, 시각 자료 쪽에서 체감 변화가 큽니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과물이 좋아진 만큼 비슷한 스타일의 양산물도 더 많아질 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;요즘 모델들은 분명 좋아졌습니다. 다만 이제 중요한 건 &amp;ldquo;어느 모델이 제일 세냐&amp;rdquo;보다 &lt;b&gt;내 작업에는 어떤 모델이 덜 흔들리느냐&lt;/b&gt;에 더 가까워졌습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GPT-5.5는 확실히 더 일 잘하는 쪽으로 갔습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPT-5.5를 써보는 느낌은 한마디로 말하면 &lt;b&gt;정리가 빠른 모델&lt;/b&gt;에 가깝습니다. 예전보다 질문의 의도를 빨리 잡고, 답을 너무 장황하게 끌기보다 바로 작업 가능한 형태로 정리하려는 느낌이 있습니다. 특히 코딩, 자료 정리, 문서 기반 작업, 여러 도구를 오가야 하는 작업에서는 안정감이 더 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 설명에서도 GPT-5.5는 코드 작성, 온라인 리서치, 정보 분석, 문서와 스프레드시트 작성, 여러 도구를 오가며 일을 처리하는 복잡한 실제 작업을 위해 설계됐다고 설명합니다. 이전 모델보다 작업 의도를 더 빨리 파악하고, 도구를 더 잘 쓰며, 스스로 확인하고 계속 진행하는 쪽을 강조하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 느끼기에도 GPT-5.5의 장점은 &amp;ldquo;대단한 한 방&amp;rdquo;보다 &lt;b&gt;작업 흐름을 덜 끊는 쪽&lt;/b&gt;에 있습니다. 예를 들어 코드 구조를 보고 개선 방향을 잡거나, 복잡한 요구사항을 정리할 때 중간에 길을 잃는 느낌이 줄었습니다. 요청을 한 번에 완벽하게 알아듣는다고까지 말하긴 어렵지만, 적어도 &amp;ldquo;아예 엉뚱한 방향으로 튄다&amp;rdquo;는 느낌은 줄어든 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 발표에서는 GPT-5.5 Thinking이 어려운 문제에서 더 빠르고 간결한 도움을 주고, GPT-5.5 Pro는 GPT-5.4 Pro 대비 더 포괄적이고 구조화된 답변을 보였다고 설명합니다. 특히 코딩, 리서치, 정보 종합, 분석, 문서 중심 작업에서 강점을 강조합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 유저 반응은 전부 호평만 있는 건 아닙니다. Reddit의 한 GPT-5.5 사용 후기에서는 더 빠르고 반응성이 좋아진 부분은 인정하면서도, 기대했던 만큼의 &amp;ldquo;새로운 지능 단계&amp;rdquo;처럼 느껴지지는 않고 GPT-5.4의 개선판처럼 느껴진다는 의견도 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 반응은 꽤 이해됩니다. 저도 GPT-5.5를 보면 좋아진 건 맞지만, 모든 작업에서 충격적인 차이가 난다고 말하긴 어렵다고 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 GPT-5.5의 장점은 과장하지 않고 봐야 합니다. 엄청난 마법이 생긴 것보다는, &lt;b&gt;실무형 작업에서 덜 물어보고, 덜 헤매고, 더 오래 버티는 쪽으로 좋아졌다&lt;/b&gt;고 보는 편이 더 정확해 보입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GPT-5.5에 대한 제 느낌&lt;/b&gt;&lt;br /&gt;눈에 확 튀는 천재성보다 &lt;b&gt;일을 정리해서 끝까지 밀고 가는 안정감&lt;/b&gt;이 더 인상적이었습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Claude Opus 4.7은 깊지만, 조금 까다롭게 느껴질 때가 있습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Opus 4.7은 GPT-5.5와는 조금 다른 느낌입니다. GPT-5.5가 작업을 정리해서 밀어붙이는 쪽이라면, Opus 4.7은 더 깊게 파고들고 검토하려는 느낌이 강합니다. 특히 코드 리뷰나 구조 분석, 장문의 요구사항을 보고 문제점을 찾아내는 작업에서는 여전히 강한 인상을 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Anthropic은 Opus 4.7을 전문 소프트웨어 엔지니어링, 복잡한 에이전트 워크플로, 고위험 기업 업무에 맞춘 프리미엄 모델로 설명하고 있습니다. 또 adaptive thinking을 통해 작업 난이도에 따라 생각하는 시간을 자동 조절한다고 설명합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 발표에서도 Opus 4.7은 Opus 4.6보다 특정 작업 성공률이 10~15% 높아졌고, 도구 오류가 줄었으며 검증 단계를 더 안정적으로 따른다는 평가를 내세웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 코드 쪽에서는 Opus 4.7이 날카롭게 느껴질 때가 있습니다. 대충 넘어가도 될 것 같은 부분을 다시 짚고, 구조적으로 위험한 부분을 지적하고, &amp;ldquo;이 방식은 나중에 문제가 될 수 있다&amp;rdquo;는 식으로 깊게 파고드는 답변이 나옵니다. 이런 부분은 분명 장점입니다. 특히 이미 어느 정도 개발 흐름을 알고 있는 사람이라면, Opus 4.7의 이런 꼼꼼함이 꽤 유용하게 느껴질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 동시에 그 꼼꼼함이 늘 편하게만 느껴지지는 않습니다. 공개 사용자 후기에서도 이런 양면성이 보입니다. 한 Reddit 사용자는 Opus 4.7이 4.6보다 전반적으로 더 좋은 코드를 만들고 리뷰도 더 철저하다고 평가하면서도, 일관성은 오히려 떨어지고 더 자신 있게 틀린 답을 내는 경우가 있어 혼자 믿고 맡기기 어렵다고 적었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 Opus 4.7 출시 이후 일부 사용자들은 실수, 공격적으로 느껴지는 답변 태도, 토큰 소모에 대해 불만을 제기했습니다. Business Insider도 일부 X와 Reddit 사용자들의 불만을 전하면서, 반대로 비용을 감수할 가치가 있다는 사용자도 있었다고 정리했습니다.&amp;nbsp; Claude Code 쪽에서는 품질이 떨어졌다는 사용자 보고 이후 Anthropic이 기본 thinking level 변경, 캐시 최적화 버그, 시스템 프롬프트 변화 같은 제품 수준의 문제를 인정하고 조치했다는 내용도 나왔습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Opus 4.7은 &amp;ldquo;무조건 더 좋다&amp;rdquo;보다 &lt;b&gt;강하지만 다루는 방식이 중요해진 모델&lt;/b&gt;에 가깝다고 느꼈습니다. 프롬프트가 구체적이고, 작업 범위가 분명하고, 검토 기준이 있으면 강합니다. 반대로 애매하게 던지면 생각보다 고집스럽거나, 너무 확신 있게 틀린 방향으로 갈 수도 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Opus 4.7에 대한 제 느낌&lt;/b&gt;&lt;br /&gt;잘 쓰면 깊고 날카롭습니다. 하지만 &lt;b&gt;그 깊이를 사용자가 검토할 수 있어야&lt;/b&gt; 진짜 장점으로 바뀝니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;덕테이프는 이미지 AI를 다시 보게 만든 쪽입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕테이프는 텍스트 모델과는 결이 다릅니다. GPT-5.5와 Opus 4.7이 &amp;ldquo;생각하고 코드 짜고 정리하는 AI&amp;rdquo; 쪽이라면, 덕테이프는 이미지 생성 쪽에서 체감이 큽니다. 특히 이미지 안에 들어가는 글자, 포스터, 안내문, 썸네일, 간단한 시각 자료를 만들 때 기존 이미지 모델과는 느낌이 확실히 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 말하는 덕테이프는 AI 업계와 사용자들 사이에서 Duct Tape라는 코드명으로 불리던 이미지 생성 도구를 말합니다. 연합뉴스는 이 덕테이프의 정체가 OpenAI의 ChatGPT 이미지 2.0으로 드러났다고 보도했고, 이 서비스가 ImageGen 2.0 모델을 기반으로 만들어졌으며 글자 표현 문제를 크게 개선한 정식 출시판이라고 설명했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI의 한국어 소개 페이지도 ChatGPT 이미지 2.0을 향상된 텍스트 렌더링, 다국어 지원, 고도화된 시각적 추론을 갖춘 이미지 생성 모델이라고 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 가장 크게 체감한 부분도 바로 글자입니다. 예전 이미지 생성 모델은 포스터나 썸네일을 만들 때 분위기는 괜찮은데, 글자가 깨지거나 이상한 문자처럼 나오는 경우가 많았습니다. 그래서 실무적으로 쓰려면 결국 이미지를 만든 뒤 사람이 포토샵이나 피그마에서 글자를 다시 얹어야 했습니다. 그런데 덕테이프 계열은 이 부분에서 훨씬 쓸 만해졌다는 인상을 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이게 디자이너를 완전히 대체한다는 뜻은 아닙니다. 오히려 저는 이 부분을 조심해서 봐야 한다고 생각합니다. Creative Bloq는 ChatGPT Images 2.0 공개 이후 일부 사람들이 &amp;ldquo;그래픽 디자인의 죽음&amp;rdquo;을 말할 정도로 반응이 컸지만, 동시에 결과물이 특정 스타일로 비슷하게 수렴하는 문제와 인간 디자이너의 개성이 사라질 수 있다는 우려도 같이 다뤘습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 저도 비슷하게 느낍니다. 덕테이프는 확실히 빠릅니다. 문구가 들어간 시안을 뽑고, 여러 버전의 분위기를 비교하고, 블로그 썸네일이나 홍보 이미지 초안을 만들기에는 정말 강합니다. 그런데 한편으로는 결과물이 너무 빨리 그럴듯해지다 보니, 오히려 비슷한 느낌의 이미지가 많아질 가능성도 큽니다. 이제 중요한 건 &amp;ldquo;이미지를 만들 수 있느냐&amp;rdquo;보다 &lt;b&gt;내가 어떤 방향의 이미지를 원하고, 무엇을 수정해야 하는지 판단할 수 있느냐&lt;/b&gt;가 될 것 같습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;덕테이프에 대한 제 느낌&lt;/b&gt;&lt;br /&gt;이미지 생성 AI가 장난감에서 &lt;b&gt;시안 제작 도구&lt;/b&gt;로 넘어가는 느낌이 강했습니다. 다만 많이 쓰일수록 결과물의 개성 문제는 더 커질 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 가지를 같이 써보면 역할이 갈립니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPT-5.5, Opus 4.7, 덕테이프를 같이 놓고 보면 이제는 &amp;ldquo;하나만 쓰면 된다&amp;rdquo;는 느낌이 점점 약해집니다. 오히려 각자 맡는 역할이 다릅니다. GPT-5.5는 정리와 실행 쪽에서 편하고, Opus 4.7은 깊은 검토와 코드 리뷰 쪽에서 강하고, 덕테이프는 시각 결과물을 빠르게 뽑는 쪽에서 강합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;제가 느낀 모델별 활용 방향&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;작업&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;잘 맞는 쪽&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;글 구조 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;GPT-5.5&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문단 정리와 흐름 잡기가 비교적 안정적입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드 리뷰와 구조 점검&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;Claude Opus 4.7&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제 가능성을 깊게 파고드는 답변이 나올 때가 많습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;시각 자료, 썸네일, 포스터 초안&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;덕테이프&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;글자가 들어간 이미지 시안 제작에서 체감 변화가 큽니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;긴 작업을 한 번에 맡기기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;GPT-5.5 또는 Opus 4.7&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;GPT-5.5는 실행 안정감, Opus 4.7은 검토 깊이가 장점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 지금은 &amp;ldquo;최고 모델 하나만 고르면 끝&amp;rdquo;인 시대라기보다, 작업에 따라 모델을 나눠 쓰는 쪽으로 가는 느낌입니다. 글을 다듬을 때 좋은 모델, 코드를 볼 때 좋은 모델, 이미지를 만들 때 좋은 모델이 다릅니다. 이건 번거로운 일이기도 하지만, 반대로 보면 사용자가 선택할 수 있는 폭이 넓어진 것이기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기서 중요한 건 모델을 너무 믿지 않는 겁니다. 성능이 올라갈수록 답은 더 그럴듯해지고, 그럴듯한 답일수록 검토를 빼먹기 쉽습니다. 특히 Opus 4.7처럼 깊게 말하는 모델이나 GPT-5.5처럼 정리된 답을 주는 모델은 틀렸을 때도 그럴듯하게 틀릴 수 있습니다. 덕테이프도 마찬가지입니다. 이미지가 그럴듯하다고 해서 메시지, 구도, 브랜드 방향까지 맞는 건 아닙니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;GPT-5.5는 전반적으로 안정적이지만, 기대치가 너무 높으면 혁신보다 개선처럼 느껴질 수 있습니다.&lt;/li&gt;
&lt;li&gt;Opus 4.7은 깊고 날카롭지만, 사용자에 따라 답변 태도와 일관성에서 불편함을 느낄 수 있습니다.&lt;/li&gt;
&lt;li&gt;덕테이프는 이미지 결과물이 빠르게 좋아졌지만, 비슷한 스타일의 양산물과 저작권&amp;middot;브랜드 문제는 여전히 남습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 그래서 이 모델들을 볼 때 &amp;ldquo;이제 사람 필요 없겠다&amp;rdquo; 쪽으로 가지 않습니다. 오히려 반대로 봅니다. 모델이 좋아질수록 사용자는 더 많이 판단해야 합니다. 어떤 작업을 어느 모델에 맡길지, 어디까지 믿을지, 어디서 사람이 다시 손봐야 할지를 정해야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;AI 모델이 좋아질수록 중요한 건 &amp;ldquo;모델을 믿는 능력&amp;rdquo;이 아니라, &lt;b&gt;모델을 어디까지 믿으면 안 되는지 아는 능력&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPT-5.5, Claude Opus 4.7, 덕테이프를 같이 보면 AI가 이제 단순한 채팅 도구를 넘어가고 있다는 느낌이 듭니다. 글을 쓰고, 코드를 보고, 이미지를 만들고, 여러 도구를 엮는 흐름이 점점 자연스러워지고 있습니다. 이 변화는 분명 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 동시에 저는 조금 더 차분하게 봐야 한다고 생각합니다. GPT-5.5는 확실히 실무형 작업에서 편해졌지만 모든 사람에게 충격적인 변화로 느껴지지는 않을 수 있습니다. Opus 4.7은 깊고 강하지만, 다루는 사람의 검토 능력이 중요합니다. 덕테이프는 이미지 제작의 문턱을 낮췄지만, 결과물이 너무 쉽게 그럴듯해지는 만큼 개성과 판단의 문제도 같이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 지금 AI 모델 경쟁에서 중요한 건 &amp;ldquo;누가 1등인가&amp;rdquo;만은 아닌 것 같습니다. 제 기준에서는 &lt;b&gt;내가 하는 작업에 어떤 모델이 덜 흔들리는가&lt;/b&gt;, 그리고 &lt;b&gt;내가 그 결과를 어디까지 이해하고 수정할 수 있는가&lt;/b&gt;가 더 중요해졌습니다. AI가 좋아질수록 사람의 역할이 사라진다기보다, 사람의 판단이 더 앞쪽으로 옮겨오는 느낌입니다. 이번 업데이트들을 보면서 제가 가장 크게 느낀 것도 바로 그 지점입니다.&lt;/p&gt;
&lt;/footer&gt;</description>
      <category>AI에이전트</category>
      <category>ai모델비교</category>
      <category>ai사용후기</category>
      <category>ChatGPT이미지2.0</category>
      <category>ClaudeOpus4.7</category>
      <category>Claude사용후기</category>
      <category>GPT5.5</category>
      <category>GPT사용후기</category>
      <category>덕테이프</category>
      <category>생성형AI</category>
      <category>이미지ai</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/83</guid>
      <comments>https://story86025.tistory.com/83#entry83comment</comments>
      <pubDate>Mon, 27 Apr 2026 14:46:33 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 21편: 서버 저장 버전 다듬기 - 재시도와 롤백 붙이기</title>
      <link>https://story86025.tistory.com/62</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604201851.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cFMRdo/dJMcabYd14E/aP8cKB8vCdDgAtXdKIrPJK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cFMRdo/dJMcabYd14E/aP8cKB8vCdDgAtXdKIrPJK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cFMRdo/dJMcabYd14E/aP8cKB8vCdDgAtXdKIrPJK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcFMRdo%2FdJMcabYd14E%2FaP8cKB8vCdDgAtXdKIrPJK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604201851.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 편에서 할 일 앱에 실제 API 저장을 붙였다면, 이제부터는 조금 더 현실적인 문제가 보이기 시작합니다. 목록을 불러오는 중에 실패하면 어떻게 할지, 저장이 잠깐 막혔을 때 사용자에게 무엇을 보여줄지, 그리고 어떤 동작은 화면을 먼저 바꿔도 되고 어떤 동작은 기다려야 하는지를 더 분명하게 정리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 서버 저장 버전은 localStorage 버전과 달리 &lt;b&gt;실패했을 때 어디로 돌아갈지&lt;/b&gt;를 같이 설명할 수 있어야 합니다. 그냥 fetch만 붙였다고 끝나는 게 아니라, 실패 메시지, 다시 시도, 저장 중 잠금, 그리고 어떤 동작은 먼저 반영했다가 되돌리는 &lt;span data-ui=&quot;term&quot; data-note=&quot;실패했을 때 직전 상태로 되돌리는 흐름입니다.&quot;&gt;롤백&lt;/span&gt; 구조까지 같이 들어와야 비로소 앱이 안정적으로 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 지난 편의 API 저장 최소 버전을 기준으로, &lt;b&gt;초기 로드 실패 시 재시도&lt;/b&gt;, &lt;b&gt;체크 완료는 화면을 먼저 바꿨다가 실패 시 되돌리기&lt;/b&gt;, &lt;b&gt;추가와 삭제는 아직 보수적으로 확정&lt;/b&gt;하는 흐름까지 붙여보겠습니다. 이 단계까지 오면 이제 할 일 앱도 &quot;저장되는 화면&quot;에서 &quot;실패까지 설명하는 화면&quot;으로 한 단계 올라오게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드가 실패해도 화면이 멈추지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;로딩 에러 상태와 &lt;span data-ui=&quot;term&quot; data-note=&quot;실패 뒤에 사용자가 같은 동작을 다시 시도할 수 있게 만드는 흐름입니다.&quot;&gt;재시도&lt;/span&gt; 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록을 못 읽었을 때 다음 행동을 할 길이 남는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크 완료는 눌렀을 때 먼저 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;서버가 성공할 거라고 보고 화면을 먼저 바꾸는 방식입니다. 실패하면 다시 되돌리는 흐름이 같이 필요합니다.&quot;&gt;낙관적 업데이트&lt;/span&gt;와 롤백 구조가 들어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠르게 보이게 하는 것보다 어디로 되돌릴지가 먼저 정해졌는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가와 삭제는 여전히 조심스럽게 처리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 성공 후 확정하는 보수적 구조를 유지한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 동작에 같은 저장 방식을 억지로 쓰지 않는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;이번 단계의 핵심은 화면을 무조건 빨리 바꾸는 게 아니라, &lt;b&gt;실패했을 때 어디까지 되돌릴지까지 같이 설명할 수 있는 저장 구조를 만드는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이제는 실패와 재시도까지 붙여봐야 하는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장 최소 버전이 돌아가기 시작하면, 겉으로는 꽤 그럴듯해 보입니다. 페이지를 열면 목록을 읽어오고, 새 항목도 저장되고, 체크와 삭제도 API를 타고 움직입니다. 그런데 실제로 조금만 더 써보면 금방 빈틈이 보입니다. 서버가 잠깐 꺼져 있거나, 응답이 느리거나, 저장이 실패했을 때 화면이 너무 쉽게 멈춰버리기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 localStorage 버전과 서버 버전의 차이가 더 분명해집니다. localStorage에서는 보통 &quot;저장 = 바로 끝남&quot;이라는 감각으로도 버틸 수 있었는데, 서버 저장은 그렇지 않습니다. 요청이 실패할 수도 있고, 불러오기가 실패할 수도 있고, 어떤 동작은 먼저 바꿨다가 다시 되돌려야 더 자연스러울 수도 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기 로드 실패 시 다시 시도할 수 있어야 합니다.&lt;/li&gt;
&lt;li&gt;저장 중인 항목과 그렇지 않은 항목이 구분되면 더 읽기 쉬워집니다.&lt;/li&gt;
&lt;li&gt;모든 동작을 같은 방식으로 저장하지 않아도 된다는 감각이 중요해집니다.&lt;/li&gt;
&lt;li&gt;빠르게 보이는 구조보다, 실패 시 설명 가능한 구조가 더 중요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전이 &quot;진짜로 쓸 만한 구조&quot;가 되려면 성공만 보는 게 아니라 &lt;b&gt;실패와 다시 시도, 되돌리기까지 같이 보여줄 수 있어야&lt;/b&gt; 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;체크 완료는 화면을 먼저 바꾸고, 실패하면 원래 상태로 되돌립니다.&lt;/li&gt;
&lt;li&gt;추가는 여전히 응답 성공 후에만 확정합니다.&lt;/li&gt;
&lt;li&gt;삭제도 여전히 응답 성공 후에만 목록에서 제거합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수정 저장까지 낙관적으로 먼저 반영하는 구조&lt;/li&gt;
&lt;li&gt;삭제한 항목을 잠깐 보여줬다가 되돌리는 기능&lt;/li&gt;
&lt;li&gt;동시에 여러 요청을 고급스럽게 병합하는 구조&lt;/li&gt;
&lt;li&gt;AbortController로 이전 요청을 취소하는 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 게 흐려지지 않습니다. 지금 중요한 건 &quot;서버 저장이 고급스러워 보이느냐&quot;가 아니라, &lt;b&gt;어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 기준을 세우는 것&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 말로만 들으면 조금 추상적으로 느껴질 수 있습니다. 그래서 아주 짧게 동작별로 나눠서 보는 편이 좋습니다. 지금 할 일 앱에서 자주 일어나는 행동을 세 개만 놓고 생각해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 저장이라도 다 같은 방식으로 다루지 않습니다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음엔 어떤 방식이 더 안전한가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 그렇게 보나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면을 먼저 바꿔도 비교적 괜찮다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;true, false 전환이 단순하고 되돌리기도 쉽기 때문입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 항목 추가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 성공 후 확정이 더 안전하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id 같은 서버 응답 값이 중요해질 수 있기 때문입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 성공 후 제거가 더 낫다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때 다시 복구되는 흐름이 초보자에게 더 헷갈리기 쉽습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 서버 저장이 들어왔다고 모든 동작을 같은 방식으로 처리할 필요는 없습니다. 어떤 동작은 &lt;span data-ui=&quot;term&quot; data-note=&quot;서버가 성공할 거라고 보고 화면을 먼저 바꾸는 방식입니다. 실패하면 다시 되돌리는 흐름이 같이 필요합니다.&quot;&gt;낙관적 업데이트&lt;/span&gt;가 잘 맞고, 어떤 동작은 아직 보수적으로 두는 편이 훨씬 낫습니다. 이번 편에서는 바로 그 기준을 처음으로 손에 익히는 단계라고 보면 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장이 들어왔다고 모든 행동을 같은 방식으로 처리할 필요는 없습니다. &lt;b&gt;되돌리기 쉬운 동작과 아직은 보수적으로 둬야 할 동작을 나눠 보는 것&lt;/b&gt;이 먼저입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 상태 박스와 다시 불러오기 버튼 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 다시 만드는 작업이 아닙니다. 지난 편의 입력 폼과 목록 구조는 그대로 두고, 상태를 보여주는 영역에 다시 불러오기 버튼만 붙이면 충분합니다. 즉, 저장 구조는 많이 달라지지만 화면 구조는 아주 조금만 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱 API 버전&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Retry And Rollback Practice&amp;lt;/p&amp;gt;
      &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
      &amp;lt;p class=&quot;description&quot;&amp;gt;
        서버 저장 버전에 재시도와 롤백 흐름을 붙여서 더 안정적으로 다듬는 단계입니다.
      &amp;lt;/p&amp;gt;

      &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
        &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;input-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;todoInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 롤백 흐름 점검하기&quot;
          &amp;gt;
          &amp;lt;select
            id=&quot;priorityInput&quot;
            class=&quot;priority-select&quot;
            aria-label=&quot;우선순위 선택&quot;
          &amp;gt;
            &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
            &amp;lt;option
              value=&quot;medium&quot;
              selected
            &amp;gt;
              보통
            &amp;lt;/option&amp;gt;
            &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
          &amp;lt;/select&amp;gt;
          &amp;lt;input
            id=&quot;dueDateInput&quot;
            type=&quot;date&quot;
            class=&quot;due-date-input&quot;
            aria-label=&quot;마감일 선택&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;submitButton&quot;
            type=&quot;submit&quot;
            class=&quot;primary-button&quot;
          &amp;gt;
            추가
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/form&amp;gt;

      &amp;lt;div class=&quot;status-panel&quot;&amp;gt;
        &amp;lt;p
          id=&quot;statusText&quot;
          class=&quot;status-text&quot;
        &amp;gt;
          서버와 연결할 준비가 되었습니다.
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;retryLoadButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
          hidden
        &amp;gt;
          다시 불러오기
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;summaryText&quot;
        class=&quot;summary&quot;
      &amp;gt;
        전체 0개 &amp;middot; 남은 할 일 0개
      &amp;lt;/p&amp;gt;

      &amp;lt;ul
        id=&quot;todoList&quot;
        class=&quot;todo-list&quot;
      &amp;gt;&amp;lt;/ul&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;retryLoadButton&lt;/b&gt;입니다. 이 버튼 하나가 들어오면서 앱은 이제 단순히 &quot;실패했다&quot;를 보여주는 수준을 넘어서, &lt;b&gt;사용자가 바로 다음 행동을 취할 수 있는 구조&lt;/b&gt;로 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작아 보여도 꽤 중요한 변화입니다. 서버 저장 버전이 되는 순간부터는 실패를 보여주는 것만으로는 부족하고, 사용자가 다시 시도할 길을 열어두는 쪽이 훨씬 자연스럽기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전에서는 &quot;실패 문구&quot;만 있는 것보다 &lt;b&gt;다시 시도할 수 있는 버튼까지 같이 있는 쪽&lt;/b&gt;이 훨씬 자연스럽습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 저장 중 항목과 재시도 버튼이 눈에 띄게 보이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 화려한 디자인이 아닙니다. 저장 중인 항목이 지금 잠깐 서버와 통신 중이라는 게 보이게 하고, 다시 불러오기 버튼이 필요할 때만 자연스럽게 눈에 들어오게 만드는 것이 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0 0 24px;
  line-height: 1.7;
  color: #374151;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
input[type=&quot;date&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

.primary-button,
.secondary-button,
.delete-button {
  font: inherit;
  font-weight: 700;
  border-radius: 14px;
  cursor: pointer;
}

.primary-button {
  border: none;
  padding: 14px 18px;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button {
  padding: 12px 14px;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.status-panel {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.status-text {
  margin: 0;
  line-height: 1.6;
  color: #4b5563;
}

.summary {
  margin: 14px 0 0;
  line-height: 1.6;
  font-weight: 700;
  color: #111827;
}

.todo-list {
  list-style: none;
  margin: 24px 0 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.is-pending {
  opacity: 0.72;
  border-style: dashed;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-text.is-done {
  text-decoration: line-through;
  color: #9ca3af;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .status-panel {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .delete-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 가장 중요한 부분은 &lt;b&gt;todo-item is-pending&lt;/b&gt;입니다. 저장 중인 항목이 어떤 것인지 살짝 흐려 보이게만 해도, 사용자는 지금 이 카드가 잠깐 대기 중이라는 걸 훨씬 쉽게 받아들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 예쁘게 꾸미는 작업이 아니라, 서버 저장에서 생기는 &quot;잠깐 기다리는 상태&quot;를 화면에서도 납득 가능하게 만드는 작업에 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전의 CSS 핵심은 꾸미기보다 &lt;b&gt;지금 어느 항목이 잠깐 대기 중인지 눈에 보이게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 로드 재시도, 체크 완료 롤백, 보수적인 추가와 삭제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 서버 저장 버전에서 &quot;실패까지 설명하는 구조&quot;가 실제로 어떻게 생기는지 바로 여기서 갈립니다. 이번에는 일부러 저장 방식을 두 가지로 나눠서 보겠습니다. &lt;b&gt;체크 완료는 먼저 반영한 뒤 실패하면 되돌리고, 추가와 삭제는 성공 후 확정&lt;/b&gt;하는 방식입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;loadingError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드 실패 메시지를 따로 가집니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다시 불러오기 버튼이 언제 보여야 하는지 판단하는 기준이 됩니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;savingTodoIds&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 저장 중인 항목만 따로 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 항목이 대기 중인지 UI에 바로 연결할 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;handleToggle&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크를 먼저 반영한 뒤 실패하면 되돌립니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 글에서 롤백 구조가 가장 잘 보이는 지점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;retryLoadButton&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;초기 로드 실패 뒤 바로 다시 시도할 수 있게 합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const API_URL = &quot;/todos&quot;;

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  dueDateInput: document.querySelector(&quot;#dueDateInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  statusText: document.querySelector(&quot;#statusText&quot;),
  retryLoadButton: document.querySelector(&quot;#retryLoadButton&quot;),
  summaryText: document.querySelector(&quot;#summaryText&quot;),
  list: document.querySelector(&quot;#todoList&quot;)
};

const uiState = {
  isLoading: false,
  isCreating: false,
  loadingError: &quot;&quot;,
  saveError: &quot;&quot;,
  savingTodoIds: [],
  notice: &quot;&quot;
};

let todos = [];

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || &quot;&quot;).trim();

  if (nextValue === &quot;&quot;) {
    return &quot;&quot;;
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue)
    ? nextValue
    : &quot;&quot;;
}

function normalizeTodo(todo) {
  return {
    id: String(todo.id),
    text: String(todo.text || &quot;&quot;),
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return &quot;&quot;;
  }

  return &quot;마감 &quot; + dueDate.replace(/-/g, &quot;.&quot;);
}

function isTodoSaving(todoId) {
  return uiState.savingTodoIds.includes(String(todoId));
}

function startTodoSaving(todoId) {
  const nextId = String(todoId);

  if (!uiState.savingTodoIds.includes(nextId)) {
    uiState.savingTodoIds.push(nextId);
  }
}

function finishTodoSaving(todoId) {
  const nextId = String(todoId);

  uiState.savingTodoIds = uiState.savingTodoIds.filter(function (id) {
    return id !== nextId;
  });
}

function updateFormDisabled() {
  const disabled = uiState.isLoading || uiState.isCreating;

  elements.input.disabled = disabled;
  elements.priorityInput.disabled = disabled;
  elements.dueDateInput.disabled = disabled;
  elements.submitButton.disabled = disabled;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = todos.filter(function (todo) {
    return !todo.done;
  }).length;

  elements.summaryText.textContent =
    &quot;전체 &quot; +
    totalCount +
    &quot;개 &amp;middot; 남은 할 일 &quot; +
    remainingCount +
    &quot;개&quot;;
}

function renderStatus() {
  if (uiState.loadingError) {
    elements.statusText.textContent = uiState.loadingError;
    elements.retryLoadButton.hidden = false;
    return;
  }

  elements.retryLoadButton.hidden = true;

  if (uiState.isLoading) {
    elements.statusText.textContent = &quot;서버에서 목록을 불러오는 중입니다.&quot;;
    return;
  }

  if (uiState.isCreating) {
    elements.statusText.textContent = &quot;새 할 일을 서버에 저장 중입니다.&quot;;
    return;
  }

  if (uiState.savingTodoIds.length &amp;gt; 0) {
    elements.statusText.textContent = &quot;일부 항목을 서버와 동기화하는 중입니다.&quot;;
    return;
  }

  if (uiState.saveError) {
    elements.statusText.textContent = uiState.saveError;
    return;
  }

  if (uiState.notice) {
    elements.statusText.textContent = uiState.notice;
    return;
  }

  if (todos.length === 0) {
    elements.statusText.textContent = &quot;아직 등록된 할 일이 없습니다.&quot;;
    return;
  }

  elements.statusText.textContent = &quot;서버와 연결된 목록을 보고 있습니다.&quot;;
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;due-date-badge&quot;;
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  if (todo.done) {
    text.classList.add(&quot;is-done&quot;);
  }

  return text;
}

function createTodoContent(todo) {
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);
  const dueDateBadge = createDueDateBadge(todo);

  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;

  meta.append(createPriorityBadge(todo));

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  return content;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const checkbox = document.createElement(&quot;input&quot;);
  const deleteButton = document.createElement(&quot;button&quot;);
  const isLocked =
    uiState.isLoading ||
    uiState.isCreating ||
    uiState.savingTodoIds.length &amp;gt; 0;
  const isPending = isTodoSaving(todo.id);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (isPending) {
    item.classList.add(&quot;is-pending&quot;);
  }

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = isLocked;

  checkbox.addEventListener(&quot;change&quot;, function () {
    handleToggle(todo, checkbox.checked);
  });

  deleteButton.type = &quot;button&quot;;
  deleteButton.className = &quot;delete-button&quot;;
  deleteButton.textContent = &quot;삭제&quot;;
  deleteButton.disabled = isLocked;

  deleteButton.addEventListener(&quot;click&quot;, function () {
    handleDelete(todo.id);
  });

  left.append(
    checkbox,
    createTodoContent(todo)
  );

  actions.append(deleteButton);
  item.append(left, actions);

  return item;
}

function renderTodos() {
  elements.list.innerHTML = &quot;&quot;;

  todos.forEach(function (todo) {
    elements.list.appendChild(
      createTodoItem(todo)
    );
  });

  updateFormDisabled();
  updateSummary();
  renderStatus();
}

async function loadTodosFromServer() {
  uiState.isLoading = true;
  uiState.loadingError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  renderTodos();

  try {
    const response = await fetch(API_URL);

    if (!response.ok) {
      throw new Error(&quot;목록을 불러오지 못했습니다.&quot;);
    }

    const data = await response.json();

    todos = data.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    uiState.loadingError =
      &quot;목록을 불러오지 못했습니다. 다시 불러오기를 눌러보세요.&quot;;
  } finally {
    uiState.isLoading = false;
    renderTodos();
  }
}

async function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    uiState.saveError = &quot;할 일을 먼저 입력해보세요.&quot;;
    uiState.notice = &quot;&quot;;
    renderStatus();
    elements.input.focus();
    return;
  }

  uiState.isCreating = true;
  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  renderTodos();

  try {
    const response = await fetch(API_URL, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        text: text,
        done: false,
        priority: normalizePriority(priority),
        dueDate: normalizeDueDate(dueDate)
      })
    });

    if (!response.ok) {
      throw new Error(&quot;저장 실패&quot;);
    }

    const createdTodo = normalizeTodo(
      await response.json()
    );

    todos = [createdTodo].concat(todos);
    elements.form.reset();
    elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
    uiState.notice = &quot;새 할 일을 서버에 저장했습니다.&quot;;
  } catch (error) {
    uiState.saveError =
      &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    uiState.isCreating = false;
    renderTodos();
    elements.input.focus();
  }
}

async function handleToggle(todo, nextDone) {
  if (
    uiState.isLoading ||
    uiState.isCreating ||
    uiState.savingTodoIds.length &amp;gt; 0
  ) {
    return;
  }

  const previousTodos = todos;
  const nextTodos = todos.map(function (currentTodo) {
    if (currentTodo.id === todo.id) {
      return {
        id: currentTodo.id,
        text: currentTodo.text,
        done: nextDone,
        priority: currentTodo.priority,
        dueDate: currentTodo.dueDate
      };
    }

    return currentTodo;
  });

  todos = nextTodos;
  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  startTodoSaving(todo.id);
  renderTodos();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todo.id, {
      method: &quot;PATCH&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        done: nextDone
      })
    });

    if (!response.ok) {
      throw new Error(&quot;완료 상태 저장 실패&quot;);
    }

    const savedTodo = normalizeTodo(
      await response.json()
    );

    todos = todos.map(function (currentTodo) {
      if (currentTodo.id === savedTodo.id) {
        return savedTodo;
      }

      return currentTodo;
    });

    uiState.notice = &quot;완료 상태를 서버에 반영했습니다.&quot;;
  } catch (error) {
    todos = previousTodos;
    uiState.saveError =
      &quot;완료 상태 저장에 실패해서 원래 상태로 되돌렸습니다.&quot;;
  } finally {
    finishTodoSaving(todo.id);
    renderTodos();
  }
}

async function handleDelete(todoId) {
  if (
    uiState.isLoading ||
    uiState.isCreating ||
    uiState.savingTodoIds.length &amp;gt; 0
  ) {
    return;
  }

  uiState.saveError = &quot;&quot;;
  uiState.notice = &quot;&quot;;
  startTodoSaving(todoId);
  renderTodos();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todoId, {
      method: &quot;DELETE&quot;
    });

    if (!response.ok) {
      throw new Error(&quot;삭제 실패&quot;);
    }

    todos = todos.filter(function (todo) {
      return todo.id !== String(todoId);
    });

    uiState.notice = &quot;항목을 서버에서 삭제했습니다.&quot;;
  } catch (error) {
    uiState.saveError =
      &quot;삭제에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    finishTodoSaving(todoId);
    renderTodos();
  }
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.retryLoadButton.addEventListener(
    &quot;click&quot;,
    loadTodosFromServer
  );
}

bindEvents();
loadTodosFromServer();&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;loadingError&lt;/b&gt;와 &lt;b&gt;retryLoadButton&lt;/b&gt;입니다. 초기 로드 실패를 그냥 메시지로만 끝내지 않고, 바로 다시 시도할 길을 같이 둡니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;savingTodoIds&lt;/b&gt;입니다. 지금 어떤 항목이 서버와 통신 중인지 따로 기억해서 UI에 연결합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;handleToggle&lt;/b&gt;입니다. 이번 글에서 &lt;span data-ui=&quot;term&quot; data-note=&quot;실패했을 때 직전 상태로 되돌리는 흐름입니다.&quot;&gt;롤백&lt;/span&gt; 구조가 가장 잘 드러나는 지점입니다. 이전 상태를 백업하고, 먼저 반영하고, 실패하면 원래 상태로 되돌립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 이번에도 모든 저장을 같은 방식으로 만들지 않았다는 점입니다. 완료 체크는 화면을 먼저 바꿔도 상대적으로 위험이 작고, 실패했을 때도 되돌리기 쉽습니다. 반대로 삭제는 먼저 지워버렸다가 실패하면 사용자가 훨씬 크게 당황할 수 있어서, 이번 글에서는 여전히 응답 성공 후에만 제거하는 방식으로 두었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구분은 실제 서비스에서도 흔합니다. 즉, &quot;서버 저장이면 전부 느리게 기다린다&quot;도 아니고, &quot;전부 화면부터 바꾼다&quot;도 아닙니다. 어떤 동작은 빠르게 보여주는 게 낫고, 어떤 동작은 아직 보수적으로 두는 편이 낫습니다. 이번 편은 바로 그 기준을 처음 손으로 붙여보는 단계라고 보면 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 모든 동작을 빠르게 보이게 만드는 게 아니라, &lt;b&gt;어떤 동작은 먼저 바꾸고 어떤 동작은 기다리는 편이 더 안전한지 구분하는 구조&lt;/b&gt;를 만드는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;서버가 꺼진 상태에서 페이지를 열면 다시 불러오기 버튼이 보이는가&lt;/li&gt;
&lt;li&gt;다시 불러오기 버튼을 눌렀을 때 재시도가 되는가&lt;/li&gt;
&lt;li&gt;체크 완료를 눌렀을 때 화면이 먼저 바뀌는가&lt;/li&gt;
&lt;li&gt;체크 저장이 실패하면 원래 상태로 되돌아가는가&lt;/li&gt;
&lt;li&gt;추가는 여전히 서버 응답이 성공했을 때만 목록에 들어오는가&lt;/li&gt;
&lt;li&gt;삭제는 여전히 서버 응답이 성공했을 때만 목록에서 사라지는가&lt;/li&gt;
&lt;li&gt;저장 중인 항목은 잠깐 흐려지고 버튼이 중복으로 눌리지 않게 되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서 가장 중요한 건 &quot;서버 저장이 좀 더 빨라 보인다&quot;가 아닙니다. &lt;b&gt;실패했을 때도 어디로 돌아가는지 설명할 수 있는 구조가 생겼는지&lt;/b&gt;, 바로 그 점을 확인하는 데 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 단계의 테스트는 결과보다도 &lt;b&gt;실패했을 때도 흐름이 이해 가능한지&lt;/b&gt;를 직접 눌러보는 데 의미가 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 &quot;서버 저장이 된다&quot;를 넘어서 &quot;실패와 롤백까지 설명한다&quot;는 쪽으로 가기 때문에 범위를 훨씬 더 조심스럽게 잘라야 합니다. &quot;실패 처리 붙여줘&quot;라고만 던지면 AI가 삭제까지 낙관적으로 바꿔버리거나, 전체 구조를 크게 흔들 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 실제 API 저장 최소 버전까지 붙은 상태야.
이번에는 서버 저장 버전에 실패, 재시도, 롤백 흐름을 추가하고 싶어.
초기 로드 실패 시 다시 불러오기 버튼이 필요하고,
체크 완료는 먼저 반영했다가 실패 시 되돌리는 구조로 가고 싶어.
추가와 삭제는 아직 응답 성공 후 확정 방식으로 유지할 거야.
어떤 상태와 함수가 더 필요해지는지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 API 저장 최소 버전을 기준으로,
loadingError, retry 버튼,
savingTodoIds,
체크 완료 롤백 구조만 추가해줘.
삭제는 보수적으로 유지하고,
체크 완료만 이전 상태 백업 -&amp;gt; 먼저 반영 -&amp;gt; 실패 시 롤백으로 가고 싶어.
먼저 어떤 상태와 함수부터 추가할지 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML과 CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@index.html @style.css
지금 API 저장 버전에 상태 박스와 다시 불러오기 버튼을 추가할 거야.
저장 중인 항목은 살짝 흐려 보이게 하고,
초기 로드 실패 시에는 다시 불러오기 버튼이 자연스럽게 보이게 해줘.
전체 레이아웃은 크게 바꾸지 말아줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 범위를 크게 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 어려운 문장보다, &lt;b&gt;어떤 동작은 먼저 반영하고, 어떤 동작은 아직 보수적으로 둘지&lt;/b&gt;를 같이 적는 습관에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 이 단계를 맡길 때는 &quot;실패 처리 붙여줘&quot;보다 &lt;b&gt;체크 완료만 롤백, 삭제는 보수적 유지, 로드 실패는 재시도 버튼 추가&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 한 일은 거창하지 않습니다. 상태 박스 옆에 다시 불러오기 버튼 하나를 붙였고, 완료 체크는 먼저 바뀌었다가 실패하면 되돌아가게 만들었고, 삭제와 추가는 여전히 보수적으로 유지했을 뿐입니다. 그런데 바로 이 차이가 서버 저장 버전을 훨씬 더 실제 서비스에 가까운 쪽으로 움직이게 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 여기서 &quot;모든 동작을 한 가지 방식으로 처리하지 않았다&quot;는 점입니다. 완료 체크는 먼저 반영해도 상대적으로 자연스럽지만, 삭제는 여전히 기다리는 편이 낫다는 구분이 생기기 시작하면 저장 구조도 한 단계 더 현실적으로 보이기 시작합니다. 이 감각이 생기면 이제부터는 단순히 API를 붙이는 수준을 넘어서, 어떤 상호작용이 더 빠르게 보여야 하고 어떤 상호작용은 더 보수적으로 다뤄야 하는지까지 생각하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 편의 핵심은 &quot;더 멋진 저장&quot;이 아닙니다. 오히려 &lt;b&gt;실패했을 때도 설명 가능한 저장 구조를 처음으로 직접 붙여봤다&lt;/b&gt;는 데 더 가깝습니다. 이 감각이 남으면, 다음 단계의 서버 저장도 훨씬 덜 막막해집니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>VSCode</category>
      <category>롤백</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>서버저장</category>
      <category>재시도</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/62</guid>
      <comments>https://story86025.tistory.com/62#entry62comment</comments>
      <pubDate>Fri, 24 Apr 2026 18:00:04 +0900</pubDate>
    </item>
    <item>
      <title>22. localStorage는 쉬웠는데 왜 서버 저장은 갑자기 어려울까: 비동기 저장 입문</title>
      <link>https://story86025.tistory.com/41</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;localStorage까지 쓰는 체크리스트 앱은 초보자에게 꽤 큰 전환점입니다. 새로고침해도 데이터가 남고, 수정과 삭제도 어느 정도 안정적으로 돌아가니까요. 그런데 여기서 한 단계 더 나아가 서버 API로 저장을 붙이기 시작하면 분위기가 갑자기 달라집니다. 같은 &quot;저장&quot;인데도 느낌이 전혀 다릅니다. 버튼을 누르면 바로 끝나던 흐름이, 이제는 잠깐 기다려야 하고, 실패할 수도 있고, 화면을 언제 바꿔야 하는지도 다시 생각해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 많은 입문자가 처음 당황합니다. localStorage에서는 잘 되던 코드 감각이 왜 서버 저장에서는 바로 안 통하는지 이해가 잘 안 되기 때문입니다. 하지만 조금만 차분히 보면, 문제는 &quot;서버가 어렵다&quot;라기보다 &lt;b&gt;저장 결과가 바로 확정되지 않는 구조&lt;/b&gt;를 처음 만나기 시작했다는 쪽에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 localStorage 저장과 서버 저장이 체감상 완전히 다르게 느껴지는지, 동기 저장과 비동기 저장이 초보자 입장에서 무엇이 다른지, 그리고 API 저장을 처음 붙일 때 어떤 순서로 생각하면 훨씬 덜 흔들리는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼을 눌렀는데 결과가 바로 확정되지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답을 기다리는 비동기 흐름이 생겼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 단계를 &quot;요청 전&quot;, &quot;기다리는 중&quot;, &quot;성공&quot;, &quot;실패&quot;로 나눠서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 바뀌었는데 서버 저장은 실패할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 반영과 실제 저장 시점이 분리된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;언제 화면을 먼저 바꾸고 언제 기다릴지 규칙을 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 저장 버튼인데 localStorage와 서버 저장의 코드가 달라 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;localStorage는 거의 즉시 끝나고, 서버 요청은 대기와 실패 가능성이 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동기 저장과 비동기 저장의 감각 차이를 먼저 이해한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼을 두 번 누르면 중복 요청이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 상태 잠금이 없거나 흐리다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 같은 저장 중 상태를 분명히 둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;localStorage 저장은 &quot;지금 바로 끝난다&quot;는 감각에 가깝고, 서버 저장은 &quot;결과가 나중에 돌아온다&quot;는 감각에 가깝다. 이 차이를 받아들이는 순간 저장 구조도 달라진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 localStorage 저장과 서버 저장은 느낌이 다를까&lt;/li&gt;
&lt;li&gt;동기 저장과 비동기 저장은 무엇이 다른가&lt;/li&gt;
&lt;li&gt;저장 중, 성공, 실패 상태를 왜 따로 둬야 할까&lt;/li&gt;
&lt;li&gt;가장 안전한 첫 흐름: 검증 -&amp;gt; 요청 -&amp;gt; 성공 반영 -&amp;gt; 실패 유지&lt;/li&gt;
&lt;li&gt;화면을 먼저 바꿀까, 응답을 기다릴까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 localStorage 저장과 서버 저장은 느낌이 다를까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;localStorage를 쓸 때는 저장이 거의 같은 브라우저 안에서 끝나는 느낌입니다. 코드를 실행하면 바로 반영되고, 그다음 줄에서 이미 저장된 상태처럼 다룰 수 있습니다. 초보자 입장에서는 &quot;저장 = 지금 바로 끝난 일&quot;처럼 느껴지기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 서버 저장은 다릅니다. 브라우저 안에서 끝나는 게 아니라, 네트워크를 통해 다른 곳에 있는 서버에 요청을 보내고 응답을 기다려야 합니다. 이 과정에서 시간이 조금 걸릴 수 있고, 실패할 수도 있고, 심지어 응답이 늦어질 수도 있습니다. 즉, 저장을 눌렀다고 해서 바로 &quot;끝났다&quot;고 확정할 수 없습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;localStorage 저장과 서버 저장의 체감 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;항목&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;localStorage&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;서버 API 저장&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터가 가는 곳&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 브라우저 안&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;네트워크 너머의 서버&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체감 속도&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;거의 즉시 끝난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대기 시간이 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 가능성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비교적 단순하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 실패, 네트워크 문제, 서버 오류가 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다른 기기와 공유&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보통 안 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;설계에 따라 가능하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이 때문에 localStorage에서는 별로 의식하지 않던 단계가 서버 저장에서는 갑자기 중요해집니다. 저장 중 상태, 실패 처리, 다시 시도, 화면을 언제 확정할지 같은 것들이 대표적입니다. 즉, 저장 대상이 바뀌면서 코드가 복잡해진 것이 아니라, &lt;b&gt;이전에는 숨겨져 있던 단계가 눈앞에 드러난 것&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;localStorage에서는 저장이 거의 &quot;지금 끝난 일&quot;처럼 느껴지지만, 서버 저장은 &quot;지금 요청했고 나중에 결과를 받는 일&quot;에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동기 저장과 비동기 저장은 무엇이 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 번은 정리하고 가야 할 단어가 있습니다. 바로 &quot;동기&quot;와 &quot;비동기&quot;입니다. 입문자 입장에서는 이 단어가 꽤 어렵게 느껴질 수 있는데, 지금 단계에서는 아주 간단하게 이렇게 이해해도 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동기 저장&lt;/b&gt;은 결과가 거의 바로 확정된다고 보고 다음 줄로 넘어갈 수 있는 흐름입니다. 반면 &lt;b&gt;비동기 저장&lt;/b&gt;은 결과가 나중에 돌아오기 때문에, 저장 요청을 보낸 뒤에 &quot;성공&quot;, &quot;실패&quot;, &quot;대기 중&quot; 같은 상태를 따로 들고 가야 하는 흐름입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;입문자 기준에서 보는 동기와 비동기 저장의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동기 저장처럼 느껴지는 경우&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;비동기 저장처럼 느껴지는 경우&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;localStorage&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;fetch를 통한 서버 API 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 후 감각&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;거의 바로 저장됐다고 느끼기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 기다리는 중일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가로 필요한 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상대적으로 적다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving, saveError 같은 상태가 더 중요해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 localStorage 저장 코드는 대체로 짧게 끝납니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveTodosToStorage(nextTodos) {

localStorage.setItem(&quot;todos&quot;, JSON.stringify(nextTodos));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 서버 저장은 보통 이런 식의 기다림이 붙습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function saveTodosToServer(nextTodos) {

const response = await fetch(&quot;/api/todos&quot;, {
method: &quot;PUT&quot;,
headers: {
&quot;Content-Type&quot;: &quot;application/json&quot;
},
body: JSON.stringify(nextTodos)
});

if (!response.ok) {
throw new Error(&quot;save failed&quot;);
}

return await response.json();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 fetch 문법 자체가 아닙니다. 핵심은 &lt;b&gt;await&lt;/b&gt;가 붙는 순간, 저장 결과를 기다리는 구조가 된다는 점입니다. 즉, 이제는 저장 버튼을 누른 뒤 잠깐 대기하고, 성공하면 확정하고, 실패하면 다시 보여줄 준비를 해야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;비동기 저장은 &quot;코드가 어렵다&quot;보다 &quot;결과가 바로 확정되지 않는다&quot;는 감각이 더 중요하다. 그래서 기다리는 상태와 실패 상태가 함께 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저장 중, 성공, 실패 상태를 왜 따로 둬야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장이 들어오면 앱은 더 이상 &quot;저장 전&quot;과 &quot;저장 후&quot; 두 가지만 가지지 않습니다. 그 사이에 &lt;b&gt;&quot;지금 저장 중&quot;&lt;/b&gt;이라는 상태가 끼어들고, 경우에 따라 &lt;b&gt;&quot;실패함&quot;&lt;/b&gt;도 따로 봐야 합니다. 이걸 무시하면 버튼을 두 번 누르는 문제부터, 실패했는데도 화면이 닫혀 버리는 문제까지 한꺼번에 생기기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게는 아래 정도 상태만 있어도 충분한 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
formError: &quot;&quot;,
saveError: &quot;&quot;,
isSaving: false
};&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;서버 저장이 들어오면 따로 보기 쉬워지는 상태들&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;의미&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;화면에서는 어떻게 보일 수 있나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 요청을 보내고 기다리는 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼 비활성화, &quot;저장 중&quot; 문구 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 실패 안내&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;저장에 실패했습니다&quot; 같은 메시지 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingId, editDraft&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 편집 상태를 유지 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때 입력창을 닫지 않고 그대로 보여줄 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 실패 시 editDraft를 유지하는 것은 초보자에게 꽤 중요한 감각입니다. 네트워크가 잠깐 끊겼다고 해서 사용자가 방금 입력한 내용을 잃어버리면 체감이 크게 나빠집니다. 그래서 보수적인 첫 버전에서는 실패했을 때 &lt;b&gt;입력창은 그대로 두고, saveError만 보여주는 방식&lt;/b&gt;이 꽤 자연스럽습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장에서는 &quot;성공했는가&quot;만 보는 걸로는 부족하다. 지금 저장 중인지, 실패했을 때 입력값을 유지할지까지 같이 봐야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 안전한 첫 흐름: 검증 -&amp;gt; 요청 -&amp;gt; 성공 반영 -&amp;gt; 실패 유지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 가장 무난한 첫 흐름은 &quot;서버가 성공했다고 답할 때까지 실제 화면 확정을 조금 미루는 방식&quot;입니다. 흔히 더 빠르게 보이게 하려고 화면을 먼저 바꾸는 방식도 있지만, 그건 다음 단계에서 다루는 편이 좋습니다. 처음에는 &lt;b&gt;성공하면 확정하고, 실패하면 입력값을 유지한다&lt;/b&gt;는 흐름이 훨씬 설명하기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 수정 저장은 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function saveEditToServer() {

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
uiState.formError = &quot;내용을 확인해 주세요.&quot;;
renderTodos();
return;
}

if (uiState.isSaving) return;

uiState.isSaving = true;
uiState.formError = &quot;&quot;;
uiState.saveError = &quot;&quot;;

const nextTodos = todos.map(todo =&amp;gt;
todo.id === uiState.editingId
? { ...todo, text: result.value }
: todo
);

try {
const savedTodos = await saveTodosToServer(nextTodos);

todos = savedTodos;
uiState.editingId = null;
uiState.editDraft = &quot;&quot;;

} catch (error) {
uiState.saveError = &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
} finally {
uiState.isSaving = false;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름에서 중요한 건 성공과 실패가 다르게 처리된다는 점입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이 흐름이 초보자에게 특히 안전한 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇이 일어나나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos를 서버 응답값으로 확정하고 편집 모드를 닫는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력값은 유지하고 에러 문구만 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 좋은 이유는 단순합니다. 사용자가 입력한 내용을 잃지 않고, 화면이 실제 서버 상태와 어긋나는 시간도 줄일 수 있기 때문입니다. 특히 입문자에게는 &quot;성공 전에는 진짜 확정이 아니다&quot;라는 감각을 익히기에 좋습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 안전한 첫 원칙&lt;/b&gt;&lt;br /&gt;서버 저장이 처음이라면, 요청 성공 전까지는 실제 확정을 조금 미루고 실패했을 때는 입력 초안을 유지하는 편이 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;화면을 먼저 바꿀까, 응답을 기다릴까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 자연스럽게 다음 질문이 나옵니다. &quot;그럼 화면은 꼭 서버 응답을 기다렸다가 바꿔야 하나?&quot; 이 질문은 꽤 중요합니다. 실제 서비스에서는 화면을 먼저 바꾸고, 서버가 실패하면 다시 되돌리는 방식도 자주 씁니다. 이걸 보통 &quot;낙관적 업데이트&quot;라고 부릅니다. 쉽게 말하면 &quot;아마 성공할 거라고 보고 먼저 반영하는 방식&quot;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 입문자에게는 이 방식을 처음부터 기본값으로 두는 것을 그다지 권하지 않습니다. 이유는 실패했을 때 되돌리는 흐름까지 한 번에 설계해야 하기 때문입니다. 즉, 단순히 더 빨라 보이게 만드는 것이 아니라, &lt;b&gt;되돌리기&lt;/b&gt;까지 같이 책임져야 합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;화면을 먼저 바꾸는 방식과 기다리는 방식의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 후 확정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 이해하기 쉽고 실패 처리도 분명하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체감상 약간 느리게 느껴질 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 먼저 반영&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;즉각 반응해서 빠르게 느껴질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;용어 한 번만 정리&lt;/b&gt;&lt;br /&gt;&quot;낙관적 업데이트&quot;는 서버가 성공할 거라고 보고 화면을 먼저 바꾸는 방식이다. 대신 실패하면 원래 상태로 되돌리는 흐름까지 같이 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장으로 넘어오면 코드가 갑자기 낯설어지는 이유는 문법이 어렵기 때문만은 아닙니다. 이전에는 거의 보이지 않던 &quot;대기&quot;, &quot;실패&quot;, &quot;되돌리기&quot;가 한꺼번에 등장하기 때문입니다. 그래서 아래 같은 실수도 꽤 자주 반복됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;localStorage 하던 감각대로 todos를 먼저 확정해 버린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 화면과 서버 상태가 어긋난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비동기 저장 구조를 동기 저장처럼 다뤘다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 성공 후 확정 구조로 가는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 없이 요청을 바로 보낸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;연속 클릭으로 요청이 중복된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 상태 잠금이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비동기 저장에서는 isSaving이 훨씬 중요해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 보내자마자 편집창을 닫는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했을 때 입력값이 사라진 것처럼 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 전 화면 확정을 너무 빨리 했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 초안을 유지할지 먼저 정하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;catch는 있는데 finally가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 뒤에 버튼이 계속 잠긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마무리 상태 정리가 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잠금 해제와 렌더링은 마지막 공통 단계로 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답을 무시하고 nextTodos를 그대로 확정한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버가 정리해 준 값이나 최신 상태를 놓칠 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답값보다 클라이언트 계산값만 믿고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;API 설계에 따라 저장 후 응답값을 반영하는 편이 더 안전할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 수정, 삭제가 서로 다른 실패 처리 방식을 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 기능은 조용히 실패하고 어떤 기능은 경고를 보여준다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 저장 흐름이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능이 달라도 저장 파이프라인은 최대한 비슷하게 맞추는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 서버 저장으로 넘어가면서 더 중요해질 수 있습니다. 어떤 API는 저장된 결과를 그대로 다시 돌려줄 수 있고, 서버 쪽에서 id나 시간 값을 다시 붙여서 줄 수도 있습니다. 그래서 무조건 nextTodos만 믿기보다, &lt;b&gt;서버 응답을 무엇으로 줄지에 따라 최종 반영 기준을 다시 볼 필요&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;비동기 저장이 어색할 때는 fetch 문법보다 &quot;지금은 대기 중인가, 성공했는가, 실패했는가&quot;를 코드가 정말 분리해서 보고 있는지부터 확인하는 편이 훨씬 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장으로 넘어오는 순간부터 체크리스트 앱은 &quot;저장 버튼을 누르면 끝나는 구조&quot;에서 조금씩 벗어나기 시작합니다. 이제는 요청 전 검증, 저장 중 잠금, 성공 후 확정, 실패 시 유지 같은 흐름이 같이 필요해집니다. 처음에는 번거롭게 느껴질 수 있지만, 이 구분이 생겨야 중복 저장도 줄고 실패했을 때도 무엇을 유지하고 무엇을 닫아야 할지 설명하기 쉬워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, localStorage와 서버 저장은 같은 &quot;저장&quot;이지만 체감 구조는 다르다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 비동기 저장에서는 isSaving, saveError 같은 상태가 훨씬 중요해진다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 추가, 수정, 삭제도 결국은 &quot;다음 상태 계산 -&amp;gt; 요청 -&amp;gt; 성공 확정 -&amp;gt; 실패 유지 -&amp;gt; 마지막 정리&quot;라는 저장 파이프라인으로 묶어볼 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 저장 구조는 단순한 버튼 처리 수준을 넘어서, 성공과 실패까지 설명할 수 있는 단계로 올라옵니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;서버 응답을 기다리지 않고 화면을 먼저 바꾸는 낙관적 업데이트를 언제 써도 되고 언제는 조심해야 하는지&lt;/b&gt;, 그리고 실패했을 때 되돌리기를 어떻게 이해하면 좋은지를 본격적으로 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>API저장</category>
      <category>asyncawait</category>
      <category>fetch기초</category>
      <category>localStorage와서버</category>
      <category>비동기저장</category>
      <category>서버저장입문</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/41</guid>
      <comments>https://story86025.tistory.com/41#entry41comment</comments>
      <pubDate>Thu, 23 Apr 2026 17:00:23 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 20편: 할 일 앱에 실제 API 저장 붙이기</title>
      <link>https://story86025.tistory.com/58</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604201849.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pQNLI/dJMcabw76nZ/bDVQDhIE5D2oixblrBzM21/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pQNLI/dJMcabw76nZ/bDVQDhIE5D2oixblrBzM21/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pQNLI/dJMcabw76nZ/bDVQDhIE5D2oixblrBzM21/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpQNLI%2FdJMcabw76nZ%2FbDVQDhIE5D2oixblrBzM21%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604201849.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 왔다면 지금 할 일 앱은 꽤 많은 걸 할 수 있는 상태입니다. 추가, 수정, 삭제, 검색, 정렬, 우선순위, 마감일까지 들어갔으니 작은 프로젝트치고는 제법 손에 익는 도구가 되었을 겁니다. 그런데 이쯤에서 또 한 번 흐름이 크게 바뀌는 지점이 옵니다. 바로 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저 안에 key/value 형태로 데이터를 저장하는 방식입니다. 새로고침 뒤에도 값이 남지만 현재 브라우저와 주소 기준으로만 저장됩니다.&quot;&gt;localStorage&lt;/span&gt; 중심 앱에서, &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저가 서버와 데이터를 주고받기 위한 요청 규칙입니다.&quot;&gt;API&lt;/span&gt;를 통한 서버 저장 감각으로 넘어가기 시작하는 구간입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자 입장에서는 이 단계가 꽤 낯섭니다. 지금까지는 저장 버튼을 누르면 거의 바로 끝난다는 감각이 강했는데, 서버 저장이 들어오는 순간부터는 결과가 바로 확정되지 않을 수 있기 때문입니다. 잠깐 기다려야 하고, 실패할 수도 있고, 화면을 언제 바꿔야 하는지도 다시 생각해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 실제 서버를 완전히 배우는 게 목적이 아닙니다. 그보다 더 중요한 건, 지금까지 만든 할 일 앱 기준으로 &lt;b&gt;왜 localStorage 저장과 서버 저장이 체감상 다르게 느껴지는지&lt;/b&gt;를 실제로 손으로 붙여보는 데 있습니다. 그래서 이번 편은 현재 앱 전체를 한 번에 서버 버전으로 갈아엎지 않고, &lt;b&gt;서버 저장 감각이 가장 잘 드러나는 최소 흐름만 먼저 연결&lt;/b&gt;해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱이 서버에서 목록을 읽어온다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;GET 요청과 &lt;span data-ui=&quot;term&quot; data-note=&quot;화면이 서버 응답을 기다리는 동안 임시로 유지해야 하는 값입니다.&quot;&gt;로딩 상태&lt;/span&gt;가 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면을 그리기 전에 먼저 서버 응답을 기다리는 구조를 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 체크, 삭제가 localStorage 대신 API로 간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;POST, PATCH, DELETE 요청이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 후 성공과 실패를 나눠서 처리하는 구조를 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱은 단순한데 저장은 갑자기 조심스러워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isLoading, isSaving 같은 상태가 중요해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 잠금과 실패 메시지를 같이 보는 감각을 익힌다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;이번 단계의 핵심은 기능을 많이 옮기는 게 아니라, &lt;b&gt;서버에서 불러오고 저장하는 최소 흐름을 먼저 손으로 익히는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 앱 전체를 한 번에 서버 버전으로 갈아엎지 않는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 할 일 앱에는 이미 검색, 필터, 수정, 순서 변경, 우선순위, 마감일까지 들어가 있습니다. 이걸 한 번에 서버 저장 버전으로 옮기면 보기에는 멋있을 수 있습니다. 하지만 초보자 기준에서는 오히려 핵심이 흐려질 가능성이 큽니다. 무엇이 저장 구조 때문이고, 무엇이 기존 기능 때문인지 구분이 어려워지기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 편에서는 범위를 일부러 줄입니다. 지금까지 만든 앱의 감각은 유지하되, &lt;b&gt;서버 저장에서 반드시 봐야 하는 동작만 먼저 떼어보겠습니다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에서 목록을 불러옵니다.&lt;/li&gt;
&lt;li&gt;새 항목을 서버에 저장합니다.&lt;/li&gt;
&lt;li&gt;완료 체크를 서버에 반영합니다.&lt;/li&gt;
&lt;li&gt;삭제를 서버에 반영합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 편은 &quot;현재 앱 전체를 서버 앱으로 완성&quot;하는 글이 아니라, &lt;b&gt;서버 저장 감각을 이해하기 위한 최소 API 실습 버전&lt;/b&gt;을 만드는 글이라고 보는 편이 맞습니다. 이 정도만 해도 localStorage와 서버 저장의 차이는 훨씬 선명하게 느껴집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장을 처음 붙일 때는 앱 전체를 다 옮기기보다, &lt;b&gt;저장 감각이 가장 잘 드러나는 핵심 동작만 먼저 떼어보는 편이 훨씬 낫습니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 mock API로 간다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 단계에서는 실제 백엔드 서버를 처음부터 직접 만드는 쪽보다, &lt;span data-ui=&quot;term&quot; data-note=&quot;실제 서버 대신 로컬에서 REST 요청을 흉내 내는 테스트용 서버입니다.&quot;&gt;mock API&lt;/span&gt;를 먼저 붙여보는 편이 훨씬 낫습니다. 중요한 건 서버 프레임워크를 배우는 게 아니라, &lt;b&gt;브라우저 앱이 API와 데이터를 주고받는 감각&lt;/b&gt;을 익히는 것이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 가장 가볍게 쓸 수 있는 방식으로, &lt;span data-ui=&quot;term&quot; data-note=&quot;JSON 파일 하나로 REST API를 빠르게 띄울 수 있는 테스트용 도구입니다.&quot;&gt;JSON Server&lt;/span&gt;를 써보겠습니다. 이 도구는 데이터 파일 하나로 GET, POST, PATCH, DELETE 요청을 바로 흉내 낼 수 있어서 초보자 실습에 잘 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 구조는 아래처럼 두면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;todo-api/
  db.json
  public/
    index.html
    style.css
    script.js&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;db.json&lt;/b&gt;이 API 데이터 역할을 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;public&lt;/b&gt; 안의 HTML, CSS, JavaScript 파일은 브라우저에서 열 앱 역할을 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 지금 단계에서는 서버를 따로 구축하는 게 아니라, &lt;b&gt;정적 파일과 REST API를 한 번에 가볍게 띄우는 구조&lt;/b&gt;를 먼저 경험하는 것이 핵심입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;실제 서버를 처음 붙일 때는 기술을 다 배우는 것보다, &lt;b&gt;앱이 API 주소와 실제로 대화하는 감각을 먼저 붙여보는 것&lt;/b&gt;이 훨씬 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: db.json 만들고 서버 띄우기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 데이터 파일부터 만들어보겠습니다. 지금까지의 할 일 앱 흐름을 이어가기 위해 text, done, priority, dueDate를 같이 들고 가는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;{
  &quot;todos&quot;: [
    {
      &quot;id&quot;: &quot;1&quot;,
      &quot;text&quot;: &quot;API 저장 흐름 이해하기&quot;,
      &quot;done&quot;: false,
      &quot;priority&quot;: &quot;medium&quot;,
      &quot;dueDate&quot;: &quot;&quot;
    },
    {
      &quot;id&quot;: &quot;2&quot;,
      &quot;text&quot;: &quot;서버에서 목록 불러오기 테스트&quot;,
      &quot;done&quot;: true,
      &quot;priority&quot;: &quot;high&quot;,
      &quot;dueDate&quot;: &quot;2026-03-25&quot;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 아래 명령으로 서버를 시작할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;npx json-server db.json&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;/li&gt;
&lt;li&gt;API 주소가 생긴다&lt;/li&gt;
&lt;li&gt;public 안의 파일도 같이 열 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 실습은 아직 운영 서버를 다루는 게 아니라, &lt;b&gt;브라우저 앱이 실제 HTTP 요청을 보내고 응답을 받는 첫 감각&lt;/b&gt;을 잡는 단계입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;지금 단계에서는 서버가 복잡할 필요가 없습니다. &lt;b&gt;GET, POST, PATCH, DELETE가 실제로 오가는지만 느껴져도 충분합니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML은 크게 안 바꾸고 상태 문구만 분명하게 둔다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 다시 만드는 작업이 아닙니다. 입력창과 목록 구조는 최대한 단순하게 두고, 지금 서버와 어떤 상태로 대화 중인지 보여줄 자리만 분명하게 두는 편이 더 중요합니다. 그래서 이번 실습 버전은 상태 문구 하나를 더 명확하게 두는 쪽으로 가겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱 API 버전&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;API Save Practice&amp;lt;/p&amp;gt;
      &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
      &amp;lt;p class=&quot;description&quot;&amp;gt;
        localStorage 대신 실제 API를 통해 목록을 불러오고 저장하는 단계입니다.
      &amp;lt;/p&amp;gt;

      &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
        &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;input-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;todoInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 서버 저장 테스트하기&quot;
          &amp;gt;
          &amp;lt;select
            id=&quot;priorityInput&quot;
            class=&quot;priority-select&quot;
            aria-label=&quot;우선순위 선택&quot;
          &amp;gt;
            &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
            &amp;lt;option
              value=&quot;medium&quot;
              selected
            &amp;gt;
              보통
            &amp;lt;/option&amp;gt;
            &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
          &amp;lt;/select&amp;gt;
          &amp;lt;input
            id=&quot;dueDateInput&quot;
            type=&quot;date&quot;
            class=&quot;due-date-input&quot;
            aria-label=&quot;마감일 선택&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;submitButton&quot;
            type=&quot;submit&quot;
            class=&quot;primary-button&quot;
          &amp;gt;
            추가
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/form&amp;gt;

      &amp;lt;p
        id=&quot;statusText&quot;
        class=&quot;status-text&quot;
      &amp;gt;
        서버와 연결할 준비가 되었습니다.
      &amp;lt;/p&amp;gt;

      &amp;lt;p
        id=&quot;summaryText&quot;
        class=&quot;summary&quot;
      &amp;gt;
        전체 0개 &amp;middot; 남은 할 일 0개
      &amp;lt;/p&amp;gt;

      &amp;lt;ul
        id=&quot;todoList&quot;
        class=&quot;todo-list&quot;
      &amp;gt;&amp;lt;/ul&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;statusText&lt;/b&gt;입니다. 서버 저장이 들어오면 이제 화면은 단순히 &quot;목록이 있느냐 없느냐&quot;만 보여주면 부족합니다. 지금 불러오는 중인지, 저장 중인지, 실패했는지를 같이 보여줘야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게는 이게 꽤 중요합니다. localStorage 버전에서는 없던 문구가 왜 필요한지, 바로 여기서부터 저장 감각이 달라지기 시작하기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전에서는 입력창보다도, &lt;b&gt;지금 무슨 상태인지 보여주는 자리&lt;/b&gt;가 훨씬 중요해집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS는 로딩 상태와 저장 상태가 눈에 띄면 충분하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 화려한 디자인이 아닙니다. 입력 폼이 너무 답답해 보이지 않게 정리하고, 저장 중이나 로딩 중에 버튼이 잠긴 상태가 바로 보이게 만드는 것이 더 중요합니다. 서버 저장이 들어오면 &quot;상태 잠금&quot;도 UI의 중요한 일부가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0 0 24px;
  line-height: 1.7;
  color: #374151;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
input[type=&quot;date&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

.primary-button,
.delete-button {
  font: inherit;
  font-weight: 700;
  border-radius: 14px;
  cursor: pointer;
}

.primary-button {
  border: none;
  padding: 14px 18px;
  background: #111827;
  color: #ffffff;
}

.delete-button {
  padding: 12px 14px;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.primary-button:disabled,
.delete-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.status-text {
  margin: 18px 0 0;
  line-height: 1.6;
  color: #4b5563;
}

.summary {
  margin: 14px 0 0;
  line-height: 1.6;
  font-weight: 700;
  color: #111827;
}

.todo-list {
  list-style: none;
  margin: 24px 0 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-text.is-done {
  text-decoration: line-through;
  color: #9ca3af;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .delete-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 중요한 건 아주 단순합니다. 저장 중에는 실제로 버튼이 눌릴 것처럼 보이지 않아야 하고, 입력창도 동시에 여러 번 조작하는 느낌을 주지 않는 쪽이 더 낫습니다. 서버 저장으로 넘어오면 &quot;상태 잠금&quot;이 사용성에도 직접 영향을 주기 시작합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장 버전의 CSS 핵심은 꾸미기보다 &lt;b&gt;지금 잠깐 기다려야 하는 상태인지 눈에 보이게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 서버에서 불러오기, 추가하기, 체크 반영, 삭제하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 여기서부터 localStorage 감각과 API 저장 감각이 실제로 갈리기 시작합니다. 이번 예시에서는 최대한 단순하게 가겠습니다. 서버 응답이 성공했을 때만 화면을 최종 확정하고, 실패하면 에러 문구를 보여주는 가장 보수적인 첫 구조로 가겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;API_URL&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos 리소스와 통신할 주소 기준입니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱이 실제로 어디와 대화하는지 보이게 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;uiState&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isLoading, isSaving, error 같은 상태를 따로 들고 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 저장에서는 이 값들이 UI와 직접 연결되기 때문입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;loadTodosFromServer&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앱 시작 시 서버에서 목록을 불러옵니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 저장 감각의 출발점이 되는 함수입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;handleSubmit, handleToggle, handleDelete&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 완료 체크, 삭제를 각각 API 요청으로 보냅니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const API_URL = &quot;/todos&quot;;

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  dueDateInput: document.querySelector(&quot;#dueDateInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  statusText: document.querySelector(&quot;#statusText&quot;),
  summaryText: document.querySelector(&quot;#summaryText&quot;),
  list: document.querySelector(&quot;#todoList&quot;)
};

const uiState = {
  isLoading: false,
  isSaving: false,
  error: &quot;&quot;
};

let todos = [];

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || &quot;&quot;).trim();

  if (nextValue === &quot;&quot;) {
    return &quot;&quot;;
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue)
    ? nextValue
    : &quot;&quot;;
}

function normalizeTodo(todo) {
  return {
    id: String(todo.id),
    text: String(todo.text || &quot;&quot;),
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return &quot;&quot;;
  }

  return &quot;마감 &quot; + dueDate.replace(/-/g, &quot;.&quot;);
}

function setStatus(text) {
  elements.statusText.textContent = text;
}

function setFormDisabled(disabled) {
  elements.input.disabled = disabled;
  elements.priorityInput.disabled = disabled;
  elements.dueDateInput.disabled = disabled;
  elements.submitButton.disabled = disabled;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = todos.filter(function (todo) {
    return !todo.done;
  }).length;

  elements.summaryText.textContent =
    &quot;전체 &quot; +
    totalCount +
    &quot;개 &amp;middot; 남은 할 일 &quot; +
    remainingCount +
    &quot;개&quot;;
}

function renderStatus() {
  if (uiState.isLoading) {
    setStatus(&quot;서버에서 목록을 불러오는 중입니다.&quot;);
    return;
  }

  if (uiState.isSaving) {
    setStatus(&quot;서버에 저장 중입니다.&quot;);
    return;
  }

  if (uiState.error) {
    setStatus(uiState.error);
    return;
  }

  if (todos.length === 0) {
    setStatus(&quot;아직 등록된 할 일이 없습니다.&quot;);
    return;
  }

  setStatus(&quot;서버와 연결된 목록을 보고 있습니다.&quot;);
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;due-date-badge&quot;;
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  if (todo.done) {
    text.classList.add(&quot;is-done&quot;);
  }

  return text;
}

function createTodoContent(todo) {
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);
  const dueDateBadge = createDueDateBadge(todo);

  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;

  meta.append(createPriorityBadge(todo));

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  return content;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const checkbox = document.createElement(&quot;input&quot;);
  const deleteButton = document.createElement(&quot;button&quot;);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = uiState.isSaving || uiState.isLoading;

  checkbox.addEventListener(&quot;change&quot;, function () {
    handleToggle(todo, checkbox.checked);
  });

  deleteButton.type = &quot;button&quot;;
  deleteButton.className = &quot;delete-button&quot;;
  deleteButton.textContent = &quot;삭제&quot;;
  deleteButton.disabled = uiState.isSaving || uiState.isLoading;

  deleteButton.addEventListener(&quot;click&quot;, function () {
    handleDelete(todo.id);
  });

  left.append(
    checkbox,
    createTodoContent(todo)
  );

  actions.append(deleteButton);
  item.append(left, actions);

  return item;
}

function renderTodos() {
  elements.list.innerHTML = &quot;&quot;;

  todos.forEach(function (todo) {
    elements.list.appendChild(
      createTodoItem(todo)
    );
  });

  updateSummary();
  renderStatus();
}

async function loadTodosFromServer() {
  uiState.isLoading = true;
  uiState.error = &quot;&quot;;
  renderStatus();

  try {
    const response = await fetch(API_URL);

    if (!response.ok) {
      throw new Error(&quot;목록을 불러오지 못했습니다.&quot;);
    }

    const data = await response.json();

    todos = data.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    uiState.error =
      &quot;목록을 불러오지 못했습니다. 서버 상태를 확인해 주세요.&quot;;
  } finally {
    uiState.isLoading = false;
    renderTodos();
  }
}

async function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    uiState.error = &quot;할 일을 먼저 입력해보세요.&quot;;
    renderStatus();
    elements.input.focus();
    return;
  }

  uiState.isSaving = true;
  uiState.error = &quot;&quot;;
  setFormDisabled(true);
  renderStatus();

  try {
    const response = await fetch(API_URL, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        text: text,
        done: false,
        priority: normalizePriority(priority),
        dueDate: normalizeDueDate(dueDate)
      })
    });

    if (!response.ok) {
      throw new Error(&quot;저장 실패&quot;);
    }

    const createdTodo = normalizeTodo(
      await response.json()
    );

    todos = [createdTodo].concat(todos);

    elements.form.reset();
    elements.priorityInput.value =
      PRIORITY_TYPES.MEDIUM;

    uiState.error = &quot;&quot;;
    setStatus(&quot;새 할 일을 서버에 저장했습니다.&quot;);
  } catch (error) {
    uiState.error =
      &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    uiState.isSaving = false;
    setFormDisabled(false);
    renderTodos();
    elements.input.focus();
  }
}

async function handleToggle(todo, nextDone) {
  if (uiState.isSaving || uiState.isLoading) {
    return;
  }

  uiState.isSaving = true;
  uiState.error = &quot;&quot;;
  renderStatus();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todo.id, {
      method: &quot;PATCH&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        done: nextDone
      })
    });

    if (!response.ok) {
      throw new Error(&quot;상태 저장 실패&quot;);
    }

    todos = todos.map(function (currentTodo) {
      if (currentTodo.id === todo.id) {
        return {
          ...currentTodo,
          done: nextDone
        };
      }

      return currentTodo;
    });

    setStatus(&quot;완료 상태를 서버에 반영했습니다.&quot;);
  } catch (error) {
    uiState.error =
      &quot;완료 상태 저장에 실패했습니다.&quot;;
  } finally {
    uiState.isSaving = false;
    renderTodos();
  }
}

async function handleDelete(todoId) {
  if (uiState.isSaving || uiState.isLoading) {
    return;
  }

  uiState.isSaving = true;
  uiState.error = &quot;&quot;;
  renderStatus();

  try {
    const response = await fetch(API_URL + &quot;/&quot; + todoId, {
      method: &quot;DELETE&quot;
    });

    if (!response.ok) {
      throw new Error(&quot;삭제 실패&quot;);
    }

    todos = todos.filter(function (todo) {
      return todo.id !== String(todoId);
    });

    setStatus(&quot;항목을 서버에서 삭제했습니다.&quot;);
  } catch (error) {
    uiState.error =
      &quot;삭제에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    uiState.isSaving = false;
    renderTodos();
  }
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );
}

bindEvents();
loadTodosFromServer();&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;uiState&lt;/b&gt;입니다. localStorage 버전보다 로딩과 저장 중 상태가 훨씬 중요해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;loadTodosFromServer&lt;/b&gt;입니다. 화면이 먼저 열리는 게 아니라, 서버 응답을 받아와서 목록을 채우는 흐름으로 바뀝니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;handleSubmit&lt;/b&gt;, &lt;b&gt;handleToggle&lt;/b&gt;, &lt;b&gt;handleDelete&lt;/b&gt;입니다. 이제 저장 동작마다 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저에서 네트워크 요청을 보내고 응답을 받는 내장 함수입니다.&quot;&gt;fetch&lt;/span&gt; 요청과 성공/실패 분기가 들어갑니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;normalizeTodo&lt;/b&gt;입니다. 서버에서 온 값도 지금 앱이 기대하는 구조로 한 번 맞춰놓는 습관이 중요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서는 일부러 가장 보수적인 흐름을 택했습니다. 완료 체크도, 삭제도, 추가도 먼저 화면을 바꾸지 않고 서버 응답이 성공했을 때만 최종 반영합니다. 이건 느려 보여서가 아니라, 첫 API 버전에서는 이쪽이 훨씬 설명하기 쉽기 때문입니다. 지금은 빠르게 보이는 것보다 &lt;b&gt;언제 확정되는지 분명한 구조&lt;/b&gt;가 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 건 앱 전체를 다 옮기지 않았다는 점입니다. 지금은 검색, 정렬, 수정까지 한 번에 다 서버 저장 버전으로 갈아엎지 않았습니다. 그 대신 목록 불러오기와 저장 흐름을 먼저 손으로 붙여보는 데 집중했습니다. 이쪽이 초보자에게 훨씬 더 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 기능이 많아진 게 아니라, &lt;b&gt;저장 대상이 브라우저 안에서 서버로 바뀌면서 로딩, 저장 중, 실패 상태까지 같이 봐야 하는 구조로 바뀐 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직접 눌러보며 테스트하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 코드보다도 손으로 눌러보는 확인이 더 중요합니다. localStorage에서는 잘 되던 감각이 서버 버전에서는 어디서 달라지는지 직접 봐야 하기 때문입니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이지를 열자마자 서버에서 목록을 불러오는가&lt;/li&gt;
&lt;li&gt;새 항목을 추가했을 때 새로고침 뒤에도 남아 있는가&lt;/li&gt;
&lt;li&gt;체크 박스를 눌렀을 때 완료 상태가 서버 기준으로 유지되는가&lt;/li&gt;
&lt;li&gt;삭제 버튼을 눌렀을 때 항목이 서버에서 실제로 지워지는가&lt;/li&gt;
&lt;li&gt;저장 중에는 같은 버튼을 여러 번 누르기 어렵게 잠기는가&lt;/li&gt;
&lt;li&gt;서버를 끄고 시도했을 때 실패 메시지가 분명하게 보이는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 동작이 안정적이라면 이번 실습은 성공이라고 봐도 됩니다. 여기서 중요한 건 &quot;API가 붙었다&quot;는 사실보다, &lt;b&gt;저장과 화면 반영이 같은 타이밍에 끝나지 않는다&lt;/b&gt;는 걸 실제로 확인하는 데 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 단계의 테스트는 결과보다도 &lt;b&gt;로딩, 저장 중, 실패가 실제로 어떤 느낌인지 직접 눌러보는 데&lt;/b&gt; 의미가 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계도 Codex에게 맡길 수 있습니다. 다만 지금은 기능 하나를 더 넣는 게 아니라 저장 감각 자체가 달라지는 구간이기 때문에, 범위를 훨씬 조심스럽게 잘라야 합니다. &quot;API 저장 붙여줘&quot;라고만 던지면 AI가 검색, 정렬, 수정까지 한꺼번에 다시 엮어버릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 localStorage 기반 할 일 앱이 있어.
이번에는 앱 전체를 한 번에 옮기지 말고,
목록 불러오기, 새 항목 추가, 완료 체크, 삭제까지만
실제 API 저장 버전으로 바꾸고 싶어.
정적 파일은 그대로 두고 fetch 기반으로 연결할 거야.
먼저 어떤 파일과 함수가 가장 크게 바뀌는지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 localStorage 저장 흐름을 걷어내고,
GET으로 목록 불러오기,
POST로 추가,
PATCH로 완료 체크,
DELETE로 삭제하는 최소 API 버전으로 바꿔줘.
isLoading, isSaving, error 상태를 따로 두고
성공 후에만 목록을 다시 반영하는 보수적인 구조로 가고 싶어.
먼저 어떤 상태와 함수가 새로 필요한지 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경 준비 쪽만 묻고 싶다면 이렇게 요청할 수도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;x86asm&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;가장 가볍게 mock API를 띄우고 싶어.
db.json 하나와 public 폴더 구조로
정적 파일과 REST API를 같이 여는 방식 기준으로,
폴더 구조와 실행 명령만 아주 간단하게 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 범위를 크게 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 어려운 문장보다, &lt;b&gt;지금은 어디까지만 연결할지&lt;/b&gt;를 같이 적는 습관에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 이 단계를 맡길 때는 &quot;API 저장 붙여줘&quot;보다 &lt;b&gt;목록 불러오기, 추가, 체크, 삭제까지만&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 만든 건 거창한 서버 앱이 아닙니다. 여전히 작은 할 일 앱이고, 로컬에서 가볍게 띄운 &lt;span data-ui=&quot;term&quot; data-note=&quot;실제 서버 대신 로컬에서 REST 요청을 흉내 내는 테스트용 서버입니다.&quot;&gt;mock API&lt;/span&gt;와 연결했을 뿐입니다. 그런데도 체감은 꽤 달라집니다. 이제는 저장이 브라우저 안에서 바로 끝나는 게 아니라, 요청을 보내고 기다린 뒤 성공하면 확정되는 흐름으로 바뀌었기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 단계는 localStorage 시즌과 서버 저장 시즌 사이를 잇는 첫 구간이라고 볼 수 있습니다. 여기서 중요한 건 기능이 많아지는 게 아니라, 저장 감각이 달라진다는 점입니다. 불러오기, 저장 중 잠금, 실패 문구, 성공 후 반영 같은 흐름이 눈에 들어오기 시작하면 이후 구조도 훨씬 덜 낯설게 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 편의 핵심은 서버를 다 배웠다는 데 있지 않습니다. 오히려 &lt;b&gt;같은 저장 버튼인데 왜 로딩과 실패 상태가 같이 필요해지는지&lt;/b&gt;를 손으로 확인했다는 데 더 가깝습니다. 그 감각이 붙으면, 다음 단계의 서버 저장도 훨씬 덜 막막해집니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>API연동</category>
      <category>codex</category>
      <category>VSCode</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>서버저장</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/58</guid>
      <comments>https://story86025.tistory.com/58#entry58comment</comments>
      <pubDate>Wed, 22 Apr 2026 17:00:45 +0900</pubDate>
    </item>
    <item>
      <title>[번외-0] 매번 처음부터 쓰지 마세요: 바이브 코딩에 바로 쓰는 프롬프트 템플릿 모음</title>
      <link>https://story86025.tistory.com/61</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기초편 6편까지 따라왔다면, 이제 감은 어느 정도 잡혔을 겁니다. AI에게 역할을 주는 법, 요청을 구조화하는 법, 작업을 잘게 나누는 법, 현재 상태를 넘기는 법, 검토와 디버깅 요청까지 한 바퀴 돌았기 때문입니다. 그런데 막상 실제로 써보려고 하면 또 다른 문제가 생깁니다. &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;p data-ke-size=&quot;size16&quot;&gt;이번 확장편 1편에서는 바로 그 부분을 정리해보겠습니다. 핵심은 &amp;ldquo;마법 프롬프트&amp;rdquo;를 소개하는 데 있지 않습니다. 오히려 &lt;b&gt;지금까지 배운 원리를 실제 작업에서 바로 꺼내 쓸 수 있는 형태로 묶는 것&lt;/b&gt;에 더 가깝습니다. 처음 만들 때, 디버깅할 때, 코드 검토를 받을 때, 설명을 다시 듣고 싶을 때처럼 자주 만나는 장면별로 정리해두면 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;템플릿이 필요한 이유&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;핵심 포인트&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음 만들 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목표와 범위를 빠르게 정리할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇을 만들지, 어디까지 할지, 무엇은 아직 안 할지를 같이 적는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;막혔을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제를 재현 가능한 질문으로 바꿀 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 상태, 재현 순서, 기대 결과, 실제 결과를 빠뜨리지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과를 다듬을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 재작성 없이 필요한 수정만 요청하기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 맞고 무엇이 어긋났는지 먼저 짚고, 수정 범위를 좁힌다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;좋은 템플릿은 답을 대신 만들어주는 문장이 아니라, 내가 자주 놓치는 정보를 빠뜨리지 않게 해주는 작업용 틀에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 템플릿이 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 AI와 같이 작업할 때 가장 자주 겪는 피로는 생각보다 단순합니다. &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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음 만들 때는 목표와 범위를 빠르게 정리하는 템플릿이 도움이 됩니다.&lt;/li&gt;
&lt;li&gt;막혔을 때는 재현 순서와 에러 정보를 놓치지 않는 템플릿이 필요합니다.&lt;/li&gt;
&lt;li&gt;결과를 다듬을 때는 무엇이 맞고 무엇이 어긋났는지 정리하는 틀이 유용합니다.&lt;/li&gt;
&lt;li&gt;이해가 안 될 때는 코드 설명을 다시 받는 전용 템플릿이 훨씬 효과적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 템플릿의 장점은 글을 멋지게 만드는 데 있지 않습니다. &lt;b&gt;내가 지금 무엇을 요청해야 하는지 빠르게 정리하고, AI가 넓게 추정할 부분을 줄여주는 데&lt;/b&gt; 더 큰 의미가 있습니다. 초보자일수록 이 차이가 큽니다. 한두 줄 덜 쓰는 것보다, 중요한 정보를 한 번 덜 놓치는 편이 훨씬 도움이 되기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해하기 쉬운 지점&lt;/b&gt;&lt;br /&gt;템플릿은 문장을 기계적으로 복붙하라는 뜻이 아닙니다. 기본 틀을 들고 있다가, 지금 상황에 맞는 정보만 바꿔 넣으라는 뜻에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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; 쓰는 디버깅 템플릿&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결과를 점검하고 다시 고칠 때&lt;/b&gt; 쓰는 검토&amp;middot;수정 요청 템플릿&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설명이 부족해서 다시 배우고 싶을 때&lt;/b&gt; 쓰는 학습용 템플릿&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 깊게 다루지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도구별 전용 문법 차이&lt;/li&gt;
&lt;li&gt;팀 문서나 저장소에 넣는 고정 지침&lt;/li&gt;
&lt;li&gt;긴 대화에서 맥락을 유지하는 운영 방식&lt;/li&gt;
&lt;li&gt;반복 작업용 개인 지침 정리법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 내용은 다음 확장편에서 따로 다루는 편이 더 자연스럽습니다. 이번 편은 어디까지나 &lt;b&gt;지금 당장 복사해서 손봐서 쓸 수 있는 템플릿 모음&lt;/b&gt;에 집중하는 편이 좋습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음 만들 때 쓰는 기본 템플릿&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 필요한 템플릿은 역시 처음 무언가를 만들 때 쓰는 기본형입니다. 초보자가 AI에게 가장 많이 하는 요청도 보통 이 범주에 들어갑니다. 문제는 많은 경우 &lt;b&gt;&amp;ldquo;앱 하나 만들어줘&amp;rdquo;&lt;/b&gt;에서 출발한다는 점입니다. 이렇게 물으면 결과가 넓어지기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 기본 템플릿에는 아래 네 가지가 꼭 들어가면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무엇을 만들지&lt;/li&gt;
&lt;li&gt;지금 내 수준과 상황이 어떤지&lt;/li&gt;
&lt;li&gt;이번 단계에서는 어디까지 할지&lt;/li&gt;
&lt;li&gt;어떤 순서로 답을 받고 싶은지&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;markdown&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;너는 코딩 초보에게 설명하는 웹 개발 튜터다.

[목표]
지금 만들고 싶은 것은
____________________________ 이다.

[맥락]
나는 ____________________________ 수준이고,
현재 ____________________________ 상태다.

[제약]
이번 단계에서는

까지만 하고 싶다.

아직 아래 내용은 넣지 말아줘.

[출력 형식]
답변은 아래 순서로 정리해줘.

무엇을 만들지 짧게 요약

바뀌는 파일 구조

전체 코드 또는 필요한 코드

핵심 설명

내가 직접 확인할 체크리스트&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 할 일 앱 첫 화면을 만들고 싶다면 이렇게 바꿔 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;너는 코딩 초보에게 설명하는 웹 개발 튜터다.

[목표]
HTML, CSS, JavaScript로
간단한 할 일 앱의 첫 화면을 만들고 싶다.

[맥락]
나는 HTML, CSS 기초만 알고 있고,
JavaScript는 기본 문법 정도만 안다.

[제약]
이번 단계에서는

입력창

추가 버튼

목록 영역
까지만 보이면 된다.

아직 아래 내용은 넣지 말아줘.

localStorage 저장

검색 기능

필터 기능

[출력 형식]
답변은 아래 순서로 정리해줘.

이번 단계 목표

필요한 파일

전체 코드

중요한 부분 설명

내가 직접 확인할 항목&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 템플릿의 좋은 점은 &amp;ldquo;처음 만들기&amp;rdquo; 상황에서 가장 자주 빠지는 정보를 한 번에 잡아준다는 점입니다. 특히 &lt;b&gt;이번 단계에서 아직 하지 않을 것&lt;/b&gt;을 같이 적는 부분이 중요합니다. 초보자는 대개 &amp;ldquo;무엇을 넣을까&amp;rdquo;만 생각하는데, 실제로는 &amp;ldquo;무엇을 이번에는 빼둘까&amp;rdquo;가 결과를 훨씬 안정적으로 만듭니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;처음 만들 때 쓰는 템플릿은 결과를 풍성하게 만드는 문장이 아니라, 이번 단계의 범위를 작게 유지하게 해주는 문장에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;막혔을 때 쓰는 디버깅 템플릿&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째로 자주 쓰게 되는 템플릿은 디버깅용입니다. 기능 요청과 디버깅 요청은 다르게 써야 한다는 이야기를 앞에서 이미 했습니다. 실제로 막혔을 때는 &amp;ldquo;왜 안 되지?&amp;rdquo;라는 감정이 먼저 올라오지만, AI에게는 그 감정 자체보다 &lt;b&gt;현재 상태와 재현 순서&lt;/b&gt;가 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 디버깅 템플릿에는 아래 항목이 들어가야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 어디까지는 정상 동작하는지&lt;/li&gt;
&lt;li&gt;문제가 무엇인지 한 줄 요약&lt;/li&gt;
&lt;li&gt;문제가 다시 나타나는 순서&lt;/li&gt;
&lt;li&gt;기대한 결과와 실제 결과&lt;/li&gt;
&lt;li&gt;에러 메시지와 관련 코드&lt;/li&gt;
&lt;li&gt;원하는 답변 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;markdown&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업]

____________________________ 기능까지는 정상 동작

지금은 ____________________________ 작업 중

[현재 문제]

[재현 순서]

[기대한 결과]

[실제 결과]

[에러 메시지]

또는

콘솔 에러 없음

[관련 코드]

관련 파일:

관련 함수:

최근에 바꾼 부분:

[원하는 답변]

가장 가능성 큰 원인 몇 가지

먼저 확인할 순서

전체 재작성 없이 필요한 수정 포인트만 제안&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능에서 문제가 생겼다고 가정하면 이런 식으로 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업]

추가, 삭제, 완료 체크, 필터까지는 정상 동작

지금은 검색 기능을 붙이는 중

[현재 문제]

검색어를 입력해도 목록이 줄어들지 않음

[재현 순서]

검색창에 &quot;회의&quot;를 입력한다

목록 변화를 본다

[기대한 결과]

&quot;회의&quot;가 들어간 항목만 보여야 함

[실제 결과]

전체 목록이 그대로 보임

[에러 메시지]

콘솔 에러 없음

[관련 코드]

관련 파일: script.js

관련 함수: handleSearchInput, getFilteredTodos, renderTodos

최근에 바꾼 부분: searchInput 요소와 bindSearchEvents 추가

[원하는 답변]

가장 가능성 큰 원인 3개

확인 순서

필요한 수정 코드만 제안&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;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 검토와 수정 요청 템플릿&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째 템플릿은 결과를 받은 뒤 다시 다듬을 때 쓰는 템플릿입니다. 이건 초보자에게 생각보다 중요합니다. 많은 사람이 AI가 코드를 주면 바로 실행하고 끝내려 하지만, 실제로는 &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;b&gt;전체 재작성 대신 수정 범위를 좁히는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 결과에서 맞는 부분]

[어긋난 부분]

[이번에 다시 요청하는 목표]

[유지할 것]

현재 구조는 최대한 유지

기존 기능은 깨지지 않게 유지

전체 리팩터링은 하지 않기

[원하는 답변]

무엇이 어긋났는지 요약

어떤 파일이나 함수만 고치면 되는지

필요한 수정 코드만 제시

내가 확인할 테스트 항목 정리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 기능이 거의 맞는데, 완료 필터와 연동이 어긋났다면 이렇게 바꿔 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 결과에서 맞는 부분]

검색 입력창이 잘 들어감

입력할 때 목록이 줄어드는 기능도 동작함

검색 지우기 버튼도 있음

[어긋난 부분]

완료 보기 상태에서는 검색 결과가 맞지 않음

검색 기능과 관계없는 함수 이름 변경이 너무 많음

[이번에 다시 요청하는 목표]

완료 필터와 검색이 함께 적용되도록 수정

현재 구조는 최대한 유지

[유지할 것]

전체 파일 구조는 그대로 둘 것

기존 추가, 삭제, 완료 체크 기능은 유지할 것

전체 리팩터링은 하지 말 것

[원하는 답변]

왜 완료 보기에서만 어긋나는지 설명

관련 함수만 추려서 수정

수정 후 테스트 순서까지 정리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 템플릿은 특히 &amp;ldquo;이상한데 다시 만들어줘&amp;rdquo; 같은 넓은 재요청을 줄여줍니다. AI도 이미 맞은 부분은 유지하고, 지금 어긋난 부분만 다시 다루게 되기 때문에 결과가 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 요청 템플릿은 불만을 말하는 문장이 아니라, 현재 결과를 기준으로 어디까지만 다시 손볼지 정리하는 문장에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;이 템플릿의 핵심은 &amp;ldquo;초보자 기준으로 다시 설명해달라&amp;rdquo;는 말만 하는 것이 아니라, &lt;b&gt;무엇이 특히 헷갈리는지&lt;/b&gt;를 같이 적는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;방금 제안한 코드에서 아래 부분이 아직 헷갈린다.

[헷갈리는 지점]

[설명 요청]
초보자 기준으로 아래 순서로 다시 설명해줘.

각 변수나 상태값이 무엇을 뜻하는지

어떤 순서로 코드가 실행되는지

왜 이런 구조가 필요한지

내가 직접 바꿔보면 좋은 작은 연습 2개

[제약]

어려운 용어는 바로 쉬운 말로 풀어줘

전체 이론 설명보다 지금 코드와 연결해서 설명해줘

너무 길게 추상적으로 설명하지 말고, 현재 예제로 설명해줘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 기능 코드가 돌아가는데 &lt;b&gt;currentSearchQuery&lt;/b&gt;와 &lt;b&gt;getFilteredTodos()&lt;/b&gt; 흐름이 잘 안 보인다면 이렇게 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;방금 제안한 검색 기능 코드에서 아래 부분이 아직 헷갈린다.

[헷갈리는 지점]

currentSearchQuery를 왜 따로 두는지

getFilteredTodos가 필터와 검색을 어떤 순서로 적용하는지

[설명 요청]
초보자 기준으로 아래 순서로 다시 설명해줘.

currentSearchQuery가 무엇을 저장하는지

검색창에 입력하면 어떤 함수가 먼저 움직이는지

getFilteredTodos가 목록을 어떻게 다시 고르는지

내가 직접 바꿔보면 좋은 연습 2개

[제약]

어려운 용어는 바로 풀어서 설명

현재 할 일 앱 예제로만 설명

코드 전체를 다시 쓰지는 말 것&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 템플릿은 &amp;ldquo;코드를 받았는데 아직 내 것이 되지 않은 상태&amp;rdquo;를 줄이는 데 도움이 됩니다. 입문자에게는 아주 중요합니다. 결국 기초를 지나서 응용으로 가려면, 돌아가는 코드를 그냥 복붙하는 단계에서 조금씩 빠져나와야 하기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;템플릿을 쓸 때 자주 하는 실수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 문제가 되나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 바꾸면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈칸만 채우고 현재 상황을 안 넣는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;템플릿은 있는데 실제 맥락이 부족해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최근에 바꾼 것, 정상 동작 범위, 현재 문제를 꼭 같이 적는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;템플릿이 너무 길어져서 핵심이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정작 AI가 봐야 할 문제 지점이 묻힐 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제와 직접 연결된 정보만 남기고 줄인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 상황에 같은 템플릿을 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능 요청과 디버깅 요청이 같은 문장으로 섞인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상황별로 기본 템플릿을 나눠서 쓴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;템플릿을 절대 규칙처럼 생각한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 문제에 맞게 줄이거나 바꾸는 유연함이 사라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금 상황이 무엇인지 먼저 고른다.&lt;/li&gt;
&lt;li&gt;그 상황에 맞는 템플릿을 꺼낸다.&lt;/li&gt;
&lt;li&gt;현재 상태와 관련 정보만 채워 넣는다.&lt;/li&gt;
&lt;li&gt;불필요한 항목은 과감히 줄인다.&lt;/li&gt;
&lt;li&gt;AI가 답한 뒤에는 다음 단계용 템플릿으로 넘어간다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;짧은 정리&lt;/b&gt;&lt;br /&gt;템플릿을 잘 쓴다는 것은 문장을 많이 외우는 게 아니라, 지금 상황이 기능 요청인지, 디버깅인지, 수정 요청인지 먼저 구분할 줄 아는 데서 시작합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;이 더 중요합니다. 그래야 AI와의 대화가 점점 덜 즉흥적으로 바뀌고, 같은 문제를 다시 만났을 때도 훨씬 빨리 정리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/61</guid>
      <comments>https://story86025.tistory.com/61#entry61comment</comments>
      <pubDate>Tue, 21 Apr 2026 18:00:52 +0900</pubDate>
    </item>
    <item>
      <title>21. 저장 버튼을 눌렀는데 왜 두 번 들어갈까: 바이브코딩 저장 흐름 나누는 법</title>
      <link>https://story86025.tistory.com/39</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 빈값, 공백, 중복 같은 입력 검증을 어디서 처리해야 하는지 다뤘습니다. 여기까지 오면 바로 다음 단계가 보입니다. 검증이 끝난 뒤, 실제로 &lt;b&gt;&quot;저장&quot;&lt;/b&gt;은 어떤 순서로 일어나야 하는가 하는 문제입니다. 초보자 입장에서는 보통 저장 버튼을 누르면 값만 바뀌면 되는 것처럼 느껴집니다. 하지만 실제로는 그 안에 생각보다 많은 단계가 들어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이게 중요하냐면, 저장 흐름이 흐리면 이상한 문제들이 하나씩 나오기 때문입니다. 저장 버튼을 두 번 눌렀더니 같은 항목이 두 번 들어가거나, 목록 화면은 이미 바뀌었는데 새로고침하면 이전 상태로 돌아오거나, 수정 입력창은 닫혔는데 실제 저장은 실패한 상태가 되는 식입니다. 기능 하나하나는 맞아 보여도 &lt;b&gt;저장 순서&lt;/b&gt;가 분명하지 않으면 앱 전체가 어색해지기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 저장을 하나의 동작으로만 보면 금방 꼬이는지, 저장을 어떤 단계로 나눠 보면 좋은지, 그리고 localStorage를 쓰는 작은 앱에서도 왜 저장 흐름을 분리해 보는 습관이 중요한지를 차근차근 정리해보겠습니다. 나중에 서버 저장으로 넘어갈 때도 결국 이 감각이 그대로 이어집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼을 빠르게 두 번 누르면 중복 저장된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 상태를 잠그지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 같은 저장 중 상태가 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 저장된 것처럼 보이는데 다시 열면 이전 상태다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 변경과 실제 저장 단계를 섞어 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 반영과 저장 단계를 나눠서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창은 닫혔는데 저장 실패가 뒤늦게 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 처리를 생각하지 않고 화면부터 먼저 닫아 버렸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 어떤 상태를 유지할지 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 수정, 삭제가 각자 제멋대로 저장된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 흐름을 기능마다 따로 짰다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 저장 파이프라인을 먼저 세운다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;저장은 버튼 하나가 아니라 &quot;검증 -&amp;gt; 잠금 -&amp;gt; 실제 저장 -&amp;gt; 화면 확정 -&amp;gt; 상태 정리&quot; 같은 여러 단계를 가진 흐름으로 보는 편이 훨씬 안전하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 저장은 버튼 하나로 끝나지 않을까&lt;/li&gt;
&lt;li&gt;저장은 어떤 순서로 나뉘어야 할까&lt;/li&gt;
&lt;li&gt;localStorage 저장도 흐름을 나눠서 보는 편이 좋은 이유&lt;/li&gt;
&lt;li&gt;저장 중 상태와 에러 상태는 어떻게 들고 갈까&lt;/li&gt;
&lt;li&gt;추가, 수정, 삭제는 결국 같은 저장 파이프라인으로 묶을 수 있다&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 저장은 버튼 하나로 끝나지 않을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 저장은 보통 &quot;값을 바꾸고 끝&quot;처럼 보입니다. 예를 들어 수정 저장이라면 text를 새 값으로 바꾸고, 추가 저장이라면 배열에 새 항목을 넣으면 되는 것처럼 느껴집니다. 그런데 실제 앱에서는 그 전에 해야 할 일과 그 뒤에 해야 할 일이 적지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 수정 저장만 생각해도 최소한 이런 질문이 붙습니다. 입력값은 유효한가? 지금 저장 중은 아닌가? 실제 데이터는 어느 시점에 바꿀 것인가? 저장이 성공했을 때 편집창은 언제 닫을 것인가? 실패하면 입력값은 유지할 것인가? 이 질문들에 답이 없으면 저장 버튼은 눌리는데 결과가 매번 들쭉날쭉해지기 쉽습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;겉으로는 저장 버튼 하나지만 실제로는 여러 단계가 붙는다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈값, 공백, 너무 긴 값 같은 문제를 실제 데이터에 넣지 않기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잠금&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 중복 클릭이나 다른 충돌 동작을 막기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;브라우저 저장소나 서버에 현재 상태를 반영하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 확정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 모드 종료, 목록 갱신, 버튼 비활성화 해제 등을 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 처리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 무엇을 유지하고 무엇을 되돌릴지 분명히 하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 저장은 &quot;데이터 한 줄 바꾸기&quot;보다 &lt;b&gt;상태 전환&lt;/b&gt;에 더 가깝습니다. 저장 전에는 편집 중 상태였고, 저장 후에는 실제 데이터가 바뀐 일반 상태로 돌아가야 합니다. 이 흐름이 분명해야 버튼이 두 번 눌려도 버티고, 실패해도 어디까지 되돌릴지 설명할 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;저장은 &quot;값 변경&quot; 자체보다, 입력값 검증부터 상태 정리까지 이어지는 한 묶음의 흐름이라고 보는 편이 훨씬 정확하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;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;실제 저장소에 반영한다.&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;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름을 코드로 보면 대략 이런 느낌입니다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveEdit() {

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
uiState.formError = &quot;내용을 확인해 주세요.&quot;;
renderTodos();
return;
}

if (uiState.isSaving) return;

uiState.isSaving = true;
uiState.formError = &quot;&quot;;

try {
const nextTodos = todos.map(todo =&amp;gt;
todo.id === uiState.editingId
? { ...todo, text: result.value }
: todo
);

saveTodosToStorage(nextTodos);

todos = nextTodos;
uiState.editingId = null;
uiState.editDraft = &quot;&quot;;

} catch (error) {
uiState.formError = &quot;저장 중 문제가 생겼습니다.&quot;;
} finally {
uiState.isSaving = false;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 초보자가 특히 봐야 할 부분은 두 군데입니다. 첫째, &lt;b&gt;nextTodos를 먼저 계산한 뒤&lt;/b&gt; 저장을 시도한다는 점입니다. 둘째, 성공하기 전까지는 현재 편집 상태를 너무 빨리 지우지 않는다는 점입니다. 이 순서가 있어야 실패했을 때도 사용자가 입력하던 값을 다시 보여줄 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;저장 순서를 나누면 뭐가 좋아지나&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순서가 분명할 때&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순서가 흐릴 때&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패해도 어느 단계에서 멈췄는지 보기가 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 닫혔는데 저장은 안 된 식의 애매한 상태가 생기기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 저장 방지가 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼 연속 클릭에 취약해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공과 실패 시 UI를 다르게 설명할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무조건 닫거나 무조건 열어두는 식이 되기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;검증과 저장과 화면 종료를 한 줄에 섞기보다, &quot;검증 -&amp;gt; 잠금 -&amp;gt; 저장 -&amp;gt; 성공 시 확정 -&amp;gt; 마지막 정리&quot; 순서로 나누면 훨씬 설명하기 쉬워진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;localStorage 저장도 흐름을 나눠서 보는 편이 좋은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게는 이런 생각이 들 수 있습니다. &quot;지금은 서버도 없고 그냥 localStorage에 넣는 건데, 이렇게까지 단계를 나눠야 하나?&quot; 어느 정도는 이해되는 생각입니다. localStorage는 눈앞에서 바로 저장되는 느낌이 강하고, 네트워크 요청처럼 오래 기다릴 일도 보통 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데도 흐름을 나눠서 보는 편이 좋은 이유가 있습니다. localStorage든 나중의 서버 저장이든, 앱 입장에서는 둘 다 &lt;b&gt;외부 저장 단계&lt;/b&gt;입니다. 즉, todos를 그냥 메모리에서 바꾸는 것과는 역할이 다릅니다. 이 구분을 지금부터 해두면 나중에 서버로 넘어가도 구조를 통째로 갈아엎지 않아도 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;localStorage도 저장 단계로 따로 보는 편이 좋은 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 도움이 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 변경과 저장 단계를 분리할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 바뀐 것이 화면인지 실제 저장인지 구분하기 쉬워진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 저장 함수로 묶기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 수정, 삭제가 같은 흐름을 공유할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;나중에 서버 저장으로 바꾸기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveTodosToStorage 부분만 바꿔도 전체 흐름은 유지될 수 있다&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;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveTodosToStorage(nextTodos) {

localStorage.setItem(&quot;todos&quot;, JSON.stringify(nextTodos));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수가 크지 않아 보여도 역할은 분명합니다. todos를 어디에 반영할지, 어떻게 문자열로 바꿀지, 나중에 다른 저장 방식으로 바꿀 여지가 어디에 있는지를 한 군데로 모아주기 때문입니다. 즉, localStorage가 단순하다고 해서 흐름까지 단순하게 섞어버릴 이유는 없습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;지금은 localStorage만 써도 괜찮다. 다만 &quot;데이터 계산&quot;과 &quot;외부 저장&quot;을 다른 단계로 보는 습관은 일찍 들일수록 좋다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저장 중 상태와 에러 상태는 어떻게 들고 갈까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 흐름을 안정적으로 만들려면 값 검증만으로는 부족합니다. 지금 저장 중인지, 직전에 실패했는지, 어떤 메시지를 보여줘야 하는지도 같이 봐야 합니다. 이 값들은 todo 데이터 자체가 아니라 &lt;b&gt;화면 상태&lt;/b&gt;에 가깝기 때문에, uiState 쪽으로 분리해서 드는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
formError: &quot;&quot;,
isSaving: false
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 formError는 입력 검증 실패나 저장 실패 안내 문구를 보여주는 데 쓸 수 있고, isSaving은 연속 클릭을 막고 버튼을 비활성화하는 데 쓸 수 있습니다. 중요한 건 이 두 값이 실제 todo 데이터 안으로 들어가면 안 된다는 점입니다. 사용자가 다시 앱을 열었을 때 &quot;저장 중&quot;이나 &quot;입력 오류&quot; 같은 값까지 영구 데이터처럼 남아 있을 필요는 없기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;저장 흐름에서 자주 쓰는 화면 상태&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태 이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;화면에서는 어떻게 쓸 수 있나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 저장 처리 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼 비활성화, &quot;저장 중&quot; 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;formError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 보여줄 검증 또는 저장 에러 문구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창 아래 안내 문구 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태들이 있어야 사용자는 왜 저장이 안 됐는지 이해할 수 있고, 개발자도 &quot;값 문제인지&quot;, &quot;중복 클릭 문제인지&quot;, &quot;저장 중 잠금 문제인지&quot;를 훨씬 쉽게 구분할 수 있습니다. 결국 저장 흐름은 값만 다루는 게 아니라, &lt;b&gt;지금 화면이 어떤 단계에 있는지도 함께 다루는 일&lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;isSaving과 formError는 todo 데이터가 아니라 저장 흐름을 설명하는 화면 상태다. 그래서 uiState 쪽에 두는 편이 훨씬 자연스럽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;다음 todos를 계산하고, 저장하고, 화면 상태를 정리한다&lt;/b&gt;는 점에서 같습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;겉보기는 달라도 저장 파이프라인은 비슷하다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;달라지는 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;공통으로 묶을 수 있는 부분&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;배열 끝에 새 항목 하나를 넣는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증, isSaving 잠금, save 호출, render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;특정 항목의 text를 바꾼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증, isSaving 잠금, save 호출, render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;특정 항목을 배열에서 제거한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 잠금, save 호출, render&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;pre class=&quot;actionscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function persistNextTodos(createNextTodos) {

if (uiState.isSaving) return false;

uiState.isSaving = true;
uiState.formError = &quot;&quot;;

try {
const nextTodos = createNextTodos(todos);

saveTodosToStorage(nextTodos);
todos = nextTodos;

return true;

} catch (error) {
uiState.formError = &quot;저장 중 문제가 생겼습니다.&quot;;
return false;
} finally {
uiState.isSaving = false;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 수정은 이렇게 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveEdit() {

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
uiState.formError = &quot;내용을 확인해 주세요.&quot;;
renderTodos();
return;
}

const ok = persistNextTodos((currentTodos) =&amp;gt; {
return currentTodos.map(todo =&amp;gt;
todo.id === uiState.editingId
? { ...todo, text: result.value }
: todo
);
});

if (ok) {
uiState.editingId = null;
uiState.editDraft = &quot;&quot;;
renderTodos();
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서는 이런 구조가 조금 길어 보일 수 있습니다. 하지만 기능이 늘수록 저장 흐름이 한 군데로 모여 있다는 장점이 커집니다. 저장 버튼 잠금, 실패 메시지, 실제 저장, 렌더링이 매번 제각각 흩어지지 않기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;추가, 수정, 삭제는 화면상으로는 달라도 &quot;다음 todos 계산 -&amp;gt; 저장 -&amp;gt; 화면 정리&quot;라는 공통 흐름을 공유한다. 그래서 저장 파이프라인을 공통으로 빼두면 나중에 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 구조를 붙이기 시작하면 기능은 더 안정돼 보이지만, 비슷한 실수도 반복됩니다. 대부분은 순서가 섞이거나, 저장과 화면 상태를 한꺼번에 너무 빨리 닫아버리는 쪽에서 나옵니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 전에 저장부터 시도한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잘못된 값이 실제 데이터에 먼저 들어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증과 저장 순서가 뒤바뀌었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증이 먼저, 저장은 그다음이라고 고정해서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 없이 바로 저장한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼 연속 클릭에 취약하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;행동 잠금 단계가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값 검증과 별도로 저장 중 잠금도 꼭 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos를 먼저 바꾸고 저장 실패를 나중에 처리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면과 저장 결과가 어긋날 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 상태 계산과 실제 반영 순서가 흐리다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;nextTodos를 먼저 계산하고, 반영 시점을 분명히 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 여부와 상관없이 편집창을 바로 닫는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패했는데도 사용자는 저장된 줄 알기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 시 유지해야 할 화면 상태를 생각하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;성공 후에만 editingId를 닫는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 수정, 삭제가 각자 다른 저장 흐름을 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 곳은 에러가 보이고 어떤 곳은 안 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 파이프라인이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 공통 흐름부터 먼저 빼는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;catch만 두고 finally를 안 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 실패 뒤 버튼이 계속 비활성화된 채 남을 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마무리 상태 정리가 한쪽 경우에만 일어난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잠금 해제와 렌더링은 finally처럼 공통 마무리 단계로 두는 편이 안전하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 여섯 번째 실수는 처음엔 눈에 잘 안 띄지만 나중에 꽤 크게 느껴집니다. 성공 케이스만 계속 테스트하면 문제가 없어 보이는데, 한 번이라도 예외가 생기면 버튼이 풀리지 않거나 입력창이 이상한 상태로 남을 수 있기 때문입니다. 그래서 저장 흐름은 &quot;성공했을 때 뭐 할까&quot;만큼이나 &lt;b&gt;&quot;실패하거나 중간에 끊기면 무엇을 정리할까&quot;&lt;/b&gt;도 같이 봐야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;저장이 이상하게 느껴질 때는 저장 함수 한 줄만 볼 게 아니라 &quot;검증 -&amp;gt; 잠금 -&amp;gt; 반영 -&amp;gt; 정리&quot;의 순서가 흐트러졌는지를 같이 보는 편이 훨씬 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 흐름을 분리해서 보기 시작하면 체크리스트 앱이 한 단계 더 안정적으로 보입니다. 이전까지는 기능마다 그때그때 다르게 처리하던 작업이, 이제는 &quot;검증하고, 잠그고, 저장하고, 성공 시 확정하고, 마지막에 정리한다&quot;는 공통 흐름 안으로 들어오기 때문입니다. 이 구조가 생기면 중복 저장도 줄고, 실패했을 때도 어디까지 되돌릴지 설명하기 쉬워집니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 저장은 버튼 하나가 아니라 여러 단계가 있는 흐름이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, localStorage를 쓰는 작은 앱에서도 데이터 계산과 외부 저장 단계를 나눠 보는 습관이 중요하다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 추가, 수정, 삭제도 결국은 같은 저장 파이프라인 안에서 정리할 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 체크리스트 앱은 기능이 많은 수준을 넘어, 데이터가 실제로 어떻게 확정되는지 설명할 수 있는 구조를 갖추기 시작합니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;localStorage만 쓰던 앱을 서버 API 저장 구조로 바꿔보려 할 때 왜 갑자기 &quot;기다림&quot;, &quot;실패&quot;, &quot;되돌리기&quot; 문제가 커지는지&lt;/b&gt;, 즉 동기 저장에서 비동기 저장으로 넘어갈 때 달라지는 감각을 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>isSaving</category>
      <category>저장순서</category>
      <category>저장파이프라인</category>
      <category>저장흐름</category>
      <category>중복저장방지</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/39</guid>
      <comments>https://story86025.tistory.com/39#entry39comment</comments>
      <pubDate>Tue, 21 Apr 2026 17:00:24 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 19편: localStorage 다음 단계, 서버 저장은 왜 감각이 다를까</title>
      <link>https://story86025.tistory.com/53</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604131325.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cEyfrf/dJMcajaKAEt/tLe2n3ICRQDFRm0RYytu01/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cEyfrf/dJMcajaKAEt/tLe2n3ICRQDFRm0RYytu01/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cEyfrf/dJMcajaKAEt/tLe2n3ICRQDFRm0RYytu01/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcEyfrf%2FdJMcajaKAEt%2FtLe2n3ICRQDFRm0RYytu01%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604131325.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 왔다면 지금 할 일 앱은 꽤 많은 걸 할 수 있는 상태입니다. 추가, 수정, 삭제, 검색, 정렬, 우선순위, 마감일까지 들어갔으니 작은 프로젝트치고는 제법 손에 익는 도구가 되었을 겁니다. 그런데 이쯤에서 또 한 번 흐름이 바뀌는 지점이 옵니다. 바로 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저의 origin 기준으로 key/value 데이터를 저장하는 Web Storage 방식입니다. 새로고침 뒤에도 값이 남지만 현재 브라우저 안에만 저장됩니다.&quot;&gt;localStorage&lt;/span&gt; 중심 앱에서, &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저가 다른 프로그램이나 서버와 데이터를 주고받기 위한 규칙입니다.&quot;&gt;API&lt;/span&gt;를 통한 서버 저장 감각으로 넘어가기 시작하는 구간입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자 입장에서는 이 단계가 꽤 낯섭니다. 지금까지는 &quot;저장&quot; 버튼을 누르면 거의 바로 끝난다는 감각이 강했는데, 서버 저장이 들어오는 순간부터는 결과가 바로 확정되지 않을 수 있기 때문입니다. 잠깐 기다려야 하고, 실패할 수도 있고, 화면을 언제 바꿔야 하는지도 다시 생각해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 localStorage 저장과 서버 저장이 체감상 완전히 다르게 느껴지는지, 그 차이를 실습처럼 느껴볼 수 있게 정리해보겠습니다. 아직 실제 서버를 바로 붙이지는 않겠습니다. 대신 지금 앱 기준으로 &lt;b&gt;같은 저장 버튼인데 왜 구조가 달라져야 하는지&lt;/b&gt;, 그리고 초보자가 처음에는 어떤 방식으로 붙여야 덜 흔들리는지를 차근차근 보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼을 눌렀는데 결과가 바로 확정되지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서버 응답을 기다리는 &lt;span data-ui=&quot;term&quot; data-note=&quot;결과가 바로 끝나지 않고, 나중에 도착하는 흐름입니다. 네트워크 요청이 대표적인 예입니다.&quot;&gt;비동기&lt;/span&gt; 흐름이 생겼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 단계를 요청 전, 기다리는 중, 성공, 실패로 나눠서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 바뀌었는데 실제 저장은 실패할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 반영과 실제 저장 시점이 분리된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;언제 기다릴지, 언제 먼저 보여줄지 규칙을 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 저장 버튼인데 localStorage와 서버 저장 코드가 다르게 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;localStorage는 즉시 끝나는 흐름에 가깝고, 네트워크 저장은 기다림과 실패 가능성이 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 대상이 바뀌면 화면 상태도 같이 바뀌어야 한다고 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;서버 저장이 낯선 이유는 문법이 어려워서라기보다, &lt;b&gt;같은 저장 버튼인데 결과가 나중에 확정되는 구조&lt;/b&gt;를 처음 만나기 때문입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;왜 localStorage와 서버 저장이 체감상 다르게 느껴지는지&lt;/b&gt;를 먼저 이해하는 것입니다. 이 차이를 모르고 바로 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저에서 네트워크 요청을 보내고 응답을 받는 내장 함수입니다.&quot;&gt;fetch&lt;/span&gt;나 &lt;span data-ui=&quot;term&quot; data-note=&quot;비동기 함수를 만들 때 쓰는 JavaScript 문법입니다. 함수는 Promise를 반환합니다.&quot;&gt;async&lt;/span&gt; 문법으로만 들어가면, 겉으로는 따라 했는데 구조는 잘 안 남는 경우가 많기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저 안에서 끝나는 저장과 네트워크를 거치는 저장은 감각이 다릅니다.&lt;/li&gt;
&lt;li&gt;같은 저장 버튼이라도 기다림과 실패 처리가 생기기 시작합니다.&lt;/li&gt;
&lt;li&gt;입력창, 저장 중 상태, 에러 문구 같은 UI가 더 중요해집니다.&lt;/li&gt;
&lt;li&gt;실제 서버를 붙이기 전, 왜 구조가 달라져야 하는지 먼저 이해하면 훨씬 덜 흔들립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;지금 단계에서는 서버 기술보다도, &lt;b&gt;저장 결과가 바로 확정되지 않는 구조를 받아들이는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;localStorage 저장과 서버 저장은 왜 느낌이 다를까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 가장 단순한 비교부터 해보는 편이 좋습니다. 지금까지는 저장이 거의 브라우저 안에서 끝났습니다. 값을 바꾸고 저장하고 다시 그리면, 그 흐름이 거의 즉시 끝난다고 느끼기 쉽습니다. 그래서 초보자는 자연스럽게 &quot;저장 = 지금 바로 끝나는 일&quot;처럼 이해하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 서버 저장은 다릅니다. 브라우저 안에서 끝나는 게 아니라, 네트워크를 통해 바깥으로 요청을 보내고 결과를 기다려야 합니다. 그러니 같은 버튼이라도 내부 감각은 완전히 달라집니다. 이제부터는 &quot;요청했다&quot;와 &quot;진짜로 저장이 끝났다&quot;를 분리해서 봐야 합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 저장이라도 체감 구조는 다릅니다&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;585&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UefgZ/dJMcaaZeVXU/TT5D31Jx2kkULr5u8AtHL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UefgZ/dJMcaaZeVXU/TT5D31Jx2kkULr5u8AtHL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UefgZ/dJMcaaZeVXU/TT5D31Jx2kkULr5u8AtHL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUefgZ%2FdJMcaaZeVXU%2FTT5D31Jx2kkULr5u8AtHL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;671&quot; height=&quot;585&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;585&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;항목&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;localStorage 중심 저장&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;서버 저장&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장되는 곳&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 브라우저 안&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;네트워크 너머의 서버&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체감 속도&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;거의 바로 끝난다고 느끼기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잠깐 기다리는 시간이 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실패 가능성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상대적으로 단순하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;응답 실패, 네트워크 끊김, 서버 오류가 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가로 필요한 UI&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상대적으로 적다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장이 낯선 이유는 문법보다도, &lt;b&gt;요청과 확정이 같은 순간이 아니게 된다&lt;/b&gt;는 점에 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 같은 저장 버튼인데 왜 감각이 달라지는지 직접 보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;511&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q8BOe/dJMcai3Zx3K/k0A9gJ3EsVjnb3HYXuheg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q8BOe/dJMcai3Zx3K/k0A9gJ3EsVjnb3HYXuheg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q8BOe/dJMcai3Zx3K/k0A9gJ3EsVjnb3HYXuheg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq8BOe%2FdJMcai3Zx3K%2Fk0A9gJ3EsVjnb3HYXuheg0%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;691&quot; height=&quot;511&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;511&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이는 말로만 들으면 조금 추상적으로 느껴질 수 있습니다. 그래서 실제 서버를 붙이기 전에, &lt;b&gt;서버처럼 느껴지는 가짜 저장 함수&lt;/b&gt;를 한 번 만들어보는 편이 좋습니다. 이렇게 하면 왜 기다림이 생기고, 왜 실패 처리가 필요해지는지가 훨씬 손에 잡힙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 지금까지의 localStorage 저장 흐름은 대체로 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveTodosToStorage(nextTodos) {
  localStorage.setItem(
    &quot;vibe-todos&quot;,
    JSON.stringify(nextTodos)
  );

  return nextTodos;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 값을 넣고 바로 끝납니다. 그래서 그다음 줄에서 이미 저장이 끝났다고 느끼기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 서버 감각을 흉내 내려면 이렇게 &lt;span data-ui=&quot;term&quot; data-note=&quot;나중에 성공하거나 실패할 비동기 작업의 결과를 표현하는 JavaScript 객체입니다.&quot;&gt;Promise&lt;/span&gt; 기반 함수로 바꿔볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveTodosToFakeServer(nextTodos) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      const shouldFail = Math.random() &amp;lt; 0.3;

      if (shouldFail) {
        reject(new Error(&quot;save failed&quot;));
        return;
      }

      resolve(nextTodos);
    }, 800);
  });
}&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;/li&gt;
&lt;li&gt;기다리는 시간 동안 저장 중 상태가 필요해집니다.&lt;/li&gt;
&lt;li&gt;실패할 수도 있으니 에러 문구나 재시도 흐름이 필요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 같은 저장 버튼이라도 이제부터는 &quot;눌렀다&quot;와 &quot;정말 끝났다&quot; 사이에 빈 공간이 생깁니다. 서버 저장이 낯설게 느껴지는 이유가 바로 여기 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장을 처음 익힐 때는 실제 서버를 바로 붙이기 전에, &lt;b&gt;잠깐 기다리고 가끔 실패하는 가짜 저장 흐름&lt;/b&gt;만 만들어봐도 감각이 훨씬 좋아집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;서버 저장이 들어오면 같이 중요해지는 상태들&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;675&quot; data-origin-height=&quot;511&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oxFpQ/dJMcabDRATn/3U3VkyOduak0Vs485UKLr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oxFpQ/dJMcabDRATn/3U3VkyOduak0Vs485UKLr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oxFpQ/dJMcabDRATn/3U3VkyOduak0Vs485UKLr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoxFpQ%2FdJMcabDRATn%2F3U3VkyOduak0Vs485UKLr0%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;675&quot; height=&quot;511&quot; data-origin-width=&quot;675&quot; data-origin-height=&quot;511&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;의미&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 요청을 보내고 기다리는 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 클릭과 중복 저장을 막기 위해서입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;saveError&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장이 실패했다는 안내 문구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 지금 무슨 상황인지 알 수 있게 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draft&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 화면에서 편집 중인 입력 초안&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {
  editingTodoId: null,
  editingText: &quot;&quot;,
  isSaving: false,
  saveError: &quot;&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 값이 왜 중요하냐면, 서버 저장에서는 더 이상 &quot;저장 전&quot;과 &quot;저장 후&quot; 둘만 보지 않기 때문입니다. 그 사이에 &quot;지금 저장 중&quot;이라는 구간이 끼어들고, 실패했을 때는 입력 초안을 유지할지까지 같이 봐야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;서버 저장에서는 &quot;성공했는가&quot;만 보는 걸로는 부족합니다. &lt;b&gt;지금 저장 중인지, 실패했을 때 무엇을 유지할지&lt;/b&gt;까지 같이 봐야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음 붙일 때는 보수적인 저장 흐름이 더 낫다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 저장 얘기가 나오면 많은 초보자가 금방 궁금해하는 게 있습니다. &quot;그럼 버튼을 누르자마자 화면을 먼저 바꾸면 안 되나?&quot; 이건 흔히 &lt;span data-ui=&quot;term&quot; data-note=&quot;서버가 성공할 거라고 보고 화면을 먼저 바꾸는 방식입니다. 실패하면 다시 되돌리는 흐름이 같이 필요합니다.&quot;&gt;낙관적 업데이트&lt;/span&gt;라고 부르는 쪽과 연결됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식 자체가 틀린 건 아닙니다. 실제 서비스에서도 자주 씁니다. 다만 지금 단계에서는 처음부터 이쪽으로 가는 걸 그다지 권하지 않습니다. 이유는 단순합니다. 실패했을 때 되돌리는 규칙까지 한꺼번에 설계해야 하기 때문입니다. 초보자 기준에서는 이 구간이 생각보다 훨씬 빨리 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 첫 서버 저장 흐름은 아래처럼 보는 편이 좋습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1143&quot; data-origin-height=&quot;193&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmFEt3/dJMcabKE4AJ/KPtI19iM12pKGjpz3lQGz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmFEt3/dJMcabKE4AJ/KPtI19iM12pKGjpz3lQGz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmFEt3/dJMcabKE4AJ/KPtI19iM12pKGjpz3lQGz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmFEt3%2FdJMcabKE4AJ%2FKPtI19iM12pKGjpz3lQGz1%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;1143&quot; height=&quot;193&quot; data-origin-width=&quot;1143&quot; data-origin-height=&quot;193&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 입력값을 확인한다
2. 저장 중 상태를 켠다
3. 요청을 보낸다
4. 성공하면 확정한다
5. 실패하면 입력 초안을 유지한다
6. 마지막에 저장 중 상태를 끈다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 수정 저장은 이런 식으로 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;async function saveEditedTodoWithServer(todoId) {
  const nextText = editingText.trim();

  if (!nextText) {
    uiState.saveError = &quot;수정 내용은 비워둘 수 없습니다.&quot;;
    renderTodos();
    return;
  }

  if (uiState.isSaving) {
    return;
  }

  uiState.isSaving = true;
  uiState.saveError = &quot;&quot;;
  renderTodos();

  const nextTodos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        ...todo,
        text: nextText
      };
    }

    return todo;
  });

  try {
    const savedTodos =
      await saveTodosToFakeServer(nextTodos);

    todos = savedTodos;
    editingTodoId = null;
    editingText = &quot;&quot;;
  } catch (error) {
    uiState.saveError =
      &quot;저장에 실패했습니다. 다시 시도해 주세요.&quot;;
  } finally {
    uiState.isSaving = false;
    renderTodos();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름의 좋은 점은 단순합니다. 실패해도 입력 초안을 잃지 않고, 저장이 진짜 끝나기 전에는 화면 확정을 너무 빨리 하지 않기 때문입니다. 초보자에게는 이쪽이 훨씬 설명하기 쉽고, 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;처음 서버 저장을 붙일 때는 빠르게 보이게 만드는 것보다, &lt;b&gt;성공 전에는 확정하지 않고 실패 시 입력을 유지하는 구조&lt;/b&gt;가 훨씬 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계도 Codex에게 맡길 수 있습니다. 다만 지금은 기능 하나를 더 넣는 게 아니라 저장 감각 자체가 달라지는 구간이기 때문에, 범위를 훨씬 조심스럽게 잘라야 합니다. &quot;서버 저장 붙여줘&quot;라고만 던지면 AI가 실제 API 연결, 화면 먼저 반영, 에러 처리까지 한꺼번에 흔들 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음에는 설명부터 받는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 localStorage 기반으로 잘 돌아가는 상태야.
이번에는 실제 서버를 바로 붙이기 전에,
왜 서버 저장은 localStorage와 감각이 다른지
현재 코드 기준으로 설명해줘.
저장 중 상태, 실패 상태, 입력 초안 유지가 왜 필요한지도 같이 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습용 가짜 서버 저장 흐름만 만들고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 localStorage 저장 흐름은 유지하고,
실제 API 대신 setTimeout과 Promise를 써서
서버처럼 느껴지는 가짜 저장 함수 예시를 만들어줘.
가끔 실패하게 해도 좋고,
isSaving, saveError가 왜 필요한지도 같이 보여줘.
기존 기능을 전부 뜯지 말고 설명용 예시만 추가해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 저장 흐름만 따로 보고 싶다면 이렇게 더 좁게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
수정 기능 기준으로,
localStorage 저장과 서버 저장 흐름이 어디서 달라지는지
비교해서 보여줘.
서버 저장 버전에서는 저장 중 상태와 실패 메시지가
어떻게 들어가야 하는지도 같이 설명해줘.
화면을 먼저 바꾸는 방식은 아직 넣지 말아줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 실제 구조를 너무 넓게 흔들지 않고, 지금 꼭 이해해야 할 부분만 드러내도록 만들 수 있습니다. 결국 AI를 잘 쓰는 방식도 복잡한 프롬프트보다 &lt;b&gt;이번엔 무엇까지 이해할 건지 범위를 같이 주는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 이 단계를 맡길 때는 &quot;서버 저장 붙여줘&quot;보다 &lt;b&gt;가짜 서버 저장 흐름으로 감각부터 설명해줘&lt;/b&gt;처럼 범위를 잘라주는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnFTct/dJMcac3OeAC/Lalk1MLoVT89kx1KoN4kdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnFTct/dJMcac3OeAC/Lalk1MLoVT89kx1KoN4kdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnFTct/dJMcac3OeAC/Lalk1MLoVT89kx1KoN4kdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnFTct%2FdJMcac3OeAC%2FLalk1MLoVT89kx1KoN4kdk%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;627&quot; height=&quot;514&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 중요한 건 실제 서버를 붙였느냐가 아닙니다. 오히려 그 전 단계에서 &lt;b&gt;왜 localStorage 저장과 서버 저장이 체감상 다르게 느껴지는지&lt;/b&gt;를 먼저 이해했다는 점이 더 중요합니다. 지금까지는 저장 버튼을 누르면 거의 바로 끝나는 흐름이었다면, 이제부터는 요청과 기다림과 실패 가능성이 같이 들어오기 시작한다는 걸 본 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 단계에서는 같은 저장 버튼이라도, 저장 중 상태와 실패 메시지, 입력 초안 유지가 왜 함께 필요해지는지를 잡는 것이 핵심입니다. 이 감각이 생기지 않으면 fetch나 async 문법만 따라 해도 구조는 잘 안 남습니다. 반대로 이 감각이 먼저 잡히면, 실제 서버를 붙이는 다음 단계도 훨씬 덜 낯설게 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 지금은 &quot;서버를 붙인다&quot;보다 &lt;b&gt;저장 결과가 바로 확정되지 않는 구조를 받아들인다&lt;/b&gt;는 쪽이 먼저입니다. 이 차이를 이해하는 순간부터, 할 일 앱은 다시 한 번 다음 단계로 넘어갈 준비가 되기 시작합니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>fetch</category>
      <category>localStorage</category>
      <category>VSCode</category>
      <category>바이브코딩</category>
      <category>비동기</category>
      <category>서버저장</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/53</guid>
      <comments>https://story86025.tistory.com/53#entry53comment</comments>
      <pubDate>Fri, 17 Apr 2026 17:00:43 +0900</pubDate>
    </item>
    <item>
      <title>20. 검증은 어디서 해야 할까: 체크리스트 앱에서 빈값, 공백, 중복 다루기</title>
      <link>https://story86025.tistory.com/37</link>
      <description>&lt;article&gt;&lt;header&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;b&gt;연속 클릭으로 두 번 저장&lt;/b&gt; 같은 어색한 장면이 하나씩 나오기 시작합니다. 초보자 입장에서는 이때 &quot;이제 검증도 넣어야겠구나&quot;라고 느끼게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 여기서 많은 사람이 검증을 하나의 큰 if 문으로만 생각한다는 점입니다. 예를 들면 저장 버튼 안에서 무조건 한 번 막아보는 식입니다. 물론 그 자체가 틀린 건 아닙니다. 하지만 조금만 기능이 붙으면 금방 애매해집니다. 입력 중에는 무엇을 보여줘야 하는지, 저장 직전에는 무엇을 막아야 하는지, 렌더링 단계에서는 무엇을 하면 안 되는지가 섞이기 시작하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 입력 검증이 단순한 부가 기능이 아니라 데이터 흐름을 안정시키는 핵심인지, 빈값과 공백과 중복을 왜 같은 규칙처럼 다루면 안 되는지, 그리고 검증을 입력 중, 저장 직전, 화면 표시 단계로 나눠서 보는 편이 왜 훨씬 덜 흔들리는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈칸도 저장된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 직전 최종 검증이 없거나, 공백 정리가 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;trim 처리와 최종 저장 검증이 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소했는데도 값이 어색하게 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중간값을 실제 데이터처럼 다루고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 입력값과 실제 저장값을 분리했는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 저장 규칙이 들쭉날쭉하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가와 수정, 클릭과 엔터 입력에서 서로 다른 검증이 돈다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 함수를 공통으로 묶는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러는 막았는데 사용자 입장에서는 왜 안 되는지 모르겠다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드 안에서만 return 하고 화면에는 아무 설명이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 메시지도 별도 화면 상태로 다뤄야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;입력 검증은 &quot;나쁜 값을 나중에 고치는 일&quot;보다 &quot;문제가 될 값을 실제 데이터 안으로 들이기 전에 막는 일&quot;에 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 입력 검증이 생각보다 중요해질까&lt;/li&gt;
&lt;li&gt;검증은 무엇을 막는 일인가&lt;/li&gt;
&lt;li&gt;검증은 어디서 해야 할까&lt;/li&gt;
&lt;li&gt;빈값, 공백, 중복은 똑같이 다루면 안 된다&lt;/li&gt;
&lt;li&gt;에러 메시지는 데이터가 아니라 화면 상태다&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 입력 검증이 생각보다 중요해질까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 검증은 처음에는 사소해 보입니다. &quot;빈칸이면 저장하지 않는다&quot; 정도만 떠오르기 때문입니다. 그런데 실제 앱에서는 작은 이상값 하나가 다른 기능까지 같이 흔들 수 있습니다. 빈 항목이 목록에 들어가면 검색 결과가 어색해질 수 있고, 공백만 있는 값이 정렬이나 필터에서 이상하게 보일 수 있고, 너무 긴 텍스트가 들어가면 모바일 화면이 갑자기 지저분해질 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 입력 검증은 단순히 보기 싫은 값을 막는 기능이 아니라 &lt;b&gt;앱 전체가 다뤄야 할 데이터를 일정한 형태로 유지하는 장치&lt;/b&gt;에 가깝습니다. 값이 한 번 todos 안으로 들어간 뒤에는 렌더링, 검색, 정렬, 저장, 재정렬 같은 모든 흐름이 그 값을 기준으로 돌아갑니다. 그래서 초반에 막을 수 있는 문제라면, 가능한 한 그 지점에서 막는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;작은 입력 문제도 나중에는 여러 기능에 번질 수 있다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;입력 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로는 사소해 보여도&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;나중에 영향을 받는 곳&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈값 저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에 내용 없는 줄이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색, 개수 계산, 화면 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공백만 있는 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;눈에는 거의 안 보여도 실제 데이터로 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 판단, 필터, 수정 흐름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;너무 긴 입력&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모바일에서 레이아웃이 흐트러질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링, 줄바꿈, 버튼 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;연속 저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 값이 두 번 들어갈 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 데이터, 저장 흐름, 버튼 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;입력 검증은 한 줄짜리 if 문처럼 보여도, 실제로는 todos 안으로 어떤 값이 들어갈 수 있는지 정하는 데이터 규칙에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검증은 무엇을 막는 일인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;검증&quot;이라고 하면 흔히 &quot;틀린 값을 막는다&quot; 정도로만 받아들이기 쉽습니다. 그런데 실제로는 검증 안에도 결이 조금 다릅니다. 어떤 것은 값을 &lt;b&gt;정리&lt;/b&gt;하는 단계이고, 어떤 것은 값을 &lt;b&gt;거절&lt;/b&gt;하는 단계이고, 어떤 것은 값이 아니라 &lt;b&gt;행동 자체를 막는&lt;/b&gt; 단계입니다. 이걸 구분해두면 코드가 훨씬 덜 엉킵니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;입력 검증도 종류가 조금 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;종류&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 따로 보면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앞뒤 공백 제거&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 의미의 값을 일정한 형태로 맞출 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값 검증&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈값, 길이 제한, 허용하지 않는 문자&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터로 받아도 되는지 판단한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;행동 검증&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중일 때 연속 클릭 막기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값은 맞아도 지금 실행하면 안 되는 상황을 걸러낸다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &quot; 장보기 &quot;라는 값이 들어왔다고 해보겠습니다. 이건 보통 &quot;틀린 입력&quot;이라기보다 &lt;b&gt;정리하면 되는 입력&lt;/b&gt;에 가깝습니다. 반면 &quot;&quot;이나 공백만 있는 값은 정리한 뒤에도 내용이 없으므로, 그때는 실제 저장을 막아야 합니다. 또 사용자가 저장 버튼을 두 번 연속 누르는 경우는 값 문제라기보다 행동 문제입니다. 값이 멀쩡해도 지금 한 번 더 저장하면 안 될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 차이를 생각하지 않으면 모든 걸 한 if 문에 몰아넣게 됩니다. 그러면 나중에 왜 어떤 경우는 trim만 하고, 어떤 경우는 저장을 막고, 어떤 경우는 버튼 자체를 잠가야 하는지가 흐려집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;검증은 &quot;값을 정리할 것인지&quot;, &quot;값을 거절할 것인지&quot;, &quot;지금 행동 자체를 막을 것인지&quot;를 구분해서 보는 편이 훨씬 덜 헷갈린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;검증은 보통 세 지점으로 나눠서 보는 편이 좋다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;지점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 맡기면 좋은가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 여기서 하는가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자 수 표시, 가벼운 경고, 저장 버튼 활성화 여부&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 지금 무엇이 문제인지 미리 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 직전&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;trim, 빈값 검사, 길이 검사, 중복 행동 막기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터로 들어가기 전 마지막 방어선이기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링 단계&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 메시지 표시, 비활성화 상태 반영&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링은 보여주는 역할이지, 데이터를 고치는 역할은 아니기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 특히 중요한 건 마지막 줄입니다. 렌더링 단계에서 빈값을 &quot;자동으로 고쳐버리거나&quot;, 잘못된 값을 보고 그제야 todos를 다시 바꾸는 식은 처음엔 편해 보여도 구조를 금방 흐립니다. 렌더링은 원칙적으로 &lt;b&gt;현재 상태를 보여주는 단계&lt;/b&gt;로 두는 편이 훨씬 낫습니다. 검증과 데이터 변경은 저장 직전 단계에서 끝내는 쪽이 훨씬 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자용으로는 아래처럼 공통 검증 함수를 하나 두는 방식이 꽤 유용합니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function validateTodoText(rawValue) {

const value = rawValue.trim();

if (!value) {
return {
ok: false,
reason: &quot;empty&quot;
};
}

if (value.length &amp;gt; 50) {
return {
ok: false,
reason: &quot;tooLong&quot;
};
}

return {
ok: true,
value
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 저장 직전에는 이 함수를 통해 최종 판단을 합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveEdit() {

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
uiState.formError = result.reason;
renderTodos();
return;
}

// 여기서부터 실제 데이터 반영
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;입력 중 검증은 안내에 가깝고, 저장 직전 검증은 차단에 가깝고, 렌더링은 그 결과를 보여주는 단계에 가깝다. 이 셋의 역할이 섞이면 금방 구조가 흐려진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 검증처럼 보여도 성격이 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;규칙&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음 버전에서 보통 어떻게 보는가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 같은 취급을 하면 안 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빈값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대개 막는 편이 좋다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 의미 자체가 없어지기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공백만 있는 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;trim 후 빈값처럼 본다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;겉으로는 안 보여도 실제 데이터로 남기면 더 헷갈린다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;너무 긴 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;길이 제한을 둘 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;레이아웃과 사용성을 해칠 수 있지만, 허용 범위는 앱마다 다를 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 내용 중복&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상황에 따라 다르다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&quot;우유 사기&quot;가 두 번 필요한 날도 있을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;연속 저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;막는 편이 좋다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값 문제라기보다 행동 중복 문제다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 중복 규칙은 함부로 &quot;무조건 금지&quot;라고 말하기 어렵습니다. 개인용 체크리스트에서는 같은 항목이 두 번 들어가는 것이 자연스러울 수도 있습니다. 반면 태그 목록이나 고정된 명칭 입력 같은 곳에서는 중복을 막는 편이 더 자연스러울 수 있습니다. 즉, 중복은 거의 항상 막아야 하는 빈값과는 성격이 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정과 추가도 완전히 같지는 않습니다. 예를 들어 수정에서 원래 값과 같은 내용을 다시 저장하려는 경우, 이것을 &quot;오류&quot;로 볼 필요는 없을 수도 있습니다. 그냥 변경 없음으로 보고 편집 모드만 닫아도 충분한 경우가 많습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;추가와 수정은 같은 검증을 써도 결과 해석은 다를 수 있다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음 버전에서 자주 쓰는 해석&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가할 값이 빈칸이다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장을 막는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중 값이 빈칸이다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장을 막고 편집 상태는 유지할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중 값이 원래와 똑같다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;오류라기보다 변경 없음으로 보고 닫을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;빈값은 거의 항상 막는 편이 좋지만, 중복은 앱 목적에 따라 달라질 수 있다. 모든 검증 규칙을 같은 성격으로 보면 오히려 이상한 제약이 생긴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에러 메시지는 데이터가 아니라 화면 상태다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증을 붙이다 보면 자연스럽게 &quot;그럼 사용자에게는 어떻게 알려주지?&quot;라는 질문이 나옵니다. 여기서도 초보자가 자주 흔들리는 부분이 있습니다. 에러 메시지를 todo 데이터 안에 넣거나, 반대로 코드 안에서만 return 하고 화면에는 아무것도 안 보여주는 경우입니다. 둘 다 조금씩 불편합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 단계에서는 에러 메시지도 화면 상태로 보는 편이 가장 무난합니다. 즉, todos 안에 저장할 필요는 없고, 지금 현재 입력창 아래에 잠깐 보여줄 정보로 생각하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
formError: &quot;&quot;,
isSaving: false
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 있으면 저장 실패 시 formError를 채우고, 저장이 성공하거나 취소하면 비우는 식으로 흐름을 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveEdit() {

const result = validateTodoText(uiState.editDraft);

if (!result.ok) {
if (result.reason === &quot;empty&quot;) {
uiState.formError = &quot;내용을 입력해 주세요.&quot;;
}

if (result.reason === &quot;tooLong&quot;) {
  uiState.formError = &quot;너무 긴 내용은 저장할 수 없습니다.&quot;;
}

renderTodos();
return;

}

uiState.formError = &quot;&quot;;

// 실제 저장 처리
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면에서는 이 상태를 읽어 입력창 아래에 안내 문구를 보여주면 됩니다. 이때 중요한 건 에러 메시지도 현재 상태에 따라 사라져야 한다는 점입니다. 사용자가 다시 입력을 시작해서 조건을 만족하게 됐다면, 이전 에러를 계속 남겨둘 이유가 없습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;에러 메시지를 다룰 때도 역할을 나누는 편이 좋다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 하나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 함수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;ok 여부와 reason만 돌려준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 함수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;reason을 바탕으로 formError를 채운다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;formError를 읽어 화면에 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 검증 자체는 깔끔하게 유지되고, 메시지 문구는 화면 요구에 맞춰 바꾸기 쉬워집니다. 나중에 같은 reason에 대해 다른 문구를 보여주고 싶어도 검증 함수까지 다시 손댈 필요가 줄어듭니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;에러 메시지는 todo 데이터가 아니라 현재 화면에서 잠깐 보여줄 안내에 가깝다. 그래서 formError 같은 화면 상태로 다루는 편이 훨씬 자연스럽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 검증을 붙이기 시작하면 구조가 조금 더 또렷해지지만, 동시에 비슷한 실수도 반복해서 보입니다. 대부분은 검증 위치가 섞이거나, 실제 데이터와 임시값 구분이 다시 흐려질 때 생깁니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;render 단계에서 빈값을 몰래 고친다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 맞는 것 같은데 실제 저장 흐름이 더 헷갈려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링이 데이터 수정까지 떠안고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링은 보여주는 역할에 가깝게 두는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가와 수정에 서로 다른 검증 규칙을 흩어 놓는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한쪽은 빈값이 막히고 다른 쪽은 그대로 저장된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 기준이 공통 함수로 안 모여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;validateTodoText 같은 공통 검증 함수부터 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;trim 없이 길이나 빈값만 검사한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공백만 있는 항목이 슬쩍 들어온다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;값 정리 단계가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검증 전에 최소한의 값 정리는 먼저 하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 금지를 무조건 기본값처럼 넣는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 입장에서는 합리적인 같은 항목도 저장이 안 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복을 빈값과 같은 절대 규칙으로 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복은 앱 목적에 따라 다르게 정하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼 연속 클릭을 값 검증 문제로만 본다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목이 두 번 생기거나 중복 저장이 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;행동 검증이 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving처럼 동작 자체를 잠그는 기준도 같이 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 메시지를 보여주고도 안 지운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력이 이미 정상인데도 계속 에러처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;formError 초기화 기준이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 시작, 저장 성공, 취소, 유효한 입력 변화에서 정리하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 꽤 자주 놓칩니다. 사용자는 같은 버튼을 두 번 눌렀을 뿐인데, 개발자는 &quot;값 검증은 통과했는데 왜 중복 저장됐지?&quot;라고 생각하기 쉽습니다. 이건 값 문제가 아니라 행동 문제에 가깝습니다. 그래서 입력 검증을 생각할 때는 텍스트 값만 볼 게 아니라, &lt;b&gt;지금 이 행동이 중복 실행돼도 되는가&lt;/b&gt;까지 같이 보는 편이 훨씬 현실적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;검증이 어색하게 느껴질 때는 &quot;규칙이 없어서&quot;보다 &quot;검증을 해야 할 위치가 섞여 있어서&quot;인 경우가 더 많다. 입력 중, 저장 직전, 렌더링의 역할을 다시 나눠서 보면 훨씬 선명해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. &lt;br /&gt;첫째, 검증은 값 정리, 값 검증, 행동 검증으로 나눠서 보는 편이 좋다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 입력 중 검증과 저장 직전 검증, 렌더링의 역할은 서로 다르다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 에러 메시지는 실제 데이터가 아니라 화면 상태로 두는 편이 훨씬 자연스럽다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 체크리스트 앱은 단순히 기능이 많은 화면이 아니라, 나쁜 값이 어디서 막히고 좋은 값이 어디서 확정되는지 설명할 수 있는 구조를 갖추기 시작합니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;저장 버튼을 누른 순간 바로 localStorage나 서버 반영까지 같이 묶일 때 어떤 순서로 처리하는 편이 좋은지&lt;/b&gt;, 즉 저장 흐름을 좀 더 안전하게 나누는 구조를 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>validate함수</category>
      <category>공백검사</category>
      <category>빈값검사</category>
      <category>입력검증</category>
      <category>중복입력</category>
      <category>폼검증입문</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/37</guid>
      <comments>https://story86025.tistory.com/37#entry37comment</comments>
      <pubDate>Thu, 16 Apr 2026 17:00:24 +0900</pubDate>
    </item>
    <item>
      <title>구형 PC에 로컬 LLM? 가능은 하지만 현실은 다릅니다.</title>
      <link>https://story86025.tistory.com/82</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a__p_data-ke-size=_siz.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wLluz/dJMcahcZZfd/JKSZLWMTuqM53k7w8X4NBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wLluz/dJMcahcZZfd/JKSZLWMTuqM53k7w8X4NBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wLluz/dJMcahcZZfd/JKSZLWMTuqM53k7w8X4NBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwLluz%2FdJMcahcZZfd%2FJKSZLWMTuqM53k7w8X4NBk%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-filename=&quot;a__p_data-ke-size=_siz.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구형 PC나 노트북에 로컬 LLM을 얹어서 클라우드 AI를 대신하겠다는 생각은, 저는 솔직히 말리고 싶습니다. 개인 프로젝트라면 괜찮고, LLM이 실제로 어떻게 돌아가는지 조금 더 깊게 알아보고 싶은 분께는 충분히 권할 수 있습니다. 하지만 그걸 &lt;b&gt;클라우드 AI의 대체재&lt;/b&gt;로 상상하는 순간부터는 이야기가 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브에는 이런 영상이 꽤 자주 보입니다. 오래된 노트북, 저전력 미니 PC, 혹은 몇 년 지난 데스크톱에 모델 하나를 올리고, 채팅이 되는 장면을 보여줍니다. 그 자체는 분명 흥미롭습니다. 실제로 뭔가가 돌아가고, 생각보다 그럴듯한 답이 나오기도 합니다. 그래서 보고 있으면 &amp;ldquo;이 정도면 나도 클라우드 안 쓰고 버틸 수 있겠는데?&amp;rdquo;라는 생각이 들기 쉽습니다. 그런데 저는 바로 그 지점에서 한 번쯤 멈춰야 한다고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;냉정하게 말하면, 저런 구형 장비로는 현재 프론티어 모델 같은 경험이 안 되는 건 당연하고, Gemma 4나 Qwen 계열처럼 요즘 많이 거론되는 오픈 모델을 얹더라도 일반 사용자가 기대하는 수준의 &amp;ldquo;AI 에이전트&amp;rdquo;가 되기는 어렵습니다. 여기서 말하는 기대 수준은 단순히 대답이 나온다는 뜻이 아닙니다. &lt;b&gt;길게 이어지는 맥락을 버티고, 속도가 너무 답답하지 않고, 도구를 붙였을 때도 무너지지 않고, 반복해서 써도 결과 편차가 너무 크지 않은 상태&lt;/b&gt;를 말합니다. 실제로는 이 기준에 도달하기 전에 하드웨어 한계가 먼저 눈에 들어오는 경우가 많습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;구형 PC 로컬 LLM에서 자주 생기는 기대와 현실&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 기대하는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 자주 벌어지는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;냉정하게 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클라우드 AI 대신 늘 쓸 수 있는 개인 비서가 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;짧은 문답은 되지만 길어질수록 속도와 품질이 같이 흔들립니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;돌아가는 것과 계속 쓸 만한 것은 다릅니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에이전트처럼 이것저것 맡길 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;툴 호출, 긴 맥락 유지, 여러 단계 작업에서 한계가 빨리 드러납니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에이전트는 모델 하나만 띄운다고 끝나지 않습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저렴하게 클라우드 비용을 아낄 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;속도, 발열, 세팅 시간, 유지 스트레스까지 합치면 생각보다 간단하지 않습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;돈만이 아니라 시간과 불편도 같이 계산해야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;구형 PC 로컬 LLM은 &lt;b&gt;배우기 위한 실험&lt;/b&gt;으로는 좋지만, &lt;b&gt;클라우드 AI를 대체할 주력 환경&lt;/b&gt;으로 기대하는 순간부터는 실망할 가능성이 훨씬 커집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;영상은 &amp;ldquo;된다&amp;rdquo;를 보여주지만, &amp;ldquo;계속 쓸 만하다&amp;rdquo;까지 증명해주지는 않습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브 영상이 주는 가장 큰 착시는 여기서 생깁니다. 설치 장면, 실행 장면, 질문 하나 던지고 답 하나 받는 장면까지는 생각보다 보기 좋습니다. 특히 로컬 LLM은 짧은 시연만 보면 꽤 그럴듯해 보일 수 있습니다. 응답이 느려도 편집으로 줄일 수 있고, 여러 번 실패한 장면은 빼고 괜찮게 나온 순간만 보여줄 수도 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 사용은 다릅니다. 사람들은 결국 한두 번 눌러보고 끝내지 않습니다. 여러 번 질문하고, 조금 긴 문서를 넣고, 이전 맥락을 이어가고, 때로는 코드나 글처럼 구조 있는 결과를 기대합니다. 이때부터 구형 장비의 한계가 훨씬 또렷하게 드러납니다. &amp;ldquo;답은 나온다&amp;rdquo;와 &amp;ldquo;계속 이걸로 일할 수 있다&amp;rdquo;는 전혀 다른 이야기라는 점이 여기서 드러납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 이 주제를 볼 때마다, 설치 성공 여부보다 &lt;b&gt;장기 사용 경험&lt;/b&gt;을 먼저 봐야 한다고 생각합니다. 한 번 돌아간다는 건 출발점일 뿐입니다. 실제로 중요한 건 그다음입니다. 내가 반복해서 써도 답답하지 않은가, 조금 복잡한 작업을 줬을 때 무너지지 않는가, 며칠 뒤 다시 켜도 여전히 쓸 마음이 드는가. 바로 이 질문들 앞에서 구형 PC 로컬 LLM은 생각보다 빨리 한계를 보이는 경우가 많습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Gemma 4나 Qwen이 나빠서가 아니라, 구형 장비에 거는 기대가 너무 큰 경우가 많습니다&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;Gemma 4 공식 문서만 봐도 모델군은 E2B, E4B, 31B, 26B A4B처럼 여러 크기로 나뉘고, Google도 파라미터 수와 정밀도가 높을수록 일반적으로 더 능력이 좋지만 처리 사이클, 메모리, 전력 비용이 더 커진다고 설명합니다. 같은 문서에는 4비트 기준 대략적인 추론 메모리도 제시되어 있는데, E2B는 약 3.2GB, E4B는 약 5GB, 31B는 약 17.4GB, 26B A4B는 약 15.6GB 수준이며, 이것도 소프트웨어 오버헤드나 긴 컨텍스트에서 늘어나는 추가 메모리는 빠진 수치입니다. 이 점만 봐도 &amp;ldquo;구형 저사양 장비로도 요즘 AI 에이전트 수준이 되겠지&amp;rdquo;라고 기대하기는 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 작은 모델은 분명 돌아갈 수 있습니다. 하지만 작은 모델이 돌아간다는 사실이 곧바로 사람들이 상상하는 수준의 에이전트 경험으로 이어지지는 않습니다. 반대로 더 큰 모델로 가면 품질은 조금 나아질 수 있어도, 이번에는 메모리와 속도가 발목을 잡습니다. 결국 구형 장비에서는 어느 쪽을 택해도 만족스러운 균형점을 찾기 쉽지 않습니다. 제가 보기에 핵심은 &amp;ldquo;무조건 안 된다&amp;rdquo;가 아니라, &lt;b&gt;체감상 만족할 만한 지점이 생각보다 좁다&lt;/b&gt;는 데 있습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에이전트는 모델 하나만 띄운다고 완성되지 않습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람들이 자주 놓치는 부분도 여기입니다. &amp;ldquo;AI 에이전트&amp;rdquo;라는 말을 너무 쉽게 생각하는 경우가 많습니다. 그냥 채팅 모델 하나 잘 띄우면 되는 것처럼 받아들이기 쉽습니다. 그런데 실제로는 보통 그 이상이 필요합니다. 도구 호출, 상태 유지, 실패 시 재시도, 여러 단계 작업 분해, 외부 문서 읽기, 정리, 실행 같은 주변 구조가 같이 붙어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Qwen 쪽만 봐도 공식 GitHub에는 Qwen3.5, Qwen3-Coder 같은 모델 저장소와 별도로, Function Calling, MCP, Code Interpreter, RAG 등을 다루는 &lt;b&gt;Qwen-Agent&lt;/b&gt; 프레임워크가 따로 공개되어 있습니다. 이 사실이 의미하는 바는 분명합니다. 에이전트는 단순히 &amp;ldquo;모델 하나 올려서 말 잘하면 끝&amp;rdquo;인 성격이 아니라는 점입니다. 모델 능력도 중요하지만, 그걸 둘러싼 실행 구조와 자원도 함께 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 구형 노트북이나 저사양 PC에서 가장 먼저 생기는 문제는, 모델이 전혀 안 도는 게 아니라 &lt;b&gt;주변 기능을 붙일수록 급격히 답답해진다&lt;/b&gt;는 점입니다. 채팅만 할 때는 그럭저럭 버티다가도, 툴을 붙이고 맥락을 늘리고 작업을 여러 단계로 나누기 시작하면 체감이 급격히 나빠집니다. 결국 &amp;ldquo;되긴 되는데, 계속 이걸로 쓰고 싶지는 않다&amp;rdquo;는 상태로 가기 쉽습니다. 저는 바로 이 상태가 구형 장비 로컬 LLM의 가장 현실적인 한계라고 생각합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;에이전트 경험은 모델 하나만 띄운다고 만들어지지 않습니다. &lt;b&gt;모델 성능, 메모리, 속도, 주변 프레임워크, 사용 흐름&lt;/b&gt;이 같이 맞아야 비로소 &amp;ldquo;쓸 만하다&amp;rdquo;는 느낌이 나옵니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;LLM이 실제로 어떻게 돌아가는지 감을 잡고 싶을 때&lt;/li&gt;
&lt;li&gt;양자화 모델과 하드웨어 제약이 결과에 어떤 차이를 만드는지 보고 싶을 때&lt;/li&gt;
&lt;li&gt;오프라인 환경에서 아주 가벼운 보조 모델을 써보고 싶을 때&lt;/li&gt;
&lt;li&gt;내 데이터가 바깥으로 안 나가는 흐름을 직접 구성해보고 싶을 때&lt;/li&gt;
&lt;li&gt;토이 프로젝트나 사내용 아주 작은 실험을 해보고 싶을 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 목적이라면 구형 장비도 충분히 의미가 있습니다. 오히려 클라우드만 쓸 때보다 더 많이 배우는 부분도 있습니다. 모델 크기가 달라질 때 체감이 어떻게 달라지는지, 왜 메모리가 중요한지, 왜 속도가 생각보다 더 큰 사용자 경험 요소인지 같은 걸 몸으로 알게 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 저는 구형 PC 로컬 LLM을 &lt;b&gt;배움의 장비&lt;/b&gt;로는 높게 봅니다. 다만 그걸 곧바로 &lt;b&gt;실사용의 주력 장비&lt;/b&gt;로 넘겨버리는 판단은 조심해야 한다고 보는 쪽입니다. 같은 로컬 LLM이라도, 실험용과 주력용은 전혀 다른 기준으로 봐야 하기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결국 문제는 돈보다 불편의 비용입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 주제에서 자주 나오는 말이 &amp;ldquo;어차피 집에 굴러다니는 장비니까 싸게 먹힌다&amp;rdquo;입니다. 이 말도 절반만 맞습니다. 분명 초기 비용은 낮아 보일 수 있습니다. 이미 있는 장비를 재활용하니 손해 보는 느낌도 덜하고, 구독료를 줄일 수 있을 것 같기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제로는 하드웨어 비용 말고도 같이 계산해야 할 것이 많습니다. 세팅 시간, 모델 바꿔보는 시간, 계속 느린 응답을 기다리는 시간, 발열과 소음, 장시간 돌릴 때의 불안정성, 결국 중요한 일은 다시 클라우드로 돌아가게 되는 이중 구조까지 다 포함하면 이야기가 달라집니다. 저는 이걸 &lt;b&gt;불편의 비용&lt;/b&gt;이라고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;취미와 실사용은 늘 다릅니다. 오래된 노트북을 살려서 로컬 모델을 돌려보는 재미는 분명 있습니다. 하지만 그 재미가 곧바로 &amp;ldquo;이제부터는 이것만 계속 쓰겠다&amp;rdquo;는 실용성으로 이어지지는 않습니다. 특히 매일 쓰는 도구일수록 더 그렇습니다. 결국 사람은 조금 더 빨리, 조금 더 안정적으로, 조금 덜 신경 쓰이는 쪽으로 돌아가기 쉽기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;LLM을 직접 만져보며 배우고 싶은 분&lt;/li&gt;
&lt;li&gt;개인 프로젝트에 제한된 범위로 붙여보고 싶은 분&lt;/li&gt;
&lt;li&gt;로컬 추론 환경이 왜 중요한지 몸으로 체험해보고 싶은 분&lt;/li&gt;
&lt;li&gt;클라우드를 완전히 끊기보다 보조 수단으로 로컬을 써보고 싶은 분&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 말리고 싶은 경우도 분명합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금 쓰는 클라우드 AI를 바로 대체하고 싶은 분&lt;/li&gt;
&lt;li&gt;요즘 상용 AI 서비스 수준의 속도와 품질을 기대하는 분&lt;/li&gt;
&lt;li&gt;에이전트형 워크플로를 주력으로 돌리고 싶은 분&lt;/li&gt;
&lt;li&gt;세팅과 유지보수 스트레스를 감수하고 싶지 않은 분&lt;/li&gt;
&lt;li&gt;영상 몇 개 보고 &amp;ldquo;이 정도면 되겠네&amp;rdquo; 정도의 기대만 있는 분&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이 문제는 장비의 문제가 아니라 기대치의 문제에 더 가깝습니다. 기대치를 낮게 잡으면 구형 PC 로컬 LLM도 꽤 재미있고 유익할 수 있습니다. 하지만 기대치를 클라우드 수준까지 끌어올리는 순간, 대부분은 만족보다 불편을 먼저 느끼게 될 가능성이 큽니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 로컬 LLM의 방향 자체는 앞으로 더 중요해질 거라고 봅니다. 개인정보, 통제권, 오프라인 활용, 개인용 AI 인프라라는 흐름은 분명 커질 가능성이 있습니다. 하지만 그 미래를 너무 쉽게 &amp;ldquo;집에 있는 구형 노트북 하나면 지금의 클라우드 AI를 거의 대체할 수 있다&amp;rdquo;로 연결하는 건 다른 문제라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구형 PC 로컬 LLM 서버는 재미있는 실험이 될 수 있습니다. 공부용 도구가 될 수도 있습니다. 작은 프로젝트의 일부가 될 수도 있습니다. 다만 많은 사람이 상상하는 &amp;ldquo;클라우드 AI 없이도 계속 쓸 수 있는 개인 AI 환경&amp;rdquo;과는 아직 거리가 있습니다. 저는 이 간격을 너무 낙관적으로 보면 오히려 실망이 커진다고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 주제에 대해서는 조금 냉정하게 말하고 싶습니다. 구형 장비에 로컬 LLM을 올리는 건 해볼 만합니다. 하지만 그 이유는 &amp;ldquo;이제 클라우드를 대체할 수 있어서&amp;rdquo;가 아니라, &lt;b&gt;LLM을 더 잘 이해하게 해주기 때문&lt;/b&gt;입니다. 이 차이를 먼저 받아들이고 시작하면 배울 게 많고, 그 차이를 무시하고 시작하면 피로감만 남을 가능성이 큽니다.&lt;/p&gt;
&lt;/footer&gt;</description>
      <category>주저리 주저리</category>
      <category>ai에이전트</category>
      <category>gemma4</category>
      <category>qwen</category>
      <category>구형PC</category>
      <category>구형노트북</category>
      <category>로컬llm</category>
      <category>바이브코딩</category>
      <category>유튜브</category>
      <category>클라우드AI</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/82</guid>
      <comments>https://story86025.tistory.com/82#entry82comment</comments>
      <pubDate>Wed, 15 Apr 2026 17:13:37 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 18편: 할 일 앱에 정렬 기준 붙이기</title>
      <link>https://story86025.tistory.com/52</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604131321.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kw245/dJMcacbFeKj/ZWHDm8JCgW2ZIa8beol9H1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kw245/dJMcacbFeKj/ZWHDm8JCgW2ZIa8beol9H1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kw245/dJMcacbFeKj/ZWHDm8JCgW2ZIa8beol9H1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fkw245%2FdJMcacbFeKj%2FZWHDm8JCgW2ZIa8beol9H1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604131321.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마감일까지 붙이고 나면, 이제 할 일 앱이 꽤 그럴듯해 보이기 시작합니다. 우선순위도 보이고, 날짜도 보이고, 검색과 필터도 되니까요. 그런데 실제로 조금만 써보면 또 다른 아쉬움이 금방 보입니다. 정보는 많아졌는데, 정작 &lt;span data-ui=&quot;term&quot; data-note=&quot;어떤 기준으로 목록을 위에서 아래로 보여줄지 정하는 방식입니다.&quot;&gt;정렬&lt;/span&gt; 기준이 없으니 상황에 따라 보기 불편한 순간이 생긴다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 어떤 날은 지금 순서 그대로 보는 게 편하고, 어떤 날은 높은 우선순위부터 보고 싶고, 또 어떤 날은 마감일이 빠른 것부터 먼저 보고 싶을 수 있습니다. 이런 기준이 없으면 정보는 늘어났는데 오히려 한눈에 읽기 어려워지는 순간이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 편에서는 정렬 기준을 붙여보겠습니다. 다만 이번에도 범위를 크게 벌리지는 않겠습니다. 자동 정렬 기준을 붙이되, &lt;b&gt;기존에 사용자가 직접 정한 순서를 지워버리는 방식으로 가지는 않겠습니다&lt;/b&gt;. 지금 단계에서는 그게 더 안전합니다. 이번 글에서는 &lt;b&gt;지금 순서, 우선순위 높은순, 마감일 빠른순&lt;/b&gt; 세 가지 기준만 추가하고, 실제 저장된 배열 순서는 그대로 두는 방식으로 가겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 기준 선택창이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;currentSortType 같은 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저가 현재 기억하고 있는 값입니다. 지금 어떤 정렬 기준이 선택돼 있는지가 여기에 해당합니다.&quot;&gt;상태&lt;/span&gt;가 하나 더 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어떤 기준으로 목록을 보여주는지 따로 기억하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 목록이라도 보이는 순서가 달라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터와 검색이 끝난 뒤, 정렬 기준을 한 번 더 적용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록을 고르는 단계와 정렬하는 단계를 분리해서 보는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;위아래 버튼이 어떤 때는 잠긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;직접 정렬 중이 아닐 때는 수동 순서 변경을 막는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 순서와 화면 정렬 기준이 다를 때 무엇을 막아야 덜 헷갈리는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;정렬 기능의 핵심은 목록을 예쁘게 섞는 일이 아니라, &lt;b&gt;현재 저장된 순서는 유지한 채 화면에서 보여주는 기준만 바꾸는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;p data-ke-size=&quot;size16&quot;&gt;즉, 이제부터는 목록을 하나의 고정된 순서로만 보는 게 아니라, &lt;b&gt;같은 데이터를 어떤 기준으로 읽을지 바꿔보는 단계&lt;/b&gt;로 들어오게 됩니다. 이 감각이 한 번 잡히면, 이후에는 단순히 기능을 더 붙이는 수준을 넘어 &quot;사용할 때 어떤 보기 방식이 필요한가&quot;까지 생각하게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;직접 정한 순서와 자동 정렬 기준을 구분해서 볼 수 있습니다.&lt;/li&gt;
&lt;li&gt;우선순위와 마감일이 단순 표시를 넘어 실제 보기 기준이 됩니다.&lt;/li&gt;
&lt;li&gt;상태를 하나 더 추가해도 기존 구조를 유지하는 연습이 됩니다.&lt;/li&gt;
&lt;li&gt;필터, 검색, 정렬이 겹치는 상황을 차분하게 다루는 감각을 익힐 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;정렬 기능은 보기 편의 기능이기도 하지만, 동시에 &lt;b&gt;지금 데이터는 그대로 두고 화면에서만 다른 기준으로 읽는다&lt;/b&gt;는 감각을 익히는 데도 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;b&gt;우선순위 높은순&lt;/b&gt;, &lt;b&gt;마감일 빠른순&lt;/b&gt; 세 가지 기준만 둡니다.&lt;/li&gt;
&lt;li&gt;필터와 검색이 먼저 적용된 뒤, 그 결과에 정렬 기준을 적용합니다.&lt;/li&gt;
&lt;li&gt;정렬 기준이 지금 순서가 아닐 때는 위아래 이동 버튼을 비활성화합니다.&lt;/li&gt;
&lt;li&gt;정렬 기준은 새로고침 후 기본값인 지금 순서로 시작합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복합 정렬 기준&lt;/li&gt;
&lt;li&gt;사용자 정의 정렬 저장&lt;/li&gt;
&lt;li&gt;정렬 기준 자체를 localStorage에 저장하는 기능&lt;/li&gt;
&lt;li&gt;완료 항목 자동 맨 아래 보내기 같은 추가 규칙&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 것이 흐려지지 않습니다. 지금 중요한 건 &quot;정렬 옵션이 많으냐&quot;보다, &lt;b&gt;목록을 거르는 단계와 정렬하는 단계를 따로 분리해서 이해하는 것&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 정렬이 왜 기존 순서와 다른 개념인지 먼저 느껴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 항목이 있다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 운동하기        - 보통 - 2026-03-30
2. 회의 자료 정리   - 높음 - 2026-03-25
3. 책 반납하기      - 낮음 - 2026-03-20&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 지금 순서로 보면 1, 2, 3 순서 그대로 보입니다. 그런데 우선순위 높은순으로 보면 2번이 먼저 보여야 하고, 마감일 빠른순으로 보면 3번이 먼저 보여야 합니다. 여기서 중요한 건 &lt;b&gt;실제 저장된 배열을 바로 바꾸는 게 아니라, 화면에서만 다른 순서로 읽는다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;정렬 기준이 바뀌면 보는 방식이 이렇게 달라집니다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;기준&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;맨 앞에 보일 수 있는 항목&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 순서&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;운동하기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;우선순위 높은순&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;회의 자료 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마감일 빠른순&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;에 가깝습니다. 이 감각이 먼저 잡히면 JavaScript가 훨씬 덜 복잡하게 보입니다. 그리고 왜 정렬 중에는 위아래 이동 버튼을 막는지도 이해하기 쉬워집니다. 지금 화면 순서와 실제 저장 순서가 다를 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;정렬 기능은 &quot;항목을 움직인다&quot;보다 &lt;b&gt;같은 목록을 다른 기준으로 보여준다&lt;/b&gt;고 이해하는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 정렬 기준 선택창 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 크게 바꾸는 작업이 아닙니다. 기존 입력 폼, 필터, 검색, 메시지, 목록 구조는 그대로 두고 그 사이에 &quot;정렬 기준&quot;을 고르는 구역 하나만 넣으면 충분합니다. 이 정도만 해도 화면에서 역할이 꽤 또렷해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Sort Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          지금 순서와 자동 정렬 기준을 구분해서 보는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 정렬 기능 점검하기&quot;
            &amp;gt;
            &amp;lt;select
              id=&quot;priorityInput&quot;
              class=&quot;priority-select&quot;
              aria-label=&quot;우선순위 선택&quot;
            &amp;gt;
              &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
              &amp;lt;option
                value=&quot;medium&quot;
                selected
              &amp;gt;
                보통
              &amp;lt;/option&amp;gt;
              &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
            &amp;lt;/select&amp;gt;
            &amp;lt;input
              id=&quot;dueDateInput&quot;
              type=&quot;date&quot;
              class=&quot;due-date-input&quot;
              aria-label=&quot;마감일 선택&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개 &amp;middot; 높은 우선순위 0개 &amp;middot; 지연 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;search-section&quot;&amp;gt;
        &amp;lt;label for=&quot;searchInput&quot;&amp;gt;검색&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;search-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;searchInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 수정, 배포, 회의&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;clearSearchButton&quot;
            type=&quot;button&quot;
            class=&quot;secondary-button&quot;
          &amp;gt;
            검색 지우기
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;sort-section&quot;&amp;gt;
        &amp;lt;label for=&quot;sortSelect&quot;&amp;gt;정렬 기준&amp;lt;/label&amp;gt;
        &amp;lt;select
          id=&quot;sortSelect&quot;
          class=&quot;sort-select&quot;
        &amp;gt;
          &amp;lt;option
            value=&quot;manual&quot;
            selected
          &amp;gt;
            지금 순서
          &amp;lt;/option&amp;gt;
          &amp;lt;option value=&quot;priority&quot;&amp;gt;우선순위 높은순&amp;lt;/option&amp;gt;
          &amp;lt;option value=&quot;dueDate&quot;&amp;gt;마감일 빠른순&amp;lt;/option&amp;gt;
        &amp;lt;/select&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;sortSelect&lt;/b&gt;가 추가됐다는 점입니다. 이 한 줄 때문에 앱은 이제 &quot;지금 어떤 기준으로 보여주는가&quot;를 따로 갖게 됩니다. 즉, 데이터는 그대로 두더라도 사용자가 보는 순서를 바꿀 수 있는 여지가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 점은 구조가 거의 흔들리지 않는다는 것입니다. 기존 입력, 필터, 검색 흐름 사이에 정렬 기준 하나만 더 들어왔기 때문에, 초보자도 어디가 바뀌었는지 비교적 쉽게 따라갈 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML에서는 선택창 하나가 늘어난 것처럼 보여도, 실제로는 &lt;b&gt;현재 정렬 기준이라는 새 상태가 공식적으로 생기는 시작점&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 정렬 영역과 비활성 상태를 분명하게 보이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 화려한 디자인이 아니라, 정렬 구역이 검색이나 필터 구역과 헷갈리지 않게 보이도록 만드는 것입니다. 그리고 현재 정렬 방식 때문에 위아래 버튼이 잠긴 상태가 되면, 그 이유가 화면에서도 어느 정도 납득되게 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row,
.search-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
input[type=&quot;date&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input,
.sort-select {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

.sort-select {
  min-width: 180px;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section,
.search-section,
.sort-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge,
.overdue-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.overdue-badge {
  background: #111827;
  color: #ffffff;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-fields {
  display: flex;
  gap: 10px;
  align-items: center;
}

.edit-input {
  flex: 1;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.edit-priority-select,
.edit-date-input {
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  background: #ffffff;
  color: #111827;
  font: inherit;
}

.edit-priority-select {
  min-width: 96px;
}

.edit-date-input {
  min-width: 150px;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done .todo-text {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row,
  .search-row,
  .edit-fields {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input,
  .sort-select,
  .edit-priority-select,
  .edit-date-input {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 특히 눈여겨볼 부분은 세 가지입니다.&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;sort-section&lt;/b&gt;과 &lt;b&gt;sort-select&lt;/b&gt;입니다. 검색과 정렬이 역할상 구분되도록 따로 보이게 했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;move-up-button:disabled&lt;/b&gt;, &lt;b&gt;move-down-button:disabled&lt;/b&gt;입니다. 정렬 기준이 바뀌었을 때 수동 순서 변경이 잠기면, 그 상태가 버튼에서도 드러나야 합니다.&lt;/li&gt;
&lt;li&gt;기존 우선순위와 마감일 배지 스타일은 유지해서, 정렬 기능이 들어와도 시각 정보 체계가 흐트러지지 않게 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 정렬 기능을 새로 꾸미는 작업이 아니라, 정렬 기준이 들어와도 화면이 더 복잡해 보이지 않게 정리하는 작업에 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;정렬 UI의 핵심은 새롭고 화려한 느낌보다 &lt;b&gt;지금 어떤 기준으로 보고 있는지가 자연스럽게 읽히고, 막혀야 할 버튼은 확실히 잠겨 보이는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 정렬 상태와 목록 렌더링 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 정렬 기능은 결국 &lt;b&gt;현재 정렬 기준을 따로 기억하고, 이미 걸러진 목록에 그 기준을 다시 적용하는 작업&lt;/b&gt;이기 때문입니다. 여기서 중요한 건 기존 배열 자체를 매번 갈아엎는 게 아니라, 보여줄 목록을 만들 때만 정렬 기준을 적용한다는 점입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;SORT_TYPES&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 기준값을 한곳에 모아둡니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자열을 여기저기 흩뿌리지 않게 해줍니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;currentSortType&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어떤 기준으로 목록을 보여주는지 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 기능의 핵심 상태입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;getSortedTodos&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 정렬 기준에 맞게 보여줄 목록을 다시 정렬합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 기능의 실제 핵심이 여기에 들어 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;getPriorityRank&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;높음, 보통, 낮음을 비교 가능한 숫자로 바꿉니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const SORT_TYPES = {
  MANUAL: &quot;manual&quot;,
  PRIORITY: &quot;priority&quot;,
  DUE_DATE: &quot;dueDate&quot;
};

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  dueDateInput: document.querySelector(&quot;#dueDateInput&quot;),
  searchInput: document.querySelector(&quot;#searchInput&quot;),
  clearSearchButton: document.querySelector(&quot;#clearSearchButton&quot;),
  sortSelect: document.querySelector(&quot;#sortSelect&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let currentSortType = SORT_TYPES.MANUAL;
let currentSearchQuery = &quot;&quot;;
let editingTodoId = null;
let editingText = &quot;&quot;;
let editingPriority = PRIORITY_TYPES.MEDIUM;
let editingDueDate = &quot;&quot;;

function normalizeText(value) {
  return String(value || &quot;&quot;)
    .trim()
    .toLowerCase();
}

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || &quot;&quot;).trim();

  if (nextValue === &quot;&quot;) {
    return &quot;&quot;;
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue)
    ? nextValue
    : &quot;&quot;;
}

function normalizeTodo(todo) {
  return {
    id: todo.id,
    text: todo.text,
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    if (!savedValue) {
      return [];
    }

    const parsedValue = JSON.parse(savedValue);

    if (!Array.isArray(parsedValue)) {
      return [];
    }

    return parsedValue.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getTodayDateString() {
  const today = new Date();
  const year = String(today.getFullYear());
  const month = String(today.getMonth() + 1).padStart(2, &quot;0&quot;);
  const day = String(today.getDate()).padStart(2, &quot;0&quot;);

  return year + &quot;-&quot; + month + &quot;-&quot; + day;
}

function isOverdueTodo(todo) {
  return (
    !todo.done &amp;amp;&amp;amp;
    todo.dueDate !== &quot;&quot; &amp;amp;&amp;amp;
    todo.dueDate &amp;lt; getTodayDateString()
  );
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getHighPriorityRemainingCount() {
  return todos.filter(function (todo) {
    return (
      !todo.done &amp;amp;&amp;amp;
      todo.priority === PRIORITY_TYPES.HIGH
    );
  }).length;
}

function getOverdueCount() {
  return todos.filter(function (todo) {
    return isOverdueTodo(todo);
  }).length;
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function getPriorityRank(priority) {
  if (priority === PRIORITY_TYPES.HIGH) {
    return 0;
  }

  if (priority === PRIORITY_TYPES.MEDIUM) {
    return 1;
  }

  return 2;
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return &quot;&quot;;
  }

  return &quot;마감 &quot; + dueDate.replace(/-/g, &quot;.&quot;);
}

function getBaseOrderMap() {
  return new Map(
    todos.map(function (todo, index) {
      return [todo.id, index];
    })
  );
}

function getFilteredTodos() {
  let result = todos;

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    result = result.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    result = result.filter(function (todo) {
      return todo.done;
    });
  }

  if (currentSearchQuery !== &quot;&quot;) {
    result = result.filter(function (todo) {
      return normalizeText(todo.text).includes(
        currentSearchQuery
      );
    });
  }

  return result;
}

function getSortedTodos(list) {
  const baseOrderMap = getBaseOrderMap();
  const nextList = list.slice();

  if (currentSortType === SORT_TYPES.PRIORITY) {
    return nextList.sort(function (a, b) {
      const rankDiff =
        getPriorityRank(a.priority) -
        getPriorityRank(b.priority);

      if (rankDiff !== 0) {
        return rankDiff;
      }

      return (
        baseOrderMap.get(a.id) -
        baseOrderMap.get(b.id)
      );
    });
  }

  if (currentSortType === SORT_TYPES.DUE_DATE) {
    return nextList.sort(function (a, b) {
      const aHasDate = a.dueDate !== &quot;&quot;;
      const bHasDate = b.dueDate !== &quot;&quot;;

      if (aHasDate &amp;amp;&amp;amp; !bHasDate) {
        return -1;
      }

      if (!aHasDate &amp;amp;&amp;amp; bHasDate) {
        return 1;
      }

      if (a.dueDate !== b.dueDate) {
        return a.dueDate &amp;lt; b.dueDate ? -1 : 1;
      }

      return (
        baseOrderMap.get(a.id) -
        baseOrderMap.get(b.id)
      );
    });
  }

  return nextList;
}

function getVisibleTodos() {
  return getSortedTodos(getFilteredTodos());
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
  elements.priorityInput.disabled = isEditing;
  elements.dueDateInput.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();
  const highPriorityRemainingCount =
    getHighPriorityRemainingCount();
  const overdueCount = getOverdueCount();
  const visibleCount = getVisibleTodos().length;

  if (currentSearchQuery !== &quot;&quot;) {
    elements.summary.textContent =
      &quot;검색 결과 &quot; +
      visibleCount +
      &quot;개 &amp;middot; 전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개 &amp;middot; 지연 &quot; +
      overdueCount +
      &quot;개&quot;;
  } else {
    elements.summary.textContent =
      &quot;전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개 &amp;middot; 지연 &quot; +
      overdueCount +
      &quot;개&quot;;
  }

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;

  elements.clearSearchButton.disabled =
    currentSearchQuery === &quot;&quot;;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function updateSortSelect() {
  elements.sortSelect.value = currentSortType;
  elements.sortSelect.disabled =
    editingTodoId !== null;
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일을 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  if (currentSortType === SORT_TYPES.PRIORITY) {
    return &quot;우선순위 높은순으로 보고 있습니다.&quot;;
  }

  if (currentSortType === SORT_TYPES.DUE_DATE) {
    return &quot;마감일 빠른순으로 보고 있습니다.&quot;;
  }

  if (getOverdueCount() &amp;gt; 0) {
    return &quot;지연된 할 일을 먼저 확인해보세요.&quot;;
  }

  return &quot;지금 순서, 우선순위, 마감일 기준으로 번갈아 보면서 정리해보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;
  editingPriority = normalizePriority(todo.priority);
  editingDueDate = normalizeDueDate(todo.dueDate);

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();
  const nextPriority = normalizePriority(
    editingPriority
  );
  const nextDueDate = normalizeDueDate(
    editingDueDate
  );

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done,
        priority: nextPriority,
        dueDate: nextDueDate
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &amp;amp;&amp;amp;
    currentSearchQuery === &quot;&quot; &amp;amp;&amp;amp;
    editingTodoId === null &amp;amp;&amp;amp;
    currentSortType === SORT_TYPES.MANUAL
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage(
      &quot;순서 변경은 지금 순서 보기에서, 검색 중이 아닐 때만 할 수 있습니다.&quot;
    );
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === &quot;up&quot;
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex &amp;lt; 0 ||
    targetIndex &amp;gt;= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === &quot;up&quot;
      ? &quot;할 일을 위로 옮겼습니다.&quot;
      : &quot;할 일을 아래로 옮겼습니다.&quot;
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;due-date-badge&quot;;
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createOverdueBadge(todo) {
  if (!isOverdueTodo(todo)) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;overdue-badge&quot;;
  badge.textContent = &quot;지연&quot;;

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  return text;
}

function createTodoContent(todo) {
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);
  const dueDateBadge = createDueDateBadge(todo);
  const overdueBadge = createOverdueBadge(todo);

  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;

  meta.append(
    createPriorityBadge(todo)
  );

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  if (overdueBadge) {
    meta.append(overdueBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  return content;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;위로&quot;,
    &quot;move-up-button&quot;,
    function () {
      moveTodo(todoId, &quot;up&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;아래로&quot;,
    &quot;move-down-button&quot;,
    function () {
      moveTodo(todoId, &quot;down&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoContent(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditPrioritySelect() {
  const select = document.createElement(&quot;select&quot;);

  select.className = &quot;edit-priority-select&quot;;

  [
    PRIORITY_TYPES.HIGH,
    PRIORITY_TYPES.MEDIUM,
    PRIORITY_TYPES.LOW
  ].forEach(function (priority) {
    const option = document.createElement(&quot;option&quot;);

    option.value = priority;
    option.textContent = getPriorityLabel(priority);

    select.appendChild(option);
  });

  select.value = editingPriority;

  select.addEventListener(&quot;change&quot;, function () {
    editingPriority = select.value;
  });

  return select;
}

function createEditDateInput() {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;date&quot;;
  input.className = &quot;edit-date-input&quot;;
  input.value = editingDueDate;

  input.addEventListener(&quot;input&quot;, function () {
    editingDueDate = input.value;
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const editFields = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  editFields.className = &quot;edit-fields&quot;;
  actions.className = &quot;todo-actions&quot;;

  editFields.append(
    createEditInput(todo.id),
    createEditPrioritySelect(),
    createEditDateInput()
  );

  editArea.append(editFields);

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getVisibleTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();
  updateSortSelect();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText, priority, dueDate) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false,
    priority: normalizePriority(priority),
    dueDate: normalizeDueDate(dueDate)
  });

  currentFilter = FILTER_TYPES.ALL;
  currentSortType = SORT_TYPES.MANUAL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  elements.dueDateInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  currentSortType = SORT_TYPES.MANUAL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  elements.dueDateInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function setSort(nextSort) {
  if (currentSortType === nextSort || editingTodoId !== null) {
    return;
  }

  currentSortType = nextSort;
  renderTodos(&quot;정렬 기준을 바꿨습니다.&quot;);
}

function handleSearchInput() {
  currentSearchQuery = normalizeText(
    elements.searchInput.value
  );

  if (editingTodoId !== null) {
    editingTodoId = null;
    editingText = &quot;&quot;;
    editingPriority = PRIORITY_TYPES.MEDIUM;
    editingDueDate = &quot;&quot;;
  }

  renderTodos();
}

function clearSearch() {
  if (currentSearchQuery === &quot;&quot;) {
    return;
  }

  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;
  renderTodos(&quot;검색어를 지웠습니다.&quot;);
  elements.searchInput.focus();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text, priority, dueDate);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindSearchEvents() {
  elements.searchInput.addEventListener(
    &quot;input&quot;,
    handleSearchInput
  );

  elements.clearSearchButton.addEventListener(
    &quot;click&quot;,
    clearSearch
  );
}

function bindSortEvents() {
  elements.sortSelect.addEventListener(
    &quot;change&quot;,
    function () {
      setSort(elements.sortSelect.value);
    }
  );
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
  bindSearchEvents();
  bindSortEvents();
}

bindEvents();
renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;649&quot; data-origin-height=&quot;264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p5Dcr/dJMcacW3fpL/p6W1Kgx9ZfSpfKrhOKOkMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p5Dcr/dJMcacW3fpL/p6W1Kgx9ZfSpfKrhOKOkMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p5Dcr/dJMcacW3fpL/p6W1Kgx9ZfSpfKrhOKOkMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp5Dcr%2FdJMcacW3fpL%2Fp6W1Kgx9ZfSpfKrhOKOkMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;649&quot; height=&quot;264&quot; data-origin-width=&quot;649&quot; data-origin-height=&quot;264&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능에서 특히 중요한 부분은 &lt;b&gt;getFilteredTodos&lt;/b&gt;와 &lt;b&gt;getSortedTodos&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;702&quot; data-origin-height=&quot;285&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/63jhQ/dJMcadIoDfk/91wXeqYIpc1MoxLFNkEnGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/63jhQ/dJMcadIoDfk/91wXeqYIpc1MoxLFNkEnGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/63jhQ/dJMcadIoDfk/91wXeqYIpc1MoxLFNkEnGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F63jhQ%2FdJMcadIoDfk%2F91wXeqYIpc1MoxLFNkEnGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;702&quot; height=&quot;285&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;285&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 점은 수동 순서 변경과 자동 정렬 기준을 일부러 분리했다는 것입니다. 지금 순서가 아닐 때는 위아래 버튼이 잠기게 만든 이유도 여기 있습니다. 데이터 안의 실제 순서와 화면에서 임시로 보여주는 정렬 기준이 섞이면, 초보자 입장에서는 결과를 해석하기가 훨씬 어려워지기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;292&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjWrKH/dJMcaaZeWWH/vUO8KiFyYJknqLuVKFWXj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjWrKH/dJMcaaZeWWH/vUO8KiFyYJknqLuVKFWXj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjWrKH/dJMcaaZeWWH/vUO8KiFyYJknqLuVKFWXj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjWrKH%2FdJMcaaZeWWH%2FvUO8KiFyYJknqLuVKFWXj0%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;485&quot; height=&quot;292&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;292&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 마감일 빠른순에서는 &lt;b&gt;날짜가 있는 항목을 먼저&lt;/b&gt; 보여주고, 날짜가 없는 항목은 뒤로 보냈습니다. 이 기준을 따로 둔 이유도 단순합니다. 날짜가 없는 항목까지 섞여 있으면 &quot;무엇이 정말 빠른 마감인지&quot;가 흐려지기 때문입니다. 즉, 이번 정렬은 예쁘게 섞는 게 아니라 &lt;b&gt;사용자가 더 쉽게 읽게 만드는 기준&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 목록을 다시 섞는 작업이 아니라, &lt;b&gt;필터와 검색이 끝난 결과 위에 현재 정렬 기준을 한 번 더 적용하는 구조를 만드는 데&lt;/b&gt; 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직접 눌러보며 테스트하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 겉으로 보기엔 간단하지만, 실제로는 필터, 검색, 수동 순서와 다 같이 엮이는 기능이라서 직접 눌러보는 확인이 더 중요합니다. 이번 편에서는 아래 순서로 점검해보는 편이 좋습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;329&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vYzsO/dJMcah49MaE/ep7kvA7h5uhH6Qo8RD010K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vYzsO/dJMcah49MaE/ep7kvA7h5uhH6Qo8RD010K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vYzsO/dJMcah49MaE/ep7kvA7h5uhH6Qo8RD010K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvYzsO%2FdJMcah49MaE%2Fep7kvA7h5uhH6Qo8RD010K%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;456&quot; height=&quot;329&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;329&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;정렬이 보기 좋게 되느냐&quot;보다, &lt;b&gt;필터, 검색, 수동 순서와 충돌 없이 같이 움직이는지&lt;/b&gt;를 끝까지 보는 것입니다. 이 감각이 있어야 이후에 보기 기준이 더 늘어나도 구조를 덜 헷갈리게 다룰 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;정렬 기능은 결과보다도 &lt;b&gt;다른 상태들과 충돌 없이 같이 움직이는지&lt;/b&gt;를 직접 눌러보며 확인하는 게 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정렬 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 검색, 필터, 수동 순서 변경, 렌더링이 다 같이 엮여 있기 때문에 더더욱 범위를 분명하게 잘라야 합니다. &quot;정렬 넣어줘&quot;라고만 던지면 AI가 수동 순서까지 덮어쓰거나, 복합 정렬까지 넓게 건드릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 검색, 순서 변경, 우선순위, 마감일, 저장 기능까지 정상 동작하는 상태야.
이번에는 정렬 기준 기능만 추가하고 싶어.
지금 순서, 우선순위 높은순, 마감일 빠른순 세 가지만 넣고,
기존 수동 순서는 그대로 유지해야 해.
필터와 검색이 먼저 적용된 뒤 정렬되게 해줘.
기존 기능은 유지하고 어떤 파일을 왜 바꿀지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 필터와 검색 구조는 유지하고,
currentSortType 상태를 하나 더 추가해서
보여줄 목록만 정렬되게 만들어줘.
실제 todos 배열 순서는 자동 정렬 때문에 매번 바꾸지 말고,
지금 순서가 아닐 때는 위아래 버튼이 비활성화되게 해줘.
마감일 빠른순에서는 날짜가 있는 항목이 먼저 오게 해줘.
필터 단계와 정렬 단계를 어디서 분리할지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
정렬 기준 선택창을 하나 추가할 거야.
검색과 필터 구역 아래에 자연스럽게 들어가게 해주고,
지금 순서가 아닐 때 위아래 버튼이 비활성화된 상태도
눈에 띄게 보이게 정리해줘.
전체 디자인은 크게 바꾸지 말아줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 정렬 기준과 수동 정렬을 섞어버릴 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 복잡한 문장보다 &lt;b&gt;어디까지는 유지하고 이번에 무엇만 바꿀지 같이 적는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 정렬 기능을 맡길 때는 &quot;정렬 넣어줘&quot;보다 &lt;b&gt;수동 순서 유지, 필터와 검색 뒤에 적용, 자동 정렬 중엔 위아래 버튼 비활성화&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;258&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tv3Gb/dJMcaiwbuTG/NErRkj3O7VPnNIgN2xTxK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tv3Gb/dJMcaiwbuTG/NErRkj3O7VPnNIgN2xTxK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tv3Gb/dJMcaiwbuTG/NErRkj3O7VPnNIgN2xTxK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftv3Gb%2FdJMcaiwbuTG%2FNErRkj3O7VPnNIgN2xTxK1%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;933&quot; height=&quot;258&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;258&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 아주 거창하지 않습니다. 정렬 기준 선택창 하나가 생겼고, 목록을 보는 방식이 세 가지로 늘어난 정도입니다. 그런데 실제로 앱을 다시 보면 체감이 꽤 달라집니다. 이제는 같은 목록이라도 &lt;b&gt;내가 지금 무엇을 기준으로 보고 있는가&lt;/b&gt;를 직접 정해서 읽을 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 기능은 수동 순서와 자동 정렬을 구분해서 보는 감각을 남긴다는 점에서도 중요합니다. 지금까지는 순서를 바꾸는 기능이 곧 보기 순서였지만, 이제부터는 저장된 순서와 화면에서 읽는 순서가 다를 수도 있다는 걸 직접 다뤄보게 됩니다. 이 경험이 한 번 생기면, 앱을 보는 시야도 조금 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위와 마감일, 검색과 필터, 그리고 정렬 기준까지 같이 쓰기 시작하면 이 할 일 앱은 단순한 체크리스트보다 조금 더 실제 도구에 가까워집니다. 해야 할 일을 적는 수준을 넘어서, 어떤 기준으로 읽고 정리할지까지 직접 선택할 수 있게 되었기 때문입니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>VSCode</category>
      <category>마감일</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>정렬기능</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/52</guid>
      <comments>https://story86025.tistory.com/52#entry52comment</comments>
      <pubDate>Wed, 15 Apr 2026 17:00:13 +0900</pubDate>
    </item>
    <item>
      <title>19. 수정 중인 값은 왜 따로 들고 가야 할까: 체크리스트 앱 임시 편집값 정리</title>
      <link>https://story86025.tistory.com/35</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크리스트 앱에 수정 기능을 처음 붙일 때는 보통 이렇게 생각합니다. 입력창 하나 띄우고, 글자를 바꾸고, 저장 버튼만 누르면 끝날 것 같다는 식입니다. 실제로 초반에는 그렇게 보여도 이상하지 않습니다. 그런데 막상 붙여보면 금방 어색한 장면이 나옵니다. 수정 중인데 검색 결과가 바로 바뀌거나, 아직 저장도 안 했는데 목록 글자가 먼저 바뀌어 있거나, 취소를 눌렀는데도 원래 값으로 안 돌아가는 경우입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 대개 수정 기능이 어려워서 생기는 게 아닙니다. 더 정확히 말하면, &lt;b&gt;입력창 안에서 잠깐 바뀌는 값&lt;/b&gt;과 &lt;b&gt;앱이 실제로 가지고 있는 데이터&lt;/b&gt;를 같은 것으로 다뤄서 생기는 경우가 많습니다. 사용자는 보통 입력 중에는 아직 확정하지 않았다고 느낍니다. 그래서 저장을 누르기 전까지는 원래 값이 남아 있기를 기대합니다. 그런데 코드가 입력 중인 값을 곧바로 실제 데이터에 덮어쓰기 시작하면, 저장과 취소의 의미가 흐려집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 수정 중인 입력값을 실제 데이터와 바로 섞으면 안 되는지, 임시 편집값을 따로 들고 간다는 것이 무슨 뜻인지, 그리고 입문자 기준에서는 편집 시작, 저장, 취소 흐름을 어떻게 나누는 편이 가장 덜 흔들리는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중인데 목록 글자가 바로 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터의 text 값을 입력 중에 바로 덮어쓰고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 편집값을 따로 두는지 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소를 눌렀는데 원래 값으로 안 돌아간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소할 대상이 이미 실제 데이터에 반영돼 버렸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 전까지는 todos를 건드리지 않는 구조인지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인데 검색, 정렬, 체크가 같이 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정 전 값이 실제 목록 값처럼 흘러들어가고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 입력값과 실제 목록 값을 분리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다른 항목 편집을 열었더니 이전 값이 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingId와 editDraft 정리가 분명하지 않다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 상태를 한곳에서 시작하고 끝내는 흐름을 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;수정 입력값은 저장되기 전까지 실제 데이터가 아니라 임시 초안에 가깝다. 이 둘을 분리해야 저장과 취소가 제대로 의미를 가진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 수정 입력값을 실제 데이터와 바로 섞으면 안 될까&lt;/li&gt;
&lt;li&gt;실제 데이터와 임시 편집값은 무엇이 다른가&lt;/li&gt;
&lt;li&gt;가장 단순한 편집 상태 구조&lt;/li&gt;
&lt;li&gt;편집 시작, 저장, 취소는 어떻게 나눌까&lt;/li&gt;
&lt;li&gt;렌더링은 현재 편집 모드를 어떻게 보여줘야 할까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 수정 입력값을 실제 데이터와 바로 섞으면 안 될까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 수정 기능을 붙일 때 가장 자주 떠올리는 방식은 입력할 때마다 todos 안의 text 값을 바로 바꾸는 것입니다. 눈으로 보기에는 간단합니다. 입력창 값이 바뀌면 실제 목록도 바로 따라 바뀌니까요. 그런데 이 방식은 겉보기에는 편해도, 저장과 취소 흐름을 거의 무너뜨리기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 &quot;장보기&quot;를 &quot;주말 장보기&quot;로 바꾸는 중이라고 해보겠습니다. 아직 다 입력하지도 않았는데 목록의 실제 text가 한 글자씩 바뀌기 시작하면, 앱 입장에서는 이미 수정이 끝난 것과 비슷한 상태가 됩니다. 이때 취소를 누르면 어떤 값을 되돌려야 하는지도 애매해집니다. 원래 값은 &quot;장보기&quot;였는데, 코드 입장에서는 이미 여러 번 바뀐 text를 진짜 데이터로 받아들였기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 문제는 이 값이 다른 기능으로 흘러갈 수 있다는 점입니다. 검색은 실제 목록 text를 기준으로 돌고, 정렬도 text나 다른 상태를 기준으로 돌 수 있고, 렌더링 역시 todos를 읽어 화면을 다시 그립니다. 아직 확정되지 않은 입력 중간값이 실제 데이터처럼 퍼져나가면, 사용자는 저장도 안 했는데 앱 전체가 먼저 흔들린다고 느끼게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleEditInput(todoId, value) {

todos = todos.map(todo =&amp;gt;
todo.id === todoId
? { ...todo, text: value }
: todo
);

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 얼핏 편해 보이지만, 입력 중간값을 바로 실제 데이터에 넣는 구조입니다. 이런 방식에서는 아래 같은 문제가 생기기 쉽습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;입력 중간값을 실제 데이터에 바로 넣을 때 흔히 생기는 문제&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 생기나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소가 애매해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 값이 이미 덮여서 복구 기준이 흐려진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 결과가 입력 중간에 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 대상이 임시값까지 포함한 실제 데이터가 돼 버린다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 버튼 의미가 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 반영은 이미 끝났는데 저장만 형식적으로 남게 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다른 기능과 충돌이 커진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 전 값과 수정 중 값의 경계가 없어지기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 입력은 &quot;지금 타이핑 중인 값&quot;이고, todos의 text는 &quot;저장된 실제 값&quot;이다. 이 둘을 바로 합치면 취소와 저장의 의미가 무너진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 데이터와 임시 편집값은 무엇이 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 구분을 조금 더 분명하게 보겠습니다. todos 안에 있는 text는 현재 앱이 실제로 가진 데이터입니다. 검색도 이 값을 보고, 정렬도 이 값을 기반으로 할 수 있고, 새로고침해도 남아 있어야 하는 쪽입니다. 반면 수정 입력창 안에서 타이핑 중인 값은 아직 확정되지 않았습니다. 이 값은 사용자가 저장을 누르기 전까지는 &lt;b&gt;임시 편집값&lt;/b&gt;으로 보는 편이 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘을 표로 정리하면 차이가 더 또렷합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;실제 데이터와 임시 편집값의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제 데이터&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;임시 편집값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대표 예시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo.text&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editDraft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;언제 바뀌나&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장을 확정했을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력할 때마다 바뀔 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새로고침 후 유지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보통 아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소 가능성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 확정된 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;언제든 버릴 수 있는 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색, 정렬 기준으로 바로 써도 되는가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음엔 보통 아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구분이 생기면 수정 기능이 갑자기 덜 모호해집니다. 사용자가 타이핑하는 동안 바뀌는 것은 editDraft이고, 실제 목록의 todo.text는 아직 그대로입니다. 그래서 취소를 누르면 editDraft만 지우면 되고, 저장을 누르면 그때 비로소 todo.text에 반영하면 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;실제 데이터는 &quot;확정본&quot;이고, editDraft는 &quot;작업 중 초안&quot;에 가깝다. 초안을 쓰는 동안 확정본이 바로 바뀌면 저장과 취소가 모두 애매해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

editingId: null,
editDraft: &quot;&quot;,
isSaving: false
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 editingId는 지금 어떤 항목을 수정 중인지 설명합니다. editDraft는 그 항목의 입력창 안에서 타이핑 중인 값입니다. isSaving은 저장 중에 버튼을 연속으로 누르지 않게 막는 용도로 쓸 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음 버전에서 이 정도면 충분한 편집 상태&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태 이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 수정 중인 항목의 id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 화면에서 어떤 항목을 입력 모드로 바꿀지 판단하기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editDraft&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창 안의 현재 임시값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터와 분리해서 수정 초안을 들고 가기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 처리 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 저장이나 연속 클릭을 막기 위해&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 editDraft를 todo 객체 안에 넣고 싶어질 수 있습니다. 예를 들어 todo.draftText 같은 식입니다. 하지만 입문 단계에서는 그렇게까지 섞지 않는 편이 더 읽기 쉽습니다. 왜냐하면 초안은 영구 데이터가 아니라, 지금 화면에서 잠깐 쓰는 값이기 때문입니다. 그래서 uiState 쪽에 두는 편이 훨씬 덜 헷갈립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 구조는 단순할수록 좋다&lt;/b&gt;&lt;br /&gt;처음에는 여러 항목 동시 편집보다 &quot;편집 대상 하나 + 임시 초안 하나&quot; 구조가 훨씬 안정적이고 이해하기 쉽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;h3 data-ke-size=&quot;size23&quot;&gt;1. 편집 시작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편집 시작 단계에서는 실제 데이터를 바꾸지 않습니다. 지금 수정하려는 항목의 id를 기록하고, 현재 text 값을 editDraft에 복사해 넣습니다. 즉, 원본을 기준으로 초안을 하나 만드는 단계입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function startEdit(todoId) {

const current = todos.find(todo =&amp;gt; todo.id === todoId);
if (!current) return;
if (uiState.isSaving) return;

uiState.editingId = todoId;
uiState.editDraft = current.text;

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 입력 중&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 중에는 editDraft만 바뀝니다. todos는 그대로 둡니다. 여기서 실제 데이터까지 바꾸기 시작하면 다시 앞 섹션의 문제가 돌아옵니다.&lt;/p&gt;
&lt;pre class=&quot;fortran&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function updateEditDraft(value) {

uiState.editDraft = value;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 저장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 버튼을 눌렀을 때 비로소 todo.text에 editDraft를 반영합니다. 이때 trim 처리나 빈값 검사 같은 것도 같이 넣을 수 있습니다. 그리고 저장이 끝나면 editingId와 editDraft를 비워서 편집 모드를 종료합니다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function saveEdit() {

const todoId = uiState.editingId;
const nextText = uiState.editDraft.trim();

if (todoId === null) return;
if (!nextText) return;
if (uiState.isSaving) return;

uiState.isSaving = true;

todos = todos.map(todo =&amp;gt;
todo.id === todoId
? { ...todo, text: nextText }
: todo
);

saveTodos();

uiState.editingId = null;
uiState.editDraft = &quot;&quot;;
uiState.isSaving = false;

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 취소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;취소는 오히려 더 단순합니다. 실제 데이터는 건드리지 않았기 때문에, editDraft와 editingId만 비우면 됩니다. 이게 바로 임시 편집값 구조의 가장 큰 장점입니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function cancelEdit() {

uiState.editingId = null;
uiState.editDraft = &quot;&quot;;

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;편집 흐름을 단계로 나누면 무엇이 좋아지나&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 바뀌는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 분리하면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingId, editDraft&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원본을 유지한 채 편집 모드만 시작할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editDraft&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 목록 값을 흔들지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos, uiState&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정 시점이 분명해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;uiState만 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 값이 그대로 남아 있으므로 되돌리기가 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;수정 기능에서 저장은 &quot;초안을 실제 데이터로 옮기는 순간&quot;이고, 취소는 &quot;초안을 버리는 순간&quot;이다. 이 구분이 분명해야 흐름이 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;렌더링은 현재 편집 모드를 어떻게 보여줘야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임시 편집값 구조를 만들었으면, 화면도 그 구조를 따라가야 합니다. 즉, editingId에 해당하는 항목만 입력창으로 바뀌고, 그 입력창의 값은 todo.text가 아니라 editDraft를 보여줘야 합니다. 이 차이를 놓치면 타이핑할 때 글자가 되돌아가거나, 저장 전 값과 실제 값이 섞여 보이기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 렌더링 기준은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;editingId와 같은 항목&lt;/b&gt;&lt;br /&gt;입력창과 저장, 취소 버튼을 보여준다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;나머지 항목&lt;/b&gt;&lt;br /&gt;평소 텍스트와 버튼을 보여준다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정 중일 때 다른 편집 버튼&lt;/b&gt;&lt;br /&gt;처음 버전에서는 비활성화하거나 숨겨도 괜찮다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function renderTodoItem(todo) {

const isEditing = uiState.editingId === todo.id;

if (isEditing) {
return {
mode: &quot;editing&quot;,
inputValue: uiState.editDraft
};
}

return {
mode: &quot;view&quot;,
text: todo.text
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 입력창 값을 uiState.editDraft에서 읽는다는 점입니다. 만약 여기서 todo.text를 그대로 입력창에 넣으면, 사용자가 타이핑해도 다음 렌더링 순간 다시 원래 값이 들어가는 식의 어색한 현상이 생길 수 있습니다. 반대로 모든 항목의 text를 editDraft 기준으로 보여주면 아직 저장하지 않은 값이 목록 전체에 실제값처럼 번질 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;렌더링에서 자주 헷갈리는 기준&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 보여줘야 하나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인 항목 입력창&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;uiState.editDraft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중이 아닌 다른 항목 텍스트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo.text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos에 반영된 새 text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소 후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 todo.text&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;편집 중인 입력창은 초안을 보여주고, 평소 목록은 실제 데이터를 보여줘야 한다. 렌더링이 이 구분을 놓치면 입력감이 바로 이상해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임시 편집값 구조를 붙일 때도 비슷한 실수들이 반복됩니다. 대부분은 실제 데이터와 editDraft의 경계를 다시 흐리게 만드는 쪽에서 나옵니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력할 때마다 todo.text를 직접 바꾼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소가 의미 없어지고 저장 전에도 목록이 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 초안과 실제 데이터를 분리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중에는 editDraft만 바꾸는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;startEdit에서 editDraft를 현재 text로 채우지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창이 비어 있거나 이전 항목 값이 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 시작 시 초기화가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 시작은 원본 text를 초안으로 복사하는 단계라고 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;취소 후 editDraft를 비우지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 항목 편집 때 이전 값이 섞여 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집 종료 상태 정리가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;cancel은 editingId와 editDraft를 함께 정리하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창 값에 todo.text를 그대로 바인딩한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;타이핑 중에 값이 되돌아가거나 깜빡인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링 기준이 초안이 아니라 실제 데이터다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인 입력창은 editDraft를 보여줘야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인데 다른 항목 편집도 바로 연다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어느 입력값이 어느 항목용인지 곧바로 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 번에 하나의 편집 흐름만 두는 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 editingId 하나만 허용하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 뒤에도 editingId를 안 비운다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장했는데도 계속 편집 모드처럼 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정 후 상태 정리가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;수정 기능이 이상하게 느껴질 때는 저장 함수만 볼 게 아니라, &quot;입력 중에는 무엇을 바꾸고 있고 화면은 무엇을 보여주는가&quot;를 같이 봐야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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; 있습니다. editDraft를 따로 들고 가기 시작하면 저장과 취소가 자연스럽게 설명되고, 검색과 정렬과 다른 기능이 수정 중간값 때문에 흔들리는 일도 크게 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 수정 중 값은 실제 데이터가 아니라 임시 초안이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, editingId와 editDraft를 분리해서 들고 가면 편집 시작, 저장, 취소 흐름이 훨씬 분명해진다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 렌더링 역시 이 구분을 따라가야 입력창은 초안을 보여주고 목록은 실제값을 보여줄 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 체크리스트 앱은 단순히 기능이 많은 화면이 아니라, 데이터와 임시 상태를 나눠서 다루는 구조를 갖추기 시작합니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;빈칸 저장, 공백만 입력, 같은 내용 반복 저장 같은 입력 검증을 어디서 처리해야 하는지&lt;/b&gt;, 그리고 왜 검증도 렌더링과 저장 사이에서 역할을 분리해서 보는 편이 좋은지를 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>editDraft</category>
      <category>수정기능구조</category>
      <category>임시편집값</category>
      <category>입력상태관리</category>
      <category>저장전상태</category>
      <category>편집모드</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/35</guid>
      <comments>https://story86025.tistory.com/35#entry35comment</comments>
      <pubDate>Tue, 14 Apr 2026 17:00:35 +0900</pubDate>
    </item>
    <item>
      <title>[기초-5] 막혔을 때 이렇게 물어보세요: 초보자를 위한 AI 디버깅 요청의 기본</title>
      <link>https://story86025.tistory.com/57</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;A_detailed_pixel_art_style_infographic_set_on_the__3c3083a355.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l2ls7/dJMcadnI6t6/T2ftIeLYPDgMjbGRLaaYk1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l2ls7/dJMcadnI6t6/T2ftIeLYPDgMjbGRLaaYk1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l2ls7/dJMcadnI6t6/T2ftIeLYPDgMjbGRLaaYk1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl2ls7%2FdJMcadnI6t6%2FT2ftIeLYPDgMjbGRLaaYk1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;A_detailed_pixel_art_style_infographic_set_on_the__3c3083a355.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 새로 붙일 때보다 더 자주 찾아오는 순간이 있습니다. 화면은 어딘가 이상하고, 버튼은 눌리는 것 같기도 한데 결과가 다르고, 콘솔에는 에러가 뜰 때도 있고 안 뜰 때도 있는 순간입니다. 이때 초보자가 가장 쉽게 하는 말은 보통 비슷합니다. &lt;b&gt;&amp;ldquo;왜 안 돼요?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 그 질문이 틀렸다는 데 있지 않습니다. 너무 자연스러운 질문이기 때문입니다. 다만 디버깅에서는 그 한마디만으로는 범위가 너무 넓습니다. 같은 &amp;ldquo;안 된다&amp;rdquo;는 말 안에도 클릭 이벤트가 안 붙은 경우, 상태값은 바뀌는데 화면만 안 바뀌는 경우, 필터나 검색 같은 조건이 겹치면서 결과가 이상해지는 경우, 아예 다른 파일에서 터지는 경우까지 섞여 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 바로 그 지점을 정리해보겠습니다. 핵심은 디버깅을 잘한다는 게 어려운 용어를 많이 아는 것이 아니라, &lt;b&gt;문제를 재현 가능한 형태로 좁히고, AI가 어디부터 확인해야 할지 따라갈 수 있게 질문을 정리하는 것&lt;/b&gt;이라는 점입니다. 이 감각이 붙기 시작하면, 막힐 때마다 무작정 전체 코드를 다시 받는 흐름에서 조금씩 벗어날 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;내가 보는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;디버깅 질문으로 바꾸면&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 확인할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼이 안 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어느 버튼인지, 클릭 후 무엇이 기대와 다른지, 콘솔 에러가 있는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;선택자, 이벤트 연결, 비활성 상태 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색이 이상하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 필터 상태에서, 어떤 검색어로, 어떤 결과가 보여야 하는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재현 순서와 상태값 흐름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러가 난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 원문, 파일명, 줄 번호, 언제 뜨는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최근에 바꾼 코드와 발생 시점&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;좋은 디버깅 질문은 &amp;ldquo;왜 안 되죠?&amp;rdquo;에서 멈추지 않고, &amp;ldquo;어떤 상태에서 무엇을 했을 때 기대와 다르게 어떻게 보였는가&amp;rdquo;까지 같이 넘기는 질문입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 디버깅 질문이 따로 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 요청과 디버깅 요청은 겉으로 비슷해 보여도 성격이 다릅니다. 기능 요청은 &amp;ldquo;무엇을 만들 것인가&amp;rdquo;를 중심으로 움직입니다. 반면 디버깅 요청은 &lt;b&gt;지금 무엇이 어긋났는가&lt;/b&gt;를 좁혀가는 과정입니다. 그래서 질문 방식도 달라져야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;b&gt;&amp;ldquo;검색 기능 추가해줘&amp;rdquo;&lt;/b&gt;는 만들고 싶은 결과가 중심인 요청입니다. 하지만 &lt;b&gt;&amp;ldquo;검색어를 입력하면 목록이 줄어들어야 하는데 전체 목록이 그대로 보인다&amp;rdquo;&lt;/b&gt;는 디버깅 요청입니다. 앞쪽은 구현 범위를 정하는 말이고, 뒤쪽은 이미 있는 흐름 안에서 어떤 연결이 빠졌는지 찾는 말입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기능 요청은 결과를 앞으로 끌어오는 질문입니다.&lt;/li&gt;
&lt;li&gt;디버깅 요청은 현재 어긋난 지점을 뒤에서 좁혀가는 질문입니다.&lt;/li&gt;
&lt;li&gt;그래서 디버깅에서는 전체 코드보다 재현 순서와 현재 상태가 더 중요할 때가 많습니다.&lt;/li&gt;
&lt;li&gt;무엇을 새로 만들지보다, 어느 부분이 기대와 다르게 움직였는지가 더 중요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 디버깅에서 특히 막히는 이유도 여기 있습니다. 기능을 추가할 때는 어느 정도 감으로도 갈 수 있지만, 디버깅은 감으로 설명하면 범위가 너무 넓어집니다. &amp;ldquo;이상하다&amp;rdquo;, &amp;ldquo;뭔가 꼬였다&amp;rdquo;, &amp;ldquo;분명 방금까지 됐는데 안 된다&amp;rdquo; 같은 표현은 실제로 맞는 말이지만, AI가 바로 다루기에는 아직 정보가 부족합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;디버깅 질문의 목적은 답을 빨리 받는 데만 있지 않습니다. 문제를 작게 잘라서, 내가 지금 무엇부터 확인해야 하는지 보이게 만드는 데 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 어디까지 다룰 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 디버깅 요청의 기본을 다룹니다. 즉, 막혔을 때 AI에게 어떤 정보를 어떤 순서로 넘기면 좋은지, 그리고 질문을 어떻게 좁혀야 하는지를 중심으로 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 다룰 내용은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제를 &amp;ldquo;증상&amp;rdquo;에서 &amp;ldquo;재현 가능한 질문&amp;rdquo;으로 바꾸는 법&lt;/li&gt;
&lt;li&gt;에러가 있을 때와 없을 때 각각 무엇을 보여줘야 하는지&lt;/li&gt;
&lt;li&gt;현재 상태와 재현 순서를 함께 정리하는 법&lt;/li&gt;
&lt;li&gt;AI에게 원인 추정, 확인 순서, 최소 수정안을 요청하는 방법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 글에서는 아래 내용은 깊게 다루지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저 개발자 도구의 세부 기능 설명&lt;/li&gt;
&lt;li&gt;네트워크 요청 분석&lt;/li&gt;
&lt;li&gt;복잡한 프레임워크 전용 디버깅 방식&lt;/li&gt;
&lt;li&gt;서버 로그와 배포 환경 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 단계에서 중요한 것은 디버깅 툴을 많이 아는 일이 아닙니다. &lt;b&gt;지금 보이는 문제를 말로 정리해서 AI가 따라갈 수 있게 만드는 구조&lt;/b&gt;를 익히는 편이 먼저입니다. 그 기본이 잡혀야, 나중에 도구를 더 배워도 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;디버깅 질문을 정리할 때 기본 순서&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;순서&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 적나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 작업과 정상 동작하는 범위&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이미 되는 기능을 AI가 다시 설명하지 않게 해준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 문제를 한두 문장으로 요약&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 질문의 초점을 잡아준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재현 순서&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 문제를 다시 일으키는 방법을 분명히 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기대한 결과와 실제 결과&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 어긋났는지 정확히 보이게 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;5&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;관련 코드와 에러 메시지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추정이 아니라 실제 단서를 보고 답하게 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;6&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원하는 답변 방식&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 재작성 대신 원인 분석과 최소 수정으로 답을 좁힐 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 현재 작업과 정상 동작 범위를 먼저 적는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;할 일 앱에서 추가, 삭제, 완료 체크, 필터까지는 정상 동작하고, 오늘은 검색 기능을 붙이는 중&amp;rdquo;이라고 먼저 써두면 좋습니다. 이 한 줄만 있어도 AI는 이미 동작하는 부분을 처음부터 다시 설명할 가능성이 줄어듭니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 현재 문제를 짧게 요약한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 설명은 길어질수록 좋은 게 아니라, &lt;b&gt;현재 증상을 한 줄로 붙잡아두는 것&lt;/b&gt;이 중요합니다. &amp;ldquo;검색어를 입력해도 목록이 줄어들지 않는다&amp;rdquo;, &amp;ldquo;삭제 후 summary 문구가 갱신되지 않는다&amp;rdquo; 같은 식이면 충분합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 재현 순서를 적는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재현 순서는 디버깅에서 가장 자주 빠지는 정보입니다. 같은 문제를 다시 만들 수 있는 순서를 적어두면, AI도 어떤 흐름에서 상태가 꼬였는지 더 좁혀갈 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[재현 순서]

완료 보기 필터를 누른다

검색창에 &quot;회의&quot;를 입력한다

보이는 항목의 삭제 버튼을 누른다&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 기대한 결과와 실제 결과를 나눠 쓴다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅에서 정말 중요한 포인트입니다. &amp;ldquo;이상하다&amp;rdquo;보다 &lt;b&gt;&amp;ldquo;원래는 이렇게 되어야 하는데 실제로는 이렇게 보인다&amp;rdquo;&lt;/b&gt;가 훨씬 좋습니다. 이 차이가 있어야 AI도 무엇이 빠졌는지 정확히 볼 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 관련 코드와 에러 메시지를 붙인다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 전부 던지는 대신 문제와 연결된 부분을 먼저 붙이는 편이 좋습니다. 그리고 에러가 있다면, 내 말로 요약하지 말고 &lt;b&gt;원문 그대로&lt;/b&gt; 넣는 것이 낫습니다. 줄 번호와 함수 이름은 생각보다 큰 힌트가 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 원하는 답변 방식까지 적는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분도 의외로 중요합니다. 초보자에게는 전체 구조를 싹 다시 짜는 답보다, &lt;b&gt;가장 가능성 큰 원인 몇 개와 확인 순서, 최소 수정 코드&lt;/b&gt;가 더 도움이 되는 경우가 많기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;디버깅 질문은 &amp;ldquo;문제 설명&amp;rdquo;이 아니라 &amp;ldquo;문제 재현 문서&amp;rdquo;에 가깝게 정리할수록 답이 더 좋아집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;할 일 앱 예시로 보는 디버깅 요청&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 예시로 보겠습니다. 가정은 이렇습니다. 검색 기능은 이미 붙였는데, 완료 보기 상태에서 검색 후 항목을 삭제하면 목록은 비는데 summary 문구가 그대로 남아 있는 상황입니다. 초보자라면 보통 &amp;ldquo;삭제는 되는데 뭔가 화면이 이상해요&amp;rdquo;라고 말하기 쉽습니다. 하지만 디버깅 요청으로는 조금 더 정리할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업]

HTML, CSS, JavaScript로 할 일 앱을 만드는 중

추가, 삭제, 완료 체크, 전체 삭제, 필터, 검색 기능까지 들어간 상태

오늘은 검색과 필터가 같이 동작하는 부분을 점검 중

[현재 문제]

완료 보기 상태에서 검색 후 마지막 항목을 삭제하면
목록은 비는데 summary 문구가 바로 갱신되지 않음

[재현 순서]

완료 보기 필터를 누른다

검색창에 &quot;회의&quot;를 입력한다

검색 결과로 보이는 마지막 항목을 삭제한다

[기대한 결과]

목록이 비면 summary가 0개로 바뀌어야 함

빈 상태 메시지도 같이 보여야 함

[실제 결과]

목록은 비어 있는데 summary 문구는 이전 숫자가 남아 있음

빈 상태 메시지도 바로 바뀌지 않음

[에러 메시지]

콘솔 에러 없음

[관련 코드]

updateSummary()

getFilteredTodos()

renderTodos()

createDeleteButton() 안에서 renderTodos를 다시 호출하는 부분

[원하는 답변]

가장 가능성 큰 원인 3개

먼저 어떤 함수 흐름을 확인하면 좋을지

전체 재작성 없이 필요한 수정 포인트만 제안&lt;/code&gt;&lt;/pre&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;까지 같이 줬기 때문입니다. 이렇게 되면 AI도 &amp;ldquo;삭제 기능을 처음부터 다시 만들까?&amp;rdquo;가 아니라, &amp;ldquo;삭제 후 summary 갱신 흐름에서 어떤 업데이트가 빠졌을까?&amp;rdquo;로 사고를 좁히게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 아래처럼 물으면 답이 훨씬 퍼질 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;삭제가 되긴 되는데 뭔가 이상해.

검색 기능 넣은 뒤부터 화면이 잘 안 맞아.
왜 그런지 고쳐줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문장은 틀린 말은 아니지만, 디버깅에는 아직 범위가 너무 넓습니다. 검색이 문제인지, 삭제가 문제인지, 화면 렌더링이 문제인지, summary 업데이트가 문제인지 AI가 다시 넓게 추정해야 하기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI에게는 이렇게 단계별로 물어보면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅 요청은 한 번에 해결책만 받으려 하기보다, &lt;b&gt;원인 추정 &amp;rarr; 확인 순서 &amp;rarr; 최소 수정&lt;/b&gt;으로 나눠서 물어보는 편이 좋습니다. 특히 초보자에게는 이 방식이 훨씬 배우기 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 먼저 원인 후보를 좁혀달라고 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 바로 코드를 다시 써달라고 하지 말고, 어떤 가능성이 큰지 먼저 묻는 방식입니다. 이 단계가 있으면 문제를 보는 눈이 조금씩 붙기 시작합니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;아래 증상을 보고 가장 가능성 큰 원인 3개만 먼저 좁혀줘.

완료 보기 상태에서 검색 후 마지막 항목 삭제

목록은 비지만 summary 숫자는 그대로 남음

콘솔 에러 없음

관련 흐름은 renderTodos, updateSummary, getFilteredTodos야.
전체 수정 코드는 아직 주지 말고,
먼저 어디를 의심하면 좋은지 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 다음에는 확인 순서를 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인 후보를 들은 뒤에는 무엇부터 확인해야 하는지 순서를 요청하면 좋습니다. 디버깅은 특히 순서가 중요합니다. 무작정 코드를 여러 군데 건드리기 시작하면 오히려 더 헷갈릴 수 있기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;좋아. 그럼 이 문제를 초보자 기준으로 어떤 순서로 확인하면 좋은지 알려줘.

어떤 함수부터 볼지

각 함수에서 어떤 값이 달라져야 하는지

콘솔로 찍어보면 좋은 값이 있다면 무엇인지

답변은 체크리스트처럼 짧게 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 그다음 최소 수정 코드만 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인과 확인 순서가 잡히면, 그때 필요한 수정만 받는 편이 좋습니다. 전체를 다시 받는 것보다 지금 코드 위에 이어서 수정할 수 있기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;이제 최소 수정으로 고쳐줘.

조건은 아래와 같아.

현재 구조는 유지할 것

함수 이름은 가능하면 유지할 것

summary 갱신과 빈 상태 메시지 문제만 수정할 것

전체 코드 재작성은 하지 말 것

바뀌는 함수만 보여주고,
왜 그 수정이 필요한지도 한 문단으로 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 마지막에는 테스트 순서까지 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅은 수정으로 끝나지 않습니다. 방금 고친 부분이 실제로 해결됐는지, 그리고 다른 기능은 안 깨졌는지도 같이 확인해야 합니다. 그래서 테스트 순서를 요청하는 습관이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;마지막으로 내가 직접 눌러볼 테스트 순서를 5단계 안으로 정리해줘.

특히 아래를 포함해줘.

완료 보기에서 검색 후 삭제

전체 보기에서 검색 후 삭제

검색어를 지운 뒤 summary가 정상인지

기존 추가, 삭제, 필터 기능이 유지되는지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름의 장점은 분명합니다. AI가 준 답을 한 번에 믿고 통째로 적용하는 방식보다, &lt;b&gt;문제를 좁히고 확인하고 수정하고 다시 점검하는 과정&lt;/b&gt;을 내 쪽에서도 같이 따라갈 수 있게 됩니다. 입문자에게는 이 흐름이 훨씬 중요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;디버깅에서 좋은 질문은 &amp;ldquo;정답 코드&amp;rdquo;를 바로 얻는 질문이 아니라, 원인을 좁히고 수정 범위를 작게 유지하게 도와주는 질문입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수와 디버깅 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅 요청에서 초보자가 자주 하는 실수는 몇 가지 패턴으로 모입니다. 이 부분만 조심해도 AI 답변의 품질이 꽤 달라집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;디버깅 질문에서 자주 하는 실수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 문제가 되나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 바꾸면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;왜 안 돼요?&amp;rdquo;만 적는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;증상 범위가 너무 넓어서 AI가 추정을 많이 하게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재현 순서, 기대 결과, 실제 결과를 같이 적는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러를 내 말로 요약한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;줄 번호, 함수 이름, 정확한 원문 정보가 빠질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 메시지는 복사해서 그대로 붙인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 코드를 무조건 다시 달라고 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제가 더 커지고 기존 맥락도 흐려질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원인 후보, 확인 순서, 최소 수정부터 요청한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;관련 없는 코드까지 한꺼번에 붙인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제와 관계없는 정보가 섞여 초점이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제와 연결된 함수와 요소부터 먼저 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 후 테스트를 안 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;고친 줄 알았는데 다른 기능이 같이 깨질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/li&gt;
&lt;li&gt;문제를 한 줄로 요약했는가&lt;/li&gt;
&lt;li&gt;재현 순서를 적었는가&lt;/li&gt;
&lt;li&gt;기대한 결과와 실제 결과를 나눠 적었는가&lt;/li&gt;
&lt;li&gt;에러가 있으면 원문 그대로 붙였는가&lt;/li&gt;
&lt;li&gt;관련 코드만 먼저 골라서 보여줬는가&lt;/li&gt;
&lt;li&gt;원인 분석, 확인 순서, 최소 수정 순서로 요청했는가&lt;/li&gt;
&lt;li&gt;수정 뒤 테스트 항목도 같이 확인했는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 체크리스트는 거창해 보일 수 있지만, 실제로는 디버깅 질문을 조금 더 선명하게 만들기 위한 최소한의 틀에 가깝습니다. 한두 번 익숙해지기 시작하면, 막혔을 때도 훨씬 덜 당황하게 됩니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편까지 오면, 이 기초 연재의 흐름이 꽤 또렷해집니다. AI에게 어떤 역할로 답하게 할지 정했고, 좋은 요청의 기본 구조를 잡았고, 큰 작업을 작은 단계로 나누는 법을 봤고, 현재 상태를 넘기는 법과 AI가 준 코드를 검토하고 다시 요청하는 법까지 정리했습니다. 그리고 이번 편에서는 그 모든 흐름이 실제로 가장 많이 모이는 지점인 &lt;b&gt;디버깅 질문의 기본&lt;/b&gt;을 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 중요한 것은 AI가 한 번에 완벽한 답을 주느냐가 아닙니다. &lt;b&gt;내가 지금 무엇이 문제인지 좁혀서 묻고, 답을 확인하고, 다시 수정 요청으로 이어갈 수 있느냐&lt;/b&gt;가 더 중요합니다. 이 감각이 생기면, 바이브 코딩은 막연히 운에 기대는 방식이 아니라 점점 다룰 수 있는 작업처럼 보이기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이제는 기초편 다음 단계로 &lt;b&gt;자주 쓰는 프롬프트 템플릿 모음&lt;/b&gt;이나 &lt;b&gt;대화 맥락 관리와 반복 지침 정리법&lt;/b&gt; 같은 확장편으로 찾아뵙겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/57</guid>
      <comments>https://story86025.tistory.com/57#entry57comment</comments>
      <pubDate>Tue, 14 Apr 2026 17:00:01 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 17편: 할 일 앱에 마감일 붙이기</title>
      <link>https://story86025.tistory.com/50</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604051651.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDllRs/dJMcaipkG3G/cN9tDTgR43S0w313uxj47k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDllRs/dJMcaipkG3G/cN9tDTgR43S0w313uxj47k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDllRs/dJMcaipkG3G/cN9tDTgR43S0w313uxj47k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDllRs%2FdJMcaipkG3G%2FcN9tDTgR43S0w313uxj47k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604051651.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위까지 붙이고 나면, 이제 할 일 앱이 꽤 쓸 만해 보이기 시작합니다. 그런데 실제로 조금만 써보면 또 다른 아쉬움이 금방 보입니다. 중요한 일은 구분되는데, &lt;span data-ui=&quot;term&quot; data-note=&quot;할 일을 언제까지 끝내야 하는지를 나타내는 날짜 값입니다.&quot;&gt;마감일&lt;/span&gt;이 없으니 언제까지 의식해야 하는지가 한눈에 안 들어온다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 편에서는 마감일 기능을 붙여보겠습니다. 다만 이번에도 범위를 크게 벌리지는 않겠습니다. 달력 전체를 붙이거나 자동 알림까지 가는 대신, &lt;b&gt;할 일에 날짜 값을 하나 더 저장하고, 그 값이 화면에서도 읽히게 만드는 흐름&lt;/b&gt;에 집중하겠습니다. 지금 단계에서는 이 정도가 가장 좋습니다. 새 속성 하나가 데이터에 들어오고, 그 값이 입력, 저장, 수정, 렌더링 전체에 연결되는 경험을 하기 딱 좋기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 새 할 일을 추가할 때 마감일을 같이 고를 수 있게 만들고, 목록에서도 그 날짜가 &lt;span data-ui=&quot;term&quot; data-note=&quot;짧은 상태 정보를 눈에 띄게 보여주는 작은 라벨 형태의 UI입니다.&quot;&gt;배지&lt;/span&gt;처럼 보이게 정리하겠습니다. 그리고 이미 저장돼 있던 이전 데이터에는 마감일 값이 없을 수 있으니, 그때는 빈 값으로 정리해서 앱이 깨지지 않게 처리하겠습니다. 여기에 더해 오늘 날짜를 지난 미완료 항목은 &lt;span data-ui=&quot;term&quot; data-note=&quot;현재 날짜를 지났지만 아직 완료되지 않은 상태입니다.&quot;&gt;지연&lt;/span&gt; 상태로 따로 눈에 띄게 보여주겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가 폼에 날짜 입력칸이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo 객체에 dueDate 값이 하나 더 들어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 text, done, priority에 이어 dueDate까지 함께 저장되는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에 마감일 배지가 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;날짜가 있으면 표시하고, 지난 날짜면 지연 상태도 같이 보여준다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장된 날짜 값이 렌더링 단계에서 어떻게 UI로 바뀌는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 데이터도 그대로 열린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dueDate가 없는 이전 항목에는 빈 값을 붙여서 정리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불러오는 단계에서 데이터를 한 번 &lt;span data-ui=&quot;term&quot; data-note=&quot;들어온 값을 현재 구조에 맞게 정리하는 과정입니다.&quot;&gt;정규화&lt;/span&gt;하는 흐름을 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;마감일 기능의 핵심은 날짜 입력칸을 하나 더 넣는 일이 아니라, &lt;b&gt;할 일 데이터에 dueDate를 추가하고 그 상태를 화면에서도 읽히게 만드는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;이때 마감일은 꽤 좋은 다음 단계입니다. 우선순위가 &quot;얼마나 중요한가&quot;를 보여준다면, 마감일은 &quot;언제까지 의식해야 하는가&quot;를 보여줍니다. 이 둘이 같이 들어오면 목록을 보는 방식이 훨씬 입체적으로 바뀝니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 우선순위라도 무엇을 먼저 봐야 할지 더 빨리 판단할 수 있습니다.&lt;/li&gt;
&lt;li&gt;데이터 구조에 속성 하나를 더 추가하는 연습이 됩니다.&lt;/li&gt;
&lt;li&gt;기존 기능을 유지한 채 상태 하나를 더 얹는 감각을 익히기 좋습니다.&lt;/li&gt;
&lt;li&gt;검색, 수정, 저장 흐름이 새 속성과 어떻게 연결되는지도 같이 볼 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;마감일 기능은 단순한 보조 정보가 아니라, &lt;b&gt;목록을 읽는 기준을 하나 더 늘리는 작업&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;목록에 마감일 배지를 표시합니다.&lt;/li&gt;
&lt;li&gt;수정 모드에서도 마감일을 바꿀 수 있게 만듭니다.&lt;/li&gt;
&lt;li&gt;오늘 날짜를 지난 미완료 항목에는 지연 배지를 추가합니다.&lt;/li&gt;
&lt;li&gt;이전 localStorage 데이터에는 빈 마감일 값을 자동으로 붙입니다.&lt;/li&gt;
&lt;li&gt;요약 문구에 지연된 항목 수를 같이 보여줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마감일 기준 자동 정렬&lt;/li&gt;
&lt;li&gt;달력 전체 화면&lt;/li&gt;
&lt;li&gt;브라우저 알림이나 푸시 알림&lt;/li&gt;
&lt;li&gt;마감일 전용 필터 버튼&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 것이 흐려지지 않습니다. 지금 중요한 건 &quot;날짜를 예쁘게 보여주는 것&quot;보다, &lt;b&gt;새로운 속성 하나가 저장, 수정, 렌더링 전체에 어떻게 연결되는지&lt;/b&gt;를 따라가는 데 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 마감일이 왜 우선순위와 다른 기준인지 먼저 느껴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 아주 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 할 일이 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 운동하기       - 보통 - 2026-03-30
2. 회의 자료 정리  - 높음 - 2026-03-25
3. 책 반납하기     - 낮음 - 2026-03-20&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서는 우선순위와 마감일이 서로 다른 역할을 합니다. 2번은 중요도가 높고, 3번은 중요도는 낮지만 날짜가 더 급할 수 있습니다. 즉, 마감일은 우선순위를 대체하는 값이 아니라, &lt;b&gt;같은 목록을 읽는 또 다른 기준&lt;/b&gt;을 하나 더 붙이는 역할에 가깝습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;마감일이 들어오면 달라지는 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 필요한 값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가할 때 날짜를 고른다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo에 dueDate 속성이 저장돼야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에서 날짜 배지가 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링할 때 dueDate 값도 함께 읽어야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지난 날짜는 따로 눈에 띈다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 날짜와 비교해서 지연 상태를 판단해야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 기능은 단순히 date 입력창을 하나 더 만드는 작업이 아닙니다. &lt;b&gt;todo 데이터 구조가 조금 넓어지고, 그 변화가 저장과 화면에 같이 반영되는 경험&lt;/b&gt;이라고 보는 편이 훨씬 정확합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;마감일 기능은 &quot;날짜를 보여준다&quot;보다 &lt;b&gt;할 일 하나가 가지는 정보가 또 한 단계 넓어진다&lt;/b&gt;고 이해하는 편이 훨씬 쉽습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 입력 폼에 날짜 입력칸 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 크게 바꾸는 작업이 아닙니다. 입력 폼 안에 날짜 입력칸 하나를 더 넣고, 나중에 목록 한 줄을 그릴 때 이 값을 같이 보여주게 만들면 충분합니다. 즉, 화면 구조는 거의 유지하면서 데이터 구조만 한 단계 넓어지는 방식이라고 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Due Date Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          할 일마다 마감일을 붙여서 더 구체적으로 정리하는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 마감일 기능 점검하기&quot;
            &amp;gt;
            &amp;lt;select
              id=&quot;priorityInput&quot;
              class=&quot;priority-select&quot;
              aria-label=&quot;우선순위 선택&quot;
            &amp;gt;
              &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
              &amp;lt;option
                value=&quot;medium&quot;
                selected
              &amp;gt;
                보통
              &amp;lt;/option&amp;gt;
              &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
            &amp;lt;/select&amp;gt;
            &amp;lt;input
              id=&quot;dueDateInput&quot;
              type=&quot;date&quot;
              class=&quot;due-date-input&quot;
              aria-label=&quot;마감일 선택&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개 &amp;middot; 높은 우선순위 0개 &amp;middot; 지연 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;search-section&quot;&amp;gt;
        &amp;lt;label for=&quot;searchInput&quot;&amp;gt;검색&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;search-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;searchInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 수정, 배포, 회의&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;clearSearchButton&quot;
            type=&quot;button&quot;
            class=&quot;secondary-button&quot;
          &amp;gt;
            검색 지우기
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;dueDateInput&lt;/b&gt;이 추가됐다는 점입니다. 그런데 실제로는 이 한 줄이 꽤 많은 걸 바꿉니다. 이제 새 항목은 text, done, priority에 이어 dueDate까지 같이 가지게 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 점은 구조가 크게 흔들리지 않는다는 것입니다. 기존 입력 흐름에 선택창 하나, 날짜 입력칸 하나만 자연스럽게 얹는 방식이라서 초보자도 따라가기 훨씬 편합니다. 이런 식으로 구조를 유지한 채 데이터만 넓어지는 경험이 몇 번 쌓이면 이후 기능 추가도 훨씬 덜 막막해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML에서는 입력칸이 하나 늘어난 것처럼 보여도, 실제로는 todo 데이터 구조가 dueDate까지 포함하는 형태로 넓어지는 시작점입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 마감일 배지와 지연 상태를 읽기 쉽게 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 두 가지입니다. 하나는 입력 폼 안에 들어온 날짜 입력칸이 기존 입력창과 어색하지 않게 보이도록 만드는 것이고, 다른 하나는 목록에서 마감일과 지연 상태가 눈에 쉽게 들어오게 정리하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row,
.search-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
input[type=&quot;date&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select,
.due-date-input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

.priority-select {
  min-width: 108px;
}

.due-date-input {
  min-width: 150px;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section,
.search-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge,
.due-date-badge,
.overdue-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.due-date-badge {
  background: #e5e7eb;
  color: #374151;
}

.overdue-badge {
  background: #111827;
  color: #ffffff;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-fields {
  display: flex;
  gap: 10px;
  align-items: center;
}

.edit-input {
  flex: 1;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.edit-priority-select,
.edit-date-input {
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  background: #ffffff;
  color: #111827;
  font: inherit;
}

.edit-priority-select {
  min-width: 96px;
}

.edit-date-input {
  min-width: 150px;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done .todo-text {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row,
  .search-row,
  .edit-fields {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .due-date-input,
  .edit-priority-select,
  .edit-date-input {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 특히 눈여겨볼 부분은 세 가지입니다.&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;due-date-input&lt;/b&gt;입니다. 추가 폼 안에서 날짜 입력칸이 따로 튀지 않도록 기존 입력 흐름에 맞췄습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;due-date-badge&lt;/b&gt;입니다. 날짜가 있는 항목은 텍스트 옆에 마감일이 자연스럽게 보이도록 했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;overdue-badge&lt;/b&gt;입니다. 마감일이 지났고 아직 안 끝난 항목은 한 번 더 눈에 들어오게 만들었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 달력을 화려하게 꾸미는 게 아닙니다. 높음, 보통, 낮음처럼 우선순위가 눈에 들어오듯이, 마감일도 상태 정보로 자연스럽게 읽히게 만드는 쪽이 훨씬 중요합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;마감일 UI는 꾸밈보다 &lt;b&gt;날짜가 있는 항목과 이미 늦어진 항목이 한눈에 구분되게&lt;/b&gt; 만드는 것이 핵심입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: dueDate 저장과 이전 데이터 정리 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 마감일 기능은 결국 &lt;b&gt;todo 객체에 dueDate를 추가하고, 그 값이 저장과 수정과 렌더링에 모두 연결되게 만드는 작업&lt;/b&gt;이기 때문입니다. 특히 이번에는 한 가지를 꼭 봐야 합니다. 기존 localStorage 데이터에는 dueDate가 없을 수 있기 때문에, 불러올 때 한 번 정리해주는 흐름이 필요합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;normalizeDueDate&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dueDate 값이 이상하거나 비어 있을 때 현재 구조에 맞게 정리합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 저장 데이터가 있어도 앱이 깨지지 않게 만듭니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;normalizeTodo&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불러온 항목을 현재 구조에 맞게 정리합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;priority에 이어 dueDate까지 포함한 현재 구조를 안정적으로 유지합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isOverdueTodo&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마감일이 지났고 아직 안 끝난 항목인지 판단합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지연 상태를 요약과 배지에 함께 연결합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createDueDateBadge&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dueDate 값을 목록에서 읽기 쉬운 배지로 바꿔줍니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;날짜 값이 단순 문자열이 아니라 실제 UI 정보로 바뀌는 지점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  dueDateInput: document.querySelector(&quot;#dueDateInput&quot;),
  searchInput: document.querySelector(&quot;#searchInput&quot;),
  clearSearchButton: document.querySelector(&quot;#clearSearchButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let currentSearchQuery = &quot;&quot;;
let editingTodoId = null;
let editingText = &quot;&quot;;
let editingPriority = PRIORITY_TYPES.MEDIUM;
let editingDueDate = &quot;&quot;;

function normalizeText(value) {
  return String(value || &quot;&quot;)
    .trim()
    .toLowerCase();
}

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeDueDate(value) {
  const nextValue = String(value || &quot;&quot;).trim();

  if (nextValue === &quot;&quot;) {
    return &quot;&quot;;
  }

  const datePattern = /^\d{4}-\d{2}-\d{2}$/;

  return datePattern.test(nextValue)
    ? nextValue
    : &quot;&quot;;
}

function normalizeTodo(todo) {
  return {
    id: todo.id,
    text: todo.text,
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority),
    dueDate: normalizeDueDate(todo.dueDate)
  };
}

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    if (!savedValue) {
      return [];
    }

    const parsedValue = JSON.parse(savedValue);

    if (!Array.isArray(parsedValue)) {
      return [];
    }

    return parsedValue.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getTodayDateString() {
  const today = new Date();
  const year = String(today.getFullYear());
  const month = String(today.getMonth() + 1).padStart(2, &quot;0&quot;);
  const day = String(today.getDate()).padStart(2, &quot;0&quot;);

  return year + &quot;-&quot; + month + &quot;-&quot; + day;
}

function isOverdueTodo(todo) {
  return (
    !todo.done &amp;amp;&amp;amp;
    todo.dueDate !== &quot;&quot; &amp;amp;&amp;amp;
    todo.dueDate &amp;lt; getTodayDateString()
  );
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getHighPriorityRemainingCount() {
  return todos.filter(function (todo) {
    return (
      !todo.done &amp;amp;&amp;amp;
      todo.priority === PRIORITY_TYPES.HIGH
    );
  }).length;
}

function getOverdueCount() {
  return todos.filter(function (todo) {
    return isOverdueTodo(todo);
  }).length;
}

function getFilteredTodos() {
  let result = todos;

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    result = result.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    result = result.filter(function (todo) {
      return todo.done;
    });
  }

  if (currentSearchQuery !== &quot;&quot;) {
    result = result.filter(function (todo) {
      return normalizeText(todo.text).includes(
        currentSearchQuery
      );
    });
  }

  return result;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
  elements.priorityInput.disabled = isEditing;
  elements.dueDateInput.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();
  const highPriorityRemainingCount =
    getHighPriorityRemainingCount();
  const overdueCount = getOverdueCount();
  const visibleCount = getFilteredTodos().length;

  if (currentSearchQuery !== &quot;&quot;) {
    elements.summary.textContent =
      &quot;검색 결과 &quot; +
      visibleCount +
      &quot;개 &amp;middot; 전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개 &amp;middot; 지연 &quot; +
      overdueCount +
      &quot;개&quot;;
  } else {
    elements.summary.textContent =
      &quot;전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개 &amp;middot; 지연 &quot; +
      overdueCount +
      &quot;개&quot;;
  }

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;

  elements.clearSearchButton.disabled =
    currentSearchQuery === &quot;&quot;;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function formatDueDateLabel(dueDate) {
  if (!dueDate) {
    return &quot;&quot;;
  }

  return &quot;마감 &quot; + dueDate.replace(/-/g, &quot;.&quot;);
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일을 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  if (getOverdueCount() &amp;gt; 0) {
    return &quot;지연된 할 일을 먼저 확인해보세요.&quot;;
  }

  return &quot;우선순위와 마감일을 같이 보면서 정리해보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;
  editingPriority = normalizePriority(todo.priority);
  editingDueDate = normalizeDueDate(todo.dueDate);

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();
  const nextPriority = normalizePriority(
    editingPriority
  );
  const nextDueDate = normalizeDueDate(
    editingDueDate
  );

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done,
        priority: nextPriority,
        dueDate: nextDueDate
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &amp;amp;&amp;amp;
    currentSearchQuery === &quot;&quot; &amp;amp;&amp;amp;
    editingTodoId === null
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage(
      &quot;순서 변경은 전체 보기에서, 검색 중이 아닐 때만 할 수 있습니다.&quot;
    );
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === &quot;up&quot;
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex &amp;lt; 0 ||
    targetIndex &amp;gt;= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === &quot;up&quot;
      ? &quot;할 일을 위로 옮겼습니다.&quot;
      : &quot;할 일을 아래로 옮겼습니다.&quot;
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createDueDateBadge(todo) {
  if (!todo.dueDate) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;due-date-badge&quot;;
  badge.textContent = formatDueDateLabel(todo.dueDate);

  return badge;
}

function createOverdueBadge(todo) {
  if (!isOverdueTodo(todo)) {
    return null;
  }

  const badge = document.createElement(&quot;span&quot;);

  badge.className = &quot;overdue-badge&quot;;
  badge.textContent = &quot;지연&quot;;

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  return text;
}

function createTodoContent(todo) {
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);
  const dueDateBadge = createDueDateBadge(todo);
  const overdueBadge = createOverdueBadge(todo);

  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;

  meta.append(
    createPriorityBadge(todo)
  );

  if (dueDateBadge) {
    meta.append(dueDateBadge);
  }

  if (overdueBadge) {
    meta.append(overdueBadge);
  }

  content.append(
    meta,
    createTodoText(todo)
  );

  return content;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;위로&quot;,
    &quot;move-up-button&quot;,
    function () {
      moveTodo(todoId, &quot;up&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;아래로&quot;,
    &quot;move-down-button&quot;,
    function () {
      moveTodo(todoId, &quot;down&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoContent(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditPrioritySelect() {
  const select = document.createElement(&quot;select&quot;);

  select.className = &quot;edit-priority-select&quot;;

  [
    PRIORITY_TYPES.HIGH,
    PRIORITY_TYPES.MEDIUM,
    PRIORITY_TYPES.LOW
  ].forEach(function (priority) {
    const option = document.createElement(&quot;option&quot;);

    option.value = priority;
    option.textContent = getPriorityLabel(priority);

    select.appendChild(option);
  });

  select.value = editingPriority;

  select.addEventListener(&quot;change&quot;, function () {
    editingPriority = select.value;
  });

  return select;
}

function createEditDateInput() {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;date&quot;;
  input.className = &quot;edit-date-input&quot;;
  input.value = editingDueDate;

  input.addEventListener(&quot;input&quot;, function () {
    editingDueDate = input.value;
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const editFields = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  editFields.className = &quot;edit-fields&quot;;
  actions.className = &quot;todo-actions&quot;;

  editFields.append(
    createEditInput(todo.id),
    createEditPrioritySelect(),
    createEditDateInput()
  );

  editArea.append(editFields);

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText, priority, dueDate) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false,
    priority: normalizePriority(priority),
    dueDate: normalizeDueDate(dueDate)
  });

  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  elements.dueDateInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  elements.dueDateInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSearchInput() {
  currentSearchQuery = normalizeText(
    elements.searchInput.value
  );

  if (editingTodoId !== null) {
    editingTodoId = null;
    editingText = &quot;&quot;;
    editingPriority = PRIORITY_TYPES.MEDIUM;
    editingDueDate = &quot;&quot;;
  }

  renderTodos();
}

function clearSearch() {
  if (currentSearchQuery === &quot;&quot;) {
    return;
  }

  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  editingDueDate = &quot;&quot;;
  renderTodos(&quot;검색어를 지웠습니다.&quot;);
  elements.searchInput.focus();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;
  const dueDate = elements.dueDateInput.value;

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text, priority, dueDate);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindSearchEvents() {
  elements.searchInput.addEventListener(
    &quot;input&quot;,
    handleSearchInput
  );

  elements.clearSearchButton.addEventListener(
    &quot;click&quot;,
    clearSearch
  );
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
  bindSearchEvents();
}

bindEvents();
renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 코드에서 특히 중요한 부분은 이전 데이터 처리입니다. 이미 localStorage 안에 저장돼 있던 todo들은 dueDate가 없는 상태일 수 있습니다. 이런 경우를 무시하면 새 코드는 돌아가도 예전 데이터에서 빈 값 처리나 배지 생성 단계가 흔들릴 수 있습니다. 그래서 불러오는 순간 한 번 정리해두는 쪽이 훨씬 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 작은 앱에서도 꽤 중요한 감각입니다. 데이터 구조가 넓어질 때마다 &quot;새로 만든 항목만 잘 되면 된다&quot;가 아니라, &lt;b&gt;이미 저장돼 있던 항목도 현재 구조로 다시 읽을 수 있어야 한다&lt;/b&gt;는 기준이 생기기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이번 편에서는 마감일이 지나고 아직 끝나지 않은 항목을 따로 지연으로 표시했습니다. 이 기능이 좋은 이유는 단순합니다. 마감일이 있어도 눈으로 잘 안 보이면 실제 사용감은 크지 않기 때문입니다. 반대로 지연 상태가 따로 보이면 목록을 보는 기준이 훨씬 또렷해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 날짜 입력칸을 추가하는 작업이 아니라, &lt;b&gt;todo 데이터에 dueDate를 넣고 이전 저장 데이터까지 현재 구조에 맞게 정리하는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&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;/li&gt;
&lt;li&gt;목록에 마감일 배지가 정상적으로 보이는가&lt;/li&gt;
&lt;li&gt;수정 모드에서도 마감일을 바꿀 수 있는가&lt;/li&gt;
&lt;li&gt;오늘 이전 날짜이면서 미완료인 항목에만 지연 배지가 보이는가&lt;/li&gt;
&lt;li&gt;요약 문구에 지연 개수가 같이 반영되는가&lt;/li&gt;
&lt;li&gt;검색, 필터, 수정, 삭제, 순서 변경, 우선순위가 마감일 기능 추가 뒤에도 그대로 동작하는가&lt;/li&gt;
&lt;li&gt;이전에 저장돼 있던 항목이 있어도 앱이 깨지지 않고 빈 마감일 상태로 열리는가&lt;/li&gt;
&lt;li&gt;새로고침 뒤에도 dueDate 값이 그대로 유지되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;날짜가 보이느냐&quot;보다, &lt;b&gt;새로 추가된 dueDate 값이 저장부터 수정, 렌더링, 요약까지 일관되게 이어지는지&lt;/b&gt;를 끝까지 확인하는 것입니다. 이 감각이 있어야 다음에 데이터 구조가 더 넓어져도 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;마감일 기능은 입력창 하나보다 &lt;b&gt;날짜 값이 저장되고 다시 읽히고 상태로도 해석되는지&lt;/b&gt;를 직접 눌러보며 확인하는 게 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마감일 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 추가 폼, 수정 모드, 렌더링, 저장 데이터 구조까지 같이 바뀌기 때문에 범위를 더 분명하게 잘라야 합니다. &quot;마감일 넣어줘&quot;라고만 던지면 AI가 자동 정렬이나 마감일 필터까지 넓게 흔들 가능성이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 검색, 순서 변경, 우선순위, 저장 기능까지 정상 동작하는 상태야.
이번에는 마감일 기능만 추가하고 싶어.
새 할 일을 만들 때 날짜를 같이 넣을 수 있고,
목록에서도 그 마감일이 배지처럼 보이게 해줘.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 todo 구조에 dueDate 속성만 추가하고 싶어.
추가할 때도 dueDate를 저장하고,
수정 모드에서도 dueDate를 바꿀 수 있게 해줘.
예전에 저장된 데이터에는 dueDate가 없을 수도 있으니
load 단계에서 기본값을 붙이는 방식으로 처리해줘.
날짜 관련 정규화 함수와 지연 판단 함수부터 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
마감일 선택칸과 마감일 배지를 추가할 거야.
기존 디자인을 크게 바꾸지 말고,
우선순위 배지와 자연스럽게 같이 보이게 정리해줘.
마감일이 지난 미완료 항목은 지연 상태가
눈에 조금 더 띄게 보이도록 해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 자동 정렬이나 달력 뷰처럼 범위를 넘어가는 기능을 섞을 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 복잡한 문장보다 &lt;b&gt;무엇은 유지하고, 이번에는 어디까지만 바꿀지 같이 적는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 마감일 기능을 맡길 때는 &quot;날짜 넣어줘&quot;보다 &lt;b&gt;자동 정렬은 제외, 기존 기능 유지, 이전 데이터도 깨지지 않게&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 아주 크지 않습니다. 입력 폼에 날짜 입력칸 하나가 더 생겼고, 목록에는 마감일 배지와 지연 상태가 붙었을 뿐입니다. 그런데 실제로 앱을 다시 보면 체감이 꽤 달라집니다. 이제는 단순히 &quot;무엇을 해야 하는가&quot;만 보는 게 아니라, &lt;b&gt;언제까지 의식해야 하는지도 같이 읽을 수 있게&lt;/b&gt; 되었기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 기능은 데이터 구조를 한 단계 더 넓히는 연습으로도 중요합니다. 지금까지는 text, done, priority 정도로 보던 항목이 이제는 dueDate까지 함께 갖게 되었고, 그 값이 저장과 수정과 화면 표시 전체를 같이 움직이게 됩니다. 이 경험이 한 번 있으면, 이후에 태그나 반복 주기 같은 속성이 추가될 때도 훨씬 덜 낯설게 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위와 마감일이 같이 보이기 시작하면 목록을 보는 감각 자체가 조금 달라집니다. 해야 할 일을 적는 수준에서, 어느 일을 먼저 의식해야 하고 무엇이 이미 밀리고 있는지도 같이 정리하는 쪽으로 한 단계 더 가까워졌기 때문입니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>ToDoApp</category>
      <category>VSCode</category>
      <category>마감일</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/50</guid>
      <comments>https://story86025.tistory.com/50#entry50comment</comments>
      <pubDate>Fri, 10 Apr 2026 18:00:15 +0900</pubDate>
    </item>
    <item>
      <title>18. 드래그 중에 수정 버튼 누르면 왜 꼬일까: 바이브코딩 상태 충돌 정리</title>
      <link>https://story86025.tistory.com/34</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크리스트 앱을 처음 만들 때는 대개 이런 순서로 기능이 붙습니다. 항목 추가, 삭제, 완료 체크, 저장, 정렬, 재정렬. 여기까지 오면 화면은 꽤 그럴듯합니다. 그런데 바로 그 지점에서 새로운 문제가 생깁니다. &lt;b&gt;기능 하나하나는 잘 되는데, 같이 놓는 순간 서로 충돌하기 시작하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 드래그로 순서를 바꾸는 중에 수정 버튼이 눌리거나, 수정 입력창이 열린 상태에서 완료 체크가 바뀌거나, 삭제 확인창이 떠 있는데 또 다른 항목 정렬이 시작되는 식입니다. 처음에는 이런 일이 전부 &amp;ldquo;작은 버그&amp;rdquo;처럼 보입니다. 하지만 조금 더 정확히 보면, 이건 개별 기능 문제가 아니라 &lt;b&gt;상태를 어떻게 관리할지에 대한 규칙이 아직 없기 때문&lt;/b&gt;인 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 재정렬, 수정, 완료 체크, 삭제 버튼이 한 화면에 함께 있을 때 왜 갑자기 코드가 흔들리기 시작하는지, 무엇을 먼저 잠가야 하고 무엇은 같이 둬도 되는지, 그리고 초보자 기준에서는 어떤 순서로 규칙을 세우는 편이 가장 덜 꼬이는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 중에 수정 버튼이 같이 눌린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 모드와 수정 모드를 동시에 허용하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 번에 하나의 주요 모드만 열어두는 규칙부터 세운다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인데 완료 체크도 바뀌고 삭제도 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중 상태를 다른 클릭 동작이 침범하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇을 잠글지 우선순위를 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드를 고칠 때마다 여기저기 같이 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태가 여러 군데 흩어져 있고 기준이 하나가 아니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 전체 상태를 한곳에 모은다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 버튼은 눌려야 하고 어떤 버튼은 막혀야 하는지 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 모드를 기준으로 UI를 다시 그리는 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링을 상태 기준으로 다시 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;기능이 많아질수록 중요한 건 기능 개수보다 상태 규칙이다. 무엇이 동시에 가능하고, 무엇은 잠깐 막혀야 하는지 먼저 정해야 코드가 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기능이 늘수록 왜 서로 충돌할까&lt;/li&gt;
&lt;li&gt;먼저 구분해야 할 두 종류의 상태&lt;/li&gt;
&lt;li&gt;처음엔 한 번에 하나의 주요 모드만 허용하는 편이 낫다&lt;/li&gt;
&lt;li&gt;화면 전체 상태를 한곳에 모으는 법&lt;/li&gt;
&lt;li&gt;이벤트 핸들러는 실행 전에 먼저 물어봐야 한다&lt;/li&gt;
&lt;li&gt;렌더링은 현재 상태를 기준으로 다시 보여줘야 한다&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;개별 기능은 단순해도 같이 놓으면 달라지는 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;기능 하나만 볼 때&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;여러 기능이 같이 있을 때 생기는 질문&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제는 항목 하나를 지우면 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인 항목도 삭제를 허용할 것인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 체크는 done 값만 바꾸면 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬 중에도 체크를 눌러도 되는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정은 입력창을 열고 저장하면 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중에 다른 항목 편집을 또 열 수 있는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬은 order만 바꾸면 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 입력창이 열린 상태에서 순서 이동을 허용할 것인가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 상태 충돌은 갑자기 생기는 예외가 아니라 기능이 늘어나면 자연스럽게 생기는 단계입니다. 그래서 중요한 건 &amp;ldquo;모든 걸 동시에 되게 한다&amp;rdquo;가 아니라, &lt;b&gt;처음엔 어떤 조합을 일부러 막을지 결정하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;한 기능만 볼 때는 정답이 쉬운데, 여러 기능이 만나는 순간부터는 &amp;ldquo;무엇을 허용하고 무엇을 잠글까&amp;rdquo;가 핵심이 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;먼저 구분해야 할 두 종류의 상태&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 상태 관리에서 가장 먼저 헷갈리는 부분은, 서로 다른 종류의 상태를 한 덩어리로 보는 것입니다. 예를 들어 완료 여부인 done과 수정 중인지 아닌지인 editingId는 모두&amp;nbsp; 상태 라고 부를 수 있지만, 성격은 꽤 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 구분하면 좋은 것은 아래 두 가지입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음부터 구분해두면 좋은 두 가지 상태&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;text, done, order&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 대상이고, 앱을 다시 열어도 유지되어야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 상호작용 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSorting, editingId, confirmDeleteId, isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 화면이 어떤 모드에 있는지를 설명한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구분이 필요한 이유는 단순합니다. done은 항목의 실제 상태라서 저장해야 합니다. 반면 editingId는 지금 어느 항목이 수정 중인지에 대한 임시 정보라서, 화면을 닫으면 굳이 남아 있을 필요가 없습니다. 둘 다 상태지만, 같은 방식으로 다루면 오히려 헷갈리기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 재정렬, 수정, 삭제 확인은 대개 &lt;b&gt;화면 상호작용 상태&lt;/b&gt;에 가깝습니다. 지금 무엇을 하고 있는지 설명하는 값이지, 항목의 영구적인 데이터는 아닙니다. 반대로 완료 체크나 순서 값은 실제 데이터입니다. 이 구분이 안 되면 &amp;ldquo;왜 저장해야 하지?&amp;rdquo;, &amp;ldquo;왜 저장하면 안 되지?&amp;rdquo; 같은 판단이 흐려집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;done은 항목이 가진 사실이고, 수정 중인지 정렬 중인지는 지금 화면이 어떤 모드인지에 가깝다. 둘을 같은 층위로 보면 곧바로 꼬이기 시작한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음엔 막는 편이 좋은 조합&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;조합&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음엔 막는 편이 좋은 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬 중 + 수정 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창이 열리면서 순서 이동 흐름을 동시에 추적해야 해 갑자기 복잡해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중 + 다른 항목 삭제 확인&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력값 보존과 삭제 대상 판단이 동시에 섞인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 확인 중 + 재정렬 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 현재 우선인지 UI가 불분명해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 + 다른 동작 연속 실행&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 저장이나 상태 꼬임이 생길 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 같이 둬도 되는 것도 있습니다. 예를 들어 done은 데이터 상태라서, 수정 모드가 아니라면 평소처럼 토글할 수 있습니다. 검색어 입력이나 필터 선택도 경우에 따라 같이 둘 수 있습니다. 다만 재정렬과 사용자 순서 변경처럼 화면 구조를 직접 흔드는 동작은 처음엔 더 보수적으로 잠그는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙은 사용자 경험에도 도움이 됩니다. 지금 내가 &lt;b&gt;편집 중 인지, 정렬 중 인지, 삭제를 확인 중&lt;/b&gt; 인지가 한눈에 분명해지기 때문입니다. 모드가 너무 많이 겹치면 사용자도 헷갈리고, 개발자도 헷갈립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자에게 가장 현실적인 원칙&lt;/b&gt;&lt;br /&gt;모든 조합을 다 허용하려 하지 말고, 먼저 &amp;ldquo;한 번에 하나의 주요 모드&amp;rdquo;만 열어두는 구조부터 안정화하는 편이 훨씬 낫다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;화면 전체 상태를 한곳에 모으는 법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제로 상태를 어디에 둘지 보겠습니다. 초보자 단계에서 가장 많이 흔들리는 구조는 상태가 여러 함수 안에 제각각 흩어져 있는 경우입니다. 어느 함수에서는 editingId를 따로 기억하고, 어느 함수에서는 isSorting만 따로 들고 있고, 삭제 확인 대상은 또 다른 변수에 있는 식입니다. 이렇게 되면 기능이 하나 늘어날 때마다 &amp;ldquo;지금 무엇이 이미 열려 있지?&amp;rdquo;를 추적하기가 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 화면 상호작용 상태는 가능하면 한군데 모아두는 편이 좋습니다. 예를 들면 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let uiState = {

isSorting: false,
draggedId: null,
targetId: null,
position: null,
editingId: null,
confirmDeleteId: null,
isSaving: false
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 좋은 이유는 눈에 보입니다. 지금 정렬 중인지, 어떤 항목이 수정 중인지, 어떤 항목이 삭제 확인 상태인지가 전부 한곳에 있습니다. 그래서 이벤트 핸들러가 동작하기 전에 &amp;ldquo;지금 이 행동을 받아도 되는가&amp;rdquo;를 묻기가 훨씬 쉬워집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;한곳에 모아두면 보기 쉬운 화면 상태&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태 이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무슨 뜻인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 따로 필요할까&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSorting&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 재정렬 흐름 안에 있는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 중에는 다른 클릭 동작을 잠글 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중인 항목의 id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 화면에서 편집 입력창을 하나만 열어두기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;confirmDeleteId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 확인을 받고 있는 항목의 id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 확인 UI를 다른 동작과 분리해서 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 처리 중인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 클릭이나 연속 저장을 막는 기준이 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 이 상태 객체가 &amp;ldquo;모든 걸 다 넣는 저장소&amp;rdquo;가 아니라는 점입니다. text, done, order 같은 실제 데이터는 여전히 todos 안에 있어야 합니다. uiState는 지금 화면이 어떤 모드인지 설명하는 역할만 맡기면 됩니다. 이 선을 지켜야 나중에 읽을 때도 덜 헷갈립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;데이터는 todos에 두고, 화면 상호작용 상태는 uiState 같은 한곳에 모아두는 편이 훨씬 읽기 쉽고 디버깅도 편하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;초보자에게는 아래처럼 &amp;ldquo;가능한가&amp;rdquo;를 묻는 작은 함수부터 두는 방식이 꽤 도움이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function canStartSorting() {

if (uiState.isSaving) return false;
if (uiState.editingId !== null) return false;
if (uiState.confirmDeleteId !== null) return false;
return true;
}

function canStartEditing(todoId) {
if (uiState.isSaving) return false;
if (uiState.isSorting) return false;
if (uiState.confirmDeleteId !== null) return false;
if (uiState.editingId !== null &amp;amp;&amp;amp; uiState.editingId !== todoId) return false;
return true;
}

function canToggleDone() {
if (uiState.isSaving) return false;
if (uiState.isSorting) return false;
if (uiState.editingId !== null) return false;
return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 실제 이벤트 핸들러는 훨씬 또렷해집니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleEditClick(todoId) {

if (!canStartEditing(todoId)) return;

uiState.editingId = todoId;
renderTodos();
}

function handleSortStart(todoId) {
if (!canStartSorting()) return;

uiState.isSorting = true;
uiState.draggedId = todoId;
}

function handleToggleClick(todoId) {
if (!canToggleDone()) return;

toggleTodo(todoId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 좋은 점은 왜 지금 이 버튼이 안 눌리는지도 설명하기 쉬워진다는 것입니다. 단순히 우연히 안 되는 것이 아니라, 현재 화면 모드상 막혀 있기 때문입니다. 즉, 버그와 규칙을 구분할 수 있게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;핸들러가 먼저 물어보면 생기는 장점&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 도움이 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼 동작 기준이 분명해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 가능한 행동과 불가능한 행동이 코드에 드러난다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 조건이 줄어든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;핸들러마다 같은 조건을 길게 반복할 필요가 줄어든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버그를 찾기 쉬워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;허용 규칙이 잘못된 건지, 실제 동작 코드가 잘못된 건지 경계를 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 감각으로 정리하면&lt;/b&gt;&lt;br /&gt;핸들러는 버튼이 눌렸다고 바로 일하는 곳이 아니라, 지금 이 행동이 허용되는지 먼저 묻고 그다음 움직이는 곳에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;렌더링은 현재 상태를 기준으로 다시 보여줘야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 규칙을 세웠다면 화면도 그 규칙을 반영해야 합니다. 많은 초보자가 여기서 놓치는 부분이 있습니다. 코드 안에서는 수정 중이면 다른 버튼을 막자고 정해놓고, 실제 화면에서는 여전히 삭제 버튼과 체크 버튼이 그대로 살아 있는 경우입니다. 그러면 사용자는 눌러보고 나서야 안 되는 걸 알게 되고, 왜 안 되는지도 헷갈립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 렌더링은 현재 uiState를 기준으로 다시 보이게 만드는 편이 좋습니다. 예를 들면 이런 식입니다.&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;br /&gt;수정 버튼, 삭제 버튼, 완료 체크를 비활성화하거나 흐리게 보이게 한다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정 중일 때&lt;/b&gt;&lt;br /&gt;해당 항목만 입력창으로 바꾸고, 다른 항목의 편집 시작 버튼은 막는다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;삭제 확인 중일 때&lt;/b&gt;&lt;br /&gt;다른 주요 동작 버튼을 잠시 비활성화한다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;저장 중일 때&lt;/b&gt;&lt;br /&gt;전체적으로 다시 클릭되지 않게 막는다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 예시는 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function renderTodoItem(todo) {

const isEditing = uiState.editingId === todo.id;
const disableActions =
uiState.isSorting ||
uiState.confirmDeleteId !== null ||
uiState.isSaving;

const disableToggle =
disableActions || uiState.editingId !== null;

const disableEdit =
disableActions ||
(uiState.editingId !== null &amp;amp;&amp;amp; uiState.editingId !== todo.id);

return {
isEditing,
disableToggle,
disableEdit
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 HTML 생성 코드는 방식마다 다를 수 있습니다. 중요한 건 결과가 하나입니다. &lt;b&gt;지금 상태에서 가능한 행동은 열어두고, 지금 상태에서 막혀야 하는 행동은 화면에서도 막혀 보이게 만들기&lt;/b&gt;. 이 기준이 있어야 사용자가 덜 헷갈리고, 개발자도 나중에 왜 이런 버튼이 안 눌리는지 설명하기 쉬워집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;현재 상태에 따라 UI가 달라져야 하는 예시&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;현재 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;화면에서 보이면 좋은 변화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 관련 표시만 살리고 다른 버튼은 잠시 비활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창과 저장 또는 취소 버튼을 분명하게 보여줌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 확인 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삭제 대상이 명확히 보이고, 다른 동작은 잠시 덜 강조함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 클릭을 막을 수 있게 전체 버튼을 잠시 비활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;상태 규칙은 코드 안에만 있으면 부족하다. 사용자가 지금 무엇을 할 수 있고 없는지를 화면에서도 알아볼 수 있어야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 충돌을 다루기 시작하면 이전보다 코드가 훨씬 논리적인 것처럼 보이기 시작합니다. 그런데도 자주 나오는 실수는 꽤 비슷합니다. 대부분은 상태 종류를 섞거나, 잠금 규칙을 코드와 화면 중 한쪽에만 적용하는 경우입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;done과 editingId를 같은 종류 상태처럼 다룬다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇은 저장해야 하고 무엇은 임시인지 기준이 흐려진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 상태와 화면 상호작용 상태를 구분하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;영구 데이터와 임시 화면 모드를 먼저 나눠 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;주요 모드를 여러 개 동시에 열어둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 중 수정창이 뜨거나 삭제 확인 중 다른 버튼이 눌린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;우선순위 규칙이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음엔 한 번에 하나의 주요 모드만 허용한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태가 여러 함수 안에 흩어져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 현재 열려 있는지 추적하기 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 상태를 한곳에 모아두지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;uiState 같은 공통 구조로 먼저 모은다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;핸들러가 허용 여부를 묻지 않고 바로 실행된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모드가 겹친 상태에서도 버튼이 섞여 동작한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;행동 가능 조건이 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;canStartSorting, canStartEditing 같은 규칙 함수부터 둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드에서는 막았는데 화면은 그대로 열어둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 눌러보고 나서야 안 된다는 걸 알게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링이 현재 상태를 반영하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비활성화나 시각적 구분도 같이 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중에도 연속 클릭을 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중복 저장, 두 번 삭제, 두 번 편집 시작 같은 문제가 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSaving 같은 공통 잠금 상태를 안 봤다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 중 상태는 전체 상호작용을 잠시 막는 기준으로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 생각보다 자주 보입니다. 개발자는 코드 안에서 return 했으니 됐다고 느끼기 쉽지만, 사용자 입장에서는 버튼이 살아 보이면 눌러도 되는 것처럼 보입니다. 그래서 상태 충돌을 줄이는 일은 조건문 몇 줄을 추가하는 것에서 끝나지 않고, &lt;b&gt;화면 자체가 그 규칙을 설명하게 만드는 일&lt;/b&gt;까지 포함됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;상태 충돌은 보통 로직 한 줄이 틀려서가 아니라, 지금 허용되는 행동을 코드와 화면이 서로 다르게 말할 때 더 자주 생긴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 상태에는 데이터 상태와 화면 상호작용 상태가 있고 이 둘을 구분해야 한다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 처음에는 한 번에 하나의 주요 모드만 허용하는 편이 훨씬 안정적이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 상태는 한곳에 모으고, 핸들러는 실행 전에 허용 여부를 먼저 묻고, 화면도 그 규칙을 같이 보여줘야 한다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 체크리스트 앱은 기능 나열 수준을 넘어, 동작 규칙을 가진 화면으로 보이기 시작합니다. 다음 편에서는 이 흐름을 이어서, &lt;b&gt;수정 중인 입력값을 실제 데이터와 어떻게 분리해서 다뤄야 하는지&lt;/b&gt;, 즉 임시 편집값을 따로 들고 가는 이유와 저장 또는 취소 흐름이 왜 중요한지를 본격적으로 다루겠습니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>UI상태관리</category>
      <category>모드관리</category>
      <category>상태충돌</category>
      <category>상호작용설계</category>
      <category>이벤트우선순위</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/34</guid>
      <comments>https://story86025.tistory.com/34#entry34comment</comments>
      <pubDate>Thu, 9 Apr 2026 17:00:05 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 16편: 할 일 앱에 우선순위 붙이기</title>
      <link>https://story86025.tistory.com/46</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_with_priority_202604051628.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buVQLk/dJMcaaSnLls/WmWh6TQAFMSLu0U21UmZkk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buVQLk/dJMcaaSnLls/WmWh6TQAFMSLu0U21UmZkk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buVQLk/dJMcaaSnLls/WmWh6TQAFMSLu0U21UmZkk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuVQLk%2FdJMcaaSnLls%2FWmWh6TQAFMSLu0U21UmZkk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_with_priority_202604051628.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능까지 붙이고 나면, 이제 할 일 앱이 꽤 그럴듯해 보이기 시작합니다. 그런데 실제로 조금만 써보면 또 다른 아쉬움이 금방 보입니다. 목록은 찾을 수 있는데, &lt;span data-ui=&quot;term&quot; data-note=&quot;할 일의 중요도를 구분하는 값입니다. 높음, 보통, 낮음 같은 기준이 여기에 해당합니다.&quot;&gt;우선순위&lt;/span&gt;가 따로 없으니 무엇을 먼저 의식해야 하는지가 한눈에 안 들어온다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 편에서는 우선순위 기능을 붙여보겠습니다. 다만 이번에도 범위를 크게 벌리지는 않겠습니다. 자동 정렬까지 한 번에 넣지는 않고, &lt;b&gt;높음, 보통, 낮음 세 가지 중 하나를 고르게 하고, 그 값이 화면에 배지처럼 보이게 만드는 흐름&lt;/b&gt;에 집중하겠습니다. 지금 단계에서는 이 정도가 가장 좋습니다. 새 속성 하나가 데이터에 들어오고, 그 값이 입력, 저장, 수정, 렌더링 전체에 연결되는 경험을 하기 딱 좋기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 새 할 일을 추가할 때 우선순위를 같이 고를 수 있게 만들고, 목록에서도 그 우선순위가 &lt;span data-ui=&quot;term&quot; data-note=&quot;짧은 상태 정보를 눈에 띄게 보여주는 작은 라벨 형태의 UI입니다.&quot;&gt;배지&lt;/span&gt;처럼 보이게 정리하겠습니다. 그리고 이미 저장돼 있던 이전 데이터에는 우선순위 값이 없을 수 있으니, 그때는 &lt;span data-ui=&quot;term&quot; data-note=&quot;값이 없을 때 자동으로 들어가는 기준값입니다. 이번 글에서는 보통이 기본값입니다.&quot;&gt;기본값&lt;/span&gt;을 붙여서 앱이 깨지지 않게 처리하겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가 폼에 우선순위 선택이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo 객체에 priority 값이 하나 더 들어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 text, done에 이어 priority까지 함께 저장되는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에 높음, 보통, 낮음 배지가 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;우선순위 값에 따라 서로 다른 스타일을 적용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링할 때 텍스트만 그리는 게 아니라 상태도 같이 보여주는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 데이터도 그대로 열린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;priority가 없는 이전 항목에는 기본값을 붙여서 정리한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불러오는 단계에서 데이터를 한 번 &lt;span data-ui=&quot;term&quot; data-note=&quot;들어온 값을 현재 구조에 맞게 정리하는 과정입니다.&quot;&gt;정규화&lt;/span&gt;하는 흐름을 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;우선순위 기능의 핵심은 선택창을 하나 더 넣는 일이 아니라, &lt;b&gt;할 일 데이터에 새 상태 하나를 추가하고 그 상태를 화면에서도 읽히게 만드는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 지금 우선순위 기능이 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 변경 기능까지 붙이고 나면 목록은 이제 제법 다듬을 수 있게 됩니다. 그런데 순서와 우선순위는 비슷해 보여도 실제로는 조금 다릅니다. 순서는 지금 내가 보고 있는 배치이고, 우선순위는 그 항목이 어떤 성격을 갖고 있는지에 더 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 오늘 꼭 해야 하는 일을 맨 위로 올릴 수는 있습니다. 하지만 우선순위 정보가 따로 없으면, 며칠 뒤 다시 봤을 때 왜 그 일을 위에 두었는지 맥락이 흐려질 수 있습니다. 반대로 우선순위가 있으면 지금 배치가 바뀌더라도 &quot;이건 높음이었지&quot;를 바로 읽을 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;텍스트만 보던 목록에 &quot;상태를 읽는 눈&quot;이 생깁니다.&lt;/li&gt;
&lt;li&gt;검색, 필터, 수정 같은 기존 기능과 데이터 구조가 어떻게 함께 가는지 볼 수 있습니다.&lt;/li&gt;
&lt;li&gt;할 일 객체에 속성을 하나 더 붙였을 때 어떤 부분이 함께 바뀌는지 연습할 수 있습니다.&lt;/li&gt;
&lt;li&gt;나중에 태그, 마감일, 카테고리 같은 확장도 덜 막막해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;우선순위 기능은 단순한 꾸미기가 아니라, &lt;b&gt;데이터 구조가 한 단계 넓어지는 경험&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;목록에 높음, 보통, 낮음 배지를 표시합니다.&lt;/li&gt;
&lt;li&gt;수정 모드에서도 우선순위를 바꿀 수 있게 만듭니다.&lt;/li&gt;
&lt;li&gt;요약 문구에 &quot;높은 우선순위이면서 아직 안 끝난 항목 수&quot;를 같이 보여줍니다.&lt;/li&gt;
&lt;li&gt;이전 localStorage 데이터에는 기본 우선순위를 자동으로 붙입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우선순위별 자동 정렬&lt;/li&gt;
&lt;li&gt;우선순위 전용 필터 버튼&lt;/li&gt;
&lt;li&gt;우선순위별 검색 확장&lt;/li&gt;
&lt;li&gt;사용자가 우선순위 이름이나 색을 직접 바꾸는 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 것이 흐려지지 않습니다. 지금 중요한 건 &quot;우선순위가 멋지게 보이느냐&quot;보다, &lt;b&gt;새로운 속성 하나가 추가됐을 때 HTML, CSS, JavaScript가 각각 어디서 달라지는지&lt;/b&gt;를 읽는 데 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 우선순위가 왜 단순 장식이 아닌지 먼저 느껴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 아주 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 할 일이 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 운동하기      - 보통
2. 회의 자료 정리 - 높음
3. 책 반납하기    - 낮음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서는 순서를 그대로 두더라도, 이제는 목록을 보는 눈이 달라집니다. &quot;회의 자료 정리&quot;가 가운데에 있어도 높음 배지가 붙어 있다면 먼저 의식하게 되기 때문입니다. 즉, 우선순위는 순서를 바꾸는 기능이 아니라, &lt;b&gt;같은 목록을 읽는 기준을 하나 더 만들어주는 기능&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;우선순위가 들어오면 달라지는 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 필요한 값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가할 때 높음, 보통, 낮음을 고른다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todo에 priority 속성이 저장돼야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록에서 배지가 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링할 때 priority 값도 함께 읽어야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 데이터도 그대로 열린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;priority가 없던 항목에는 기본값을 붙여야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 기능은 단순히 select 하나를 더 만드는 작업이 아닙니다. &lt;b&gt;todo 데이터 구조가 조금 넓어지고, 그 변화가 저장과 화면에 같이 반영되는 경험&lt;/b&gt;이라고 보는 편이 훨씬 정확합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;우선순위 기능은 &quot;꾸미기&quot;가 아니라, &lt;b&gt;할 일 하나가 가지는 정보가 한 단계 더 늘어나는 경험&lt;/b&gt;이라고 보면 훨씬 이해가 쉽습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 입력창 옆에 우선순위 선택 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 전체를 뜯는 작업이 아닙니다. 입력 폼 안에 선택창 하나를 더 넣고, 나중에 목록 한 줄을 그릴 때 이 값을 배지처럼 보여주게 만들면 충분합니다. 즉, 새로 들어오는 건 많아 보이지 않지만 데이터 구조 쪽 변화는 꽤 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Priority Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          할 일의 중요도를 높음, 보통, 낮음으로 나눠서 표시하는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 우선순위 기능 점검하기&quot;
            &amp;gt;
            &amp;lt;select
              id=&quot;priorityInput&quot;
              class=&quot;priority-select&quot;
              aria-label=&quot;우선순위 선택&quot;
            &amp;gt;
              &amp;lt;option value=&quot;high&quot;&amp;gt;높음&amp;lt;/option&amp;gt;
              &amp;lt;option
                value=&quot;medium&quot;
                selected
              &amp;gt;
                보통
              &amp;lt;/option&amp;gt;
              &amp;lt;option value=&quot;low&quot;&amp;gt;낮음&amp;lt;/option&amp;gt;
            &amp;lt;/select&amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개 &amp;middot; 높은 우선순위 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;search-section&quot;&amp;gt;
        &amp;lt;label for=&quot;searchInput&quot;&amp;gt;검색&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;search-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;searchInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 수정, 배포, 회의&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;clearSearchButton&quot;
            type=&quot;button&quot;
            class=&quot;secondary-button&quot;
          &amp;gt;
            검색 지우기
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 &lt;b&gt;priorityInput&lt;/b&gt; 하나가 추가됐다는 점입니다. 그런데 실제로는 이 한 줄이 꽤 많은 걸 바꿉니다. 이제부터 새 항목은 text와 done만 갖는 게 아니라, priority라는 값도 같이 갖게 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 좋은 점은 구조가 크게 흐트러지지 않는다는 점입니다. 입력창, 버튼, 필터, 검색, 목록 흐름은 그대로 두고, 입력 단계에서 하나를 더 고르게 하는 방식이기 때문에 초보자도 훨씬 따라가기 쉽습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML에서는 선택창 하나만 더 늘어난 것처럼 보이지만, 실제로는 todo 데이터 자체가 한 단계 넓어지는 시작점이 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 우선순위 배지와 선택창이 자연스럽게 보이게 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS의 핵심은 두 가지입니다. 하나는 입력 폼 안에 들어온 선택창이 기존 입력창과 어색하지 않게 보이도록 만드는 것이고, 다른 하나는 목록에서 우선순위가 글자로만 지나가지 않고 &lt;b&gt;배지처럼 한눈에 들어오게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row,
.search-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;],
select {
  font: inherit;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
}

.priority-select {
  min-width: 108px;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  background: #ffffff;
  color: #111827;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled,
select:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section,
.search-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.todo-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.priority-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
}

.priority-badge--high {
  background: #fee2e2;
  color: #b91c1c;
}

.priority-badge--medium {
  background: #fef3c7;
  color: #92400e;
}

.priority-badge--low {
  background: #dcfce7;
  color: #166534;
}

.todo-text {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-fields {
  display: flex;
  gap: 10px;
  align-items: center;
}

.edit-input {
  flex: 1;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.edit-priority-select {
  min-width: 96px;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  background: #ffffff;
  color: #111827;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done .todo-text {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row,
  .search-row,
  .edit-fields {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }

  .priority-select,
  .edit-priority-select {
    width: 100%;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 눈여겨볼 부분은 세 가지입니다.&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;priority-select&lt;/b&gt; : 추가 폼에 들어간 선택창이 기존 입력창과 너무 따로 놀지 않게 맞춰줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;priority-badge&lt;/b&gt;와 세 가지 변형 클래스 : 높음, 보통, 낮음이 한눈에 구분되게 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;edit-priority-select&lt;/b&gt; : 수정 모드에서도 우선순위를 바로 바꿀 수 있게 해줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 색을 화려하게 꾸미는 게 아닙니다. 높음, 보통, 낮음이 서로 다른 성격으로 읽히되, 목록 전체를 시끄럽게 만들지 않는 정도가 훨씬 중요합니다. 초보자 실습에서는 예쁜 색보다 &lt;b&gt;상태가 분명하게 읽히는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;우선순위 UI는 화려함보다 &lt;b&gt;높음, 보통, 낮음이 한눈에 구분되면서도 전체 화면이 과하게 시끄럽지 않은 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: priority 저장과 이전 데이터 정리 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 우선순위 기능은 결국 &lt;b&gt;todo 객체에 priority를 추가하고, 그 값이 저장과 수정과 렌더링에 모두 연결되게 만드는 작업&lt;/b&gt;이기 때문입니다. 특히 이번에는 한 가지를 꼭 봐야 합니다. 기존 localStorage 데이터에는 priority가 없을 수 있기 때문에, 불러올 때 한 번 정리해주는 흐름이 필요합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;PRIORITY_TYPES&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;우선순위 기준값을 한곳에 모아둡니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자열을 여기저기 흩뿌리지 않게 해줍니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;normalizePriority&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;priority 값이 이상하거나 없을 때 기본값으로 정리합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예전 저장 데이터가 있어도 앱이 깨지지 않게 만듭니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;normalizeTodo&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불러온 항목을 현재 구조에 맞게 정리합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 데이터와 새 데이터를 같은 구조로 맞춥니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createPriorityBadge&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;priority 값을 눈에 보이는 배지로 바꿔줍니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 안의 상태가 실제 UI로 연결되는 지점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const PRIORITY_TYPES = {
  HIGH: &quot;high&quot;,
  MEDIUM: &quot;medium&quot;,
  LOW: &quot;low&quot;
};

const PRIORITY_LABELS = {
  high: &quot;높음&quot;,
  medium: &quot;보통&quot;,
  low: &quot;낮음&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  priorityInput: document.querySelector(&quot;#priorityInput&quot;),
  searchInput: document.querySelector(&quot;#searchInput&quot;),
  clearSearchButton: document.querySelector(&quot;#clearSearchButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let currentSearchQuery = &quot;&quot;;
let editingTodoId = null;
let editingText = &quot;&quot;;
let editingPriority = PRIORITY_TYPES.MEDIUM;

function normalizeText(value) {
  return String(value || &quot;&quot;)
    .trim()
    .toLowerCase();
}

function normalizePriority(value) {
  const allowedValues = Object.values(PRIORITY_TYPES);

  return allowedValues.includes(value)
    ? value
    : PRIORITY_TYPES.MEDIUM;
}

function normalizeTodo(todo) {
  return {
    id: todo.id,
    text: todo.text,
    done: Boolean(todo.done),
    priority: normalizePriority(todo.priority)
  };
}

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    if (!savedValue) {
      return [];
    }

    const parsedValue = JSON.parse(savedValue);

    if (!Array.isArray(parsedValue)) {
      return [];
    }

    return parsedValue.map(function (todo) {
      return normalizeTodo(todo);
    });
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getHighPriorityRemainingCount() {
  return todos.filter(function (todo) {
    return (
      !todo.done &amp;amp;&amp;amp;
      todo.priority === PRIORITY_TYPES.HIGH
    );
  }).length;
}

function getFilteredTodos() {
  let result = todos;

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    result = result.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    result = result.filter(function (todo) {
      return todo.done;
    });
  }

  if (currentSearchQuery !== &quot;&quot;) {
    result = result.filter(function (todo) {
      return normalizeText(todo.text).includes(
        currentSearchQuery
      );
    });
  }

  return result;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
  elements.priorityInput.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();
  const highPriorityRemainingCount =
    getHighPriorityRemainingCount();
  const visibleCount = getFilteredTodos().length;

  if (currentSearchQuery !== &quot;&quot;) {
    elements.summary.textContent =
      &quot;검색 결과 &quot; +
      visibleCount +
      &quot;개 &amp;middot; 전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개&quot;;
  } else {
    elements.summary.textContent =
      &quot;전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개 &amp;middot; 높은 우선순위 &quot; +
      highPriorityRemainingCount +
      &quot;개&quot;;
  }

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;

  elements.clearSearchButton.disabled =
    currentSearchQuery === &quot;&quot;;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getPriorityLabel(priority) {
  return PRIORITY_LABELS[priority] || &quot;보통&quot;;
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일을 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  return &quot;우선순위 배지와 순서를 함께 보면서 정리해보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;
  editingPriority = normalizePriority(todo.priority);

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();
  const nextPriority = normalizePriority(
    editingPriority
  );

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done,
        priority: nextPriority
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &amp;amp;&amp;amp;
    currentSearchQuery === &quot;&quot; &amp;amp;&amp;amp;
    editingTodoId === null
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage(
      &quot;순서 변경은 전체 보기에서, 검색 중이 아닐 때만 할 수 있습니다.&quot;
    );
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === &quot;up&quot;
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex &amp;lt; 0 ||
    targetIndex &amp;gt;= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === &quot;up&quot;
      ? &quot;할 일을 위로 옮겼습니다.&quot;
      : &quot;할 일을 아래로 옮겼습니다.&quot;
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createPriorityBadge(todo) {
  const badge = document.createElement(&quot;span&quot;);

  badge.className =
    &quot;priority-badge priority-badge--&quot; + todo.priority;
  badge.textContent = getPriorityLabel(todo.priority);

  return badge;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.className = &quot;todo-text&quot;;
  text.textContent = todo.text;

  return text;
}

function createTodoContent(todo) {
  const content = document.createElement(&quot;div&quot;);
  const meta = document.createElement(&quot;div&quot;);

  content.className = &quot;todo-content&quot;;
  meta.className = &quot;todo-meta&quot;;

  meta.append(
    createPriorityBadge(todo)
  );

  content.append(
    meta,
    createTodoText(todo)
  );

  return content;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;위로&quot;,
    &quot;move-up-button&quot;,
    function () {
      moveTodo(todoId, &quot;up&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;아래로&quot;,
    &quot;move-down-button&quot;,
    function () {
      moveTodo(todoId, &quot;down&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoContent(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditPrioritySelect() {
  const select = document.createElement(&quot;select&quot;);

  select.className = &quot;edit-priority-select&quot;;

  [
    PRIORITY_TYPES.HIGH,
    PRIORITY_TYPES.MEDIUM,
    PRIORITY_TYPES.LOW
  ].forEach(function (priority) {
    const option = document.createElement(&quot;option&quot;);

    option.value = priority;
    option.textContent = getPriorityLabel(priority);

    select.appendChild(option);
  });

  select.value = editingPriority;

  select.addEventListener(&quot;change&quot;, function () {
    editingPriority = select.value;
  });

  return select;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const editFields = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  editFields.className = &quot;edit-fields&quot;;
  actions.className = &quot;todo-actions&quot;;

  editFields.append(
    createEditInput(todo.id),
    createEditPrioritySelect()
  );

  editArea.append(editFields);

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText, priority) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false,
    priority: normalizePriority(priority)
  });

  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  elements.priorityInput.value = PRIORITY_TYPES.MEDIUM;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSearchInput() {
  currentSearchQuery = normalizeText(
    elements.searchInput.value
  );

  if (editingTodoId !== null) {
    editingTodoId = null;
    editingText = &quot;&quot;;
    editingPriority = PRIORITY_TYPES.MEDIUM;
  }

  renderTodos();
}

function clearSearch() {
  if (currentSearchQuery === &quot;&quot;) {
    return;
  }

  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  editingTodoId = null;
  editingText = &quot;&quot;;
  editingPriority = PRIORITY_TYPES.MEDIUM;
  renderTodos(&quot;검색어를 지웠습니다.&quot;);
  elements.searchInput.focus();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();
  const priority = elements.priorityInput.value;

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text, priority);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindSearchEvents() {
  elements.searchInput.addEventListener(
    &quot;input&quot;,
    handleSearchInput
  );

  elements.clearSearchButton.addEventListener(
    &quot;click&quot;,
    clearSearch
  );
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
  bindSearchEvents();
}

bindEvents();
renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1053&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTrGxI/dJMcaiQlOv5/TXI6p9HBjG4jiuuQNo86p1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTrGxI/dJMcaiQlOv5/TXI6p9HBjG4jiuuQNo86p1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTrGxI/dJMcaiQlOv5/TXI6p9HBjG4jiuuQNo86p1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTrGxI%2FdJMcaiQlOv5%2FTXI6p9HBjG4jiuuQNo86p1%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;1053&quot; height=&quot;618&quot; data-origin-width=&quot;1053&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이번 코드에서 가장 먼저 봐야 할 건 네 군데입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;normalizePriority&lt;/b&gt;와 &lt;b&gt;normalizeTodo&lt;/b&gt;입니다. 기존 저장 데이터에 priority가 없더라도 기본값인 보통으로 정리해서 앱이 깨지지 않게 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;addTodo&lt;/b&gt;와 &lt;b&gt;saveEditedTodo&lt;/b&gt;입니다. 새 항목을 만들 때도, 기존 항목을 고칠 때도 priority를 같이 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createPriorityBadge&lt;/b&gt;입니다. 데이터 안의 priority 값을 실제 화면 배지로 바꿔주는 지점입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getHighPriorityRemainingCount&lt;/b&gt;입니다. 우선순위가 단순 표시가 아니라 요약 정보에도 반영되게 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능에서 특히 중요한 부분은 이전 데이터 처리입니다. 이미 localStorage 안에 저장돼 있던 todo들은 priority가 없는 상태일 수 있습니다. 이런 경우를 무시하면 새 코드는 돌아가는데 예전 데이터에서 갑자기 undefined가 튀어나오거나, 배지가 비어 보일 수 있습니다. 그래서 불러오는 순간 한 번 정리해두는 쪽이 훨씬 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 작은 앱에서도 꽤 중요한 감각입니다. 데이터 구조가 넓어질 때마다, &quot;새로 만든 데이터만 맞으면 된다&quot;가 아니라 &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;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 선택창을 추가하는 작업이 아니라, &lt;b&gt;todo 데이터에 priority를 공식적으로 넣고 이전 저장 데이터까지 현재 구조에 맞게 정리하는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;831&quot; data-origin-height=&quot;936&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lvIcI/dJMcahKLfH2/hDN5LrmqvdCiJ6YzNnhE8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lvIcI/dJMcahKLfH2/hDN5LrmqvdCiJ6YzNnhE8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lvIcI/dJMcahKLfH2/hDN5LrmqvdCiJ6YzNnhE8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlvIcI%2FdJMcahKLfH2%2FhDN5LrmqvdCiJ6YzNnhE8k%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;831&quot; height=&quot;936&quot; data-origin-width=&quot;831&quot; data-origin-height=&quot;936&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&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;/li&gt;
&lt;li&gt;목록에 우선순위 배지가 정상적으로 보이는가&lt;/li&gt;
&lt;li&gt;수정 모드에서도 우선순위를 바꿀 수 있는가&lt;/li&gt;
&lt;li&gt;요약 문구에 높은 우선순위 미완료 개수가 같이 반영되는가&lt;/li&gt;
&lt;li&gt;검색, 필터, 수정, 삭제, 순서 변경이 우선순위 기능 추가 뒤에도 그대로 동작하는가&lt;/li&gt;
&lt;li&gt;이전에 저장돼 있던 항목이 있어도 앱이 깨지지 않고 기본 우선순위로 열리는가&lt;/li&gt;
&lt;li&gt;새로고침 뒤에도 priority 값이 그대로 유지되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;배지가 예쁘게 보이느냐&quot;보다, &lt;b&gt;새로 추가된 priority 값이 저장부터 수정, 렌더링, 요약까지 일관되게 이어지는지&lt;/b&gt;를 끝까지 확인하는 것입니다. 이 감각이 있어야 다음에 데이터 구조가 더 넓어져도 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;우선순위 기능은 화면보다도 &lt;b&gt;데이터와 저장 구조가 같이 넓어졌는지&lt;/b&gt;를 직접 눌러보며 확인하는 게 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 추가 폼, 수정 모드, 렌더링, 저장 데이터 구조까지 같이 바뀌기 때문에, 더더욱 범위를 분명하게 잘라야 합니다. &quot;우선순위 넣어줘&quot;라고만 던지면 AI가 자동 정렬이나 우선순위 필터까지 넓게 건드릴 가능성이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 검색, 순서 변경, 저장 기능까지 정상 동작하는 상태야.
이번에는 우선순위 기능만 추가하고 싶어.
새 할 일을 만들 때 높음, 보통, 낮음 중 하나를 고를 수 있고,
목록에서도 그 우선순위가 배지처럼 보이게 해줘.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 todo 구조에 priority 속성만 추가하고 싶어.
추가할 때도 priority를 저장하고,
수정 모드에서도 priority를 바꿀 수 있게 해줘.
예전에 저장된 데이터에는 priority가 없을 수도 있으니
load 단계에서 기본값을 붙이는 방식으로 처리해줘.
새로 생길 상수와 정규화 함수부터 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
우선순위 선택창과 우선순위 배지를 추가할 거야.
높음, 보통, 낮음이 서로 구분되게 보이되
전체 디자인이 너무 시끄럽지 않게 정리해줘.
수정 모드에서도 우선순위 선택창이 자연스럽게 보이게 해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 자동 정렬이나 우선순위 전용 필터처럼 범위를 넘어가는 기능을 멋대로 붙일 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 대단한 문장보다 &lt;b&gt;무엇은 유지하고, 이번엔 어디까지만 바꿀지 같이 적는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 우선순위 기능을 맡길 때는 &quot;우선순위 넣어줘&quot;보다 &lt;b&gt;자동 정렬은 제외, 기존 기능 유지, 이전 데이터도 깨지지 않게&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 거창하지 않습니다. 입력 폼에 선택창 하나가 더 생겼고, 목록에는 높음, 보통, 낮음 배지가 붙었을 뿐입니다. 그런데 실제로 앱을 다시 보면 체감이 꽤 달라집니다. 이제는 단순히 &quot;무슨 일을 해야 하는지&quot;만 보는 게 아니라, &lt;b&gt;그 일이 어느 정도 중요한지도 같이 읽을 수 있게&lt;/b&gt; 되었기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 기능은 데이터 구조를 넓히는 첫 감각으로도 꽤 중요합니다. 지금까지는 text와 done 정도로 보던 항목이 이제는 priority까지 함께 갖게 되었고, 그 값이 저장과 수정과 화면 표시 전체를 같이 움직이게 됩니다. 이 경험이 한 번 있으면, 이후에 태그나 마감일 같은 정보가 추가될 때도 훨씬 덜 낯설게 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색과 순서 변경, 수정 기능까지 들어간 앱에 우선순위가 붙고 나면 목록을 보는 방식 자체가 조금 달라집니다. 해야 할 일을 적는 수준에서, 어느 일을 먼저 의식해야 할지도 같이 정리하는 쪽으로 한 단계 더 가까워졌기 때문입니다.&lt;br /&gt;&lt;br /&gt;#바이브코딩, #AI코딩, #VSCode, #Codex, #할일앱, #우선순위, #상태관리, #미니프로젝트, #코딩초보&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>VSCode</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>우선순위</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/46</guid>
      <comments>https://story86025.tistory.com/46#entry46comment</comments>
      <pubDate>Wed, 8 Apr 2026 18:00:13 +0900</pubDate>
    </item>
    <item>
      <title>[기초-4] 돌아간다고 끝은 아니다: AI 코드 검토와 다시 요청하는 법</title>
      <link>https://story86025.tistory.com/54</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;기초 4.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WhCpJ/dJMcah4L7cZ/IxksTpvH06aiUs7wnlIKb0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WhCpJ/dJMcah4L7cZ/IxksTpvH06aiUs7wnlIKb0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WhCpJ/dJMcah4L7cZ/IxksTpvH06aiUs7wnlIKb0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWhCpJ%2FdJMcah4L7cZ%2FIxksTpvH06aiUs7wnlIKb0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;기초 4.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 코드를 부탁하고 나면, 초보자 입장에서는 아주 비슷한 감정이 한 번씩 찾아옵니다. 화면은 일단 뜨고, 버튼도 어느 정도 눌리고, 콘솔 에러도 없어 보이는데 이상하게 마음이 놓이지 않는 순간입니다. &amp;ldquo;이게 맞는 건가?&amp;rdquo;, &amp;ldquo;지금 돌아가는 것 같긴 한데 나중에 더 꼬이는 건 아닐까?&amp;rdquo; 같은 느낌이 드는 때가 바로 그 지점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 감각은 이상한 게 아닙니다. 오히려 자연스럽습니다. AI는 꽤 그럴듯한 코드를 빠르게 만들어주지만, &lt;b&gt;그 코드가 정말 내가 요청한 범위에 맞는지&lt;/b&gt;, &lt;b&gt;기존 기능을 건드리지는 않았는지&lt;/b&gt;, &lt;b&gt;지금 단계에서 괜히 복잡해진 건 아닌지&lt;/b&gt;는 따로 확인해야 하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 그 과정을 다루겠습니다. 핵심은 시니어 개발자처럼 완벽하게 리뷰하는 데 있지 않습니다. &lt;b&gt;초보자 기준에서 지금 꼭 봐야 할 것부터 확인하고, 무엇이 어긋났는지 정리해서 AI에게 다시 요청하는 법&lt;/b&gt;을 익히는 데 있습니다. 이 흐름이 잡히면, AI가 준 답을 무조건 믿거나 무조건 버리는 대신, 조금 더 주도적으로 다룰 수 있게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 봐야 할 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;바로 다음 행동&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능이 일단 돌아간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정말 내가 요청한 기능만 들어갔는지, 빠진 건 없는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요청 목록과 결과를 한 번 나란히 비교한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 기능이 붙었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존에 되던 기능이 같이 깨지지 않았는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 삭제, 필터 같은 기존 흐름을 다시 눌러본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드가 길고 복잡해 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 단계 범위를 넘어가는 변경이 들어갔는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 재작성 대신 필요한 부분만 다시 요청한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;AI가 만든 코드를 검토한다는 것은 완벽함을 따지는 일이 아니라, 지금 단계의 목표와 범위에 맞는지 먼저 확인하는 일에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 검토가 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 AI와 같이 코딩할 때 가장 자주 겪는 착각 중 하나는 이것입니다. &lt;b&gt;&amp;ldquo;에러가 없고 화면이 보이면 일단 맞는 코드다.&amp;rdquo;&lt;/b&gt; 물론 완전히 틀린 말은 아닙니다. 실제로 실행이 된다는 건 중요한 출발점입니다. 하지만 거기서 끝내면 놓치는 것이 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 기능을 붙여달라고 했는데, 검색은 되지만 기존 필터가 깨질 수도 있습니다. 삭제 버튼을 고쳐달라고 했는데, 삭제는 되지만 summary 문구가 갱신되지 않을 수도 있습니다. 더 심하면 이번 단계에서 필요하지 않은 구조까지 한꺼번에 바뀌어서, 지금은 돌아가도 다음 단계에서 이해하기 어려워질 수도 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청한 기능이 일부만 들어갔는데 겉으로는 된 것처럼 보일 수 있습니다.&lt;/li&gt;
&lt;li&gt;새 기능은 붙었지만 기존 기능이 같이 깨질 수 있습니다.&lt;/li&gt;
&lt;li&gt;이번 단계에 필요 없는 코드까지 들어와서 구조가 불필요하게 커질 수 있습니다.&lt;/li&gt;
&lt;li&gt;설명은 그럴듯하지만 실제 코드와 맞지 않는 경우도 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 검토의 의미를 너무 크게 잡을 필요는 없습니다. 지금 단계에서 중요한 건 보안, 성능, 아키텍처를 깊게 따지는 일이 아닙니다. 오히려 &lt;b&gt;내가 요청한 것과 실제 결과가 맞는지&lt;/b&gt;, &lt;b&gt;기존 흐름이 유지되는지&lt;/b&gt;, &lt;b&gt;초보자인 내가 이해 가능한 크기인지&lt;/b&gt;를 먼저 보는 편이 훨씬 현실적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해하기 쉬운 지점&lt;/b&gt;&lt;br /&gt;검토는 AI를 믿지 못해서 하는 일이 아닙니다. 다음 단계로 넘어가기 전에 지금 결과를 내가 통제 가능한 상태로 만드는 과정에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 어디까지 볼 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서도 범위를 분명하게 잘라두는 편이 좋습니다. 초보자 기준에서 &amp;ldquo;AI 코드 검토&amp;rdquo;라고 하면 너무 많은 주제가 한꺼번에 떠오를 수 있기 때문입니다. 그래서 이번 편은 &lt;b&gt;기능이 맞는지 확인하고, 어긋난 부분을 다시 요청하는 흐름&lt;/b&gt;에 집중하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 다룰 내용은 아래 정도입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내 요청과 결과가 맞는지 비교하는 기본 방법&lt;/li&gt;
&lt;li&gt;기존 기능이 깨졌는지 확인하는 방법&lt;/li&gt;
&lt;li&gt;이번 단계 범위를 넘어간 코드를 구분하는 방법&lt;/li&gt;
&lt;li&gt;AI에게 전체 재작성 없이 다시 요청하는 방법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 글에서는 아래 내용은 깊게 다루지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성능 최적화&lt;/li&gt;
&lt;li&gt;보안 취약점 점검&lt;/li&gt;
&lt;li&gt;대규모 프로젝트 구조 설계&lt;/li&gt;
&lt;li&gt;정교한 테스트 자동화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 주제들이 중요하지 않다는 뜻은 아닙니다. 다만 지금 연재 흐름에서는 먼저 &lt;b&gt;AI가 준 결과를 읽고, 현재 단계에 맞게 바로잡는 기본기&lt;/b&gt;가 더 중요합니다. 입문자에게는 이 단계가 잡혀야, 이후의 디버깅이나 테스트도 덜 막막해집니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자는 이 네 가지부터 보면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 만든 코드를 처음 검토할 때는 많은 것을 한꺼번에 보려 하지 않는 편이 좋습니다. 초보자에게는 먼저 &lt;b&gt;요구사항&lt;/b&gt;, &lt;b&gt;기존 기능&lt;/b&gt;, &lt;b&gt;불필요한 변경&lt;/b&gt;, &lt;b&gt;이해 가능성&lt;/b&gt; 이 네 가지만 보는 습관이 있어도 충분히 큰 도움이 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;초보자가 먼저 확인하면 좋은 네 가지&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;확인 항목&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 보나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시 질문&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;요구사항 일치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;내가 부탁한 기능이 빠짐없이 들어갔는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 기능을 부탁했는데 실시간 반영, 지우기 버튼, 필터 연동까지 모두 들어갔는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;기존 기능 유지&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 되던 기능이 새 코드 뒤에 깨지지 않았는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색을 붙인 뒤 추가, 삭제, 완료 체크, 필터가 그대로 동작하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;불필요한 변경&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 단계와 관계없는 구조 변경이 들어갔는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 기능만 필요했는데 저장 방식과 전체 레이아웃까지 한꺼번에 바뀌었는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;이해 가능성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 내 수준에서 흐름을 따라갈 수 있는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;함수 이름과 역할이 읽히는가, 설명이 코드와 맞는가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 요구사항이 맞는지 먼저 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 할 일은 AI가 만든 결과를 보고 감탄하는 게 아니라, &lt;b&gt;내가 원래 요청한 목록과 하나씩 비교해보는 것&lt;/b&gt;입니다. 이 단계가 생각보다 중요합니다. AI는 종종 핵심 기능 하나를 놓친 채 나머지를 그럴듯하게 채워 넣을 수 있기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 기존 기능이 깨졌는지 꼭 눌러본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발에서는 새 기능이 붙은 뒤 원래 되던 것이 깨지는 일을 종종 겪습니다. 이걸 흔히 &lt;b&gt;회귀&lt;/b&gt;라고 부르는데, 쉽게 말하면 &lt;i&gt;원래 되던 게 새 수정 뒤에 안 되는 상태&lt;/i&gt;라고 생각하면 됩니다. 초보자일수록 새 기능만 보고 지나가기가 쉬워서, 이 확인이 더 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 범위를 넘어간 변경이 없는지 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI는 문제를 고치다가 전체 구조를 한 번에 정리하려는 답을 내놓을 때가 있습니다. 이런 정리는 때로 도움이 되지만, 초보자 입장에서는 부담이 커질 수 있습니다. 지금은 검색 기능만 붙이면 되는 단계인데, 함수 이름과 파일 구조가 전부 바뀌면 다음부터 따라가기가 훨씬 어려워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 내가 이해 가능한 크기인지 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 돌아간다고 해도, 지금 내 수준에서 전혀 설명이 안 되는 구조라면 바로 다음 단계에서 막힐 가능성이 높습니다. 그래서 검토의 마지막에는 &lt;b&gt;&amp;ldquo;이 흐름을 내가 다음에도 설명할 수 있을까?&amp;rdquo;&lt;/b&gt;를 한 번 물어보는 편이 좋습니다. 완전히 다 이해할 필요는 없지만, 최소한 어느 함수가 무엇을 하는지는 읽히는 편이 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;초보자에게 좋은 코드는 가장 멋진 코드가 아니라, 지금 단계 목표를 충족하면서도 다음 수정 요청을 이어가기 쉬운 코드에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;라고 해보겠습니다. 내가 AI에게 부탁한 조건은 아래와 같다고 가정하겠습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[이번 단계 목표]

검색 입력창 추가

입력할 때마다 목록이 바로 줄어들기

검색 지우기 버튼 추가

전체, 진행 중, 완료 필터와 함께 동작하기

[이번 단계에서 아직 하지 않을 것]

검색 기록 저장

정규식 검색

서버 연동

전체 파일 구조 재작성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 AI가 결과를 줬다면, 검토는 아래 순서로 하면 됩니다.&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;br /&gt;검색 입력창이 있는가, 실시간 반영되는가, 지우기 버튼이 있는가, 필터와 같이 움직이는가를 하나씩 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기존 기능을 다시 눌러본다&lt;/b&gt;&lt;br /&gt;추가, 삭제, 완료 체크, 전체 삭제, 필터 버튼이 여전히 정상 동작하는지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이번 단계 밖의 변경이 있는지 본다&lt;/b&gt;&lt;br /&gt;검색만 부탁했는데 localStorage 구조, 수정 기능, 버튼 이름, 전체 레이아웃까지 크게 바뀌었는지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코드 흐름을 짧게 설명해보려고 해본다&lt;/b&gt;&lt;br /&gt;searchInput 값이 어디에 저장되고, 어떤 함수가 목록을 다시 고르는지 말로 설명이 되는지 봅니다.&lt;/li&gt;
&lt;/ol&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;. 그리고 바로 이 지점에서 다시 요청을 하면 됩니다. 중요한 건 &amp;ldquo;이상한데 고쳐줘&amp;rdquo;처럼 다시 넓게 묻지 않는 것입니다. 무엇이 어긋났는지 짧게라도 정리해서 넘겨야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 이런 식으로 정리할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[검토 결과]

검색 입력창과 지우기 버튼은 들어감

입력할 때 목록은 줄어듦

하지만 완료 필터 상태에서 검색하면 결과가 맞지 않음

검색 기능과 관계없는 함수 이름 변경이 너무 많이 들어감

[다시 요청할 핵심]

필터와 검색이 함께 적용되도록 수정

전체 리팩터링은 하지 말 것

현재 구조를 최대한 유지할 것

바뀌는 함수만 설명할 것&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도만 있어도 AI는 훨씬 좁은 범위에서 다시 답하게 됩니다. 초보자에게 이 방식이 특히 좋은 이유는, &lt;b&gt;문제를 &amp;ldquo;싫은 느낌&amp;rdquo;이 아니라 &amp;ldquo;확인된 항목&amp;rdquo;으로 바꿔서 전달할 수 있기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI에게는 이렇게 다시 요청하면 된다&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;를 같이 적는 것입니다. 이 세 가지가 들어가면 &amp;ldquo;다시 만들어줘&amp;rdquo;보다 훨씬 안정적인 답이 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 누락된 기능만 다시 요청하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적으로는 괜찮은데 몇 가지가 빠졌다면, 범위를 다시 넓히지 말고 빠진 것만 집어서 요청하는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;방금 제안한 코드에서

검색 입력창과 실시간 반영은 잘 들어갔어.

다만 아직 두 가지가 빠져 있어.

검색 지우기 버튼을 누르면 입력값이 비워져야 해

완료 필터 상태에서도 검색이 함께 적용돼야 해

전체 구조를 다시 쓰지 말고,
이 두 부분에 필요한 수정만 제안해줘.

답변은 아래 순서로 줘.

무엇이 빠졌는지 요약

어느 함수나 요소를 고쳐야 하는지

필요한 코드만 제시

내가 확인할 테스트 순서&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 기존 기능이 깨졌을 때 다시 요청하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 기능은 됐는데 예전 기능이 깨졌다면, 이건 꽤 중요한 신호입니다. 이런 경우에는 &amp;ldquo;기존 기능 유지&amp;rdquo;를 분명하게 다시 걸어주는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;검색 기능은 동작하는데,

추가한 뒤에는 기존 전체 삭제 버튼이 반응하지 않아.

이번 수정의 목표는

검색 기능은 그대로 유지하고

전체 삭제 버튼 동작만 다시 살리는 것
이야.

검색 기능과 관계없는 구조 변경은 하지 말고,
이 문제가 생긴 가장 가능성 큰 원인부터 설명해줘.
그다음 최소 수정 코드만 보여줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 범위를 너무 넓게 바꿨을 때 다시 요청하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게 특히 유용한 방식입니다. AI가 한 번에 너무 많은 걸 바꿨다면, 정중하지만 분명하게 범위를 줄여야 합니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;지금 답변은 검색 기능을 추가하는 것보다

전체 구조를 너무 많이 바꿔버렸어.

이번 단계에서는

현재 파일 구조 유지

기존 함수 이름 최대한 유지

검색 기능과 직접 관련된 부분만 수정
이 기준으로 다시 제안해줘.

리팩터링은 지금 하지 말고,
검색 상태 저장과 목록 필터링 흐름만 보정해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 설명이 부족할 때 다시 요청하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 받았는데 왜 그렇게 바꿨는지 감이 안 잡히면, 설명을 다시 받는 것도 중요합니다. 특히 초보자는 이 단계를 건너뛰면 다음 수정 요청이 더 어려워집니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;코드는 이해했는데,

왜 currentSearchQuery를 따로 두는지와
getFilteredTodos가 어떤 순서로 조건을 적용하는지가 아직 헷갈려.

초보자 기준으로 아래 형식으로 다시 설명해줘.

각 상태값이 무엇을 뜻하는지

목록이 다시 그려질 때 어떤 순서로 조건이 적용되는지

이 구조가 필요한 이유

내가 직접 바꿔보면 좋은 작은 연습 2개&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 재요청 방식의 장점은 분명합니다. AI를 다시 쓰는 것이 아니라, &lt;b&gt;내가 원하는 수정 방향을 더 좁게 지정하는 것&lt;/b&gt;이기 때문입니다. 그러면 결과도 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;재요청은 실패의 표시가 아닙니다. 처음 답을 바탕으로 범위를 더 정확하게 좁혀가는 과정이라고 보는 편이 맞습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;검토와 재요청에서 자주 하는 실수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 문제가 되나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 바꾸면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;뭔가 이상해요&amp;rdquo;만 말한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 어긋났는지 범위가 너무 넓다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;빠진 기능, 깨진 기능, 불필요한 변경 중 무엇인지 먼저 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다시 전체를 새로 짜달라고 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제가 더 커지고 기존 맥락도 사라질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최소 수정 원칙을 먼저 걸어둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 기능만 보고 기존 기능은 안 눌러본다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 되던 기능이 깨져도 놓치기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 삭제, 필터처럼 기존 핵심 흐름을 다시 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;설명을 이해 못 했는데 그냥 넘어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 수정 요청에서 더 크게 막힐 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/li&gt;
&lt;li&gt;새 기능 뒤에 기존 기능을 다시 눌러봤는가&lt;/li&gt;
&lt;li&gt;이번 단계 범위를 넘는 변경이 들어갔는가&lt;/li&gt;
&lt;li&gt;내가 이해 안 되는 함수나 상태값이 남아 있는가&lt;/li&gt;
&lt;li&gt;재요청할 때 전체 재작성 대신 최소 수정 범위를 적었는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 다섯 가지만 반복해도, AI와의 작업은 꽤 달라집니다. 처음부터 완벽한 결과를 받는 것보다, &lt;b&gt;결과를 읽고 필요한 만큼 바로잡는 습관&lt;/b&gt;이 붙기 시작하기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;AI가 준 결과를 검토하고 다시 요청하는 법&lt;/b&gt;까지 왔습니다. 이쯤 되면 이미 &amp;ldquo;아무 말이나 던지고 결과를 기다리는 방식&amp;rdquo;과는 꽤 멀어졌다고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게 가장 중요한 변화도 바로 여기 있습니다. AI가 코드를 만든다는 사실보다, &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;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/54</guid>
      <comments>https://story86025.tistory.com/54#entry54comment</comments>
      <pubDate>Tue, 7 Apr 2026 18:00:43 +0900</pubDate>
    </item>
    <item>
      <title>17. 입력은 달라도 바뀌는 건 같다: 마우스와 터치를 하나의 재정렬 로직으로 묶는 법</title>
      <link>https://story86025.tistory.com/32</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크톱용 드래그와 모바일용 터치 재정렬을 각각 붙이고 나면, 처음에는 꽤 뿌듯합니다. 한쪽에서는 마우스로 움직이고, 다른 쪽에서는 손가락으로 순서를 바꿀 수 있으니까요. 그런데 조금만 지나면 또 다른 문제가 보입니다. 같은 기능을 고치는데 코드가 두 군데에 있고, 드롭 위치 규칙을 바꾸려면 마우스 쪽도 수정하고 터치 쪽도 수정해야 하고, 어느 한쪽에서만 버그를 고쳤더니 다른 쪽은 그대로 남아 있는 식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 많은 입문자가 두 가지 중 하나로 갑니다. 하나는 그냥 두 벌의 코드를 계속 따로 유지하는 것이고, 다른 하나는 마우스와 터치를 &lt;b&gt;한 개의 거대한 이벤트 함수&lt;/b&gt;로 억지로 합치려는 것입니다. 그런데 둘 다 오래 가기는 쉽지 않습니다. 전자는 같은 로직을 계속 두 번 고쳐야 하고, 후자는 이벤트 모양이 다른 입력을 한 함수 안에 다 집어넣으려다 코드가 급격히 무거워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 필요한 건 &amp;ldquo;이벤트를 하나로 합치기&amp;rdquo;가 아니라, &lt;b&gt;실제로 공통인 것만 공통 로직으로 묶는 일&lt;/b&gt;입니다. 입력 방식은 달라도, 최종적으로 바뀌는 건 결국 같은 데이터입니다. dragged 항목이 누구인지, target이 누구인지, 앞에 넣을지 뒤에 넣을지, 그리고 그 결과로 order를 어떻게 바꿀지. 이 핵심이 같다면 그 부분은 하나의 재정렬 로직으로 묶는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 드래그 코드가 두 벌이 되면 금방 흔들리는지, 무엇을 공통으로 묶고 무엇은 입력 방식별로 남겨둬야 하는지, 그리고 초보자 기준에서 가장 현실적인 재정렬 구조는 어떤 모습인지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 버그를 마우스 쪽과 터치 쪽에서 각각 고치고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터 재배치 로직이 입력별 코드 안에 중복돼 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력별 처리와 공통 재배치 로직을 분리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한쪽만 수정하면 다른 쪽 동작이 달라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before, after 규칙이나 order 재계산이 두 군데에 따로 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최종 재배치 규칙은 하나의 함수에서만 맡긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트를 하나로 합치려다 코드가 더 복잡해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 방식이 다른 raw event를 한 함수가 다 이해하려고 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트는 따로 받고, 공통 데이터만 아래로 넘긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조를 조금만 바꿔도 전체가 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트 처리, 상태 추적, 데이터 변경, 저장, 렌더링이 한 덩어리다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;역할별 경계를 먼저 세운다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;마우스와 터치는 입력 방식이 다를 뿐이고, 최종적으로 바뀌는 데이터는 같다. 그래서 이벤트는 나뉘어도 재정렬 핵심 로직은 하나로 묶는 편이 안정적이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 드래그 로직이 두 벌이 되면 금방 흔들릴까&lt;/li&gt;
&lt;li&gt;공통으로 묶을 것과 따로 둘 것을 먼저 나눠야 한다&lt;/li&gt;
&lt;li&gt;입력은 달라도 결국 필요한 정보는 같다&lt;/li&gt;
&lt;li&gt;공통 재정렬 함수는 여기까지 맡기면 된다&lt;/li&gt;
&lt;li&gt;마우스와 터치 핸들러는 얇게 두는 편이 좋은 이유&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 드래그 로직이 두 벌이 되면 금방 흔들릴까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 분리해 두는 편이 오히려 편해 보일 수 있습니다. 마우스는 마우스대로, 터치는 터치대로 흐름이 다르기 때문입니다. 실제로 시작 이벤트도 다르고, 움직이는 동안 현재 위치를 읽는 방식도 다릅니다. 그래서 처음에는 &amp;ldquo;그냥 따로 짜는 게 더 단순하지 않나?&amp;rdquo;라는 생각이 들기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 기능이 자라기 시작할 때입니다. 예를 들어 드롭 규칙을 바꿔서 target 앞이 아니라 뒤에 넣는 조건을 추가했다고 해보겠습니다. 또는 source index와 target index가 같을 때는 아무 일도 안 하게 하기로 했다고 해보겠습니다. 이런 규칙은 입력 방식과 무관하게 &lt;b&gt;최종 재배치 결과&lt;/b&gt;에 관한 것입니다. 그런데 이 로직이 마우스 코드와 터치 코드에 각각 들어 있으면, 같은 수정을 두 번 해야 하고 언젠가는 둘 중 하나만 바뀌는 순간이 생깁니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;처음엔 편해 보여도 나중에 비용이 커지는 구조&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;처음엔 왜 편해 보이나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;나중에 왜 불편해지나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스와 터치를 완전히 따로 구현&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;각 입력 방식만 보면 흐름이 짧아 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재배치 규칙, 저장, 상태 초기화를 두 번 고쳐야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 걸 한 개의 거대한 이벤트 함수에 몰아넣음&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;겉으로는 하나의 코드로 통일된 것처럼 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;event 형태 차이까지 한 함수가 떠안으면서 오히려 읽기 어려워진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력별 처리와 공통 재배치 로직을 분리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음엔 한 단계 더 나눠 보는 느낌이 든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;이 더 큰 문제입니다. 이걸 잡아두면 이후에 드롭 규칙을 바꾸거나 order 계산 방식을 손볼 때 훨씬 덜 불안해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;입력 방식이 다르다고 해서 최종 데이터 변경 로직까지 따로 가져갈 필요는 없다. 오히려 그 부분이 분리돼 있을수록 같은 버그를 두 번 고치게 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공통으로 묶을 것과 따로 둘 것을 먼저 나눠야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 가장 중요한 감각은 &amp;ldquo;무조건 하나로 합친다&amp;rdquo;가 아닙니다. &lt;b&gt;무엇은 공통으로 묶고, 무엇은 입력별로 남겨둘지 선을 긋는 것&lt;/b&gt;입니다. 이 경계가 흐려지면 공통 함수도 무거워지고, 입력별 함수도 여전히 중복이 많아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 기준에서는 아래 정도로 나눠 보면 가장 이해가 쉽습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;공통으로 묶을 것과 입력별로 둘 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;공통으로 묶는 편이 좋은 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;입력별로 두는 편이 좋은 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 상태를 시작하는 함수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스의 시작 이벤트와 터치의 시작 이벤트 자체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대상 추적&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 target id와 before 또는 after 상태를 저장하는 방식&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스 좌표를 읽는 법, 터치 좌표를 읽는 법&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;order 재배치, 저장, 렌더링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop과 touchend가 언제 일어나는지에 대한 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 정렬 상태를 비우는 함수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragend, touchend, touchcancel 같은 종료 이벤트 연결&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;, 그 결과로 얻고 싶은 정보는 비슷합니다. dragged 항목이 누구인지, target이 누구인지, 앞에 넣을지 뒤에 넣을지, 실제로 정렬을 확정할지 말지. 그래서 raw event 자체를 억지로 하나로 만들려 들기보다, &lt;b&gt;event에서 필요한 정보만 꺼내서 공통 로직으로 내려보내는 구조&lt;/b&gt;가 훨씬 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;이벤트는 다르게 들어와도 괜찮다. 중요한 건 아래 단계로 내려갈 때 어떤 정보로 바꿔서 넘기느냐다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;&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;p data-ke-size=&quot;size16&quot;&gt;이걸 상태 객체로 묶으면 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let sortState = {

isSorting: false,
draggedId: null,
targetId: null,
position: null
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 이 구조가 마우스 전용도, 터치 전용도 아니라는 것입니다. 그냥 &lt;b&gt;재정렬을 설명하는 데 필요한 최소 정보&lt;/b&gt;입니다. 마우스는 dragstart나 dragover 같은 이벤트를 통해 이 값을 채워 넣고, 터치는 touchstart나 touchmove를 통해 같은 값을 채워 넣으면 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;공통 상태를 이렇게 보면 훨씬 덜 헷갈린다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;뜻&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;입력 방식과 무관하게 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;isSorting&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 정렬 흐름 안에 있는지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 움직이는 항목의 id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;targetId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 드롭 기준이 되는 항목의 id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;position&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before인지 after인지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 중요한 이유는, 이제부터는 마우스든 터치든 결국 이 상태를 업데이트하는 역할만 맡기면 된다는 점입니다. 즉, 이벤트 처리 함수는 &amp;ldquo;무슨 종류의 event가 들어왔는가&amp;rdquo;보다 &lt;b&gt;이 상태를 어떻게 채울 것인가&lt;/b&gt;에 집중하면 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이벤트를 직접 공통화하려고 하기보다, 이벤트가 남겨야 하는 상태를 공통화하는 편이 훨씬 현실적이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;draggedId, targetId, position이 충분한지 검사한다.&lt;/li&gt;
&lt;li&gt;같은 항목 위로 자기 자신을 놓는 상황인지 본다.&lt;/li&gt;
&lt;li&gt;재정렬 함수를 호출한다.&lt;/li&gt;
&lt;li&gt;저장한다.&lt;/li&gt;
&lt;li&gt;렌더링한다.&lt;/li&gt;
&lt;li&gt;임시 정렬 상태를 비운다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 아래처럼 공통 확정 함수를 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function commitSorting() {

const { isSorting, draggedId, targetId, position } = sortState;

if (!isSorting) {
clearSortingState();
return;
}

if (draggedId === null || targetId === null || !position) {
clearSortingState();
return;
}

if (draggedId === targetId) {
clearSortingState();
return;
}

moveTodoByDropPosition(draggedId, targetId, position);
clearSortingState();
}

function clearSortingState() {
sortState = {
isSorting: false,
draggedId: null,
targetId: null,
position: null
};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드의 장점은 분명합니다. 이제 order를 실제로 어떻게 바꿀지, 저장은 언제 할지, 정렬 종료 후 상태를 어떻게 비울지는 &lt;b&gt;한 군데에서만 관리&lt;/b&gt;됩니다. 이후 드롭 규칙을 바꾸거나, 드래그 종료 시 예외 처리를 늘리거나, 저장 타이밍을 조정하고 싶을 때도 이 구간만 보면 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;공통 재정렬 함수가 맡는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;맡기는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 공통으로 두는 편이 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 검증&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스와 터치가 같은 기준으로 종료 조건을 공유할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 재배치&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before와 after, order 재계산 규칙을 한 곳에서만 관리할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 초기화&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한쪽만 상태를 비우고 다른 쪽은 남는 문제를 줄일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 여기까지입니다. 공통 함수는 raw event를 이해할 필요가 없습니다. clientY가 어디에 있었는지, touch 좌표가 어떻게 들어오는지 같은 건 입력 쪽에서 처리하면 됩니다. 공통 함수는 &lt;b&gt;이미 정리된 상태 정보&lt;/b&gt;만 받아서 데이터 변경을 책임지는 편이 가장 깔끔합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 중요한 기준&lt;/b&gt;&lt;br /&gt;공통 함수는 event를 해석하는 곳이 아니라, 해석이 끝난 결과를 가지고 실제 순서를 바꾸는 곳이어야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마우스와 터치 핸들러는 얇게 두는 편이 좋은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 입력별 함수는 훨씬 가벼워질 수 있습니다. 이 함수들은 더 이상 order 계산이나 저장, 렌더링 전체를 직접 책임질 필요가 없습니다. 대신 &amp;ldquo;지금 들어온 입력을 sortState 형태로 바꿔주는 역할&amp;rdquo; 정도만 맡기면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 마우스 쪽은 아래처럼 단순해질 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleMouseDragStart(todoId) {

sortState.isSorting = true;
sortState.draggedId = todoId;
}

function handleMouseDragOver(event, todoId, element) {
event.preventDefault();

const position = getDropPositionFromY(event.clientY, element);

sortState.targetId = todoId;
sortState.position = position;
}

function handleMouseDrop(event) {
event.preventDefault();
commitSorting();
}

function handleMouseDragEnd() {
clearSortingState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터치 쪽도 비슷한 역할만 맡게 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleTouchStart(todoId) {

sortState.isSorting = true;
sortState.draggedId = todoId;
}

function handleTouchMove(event) {
if (!sortState.isSorting) return;

const touch = event.touches[0];
const currentElement = document.elementFromPoint(touch.clientX, touch.clientY);
const item = currentElement &amp;amp;&amp;amp; currentElement.closest('[data-todo-id]');

if (!item) return;

const todoId = Number(item.dataset.todoId);
const position = getDropPositionFromY(touch.clientY, item);

sortState.targetId = todoId;
sortState.position = position;

event.preventDefault();
}

function handleTouchEnd() {
commitSorting();
}

function handleTouchCancel() {
clearSortingState();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 좋은 이유는 분명합니다. 마우스와 터치가 서로 다른 점은 그대로 인정하면서도, &lt;b&gt;결국 같은 상태를 채우고 같은 공통 함수로 내려간다&lt;/b&gt;는 일관성을 가질 수 있기 때문입니다. 그래서 드롭 규칙이 바뀌면 commitSorting이나 moveTodoByDropPosition만 보면 되고, 좌표 읽는 방식을 바꾸고 싶으면 입력별 함수만 보면 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;얇은 입력 핸들러가 주는 장점&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 체감이 큰가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 범위가 줄어든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 방식 수정과 재배치 규칙 수정을 분리해서 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;디버깅이 쉬워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트 문제인지, 데이터 변경 문제인지 경계가 더 분명해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 버그를 두 번 고칠 일이 줄어든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 로직이 하나라서 동작 기준도 하나로 맞춰진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 많은 입문자가 오해하는 부분이 있습니다. &amp;ldquo;공통 로직으로 묶는다&amp;rdquo;는 말이 입력별 핸들러까지 모두 똑같이 만들라는 뜻은 아닙니다. 오히려 입력별 핸들러는 다르게 두되, 그들이 결국 같은 상태와 같은 확정 함수로 연결되도록 만드는 편이 훨씬 현실적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;입력별 핸들러는 raw event를 읽는 얇은 껍데기처럼 두고, 실제 데이터 변경은 공통 함수가 맡게 하면 구조가 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 자주 나오는 실수는 조금 비슷합니다. 공통화 자체가 문제가 아니라, &lt;b&gt;어디까지를 공통으로 묶을지 경계가 흐린 경우&lt;/b&gt;가 많습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스용과 터치용에서 재배치 함수를 각각 따로 갖고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버그를 한쪽만 고쳐서 동작이 달라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최종 데이터 변경 로직이 중복돼 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 재배치와 저장은 하나의 공통 함수가 맡는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모든 raw event를 한 함수에서 직접 처리하려 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;조건문이 많아지고 읽기가 갑자기 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통화 경계를 event 해석 단계까지 너무 올렸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;event 해석은 입력별, 데이터 변경은 공통으로 나눈다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;sortState를 입력별로 따로 가진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 이름은 비슷한데 정리 시점이 자꾸 다르다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 상태를 굳이 둘로 나눴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId, targetId, position은 하나의 구조로 보는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;clearSortingState를 한쪽 종료 흐름에만 붙인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 입력에서 이전 상태가 남아 이상하게 이어진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통 정리 단계가 빠져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리 함수는 공통으로 두고 각 종료 이벤트가 그 함수를 호출하게 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력별 규칙과 공통 규칙이 섞여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모바일에서만 스크롤 예외가 생기고 데스크톱에도 조건이 퍼진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 특수 규칙이 공통 함수 안까지 내려왔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;스크롤 제어 같은 입력 특수성은 입력별 핸들러에 남긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;공통화한 뒤에도 렌더링과 저장을 여러 곳에서 따로 호출한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 지점이 줄지 않고 여전히 여기저기 흩어진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재정렬 완료 시점이 하나로 모이지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정 함수 안에서만 저장과 렌더링이 일어나게 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 특히 많이 나오는 건 두 번째 실수입니다. 공통화한다고 해서 event.clientY와 touch 좌표 읽기까지 한 함수가 다 하게 만들면, 오히려 코드가 더 무거워집니다. 공통화의 목표는 &amp;ldquo;모든 줄을 하나로 합치는 것&amp;rdquo;이 아니라, &lt;b&gt;같은 의미를 가진 로직을 한곳에 모으는 것&lt;/b&gt;입니다. 이 차이를 놓치면 공통화도 실패하고 분리도 실패하게 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;공통화는 이벤트 종류를 하나로 만드는 일이 아니라, 서로 다른 이벤트가 결국 같은 상태와 같은 재배치 규칙으로 모이게 만드는 일에 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마우스와 터치를 각각 이해한 뒤, 그것을 하나의 재정렬 구조로 묶는 단계까지 오면 앱의 중심이 조금 더 선명해집니다. 이제는 &amp;ldquo;데스크톱용 코드&amp;rdquo;와 &amp;ldquo;모바일용 코드&amp;rdquo;가 따로 노는 것이 아니라, 입력은 달라도 결국 &lt;b&gt;같은 sortState를 채우고 같은 재정렬 함수로 내려간다&lt;/b&gt;는 흐름이 보이기 때문입니다. 이 구조가 잡히면 이후에 규칙을 고치거나 버그를 찾을 때도 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 입력 방식은 달라도 최종적으로 바뀌는 데이터는 같다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, raw event 자체를 억지로 하나로 만들기보다 event를 필요한 정보로 바꿔서 공통 로직으로 넘기는 편이 훨씬 현실적이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 저장과 렌더링, 상태 초기화처럼 &amp;ldquo;정렬 완료 후 반드시 같은 규칙으로 일어나야 하는 일&amp;rdquo;은 한 군데에 모아두는 편이 좋다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 재정렬 기능은 단순히 돌아가는 수준을 넘어, 어느 정도 설명 가능한 구조를 갖추게 됩니다. 다음 편에서는 여기서 한 걸음 더 나아가, &lt;b&gt;재정렬 기능과 수정 모드, 체크 상태, 삭제 버튼이 한 화면에서 겹칠 때 어떤 상태를 잠그고 어떤 상태는 유지해야 하는지&lt;/b&gt;를 다루겠습니다. 기능이 늘어날수록 결국 중요한 건 로직 하나보다 상태 충돌을 어떻게 줄이느냐이기 때문입니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>공통로직설계</category>
      <category>마우스터치통합</category>
      <category>상태관리구조</category>
      <category>이벤트추상화</category>
      <category>재정렬공통화</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/32</guid>
      <comments>https://story86025.tistory.com/32#entry32comment</comments>
      <pubDate>Tue, 7 Apr 2026 15:42:46 +0900</pubDate>
    </item>
    <item>
      <title>버전 하나가 끝났다고 프로젝트가 끝난 건 아니다: 개인 재무 서비스 개발 기록</title>
      <link>https://story86025.tistory.com/80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;공개 저장소를 열어보면 가끔 완성처럼 보이는 순간이 있습니다. 버전 번호가 붙어 있고, 들어갈 화면도 정리돼 있고, 문서도 적혀 있고, 테스트나 체크리스트 같은 흔적도 보일 때가 그렇습니다. 그런데 실제 프로젝트는 그 시점에서 끝나기보다, 오히려 그때부터 다음 단계가 더 또렷해지는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 본 개인 재무 서비스 프로젝트도 그런 쪽에 가깝습니다. 이미 사용자 입장에서 따라갈 수 있는 흐름은 보입니다. 대시보드가 있고, 재무설계가 있고, 추천이 있고, 공시 모니터링과 데이터 소스 설정 화면도 보입니다. 그렇다고 해서 이 프로젝트를 &amp;ldquo;완료된 결과물&amp;rdquo;로 읽는 건 조금 서두른 판단에 가깝습니다. 공개된 흔적을 조금만 더 보면, 지금은 &lt;b&gt;한 단계가 정리된 상태&lt;/b&gt;에 더 가깝고, 프로젝트 전체는 여전히 다음 국면으로 움직이고 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 글은 &amp;ldquo;무엇을 다 만들었다&amp;rdquo;를 정리하는 회고가 아닙니다. 대신 &lt;b&gt;왜 이 프로젝트를 아직 진행 중이라고 읽는 게 자연스러운지&lt;/b&gt;, 여기서 무엇을 배울 수 있는지를 차분하게 풀어보려 합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 뜻하는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;지금 더 중요하게 볼 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버전 번호가 있고 핵심 화면도 보입니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용 가능한 제품 단면이 정리됐다는 뜻에 가깝습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이후 변경을 버틸 구조와 기준이 잡혔는지를 봐야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;특정 단계의 완료 기준이 문서화돼 있습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 단계의 출구가 정리됐다는 뜻이지, 전체 프로젝트 종료는 아닙니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 단계에서 무엇을 더 단단하게 만들려는지 봐야 합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;최근 변경과 오픈 PR이 계속 이어집니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;프로젝트가 아직 적극적으로 다듬어지고 있다는 뜻입니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 기능 수보다 경계, 신뢰, 운영 기준 쪽이 더 중요해진 상태입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;프로젝트는 배포되거나 버전이 붙는 순간 끝나는 게 아니라, &lt;b&gt;다음 변경을 버틸 기준이 잡힐 때까지 계속 정리되는 일&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이번 글은 &amp;lsquo;완성 회고&amp;rsquo;가 아니라 &amp;lsquo;진행 중 기록&amp;rsquo;인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공개 저장소를 볼 때 가장 쉽게 하는 오해가 하나 있습니다. 화면이 여럿 보이고, README가 정리돼 있고, 버전명이 붙어 있으면 &amp;ldquo;이제 끝난 프로젝트구나&amp;rdquo;라고 받아들이는 겁니다. 물론 그런 흔적은 중요합니다. 하지만 실제 개발에서는 그보다 더 세밀한 구분이 있습니다. &lt;b&gt;이번 단계가 끝난 것&lt;/b&gt;과 &lt;b&gt;프로젝트 전체가 끝난 것&lt;/b&gt;은 다를 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 바로 그 차이를 잘 보여줍니다. 한쪽에서는 핵심 진입 경로와 기능 설명이 꽤 또렷하게 잡혀 있습니다. 그런데 다른 한쪽에서는 다음 단계로 보이는 planning v3 정리, 초안 센터 보강, 입력 흐름 안정화, 결과 신뢰 표시 같은 작업이 계속 이어집니다. 즉, 지금 상태는 &amp;ldquo;대충 만든 실험판&amp;rdquo;도 아니고, 그렇다고 &amp;ldquo;더 손볼 데 없는 완성본&amp;rdquo;도 아닙니다. &lt;b&gt;운영 가능한 단면이 나왔고, 그 위에 다음 단계 기준을 쌓는 중간 상태&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이는 생각보다 중요합니다. 왜냐하면 초보자는 종종 &amp;ldquo;돌아가면 끝&amp;rdquo;이라고 느끼기 쉽기 때문입니다. 그런데 실제로 오래 가는 프로젝트는, 돌아가는 시점 이후에 오히려 더 많은 정리가 들어갑니다. 기능을 하나 더 넣는 것보다, 지금 있는 흐름을 어떻게 설명하고 어떻게 검증하고 어디까지를 계약으로 고정할지를 먼저 다듬게 되기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;&amp;ldquo;쓸 수 있다&amp;rdquo;는 말과 &amp;ldquo;끝났다&amp;rdquo;는 말은 다릅니다. 공개 저장소를 읽을 때는 이 둘을 일부러 구분해서 보는 편이 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;pre class=&quot;jboss-cli&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;/dashboard
/planning
/recommend
/public/dart
/settings/data-sources
/products/catalog&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 목록만 봐도 프로젝트 성격이 어느 정도 드러납니다. 대시보드는 전체 흐름의 입구 역할을 하고, 재무설계는 사용자 입력을 토대로 현재 상태와 액션을 계산하는 중심부에 가깝습니다. 추천은 그 결과를 저장하거나 다시 꺼내 보는 흐름과 연결되고, DART 영역은 공시 모니터링처럼 외부 신호를 끌어오는 축으로 읽힙니다. 데이터 소스 설정 화면은 그 외부 데이터가 어떤 상태인지 보여주는 운영 축에 가깝고, 상품 카탈로그는 탐색과 비교 쪽으로 확장되는 면을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 중요할까요. 초보자 기준에서는 화면 수보다 &lt;b&gt;화면 간 역할 분담&lt;/b&gt;이 더 중요하기 때문입니다. 예를 들어 재무설계 화면이 따로 있고, 추천과 히스토리가 그 뒤를 받치고, 데이터 소스 상태가 또 따로 드러나 있다면, 이미 이 프로젝트는 &amp;ldquo;한 번 실행해보는 코드&amp;rdquo;를 넘어서 &lt;b&gt;하나의 제품 흐름&lt;/b&gt;을 가지기 시작한 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 일일 갱신이나 리포트 생성 같은 운영 축까지 붙어 있다는 건 더 의미가 큽니다. 사용자가 한 번 눌러보고 끝나는 페이지가 아니라, 시간이 지나도 정보를 다시 갱신하고 결과를 관리해야 하는 서비스라는 뜻이기 때문입니다. 즉, 이 프로젝트는 기능 몇 개를 나열한 샘플이 아니라, 이미 &lt;b&gt;입력&amp;middot;판단&amp;middot;추천&amp;middot;외부 데이터&amp;middot;운영 상태&lt;/b&gt;가 맞물리는 형태를 갖추고 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Planning v2 완료가 뜻하는 것과 뜻하지 않는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트를 보면서 가장 흥미로운 부분 중 하나는 &amp;ldquo;Planning v2 완료 판정&amp;rdquo; 같은 표현이 따로 적혀 있다는 점입니다. 쉽게 말하면, 개발자가 스스로 &amp;ldquo;이번 단계는 어디까지 충족하면 끝났다고 부를 것인가&amp;rdquo;를 미리 정해두는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 기준이 있다는 건 꽤 좋은 신호입니다. 지금 단계에서 무엇을 통과해야 하는지, 무엇을 아직 다음 단계로 넘겨야 하는지가 섞이지 않기 때문입니다. 기능이 많아질수록 이 기준이 없으면, 매번 &amp;ldquo;이제 끝난 건가?&amp;rdquo;를 감각으로만 판단하게 됩니다. 그러면 팀이 작든 혼자 하든 금방 흔들립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기서 중요한 건, &lt;b&gt;단계 완료와 프로젝트 완료를 혼동하지 않는 것&lt;/b&gt;입니다. 이번 프로젝트는 바로 그 지점을 잘 보여줍니다. 한 단계의 완료 기준은 정리돼 있지만, 공개 흔적을 보면 다음 단계에 해당하는 planning v3 관련 작업이 이미 따로 움직이고 있습니다. 즉, 이전 단계의 출구는 닫혔을 수 있어도, 전체 프로젝트의 문이 닫힌 건 아닙니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이 시점에서 구분해서 보면 좋은 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이 말이 뜻하는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이 말이 뜻하지 않는 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 단계의 기능과 검증 기준이 어느 정도 고정됐다는 뜻&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앞으로 UX 개선, 데이터 계약 정리, 다음 버전 설계가 더 없다는 뜻은 아닙니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이 시점부터는 다음 단계 작업을 별도 흐름으로 분리하기 쉬워진다는 뜻&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;프로젝트 전체가 완전히 닫혔다는 의미는 아닙니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 조금만 커져도, &amp;ldquo;기능 추가&amp;rdquo;와 &amp;ldquo;계약 정리&amp;rdquo;와 &amp;ldquo;검증 기준 고정&amp;rdquo;이 서로 다른 종류의 일이라는 걸 느끼게 되기 때문입니다. 그리고 실제 제품은 대개 이 셋이 번갈아 나오면서 자랍니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;버전 완료는 멈춤의 신호보다 &lt;b&gt;다음 단계를 분리해서 시작할 수 있는 기준점&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최근 작업이 보여주는 다음 단계: 경계, 신뢰, 운영&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 공개 흔적을 차분히 보면, 지금 이 프로젝트가 어디에 힘을 쓰고 있는지도 어느 정도 읽힙니다. 흥미로운 건 &amp;ldquo;화면을 더 많이 늘리는 일&amp;rdquo;보다 &lt;b&gt;경계를 정리하고, 결과를 더 믿을 수 있게 보여주고, 운영 기준을 다듬는 일&lt;/b&gt;이 계속 보인다는 점입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 경계를 먼저 고정하는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;planning v3 쪽에서 보이는 단어들을 보면 엔티티 모델, API 계약, 롤백 기준, 초안 센터 하드닝 같은 표현이 나옵니다. 이런 단어는 얼핏 기술적으로만 보일 수 있지만, 실제 의미는 꽤 단순합니다. &lt;b&gt;무엇이 들어오고, 무엇이 나가고, 문제가 생기면 어디까지 되돌릴 수 있는지&lt;/b&gt;를 먼저 정리하는 작업이라는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자는 여기서 종종 &amp;ldquo;기능이 덜 보이는데 왜 이런 걸 먼저 하지?&amp;rdquo;라고 느낄 수 있습니다. 그런데 프로젝트가 조금만 커지면 바로 이해가 됩니다. 입력 방식이 늘어나고, 초안 저장이나 업로드가 붙고, 추천 결과와 보고서가 이어지기 시작하면, 가장 먼저 흔들리는 건 화면보다도 &lt;b&gt;데이터가 오가는 약속&lt;/b&gt;입니다. 그래서 다음 단계로 넘어갈수록 기능보다 경계부터 고정하는 일이 더 중요해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 숫자만이 아니라 신뢰도 같이 보여주려는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 눈에 띄는 건 결과 신선도나 데이터 소스 신뢰를 더 분명하게 드러내려는 흔적입니다. 재무나 공공 데이터가 붙는 서비스에서는 이게 아주 중요합니다. 숫자 자체보다 먼저 드는 질문이 &amp;ldquo;이 정보는 지금 믿어도 되는가&amp;rdquo;이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 결과 카드에 freshness, 즉 최신성 정보를 붙이거나 데이터 소스 설정 화면을 단순한 옵션 모음이 아니라 신뢰 허브처럼 다시 정리하는 건, 생각보다 제품적인 판단입니다. 기능을 더 넣는 것처럼 화려하진 않지만, 사용자가 결과를 받아들이는 방식을 바꾸기 때문입니다. 특히 추천이나 상품, 환율, 구독 같은 정보는 시간이 지나면 값이나 해석이 달라질 수 있어서, &lt;b&gt;결과와 함께 상태를 보여주는 일&lt;/b&gt;이 중요해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 운영 기준이 제품 안으로 들어오는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QA 게이트, 골든 데이터셋, 릴리즈 체크리스트, 일일 갱신 파이프라인 같은 표현도 눈에 들어옵니다. 이건 개발자용 메모처럼 보일 수 있지만, 실제로는 제품과 꽤 가까운 일입니다. 왜냐하면 이런 기준이 있어야 이후 수정이 사용자를 덜 놀라게 만들 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 결과를 저장하는 흐름이 있고, 추천과 리포트가 서로 연결되고, 외부 데이터가 주기적으로 갱신된다면, 수정 하나가 여러 화면에 연쇄적으로 영향을 줄 수 있습니다. 이런 프로젝트에서는 &amp;ldquo;새 기능이 있느냐&amp;rdquo;보다 &amp;ldquo;수정 뒤에도 같은 기준으로 확인할 수 있느냐&amp;rdquo;가 더 중요해지는 시점이 옵니다. 지금은 바로 그 시점으로 넘어가는 과정처럼 읽힙니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;최근 흐름을 한 줄씩 요약하면 이렇습니다.

- 다음 단계의 엔티티 모델과 API/롤백 기준 정리
- 초안 센터와 업로드 흐름 보강
- 결과 카드의 최신성 표시 강화
- 데이터 소스 화면을 신뢰 중심으로 재정리
- QA/회귀/릴리즈 기준 고정
- 추천, 리포트, 기록 흐름의 연결 강화&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;프로젝트가 다음 단계로 넘어갈수록 중요한 건 화면 수가 아니라, &lt;b&gt;변경을 버틸 계약과 신뢰 표시와 운영 기준&lt;/b&gt;이 함께 자라는가입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;프로젝트는 &amp;ldquo;돌아간다&amp;rdquo;에서 끝나지 않는다&lt;/b&gt;는 점입니다. 돌아가는 순간은 출발선에 가깝고, 그다음에는 구조를 정리하고 기준을 고정하고 신뢰를 표현하는 일이 따라옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &lt;b&gt;문서와 체크리스트도 제품 작업&lt;/b&gt;이라는 점입니다. 초보자일수록 README나 체크리스트를 개발 바깥의 일처럼 느끼기 쉽습니다. 하지만 실제로는 그런 문서가 있어야 다음 변경이 덜 흔들리고, 나중에 다시 봐도 기준을 잃지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 공개 저장소를 읽을 때는 스크린샷보다 &lt;b&gt;이름과 흐름&lt;/b&gt;을 보는 편이 좋습니다. 어떤 화면이 메인 진입인지, 무엇이 저장되고 무엇이 다시 연결되는지, 왜 최신성이나 신뢰 같은 단어가 반복되는지 읽기 시작하면, 프로젝트가 어디쯤 와 있는지 훨씬 잘 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 바이브코딩을 하는 사람에게는 이 구간이 더 빨리 찾아옵니다. AI가 코드를 빠르게 만들어주면, 초기 화면과 기능은 예상보다 빨리 붙습니다. 대신 그다음부터는 &amp;ldquo;무엇을 더 넣을까&amp;rdquo;보다 &amp;ldquo;지금 있는 걸 어떤 기준으로 묶을까&amp;rdquo;가 더 어려워집니다. 이번 프로젝트가 보여주는 것도 바로 그 지점입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내 프로젝트에는 지금 어떤 화면이 실제 중심축인가&lt;/li&gt;
&lt;li&gt;이번 단계가 끝났다고 부를 기준이 따로 있는가&lt;/li&gt;
&lt;li&gt;다음 단계는 새 기능 추가인지, 아니면 계약과 검증 정리인지&lt;/li&gt;
&lt;li&gt;사용자가 결과를 믿을 수 있게 어떤 표시를 붙이고 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 네 가지 질문만 자주 던져도, 프로젝트를 보는 눈이 꽤 달라집니다. &amp;ldquo;코드가 몇 줄 늘었나&amp;rdquo;보다 &amp;ldquo;지금 무엇이 고정됐고 무엇이 아직 열려 있나&amp;rdquo;를 보게 되기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;이건 오히려 좋은 신호에 가깝습니다. 실제 제품은 대개 이런 식으로 자라기 때문입니다. 먼저 쓸 수 있는 단면이 나오고, 그다음에 경계를 고정하고, 그 위에 더 많은 기능과 운영 기준을 쌓습니다. 그래서 지금 이 프로젝트를 볼 때 중요한 건 &amp;ldquo;얼마나 많이 만들었나&amp;rdquo;보다 &lt;b&gt;다음 변화를 얼마나 덜 흔들리게 받을 준비를 하고 있나&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개인프로젝트/개인재무설계</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/80</guid>
      <comments>https://story86025.tistory.com/80#entry80comment</comments>
      <pubDate>Sun, 5 Apr 2026 18:41:51 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 15편: 할 일 앱에 검색 기능 붙이기</title>
      <link>https://story86025.tistory.com/43</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_code_202603241702.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHol52/dJMcadnOVtL/ksMxgxTaE2OcrKJmbOosik/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHol52/dJMcadnOVtL/ksMxgxTaE2OcrKJmbOosik/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHol52/dJMcadnOVtL/ksMxgxTaE2OcrKJmbOosik/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHol52%2FdJMcadnOVtL%2FksMxgxTaE2OcrKJmbOosik%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_code_202603241702.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;할 일 앱에 기능이 조금씩 붙기 시작하면, 어느 순간부터는 &quot;추가&quot;보다 &quot;찾기&quot;가 더 중요해집니다. 항목이 몇 개 안 될 때는 그냥 눈으로 훑어도 괜찮지만, 조금만 쌓여도 방금 적은 할 일이 어디 갔는지 다시 찾게 되는 순간이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 그 지점을 해결해보겠습니다. 다만 이번에도 범위를 크게 벌리지는 않겠습니다. 검색창 하나 넣고 끝내는 게 아니라, &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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색창과 검색 지우기 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;검색창에 들어 있는 현재 문자열을 따로 기억하는 값입니다. currentSearchQuery 같은 값이 여기에 해당합니다.&quot;&gt;검색 상태&lt;/span&gt;가 하나 더 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어떤 검색어를 기준으로 보여주는지 따로 기억하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 목록이라도 보이는 항목 수가 달라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터 결과 위에 검색 조건이 한 번 더 얹힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터와 검색이 어떤 순서로 겹치는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 중에는 이동 버튼이 잠긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보이는 항목 일부만 잡고 순서를 바꾸지 않도록 제한한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇을 일부러 막아야 덜 헷갈리는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;검색 기능의 핵심은 입력창을 하나 더 만드는 일이 아니라, &lt;b&gt;현재 필터 상태와 검색 상태를 함께 들고 있으면서 보여줄 목록을 다시 고르는 데&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;목록이 길어졌을 때 원하는 항목을 더 빨리 찾을 수 있습니다.&lt;/li&gt;
&lt;li&gt;필터와 검색이 같이 움직이는 구조를 연습할 수 있습니다.&lt;/li&gt;
&lt;li&gt;배열 전체가 아니라 지금 보여줄 항목만 다시 고르는 감각을 익히기 좋습니다.&lt;/li&gt;
&lt;li&gt;기존 기능을 유지한 채 상태 하나를 더 얹는 경험이 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;검색 기능이 들어오면 앱은 단순히 항목을 보여주는 수준을 넘어서, &lt;b&gt;지금 어떤 조건으로 보여주고 있는지&lt;/b&gt;까지 설명할 수 있어야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;입력할 때마다 검색 결과가 바로 반영되게 만듭니다.&lt;/li&gt;
&lt;li&gt;검색어 지우기 버튼도 같이 둡니다.&lt;/li&gt;
&lt;li&gt;전체, 진행 중, 완료 필터와 검색이 함께 동작하게 만듭니다.&lt;/li&gt;
&lt;li&gt;검색 중일 때는 순서 변경 버튼을 비활성화합니다.&lt;/li&gt;
&lt;li&gt;수정 중에 검색어를 바꾸면 편집 상태는 정리하고 다시 렌더링합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 필드를 동시에 검색하는 기능&lt;/li&gt;
&lt;li&gt;완전 일치 검색, 정규식 검색&lt;/li&gt;
&lt;li&gt;검색어를 브라우저 저장소에 따로 저장하는 기능&lt;/li&gt;
&lt;li&gt;검색 히스토리나 최근 검색어 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 이번 단계에서 진짜 봐야 할 것이 흐려지지 않습니다. 지금 중요한 건 &quot;검색 기능을 멋지게 만든다&quot;보다, &lt;b&gt;입력값 하나가 현재 화면 상태에 어떻게 연결되는지&lt;/b&gt;를 끝까지 읽는 데 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 검색이 왜 상태 기능인지 먼저 느껴보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;cleaned-image (1).png&quot; data-origin-width=&quot;1408&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baolV9/dJMcahcIl8p/pfR45i5NDBLR8F9WFnF47K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baolV9/dJMcahcIl8p/pfR45i5NDBLR8F9WFnF47K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baolV9/dJMcahcIl8p/pfR45i5NDBLR8F9WFnF47K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaolV9%2FdJMcahcIl8p%2FpfR45i5NDBLR8F9WFnF47K%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;1408&quot; height=&quot;768&quot; data-filename=&quot;cleaned-image (1).png&quot; data-origin-width=&quot;1408&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 항목이 있다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 운동하기      - 미완료
2. 회의 자료 정리 - 완료
3. 회의실 예약    - 미완료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 검색창에 &lt;b&gt;회의&lt;/b&gt;를 입력하면, 전체 보기에서는 2번과 3번이 보여야 합니다. 그런데 같은 검색어여도 진행 중 보기에서는 3번만 보여야 하고, 완료 보기에서는 2번만 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점이 중요합니다. 검색 기능은 항목을 없애는 게 아니라, &lt;b&gt;현재 필터 위에 검색 조건을 한 번 더 얹는 기능&lt;/b&gt;입니다. 즉, 실제 데이터는 그대로 있고, 지금 보여주는 기준만 달라집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;검색과 필터가 겹치면 이렇게 봅니다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;보여야 하는 항목&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 + 회의&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;회의 자료 정리, 회의실 예약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;진행 중 + 회의&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;회의실 예약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료 + 회의&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;는 구조를 먼저 잡는 데 있습니다. 이 감각이 먼저 잡히면 JavaScript도 훨씬 덜 복잡하게 보입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;검색 기능은 &quot;단어 찾기&quot;보다 &lt;b&gt;현재 상태를 하나 더 들고 있으면서 보여줄 결과를 다시 고르는 구조&lt;/b&gt;라고 이해하는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 검색 입력창과 지우기 버튼 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 HTML 최상단 구조를 크게 흔들지 않습니다. 입력창, 요약 영역, 필터 버튼, 메시지, 목록 영역은 그대로 두고, 그 사이에 검색 구역을 하나 더 넣는 정도면 충분합니다. 이 정도만 해도 화면 구조가 훨씬 자연스럽게 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Search Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          검색어에 맞는 항목만 빠르게 찾을 수 있게 만드는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 검색 기능 점검하기&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;search-section&quot;&amp;gt;
        &amp;lt;label for=&quot;searchInput&quot;&amp;gt;검색&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;search-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;searchInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 수정, 배포, 회의&quot;
          &amp;gt;
          &amp;lt;button
            id=&quot;clearSearchButton&quot;
            type=&quot;button&quot;
            class=&quot;secondary-button&quot;
          &amp;gt;
            검색 지우기
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 두 가지입니다. 하나는 &lt;b&gt;searchInput&lt;/b&gt;이고, 다른 하나는 &lt;b&gt;clearSearchButton&lt;/b&gt;입니다. 즉, 사용자가 검색어를 입력하는 자리와, 현재 검색 상태를 바로 지울 수 있는 버튼을 분명하게 나눠두는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구분이 중요한 이유는 검색 기능이 단순히 값을 읽는 것에서 끝나지 않기 때문입니다. 검색어가 있다는 건 곧 &quot;지금 전체 목록을 그대로 보여주고 있지 않다&quot;는 뜻이기도 합니다. 그래서 입력창과 지우기 버튼이 같이 있어야 현재 상태를 사용자가 더 쉽게 이해할 수 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML 단계에서는 검색창을 하나 더 넣는 것보다, &lt;b&gt;검색 상태를 시작하고 끝낼 수 있는 구조를 같이 만드는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 검색 영역과 비활성 상태를 분명하게 보이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능은 동작만큼이나 지금 검색 중인지 아닌지가 눈에 보여야 체감이 납니다. 그래서 이번 CSS의 핵심은 화려한 꾸밈보다 &lt;b&gt;검색 입력창, 지우기 버튼, 비활성 상태가 분명하게 읽히게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row,
.search-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section,
.search-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row,
  .search-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 특히 봐야 할 부분은 세 군데입니다.&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;search-section&lt;/b&gt; : 검색 영역이 필터 구역과 자연스럽게 이어지도록 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;search-row&lt;/b&gt; : 입력창과 지우기 버튼을 한 줄로 묶습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;secondary-button:disabled&lt;/b&gt; : 검색어가 없을 때 지우기 버튼이 눌릴 것처럼 보이지 않게 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 새로운 디자인을 만드는 작업이 아니라, &lt;b&gt;검색 기능이 들어온 뒤 화면이 답답해 보이지 않게 정리하는 작업&lt;/b&gt;에 가깝습니다. 검색창은 자주 쓰는 요소라서, 이 부분이 흐리면 작은 앱도 금방 복잡해 보이기 쉽습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;검색 UI는 화려함보다 &lt;b&gt;지금 검색 중인지, 지울 수 있는 상태인지가 분명하게 보이는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 검색 상태와 목록 렌더링 연결하기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1083&quot; data-origin-height=&quot;419&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxBont/dJMcadnOUxx/tn1a4KtmsIArZktBq3PnDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxBont/dJMcadnOUxx/tn1a4KtmsIArZktBq3PnDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxBont/dJMcadnOUxx/tn1a4KtmsIArZktBq3PnDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxBont%2FdJMcadnOUxx%2Ftn1a4KtmsIArZktBq3PnDk%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;1083&quot; height=&quot;419&quot; data-origin-width=&quot;1083&quot; data-origin-height=&quot;419&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 검색 기능은 결국 &lt;b&gt;현재 검색어를 따로 기억하고, 그 검색어에 맞는 항목만 다시 골라서 그리는 흐름&lt;/b&gt;이기 때문입니다. 여기에 기존 필터 상태까지 같이 묶이면, 이제부터는 상태를 하나만 다루는 앱에서 조금 더 나아가게 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;currentSearchQuery&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 검색어 상태를 따로 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 기능의 핵심 상태입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;normalizeText&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자열을 &lt;span data-ui=&quot;term&quot; data-note=&quot;문자열을 공백 제거와 소문자 변환으로 비교하기 쉽게 만드는 처리입니다.&quot;&gt;정규화&lt;/span&gt;합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대소문자와 공백 차이 때문에 결과가 어색해지는 걸 줄일 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;getFilteredTodos&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터와 검색어를 모두 반영해서 보여줄 목록을 고릅니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 기능의 실제 핵심이 여기에 들어 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;clearSearch&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 상태를 초기화합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  searchInput: document.querySelector(&quot;#searchInput&quot;),
  clearSearchButton: document.querySelector(&quot;#clearSearchButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let currentSearchQuery = &quot;&quot;;
let editingTodoId = null;
let editingText = &quot;&quot;;

function normalizeText(value) {
  return String(value || &quot;&quot;)
    .trim()
    .toLowerCase();
}

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    return savedValue ? JSON.parse(savedValue) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getFilteredTodos() {
  let result = todos;

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    result = result.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    result = result.filter(function (todo) {
      return todo.done;
    });
  }

  if (currentSearchQuery !== &quot;&quot;) {
    result = result.filter(function (todo) {
      return normalizeText(todo.text).includes(
        currentSearchQuery
      );
    });
  }

  return result;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();
  const visibleCount = getFilteredTodos().length;

  if (currentSearchQuery !== &quot;&quot;) {
    elements.summary.textContent =
      &quot;검색 결과 &quot; +
      visibleCount +
      &quot;개 &amp;middot; 전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개&quot;;
  } else {
    elements.summary.textContent =
      &quot;전체 &quot; +
      totalCount +
      &quot;개 &amp;middot; 남은 할 일 &quot; +
      remainingCount +
      &quot;개&quot;;
  }

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;

  elements.clearSearchButton.disabled =
    currentSearchQuery === &quot;&quot;;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일을 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  return &quot;검색하거나 위로, 아래로 버튼으로 순서를 바꿔보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentSearchQuery !== &quot;&quot;) {
    return &quot;검색어에 맞는 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &amp;amp;&amp;amp;
    currentSearchQuery === &quot;&quot; &amp;amp;&amp;amp;
    editingTodoId === null
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage(
      &quot;순서 변경은 전체 보기에서, 검색 중이 아닐 때만 할 수 있습니다.&quot;
    );
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === &quot;up&quot;
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex &amp;lt; 0 ||
    targetIndex &amp;gt;= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === &quot;up&quot;
      ? &quot;할 일을 위로 옮겼습니다.&quot;
      : &quot;할 일을 아래로 옮겼습니다.&quot;
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.textContent = todo.text;

  return text;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;위로&quot;,
    &quot;move-up-button&quot;,
    function () {
      moveTodo(todoId, &quot;up&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;아래로&quot;,
    &quot;move-down-button&quot;,
    function () {
      moveTodo(todoId, &quot;down&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoText(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  actions.className = &quot;todo-actions&quot;;

  editArea.append(
    createEditInput(todo.id)
  );

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false
  });

  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSearchInput() {
  currentSearchQuery = normalizeText(
    elements.searchInput.value
  );

  if (editingTodoId !== null) {
    editingTodoId = null;
    editingText = &quot;&quot;;
  }

  renderTodos();
}

function clearSearch() {
  if (currentSearchQuery === &quot;&quot;) {
    return;
  }

  currentSearchQuery = &quot;&quot;;
  elements.searchInput.value = &quot;&quot;;
  editingTodoId = null;
  editingText = &quot;&quot;;
  renderTodos(&quot;검색어를 지웠습니다.&quot;);
  elements.searchInput.focus();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindSearchEvents() {
  elements.searchInput.addEventListener(
    &quot;input&quot;,
    handleSearchInput
  );

  elements.clearSearchButton.addEventListener(
    &quot;click&quot;,
    clearSearch
  );
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
  bindSearchEvents();
}

bindEvents();
renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능의 핵심은 아주 단순하게 말하면 이겁니다. 검색창에 입력이 들어오면 검색 상태가 바뀌고, 그 상태에 맞는 목록만 다시 골라서 화면을 다시 그립니다. 그 과정에서 기존 필터 상태가 함께 적용되기 때문에, 같은 검색어라도 전체 보기인지 완료 보기인지에 따라 결과가 달라질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 점은 이번 편에서 &lt;b&gt;검색 중에는 순서 변경을 막았다&lt;/b&gt;는 부분입니다. 이건 구현을 못 해서가 아니라 지금 단계에서는 그쪽이 더 안전하기 때문입니다. 검색 결과로 일부 항목만 보이는 상태에서 순서를 바꾸면, 화면에 안 보이는 항목과의 위치 관계 때문에 결과가 헷갈리기 쉽습니다. 그래서 이번 단계에서는 사용자가 덜 혼란스러운 쪽을 택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 검색어를 바꾸는 순간 편집 상태를 정리한 것도 같은 이유입니다. 수정 중인 항목이 검색 결과에서 갑자기 사라지거나 남아 있는 상황까지 초반부터 다루면 흐름이 복잡해집니다. 지금은 &lt;b&gt;검색 상태가 바뀌면 편집 상태는 한 번 정리한다&lt;/b&gt;는 쪽이 훨씬 단순합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 검색창을 붙이는 작업이 아니라, &lt;b&gt;필터 상태와 검색 상태를 함께 들고 있으면서 보여줄 목록을 다시 고르는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;전체 보기에서 검색이 정상적으로 동작하는가&lt;/li&gt;
&lt;li&gt;진행 중 보기와 완료 보기에서도 검색이 같이 동작하는가&lt;/li&gt;
&lt;li&gt;검색어를 지우면 전체 목록으로 다시 돌아오는가&lt;/li&gt;
&lt;li&gt;검색 중에는 위로, 아래로 버튼이 비활성화되는가&lt;/li&gt;
&lt;li&gt;검색 도중 수정 모드를 시작했다가 검색어를 바꾸면 편집 상태가 정리되는가&lt;/li&gt;
&lt;li&gt;수정, 삭제, 전체 삭제, 필터가 검색 기능 추가 뒤에도 그대로 동작하는가&lt;/li&gt;
&lt;li&gt;새로고침 뒤에도 기존 할 일 데이터는 유지되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 동작이 안정적이라면 이번 기능은 성공이라고 봐도 됩니다. 중요한 건 &quot;검색 결과가 나온다&quot;보다, &lt;b&gt;검색 상태가 다른 기능들과 충돌 없이 같이 움직이는지&lt;/b&gt;를 보는 데 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;검색 기능은 입력창 하나보다, &lt;b&gt;다른 상태들과 충돌 없이 함께 움직이는지&lt;/b&gt;를 직접 눌러봐야 감이 옵니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 필터, 렌더링, 버튼 비활성화까지 같이 엮여 있기 때문에 범위를 더 분명하게 잘라야 합니다. &quot;검색 기능 넣어줘&quot;라고만 하면 AI가 예상보다 넓게 흔들 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 순서 변경, 저장 기능까지 정상 동작하는 상태야.
이번에는 검색 기능만 추가하고 싶어.
검색창에 입력하면 결과가 바로 반영되게 하고,
전체, 진행 중, 완료 필터와도 같이 동작하게 해줘.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 목록 렌더링 구조는 유지하고,
현재 검색어를 따로 들고 있으면서
검색 결과만 다시 보여주게 해줘.
기존 필터와 함께 동작해야 하고,
검색 중에는 위로, 아래로 버튼을 비활성화해줘.
검색 상태가 바뀌면 수정 중인 상태는 정리하는 쪽으로 가고 싶어.
새로 생길 상태값과 함수부터 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
검색 입력창과 검색 지우기 버튼을 추가할 거야.
기존 디자인을 크게 바꾸지 말고,
필터 영역 아래에 자연스럽게 붙는 검색 영역만 정리해줘.
검색어가 없을 때는 지우기 버튼이 비활성화된 상태가
눈에 띄게 보이게 해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 기능 범위를 넘어가서 전체 구조를 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 복잡한 문장보다 &lt;b&gt;무엇은 유지하고, 어디까지만 바꿀지 같이 적는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 검색 기능을 맡길 때는 &quot;검색 넣어줘&quot;보다 &lt;b&gt;필터와 함께 동작, 기존 기능 유지, 검색 중 순서 변경 비활성화&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 거창하지 않습니다. 검색 입력창 하나와 지우기 버튼이 생겼고, 현재 검색어에 맞는 항목만 다시 보여주게 만든 정도입니다. 그런데 실제로 앱을 써보면 이 차이가 꽤 큽니다. 이제는 목록이 길어져도 원하는 항목을 눈으로만 훑지 않아도 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 기능은 단순히 편리해졌다는 데서 끝나지 않습니다. 필터 상태와 검색 상태를 같이 들고 있으면서, 그 결과를 다시 화면에 반영하는 흐름을 한 번 직접 다뤄봤다는 점이 더 중요합니다. 이 감각이 붙기 시작하면, 앞으로 상태가 두세 개 겹치는 기능도 훨씬 덜 막막하게 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능이 들어가고 나면 이 앱은 이제 &quot;적고 지우는&quot; 수준을 조금 넘어서기 시작합니다. 목록이 길어졌을 때도 계속 쓸 수 있는 형태로 한 단계 더 가까워졌기 때문입니다. 바로 이런 작고 분명한 기능이, 프로젝트를 생각보다 오래 살아 있게 만듭니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>.AI코딩</category>
      <category>chatGPT</category>
      <category>검색기능</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>바이브코딩입문</category>
      <category>배열</category>
      <category>코딩독학</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/43</guid>
      <comments>https://story86025.tistory.com/43#entry43comment</comments>
      <pubDate>Fri, 3 Apr 2026 17:00:16 +0900</pubDate>
    </item>
    <item>
      <title>16. 데스크톱에선 되는데 모바일에선 왜 안 될까: 터치 기반 재정렬 입문</title>
      <link>https://story86025.tistory.com/30</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크톱에서 드래그 앤 드롭이 어느 정도 자연스럽게 돌아가기 시작하면, 거의 바로 다음 질문이 나옵니다. &lt;b&gt;&amp;ldquo;이제 모바일에서도 되게 만들 수 있을까?&amp;rdquo;&lt;/b&gt; 겉으로만 보면 그렇게 어려워 보이지 않습니다. 손가락으로 항목을 눌러서 옮기면 될 것 같기 때문입니다. 그런데 실제로 붙여보면 이 단계에서 다시 분위기가 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크톱에서는 마우스로 항목을 끌고 놓는 흐름이 비교적 분명합니다. 반면 모바일에서는 손가락이 &lt;b&gt;스크롤 도구이기도 하고, 클릭 도구이기도 하고, 드래그 도구이기도 합니다.&lt;/b&gt; 같은 입력 장치가 여러 역할을 겸하고 있기 때문에, 데스크톱에서 만든 흐름을 그대로 믿으면 갑자기 스크롤이 막히거나, 드롭 대상이 제대로 안 잡히거나, 손가락 아래에 가려진 위치를 제대로 읽지 못하는 문제가 생기기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 데스크톱 드래그가 모바일에서 그대로 안 맞는지부터 시작해서, 터치 기반 재정렬을 처음 붙일 때 어떤 규칙부터 정해야 하는지, 그리고 입문자라면 왜 처음부터 복잡한 모바일 드래그를 욕심내기보다 &lt;b&gt;드래그 핸들&lt;/b&gt;과 &lt;b&gt;터치 상태 분리&lt;/b&gt;부터 이해하는 편이 좋은지를 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데스크톱에선 잘 되던 드래그가 모바일에선 어색하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;손가락은 마우스와 다르게 스크롤과 드래그를 동시에 겸한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스 기준 규칙을 그대로 옮기지 말고 터치 규칙을 따로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그하려고 했는데 페이지가 먼저 스크롤된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;터치 이동을 브라우저가 스크롤로 받아들이고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어디서부터 드래그를 시작할지 명확한 규칙이 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 위치가 이상하게 잡힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;손가락 아래 대상 요소를 마우스처럼 바로 읽기 어렵다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 터치 좌표로 실제 대상 요소를 다시 찾는 흐름이 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모바일용으로 고치다 보니 코드가 더 복잡해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스와 터치의 차이를 구분하지 않고 한꺼번에 섞었다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 터치용 규칙을 따로 분명하게 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;모바일 재정렬은 마우스를 손가락으로 바꾸는 문제가 아니라, 스크롤과 드래그가 같은 입력 안에서 충돌하는 환경을 다시 설계하는 문제에 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 데스크톱 드래그가 모바일에서 그대로 안 맞을까&lt;/li&gt;
&lt;li&gt;마우스 이벤트와 터치 이벤트는 무엇이 다른가&lt;/li&gt;
&lt;li&gt;첫 모바일 버전에서 먼저 정해야 할 규칙&lt;/li&gt;
&lt;li&gt;가장 현실적인 첫 접근: 드래그 핸들 + touchstart, touchmove, touchend&lt;/li&gt;
&lt;li&gt;스크롤과 드래그가 충돌할 때는 어떻게 볼까&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 데스크톱 드래그가 모바일에서 그대로 안 맞을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크톱에서 드래그는 비교적 단순합니다. 마우스를 누르고 움직이고 놓는 흐름이 있고, 사용자는 보통 포인터가 무엇을 가리키는지도 눈으로 명확하게 확인할 수 있습니다. 반면 모바일에서는 손가락 자체가 화면 일부를 가립니다. 즉, 지금 내가 정확히 어느 항목의 위쪽 절반에 있는지 아래쪽 절반에 있는지 보기가 생각보다 쉽지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 손가락은 스크롤을 담당하는 입력이기도 합니다. 사용자는 목록을 위아래로 읽기 위해 스와이프를 하고, 같은 동작처럼 보이는 제스처로 항목을 옮기고 싶어 하기도 합니다. 이 둘을 코드가 구분하지 못하면, 순서 변경을 하려던 손동작이 스크롤로 처리되거나 반대로 스크롤하려던 손동작이 드래그 시작으로 해석될 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;데스크톱과 모바일은 입력 환경 자체가 다르다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;데스크톱에서 비교적 쉬운 점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;모바일에서 갑자기 어려워지는 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 도구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스는 클릭과 드래그 역할이 비교적 분명하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;손가락은 클릭, 스크롤, 드래그 역할이 겹친다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정밀도&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;포인터 위치를 비교적 정확하게 볼 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;손가락이 화면을 가려 대상 판단이 더 어렵다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;스크롤 충돌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보통 휠이나 트랙패드가 따로 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 손동작이 스크롤과 드래그 후보가 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 피드백&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;포인터와 표시선을 동시에 보기 쉽다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;모바일에서는 &amp;ldquo;손가락이 지금 스크롤을 하려는가, 순서를 바꾸려는가&amp;rdquo;를 앱이 먼저 구분해줘야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마우스 이벤트와 터치 이벤트는 무엇이 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 입장에서 가장 먼저 헷갈리는 건 이벤트 이름입니다. 데스크톱에서는 dragstart, dragover, drop 같은 흐름을 따라왔는데, 모바일로 오면 touchstart, touchmove, touchend 같은 이름이 보입니다. 이름이 달라지니 전혀 다른 세계처럼 느껴질 수 있습니다. 그런데 역할로 보면 생각보다 크게 다르지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 &amp;ldquo;이름을 외우는 것&amp;rdquo;보다 &lt;b&gt;각 단계가 무슨 책임을 맡는지&lt;/b&gt;를 이해하는 것입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;데스크톱 드래그 흐름과 모바일 터치 흐름을 역할로 비교해보기&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;데스크톱에서 자주 보던 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;모바일에서 보게 되는 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragstart&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchstart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이동 중 추적&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchmove&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;놓기 또는 확정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중단 또는 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragend&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchend 또는 touchcancel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일에서 특히 신경 써야 하는 점은 &lt;b&gt;touchmove 안에서 지금 손가락 아래에 실제로 어떤 요소가 있는지 다시 찾는 과정&lt;/b&gt;이 필요하다는 것입니다. 데스크톱에서는 포인터 기준으로 흐름이 비교적 자연스럽게 이어지지만, 터치에서는 사용자의 손가락 아래에 target이 가려질 수 있고, 이벤트를 처음 받은 요소와 현재 아래 있는 요소가 다를 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 모바일 첫 버전에서는 보통 아래 같은 상태를 따로 들고 갑니다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let touchDraggedId = null;

let touchTargetId = null;
let touchDropPosition = null;
let isTouchSorting = false;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태들은 모두 &lt;b&gt;임시 상태&lt;/b&gt;입니다. 실제 데이터인 todos를 바로 바꾸는 것이 아니라, 지금 어떤 항목을 잡았는지, 어디로 놓으려는지, 지금 정렬 모드가 켜져 있는지를 기억하는 데 쓰입니다. 이 구분은 데스크톱 드래그에서도 중요했지만, 모바일에서는 더 중요해집니다. 왜냐하면 손가락 이동 중에 발생하는 임시 상태 변화가 훨씬 많기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 이해하면&lt;/b&gt;&lt;br /&gt;모바일 터치 정렬도 결국 시작, 이동 중 추적, 놓기, 정리의 흐름이다. 이름이 달라졌을 뿐, 무슨 역할을 맡는지는 크게 다르지 않다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 모바일 버전에서 먼저 정해야 할 규칙&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 재정렬은 처음부터 규칙을 좁게 잡는 편이 훨씬 낫습니다. 데스크톱에서는 조금 투박해도 마우스로 커버가 되지만, 모바일에서는 같은 애매함이 곧바로 불편함으로 느껴집니다. 그래서 &amp;ldquo;어디를 누르면 정렬 시작인지&amp;rdquo;, &amp;ldquo;지금은 스크롤인지 정렬인지&amp;rdquo;, &amp;ldquo;어떤 모드에서만 허용할지&amp;rdquo;를 먼저 정해두는 편이 좋습니다.&lt;/p&gt;
&lt;p data-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;br /&gt;스크롤하려고 터치했는데 정렬이 시작되는 문제를 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;custom 모드에서만 재정렬을 허용한다&lt;/b&gt;&lt;br /&gt;최신순이나 오래된순 같은 자동 정렬 모드에서는 사용자가 방금 옮긴 결과를 화면에서 바로 이해하기 어렵습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 보기에서만 먼저 허용한다&lt;/b&gt;&lt;br /&gt;검색이나 필터가 걸린 상태에서는 숨겨진 항목 때문에 결과가 더 헷갈릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;첫 버전에서는 길게 누르기보다 핸들 시작을 우선한다&lt;/b&gt;&lt;br /&gt;길게 누르기까지 같이 붙이면 규칙이 하나 더 늘어서 입문자에게는 오히려 흐름이 흐려질 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 모바일 버전에서 먼저 정하면 좋은 규칙&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;규칙&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 이렇게 두는가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;핸들에서만 정렬 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 본문 터치는 스크롤과 클릭에 남겨둘 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;custom 모드 한정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 순서와 자동 정렬 규칙이 섞이는 혼란을 줄일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 보기 한정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보이는 항목만 움직이는 상황과 전체 순서가 섞이는 걸 막을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 간단한 규칙 우선&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 감각으로 정리하면&lt;/b&gt;&lt;br /&gt;모바일 재정렬은 &amp;ldquo;무엇을 할 수 있게 만들까&amp;rdquo;보다 먼저 &amp;ldquo;무엇은 아직 못 하게 둘까&amp;rdquo;를 정하는 쪽이 훨씬 중요할 때가 많다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 현실적인 첫 접근: 드래그 핸들 + touchstart, touchmove, touchend&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;HTML 구조는 아래처럼 단순하게 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;li class=&quot;todo-item&quot; data-todo-id=&quot;101&quot;&amp;gt;

순서 이동
장보기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 드래그 핸들에만 터치 시작 이벤트를 붙이고, 이동 중에는 현재 손가락 아래에 있는 요소를 다시 찾는 것입니다. 이때 초보자에게 자주 도움이 되는 방식이 &lt;b&gt;document.elementFromPoint&lt;/b&gt;입니다. 현재 터치 좌표 아래에 어떤 요소가 있는지를 찾아서, 그 요소가 어느 항목인지 다시 확인하는 식입니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let touchDraggedId = null;

let touchTargetId = null;
let touchDropPosition = null;
let isTouchSorting = false;

function getDropPositionFromTouch(touch, element) {
const rect = element.getBoundingClientRect();
const middleY = rect.top + rect.height / 2;

return touch.clientY &amp;lt; middleY ? 'before' : 'after';
}

function handleTouchStart(event, todoId) {
isTouchSorting = true;
touchDraggedId = todoId;
}

function handleTouchMove(event) {
if (!isTouchSorting) return;

const touch = event.touches[0];
const currentElement = document.elementFromPoint(touch.clientX, touch.clientY);
const item = currentElement &amp;amp;&amp;amp; currentElement.closest('[data-todo-id]');

if (!item) return;

touchTargetId = Number(item.dataset.todoId);
touchDropPosition = getDropPositionFromTouch(touch, item);

event.preventDefault();
}

function handleTouchEnd() {
if (isTouchSorting &amp;amp;&amp;amp; touchDraggedId !== null &amp;amp;&amp;amp; touchTargetId !== null &amp;amp;&amp;amp; touchDropPosition) {
moveTodoByDropPosition(touchDraggedId, touchTargetId, touchDropPosition);
}

resetTouchSorting();
}

function resetTouchSorting() {
touchDraggedId = null;
touchTargetId = null;
touchDropPosition = null;
isTouchSorting = false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 초보자가 꼭 봐야 할 부분은 세 군데입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;touchstart&lt;/b&gt;에서는 실제 데이터 변경이 아니라 &amp;ldquo;누구를 움직일지&amp;rdquo;만 잡습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;touchmove&lt;/b&gt;에서는 현재 손가락 아래 target과 before&amp;middot;after를 계속 갱신합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;touchend&lt;/b&gt;에서만 실제 재배치를 실행합니다.&lt;/li&gt;
&lt;/ol&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자 기준으로 특히 중요한 점&lt;/b&gt;&lt;br /&gt;모바일 드래그도 결국은 &amp;ldquo;누구를 잡았는가&amp;rdquo;, &amp;ldquo;어디 위에 있는가&amp;rdquo;, &amp;ldquo;손을 뗐을 때 어떻게 반영할 것인가&amp;rdquo;를 나누는 문제다. 한 번에 다 하려 하면 오히려 더 헷갈린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;그래서 여기서는 &amp;ldquo;무조건 preventDefault를 붙이면 된다&amp;rdquo;처럼 생각하면 오히려 더 꼬입니다. 전체 목록에서 터치 이동을 전부 막아버리면, 이제 드래그는 되더라도 페이지 스크롤이 너무 불편해질 수 있기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;스크롤과 드래그 충돌을 볼 때 먼저 구분할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 불편해지나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;더 나은 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 전체를 드래그 시작 영역으로 둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;스크롤 의도와 정렬 의도가 쉽게 충돌한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;핸들 영역만 정렬 시작점으로 두는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 리스트에서 무조건 기본 동작을 막는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;페이지 스크롤이 답답해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 정렬 중일 때만 필요한 범위에서 막는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기본 동작을 전혀 안 막는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서 변경보다 스크롤이 먼저 반응할 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;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;정렬 중인 touchmove에서만 필요한 제어를 한다&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS 쪽에서 핸들에만 별도 힌트를 주는 방식도 도움이 될 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;.drag-handle {

touch-action: none;
}&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁&lt;/b&gt;&lt;br /&gt;모바일에서 스크롤과 드래그를 모두 만족시키려면, &amp;ldquo;어디를 누르면 정렬 시작인지&amp;rdquo;를 먼저 좁히는 편이 &amp;ldquo;전체를 다 되게 하려는 시도&amp;rdquo;보다 훨씬 효과적이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 재정렬을 붙일 때는 비슷한 실수가 반복됩니다. 겉보기에는 &amp;ldquo;이벤트 이름만 바꾸면 될 것 같은데&amp;rdquo; 싶지만, 실제로는 입력 환경이 달라지기 때문에 데스크톱에서 통하던 직관이 그대로 안 맞는 경우가 많습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데스크톱의 dragstart, dragover, drop만 그대로 믿는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모바일에서는 기대한 반응이 안 나온다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;터치 환경에서 필요한 상태 추적이 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;터치용 흐름을 따로 설계하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 전체를 드래그 시작 영역으로 둔다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;스크롤하려다 자꾸 재정렬이 시작된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;스크롤과 정렬의 시작점이 분리되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;핸들 영역을 따로 두는 편이 훨씬 안정적이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchmove에서 현재 대상 요소를 다시 안 찾는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 위치가 계속 이상하거나 이전 요소 기준으로 남는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;손가락 이동 중 target 추적이 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 좌표 아래 요소를 다시 찾는 흐름이 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기본 동작을 전체에서 다 막는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록 스크롤이 지나치게 불편해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 중이 아닌 상황까지 같이 막았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬이 실제로 진행 중인 범위에서만 제어하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;touchend만 보고 touchcancel을 빼먹는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간에 상태가 꼬여 다음 터치에서 이상하게 이어진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예상 밖 종료 상황에서 임시 상태를 정리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정리 함수는 종료 계열 이벤트에서 같이 호출하는 편이 좋다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색, 필터, 자동 정렬 상태에서 그대로 재정렬을 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과 설명이 더 어려워지고 버그처럼 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 계산 규칙과 사용자 순서 변경이 겹쳐 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 custom 모드와 전체 보기에서 먼저 안정화한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 다섯 번째 실수는 데스크톱만 테스트하다 보면 의외로 놓치기 쉽습니다. 모바일에서는 중간에 전화 알림이 뜨거나 앱 전환처럼 흐름이 갑자기 끊길 수 있습니다. 이럴 때 임시 정렬 상태를 제대로 정리하지 않으면, 다음 터치부터는 &amp;ldquo;왜 갑자기 이전 항목이 드래그된 것처럼 보이지?&amp;rdquo; 같은 묘한 문제가 생길 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;모바일 재정렬은 코드 한 줄보다 입력 규칙을 먼저 분명히 해야 한다. 정렬 시작점, 스크롤 허용 범위, 임시 상태 정리 시점이 흐려지면 기능이 금방 불안해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;에 가깝습니다. 이 감각이 생기면 왜 핸들이 필요해지는지, 왜 touchmove에서 target을 다시 찾아야 하는지, 왜 전체를 한 번에 다 되게 하려 하기보다 규칙을 줄여서 시작하는 편이 좋은지도 훨씬 또렷해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 모바일에서는 같은 손동작이 스크롤과 드래그 후보가 되기 때문에 시작 규칙이 특히 중요하다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 첫 버전은 항목 전체가 아니라 핸들에서만 정렬을 시작하게 만드는 편이 훨씬 안정적이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 터치 재정렬도 결국은 임시 상태 추적과 실제 데이터 변경을 분리해서 보는 흐름이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 데스크톱용과 모바일용 흐름을 각각 이해한 셈입니다. 다음 편에서는 이 둘을 따로따로 관리하던 구조를 한 단계 더 정리해서, &lt;b&gt;마우스와 터치를 하나의 재정렬 로직으로 합쳐보는 방법&lt;/b&gt;을 다루겠습니다. 즉, 입력 방식은 달라도 최종적으로 바뀌는 것은 같은 order 데이터라는 관점으로 다시 묶어보는 단계입니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>touchmove</category>
      <category>touchstart</category>
      <category>모바일드래그</category>
      <category>모바일재정렬</category>
      <category>터치이벤트</category>
      <category>터치정렬입문</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/30</guid>
      <comments>https://story86025.tistory.com/30#entry30comment</comments>
      <pubDate>Thu, 2 Apr 2026 12:11:17 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 14편: 할 일 순서를 위아래로 바꾸기</title>
      <link>https://story86025.tistory.com/38</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_code_202603241546.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rWW0a/dJMb996Pwd6/8xpfdk6WHZs1cEc45oPqIk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rWW0a/dJMb996Pwd6/8xpfdk6WHZs1cEc45oPqIk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rWW0a/dJMb996Pwd6/8xpfdk6WHZs1cEc45oPqIk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrWW0a%2FdJMb996Pwd6%2F8xpfdk6WHZs1cEc45oPqIk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_code_202603241546.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;할 일 앱을 조금만 써보면 금방 느끼는 순간이 있습니다. 항목을 추가하고, 체크하고, 수정하고, 삭제하는 것까지는 되는데 정작 &lt;b&gt;순서를 바꾸는 기능이 없으면&lt;/b&gt; 생각보다 답답하다는 점입니다. 가장 급한 일을 위로 올리고 싶을 때도 있고, 방금 추가한 항목을 아래로 내리고 싶을 때도 있는데 지금 구조에서는 그게 안 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 이 불편을 직접 해결해보겠습니다. 다만 이번에도 범위를 크게 벌리지 않겠습니다. 처음부터 &lt;span data-ui=&quot;term&quot; data-note=&quot;마우스로 항목을 끌어다 놓아서 순서를 바꾸는 방식입니다.&quot;&gt;드래그 앤 드롭&lt;/span&gt;으로 가면 겉보기는 멋있을 수 있어도, 초보자 기준에서는 상태를 따라가기가 확실히 더 어렵습니다. 그래서 이번에는 &lt;b&gt;&quot;위로&quot;, &quot;아래로&quot; 버튼으로 순서를 바꾸는 방식&lt;/b&gt;으로 가겠습니다. 이쪽이 훨씬 단순하고, &lt;span data-ui=&quot;term&quot; data-note=&quot;같은 자료형의 값들을 순서대로 담아두는 목록입니다. todos가 여기에 해당합니다.&quot;&gt;배열&lt;/span&gt; 안에서 실제로 무슨 일이 일어나는지 읽기에도 좋습니다.&lt;/p&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-origin-width=&quot;1360&quot; data-origin-height=&quot;777&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9fv28/dJMcafMLXwy/mhu06N5ZueMqUI1vXA3wXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9fv28/dJMcafMLXwy/mhu06N5ZueMqUI1vXA3wXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9fv28/dJMcafMLXwy/mhu06N5ZueMqUI1vXA3wXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9fv28%2FdJMcafMLXwy%2Fmhu06N5ZueMqUI1vXA3wXk%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;1360&quot; height=&quot;777&quot; data-origin-width=&quot;1360&quot; data-origin-height=&quot;777&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진행 중 보기나 완료 보기 상태에서 순서를 바꾸면, 숨겨진 항목과 섞여서 움직이기 때문에 초보자 입장에서는 결과가 헷갈리기 쉽습니다. 이번 단계에서는 사용자가 덜 헷갈리는 쪽이 맞습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;각 항목에 위로, 아래로 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 항목의 &lt;span data-ui=&quot;term&quot; data-note=&quot;지금 어떤 항목이 몇 번째 위치에 있는지를 나타내는 번호입니다.&quot;&gt;인덱스&lt;/span&gt;를 찾고 위치를 바꾸는 로직이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;배열 안 순서가 화면 순서와 연결된다는 점을 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;맨 위와 맨 아래 버튼은 비활성화된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;더 이상 이동할 수 없는 상태를 코드와 UI가 같이 판단한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼이 언제 잠겨야 하는지 기준이 분명한지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;진행 중, 완료 보기에서는 이동이 막힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 보기에서만 수동 순서 변경을 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터된 목록과 실제 순서를 일부러 분리해서 보는 이유를 이해한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;이번 기능의 핵심은 버튼 두 개를 더 넣는 게 아니라, &lt;b&gt;배열 안 순서를 바꾸면 화면 순서도 같이 바뀐다&lt;/b&gt;는 흐름을 직접 다뤄보는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;할 일의 우선순위를 손으로 정리할 수 있습니다.&lt;/li&gt;
&lt;li&gt;같은 목록이라도 더 쓰기 편한 상태로 바꿀 수 있습니다.&lt;/li&gt;
&lt;li&gt;배열 안의 항목 순서가 실제 화면 순서와 연결된다는 감각을 익힐 수 있습니다.&lt;/li&gt;
&lt;li&gt;기존 기능을 유지한 채 새로운 동작 하나를 더 얹는 연습이 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 기능의 핵심은 &quot;버튼을 하나 더 만든다&quot;가 아니라, &lt;b&gt;배열 안의 순서를 바꾸면 화면 순서도 같이 바뀐다&lt;/b&gt;는 흐름을 몸으로 익히는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;b&gt;아래로&lt;/b&gt; 버튼을 추가합니다.&lt;/li&gt;
&lt;li&gt;맨 위 항목의 &quot;위로&quot; 버튼은 비활성화합니다.&lt;/li&gt;
&lt;li&gt;맨 아래 항목의 &quot;아래로&quot; 버튼은 비활성화합니다.&lt;/li&gt;
&lt;li&gt;순서 변경은 &lt;b&gt;전체 보기&lt;/b&gt;에서만 가능합니다.&lt;/li&gt;
&lt;li&gt;수정 중일 때는 순서 변경 버튼도 잠깐 비활성화합니다.&lt;/li&gt;
&lt;li&gt;이동 후에는 저장까지 함께 반영되게 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;드래그 앤 드롭 정렬&lt;/li&gt;
&lt;li&gt;진행 중 보기나 완료 보기에서의 순서 재정렬&lt;/li&gt;
&lt;li&gt;키보드 방향키로 이동하는 복잡한 편집 흐름&lt;/li&gt;
&lt;li&gt;순서 변경 기록을 따로 되돌리는 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 지금 단계에서 필요한 핵심이 또렷해집니다. 이번 편에서 가장 중요한 건 &quot;순서 변경 기능 완성&quot;이 아니라, &lt;b&gt;현재 배열에서 항목 위치를 바꾸고 그 결과를 다시 그리는 방식&lt;/b&gt;을 이해하는 것입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 순서 변경이 왜 결국 배열 문제인지 먼저 느껴보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;cleaned-image.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRGEbO/dJMcagx5SBW/msUzOR6COLq6kpEZlfQjo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRGEbO/dJMcagx5SBW/msUzOR6COLq6kpEZlfQjo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRGEbO/dJMcagx5SBW/msUzOR6COLq6kpEZlfQjo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRGEbO%2FdJMcagx5SBW%2FmsUzOR6COLq6kpEZlfQjo0%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;2816&quot; height=&quot;1536&quot; data-filename=&quot;cleaned-image.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 아주 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 항목이 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;[0] 장보기
[1] 메일 답장
[2] 운동하기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &quot;메일 답장&quot;을 위로 올린다면, 실제로는 화면을 움직이는 게 아니라 배열 안의 위치가 이렇게 바뀌는 겁니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;[0] 메일 답장
[1] 장보기
[2] 운동하기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 순서 변경 기능은 &quot;화면에서 카드가 움직인다&quot;처럼 느껴지지만, 실제로는 &lt;b&gt;배열 안에서 항목 하나를 꺼내 다른 위치에 다시 넣는 작업&lt;/b&gt;에 더 가깝습니다. 이 감각이 먼저 잡히면 JavaScript가 훨씬 덜 낯설게 보입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;순서 변경에서 실제로 일어나는 일&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 동작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 필요한 계산&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;위로 버튼을 누른다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 인덱스에서 1을 뺀 위치로 옮긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아래로 버튼을 누른다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 인덱스에서 1을 더한 위치로 옮긴다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;맨 위 항목은 더 못 올라간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대상 인덱스가 0보다 작아지면 막는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;맨 아래 항목은 더 못 내려간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;순서 변경 기능은 결국 &lt;b&gt;배열 안에서 항목을 어디로 옮길지 계산하는 문제&lt;/b&gt;라고 보면 훨씬 이해하기 쉽습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML은 구조를 그대로 두고 목록 한 줄 동작만 바꾼다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 의외로 HTML 최상단 구조를 크게 건드리지 않습니다. 입력창, 요약 영역, 필터 버튼, 메시지, 목록 영역은 그대로 두고, 실제 변화는 목록 한 줄을 그릴 때 일어납니다. 즉, &quot;위로&quot;, &quot;아래로&quot; 버튼은 정적인 HTML에 박아두기보다 JavaScript가 각 항목을 만들 때 같이 붙여주는 쪽이 더 자연스럽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 유지하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Order Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          할 일 순서를 위아래로 직접 정리할 수 있게 만드는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 우선순위 다시 정리하기&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML에서 중요한 건 새 태그가 많이 생겼느냐가 아닙니다. 오히려 &lt;b&gt;지금 구조를 유지한 채 목록 한 줄의 구성만 JavaScript에서 바꾸게 할 수 있느냐&lt;/b&gt;가 더 중요합니다. 초보자 기준에서는 이 감각이 꽤 큽니다. 매번 HTML 전체를 뒤집는 방식보다, 필요한 부위만 동적으로 바꾸는 쪽이 훨씬 읽기 쉽기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 편에서는 HTML 전체를 뜯는 것보다, &lt;b&gt;목록 한 줄 안에 어떤 버튼이 추가될지 JavaScript 쪽에서 관리하는 구조&lt;/b&gt;를 이해하는 편이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 이동 버튼과 비활성 상태를 분명하게 보이기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;711&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZEnxC/dJMcabp33hj/kj1IfDQLZ7K6Sigpu9aqKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZEnxC/dJMcabp33hj/kj1IfDQLZ7K6Sigpu9aqKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZEnxC/dJMcabp33hj/kj1IfDQLZ7K6Sigpu9aqKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZEnxC%2FdJMcabp33hj%2Fkj1IfDQLZ7K6Sigpu9aqKK%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;1094&quot; height=&quot;711&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;711&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 변경 기능은 버튼을 누르는 것 자체도 중요하지만, &lt;b&gt;지금 이 버튼이 눌릴 수 있는 상태인지&lt;/b&gt;가 같이 보여야 훨씬 덜 헷갈립니다. 그래서 이번 CSS는 화려하게 꾸미는 것보다, 이동 버튼과 비활성 상태를 명확하게 읽히게 만드는 쪽이 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.save-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button,
.move-up-button,
.move-down-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
.move-up-button:disabled,
.move-down-button:disabled,
input:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button,
  .move-up-button,
  .move-down-button {
    flex: 1 1 auto;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 특히 중요한 건 세 가지입니다.&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;move-up-button&lt;/b&gt;과 &lt;b&gt;move-down-button&lt;/b&gt;이 다른 보조 버튼과 자연스럽게 섞이도록 맞췄습니다.&lt;/li&gt;
&lt;li&gt;맨 위나 맨 아래에서 버튼이 비활성화되면 바로 눈에 띄게 opacity를 낮췄습니다.&lt;/li&gt;
&lt;li&gt;모바일에서는 버튼이 많아질 수 있으니 한 줄에 억지로 넣지 않고 자연스럽게 줄바꿈되도록 잡았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 새로운 디자인을 만드는 작업이 아닙니다. 순서 변경 기능이 들어왔을 때 화면이 답답해지지 않도록, 그리고 사용할 수 없는 버튼이 명확하게 보이도록 정리하는 작업에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이동 버튼 UI의 핵심은 &quot;예쁘게 보이기&quot;보다 &lt;b&gt;지금 눌릴 수 있는지 없는지가 분명하게 보이기&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 위로, 아래로 이동 흐름 붙이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 순서를 바꾸는 기능은 결국 &lt;b&gt;배열 안에 있는 항목의 위치를 바꾸고, 그 바뀐 배열을 다시 화면에 그려주는 흐름&lt;/b&gt;이기 때문입니다. 여기서 중요한 건 새로운 문법보다, 지금 어떤 항목이 몇 번째에 있는지를 읽고 바꾸는 감각입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;findTodoIndex&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 항목이 배열에서 몇 번째인지 찾습니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서 변경 계산의 출발점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;canReorderTodos&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서 변경이 가능한 상황인지 판단합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터 중이거나 수정 중일 때는 일부러 막기 위해서입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;moveTodo&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제로 배열 안 항목 위치를 바꿉니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 기능의 가장 핵심 함수입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createMoveUpButton / createMoveDownButton&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이동 버튼 생성과 비활성 기준을 함께 다룹니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;UI와 로직이 따로 놀지 않게 해줍니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;script.js&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let editingTodoId = null;
let editingText = &quot;&quot;;

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    return savedValue ? JSON.parse(savedValue) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getFilteredTodos() {
  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return todos.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return todos.filter(function (todo) {
      return todo.done;
    });
  }

  return todos;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();

  elements.summary.textContent =
    &quot;전체 &quot; +
    totalCount +
    &quot;개 &amp;middot; 남은 할 일 &quot; +
    remainingCount +
    &quot;개&quot;;

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다. 순서 변경은 전체 보기에서 할 수 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다. 순서 변경은 전체 보기에서 할 수 있습니다.&quot;;
  }

  return &quot;위로와 아래로 버튼으로 할 일 순서를 바꿔보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function findTodoIndex(todoId) {
  return todos.findIndex(function (todo) {
    return todo.id === todoId;
  });
}

function canReorderTodos() {
  return (
    currentFilter === FILTER_TYPES.ALL &amp;amp;&amp;amp;
    editingTodoId === null
  );
}

function moveTodo(todoId, direction) {
  if (!canReorderTodos()) {
    setMessage(&quot;순서 변경은 전체 보기에서만 할 수 있습니다.&quot;);
    return;
  }

  const currentIndex = findTodoIndex(todoId);

  if (currentIndex === -1) {
    return;
  }

  const targetIndex =
    direction === &quot;up&quot;
      ? currentIndex - 1
      : currentIndex + 1;

  if (
    targetIndex &amp;lt; 0 ||
    targetIndex &amp;gt;= todos.length
  ) {
    return;
  }

  const movedTodo = todos.splice(currentIndex, 1)[0];

  todos.splice(targetIndex, 0, movedTodo);

  saveTodos();
  renderTodos(
    direction === &quot;up&quot;
      ? &quot;할 일을 위로 옮겼습니다.&quot;
      : &quot;할 일을 아래로 옮겼습니다.&quot;
  );
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.textContent = todo.text;

  return text;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createMoveUpButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;위로&quot;,
    &quot;move-up-button&quot;,
    function () {
      moveTodo(todoId, &quot;up&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === 0
  );
}

function createMoveDownButton(todoId, todoIndex, disabled) {
  return createActionButton(
    &quot;아래로&quot;,
    &quot;move-down-button&quot;,
    function () {
      moveTodo(todoId, &quot;down&quot;);
    },
    disabled || !canReorderTodos() || todoIndex === todos.length - 1
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const todoIndex = findTodoIndex(todo.id);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoText(todo)
  );

  actions.append(
    createMoveUpButton(todo.id, todoIndex, isLocked),
    createMoveDownButton(todo.id, todoIndex, isLocked),
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  actions.className = &quot;todo-actions&quot;;

  editArea.append(
    createEditInput(todo.id)
  );

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false
  });

  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
}

bindEvents();
renderTodos();&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;findTodoIndex&lt;/b&gt; : 지금 항목이 배열에서 몇 번째인지 찾습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;canReorderTodos&lt;/b&gt; : 전체 보기일 때만 순서 변경이 가능하도록 제한합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;moveTodo&lt;/b&gt; : 실제로 배열 안의 항목 위치를 바꿉니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createMoveUpButton&lt;/b&gt;, &lt;b&gt;createMoveDownButton&lt;/b&gt; : 위아래 버튼을 만들고, 눌릴 수 없는 상태도 같이 정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능의 핵심은 단순합니다. 버튼을 누르면 항목을 복사해서 새로 만들거나, 텍스트를 바꾸는 게 아닙니다. &lt;b&gt;배열 안에서 현재 항목을 꺼내 다른 위치에 다시 넣고, 저장한 뒤 목록을 다시 그리는 것&lt;/b&gt;입니다. 이 흐름만 잡히면 순서 변경 기능의 대부분은 이해한 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 점은 이번 편에서 &lt;b&gt;진행 중 보기와 완료 보기에서는 이동 버튼을 비활성화&lt;/b&gt;했다는 점입니다. 이건 구현을 못 해서가 아니라, 지금 단계에서는 이쪽이 더 맞기 때문입니다. 숨겨진 항목이 섞여 있는 상태에서 순서를 움직이면 &quot;왜 안 보이는 항목 때문에 위치가 저렇게 바뀌지?&quot; 같은 혼란이 생기기 쉽습니다. 초보자 실습에서는 이런 혼란을 줄이는 쪽이 맞습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript 핵심은 &quot;위아래 버튼을 붙였다&quot;가 아니라, &lt;b&gt;배열의 실제 순서를 바꾸고 그 결과를 다시 그린다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;맨 위 항목에서는 &quot;위로&quot; 버튼이 비활성화되는가&lt;/li&gt;
&lt;li&gt;맨 아래 항목에서는 &quot;아래로&quot; 버튼이 비활성화되는가&lt;/li&gt;
&lt;li&gt;진행 중 보기와 완료 보기에서는 이동 버튼이 비활성화되는가&lt;/li&gt;
&lt;li&gt;수정 버튼을 누른 상태에서는 이동 버튼이 비활성화되는가&lt;/li&gt;
&lt;li&gt;순서를 바꾼 뒤 새로고침해도 그 순서가 유지되는가&lt;/li&gt;
&lt;li&gt;수정, 삭제, 전체 삭제, 필터가 순서 변경 기능 추가 뒤에도 그대로 동작하는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &quot;버튼이 눌리느냐&quot;보다 &lt;b&gt;배열 순서와 화면 순서가 정말 같이 움직이는지&lt;/b&gt;, 그리고 &lt;b&gt;막아야 할 상황에서는 제대로 막히는지&lt;/b&gt;를 보는 것입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;순서 변경 기능은 성공했을 때보다, &lt;b&gt;막혀야 할 순간에 제대로 막히는지&lt;/b&gt;까지 확인해봐야 구조가 더 분명해집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 변경 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 필터 상태와 목록 렌더링, 버튼 비활성화까지 같이 엮여 있기 때문에 더더욱 범위를 분명하게 잘라야 합니다. &quot;순서 바꾸기 넣어줘&quot;라고만 하면, AI가 드래그 앤 드롭까지 넓게 가거나 필터 로직을 과하게 흔들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 수정, 삭제, 전체 삭제,
필터, 저장 기능까지 정상 동작하는 상태야.
이번에는 각 항목에 위로, 아래로 버튼을 붙여서
순서를 바꾸는 기능만 넣고 싶어.
드래그 앤 드롭 말고 버튼 방식으로 가고,
순서 변경은 전체 보기에서만 가능하게 해줘.
기존 기능은 유지하고 어떤 파일을 왜 바꿀지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 목록 렌더링 구조는 유지하고,
각 항목에 위로, 아래로 버튼을 추가해줘.
배열 안에서 항목 순서를 실제로 바꾸는 방식으로 구현하고,
진행 중 보기와 완료 보기에서는 순서 변경 버튼이 비활성화되게 해줘.
수정 중에도 이동 버튼은 잠깐 막히게 해줘.
새로 생길 함수와 상태 기준부터 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
위로, 아래로 버튼을 기존 액션 버튼들과 자연스럽게 섞이게 해줘.
맨 위나 맨 아래에서 비활성화된 버튼은
눌릴 수 없는 상태가 바로 보이게 해줘.
모바일에서도 버튼들이 너무 답답하지 않게 줄바꿈되게 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 요청하면 AI가 기능 범위를 넘어가서 구조 전체를 흔들 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 방식도 대단한 표현보다 &lt;b&gt;수정 범위와 제한 조건을 같이 주는 습관&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 순서 변경 기능을 맡길 때는 &quot;순서 바꾸기 넣어줘&quot;보다 &lt;b&gt;버튼 방식, 전체 보기만 허용, 기존 기능 유지&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 직접 해보면, 이제 할 일 앱은 단순히 목록을 보여주는 수준을 조금 넘어서기 시작합니다. 같은 데이터라도 내가 손으로 정렬하고, 그 결과를 다시 저장하고, 다음에 다시 열었을 때도 그 순서를 유지하는 흐름이 들어오기 때문입니다. 그 차이가 생각보다 큽니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/38</guid>
      <comments>https://story86025.tistory.com/38#entry38comment</comments>
      <pubDate>Wed, 1 Apr 2026 17:00:09 +0900</pubDate>
    </item>
    <item>
      <title>[Claude] 요즘 말하는 하네스 엔지니어링, 결국 뭘 설계하는 걸까</title>
      <link>https://story86025.tistory.com/77</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_harness_202603311859.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bW3bx5/dJMcabjoJJS/E8apK0hK4d1RskyvjkPpV1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bW3bx5/dJMcabjoJJS/E8apK0hK4d1RskyvjkPpV1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bW3bx5/dJMcabjoJJS/E8apK0hK4d1RskyvjkPpV1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbW3bx5%2FdJMcabjoJJS%2FE8apK0hK4d1RskyvjkPpV1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_harness_202603311859.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude 관련 이야기를 보다 보면, 요즘은 &lt;b&gt;프롬프트를 어떻게 잘 쓰느냐&lt;/b&gt;보다 &lt;b&gt;하네스를 어떻게 짜느냐&lt;/b&gt; 쪽이 더 많이 들립니다. 처음 들으면 조금 낯섭니다. 하네스라고 하면 자동차 배선이나 케이블 묶음 같은 게 먼저 떠오르기 때문입니다. 그런데 비유가 아주 틀리지는 않습니다. 모델 하나만 놓고 &amp;ldquo;잘해봐&amp;rdquo;라고 하는 게 아니라, &lt;b&gt;Claude가 어떤 환경에서, 어떤 규칙으로, 어떤 검증 루프를 돌며 일하게 할지&lt;/b&gt;를 묶어두는 이야기이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점이 하나 있습니다. &lt;span data-ui=&quot;term&quot; data-note=&quot;요즘 커뮤니티에서 자주 쓰는 표현입니다. Anthropic 공식 제품명은 아니고, 공식 문서 쪽 표현은 agent harness 또는 harness design에 더 가깝습니다.&quot;&gt;하네스 엔지니어링&lt;/span&gt;은 무슨 새 기능 이름이 아닙니다. 버튼 하나 눌러 켜는 모드도 아닙니다. 오히려 &lt;b&gt;Claude를 둘러싼 작업 환경을 설계하는 방식&lt;/b&gt;에 가깝습니다. 그래서 이 개념을 한 번 이해해두면, &lt;span data-ui=&quot;term&quot; data-note=&quot;프로젝트 규칙과 작업 습관을 세션마다 읽히는 지속형 지침 파일입니다.&quot;&gt;CLAUDE.md&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;반복 작업을 재사용할 수 있게 만드는 Claude Code의 작업 매뉴얼입니다.&quot;&gt;skills&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;Claude Code 수명 주기의 특정 시점마다 자동으로 실행되는 명령&amp;middot;프롬프트&amp;middot;엔드포인트입니다.&quot;&gt;hooks&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;각기 다른 역할로 분리된 보조 에이전트입니다.&quot;&gt;subagents&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;외부 도구와 데이터를 Claude에 연결하는 표준입니다.&quot;&gt;MCP&lt;/span&gt; 같은 요소가 따로 노는 기능이 아니라, 하나의 큰 그림 안에 들어오기 시작합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;하네스 엔지니어링은 Claude에게 더 멋진 말을 거는 기술이라기보다, &lt;b&gt;Claude가 덜 헤매고, 더 검증 가능하게 일하도록 작업장을 설계하는 일&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;하네스 엔지니어링은 프롬프트 엔지니어링의 상위 버전일까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷해 보여도 완전히 같지는 않습니다. 입문자 기준으로는 이렇게 나누면 이해가 쉽습니다. 프롬프트 엔지니어링은 &amp;ldquo;&lt;b&gt;무엇을 어떻게 말할까&lt;/b&gt;&amp;rdquo;에 가깝고, &lt;span data-ui=&quot;term&quot; data-note=&quot;모델이 필요한 정보를 적절한 시점에 불러오도록 맥락을 설계하는 방식입니다.&quot;&gt;컨텍스트 엔지니어링&lt;/span&gt;은 &amp;ldquo;&lt;b&gt;무엇을 보여줄까&lt;/b&gt;&amp;rdquo;에 가깝습니다. 반면 하네스 엔지니어링은 &amp;ldquo;&lt;b&gt;Claude가 어떤 환경과 절차 안에서 일하게 할까&lt;/b&gt;&amp;rdquo;를 다룹니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;핵심 질문&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;초보자식 비유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;프롬프트 엔지니어링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;무엇을 어떻게 요청할까&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;좋은 질문 만들기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;컨텍스트 엔지니어링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;무슨 자료를 언제 넣을까&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;책상 위에 올릴 자료 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;하네스 엔지니어링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;어떤 도구, 규칙, 검증 루프로 일하게 할까&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;Claude의 작업실 설계&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 하네스 엔지니어링은 프롬프트를 무시하자는 얘기가 아닙니다. 프롬프트도 여전히 중요합니다. 다만 Claude Code처럼 파일을 읽고, 명령을 실행하고, 수정까지 하는 &lt;span data-ui=&quot;term&quot; data-note=&quot;단순 답변형 챗봇이 아니라 파일 읽기, 편집, 명령 실행까지 스스로 이어가는 작업형 코딩 도구입니다.&quot;&gt;agentic coding&lt;/span&gt; 환경에서는, 좋은 프롬프트 하나보다 &lt;b&gt;좋은 루프 하나&lt;/b&gt;가 결과를 더 크게 바꾸는 순간이 많다는 뜻에 가깝습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 하필 Claude에서 이 이야기가 더 크게 들릴까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 꽤 분명합니다. Claude Code는 애초에 &amp;ldquo;답만 하는 채팅창&amp;rdquo;이 아니라, 파일을 읽고, 수정하고, 명령을 실행하고, 검증까지 해보는 작업형 도구로 설계되어 있습니다. 그러다 보니 모델 성능만으로는 설명이 안 되는 차이가 자꾸 생깁니다. 같은 Claude를 써도, 어떤 사람은 금방 만족하고 어떤 사람은 계속 삐걱거립니다. 이 차이가 어디서 오느냐를 뜯어보면, 모델보다 &lt;b&gt;주변 설계&lt;/b&gt;에서 갈리는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Claude가 작업을 시작할 때 무엇을 먼저 읽는지, 수정 전엔 조사만 하게 둘지, 끝나면 어떤 테스트를 꼭 돌리게 할지, 보호해야 할 파일은 무엇인지, 리뷰와 구현을 같은 컨텍스트에서 할지 아니면 분리할지 같은 문제는 전부 하네스 쪽 이야기입니다. 다시 말해, &lt;b&gt;Claude를 얼마나 잘 썼는가&lt;/b&gt;보다 &lt;b&gt;Claude가 일할 판을 얼마나 잘 깔아줬는가&lt;/b&gt;가 중요해지는 구간이 있다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점이 요즘 많은 사람에게 꽂히는 이유도 여기에 있습니다. 모델이 좋아질수록, &amp;ldquo;말을 잘 시키는 법&amp;rdquo;만으로는 설명이 안 되는 성능 차이가 더 눈에 보이기 시작했기 때문입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;재밌게 바꿔 말하면&lt;/b&gt;&lt;br /&gt;예전에는 &amp;ldquo;Claude에게 뭘 시킬까&amp;rdquo;가 핵심이었다면, 지금은 &amp;ldquo;Claude가 일하는 사무실을 어떻게 꾸밀까&amp;rdquo;가 점점 중요해지고 있는 셈입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Claude 하네스라고 하면 실제로는 무엇을 만지는 걸까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터가 실전입니다. 하네스라고 하면 무슨 거대한 SDK 프로젝트를 떠올리기 쉬운데, 꼭 그렇지는 않습니다. Claude Code를 CLI로 쓰든, IDE에서 쓰든, 데스크톱 앱에서 쓰든 이미 만질 수 있는 지점이 여럿 있습니다. 중요한 건 기능 이름을 외우는 게 아니라, &lt;b&gt;그 기능이 하네스의 어느 층에 해당하는지&lt;/b&gt; 이해하는 것입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;하네스 층&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;무슨 역할을 하나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;입문자 시작점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;프로젝트 규칙과 작업 습관을 세션마다 읽히는 지속형 지침 파일입니다.&quot;&gt;CLAUDE.md&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;프로젝트 규칙, 빌드 명령, 금지 규칙, 작업 습관을 고정합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;가장 먼저 만질 만한 층&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;권한 모드 / 샌드박스&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;Claude가 어디까지 읽고 쓰고 실행할지 경계를 정합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;조사용이면 보수적으로, 익숙해지면 점진적으로 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;반복 작업을 재사용할 수 있게 만드는 Claude Code의 작업 매뉴얼입니다.&quot;&gt;skills&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;버그 수정, 리뷰, 로그 정리 같은 반복 절차를 재사용하게 합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;자주 반복하는 일 하나부터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;Claude Code 수명 주기의 특정 시점마다 자동으로 실행되는 명령&amp;middot;프롬프트&amp;middot;엔드포인트입니다.&quot;&gt;hooks&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;편집 뒤 포맷팅, 보호 파일 차단, 알림 같은 규칙을 자동화합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;결정 규칙이 분명한 것부터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;각기 다른 역할로 분리된 보조 에이전트입니다.&quot;&gt;subagents&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;조사, 계획, 리뷰, 수정 같은 역할을 분리하고 컨텍스트를 나눕니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;역할이 갈라질 때만 도입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;외부 도구와 데이터를 Claude에 연결하는 표준입니다.&quot;&gt;MCP&lt;/span&gt; / 도구&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;브라우저, 문서, 로그, 사내 도구 등 외부 시스템을 붙입니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;진짜 필요한 연결만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;테스트 / 스크린샷 / git / 진행 메모&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;Claude가 스스로 검증하고 다음 세션으로 상태를 넘길 수 있게 합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&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;skill&lt;/b&gt;과 &lt;b&gt;서브에이전트&lt;/b&gt;를 따로 봤다면, 하네스 엔지니어링은 그 둘을 포함해 전체를 엮는 관점이라고 보면 됩니다. skill은 &amp;ldquo;어떻게 일할까&amp;rdquo;에 가깝고, subagent는 &amp;ldquo;누가 맡을까&amp;rdquo;에 가깝습니다. 하네스는 거기에 권한, 자동화, 검증, 인계까지 얹은 더 큰 구조입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Anthropic이 보여준 예시는 왜 자꾸 회자될까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 이 단어가 더 많이 보이는 데는 이유가 있습니다. Anthropic이 최근 공개한 예시들이 꽤 인상적이기 때문입니다. 핵심은 한 가지입니다. &lt;b&gt;좋은 결과가 모델 하나에서 뚝 떨어진 게 아니라, 하네스를 꽤 집요하게 설계한 결과&lt;/b&gt;처럼 보인다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장시간 작업용 하네스 예시에서는, 첫 세션에서 환경을 세팅하는 전용 에이전트와 이후에 조금씩 진도를 내는 코딩 에이전트를 나눠두고, &lt;code&gt;init.sh&lt;/code&gt;, 기능 목록 파일, 진행 로그 파일, git 커밋 같은 구조화된 흔적을 남기게 했습니다. 이 아이디어가 중요한 이유는 간단합니다. Claude가 매 세션마다 똑똑하게 &amp;ldquo;기억해내길&amp;rdquo; 기대하는 대신, &lt;b&gt;다음 세션이 다시 읽고 따라갈 수 있는 외부 흔적을 남기게 했기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 최근 예시에서는 한 단계 더 나아갑니다. &lt;span data-ui=&quot;term&quot; data-note=&quot;긴 작업을 하다 보면 컨텍스트가 차면서 모델이 괜히 서둘러 마무리하려는 경향을 가리키는 표현입니다.&quot;&gt;context anxiety&lt;/span&gt;와 자기 결과를 스스로 후하게 평가하는 &lt;span data-ui=&quot;term&quot; data-note=&quot;자기가 만든 결과를 스스로 평가할 때 지나치게 후한 점수를 주는 경향입니다.&quot;&gt;self-evaluation&lt;/span&gt; 문제를 줄이기 위해, planner, generator, evaluator 구조를 둔 멀티 에이전트 하네스를 썼습니다. 여기서 포인트는 &amp;ldquo;모델을 더 혼내자&amp;rdquo;가 아니라, &lt;b&gt;일하는 에이전트와 평가하는 에이전트를 분리하자&lt;/b&gt;는 발상입니다. 팀에서 작성자와 리뷰어를 나누는 것과 비슷한 그림입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 대목이 중요한 이유&lt;/b&gt;&lt;br /&gt;하네스 엔지니어링은 Claude를 천재로 만드는 비밀 양념이 아니라, &lt;b&gt;Claude가 실수했을 때 빨리 드러나게 하고 다시 궤도로 복귀하게 만드는 구조&lt;/b&gt;를 만드는 일에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자는 어디서부터 시작하면 좋을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 욕심을 내면 오히려 꼬입니다. 처음부터 subagent, hook, MCP, plugin을 전부 얹으면 멋있어 보일 수는 있어도, 무엇 때문에 결과가 좋아졌는지 파악하기 어려워집니다. 초보자라면 하네스를 이렇게 생각하는 편이 좋습니다. &lt;b&gt;먼저 검증, 그다음 규칙, 그다음 자동화&lt;/b&gt;입니다.&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;CLAUDE.md를 짧게 씁니다.&lt;/b&gt; 프로젝트 규칙과 작업 순서만 간단히 고정합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Plan 성격의 흐름을 먼저 익힙니다.&lt;/b&gt; 바로 수정시키기보다, 먼저 조사하고 계획하게 만드는 습관을 들입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자주 반복하는 절차 하나만 skill로 만듭니다.&lt;/b&gt; 버그 수정이나 작업 로그 정리처럼요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;hook은 결정 규칙이 분명한 것만 자동화합니다.&lt;/b&gt; 예를 들면 포맷팅이나 보호 파일 차단입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;역할이 진짜 갈라질 때만 subagent를 씁니다.&lt;/b&gt; 조사, 리뷰, 구현이 한 덩어리로 읽기 어려울 때입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아주 작게 시작하면 이런 느낌입니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;CLAUDE.md

- 먼저 관련 파일을 찾고,
  바로 수정하지 말고 계획을 짧게 설명한다.
- 변경 전후로 테스트 명령을 실행한다.
- 실패하면 원인과 다음 시도를 분리해서 적는다.
- 마지막에는 바뀐 파일,
  검증 결과,
  남은 위험을 요약한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도만 해도 이미 프롬프트 엔지니어링에서 한 발 나간 셈입니다. 왜냐하면 이건 &amp;ldquo;한 번 잘 말하기&amp;rdquo;가 아니라, &lt;b&gt;매번 같은 작업 습관을 Claude에게 읽히는 구조&lt;/b&gt;이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 흐름을 더 그림처럼 보면 이런 식입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;사용자 요청
&amp;rarr; CLAUDE.md 로 프로젝트 규칙 로드
&amp;rarr; 먼저 조사하고 계획
&amp;rarr; 필요하면 skill 호출
&amp;rarr; 조사나 리뷰는 subagent로 분리
&amp;rarr; 수정 직후 hook으로 포맷/검증
&amp;rarr; 테스트, 스크린샷, git diff로 확인
&amp;rarr; 진행 메모와 commit으로 다음 세션에 인계&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 보이기 시작하면, 하네스 엔지니어링이 그렇게 추상적인 말처럼 느껴지지 않습니다. 결국은 &lt;b&gt;Claude가 일하는 루틴을 설계하는 일&lt;/b&gt;로 내려오기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;br /&gt;아닙니다. MCP 서버나 외부 도구가 많아질수록 컨트롤이 어려워지고 비용도 올라갑니다. 필요한 것만 붙이는 편이 낫습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서브에이전트를 많이 쓰면 더 똑똑해진다.&lt;/b&gt;&lt;br /&gt;역할이 분명할 때는 좋지만, 작은 일까지 나누면 결과를 읽는 쪽이 더 복잡해질 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Auto 성격의 권한 모드는 무조건 편하다.&lt;/b&gt;&lt;br /&gt;편한 건 맞지만, 검토가 필요한 작업까지 무심코 밀어버리면 하네스가 좋아진 게 아니라 그냥 경계가 느슨해진 것에 가깝습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CLAUDE.md는 길수록 좋다.&lt;/b&gt;&lt;br /&gt;긴 문서는 대개 읽는 사람도 흐려지고, 모델도 핵심을 놓치기 쉽습니다. 짧고 검증 가능한 규칙이 더 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;좋은 방향&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;아쉬운 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;검증 루프부터 만든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;모델만 바꾸며 체감만 비교한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;규칙은 짧고 선명하게 적는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;CLAUDE.md를 장문의 위키처럼 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;반복 절차만 skill로 묶는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;모든 일을 하나의 skill에 몰아넣는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;결정 규칙이 분명한 것만 hook으로 자동화한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;판단이 필요한 일까지 전부 자동화하려 든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;역할이 갈라질 때만 subagent를 붙인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;작은 일도 무조건 멀티 에이전트로 간다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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; &amp;ldquo;잘 됐는지 네가 스스로 확인해&amp;rdquo;가 Claude에겐 생각보다 큰 차이를 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조사와 수정을 분리해보세요.&lt;/b&gt; 처음엔 Plan 성격으로 조사만 시키고, 그다음 구현으로 넘어가는 습관이 꽤 좋습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보호 파일은 hook으로 막는 편이 낫습니다.&lt;/b&gt; 말로 &amp;ldquo;건드리지 마&amp;rdquo; 하는 것보다 훨씬 단단합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;progress 메모를 남기게 하세요.&lt;/b&gt; 긴 작업일수록 다음 세션에서 다시 올라타는 비용이 크게 줄어듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;팀에서 쓰기 시작하면 plugin 단계가 보입니다.&lt;/b&gt; 개인 세팅을 팀 하네스로 바꾸는 순간입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁 한 줄&lt;/b&gt;&lt;br /&gt;모델을 바꾸기 전에, &lt;b&gt;Claude가 무엇으로 자기 결과를 검증하는지&lt;/b&gt;부터 먼저 점검해보는 편이 체감이 훨씬 큽니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 Claude 하네스 엔지니어링이 자꾸 언급되는 이유는 생각보다 단순합니다. 이제는 모델 하나만 좋아졌다고 끝나는 구간이 아니라, &lt;b&gt;모델이 일하는 방식 자체를 설계해야 성능이 안정되는 구간&lt;/b&gt;에 들어섰기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 하네스 엔지니어링은 전문가들만 하는 거대한 시스템 설계처럼 보이지만, 초보자에게도 시작점은 분명합니다. 테스트를 먼저 주고, CLAUDE.md를 짧게 쓰고, 반복 작업 하나를 skill로 묶고, 필요할 때만 hook과 subagent를 붙이는 것. 사실 이 정도만 해도 이미 Claude를 &amp;ldquo;그냥 잘 대답하는 모델&amp;rdquo;이 아니라 &lt;b&gt;조금 더 일하게 만드는 시스템&lt;/b&gt;으로 다루기 시작한 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 문장으로 끝내면 이렇습니다. &lt;b&gt;하네스 엔지니어링은 Claude를 더 똑똑하게 만드는 기술이라기보다, Claude가 덜 엉뚱하게 일하고 더 검증 가능하게 움직이도록 만드는 기술&lt;/b&gt;에 가깝습니다. 이 감이 잡히면, 왜 요즘 프롬프트보다 하네스 이야기가 더 자주 들리는지도 자연스럽게 이해됩니다.&lt;/p&gt;</description>
      <category>AI에이전트/Claude</category>
      <category>Claude</category>
      <category>claude code</category>
      <category>서브에이전트</category>
      <category>클로드</category>
      <category>클로드 하네스</category>
      <category>클로드코드</category>
      <category>하네스엔지니어링</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/77</guid>
      <comments>https://story86025.tistory.com/77#entry77comment</comments>
      <pubDate>Tue, 31 Mar 2026 18:55:11 +0900</pubDate>
    </item>
    <item>
      <title>[기초-3] AI가 엉뚱하게 답하는 이유: 코드&amp;middot;에러&amp;middot;화면 상태를 제대로 전달하는 법</title>
      <link>https://story86025.tistory.com/51</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;기초 3.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oMnCB/dJMcahKt1WY/kmumb0QSKu3a1mlgwspnl0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oMnCB/dJMcahKt1WY/kmumb0QSKu3a1mlgwspnl0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oMnCB/dJMcahKt1WY/kmumb0QSKu3a1mlgwspnl0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoMnCB%2FdJMcahKt1WY%2Fkmumb0QSKu3a1mlgwspnl0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;기초 3.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 편에서 큰 요청을 작은 작업으로 나누는 법을 정리했다면, 이번에는 그다음에 꼭 부딪히는 문제를 다뤄볼 차례입니다. 작업은 잘 나눴는데도 AI가 자꾸 엉뚱한 파일을 고치거나, 이미 있는 기능을 다시 만들거나, 정작 지금 막힌 부분이 아닌 다른 설명을 길게 내놓는 경우가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때 많은 사람은 &amp;ldquo;모델이 별로인가?&amp;rdquo;부터 떠올립니다. 하지만 실제로는 &lt;b&gt;현재 상태를 충분히 넘기지 않았기 때문&lt;/b&gt;인 경우가 많습니다. &amp;ldquo;안 돼요&amp;rdquo;, &amp;ldquo;에러 나요&amp;rdquo;, &amp;ldquo;버튼이 안 먹어요&amp;rdquo; 같은 말만으로는 AI가 판단해야 할 가지가 너무 많습니다. 겉으로는 같은 증상처럼 보여도, 원인은 완전히 다를 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 그 빈칸을 어떻게 줄일지 정리해보겠습니다. 핵심은 복잡한 말을 길게 늘어놓는 게 아닙니다. &lt;b&gt;지금 어디까지 만들었는지, 어느 파일이 관련 있는지, 에러가 정확히 무엇인지, 화면에서 어떤 순서로 문제가 보이는지&lt;/b&gt;를 AI가 따라갈 수 있게 넘기는 것입니다. 이 흐름이 잡히면 답변의 초점이 훨씬 또렷해집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;내가 보는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;AI에게 꼭 줘야 할 정보&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;빠졌을 때 생기기 쉬운 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼을 눌러도 반응이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;관련 파일, 이벤트 연결 코드, 콘솔 에러 유무, 최근에 바꾼 부분&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원인과 상관없는 설명이 길어지거나, 전체 구조를 다시 짜는 답이 나오기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;콘솔에 에러가 뜬다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;에러 원문, 파일명과 줄 번호, 언제 뜨는지, 직전에 수정한 내용&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI가 넓게 추정만 하다가, 실제 원인보다 멀어진 답을 내놓을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면은 보이는데 동작이 이상하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재현 순서, 현재 필터&amp;middot;검색&amp;middot;수정 상태, 기대한 결과와 실제 결과&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;겉모습만 보고 답하다가, 상태가 꼬인 지점을 놓치기 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;AI가 자꾸 빗나가게 답하는 이유는 질문을 못해서가 아니라, 지금 내 코드와 화면이 어떤 상태인지 충분히 전달되지 않았기 때문인 경우가 많습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 현재 상태 전달이 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코딩에서 겉으로 같은 문제처럼 보여도, 실제 원인은 완전히 다를 때가 많습니다. 예를 들어 삭제 버튼이 안 눌린다고 해봅시다. 버튼 선택자가 잘못됐을 수도 있고, 이벤트가 연결되지 않았을 수도 있고, 화면을 다시 그리는 과정에서 버튼이 새로 만들어지면서 연결이 끊겼을 수도 있습니다. 혹은 아예 비활성 상태가 걸려 있는 것일 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자 입장에서는 이게 다 비슷하게 보입니다. 그냥 &amp;ldquo;삭제 버튼이 안 돼요&amp;rdquo;처럼 느껴집니다. 그런데 AI는 그 안에서 가능한 원인을 여러 갈래로 떠올립니다. 이때 현재 상태가 비어 있으면 답변도 넓어집니다. 그러면 원인과 상관없는 설명이 길어지고, 읽는 사람은 더 헷갈리게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어디까지 구현됐는지 모르면 이미 있는 기능을 또 설명하기 쉽습니다.&lt;/li&gt;
&lt;li&gt;어느 파일이 관련 있는지 모르면 전체 프로젝트를 다시 짜는 답이 나오기 쉽습니다.&lt;/li&gt;
&lt;li&gt;에러가 있는지 없는지 모르면 추정이 지나치게 넓어집니다.&lt;/li&gt;
&lt;li&gt;어떤 순서로 문제가 보이는지 모르면 화면 상태 문제를 놓치기 쉽습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 AI에게 현재 상태를 잘 넘긴다는 것은, AI가 똑똑해지게 만드는 마법이 아닙니다. &lt;b&gt;지금 이 문제를 어느 범위에서 봐야 하는지 좁혀주는 일&lt;/b&gt;에 가깝습니다. 그리고 초보자에게는 이 차이가 생각보다 큽니다. 질문을 조금만 정리해도, 답변이 읽을 만한 크기로 내려오기 시작하기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해하기 쉬운 지점&lt;/b&gt;&lt;br /&gt;코드를 많이 붙인다고 해서 항상 좋은 것은 아닙니다. 중요한 건 많이 주는 것이 아니라, &lt;i&gt;문제와 연결된 순서대로&lt;/i&gt; 주는 것입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서는 어디까지 다룰 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 &amp;ldquo;막혔을 때 AI에게 현재 상태를 어떻게 넘길 것인가&amp;rdquo;에 집중하겠습니다. 범위를 넓히면 오히려 핵심이 흐려지기 쉬워서, 이번에도 다룰 것과 다루지 않을 것을 먼저 잘라두는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 다룰 것은 아래 네 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관련 코드와 파일을 어떻게 골라서 보여줄지&lt;/li&gt;
&lt;li&gt;에러 메시지를 어떤 방식으로 전달하면 좋은지&lt;/li&gt;
&lt;li&gt;화면 문제를 어떻게 재현 순서로 설명할지&lt;/li&gt;
&lt;li&gt;이걸 한 번에 정리해서 요청하는 기본 템플릿&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 글에서는 아래 내용은 깊게 들어가지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 로그를 보는 방법&lt;/li&gt;
&lt;li&gt;배포 환경에서만 생기는 문제&lt;/li&gt;
&lt;li&gt;복잡한 라이브러리 버전 충돌&lt;/li&gt;
&lt;li&gt;네트워크 탭이나 고급 디버깅 도구 사용법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 단계에서 중요한 것은 도구를 더 많이 배우는 일이 아닙니다. &lt;b&gt;AI가 내 문제를 덜 오해하게 만드는 최소한의 전달 구조&lt;/b&gt;를 익히는 쪽이 먼저입니다. 그 감각이 잡히면, 나중에 더 복잡한 문제를 다룰 때도 훨씬 덜 흔들립니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 맥락: 어느 파일의 무엇이 문제인지 먼저 좁히기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 문제를 AI에게 물을 때 가장 먼저 해야 할 일은, &lt;b&gt;지금 어디까지 만들어졌는지와 어느 부분이 이번 질문의 범위인지&lt;/b&gt;를 분리해서 보여주는 것입니다. 많은 초보자가 바로 코드부터 붙여넣는데, 사실 그 전에 짧게라도 현재 작업 범위를 말해주는 편이 훨씬 도움이 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 어디까지 구현됐는지 먼저 알려주기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 할 일 앱이라면 &amp;ldquo;추가, 삭제, 완료 체크, 필터까지는 정상 동작한다. 오늘은 검색 기능을 붙이는 중이다&amp;rdquo; 정도만 적어도 좋습니다. 이 한 줄이 있으면 AI는 이미 완성된 기능을 또 설명할 가능성이 줄어듭니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 관련 파일과 함수부터 좁히기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 파일을 한꺼번에 붙이기보다, 이번 문제와 직접 연결된 파일을 먼저 적어두는 편이 좋습니다. &lt;b&gt;index.html, style.css, script.js&lt;/b&gt;처럼 파일 이름을 밝히고, 그 안에서 어떤 함수나 어떤 요소가 관련 있는지 같이 적어주면 훨씬 선명해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 최근에 바꾼 부분을 같이 적기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 대개 방금 손댄 곳 주변에서 생기는 경우가 많습니다. 물론 항상 그런 것은 아니지만, 초보자 기준으로는 이 정보가 꽤 중요합니다. &amp;ldquo;오늘 searchInput과 clearSearchButton을 추가했다&amp;rdquo; 같은 문장은 생각보다 많은 힌트를 줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 기대한 결과와 실제 결과를 분리해서 쓰기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;검색이 안 돼요&amp;rdquo;보다 &amp;ldquo;입력값에 맞는 항목만 보여야 하는데 전체 목록이 그대로 보인다&amp;rdquo;가 훨씬 좋습니다. AI는 기대한 결과와 실제 결과의 차이를 봐야, 어떤 흐름이 빠졌는지 더 잘 좁혀갈 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;코드를 적게 주라는 뜻이 아닙니다. 관련 없는 코드까지 한꺼번에 섞지 말고, 문제와 연결된 파일과 흐름부터 먼저 보여주라는 뜻입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 기능을 붙이는 중이라면, 아래 정도로 정리해서 넘기는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업]

HTML, CSS, JavaScript로 할 일 앱을 만드는 중

추가, 완료, 삭제, 필터까지는 정상 동작

오늘 검색 입력창과 검색 지우기 버튼을 추가함

[현재 문제]

검색어를 입력해도 목록이 줄어들지 않음

콘솔 에러는 없음

[관련 파일]

index.html: #searchInput, #clearSearchButton 추가

script.js: elements 객체, bindSearchEvents, handleSearchInput, getFilteredTodos

[관련 코드]
function handleSearchInput() {
currentSearchQuery = normalizeText(elements.searchInput.value);
renderTodos();
}

function bindSearchEvents() {
elements.searchInput.addEventListener(&quot;input&quot;, handleSearchInput);
}

[기대한 결과]

입력 중인 검색어에 맞는 항목만 바로 보여야 함

[실제 결과]

어떤 검색어를 입력해도 전체 목록이 그대로 보임

[원하는 답변]

가장 가능성 큰 원인 3개

확인 순서

필요한 수정 코드만 제안&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도만 정리해도 AI는 &amp;ldquo;검색 기능 전반을 처음부터 설명할까?&amp;rdquo;가 아니라, &amp;ldquo;이 흐름에서 무엇이 빠졌을까?&amp;rdquo; 쪽으로 답을 좁히기 시작합니다. 바로 그 차이가 중요합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;ldquo;null 관련 에러가 떠요&amp;rdquo;, &amp;ldquo;undefined 어쩌구가 나와요&amp;rdquo;처럼 자기식으로 요약하는 것입니다. 그런데 이 요약 과정에서 중요한 정보가 많이 빠집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 메시지에는 생각보다 많은 힌트가 들어 있습니다. 어떤 값이 비어 있는지, 어느 파일 몇 번째 줄인지, 어느 함수에서 터졌는지 같은 정보가 들어 있기 때문입니다. 그래서 가능한 한 &lt;b&gt;복사해서 그대로 붙이는 습관&lt;/b&gt;이 좋습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;에러를 전달할 때 같이 붙이면 좋은 정보&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;항목&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;에러 원문&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;Uncaught TypeError: Cannot read properties of null...&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 종류의 문제인지 가장 먼저 좁혀준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;파일명과 줄 번호&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;script.js:214&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어디를 먼저 봐야 하는지 바로 정할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;발생 시점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;페이지 로드 직후, 버튼 클릭 직후, 저장 버튼 누른 뒤&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제가 초기 로드인지, 특정 동작 이후인지 구분할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;직전에 바꾼 것&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 영역 추가, 함수 이름 변경, 버튼 클래스 수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원인이 생긴 지점을 더 빠르게 좁힐 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;에러 유무 자체&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;콘솔 에러 없음&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;pre class=&quot;less&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[에러 메시지]

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')
at bindSearchEvents (script.js:214:24)

[발생 시점]

페이지 새로고침 직후 바로 발생

검색 영역을 추가한 뒤부터 시작됨

[직전에 바꾼 것]

index.html에 search-section 추가

script.js에 bindSearchEvents 함수 추가

[관련 질문]

가장 가능성 큰 원인이 무엇인지

먼저 확인할 HTML selector와 JS 연결 순서를 알려줘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &amp;ldquo;이 에러가 뭔지 알려줘&amp;rdquo;보다도, &lt;b&gt;어느 지점을 먼저 확인해야 할지&lt;/b&gt;를 물어보는 것입니다. 초보자에게는 이 질문 방식이 특히 유용합니다. 한 번에 해결책을 통째로 받기보다, 원인을 좁혀가는 순서를 배우기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제가 시작되기 전 상태가 무엇이었는지&lt;/li&gt;
&lt;li&gt;어떤 버튼이나 입력을 어떤 순서로 했는지&lt;/li&gt;
&lt;li&gt;지금 필터, 검색, 수정 모드 같은 상태가 걸려 있는지&lt;/li&gt;
&lt;li&gt;실제로 화면에서 무엇이 보였는지&lt;/li&gt;
&lt;li&gt;원래 기대한 결과가 무엇이었는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;삭제 후 화면이 이상해요&amp;rdquo;만으로는 부족합니다. 전체 보기인지 완료 보기인지, 검색 중이었는지, 수정 중이었는지에 따라 완전히 다른 문제가 될 수 있기 때문입니다. 상태가 여러 개 겹치는 앱일수록 이 설명이 더 중요해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;할 일 앱 예시로 바꿔 쓰면 이런 식이 더 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 상태]

완료 보기 필터가 선택된 상태

검색창에 &quot;회의&quot;를 입력한 상태

검색 결과로 1개 항목만 보이는 상태

[재현 순서]

완료 보기 버튼을 누른다

검색창에 &quot;회의&quot;를 입력한다

보이는 항목의 삭제 버튼을 누른다

[실제 결과]

목록은 비었는데 summary 문구는 그대로 &quot;검색 결과 1개&quot;로 남아 있음

빈 상태 메시지도 바로 바뀌지 않음

[기대한 결과]

목록이 비면 summary 문구가 0개로 바뀌고

&quot;검색어에 맞는 할 일이 없습니다&quot; 같은 안내가 보여야 함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설명은 단순히 &amp;ldquo;삭제 후 summary가 이상해요&amp;rdquo;보다 훨씬 낫습니다. AI가 봐야 할 상태가 이미 들어 있기 때문입니다. 이 경우에는 단순 삭제 기능 문제가 아니라, &lt;b&gt;필터 상태와 검색 상태를 함께 반영한 뒤 요약 문구를 다시 그리는 흐름&lt;/b&gt;을 의심하게 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;화면 문제는 화면만의 문제가 아닐 때가 많습니다. 그 화면이 만들어지기 전 상태와 버튼 순서를 같이 줘야 실제 원인에 더 가까워집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크린샷도 분명 도움이 됩니다. 특히 정렬이 깨졌거나, 버튼 위치가 이상하거나, 비활성 상태가 눈에 잘 안 들어오는 문제에서는 꽤 유용합니다. 다만 스크린샷만 던지고 끝내기보다는, &lt;b&gt;무엇을 눌렀고 무엇이 기대와 달랐는지&lt;/b&gt;를 텍스트로 같이 적는 편이 안전합니다. 화면 한 장만으로는 이전 상태나 클릭 순서가 빠지기 쉽기 때문입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI에게는 이렇게 정리해서 요청하면 된다&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;cs&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;[현재 작업]

지금 무엇을 만들고 있는지

어디까지는 정상 동작하는지

오늘 새로 붙인 기능이 무엇인지

[현재 문제]

지금 어떤 문제가 보이는지 한두 문장으로 정리

[관련 파일]

어떤 파일이 직접 관련 있는지

관련 함수나 요소 이름이 무엇인지

[관련 코드]

문제와 연결된 코드만 먼저 첨부

연결 문제가 의심되면 HTML, CSS, JS를 같이 첨부

[에러 메시지]

있으면 원문 그대로

없으면 &quot;콘솔 에러 없음&quot;이라고 명시

[재현 순서]

어떤 상태에서 시작하는지

무엇을 누르거나 입력하는지

어디서 문제가 보이는지

[기대한 결과]

원래는 어떻게 동작해야 하는지

[원하는 답변 방식]

가장 가능성 큰 원인 몇 개

확인 순서

수정이 필요한 코드만 제안

전체 리팩터링은 하지 말 것&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 할 일 앱 검색 기능을 예로 들면, 이렇게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;지금 HTML, CSS, JavaScript로 할 일 앱을 만드는 중이야.

추가, 삭제, 완료 체크, 필터까지는 정상 동작하고,
오늘은 검색 기능을 붙이는 단계야.

문제는 완료 보기 상태에서 검색 후 항목을 삭제하면
목록은 비는데 summary 문구가 바로 갱신되지 않는다는 점이야.
콘솔 에러는 없어.

관련 파일은 script.js이고,
renderTodos, updateSummary, getFilteredTodos 흐름이 연결돼 있어.

재현 순서

완료 보기 선택

검색창에 특정 단어 입력

검색 결과로 보이는 항목 삭제

기대한 결과

목록이 비면 summary도 0개로 바뀌고

빈 상태 메시지가 같이 보여야 해

원하는 답변

가장 의심되는 함수 흐름을 먼저 짚어줘

왜 그런지 설명해줘

수정할 코드 위치만 제안해줘

전체 코드를 처음부터 다시 쓰지는 말아줘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도만 정리해도 AI는 답변을 훨씬 좁혀서 하게 됩니다. 특히 마지막의 &lt;b&gt;&amp;ldquo;전체 코드를 다시 쓰지 말아줘&amp;rdquo;&lt;/b&gt; 같은 문장은 입문자에게 꽤 중요합니다. 초보자는 큰 구조를 통째로 갈아엎는 답보다, 지금 코드에서 무엇을 확인하고 어디를 바꾸면 되는지 배우는 쪽이 더 도움이 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 자주 하는 실수도 같이 기억해두면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&amp;ldquo;안 돼요&amp;rdquo;만 적는다&lt;/b&gt;&lt;br /&gt;문제 범위가 너무 넓어서 AI가 추정을 많이 하게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러를 내 말로만 요약한다&lt;/b&gt;&lt;br /&gt;파일명, 줄 번호, 함수 이름 같은 중요한 단서가 빠질 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코드를 무작정 전부 붙인다&lt;/b&gt;&lt;br /&gt;관련 없는 코드까지 섞이면 초점이 흐려집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재현 순서를 안 적는다&lt;/b&gt;&lt;br /&gt;특정 상태에서만 생기는 문제를 놓치기 쉽습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기대한 결과를 안 적는다&lt;/b&gt;&lt;br /&gt;AI는 &amp;ldquo;무엇이 잘못됐는가&amp;rdquo;만이 아니라 &amp;ldquo;원래 무엇이 되어야 하는가&amp;rdquo;도 알아야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;짧은 체크리스트&lt;/b&gt;&lt;br /&gt;현재 작업, 현재 문제, 관련 파일, 에러 원문, 재현 순서, 기대한 결과. 이 여섯 가지만 챙겨도 질문의 품질은 꽤 달라집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 연재 흐름을 묶어보면 이렇습니다. 먼저 AI에게 어떤 역할로 답하게 할지 정했고, 그다음 좋은 요청의 기본 구조를 잡았고, 큰 작업을 작은 단계로 나누는 법까지 왔습니다. 이번 편은 거기서 한 걸음 더 나아가, &lt;b&gt;지금 내 코드와 화면이 어떤 상태인지 AI가 따라갈 수 있게 넘기는 법&lt;/b&gt;을 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 이 부분이 중요한 이유는 분명합니다. AI의 답이 늘 맞는가보다, &lt;b&gt;내가 지금 무엇을 물어보고 있는지 분명한가&lt;/b&gt;가 먼저이기 때문입니다. 현재 상태를 잘 넘기기 시작하면 답변이 덜 퍼지고, 수정 요청도 훨씬 쉬워집니다. 그렇게 해야 AI가 준 답을 그냥 복붙하는 단계를 조금씩 넘어설 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편에서는 여기서 자연스럽게 이어서, &lt;b&gt;AI가 준 코드를 어떻게 검토하고 다시 요청하면 좋은지&lt;/b&gt;를 다루면 흐름이 좋습니다. 돌아간다고 끝이 아니라, 무엇이 빠졌고 무엇을 다시 물어봐야 하는지 보는 감각도 결국 같이 붙어야 하기 때문입니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/51</guid>
      <comments>https://story86025.tistory.com/51#entry51comment</comments>
      <pubDate>Tue, 31 Mar 2026 17:00:39 +0900</pubDate>
    </item>
    <item>
      <title>15. 리스트가 길어지면 왜 드래그가 버벅일까: 바이브코딩에서 렌더링과 이벤트 정리</title>
      <link>https://story86025.tistory.com/28</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭이 어느 정도 돌아가기 시작하면, 다음에 보이는 문제는 기능 부족이 아니라 &lt;b&gt;느낌의 문제&lt;/b&gt;입니다. 처음에는 그럴듯하게 움직였는데, 항목 수가 늘어나고 삭제 버튼, 완료 체크, 수정, 검색, 필터까지 붙기 시작하면 드래그가 점점 버벅거립니다. 어떤 때는 마우스를 따라오지 못하고, 어떤 때는 표시선이 늦게 반응하고, 어떤 때는 드롭은 됐는데 화면이 한 박자 늦게 바뀌는 식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 초보자는 보통 두 가지를 먼저 의심합니다. &amp;ldquo;브라우저가 느린가?&amp;rdquo; 혹은 &amp;ldquo;AI가 코드를 이상하게 짠 건가?&amp;rdquo; 물론 그럴 수도 있습니다. 하지만 입문자 단계에서는 그보다 더 흔한 이유가 있습니다. &lt;b&gt;드래그 중에 너무 많은 일을 너무 자주 하고 있는 것&lt;/b&gt;입니다. 특히 드래그 관련 이벤트는 클릭보다 훨씬 자주 발생하는 편이라, 여기에 전체 렌더링이나 저장 같은 무거운 작업을 붙이면 체감이 바로 나빠집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 리스트가 길어질수록 드래그가 불안정해지는지, 왜 특히 &lt;b&gt;dragover&lt;/b&gt; 단계가 문제를 크게 만드는지, 그리고 초보자 기준에서는 어떤 정리부터 해야 가장 효과가 큰지 차근차근 풀어보겠습니다. 핵심은 거창한 최적화가 아닙니다. &lt;b&gt;실제 데이터가 바뀌는 순간&lt;/b&gt;과 &lt;b&gt;잠깐 화면만 반응하면 되는 순간&lt;/b&gt;을 분리해서 보는 감각입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그가 점점 버벅인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자주 발생하는 이벤트 안에서 너무 무거운 작업을 하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어떤 이벤트에서 실제 데이터가 바뀌는지 먼저 구분한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시선이 흔들리거나 늦게 반응한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover마다 전체 목록을 다시 그리거나 상태를 과하게 바꾸고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover에서는 가벼운 시각 상태만 다루는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능은 맞는데 코드를 조금만 고쳐도 다른 부분이 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;렌더링, 저장, 이벤트 연결이 한 덩어리로 묶여 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트, 데이터 변경, 렌더링을 역할별로 다시 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 수가 늘수록 클릭도 둔해지는 것 같다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼마다 개별 이벤트를 너무 많이 다시 붙이고 있을 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클릭성 기능은 부모에서 묶어 받는 구조를 고려한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;드래그가 느려질 때 가장 먼저 볼 것은 브라우저가 아니라, 자주 발생하는 이벤트 안에서 어떤 일을 반복하고 있는가다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 리스트가 길어지면 갑자기 느려질까&lt;/li&gt;
&lt;li&gt;dragover가 특히 버벅임을 만들기 쉬운 이유&lt;/li&gt;
&lt;li&gt;매번 전체를 다시 그리면 생기는 문제&lt;/li&gt;
&lt;li&gt;클릭 이벤트는 묶고, 드래그 상태는 가볍게 유지하기&lt;/li&gt;
&lt;li&gt;초보자에게 가장 현실적인 개선 순서&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 리스트가 길어지면 갑자기 느려질까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 잘 안 느껴집니다. 항목이 몇 개 안 되고, 버튼도 몇 개 안 되고, 이벤트도 적기 때문입니다. 그런데 목록이 길어지고 각 항목에 완료 체크, 삭제 버튼, 수정 버튼, 드래그 관련 처리까지 붙기 시작하면 화면이 한 번 갱신될 때 건드려야 하는 요소 수가 많아집니다. 여기에 검색과 필터, 정렬까지 같이 돌고 있으면 체감이 더 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 &amp;ldquo;항목이 많아서 느리다&amp;rdquo;가 정확한 설명은 아니라는 점입니다. 더 정확히 말하면 &lt;b&gt;항목이 많아졌을 때도 똑같은 방식으로 전체를 자주 다시 만들고 있기 때문에 느려지는 경우&lt;/b&gt;가 많습니다. 특히 드래그는 마우스를 움직이는 동안 계속 반응해야 하므로, 작은 비효율도 클릭보다 훨씬 크게 느껴집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;항목이 늘면 무엇이 같이 커지나&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;늘어나는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 체감이 커지나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 요소 수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 번 렌더링할 때 다시 만들거나 갱신할 대상이 많아진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트 연결 수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;각 항목마다 클릭, 드래그 이벤트가 반복해서 붙을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;계산 횟수&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색, 필터, 정렬, 드롭 위치 계산이 동시에 돌면 한 번의 반응이 무거워진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불필요한 반복 작업&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 변화가 없는데도 저장, 렌더링, 표시 갱신을 계속 하면 체감이 급격히 떨어진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 성능 문제를 볼 때는 막연하게 &amp;ldquo;최적화해야 한다&amp;rdquo;보다 먼저 &lt;b&gt;지금 어떤 일이 너무 자주 반복되는가&lt;/b&gt;를 보는 편이 좋습니다. 그리고 드래그 앤 드롭에서는 그 출발점이 거의 항상&amp;nbsp;&lt;b&gt;dragover &lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리스트가 길어질수록 문제가 되는 건 항목 수 자체보다, 자주 발생하는 이벤트 안에서 전체를 계속 다시 처리하는 구조다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;dragover가 특히 버벅임을 만들기 쉬운 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클릭 이벤트는 버튼을 한 번 눌렀을 때 한 번 반응합니다. 반면 &lt;b&gt;dragover&lt;/b&gt;는 사용자가 항목 위를 지나가는 동안 매우 자주 발생합니다. 마우스를 조금만 움직여도 계속 들어오기 때문에, 이 안에 무거운 작업이 들어 있으면 바로 느려집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 자주 만드는 첫 버전은 대개 이런 흐름입니다. dragover가 들어오면 현재 드롭 위치를 계산하고, 표시선을 갱신하고, 목록 전체를 다시 렌더링하고, 어떤 경우에는 저장까지 해버립니다. 기능은 맞을 수 있지만, dragover에 이런 작업이 다 몰리면 드래그가 불안정해지는 건 거의 자연스러운 결과입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 같은 패턴은 처음엔 그럴듯해 보여도 dragover 안에서는 부담이 큽니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleDragOver(event, todoId, element) {

event.preventDefault();

currentDropId = todoId;
currentDropPosition = getDropPosition(event, element);

renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 dragover가 일어날 때마다 전체 목록을 다시 그립니다. 목록이 작을 때는 버틸 수 있어도, 항목이 늘어나면 표시선 하나 바꾸려고 목록 전체를 재생성하는 셈이 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;dragover 안에서 피하는 편이 좋은 작업&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;작업&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 부담이 큰가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 render&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아직 데이터는 안 바뀌었는데 화면 전체를 계속 다시 만든다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;save 호출&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 순서 변경은 drop 때 일어나는데, 중간 상태를 계속 기록할 이유가 없다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색&amp;middot;정렬&amp;middot;필터 전체 재계산 후 재렌더&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지나가는 순간마다 무거운 계산이 반복된다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dragover에서 해야 할 일은 생각보다 작게 잡는 편이 좋습니다. 대개는 아래 정도면 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;drop을 허용한다&lt;/li&gt;
&lt;li&gt;현재 target과 before&amp;middot;after를 계산한다&lt;/li&gt;
&lt;li&gt;정말 달라진 경우에만 표시선을 바꾼다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, dragover는 &lt;b&gt;데이터를 바꾸는 단계가 아니라 드롭 의도를 가볍게 추적하는 단계&lt;/b&gt;로 보는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;dragover는 결과를 확정하는 순간이 아니라, 지금 어디로 들어갈지를 잠깐 보여주는 단계에 가깝다. 그래서 가볍게 움직여야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;renderTodos&lt;/b&gt;를 다시 부르면 되니까요. add, delete, toggle, drop처럼 실제 데이터가 변한 뒤에는 이 방식이 꽤 잘 맞습니다. 문제는 아직 데이터가 안 바뀐 순간에도 이 방식을 그대로 쓰기 시작할 때입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 중에는 대부분의 시간이 &amp;ldquo;&lt;b&gt;확정 전 상태&lt;/b&gt;&amp;rdquo;입니다. 사용자는 아직 놓지 않았고, 진짜 순서 변경도 일어나지 않았습니다. 이때 필요한 건 잠깐의 시각적 피드백이지, 목록 전체를 다시 만드는 일이 아닙니다. 그래서 드래그 관련 상태를 두 종류로 나눠서 보는 편이 좋습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;드래그 중 상태를 나눠서 보면 훨씬 덜 헷갈린다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;종류&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;언제 바뀌나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;저장 대상인가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 데이터 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;todos, order&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop이 완료됐을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 드래그 상태&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId, currentDropId, currentDropPosition&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragstart, dragover, dragend 동안 계속 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이벤트별로 무엇을 해야 하나&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이벤트&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;데이터 변경&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;저장&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;전체 렌더링&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragstart&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대체로 아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;아니오&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대체로 아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;delete, toggle, edit&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;예&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;실제 순서 변경이 확정되는 drop 시점에만 크게 반응하는 구조&lt;/b&gt;가 훨씬 안정적입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function handleDrop(event) {

event.preventDefault();

if (!draggedId || !currentDropId || !currentDropPosition) return;

moveTodoByDropPosition(draggedId, currentDropId, currentDropPosition);

draggedId = null;
currentDropId = null;
currentDropPosition = null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중요한 감각&lt;/b&gt;&lt;br /&gt;드래그 중의 움직임은 대부분 임시 상태이고, drop만 실제 변경이다. 이 둘을 같은 무게로 다루면 화면이 쉽게 무거워진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클릭 이벤트는 묶고, 드래그 상태는 가볍게 유지하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 길어질수록 또 하나 눈에 띄는 부분이 있습니다. 항목마다 완료 버튼, 삭제 버튼, 수정 버튼에 각각 이벤트를 붙여두면 렌더링할 때마다 그 연결도 다시 만들어질 수 있다는 점입니다. 이 방식이 틀렸다는 뜻은 아닙니다. 다만 리스트가 커지고 재렌더가 많아질수록 부담이 조금씩 쌓일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 클릭성 기능은 한 단계 더 정리해볼 수 있습니다. 바로 &lt;b&gt;부모 요소 하나에서 이벤트를 받아서 분기하는 방식&lt;/b&gt;입니다. 흔히 이벤트 위임이라고 부르지만, 입문자 입장에서는 그냥 &amp;ldquo;&lt;b&gt;버튼마다 따로 받지 않고 목록 전체가 한 번 받는다&lt;/b&gt;&amp;rdquo; 정도로 이해해도 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 삭제와 완료 체크처럼 클릭으로 끝나는 기능은 아래처럼 묶을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;todoList.addEventListener('click', (event) =&amp;gt; {

const button = event.target.closest('button');
if (!button) return;

const action = button.dataset.action;
const id = Number(button.dataset.id);

if (action === 'delete') {
deleteTodo(id);
return;
}

if (action === 'toggle') {
toggleTodo(id);
return;
}

if (action === 'edit') {
startEditTodo(id);
}
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 장점은 꽤 분명합니다. 렌더링할 때 버튼마다 click 리스너를 다시 붙이는 대신, 목록 하나가 클릭을 받아서 그 안에서 어떤 버튼인지 구분하면 되기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;클릭 이벤트를 묶는 방식과 항목마다 붙이는 방식의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;장점&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목마다 개별 click 연결&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음에는 직관적이다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;리스트가 길고 재렌더가 많아지면 반복 연결이 늘어날 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;부모 하나에서 click 받기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 덜 반복되고 수정 지점이 줄어든다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼의 data 정보와 action 구분이 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 드래그 이벤트까지 처음부터 전부 같은 방식으로 한 번에 묶으려 하면 입문자에게는 오히려 더 헷갈릴 수 있습니다. 클릭 계열은 부모 위임이 꽤 잘 맞지만, dragover와 drop은 요소 위치나 현재 요소 자체가 중요해서 첫 버전에서는 &lt;b&gt;각 항목에서 받되, 그 안의 작업을 아주 가볍게 유지하는 편&lt;/b&gt;이 훨씬 이해하기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 초보자 기준에서는 이렇게 나누면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;완료, 삭제, 수정 같은 클릭 기능&lt;/b&gt;: 부모에서 묶어 받기 고려&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dragstart, dragover, drop 같은 드래그 기능&lt;/b&gt;: 항목 단위로 두되, 전체 렌더 대신 가벼운 상태 변경만 하기&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 감각으로 정리하면&lt;/b&gt;&lt;br /&gt;클릭은 한곳으로 모으기 쉽고, 드래그는 자주 일어나기 때문에 가볍게 두는 편이 낫다. 둘을 똑같은 방식으로 보지 않는 것이 오히려 현실적이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;dragover에서 전체 render를 빼기&lt;/b&gt;&lt;br /&gt;표시선만 바꾸고, 실제 데이터 변경과 전체 렌더는 drop 시점으로 미룹니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현재 drop 대상과 위치가 진짜 바뀐 경우에만 표시선 업데이트&lt;/b&gt;&lt;br /&gt;같은 항목의 같은 절반 위에 계속 머물고 있다면 매번 다시 바꿀 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;저장은 drop에서만 하기&lt;/b&gt;&lt;br /&gt;드래그 중간 상태까지 저장할 이유는 거의 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클릭성 버튼은 부모에서 묶어 받기 검토&lt;/b&gt;&lt;br /&gt;완료, 삭제, 수정은 구조를 단순하게 만들 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;custom 모드에서만 재배치 허용&lt;/b&gt;&lt;br /&gt;자동 정렬 모드와 성능 문제를 한 번에 섞지 않는 편이 좋습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;먼저 손보면 체감이 큰 순서&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;먼저 보기 좋은 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover 안의 전체 렌더 제거&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 체감에 가장 직접적인 영향을 준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시선 갱신 최소화&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 상태 반복 갱신을 줄여 준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;save를 drop 시점으로 제한&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;의미 없는 중간 저장을 줄인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클릭 이벤트 구조 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;목록이 길어졌을 때 전체 구조가 덜 무거워진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &amp;ldquo;&lt;b&gt;완벽한 최적화&lt;/b&gt;&amp;rdquo;를 목표로 삼지 않는 것입니다. 초보자에게는 그보다 &lt;b&gt;지금 이 이벤트에서 꼭 해야 하는 일만 남기는 습관&lt;/b&gt;이 훨씬 중요합니다. dragover에서 해야 할 일이 정말 표시선 갱신뿐이라면, 나머지는 과감히 나중 단계로 미루는 쪽이 맞습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 현실적인 기준&lt;/b&gt;&lt;br /&gt;성능을 개선한다는 건 무언가 대단한 기법을 쓰는 일이 아니라, 자주 일어나는 이벤트 안에서 꼭 필요한 일만 남기고 나머지를 빼내는 일에 더 가깝다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트가 길어질 때의 버벅임은 특별한 한 가지 버그보다, 작아 보이는 비효율이 여러 군데 쌓여서 생기는 경우가 많습니다. 아래 실수들은 특히 반복해서 보이는 편입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover마다 renderTodos를 호출한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그가 전체적으로 끊기고 늦다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;확정 전 상태에 전체 렌더를 붙였다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop 전까지는 가벼운 시각 상태만 다룬다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover마다 save를 호출한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;불필요하게 무겁고 코드도 더 복잡해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간 상태를 실제 데이터처럼 취급했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장은 실제 순서가 바뀐 drop 이후에만 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 drop 대상이 안 바뀌었는데도 표시를 매번 다시 준다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시선이 흔들리거나 불필요하게 깜빡인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 상태를 계속 다시 반영하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;id와 position이 진짜 바뀌었을 때만 업데이트한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;완료, 삭제, 수정 버튼을 항목마다 계속 다시 연결한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조가 반복되고 수정 포인트가 많아진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클릭 이벤트 구조가 불필요하게 퍼져 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;클릭 계열은 부모에서 묶는 구조를 고려한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 상태와 실제 데이터 상태를 구분하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;코드가 금방 엉키고 어디서 바뀌는지 감이 안 잡힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;임시 상태와 저장 상태를 같은 무게로 다루고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId, currentDropPosition 같은 값은 임시 상태로 분리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬, 검색, 드래그 재배치를 동시에 다 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;느릴 뿐 아니라 결과도 설명하기 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;여러 상태가 한 번에 겹쳐 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 custom 모드와 전체 보기부터 안정화한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 가장 먼저 손대기 좋은 건 늘 비슷합니다. dragover 안의 무거운 작업을 빼고, 표시선 업데이트를 최소화하고, drop 때만 실제 재배치와 저장을 하게 만드는 것. 이 세 가지만 정리해도 드래그 느낌이 꽤 달라지는 경우가 많습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;성능 문제는 대단한 알고리즘보다, &amp;ldquo;이 순간 정말 이 작업이 필요한가&amp;rdquo;를 안 따진 채 자주 일어나는 이벤트 안에 많은 일을 몰아넣을 때 더 쉽게 생긴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;를 같이 보는 눈이 필요합니다. 같은 기능이라도 어떤 이벤트 안에 넣느냐에 따라 체감은 크게 달라집니다. 특히 dragover처럼 자주 일어나는 단계에서는 전체를 다시 만드는 대신, 지금 꼭 필요한 반응만 남기는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 리스트가 길어질수록 중요한 건 항목 수 자체보다 자주 발생하는 이벤트 안의 작업량이라는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, dragover는 대부분 임시 상태를 다루는 단계이고, 실제 재배치와 저장은 drop에서 일어나야 한다는 점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 클릭 계열과 드래그 계열은 같은 방식으로 묶기보다, 클릭은 구조를 줄이고 드래그는 가볍게 유지하는 쪽이 현실적이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 체크리스트 앱은 단순히 돌아가는 수준을 넘어, 꽤 설명 가능한 구조를 갖추기 시작합니다. 다음 편에서는 &lt;b&gt;데스크톱에서는 잘 되던 드래그가 모바일 터치 환경에서는 왜 다시 다른 문제로 바뀌는지&lt;/b&gt;, 그리고 왜 마우스 기준으로 만든 흐름을 그대로 믿으면 안 되는지를 이어서 다룹니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>dragover성능</category>
      <category>렌더링최적화</category>
      <category>이벤트위임</category>
      <category>이벤트최적화</category>
      <category>자바스크립트성능</category>
      <category>재렌더링문제</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/28</guid>
      <comments>https://story86025.tistory.com/28#entry28comment</comments>
      <pubDate>Tue, 31 Mar 2026 12:40:50 +0900</pubDate>
    </item>
    <item>
      <title>Gemini는 NotebookLM이 본체다? 써보면 그렇게 느껴지는 이유</title>
      <link>https://story86025.tistory.com/76</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_Codex_202603311740.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zMqIU/dJMcaflL87C/yzykFn8zE9pk6xkYKosbSK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zMqIU/dJMcaflL87C/yzykFn8zE9pk6xkYKosbSK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zMqIU/dJMcaflL87C/yzykFn8zE9pk6xkYKosbSK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzMqIU%2FdJMcaflL87C%2FyzykFn8zE9pk6xkYKosbSK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_Codex_202603311740.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글의 제목은 사실 선언이라기보다 &lt;b&gt;사용하면서 생긴 느낌&lt;/b&gt;에 가깝습니다. &lt;span data-ui=&quot;term&quot; data-note=&quot;Google의 범용 AI 도우미 제품군/앱입니다. 채팅, 생성, 리서치, 연결 기능이 넓게 퍼져 있습니다.&quot;&gt;Gemini&lt;/span&gt;를 이것저것 써보다 보면, 기능은 정말 많습니다. 검색도 하고, 글도 쓰고, 이미지도 만들고, &lt;span data-ui=&quot;term&quot; data-note=&quot;긴 주제를 여러 단계로 조사해서 보고서 형태로 정리해주는 Gemini 기능입니다.&quot;&gt;Deep Research&lt;/span&gt;도 돌리고, 공부용 도구도 붙습니다. 그런데 막상 &amp;ldquo;그래서 내가 가장 자주 다시 열게 되는 곳이 어디냐&amp;rdquo;라고 물으면, 제 경우엔 자꾸 &lt;span data-ui=&quot;term&quot; data-note=&quot;내 자료를 넣고, 그 자료를 바탕으로 이해&amp;middot;정리&amp;middot;질문하는 데 강한 Google의 AI 연구/학습 도구입니다.&quot;&gt;NotebookLM&lt;/span&gt; 쪽으로 손이 갑니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그럴까 생각해보면 이유는 단순합니다. Gemini는 굉장히 넓고, NotebookLM은 굉장히 또렷합니다. Gemini는 할 수 있는 일이 많아서 인상적이고, NotebookLM은 &lt;b&gt;무엇을 위해 켜는지가 분명해서&lt;/b&gt; 기억에 남습니다. 그래서 과장해서 말하면, Gemini가 바깥으로 크게 펼쳐진 얼굴이라면, NotebookLM은 안쪽에서 실제로 일을 굴리는 본체처럼 느껴질 때가 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기서 말하는 &amp;lsquo;본체&amp;rsquo;는 조직도 얘기가 아닙니다.&lt;/b&gt;&lt;br /&gt;공식 제품 구조를 말하는 게 아니라, 사용자가 &amp;ldquo;아, 이건 진짜 도구 같다&amp;rdquo;라고 느끼는 중심이 어디냐는 뜻에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Gemini보다 NotebookLM에서 더 &amp;lsquo;본체 느낌&amp;rsquo;이 나는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제일 큰 차이는 출발점입니다. Gemini를 열면 기본적으로 &amp;ldquo;무엇이든 물어보는 창&amp;rdquo;에 가깝습니다. 반대로 NotebookLM은 대개 &lt;b&gt;내 자료부터 넣고 시작하는 공간&lt;/b&gt;처럼 느껴집니다. 이 차이가 생각보다 큽니다. 질문 중심으로 시작하면 AI가 똑똑해 보이는 순간은 많지만, 자료 중심으로 시작하면 AI가 &lt;b&gt;쓸모 있어 보이는 순간&lt;/b&gt;이 더 많아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 블로그 원고를 준비한다고 해보겠습니다. Gemini에서는 &amp;ldquo;Gemini와 NotebookLM의 차이를 설명해줘&amp;rdquo;라고 바로 물을 수 있습니다. 물론 편합니다. 그런데 NotebookLM에서는 내가 모아둔 자료, 기사, PDF, 메모, 강의 자료를 넣어두고 &amp;ldquo;여기서 공통된 핵심만 뽑아줘&amp;rdquo;, &amp;ldquo;관점이 엇갈리는 부분만 따로 정리해줘&amp;rdquo;, &amp;ldquo;이 자료만 기준으로 블로그 초안 구조를 잡아줘&amp;rdquo;처럼 움직이게 됩니다. 이 순간부터 AI는 대화 상대가 아니라 &lt;b&gt;자료실을 같이 뒤지는 조수&lt;/b&gt;에 가까워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 이 지점 때문에 NotebookLM이 더 본체처럼 느껴집니다. 사람들은 생각보다 &amp;ldquo;답변이 멋진 AI&amp;rdquo;보다 &amp;ldquo;내 자료를 제대로 읽는 AI&amp;rdquo;에 더 오래 머무는 경우가 많기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NotebookLM은 왜 더 도구답게 느껴질까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NotebookLM의 강점은 화려한 말솜씨보다 &lt;b&gt;근거를 따라가게 만드는 구조&lt;/b&gt;에 있습니다. 그냥 그럴듯하게 말해주는 쪽보다, 어디서 가져온 말인지, 어떤 자료를 바탕으로 답했는지를 확인하게 만드는 흐름이 강합니다. 입문자 입장에서는 이게 꽤 중요합니다. AI를 오래 쓸수록 사람은 답변의 유창함보다 &lt;b&gt;출처를 붙잡을 수 있는가&lt;/b&gt;를 더 따지게 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 &lt;span data-ui=&quot;term&quot; data-note=&quot;문서나 자료를 대화형 오디오로 풀어주는 기능입니다.&quot;&gt;Audio Overview&lt;/span&gt; 같은 기능이 붙으면 체감이 더 선명해집니다. 단순 요약을 읽는 게 아니라, 자료를 다른 방식으로 다시 소화하게 해주기 때문입니다. 그래서 NotebookLM은 &amp;ldquo;답해주는 AI&amp;rdquo;라기보다 &amp;ldquo;내가 넣은 내용을 다시 이해하게 만드는 AI&amp;rdquo;에 더 가깝게 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 아주 미묘한 차이 같지만 실제 사용에서는 꽤 다릅니다. Gemini는 잘 물어보면 잘 답해주는 느낌이 강하고, NotebookLM은 잘 넣어두면 계속 써먹히는 느낌이 강합니다. 전자는 순간적인 만족이 크고, 후자는 누적되는 만족이 큽니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;비교 포인트&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;Gemini에서 받는 인상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;NotebookLM에서 받는 인상&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;출발점&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;질문부터 던진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;자료부터 쌓는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;느낌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;만능 비서&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;전용 연구 노트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;강한 순간&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;빠른 발상, 넓은 질문, 즉석 생성&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;자료 정리, 근거 확인, 학습&amp;middot;리서치 누적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;사용 후 느낌&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;좋은 답을 받았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;쓸 만한 작업물이 남았다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;오히려 Gemini가 너무 넓어서 중심이 흐려 보일 때가 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini가 약하다는 뜻은 아닙니다. 오히려 반대입니다. 지금의 Gemini는 너무 많은 역할을 맡고 있습니다. 채팅, 이미지, 오디오, 문서 작성, 앱 연결, 학습 도구, 리서치, 모바일 경험까지 다 끌어안는 쪽에 가깝습니다. 기능표만 보면 &amp;ldquo;이게 메인이다&amp;rdquo; 싶을 정도로 크고 넓습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 입문자 입장에서는 가끔 그 넓이가 장점이 아니라 &lt;b&gt;정체성의 흐림&lt;/b&gt;으로 느껴질 때가 있습니다. 오늘은 검색형으로 쓰고, 내일은 글쓰기 도구처럼 쓰고, 또 다른 날은 공부 도구처럼 쓰다 보면 &amp;ldquo;그래서 이 제품의 가장 강한 얼굴이 뭐지?&amp;rdquo;라는 질문이 남습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 NotebookLM은 그 질문에 꽤 빨리 답합니다. &amp;ldquo;내 자료를 넣고, 그 자료를 바탕으로 이해하고 정리하는 도구.&amp;rdquo; 이 정의가 선명합니다. 사용자는 선명한 도구를 더 빨리 기억합니다. 그래서 체감상 Gemini보다 NotebookLM 쪽이 더 본체처럼 느껴지는 겁니다. 넓어서 큰 것과, 선명해서 중심처럼 느껴지는 것은 조금 다른 문제입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;흥미로운 건, 구글도 둘을 점점 닮게 만들고 있다는 점이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 단순 감상에서 끝내기 어려운 이유도 여기에 있습니다. 최근 흐름을 보면 Gemini 쪽에도 공부와 이해 중심 기능이 많이 붙고 있고, 반대로 Gemini의 리서치 흐름 안으로 NotebookLM이 자연스럽게 이어지는 장면도 보입니다. 다시 말해, 둘은 따로 노는 제품이라기보다 &lt;b&gt;점점 같은 방향으로 가까워지는 느낌&lt;/b&gt;이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 &amp;ldquo;Gemini는 NotebookLM이 본체다&amp;rdquo;라는 말을 아주 문자 그대로 받아들이기보다, &lt;b&gt;Gemini의 가장 설득력 있는 얼굴이 NotebookLM 쪽에서 더 선명하게 보인다&lt;/b&gt;는 뜻으로 읽는 편이 맞다고 봅니다. 이 표현이 조금 거칠긴 해도, 실제 사용감은 꽤 정확하게 짚습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;조금 더 정확하게 바꾸면&lt;/b&gt;&lt;br /&gt;&amp;ldquo;Gemini의 여러 기능 중에서, 사용자가 가장 오래 붙잡고 실제 도구처럼 느끼는 축은 NotebookLM에 가깝다&amp;rdquo; 정도가 이 글의 진짜 주장입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;AI를 대화상대보다 &lt;b&gt;자료 정리 도구&lt;/b&gt;로 쓰고 싶은 사람&lt;/li&gt;
&lt;li&gt;PDF, 기사, 유튜브, 메모, 회의록처럼 &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;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 즉석 아이디어 발상, 빠른 대화, 범용 생성, 여러 Google 서비스와 이어지는 흐름이 더 중요하다면 여전히 Gemini가 첫 화면일 가능성이 큽니다. 그러니까 둘 중 하나가 무조건 우위라는 얘기라기보다, &lt;b&gt;어디에서 &amp;lsquo;본체감&amp;rsquo;을 느끼느냐가 다르다&lt;/b&gt;고 보는 편이 현실적입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내 기준으로 정리하면 이렇다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini는 입구입니다. 많이 열려 있고, 많은 걸 받아주고, 질문을 던지기 쉽습니다. NotebookLM은 작업실에 가깝습니다. 들어가면 자료가 있고, 맥락이 남고, 다음에 다시 돌아왔을 때 이어서 쓸 수 있습니다. 둘 다 중요하지만, 오래 써보면 사람은 대개 입구보다 작업실에 더 정이 갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;Gemini는 NotebookLM이 본체다&amp;rdquo;라는 말은 꽤 거친 표현이면서도, 동시에 묘하게 맞는 말입니다. 공식 설명문으로는 무리일 수 있어도, 사용자 느낌으로는 의외로 정확합니다. 구글 AI가 가장 &amp;lsquo;도구답게&amp;rsquo; 느껴지는 지점이 어디냐고 묻는다면, 많은 사람에게 그 답은 Gemini의 대화창이 아니라 NotebookLM의 노트 안쪽일지도 모릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄로 끝내면 이렇습니다. &lt;b&gt;Gemini가 현관이라면, NotebookLM은 방입니다.&lt;/b&gt; 그리고 사람은 결국, 오래 머무는 쪽을 본체처럼 느끼게 됩니다.&lt;/p&gt;</description>
      <category>AI에이전트/Gemini</category>
      <category>GEMINI</category>
      <category>notebooklm</category>
      <category>구글AI</category>
      <category>노트북lm</category>
      <category>제미나이</category>
      <category>제미니</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/76</guid>
      <comments>https://story86025.tistory.com/76#entry76comment</comments>
      <pubDate>Mon, 30 Mar 2026 20:57:19 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 13편: 할 일 앱에 수정 기능 붙이기</title>
      <link>https://story86025.tistory.com/36</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_code_202603241547.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLxnuu/dJMcahjwn4H/uL1i4SFKyyqS5ptn1FotO1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLxnuu/dJMcahjwn4H/uL1i4SFKyyqS5ptn1FotO1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLxnuu/dJMcahjwn4H/uL1i4SFKyyqS5ptn1FotO1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLxnuu%2FdJMcahjwn4H%2FuL1i4SFKyyqS5ptn1FotO1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_code_202603241547.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터 기능까지 붙이고 나면, 이제 할 일 앱이 얼핏 그럴듯해 보이기 시작합니다. 그런데 실제로 조금만 써보면 금방 아쉬운 지점이 보입니다. 잘못 입력한 항목을 다시 쓰려면 지우고 새로 추가해야 하고, 문구를 조금만 바꾸고 싶어도 삭제 후 재입력을 해야 합니다. 이건 작은 불편 같지만, 실제로는 꽤 자주 걸리는 부분입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 편에서는 &lt;b&gt;수정 기능&lt;/b&gt;을 붙여보겠습니다. 다만 이번에도 범위를 크게 벌리지 않겠습니다. 한 번에 여러 항목을 수정하는 방식까지 넣기 시작하면 흐름이 금방 커집니다. 초보자에게 지금 필요한 건 &quot;수정 기능이란 게 이런 구조로 붙는구나&quot;를 이해하는 것이지, 모든 경우를 다 처리하는 것이 아니기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 &lt;b&gt;한 번에 하나의 항목만 수정&lt;/b&gt;할 수 있게 만들겠습니다. 수정 버튼을 누르면 그 항목이 입력 상태로 바뀌고, 저장과 취소 중 하나를 선택할 수 있게 만들겠습니다. 이렇게 해야 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저가 현재 기억하고 있는 값입니다. 지금 수정 중인 항목 번호나 입력 중인 초안 문자열이 여기에 해당합니다.&quot;&gt;상태&lt;/span&gt;가 너무 복잡해지지 않고, JavaScript에서 무엇을 따로 기억해야 하는지도 비교적 또렷하게 보입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;각 항목에 수정 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingTodoId, editingText 같은 편집 상태가 추가된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어느 항목을 수정 중인지 따로 기억하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장과 취소 버튼이 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;편집용 입력창과 저장 흐름이 따로 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 목록 한 줄과 편집 중 한 줄이 어떻게 다르게 렌더링되는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 항목을 수정하는 동안 다른 버튼이 잠깐 막힌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동시에 여러 흐름이 섞이지 않도록 상호작용 잠금이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;단순한 UX를 위해 무엇을 일부러 막아두는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;수정 기능의 핵심은 버튼 하나를 더 넣는 데 있지 않습니다. &lt;b&gt;지금 어느 항목을 수정 중인지 기억하고, 그 상태에 따라 화면 한 줄의 구조를 바꾸는 것&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 지금 수정 기능을 붙이는가&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1388&quot; data-origin-height=&quot;759&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNgOhI/dJMcadH66pq/YAeKhO8rtuJSR2fgvXfIWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNgOhI/dJMcadH66pq/YAeKhO8rtuJSR2fgvXfIWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNgOhI/dJMcadH66pq/YAeKhO8rtuJSR2fgvXfIWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNgOhI%2FdJMcadH66pq%2FYAeKhO8rtuJSR2fgvXfIWk%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;1388&quot; height=&quot;759&quot; data-origin-width=&quot;1388&quot; data-origin-height=&quot;759&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 단계에서 수정 기능은 꽤 좋은 다음 단계입니다. 이유는 단순합니다. 새 항목 추가, 완료 체크, 삭제, 전체 삭제, 필터까지 해봤다면 이제는 &lt;b&gt;기존 데이터를 바꾸는 흐름&lt;/b&gt;을 익혀볼 차례이기 때문입니다. 추가는 새 항목을 넣는 동작이고, 삭제는 항목을 없애는 동작입니다. 수정은 그 중간에 있습니다. 이미 있는 값을 읽어와서, 바꿔서, 다시 저장해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에는 생각보다 많은 감각이 들어 있습니다. 지금 어떤 항목을 수정 중인지 기억해야 하고, 화면도 그 상태에 맞게 바뀌어야 하고, 저장했을 때는 목록과 저장 데이터가 같이 바뀌어야 합니다. 겉으로는 버튼 하나 더 생기는 것처럼 보여도, 실제로는 상태 관리 연습에 꽤 잘 맞는 기능입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 데이터를 불러와 수정하는 흐름을 익힐 수 있습니다.&lt;/li&gt;
&lt;li&gt;현재 수정 중인 상태를 따로 기억하는 방식을 배울 수 있습니다.&lt;/li&gt;
&lt;li&gt;취소와 저장처럼 분기되는 동작을 다루는 연습이 됩니다.&lt;/li&gt;
&lt;li&gt;기존 기능을 유지한 채 새로운 기능을 추가하는 감각을 익히기 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 기능은 단순히 버튼 하나를 더 넣는 일이 아니라, &lt;b&gt;현재 상태를 따로 기억하고 화면에 반영하는 연습&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/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;/li&gt;
&lt;li&gt;수정 중에는 다른 항목 버튼과 필터 버튼을 잠깐 비활성화합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 편에서는 아래 내용은 일부러 넣지 않겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 항목을 동시에 수정하는 기능&lt;/li&gt;
&lt;li&gt;수정 중인 내용을 따로 임시 저장하는 기능&lt;/li&gt;
&lt;li&gt;키보드 단축키를 많이 넣는 복잡한 편집 흐름&lt;/li&gt;
&lt;li&gt;완료 상태까지 한 화면에서 동시에 바꾸는 복합 편집&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 범위를 줄이는 이유는 분명합니다. 지금은 수정 기능의 핵심 구조를 이해하는 게 먼저이기 때문입니다. 범위가 넓어지면 &quot;왜 이렇게 동작하는지&quot;보다 &quot;어떻게든 되게 만드는 것&quot;에만 매달리기 쉽습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 수정 기능이 왜 상태 기능인지 먼저 느껴보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1366&quot; data-origin-height=&quot;647&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcr9xF/dJMcaduyMMT/dKpYDg14oV5BPCeIYV9iFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcr9xF/dJMcaduyMMT/dKpYDg14oV5BPCeIYV9iFK/img.png&quot; data-alt=&quot;&amp;amp;lt; 수정 기능의 상태 전환 개념도 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcr9xF/dJMcaduyMMT/dKpYDg14oV5BPCeIYV9iFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdcr9xF%2FdJMcaduyMMT%2FdKpYDg14oV5BPCeIYV9iFK%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;1366&quot; height=&quot;647&quot; data-origin-width=&quot;1366&quot; data-origin-height=&quot;647&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 수정 기능의 상태 전환 개념도 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 아주 짧게 흐름을 먼저 상상해보는 편이 좋습니다. 아래처럼 항목 하나가 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;[ ] JavaScript 복습하기   [수정] [삭제]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 수정 버튼을 누르면, 그냥 텍스트만 바뀌는 게 아닙니다. 화면 한 줄의 모양 자체가 바뀌어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;[입력창: JavaScript 복습하기]   [저장] [취소]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 수정 기능은 &quot;값을 하나 바꾸는 기능&quot;이라기보다, &lt;b&gt;현재 이 항목이 평소 상태인지, 수정 중 상태인지&lt;/b&gt;에 따라 한 줄 전체를 다르게 그리는 기능에 가깝습니다. 이 감각이 먼저 잡히면 JavaScript 코드가 훨씬 덜 낯설어집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;수정 기능에서 실제로 바뀌는 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;뒤에서 필요한 상태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 버튼을 누르면 입력창이 나타난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 수정 중인 항목 번호가 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중 내용이 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 수정 중인 문자열을 따로 기억해야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장과 취소가 분기된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;editingTodoId와 editingText 같은 상태가 있어야 이 한 줄의 모양을 바꿀 수 있다&lt;/b&gt;는 점을 먼저 잡고 들어가면 훨씬 편합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 기능은 &quot;글자를 고친다&quot;보다 &lt;b&gt;&quot;지금 수정 중인 항목을 따로 기억한다&quot;&lt;/b&gt;는 쪽이 먼저입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML은 구조를 유지하고 목록 한 줄만 다르게 그린다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 의외로 HTML 최상단 구조를 많이 바꾸지 않습니다. 수정 입력창과 저장, 취소 버튼은 목록을 그릴 때 JavaScript가 각 항목 안에 동적으로 넣어줄 것이기 때문입니다. 그래서 이번 편에서는 HTML 전체 구조를 유지하면서도, &lt;b&gt;코드가 한눈에 읽히도록 정렬을 분명하게 맞춘 상태&lt;/b&gt;로 보는 게 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 유지하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Edit Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          추가한 할 일을 나중에 다시 고칠 수 있게 만드는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 수정 기능 테스트하기&quot;
            &amp;gt;
            &amp;lt;button
              id=&quot;submitButton&quot;
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 HTML이 크게 안 바뀌는 이유는, 수정 기능의 핵심이 정적 구조보다 &lt;b&gt;목록 항목을 그리는 방식 자체&lt;/b&gt;에 있기 때문입니다. 즉, 수정 버튼과 편집 입력창은 페이지 처음부터 박혀 있는 요소가 아니라, JavaScript가 상황에 따라 넣어주는 요소입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조는 초보자에게도 오히려 도움이 됩니다. 기능이 추가될 때마다 HTML 상단을 계속 크게 뜯지 않고, &lt;b&gt;목록 항목 한 줄을 만드는 로직 안에서만 상태를 바꾸는 방식&lt;/b&gt;을 연습할 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 편에서는 HTML을 새로 뜯는 것보다, &lt;b&gt;동적으로 바뀌는 목록 한 줄의 구조를 JavaScript 안에서 어떻게 다루는지&lt;/b&gt;를 보는 편이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 수정 중 상태가 눈에 보이게 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 기능은 &quot;동작&quot;만큼이나 &quot;지금 수정 중이다&quot;가 화면에서 보여야 제대로 체감됩니다. 그래서 이번 CSS의 핵심은 화려한 디자인이 아니라, &lt;b&gt;수정 중인 항목과 일반 항목이 눈에 띄게 구분되게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.inline-button,
.cancel-button,
.filter-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.save-button {
  border: none;
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.primary-button:disabled,
.secondary-button:disabled,
.delete-button:disabled,
.inline-button:disabled,
.cancel-button:disabled,
.save-button:disabled,
.filter-button:disabled,
input:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-item.editing {
  border-color: #111827;
  background: #ffffff;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-edit {
  flex: 1;
}

.edit-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #111827;
  border-radius: 12px;
  font: inherit;
}

.todo-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-actions {
    width: 100%;
  }

  .inline-button,
  .save-button,
  .cancel-button,
  .delete-button {
    flex: 1 1 auto;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 가장 중요한 부분은 네 군데입니다.&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;todo-item editing&lt;/b&gt; : 수정 중인 카드가 일반 카드와 구분되게 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;edit-input&lt;/b&gt; : 항목 수정용 입력창이 일반 입력창과 섞이지 않게 잡아줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;todo-actions&lt;/b&gt; : 수정, 저장, 취소, 삭제 버튼을 한 묶음으로 다룹니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;save-button&lt;/b&gt; : 저장 버튼은 더 중요한 동작처럼 보이게 구분합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 CSS는 새로운 화면을 만드는 작업이 아니라 &lt;b&gt;수정 상태와 일반 상태를 다르게 읽히게 만드는 작업&lt;/b&gt;에 가깝습니다. 이런 구분이 있어야 사용자도 지금 &quot;그냥 보고 있는지&quot;, &quot;실제로 편집 중인지&quot;를 헷갈리지 않습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 기능의 CSS는 예쁘게 꾸미는 게 아니라, &lt;b&gt;지금 이 항목이 편집 중이라는 걸 눈으로 바로 알 수 있게 만드는 것&lt;/b&gt;이 핵심입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 수정 상태, 저장, 취소 흐름 붙이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 수정 기능은 결국 &lt;b&gt;지금 어느 항목을 수정 중인지&lt;/b&gt;를 따로 기억하고, 그 상태에 맞게 목록 한 줄의 모양과 동작을 바꾸는 작업이기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingTodoId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어떤 항목을 수정 중인지 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;수정 기능의 핵심 상태입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;editingText&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 중인 초안 문자열을 따로 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창 안의 현재 내용을 저장과 취소 흐름에 연결합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createReadonlyItem / createEditingItem&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목도 상태에 따라 다른 구조로 렌더링합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  submitButton: document.querySelector(&quot;#submitButton&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;
let editingTodoId = null;
let editingText = &quot;&quot;;

function loadTodos() {
  try {
    const savedValue = localStorage.getItem(STORAGE_KEY);

    return savedValue ? JSON.parse(savedValue) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getFilteredTodos() {
  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return todos.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return todos.filter(function (todo) {
      return todo.done;
    });
  }

  return todos;
}

function updateComposerState() {
  const isEditing = editingTodoId !== null;

  elements.input.disabled = isEditing;
  elements.submitButton.disabled = isEditing;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();

  elements.summary.textContent =
    &quot;전체 &quot; +
    totalCount +
    &quot;개 &amp;middot; 남은 할 일 &quot; +
    remainingCount +
    &quot;개&quot;;

  elements.clearAllButton.disabled =
    totalCount === 0 || editingTodoId !== null;
}

function updateFilterButtons() {
  const isEditing = editingTodoId !== null;

  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );

    button.disabled = isEditing;
  });
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (editingTodoId !== null) {
    return &quot;수정 중에는 저장 또는 취소를 먼저 선택해보세요.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  return &quot;체크하거나 수정하고, 필요 없으면 삭제해보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function focusEditingInput() {
  const input = document.querySelector(
    &quot;[data-edit-input=\&quot;true\&quot;]&quot;
  );

  if (input) {
    input.focus();
  }
}

function startEditing(todo) {
  editingTodoId = todo.id;
  editingText = todo.text;

  renderTodos(&quot;할 일 수정 모드를 시작했습니다.&quot;);
}

function cancelEditing(messageText) {
  editingTodoId = null;
  editingText = &quot;&quot;;

  renderTodos(messageText || &quot;수정을 취소했습니다.&quot;);
}

function saveEditedTodo(todoId) {
  const nextText = editingText.trim();

  if (!nextText) {
    setMessage(&quot;수정 내용은 비워둘 수 없습니다.&quot;);
    focusEditingInput();
    return;
  }

  todos = todos.map(function (todo) {
    if (todo.id === todoId) {
      return {
        id: todo.id,
        text: nextText,
        done: todo.done
      };
    }

    return todo;
  });

  editingTodoId = null;
  editingText = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 수정했습니다.&quot;);
}

function createCheckbox(todo, disabled) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;
  checkbox.disabled = Boolean(disabled);

  if (!disabled) {
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
    });
  }

  return checkbox;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.textContent = todo.text;

  return text;
}

function createActionButton(
  label,
  className,
  handler,
  disabled
) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = className;
  button.textContent = label;
  button.disabled = Boolean(disabled);

  if (!disabled) {
    button.addEventListener(&quot;click&quot;, handler);
  }

  return button;
}

function createDeleteButton(todoId, disabled) {
  return createActionButton(
    &quot;삭제&quot;,
    &quot;delete-button&quot;,
    function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todoId;
      });

      saveTodos();
      renderTodos(&quot;항목을 삭제했습니다.&quot;);
    },
    disabled
  );
}

function createEditButton(todo, disabled) {
  return createActionButton(
    &quot;수정&quot;,
    &quot;inline-button&quot;,
    function () {
      startEditing(todo);
    },
    disabled
  );
}

function createReadonlyItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);
  const isLocked = editingTodoId !== null;

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;
  actions.className = &quot;todo-actions&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo, isLocked),
    createTodoText(todo)
  );

  actions.append(
    createEditButton(todo, isLocked),
    createDeleteButton(todo.id, isLocked)
  );

  item.append(left, actions);

  return item;
}

function createEditInput(todoId) {
  const input = document.createElement(&quot;input&quot;);

  input.type = &quot;text&quot;;
  input.className = &quot;edit-input&quot;;
  input.value = editingText;
  input.setAttribute(&quot;data-edit-input&quot;, &quot;true&quot;);

  input.addEventListener(&quot;input&quot;, function () {
    editingText = input.value;
  });

  input.addEventListener(&quot;keydown&quot;, function (event) {
    if (event.key === &quot;Enter&quot;) {
      event.preventDefault();
      saveEditedTodo(todoId);
    }

    if (event.key === &quot;Escape&quot;) {
      event.preventDefault();
      cancelEditing();
    }
  });

  return input;
}

function createEditingItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const editArea = document.createElement(&quot;div&quot;);
  const actions = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item editing&quot;;
  editArea.className = &quot;todo-edit&quot;;
  actions.className = &quot;todo-actions&quot;;

  editArea.append(
    createEditInput(todo.id)
  );

  actions.append(
    createActionButton(
      &quot;저장&quot;,
      &quot;save-button&quot;,
      function () {
        saveEditedTodo(todo.id);
      }
    ),
    createActionButton(
      &quot;취소&quot;,
      &quot;cancel-button&quot;,
      function () {
        cancelEditing();
      }
    )
  );

  item.append(editArea, actions);

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateComposerState();
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    if (todo.id === editingTodoId) {
      elements.list.appendChild(
        createEditingItem(todo)
      );
      return;
    }

    elements.list.appendChild(
      createReadonlyItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());

  if (editingTodoId !== null) {
    focusEditingInput();
  }
}

function addTodo(todoText) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false
  });

  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0 || editingTodoId !== null) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter || editingTodoId !== null) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
}

bindEvents();
renderTodos();&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;editingTodoId&lt;/b&gt; : 지금 어느 항목을 수정 중인지 기억합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;editingText&lt;/b&gt; : 입력창 안의 현재 문자열을 따로 들고 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createReadonlyItem&lt;/b&gt;과 &lt;b&gt;createEditingItem&lt;/b&gt; : 같은 할 일 항목이라도 일반 보기와 수정 보기일 때 화면을 다르게 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 기능의 핵심은 &quot;수정 버튼을 눌렀다&quot;가 아닙니다. 실제 핵심은 &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-origin-width=&quot;641&quot; data-origin-height=&quot;806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buHg9C/dJMcacibD7W/8Q52K9pNpMi3bFp2yYcKJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buHg9C/dJMcacibD7W/8Q52K9pNpMi3bFp2yYcKJ1/img.png&quot; data-alt=&quot;&amp;amp;lt; 상호작용 잠금 (Interaction Lock) &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buHg9C/dJMcacibD7W/8Q52K9pNpMi3bFp2yYcKJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuHg9C%2FdJMcacibD7W%2F8Q52K9pNpMi3bFp2yYcKJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;641&quot; height=&quot;806&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;806&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 상호작용 잠금 (Interaction Lock) &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 점은 &lt;b&gt;한 번에 하나만 수정&lt;/b&gt;하도록 일부러 제한했다는 점입니다. 여러 항목을 동시에 수정하게 만들 수도 있지만, 지금 단계에서는 그게 별로 좋은 선택이 아닙니다. 상태가 급격히 복잡해지고, 저장과 취소가 섞이기 시작하면 어디가 꼬였는지 읽기 어려워지기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이번 버전에서는 수정 중일 때 다른 조작을 잠깐 막았습니다. 이건 기능 부족이 아니라, 초보자 기준에서 더 단순한 흐름을 주기 위한 선택입니다. 저장과 취소가 끝난 뒤 다른 조작으로 넘어가는 쪽이 훨씬 덜 헷갈립니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript 핵심은 &quot;수정 버튼 추가&quot;가 아니라, &lt;b&gt;수정 중인 항목을 따로 기억하고 그 상태에 맞는 화면을 그린다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;입력창에 기존 텍스트가 제대로 들어오는가&lt;/li&gt;
&lt;li&gt;저장을 누르면 수정 내용이 반영되는가&lt;/li&gt;
&lt;li&gt;취소를 누르면 원래 목록 상태로 돌아가는가&lt;/li&gt;
&lt;li&gt;빈 문자열로 저장하려고 하면 막히는가&lt;/li&gt;
&lt;li&gt;수정 중에는 다른 수정 버튼, 삭제 버튼, 필터 버튼이 잠깐 비활성화되는가&lt;/li&gt;
&lt;li&gt;완료 체크, 전체 삭제, 새로고침 후 유지가 수정 기능 추가 뒤에도 그대로 동작하는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 편에서는 &quot;입력창이 뜨는가&quot;보다 &lt;b&gt;저장과 취소 뒤 상태가 어떻게 정리되는가&lt;/b&gt;를 꼭 봐야 합니다. 수정 기능은 화면을 바꾸는 것보다 상태를 닫는 타이밍이 더 중요해지는 경우가 많기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;수정 기능은 시작보다 &lt;b&gt;저장 뒤와 취소 뒤에 상태가 어떻게 정리되는지&lt;/b&gt;를 직접 눌러보는 게 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 기능도 Codex에게 맡길 수 있습니다. 다만 이번 기능은 HTML보다 JavaScript 쪽 로직이 크게 바뀌기 때문에, 요청 범위를 더 분명하게 자르는 편이 좋습니다. &quot;수정 기능 넣어줘&quot;라고 한 줄로 던지면 AI가 예상보다 넓게 흔들 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 완료 체크, 삭제, 전체 삭제,
필터, 저장 기능까지 정상 동작하는 상태야.
이번에는 각 항목에 수정 기능만 추가하고 싶어.
한 번에 하나의 항목만 수정할 수 있게 하고,
저장과 취소 흐름이 분명하게 보이게 해줘.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이렇게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
지금 목록 렌더링 구조는 유지하고,
수정 버튼을 누르면 해당 항목만 입력창으로 바뀌게 해줘.
저장과 취소 버튼도 필요하고,
수정 내용이 비어 있으면 저장되지 않게 막아줘.
한 번에 하나만 수정할 수 있게 하고,
수정 중인 항목을 어떤 변수로 관리할지도 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 정리하고 싶다면 이렇게 더 작게 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
수정 모드일 때 항목 카드가 일반 상태와 구분되게 해줘.
수정 입력창, 저장 버튼, 취소 버튼이 한눈에 보이게 하고,
모바일에서도 버튼이 너무 답답하지 않게 정리해줘.
전체 디자인을 갈아엎지 말고 필요한 부분만 추가해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 기능 추가와 전면 개편을 섞을 가능성이 줄어듭니다. 결국 AI를 잘 쓰는 것도 화려한 문장보다 &lt;b&gt;수정 범위를 정확하게 제한하는 습관&lt;/b&gt;에 더 가까운 경우가 많습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 수정 기능을 맡길 때는 &quot;수정 기능 넣어줘&quot;보다 &lt;b&gt;한 번에 하나만, 저장과 취소 포함, 기존 기능 유지&lt;/b&gt;처럼 경계를 같이 주는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 아주 크지 않습니다. 각 항목에 수정 버튼이 생기고, 저장과 취소 흐름이 들어갔을 뿐입니다. 그런데 바로 이런 기능이 프로젝트를 &quot;조금 더 실제로 쓸 만한 도구&quot;처럼 보이게 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게 지금 중요한 건 새로운 문법을 많이 아는 것이 아닙니다. &lt;b&gt;이미 있는 데이터를 어떻게 불러와서, 상태를 따로 기억하고, 화면에 다시 반영하는지 끝까지 따라가는 경험&lt;/b&gt;이 더 중요합니다. 수정 기능은 그 연습으로 꽤 잘 맞는 기능입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 직접 해보면, 이제 할 일 앱은 단순히 적고 지우는 수준을 조금 넘어서기 시작합니다. 한 번 입력한 내용을 나중에 다시 손보고, 그 손본 결과를 저장하고, 다시 화면에 반영하는 흐름까지 들어오기 때문입니다. 그 차이가 생각보다 큽니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>ToDoApp</category>
      <category>VSCode</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>상태관리</category>
      <category>수정기능</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/36</guid>
      <comments>https://story86025.tistory.com/36#entry36comment</comments>
      <pubDate>Mon, 30 Mar 2026 17:00:31 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 12편: 할 일 앱에 전체&amp;middot;진행 중&amp;middot;완료 필터 붙이기</title>
      <link>https://story86025.tistory.com/33</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_code_202603241545.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UWnWF/dJMcaflF19z/NueVBbMZXbZ2BIal42KMbK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UWnWF/dJMcaflF19z/NueVBbMZXbZ2BIal42KMbK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UWnWF/dJMcaflF19z/NueVBbMZXbZ2BIal42KMbK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUWnWF%2FdJMcaflF19z%2FNueVBbMZXbZ2BIal42KMbK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_code_202603241545.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링까지 한 번 거친 프로젝트를 다시 보면, 이제는 새 기능을 붙일 준비가 조금씩 된 상태라고 볼 수 있습니다. 다만 여기서 중요한 건 여전히 같습니다. &lt;b&gt;기능을 크게 키우는 것보다, 작고 분명한 기능 하나를 정확하게 넣는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 이 단계에서 가장 자주 흔들리는 이유는 &quot;이제는 뭔가 더 복잡한 걸 해야 할 것 같다&quot;는 생각 때문입니다. 그런데 실제로는 반대일 때가 많습니다. 지금은 기능을 하나 더 넣더라도, &lt;b&gt;기존 구조를 유지하면서 어디에 무엇이 추가되는지 따라갈 수 있는 크기&lt;/b&gt;여야 합니다. 그래야 다음 수정에서도 덜 흔들립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 지난 편에서 정리한 할 일 앱에 &lt;span data-ui=&quot;term&quot; data-note=&quot;전체, 진행 중, 완료처럼 지금 어떤 항목만 보여줄지 정하는 기준입니다.&quot;&gt;필터&lt;/span&gt;를 붙여보겠습니다. 이 기능은 작아 보이지만, 실제로는 꽤 좋은 연습입니다. 현재 값을 따로 기억해두고, 같은 목록이라도 어떤 기준으로 보여줄지 바꿔야 하기 때문입니다. 즉, 단순히 버튼 하나 더 넣는 게 아니라 &lt;b&gt;상태와 화면 표시를 연결하는 감각&lt;/b&gt;을 익히는 데 잘 맞는 기능입니다.&lt;/p&gt;
&lt;table style=&quot;width: 99.8837%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top; width: 26.2791%;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top; width: 33.7209%;&quot;&gt;실제로 추가되는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top; width: 39.6512%;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 26.2791%;&quot;&gt;전체, 진행 중, 완료 버튼이 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 33.7209%;&quot;&gt;currentFilter 같은 &lt;span data-ui=&quot;term&quot; data-note=&quot;브라우저가 현재 기억하고 있는 값입니다. currentFilter 같은 값이 여기에 해당합니다.&quot;&gt;상태&lt;/span&gt;가 하나 더 생긴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 39.6512%;&quot;&gt;지금 어떤 기준으로 목록을 보여주는지 따로 기억하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 26.2791%;&quot;&gt;같은 목록이라도 보이는 항목이 달라진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 33.7209%;&quot;&gt;현재 필터 기준으로 실제 보여줄 배열만 다시 골라낸다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 39.6512%;&quot;&gt;전체 목록을 바꾸는 게 아니라, 보여주는 기준만 달라지는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 26.2791%;&quot;&gt;활성 버튼이 눈에 띄게 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 33.7209%;&quot;&gt;버튼 상태와 화면 상태가 같이 움직인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top; width: 39.6512%;&quot;&gt;선택된 필터가 UI에서도 분명하게 보이는지 확인한&lt;br /&gt;다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;752&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSJQb5/dJMcacbpzbl/7gx8R13YVxlfZiwr4EWAU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSJQb5/dJMcacbpzbl/7gx8R13YVxlfZiwr4EWAU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSJQb5/dJMcacbpzbl/7gx8R13YVxlfZiwr4EWAU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSJQb5%2FdJMcacbpzbl%2F7gx8R13YVxlfZiwr4EWAU0%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;1395&quot; height=&quot;752&quot; data-origin-width=&quot;1395&quot; data-origin-height=&quot;752&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;이번 편의 핵심은 버튼을 세 개 더 만드는 일이 아니라, &lt;b&gt;현재 필터 상태를 따로 들고 있으면서 화면에 어떤 목록을 보여줄지 다시 고르는 구조&lt;/b&gt;를 만드는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;작은 기능 하나를 붙여보는 과정에는 생각보다 많은 것이 들어 있습니다. HTML에 버튼을 추가하고, CSS로 현재 선택 상태를 보이게 만들고, JavaScript에서 현재 필터 상태를 따로 들고 있어야 합니다. 즉, 겉보기에는 작은 기능이지만 구조, 스타일, 동작이 모두 연결됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 기능을 유지하면서 새 기능을 붙이는 감각을 익힐 수 있습니다.&lt;/li&gt;
&lt;li&gt;어디까지가 UI이고 어디부터가 상태 관리인지 조금 더 분명해집니다.&lt;/li&gt;
&lt;li&gt;Codex에게도 더 구체적이고 안전한 요청을 할 수 있게 됩니다.&lt;/li&gt;
&lt;li&gt;배포한 프로젝트를 &quot;살아 있는 결과물&quot;처럼 조금씩 키워가는 경험이 생깁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;지금 단계에서 중요한 건 기능 수가 아니라, &lt;b&gt;하나의 기능을 기존 구조 안에 자연스럽게 붙이는 경험&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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; : 아직 완료되지 않은 항목만 보여줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완료&lt;/b&gt; : 체크가 끝난 항목만 보여줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능이 좋은 이유는 결과가 바로 눈에 보이기 때문입니다. 버튼 하나를 누르면 같은 목록이라도 다르게 보이고, 완료 체크를 바꾸면 현재 필터에 따라 목록이 바로 달라집니다. 초보자 입장에서는 이런 즉각적인 반응이 구조를 이해하는 데 꽤 도움이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서도 바꾸지 않는 것은 분명히 두고 가겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;할 일 추가 기능은 그대로 유지합니다.&lt;/li&gt;
&lt;li&gt;완료 체크와 삭제 기능도 그대로 유지합니다.&lt;/li&gt;
&lt;li&gt;전체 삭제와 브라우저 저장 기능도 그대로 둡니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 편은 &quot;새 앱 만들기&quot;가 아니라, &lt;b&gt;현재 앱 위에 보기 방식 하나를 더 얹는 작업&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10분 실습: 필터가 왜 상태 기능인지 먼저 느껴보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1339&quot; data-origin-height=&quot;770&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lmqts/dJMcaadBWbW/PAhLW0vBTgOoKPRi6TkYQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lmqts/dJMcaadBWbW/PAhLW0vBTgOoKPRi6TkYQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lmqts/dJMcaadBWbW/PAhLW0vBTgOoKPRi6TkYQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLmqts%2FdJMcaadBWbW%2FPAhLW0vBTgOoKPRi6TkYQ0%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;1339&quot; height=&quot;770&quot; data-origin-width=&quot;1339&quot; data-origin-height=&quot;770&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보기 전에 아주 짧게 상상 실험부터 해보는 편이 좋습니다. 아래처럼 세 개의 할 일을 만든다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;1. 장보기      - 미완료
2. 메일 답장   - 완료
3. 운동하기    - 미완료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 &quot;전체&quot;를 누르면 세 개가 다 보여야 합니다. &quot;진행 중&quot;을 누르면 1번과 3번만 보여야 하고, &quot;완료&quot;를 누르면 2번만 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 목록 자체를 지우거나 새로 만드는 게 아니라, &lt;b&gt;지금 어떤 기준으로 보여줄지를 따로 정한다&lt;/b&gt;는 점입니다. 즉, 데이터는 그대로 있고, 화면에 보이는 결과만 달라집니다. 이 감각이 바로 필터 기능의 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 구현에서도 똑같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;todos 배열은 그대로 둡니다.&lt;/li&gt;
&lt;li&gt;currentFilter 같은 현재 기준을 하나 따로 둡니다.&lt;/li&gt;
&lt;li&gt;그 기준에 맞는 항목만 골라서 &lt;span data-ui=&quot;term&quot; data-note=&quot;현재 값을 기준으로 화면을 다시 그리는 흐름입니다.&quot;&gt;렌더링&lt;/span&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 필터는 목록을 바꾸는 기능이라기보다, &lt;b&gt;보여주는 기준을 바꾸는 기능&lt;/b&gt;에 더 가깝습니다. 이 차이를 먼저 잡고 들어가면 코드가 훨씬 덜 복잡하게 느껴집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;필터를 구현할 때는 &quot;항목을 없앤다&quot;보다 &lt;b&gt;&quot;같은 항목을 다른 기준으로 보여준다&quot;&lt;/b&gt;고 이해하는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;HTML 수정: 필터 버튼 영역 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 HTML에서는 필터 버튼을 둘 영역만 추가하면 됩니다. 입력창과 툴바 아래에 따로 두면, 현재 상태와 보기 전환이 조금 더 분명하게 구분됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
        &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Filter Practice&amp;lt;/p&amp;gt;
        &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
        &amp;lt;p class=&quot;description&quot;&amp;gt;
          전체, 진행 중, 완료 보기 전환을 추가하는 단계입니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;composer&quot;&amp;gt;
        &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
          &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
          &amp;lt;div class=&quot;input-row&quot;&amp;gt;
            &amp;lt;input
              id=&quot;todoInput&quot;
              type=&quot;text&quot;
              placeholder=&quot;예: 필터 기능 점검하기&quot;
            &amp;gt;
            &amp;lt;button
              type=&quot;submit&quot;
              class=&quot;primary-button&quot;
            &amp;gt;
              추가
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/form&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
        &amp;lt;p
          id=&quot;summaryText&quot;
          class=&quot;summary&quot;
        &amp;gt;
          전체 0개 &amp;middot; 남은 할 일 0개
        &amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div class=&quot;filter-section&quot;&amp;gt;
        &amp;lt;div
          class=&quot;filter-group&quot;
          role=&quot;group&quot;
          aria-label=&quot;필터 선택&quot;
        &amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button is-active&quot;
            data-filter=&quot;all&quot;
            aria-pressed=&quot;true&quot;
          &amp;gt;
            전체
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;active&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            진행 중
          &amp;lt;/button&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            class=&quot;filter-button&quot;
            data-filter=&quot;completed&quot;
            aria-pressed=&quot;false&quot;
          &amp;gt;
            완료
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;p
        id=&quot;message&quot;
        class=&quot;message&quot;
      &amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;div class=&quot;list-section&quot;&amp;gt;
        &amp;lt;ul
          id=&quot;todoList&quot;
          class=&quot;todo-list&quot;
        &amp;gt;&amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 필터 버튼 세 개가 아니라, &lt;b&gt;지금 어떤 보기 상태인지 구분할 수 있는 구역이 따로 생겼다&lt;/b&gt;는 점입니다. 즉, 사용자가 앱을 &quot;입력하는 구역&quot;, &quot;상태를 보는 구역&quot;, &quot;보기 방식을 바꾸는 구역&quot;으로 나눠서 볼 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 눈에 익혀둘 부분은 &lt;b&gt;data-filter&lt;/b&gt;입니다. 이 속성 덕분에 JavaScript에서 &quot;이 버튼이 전체용인지, 진행 중용인지, 완료용인지&quot;를 구분하기 쉬워집니다. 초보자 기준에서는 이런 연결이 눈에 보이는 방식이 이해하기 편합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML 단계에서는 필터 버튼을 단순히 늘리는 것보다, &lt;b&gt;JavaScript가 어떤 버튼이 어떤 역할인지 읽기 쉬운 구조로 두는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 활성 필터 상태가 보이게 만들기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1264&quot; data-origin-height=&quot;790&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1s6lS/dJMcahjwppV/FpKi8HSbGksne9I5KlBjWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1s6lS/dJMcahjwppV/FpKi8HSbGksne9I5KlBjWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1s6lS/dJMcahjwppV/FpKi8HSbGksne9I5KlBjWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1s6lS%2FdJMcahjwppV%2FFpKi8HSbGksne9I5KlBjWK%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;1264&quot; height=&quot;790&quot; data-origin-width=&quot;1264&quot; data-origin-height=&quot;790&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 버튼을 누르는 것만큼이나, &lt;b&gt;지금 어떤 필터가 선택돼 있는지 눈에 보여야&lt;/b&gt; 제대로 쓸 수 있습니다. 그래서 CSS에서는 필터 버튼의 기본 상태와 활성 상태를 구분해주는 쪽이 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.panel-header {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0;
  line-height: 1.7;
  color: #374151;
}

.composer {
  margin-top: 24px;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

.primary-button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button,
.filter-button {
  border-radius: 14px;
  padding: 12px 14px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.secondary-button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.filter-section {
  margin-top: 16px;
}

.filter-group {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.filter-button {
  border-radius: 999px;
}

.filter-button.is-active {
  background: #111827;
  color: #ffffff;
  border-color: #111827;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.list-section {
  margin-top: 24px;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

.delete-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .filter-group {
    width: 100%;
  }

  .filter-button {
    flex: 1 1 auto;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 가장 중요한 부분은 &lt;b&gt;filter-button&lt;/b&gt;과 &lt;b&gt;filter-button is-active&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;필터 UI는 버튼을 많이 만드는 게 아니라, &lt;b&gt;지금 어떤 기준이 선택됐는지 한눈에 보이게 만드는 것&lt;/b&gt;이 핵심입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 필터 상태와 목록 렌더링 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 왜냐하면 필터 기능은 결국 &lt;b&gt;지금 어떤 보기 상태를 선택했는지&lt;/b&gt;를 따로 기억하고, 그 상태에 맞게 목록을 다시 그려주는 작업이기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;FILTER_TYPES&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터 종류를 한곳에 모아둡니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문자열을 여기저기 흩뿌리지 않게 해줍니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;currentFilter&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 어떤 필터가 선택돼 있는지 기억합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;필터 기능의 핵심 상태입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;getFilteredTodos&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 기준에 맞는 항목만 골라냅니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면에 실제로 보일 목록을 정합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;updateFilterButtons&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;선택된 버튼 상태를 UI에 반영합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const FILTER_TYPES = {
  ALL: &quot;all&quot;,
  ACTIVE: &quot;active&quot;,
  COMPLETED: &quot;completed&quot;
};

const elements = {
  form: document.querySelector(&quot;#todoForm&quot;),
  input: document.querySelector(&quot;#todoInput&quot;),
  list: document.querySelector(&quot;#todoList&quot;),
  message: document.querySelector(&quot;#message&quot;),
  summary: document.querySelector(&quot;#summaryText&quot;),
  clearAllButton: document.querySelector(&quot;#clearAllButton&quot;),
  filterButtons: Array.from(
    document.querySelectorAll(&quot;[data-filter]&quot;)
  )
};

let todos = loadTodos();
let currentFilter = FILTER_TYPES.ALL;

function loadTodos() {
  try {
    const savedTodos = localStorage.getItem(STORAGE_KEY);

    return savedTodos ? JSON.parse(savedTodos) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function setMessage(text) {
  elements.message.textContent = text;
}

function getRemainingCount() {
  return todos.filter(function (todo) {
    return !todo.done;
  }).length;
}

function getFilteredTodos() {
  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return todos.filter(function (todo) {
      return !todo.done;
    });
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return todos.filter(function (todo) {
      return todo.done;
    });
  }

  return todos;
}

function updateSummary() {
  const totalCount = todos.length;
  const remainingCount = getRemainingCount();

  elements.summary.textContent =
    &quot;전체 &quot; +
    totalCount +
    &quot;개 &amp;middot; 남은 할 일 &quot; +
    remainingCount +
    &quot;개&quot;;

  elements.clearAllButton.disabled = totalCount === 0;
}

function updateFilterButtons() {
  elements.filterButtons.forEach(function (button) {
    const isActive =
      button.dataset.filter === currentFilter;

    button.classList.toggle(&quot;is-active&quot;, isActive);
    button.setAttribute(
      &quot;aria-pressed&quot;,
      String(isActive)
    );
  });
}

function getDefaultMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일만 보고 있습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일만 보고 있습니다.&quot;;
  }

  return &quot;체크해서 완료 처리하거나, 필요 없으면 삭제해보세요.&quot;;
}

function getEmptyFilterMessage() {
  if (todos.length === 0) {
    return &quot;아직 등록된 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.ACTIVE) {
    return &quot;진행 중인 할 일이 없습니다.&quot;;
  }

  if (currentFilter === FILTER_TYPES.COMPLETED) {
    return &quot;완료한 할 일이 없습니다.&quot;;
  }

  return &quot;아직 등록된 할 일이 없습니다.&quot;;
}

function createCheckbox(todo) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;

  checkbox.addEventListener(&quot;change&quot;, function () {
    todo.done = checkbox.checked;
    saveTodos();
    renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
  });

  return checkbox;
}

function createTodoText(todo) {
  const text = document.createElement(&quot;span&quot;);

  text.textContent = todo.text;

  return text;
}

function createDeleteButton(todoId) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = &quot;delete-button&quot;;
  button.textContent = &quot;삭제&quot;;

  button.addEventListener(&quot;click&quot;, function () {
    todos = todos.filter(function (currentTodo) {
      return currentTodo.id !== todoId;
    });

    saveTodos();
    renderTodos(&quot;항목을 삭제했습니다.&quot;);
  });

  return button;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  left.append(
    createCheckbox(todo),
    createTodoText(todo)
  );

  item.append(
    left,
    createDeleteButton(todo.id)
  );

  return item;
}

function renderTodos(messageText) {
  const visibleTodos = getFilteredTodos();

  elements.list.innerHTML = &quot;&quot;;
  updateSummary();
  updateFilterButtons();

  if (visibleTodos.length === 0) {
    setMessage(messageText || getEmptyFilterMessage());
    return;
  }

  visibleTodos.forEach(function (todo) {
    elements.list.appendChild(
      createTodoItem(todo)
    );
  });

  setMessage(messageText || getDefaultMessage());
}

function addTodo(todoText) {
  todos.unshift({
    id: Date.now(),
    text: todoText,
    done: false
  });

  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
}

function clearAllTodos() {
  if (todos.length === 0) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    setMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  currentFilter = FILTER_TYPES.ALL;
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
}

function setFilter(nextFilter) {
  if (currentFilter === nextFilter) {
    return;
  }

  currentFilter = nextFilter;
  renderTodos();
}

function handleSubmit(event) {
  event.preventDefault();

  const text = elements.input.value.trim();

  if (!text) {
    setMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    elements.input.focus();
    return;
  }

  addTodo(text);
  elements.input.value = &quot;&quot;;
  elements.input.focus();
}

function bindFilterEvents() {
  elements.filterButtons.forEach(function (button) {
    button.addEventListener(&quot;click&quot;, function () {
      setFilter(button.dataset.filter);
    });
  });
}

function bindEvents() {
  elements.form.addEventListener(
    &quot;submit&quot;,
    handleSubmit
  );

  elements.clearAllButton.addEventListener(
    &quot;click&quot;,
    clearAllTodos
  );

  bindFilterEvents();
}

bindEvents();
renderTodos();&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;currentFilter&lt;/b&gt; : 지금 어떤 보기 상태인지 기억하는 값입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getFilteredTodos&lt;/b&gt; : 현재 필터 기준으로 실제 보여줄 목록만 골라냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;updateFilterButtons&lt;/b&gt; : 버튼의 활성 상태를 화면에 반영합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 기능의 핵심 흐름은 이렇습니다. 버튼을 누르면 현재 필터 상태가 바뀌고, 그 상태에 맞게 보여줄 목록을 다시 고르고, 화면도 같이 갱신됩니다. 보기 버튼이 눈에 띄게 바뀌는 것도 같은 흐름 안에 들어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 한 가지 더 중요한 점이 있습니다. 새 할 일을 추가할 때는 일부러 &lt;b&gt;전체 보기로 돌아오게&lt;/b&gt; 만들었습니다. 예를 들어 &quot;완료&quot; 필터를 켜둔 상태에서 새 할 일을 추가하면, 방금 만든 항목이 바로 안 보여서 초보자가 당황할 수 있기 때문입니다. 이런 작은 판단도 실제 사용감에는 꽤 크게 작용합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript는 버튼을 세 개 더 만드는 작업이 아니라, &lt;b&gt;현재 필터 상태를 따로 들고 있으면서 화면에 보일 배열을 다시 고르는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직접 눌러보며 테스트하기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1083&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IhDbV/dJMcacvJT6v/BOBowwtfzqK5neSs3dlmA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IhDbV/dJMcacvJT6v/BOBowwtfzqK5neSs3dlmA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IhDbV/dJMcacvJT6v/BOBowwtfzqK5neSs3dlmA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIhDbV%2FdJMcacvJT6v%2FBOBowwtfzqK5neSs3dlmA0%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;1083&quot; height=&quot;743&quot; data-origin-width=&quot;1083&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기능은 겉으로 보기엔 간단하지만, 실제로는 현재 상태와 화면 갱신이 같이 움직입니다. 그래서 마지막에는 꼭 직접 눌러보는 과정이 필요합니다. 이번 편에서는 아래 순서로 확인해보는 편이 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;할 일을 3개 이상 추가한 뒤 전체 보기에서 모두 보이는가&lt;/li&gt;
&lt;li&gt;그중 하나를 완료 처리한 뒤 진행 중 보기에서는 미완료만 보이는가&lt;/li&gt;
&lt;li&gt;완료 보기에서는 완료 항목만 보이는가&lt;/li&gt;
&lt;li&gt;완료 상태를 다시 바꾸면 현재 필터에 맞게 목록이 달라지는가&lt;/li&gt;
&lt;li&gt;항목을 삭제했을 때 현재 필터 기준으로도 자연스럽게 사라지는가&lt;/li&gt;
&lt;li&gt;전체 삭제 뒤에는 전체 보기로 돌아오는가&lt;/li&gt;
&lt;li&gt;새로고침 뒤에도 마지막 항목 상태들이 그대로 유지되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서 중요한 건 &quot;버튼 색이 바뀌었나&quot;보다 &lt;b&gt;필터 상태와 목록 상태가 같이 맞물려 움직이는가&lt;/b&gt;를 보는 것입니다. 필터 기능은 겉으로는 단순한데, 실제로는 상태 관리 연습에 꽤 잘 맞는 기능입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;필터 기능은 눈으로만 보면 쉬워 보이지만, &lt;b&gt;현재 상태가 바뀔 때 화면도 같이 맞게 움직이는지&lt;/b&gt;를 직접 눌러봐야 진짜 감이 옵니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계도 Codex를 쓰기에 좋습니다. 다만 여전히 중요한 건 범위를 크게 던지지 않는 것입니다. 필터 기능은 HTML, CSS, JavaScript가 모두 바뀔 수 있는 작업이기 때문에, 오히려 더 조심스럽게 잘라서 요청하는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 방향을 먼저 정리받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 추가, 완료 체크, 삭제, 전체 삭제,
새로고침 후 유지까지 정상 동작하는 상태야.
이번에는 전체, 진행 중, 완료 필터만 추가하고 싶어.
기존 기능은 유지하고 구조도 크게 뒤집지 말아줘.
먼저 HTML, CSS, JavaScript에서
어떤 부분을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript 쪽만 더 좁게 요청하고 싶다면 이렇게 말할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 목록 데이터는 그대로 두고,
보기 상태만 따로 저장해서
전체, 진행 중, 완료 필터가 동작하게 만들어줘.
추가, 삭제, 전체 삭제 기능은 유지해야 하고
render 흐름을 크게 망가뜨리면 안 돼.
수정 전에 새로 생길 변수와 함수부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 요청하고 싶다면 이렇게 더 작게 자를 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
필터 버튼 3개를 추가할 건데,
선택된 버튼이 눈에 띄게 보이도록만 정리해줘.
전체 디자인은 크게 바꾸지 말고,
모바일에서도 버튼이 한 줄 또는 자연스럽게 줄바꿈되게 해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 나누면 AI가 기능 추가와 전면 수정을 혼동할 가능성이 줄어듭니다. 초보자 입장에서는 이 차이가 꽤 중요합니다. 결국 AI를 잘 쓰는 것도 거창한 프롬프트보다 &lt;b&gt;수정 범위를 정확하게 자르는 습관&lt;/b&gt;에 더 가까운 경우가 많습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex에게 필터 기능을 맡길 때는 &quot;필터 넣어줘&quot;보다 &lt;b&gt;무엇은 유지하고, 어디까지만 바꿀지&lt;/b&gt;를 같이 말하는 편이 훨씬 안정적입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 추가한 기능은 아주 작습니다. 보기 필터 버튼 세 개가 생기고, 현재 상태에 따라 보여주는 목록이 달라지는 정도입니다. 그런데 바로 이런 기능이 프로젝트를 한 단계 더 살아 있는 도구처럼 보이게 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게 지금 중요한 건 거대한 기능을 한 번에 붙이는 것이 아닙니다. &lt;b&gt;기존 구조를 유지하면서 새로운 상태 하나를 추가하고, 그 상태가 화면에 어떻게 반영되는지 끝까지 따라가는 경험&lt;/b&gt;이 더 중요합니다. 이번 필터 기능은 그 연습으로 꽤 잘 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 직접 해보면, 이제 할 일 앱은 단순히 적고 지우는 수준을 조금 넘어서기 시작합니다. 같은 목록이라도 어떤 기준으로 볼지를 내가 선택할 수 있게 되었기 때문입니다. 그 차이가 생각보다 큽니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/33</guid>
      <comments>https://story86025.tistory.com/33#entry33comment</comments>
      <pubDate>Fri, 27 Mar 2026 17:12:15 +0900</pubDate>
    </item>
    <item>
      <title>[ChatGPT] Codex skill과 서브에이전트, 헷갈리는 지점만 다시 정리</title>
      <link>https://story86025.tistory.com/74</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_Codex_202603241752.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1a05H/dJMcabjiGnr/7dQAI04HC5Kr0UCXLQJJS0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1a05H/dJMcabjiGnr/7dQAI04HC5Kr0UCXLQJJS0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1a05H/dJMcabjiGnr/7dQAI04HC5Kr0UCXLQJJS0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1a05H%2FdJMcabjiGnr%2F7dQAI04HC5Kr0UCXLQJJS0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_Codex_202603241752.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex를 보다 보면 &lt;span data-ui=&quot;term&quot; data-note=&quot;반복 작업을 안정적으로 수행하게 만드는 재사용 가능한 작업 매뉴얼입니다.&quot;&gt;skill&lt;/span&gt;이랑 &lt;span data-ui=&quot;term&quot; data-note=&quot;메인 에이전트가 하위 작업을 나눠 맡기기 위해 띄우는 보조 에이전트입니다.&quot;&gt;서브에이전트&lt;/span&gt;가 자꾸 비슷해 보입니다. 둘 다 일을 잘하게 만드는 장치처럼 보이기 때문입니다. 그런데 이 둘은 겉보기와 달리 맡는 역할이 꽤 다릅니다. 이 차이를 제대로 못 잡으면, skill에 넣어야 할 걸 서브에이전트로 풀려고 하고, 반대로 역할 분담이 필요한 일을 skill 하나로 억지로 해결하려고 하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 잡아두면 좋은 문장은 이겁니다. &lt;b&gt;서브에이전트는 &amp;ldquo;누가 일을 나눠서 할 것인가&amp;rdquo;에 가깝고, skill은 &amp;ldquo;그 일을 어떤 방식으로 처리할 것인가&amp;rdquo;에 가깝다.&lt;/b&gt; 이 한 줄만 정확히 이해해도 Codex를 보는 눈이 꽤 달라집니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심만 먼저 정리하면&lt;/b&gt;&lt;br /&gt;서브에이전트는 &lt;b&gt;분업&lt;/b&gt;이고, skill은 &lt;b&gt;절차 재사용&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;둘은 서로 대체하는 기능이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 가장 자주 하는 오해가 있습니다. &amp;ldquo;skill이 있으면 서브에이전트는 없어도 되는 거 아닌가?&amp;rdquo; 혹은 &amp;ldquo;서브에이전트가 더 고급 기능이니까 skill은 나중에 봐도 되는 거 아닌가?&amp;rdquo; 같은 생각입니다. 그런데 실제로는 둘이 경쟁하는 기능이 아니라, 다른 문제를 푸는 도구에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 보겠습니다. PR 하나를 검토한다고 했을 때, 보안 위험, 버그 가능성, 테스트 누락, 프레임워크 문서 확인을 한 에이전트가 다 볼 수도 있습니다. 하지만 이걸 &lt;span data-ui=&quot;term&quot; data-note=&quot;각 역할을 가진 에이전트가 동시에 움직이고, 마지막에 결과를 모아주는 작업 방식입니다.&quot;&gt;멀티 에이전트 워크플로&lt;/span&gt;로 나누면 탐색 담당, 리뷰 담당, 문서 확인 담당처럼 역할이 쪼개집니다. 이건 &lt;b&gt;사람을 나누는 문제&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, 버그 수정할 때마다 &amp;ldquo;재현 &amp;rarr; 원인 확인 &amp;rarr; 최소 수정 &amp;rarr; 검증 &amp;rarr; 요약&amp;rdquo; 같은 순서를 반복하고 있다면, 그건 역할을 나눌 문제라기보다 &lt;b&gt;매번 같은 절차를 다시 설명하고 있는 문제&lt;/b&gt;에 가깝습니다. 이럴 때 들어가는 게 skill입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;더 잘 맞는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;왜 그런가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;매번 같은 작업 절차를 반복함&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;skill&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;같은 지시를 반복하지 않게 해주기 때문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;보안, 테스트, 문서 확인처럼 관점이 갈라짐&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;서브에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;역할을 나눠 병렬로 보는 편이 더 자연스럽기 때문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;프로젝트 공통 규칙을 계속 설명해야 함&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;프로젝트에서 Codex가 항상 참고할 지속형 규칙 파일입니다.&quot;&gt;AGENTS.md&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;저장소 전체에 적용되는 기본 규칙이기 때문&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;브라우저, 문서 서버, 외부 로그 도구가 필요함&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;외부 도구나 문서 서버를 Codex와 연결할 때 쓰는 규격입니다.&quot;&gt;MCP&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;외부 시스템 연결 문제가 핵심이기 때문&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, skill과 서브에이전트를 같은 선상에서 비교하면 자꾸 꼬입니다. 하나는 &lt;b&gt;작업 방식&lt;/b&gt;을 고정하는 도구이고, 다른 하나는 &lt;b&gt;작업자 구성&lt;/b&gt;을 나누는 도구입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;skill이 먼저 필요한 경우는 생각보다 단순하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill이 필요한 순간은 의외로 화려하지 않습니다. 오히려 아주 현실적입니다. 비슷한 프롬프트를 세 번 이상 반복하고 있다면, 그때부터는 skill을 의심해볼 만합니다. 버그 수정, 릴리스 노트 초안, 작업 로그 정리, 커밋 전 체크리스트 같은 일들이 여기에 잘 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 skill이 &amp;ldquo;똑똑한 성격&amp;rdquo;을 부여하는 기능이 아니라는 점입니다. skill은 어디까지나 &lt;b&gt;작업 절차를 묶어두는 디렉터리&lt;/b&gt;에 가깝습니다. 그래서 이름보다 &lt;code&gt;description&lt;/code&gt;이 더 중요하고, 멋진 소개 문구보다 &amp;ldquo;언제 켜져야 하는지&amp;rdquo;가 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래처럼 쓰는 게 초보자에게는 훨씬 낫습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;---
name: bugfix-helper
description: &amp;gt;
  화면 오류를 원인 중심으로 찾고
  최소 수정으로 마무리할 때 쓰는 스킬
---

1. 먼저 재현 조건을 확인한다.
2. 관련 파일 범위를 좁힌다.
3. 최소 수정으로 해결한다.
4. 필요한 검증 방법을 정리한다.
5. 마지막에 원인과 수정 내용을 요약한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 형태가 좋은 이유는, 읽는 사람도 이해하기 쉽고 Codex도 언제 써야 하는지 판단하기 쉽기 때문입니다. 반대로 &amp;ldquo;React 관련 작업을 돕는 skill&amp;rdquo;처럼 넓게 써버리면, 나중에 호출 기준이 흐려지고 결과도 애매해지기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 글이나 예시를 찾다 보면 &lt;span data-ui=&quot;term&quot; data-note=&quot;예전에 재사용 프롬프트용으로 쓰이던 방식입니다. 현재는 skills 쪽이 공식 권장 흐름입니다.&quot;&gt;custom prompt&lt;/span&gt;가 나올 때도 있는데, 지금 흐름에서는 재사용 가능한 지시는 skill 쪽으로 이해하는 편이 더 맞습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서브에이전트가 필요한 순간은 일이 갈라질 때다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 서브에이전트는 &amp;ldquo;같은 일을 반복한다&amp;rdquo;보다 &amp;ldquo;일이 여러 갈래로 나뉜다&amp;rdquo;는 신호에서 시작합니다. 특히 PR 리뷰, 코드베이스 탐색, UI 재현과 코드 수정이 함께 얽힌 버그, 문서 확인이 필요한 변경 같은 작업에서 체감이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 작업을 한 에이전트에게 다 맡기면 가능은 합니다. 다만 중간 탐색 로그, 테스트 출력, 스택 트레이스, 브라우저 재현 기록이 한 대화에 계속 섞이기 시작하면 메인 흐름이 지저분해지기 쉽습니다. 그래서 공식 문서도 &lt;span data-ui=&quot;term&quot; data-note=&quot;중간 로그와 탐색 흔적이 메인 대화에 너무 많이 쌓여 중요한 정보가 묻히는 상태입니다.&quot;&gt;컨텍스트 오염&lt;/span&gt;이나 &lt;span data-ui=&quot;term&quot; data-note=&quot;대화가 길어지면서 덜 중요한 정보까지 쌓여 응답 품질이 떨어지는 현상을 가리킵니다.&quot;&gt;컨텍스트 로트&lt;/span&gt;를 줄이기 위한 방식으로 서브에이전트 워크플로를 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하면 이렇습니다. 메인 에이전트는 팀장처럼 요구사항과 최종 정리에 집중하고, 서브에이전트는 탐색, 리뷰, 검증 같은 잡음을 각자 맡아서 처리한 뒤 요약만 돌려주는 구조입니다. 그래서 서브에이전트는 &amp;ldquo;더 똑똑한 한 명&amp;rdquo;이라기보다 &amp;ldquo;역할이 나뉜 작은 팀&amp;rdquo;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기에는 꼭 붙는 조건이 있습니다. &lt;b&gt;Codex가 서브에이전트를 자동으로 막 띄우는 건 아니라는 점&lt;/b&gt;입니다. 역할을 나눠 달라고 사용자가 분명히 요청해야 하고, 각 서브에이전트가 따로 모델과 도구를 쓰기 때문에 토큰과 시간이 더 들 수 있습니다. 작은 작업까지 무조건 멀티 에이전트로 가는 게 정답은 아닙니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;현재 브랜치를 main과 비교해서 검토해줘.

보안, 버그, 테스트 취약성,
문서 확인 항목마다
서브에이전트 1개씩 나눠 병렬로 확인하고,

모든 결과가 모이면
항목별 핵심만 요약해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프롬프트가 좋은 이유는 단순합니다. &amp;ldquo;나눠라&amp;rdquo;가 분명하고, &amp;ldquo;마지막엔 요약만 달라&amp;rdquo;도 분명하기 때문입니다. 초보자일수록 멀티 에이전트를 추상적으로 이해하려 하지 말고, 이렇게 사람 나누듯이 말하는 편이 훨씬 체감이 빠릅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 둘을 같이 쓰면 언제 좋아지나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill과 서브에이전트는 서로 대체재가 아니라서, 오히려 같이 붙었을 때 더 선명해지는 경우가 많습니다. 예를 들어 탐색 담당 서브에이전트는 코드 경로만 좁히고, 수정 담당 서브에이전트는 최소 수정 원칙을 따르게 만들고 싶다고 해보겠습니다. 이때 역할 분담은 서브에이전트가 맡고, &amp;ldquo;최소 수정 원칙&amp;rdquo;이나 &amp;ldquo;마지막 요약 형식&amp;rdquo; 같은 재사용 규칙은 skill이 맡는 식으로 분리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 익숙해지면 여기서 한 단계 더 갈 수도 있습니다. &lt;span data-ui=&quot;term&quot; data-note=&quot;사용자가 직접 역할과 동작 규칙을 정해 만든 전용 에이전트입니다.&quot;&gt;커스텀 에이전트&lt;/span&gt;는 부모 세션의 설정을 상속할 수 있고, 그 안에 &lt;code&gt;skills.config&lt;/code&gt;도 포함됩니다. 즉, 어떤 서브에이전트는 부모가 쓰는 skill을 그대로 물려받고, 어떤 서브에이전트는 특정 skill만 꺼버리도록 조정할 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 말이 실전에서 왜 중요하냐면, 역할마다 필요한 매뉴얼이 다를 수 있기 때문입니다. 예를 들어 문서 확인 담당 에이전트에게는 &amp;ldquo;코드 편집용 skill&amp;rdquo;이 방해가 될 수 있고, 반대로 수정 담당 에이전트에게는 &amp;ldquo;최소 수정 버그픽스 skill&amp;rdquo;이 잘 맞을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;name = &quot;ui_fixer&quot;
description = &quot;문제 원인이 확인된 뒤 작은 수정만 담당하는 에이전트&quot;

developer_instructions = &quot;&quot;&quot;
원인이 확인된 뒤에만 수정한다.
관련 없는 파일은 건드리지 않는다.
바뀐 동작만 검증한다.
&quot;&quot;&quot;

[[skills.config]]
path = &quot;/Users/me/.agents/skills/docs-editor/SKILL.md&quot;
enabled = false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예시가 보여주는 건 단순합니다. &lt;b&gt;에이전트는 역할&lt;/b&gt;이고, &lt;b&gt;skill은 작업 매뉴얼&lt;/b&gt;이며, 둘은 같은 자리에 있는 기능이 아니라는 점입니다. 그래서 둘을 함께 쓰면 &amp;ldquo;누가 무엇을 할지&amp;rdquo;와 &amp;ldquo;그걸 어떤 방식으로 할지&amp;rdquo;를 각각 따로 제어할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;먼저 AGENTS.md부터 정리합니다.&lt;/b&gt; 프로젝트 실행 방법, 테스트 명령, 금지 규칙처럼 저장소 공통 규칙을 먼저 고정합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그다음 skill 하나만 만듭니다.&lt;/b&gt; 버그 수정용이든 작업 로그 정리용이든, 정말 자주 반복하는 일 하나만 골라서 시작합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일이 자연스럽게 갈라질 때만 서브에이전트를 붙입니다.&lt;/b&gt; 리뷰 관점이 여러 개거나, 재현&amp;middot;탐색&amp;middot;수정이 분리될 때가 적당합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처음에는 2~3개 정도만 나눕니다.&lt;/b&gt; 너무 많이 나누면 초보자 입장에서는 결과를 읽는 쪽이 더 어려워집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;서브에이전트가 또 다른 서브에이전트를 만들 수 있는 깊이 제한입니다.&quot;&gt;max_depth&lt;/span&gt;는 기본값에 가깝게 두는 편이 안전합니다.&lt;/b&gt; 깊이를 키우면 에이전트가 에이전트를 다시 낳는 구조가 생길 수 있어서 추적이 금방 어려워집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;탐색용이나 리뷰용 에이전트는 가능한 한 &lt;span data-ui=&quot;term&quot; data-note=&quot;읽기만 가능하고 파일 변경은 막는 샌드박스 설정입니다.&quot;&gt;read-only&lt;/span&gt;에 가깝게 둡니다.&lt;/b&gt; 처음부터 수정 권한까지 넓히는 것보다 훨씬 덜 불안합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자 추천선&lt;/b&gt;&lt;br /&gt;처음에는 &amp;ldquo;skill 하나 + 서브에이전트 둘 정도&amp;rdquo;만으로도 충분합니다. 너무 많은 역할과 규칙을 한 번에 넣으면, 무엇이 효과가 있었는지 오히려 알기 어려워집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;헷갈리는 포인트를 마지막으로 한 번만 더 정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill은 Codex를 &amp;ldquo;다른 성격의 존재&amp;rdquo;로 바꾸는 기능이 아닙니다. 특정 일을 할 때 매번 반복하던 절차를 재사용하게 만드는 기능에 가깝습니다. 반면 서브에이전트는 &amp;ldquo;이 일을 여러 사람이 나눠 하면 더 낫겠다&amp;rdquo;는 순간에 등장하는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 버그 수정 체크리스트, 작업 로그 정리, PR 설명 형식처럼 &lt;b&gt;반복되는 절차&lt;/b&gt;가 문제라면 skill 쪽으로 생각하는 게 맞습니다. 보안 리뷰, 문서 확인, 브라우저 재현, 코드 경로 추적처럼 &lt;b&gt;관점이 갈라지는 작업&lt;/b&gt;이라면 서브에이전트가 먼저 떠오르는 게 자연스럽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 둘이 함께 붙는 순간, Codex는 단순히 &amp;ldquo;말 잘 듣는 채팅창&amp;rdquo;에서 조금 더 작업 환경처럼 보이기 시작합니다. 누가 맡을지와 어떻게 처리할지를 따로 설계할 수 있기 때문입니다. 이 차이가 감으로 잡히면, skill과 서브에이전트를 더 이상 비슷한 기능으로 보지 않게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. &lt;b&gt;skill은 반복성&lt;/b&gt;이고, &lt;b&gt;서브에이전트는 분업&lt;/b&gt;입니다. 이 두 개를 따로 이해해두면, 나중에 커스텀 에이전트나 자동화로 넘어갈 때도 훨씬 덜 꼬입니다.&lt;/p&gt;</description>
      <category>AI에이전트/Chatgpt</category>
      <category>Agent.md</category>
      <category>codex</category>
      <category>Codex skill</category>
      <category>codex스킬</category>
      <category>config.toml</category>
      <category>SKILL.md</category>
      <category>멀티에이전트</category>
      <category>서브에이전트</category>
      <category>코덱스 멀티에이전트</category>
      <category>코덱스 서브에이전트</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/74</guid>
      <comments>https://story86025.tistory.com/74#entry74comment</comments>
      <pubDate>Thu, 26 Mar 2026 18:54:07 +0900</pubDate>
    </item>
    <item>
      <title>14. 드롭은 됐는데 왜 앞뒤가 자꾸 틀릴까: 바이브코딩에서 마우스 위치 읽는 법</title>
      <link>https://story86025.tistory.com/26</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 드래그 앤 드롭을 한 단계 더 자연스럽게 만들기 위해, &lt;b&gt;자리 바꾸기&lt;/b&gt;가 아니라 &lt;b&gt;끼워 넣기&lt;/b&gt; 방식으로 생각해야 한다는 점을 정리했습니다. 여기까지 오면 바로 다음 문제가 보입니다. 드롭은 분명 되는데, 사용자가 기대한 위치와 실제 들어간 위치가 묘하게 다르다는 점입니다. 어떤 때는 항목이 앞에 들어가고, 어떤 때는 뒤에 들어가야 더 자연스럽게 느껴지는데, 지금 구조만으로는 그 차이를 설명하기가 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 필요한 것이 바로 &lt;b&gt;마우스 위치&lt;/b&gt;입니다. 단순히 &amp;ldquo;어느 항목 위에 놓았는가&amp;rdquo;만 알아서는 부족합니다. 같은 항목 위에 드롭하더라도, 사용자는 그 항목의 &lt;b&gt;위쪽&lt;/b&gt;에 놓았는지 &lt;b&gt;아래쪽&lt;/b&gt;에 놓았는지에 따라 앞에 넣기를 기대할 수도 있고, 뒤에 넣기를 기대할 수도 있기 때문입니다. 즉, 이제부터는 target 항목 하나만 보는 것이 아니라, &lt;b&gt;target 항목 + 현재 커서가 그 항목의 어디쯤에 있는가&lt;/b&gt;를 함께 봐야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 드래그 앤 드롭이 여기서부터 더 자연스럽게 느껴지기 시작하는지, 마우스 위치를 이용해 before와 after를 어떻게 나누는지, 그리고 사용자가 지금 어디로 드롭하려는지 한눈에 보이게 만드는 &lt;b&gt;드롭 표시선&lt;/b&gt;은 왜 중요한지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭은 되는데 어떤 때는 앞, 어떤 때는 뒤로 가야 할지 감이 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;target 항목만 보고 있고, 마우스가 그 항목의 위쪽인지 아래쪽인지는 안 보고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before와 after를 나눌 기준을 먼저 정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭은 되지만 결과가 여전히 어색하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스 위치와 삽입 규칙이 연결되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;위쪽이면 앞, 아래쪽이면 뒤 같은 규칙을 코드로 고정한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자는 놓을 자리를 모르겠고, 개발자는 왜 이상한지 모르겠다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 표시선이 없어서 현재 의도가 눈에 안 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before와 after에 맞는 시각적 표시를 따로 둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 중 화면이 자꾸 불안정하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover마다 전체를 다시 그리면서 상태가 흔들리고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서는 표시선 업데이트를 가볍게 유지한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;자연스러운 드래그 앤 드롭은 target 항목만 아는 것으로 끝나지 않는다. 커서가 그 항목의 위쪽인지 아래쪽인지까지 같이 읽어야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 이제는 마우스 위치를 같이 봐야 할까&lt;/li&gt;
&lt;li&gt;target 항목만 알아서는 부족한 이유&lt;/li&gt;
&lt;li&gt;위쪽 절반과 아래쪽 절반은 어떻게 판단할까&lt;/li&gt;
&lt;li&gt;before&amp;middot;after를 실제 순서 변경으로 바꾸는 흐름&lt;/li&gt;
&lt;li&gt;드롭 표시선은 왜 필요한가&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이제는 마우스 위치를 같이 봐야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자리 바꾸기 단계에서는 사실 마우스가 항목의 어디쯤에 있는지까지는 크게 중요하지 않았습니다. B를 D 위에 놓았다면, 그냥 B와 D의 자리를 맞바꾸는 식으로 설명할 수 있었기 때문입니다. 하지만 끼워 넣기로 넘어오면 이야기가 달라집니다. 같은 D 위에 드롭하더라도, 사용자는 D &lt;b&gt;앞&lt;/b&gt;에 넣고 싶었을 수도 있고 D &lt;b&gt;뒤&lt;/b&gt;에 넣고 싶었을 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이제부터는 드롭이 단순히 &amp;ldquo;이 항목 위에 놓였다&amp;rdquo;로 끝나지 않습니다. 그 항목의 어느 쪽에 가까웠는지까지 봐야 합니다. 보통 세로 목록이라면 가장 단순한 기준은 이것입니다. &lt;b&gt;항목의 위쪽 절반에 커서가 있으면 before, 아래쪽 절반에 있으면 after&lt;/b&gt;로 보는 방식입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;왜 위치 정보가 필요해지는가&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;target만 알면 생기는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;위치까지 알면 해결되는 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;B를 D 쪽으로 끌어다 놓음&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;D 앞에 넣을지 뒤에 넣을지 애매하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;커서가 D의 위쪽이면 before, 아래쪽이면 after로 정할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 미세하게 위쪽을 노려 드롭함&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과가 항상 같은 방향으로 들어가서 어색하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자 의도와 결과가 더 잘 맞는다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 작은 차이처럼 보이지만 체감은 꽤 큽니다. 드래그 앤 드롭이 갑자기 &amp;ldquo;쓸 만하다&amp;rdquo;는 느낌을 주는 지점도 바로 여기입니다. 사용자가 마우스를 어디에 두느냐에 따라 결과가 자연스럽게 달라지기 시작하기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;끼워 넣기에서 target 항목은 도착지일 뿐이고, 실제 삽입 방향은 커서가 그 항목의 위쪽인지 아래쪽인지가 결정한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;target 항목만 알아서는 부족한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 더 직관적으로 보려면 같은 예시를 두 가지로 나눠보면 됩니다. A, B, C, D, E 목록이 있다고 해보겠습니다. 여기서 B를 D 쪽으로 옮긴다고 해도, 사용자가 어떤 자리를 원하느냐는 두 가지로 갈릴 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 target이라도 결과는 달라질 수 있다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;드롭 의도&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자가 기대하는 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;D 위쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, B, D, E&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;D 아래쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, D, B, E&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 target은 D입니다. 그런데 결과는 완전히 다릅니다. 이 말은 곧, &lt;b&gt;target id 하나만으로는 자연스러운 드롭을 설명할 수 없다&lt;/b&gt;는 뜻입니다. 추가로 필요한 정보가 하나 더 있습니다. 그게 바로 &lt;b&gt;dropPosition&lt;/b&gt;, 즉 before인지 after인지에 대한 정보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이제부터 드래그 상태를 기억할 때는 보통 두 가지를 함께 생각하게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;draggedId&lt;/b&gt;: 지금 움직이는 항목이 누구인가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dropPosition&lt;/b&gt;: 대상 항목의 앞에 넣을 것인가, 뒤에 넣을 것인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 target id는 여전히 필요합니다. 결국 필요한 정보는 세 가지입니다. &lt;b&gt;누가 움직였는가&lt;/b&gt;, &lt;b&gt;누구를 기준으로 놓았는가&lt;/b&gt;, &lt;b&gt;그 기준의 앞인가 뒤인가&lt;/b&gt;. 이 셋이 모여야 삽입이 자연스러워집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 정리하면&lt;/b&gt;&lt;br /&gt;드롭은 &amp;ldquo;어느 항목 위에 놓았는가&amp;rdquo;가 아니라 &amp;ldquo;어느 항목의 앞 또는 뒤에 넣을 것인가&amp;rdquo;로 해석해야 자연스럽다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;을 계산할 수 있습니다. 그리고 현재 마우스의 Y좌표가 그 가운데보다 위에 있는지 아래에 있는지를 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 버전에서는 보통 아래처럼 생각하면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function getDropPosition(event, element) {

const rect = element.getBoundingClientRect();
const middleY = rect.top + rect.height / 2;

return event.clientY &amp;lt; middleY ? 'before' : 'after';
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 값은 세 가지입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 보면 좋은 좌표 정보&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;값&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;뜻&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;rect.top&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 요소의 화면상 위쪽 위치&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;가운데 선을 계산하기 위한 시작점이 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;rect.height&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 요소 높이&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;위쪽 절반과 아래쪽 절반을 나누는 기준이 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;event.clientY&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 커서의 세로 위치&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;세로 목록이라면 X축보다 Y축이 더 중요하다&lt;/b&gt;는 점입니다. 지금 우리가 원하는 것은 왼쪽/오른쪽 구분이 아니라 위/아래 구분이기 때문입니다. 그래서 첫 버전에서는 마우스 Y좌표만으로도 충분히 자연스러운 판단을 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 dragover 이벤트 안에서 이 값을 계산해 현재 대상과 위치를 업데이트합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let draggedId = null;

let currentDropId = null;
let currentDropPosition = null;

function handleDragOver(event, todoId, element) {
event.preventDefault();

currentDropId = todoId;
currentDropPosition = getDropPosition(event, element);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 지금 어떤 항목이 드롭 기준점인지, 그리고 그 기준점의 앞인지 뒤인지를 계속 추적할 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 이해는 이 정도면 충분하다&lt;/b&gt;&lt;br /&gt;현재 대상 요소의 가운데 선을 기준으로, 커서가 위에 있으면 before, 아래에 있으면 after로 본다. 세로 목록에서는 이 규칙만으로도 체감이 꽤 좋아진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;before&amp;middot;after를 실제 순서 변경으로 바꾸는 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 마우스 위치를 읽을 수 있게 됐으니, 그 결과를 실제 재배치로 연결해야 합니다. 여기서 중요한 건 지난 글에서 다뤘던 index 보정이 여전히 필요하다는 점입니다. 다만 이번에는 before와 after에 따라 삽입 위치가 한 번 더 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 버전의 기본 흐름은 이렇게 잡는 편이 무난합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 custom 순서대로 정렬된 배열을 하나 만든다.&lt;/li&gt;
&lt;li&gt;dragged 항목의 index와 target 항목의 index를 찾는다.&lt;/li&gt;
&lt;li&gt;dragged 항목을 먼저 뺀다.&lt;/li&gt;
&lt;li&gt;source가 target보다 앞에 있었다면, removal 이후 target index를 한 칸 줄여서 본다.&lt;/li&gt;
&lt;li&gt;dropPosition이 before면 target 앞에, after면 target 뒤에 넣는다.&lt;/li&gt;
&lt;li&gt;최종 배열 기준으로 order를 1부터 다시 붙인다.&lt;/li&gt;
&lt;li&gt;저장하고 다시 그린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function moveTodoByDropPosition(draggedId, targetId, position) {

const ordered = [...todos].sort((a, b) =&amp;gt; a.order - b.order);

const sourceIndex = ordered.findIndex(todo =&amp;gt; todo.id === draggedId);
const targetIndex = ordered.findIndex(todo =&amp;gt; todo.id === targetId);

if (sourceIndex === -1 || targetIndex === -1) return;
if (sourceIndex === targetIndex) return;

const next = [...ordered];
const removed = next.splice(sourceIndex, 1)[0];

const targetIndexAfterRemoval =
sourceIndex &amp;lt; targetIndex
? targetIndex - 1
: targetIndex;

const insertIndex =
position === 'before'
? targetIndexAfterRemoval
: targetIndexAfterRemoval + 1;

next.splice(insertIndex, 0, removed);

todos = next.map((todo, index) =&amp;gt; {
return {
...todo,
order: index + 1
};
});

saveTodos();
renderTodos();
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;targetIndexAfterRemoval&lt;/b&gt;: 먼저 하나를 뺀 뒤 target의 새 위치를 다시 계산한다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;insertIndex&lt;/b&gt;: before면 그 위치에 넣고, after면 한 칸 뒤에 넣는다&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 target이라도 before와 after는 다르게 계산된다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;결과 예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D, E에서 B를 D 위쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, B, D, E&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D, E에서 B를 D 아래쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, D, B, E&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D, E에서 D를 B 위쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, D, B, C, E&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D, E에서 D를 B 아래쪽 절반에 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, D, C, E&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이전 글의 index 보정 위에, 이번 글에서는 before냐 after냐에 따라 삽입 위치를 한 번 더 갈라주는 셈입니다. 이 흐름이 붙으면 드래그 결과가 훨씬 자연스러워집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;before&amp;middot;after 판단은 화면 감각을 위한 정보이고, index 보정은 배열 계산을 위한 정보다. 둘이 같이 있어야 드롭 결과가 자연스럽고 정확해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;드롭 표시선은 왜 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터는 사용자 경험이 달라집니다. before와 after를 안쪽에서 잘 계산하고 있어도, 사용자가 현재 어느 위치로 들어갈지 알 수 없다면 드래그는 여전히 불안하게 느껴질 수 있습니다. 그래서 &lt;b&gt;드롭 표시선&lt;/b&gt;이 중요합니다. 이 선 하나가 지금 &amp;ldquo;앞에 들어가는 중인지&amp;rdquo; &amp;ldquo;뒤에 들어가는 중인지&amp;rdquo;를 눈으로 바로 보여주기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드롭 표시선은 사용자에게만 좋은 것이 아닙니다. 개발자에게도 꽤 유용합니다. 왜냐하면 지금 코드가 before로 판단하고 있는지 after로 판단하고 있는지를 눈으로 바로 확인할 수 있기 때문입니다. 즉, 이건 UI 장식이면서 동시에 디버깅 도구이기도 합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;드롭 표시선이 주는 효과&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;없을 때&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;있을 때&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 어디로 들어갈지 감으로만 추측한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;앞에 들어가는지 뒤에 들어가는지 바로 보인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;개발자는 결과가 이상할 때 원인을 추측해야 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before&amp;middot;after 판단이 맞는지 눈으로 검증할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그가 전체적으로 불안정하게 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동작이 예측 가능해져서 훨씬 자연스럽다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 버전에서는 구현도 과하게 복잡할 필요가 없습니다. 현재 target 항목과 currentDropPosition만 알고 있으면, target의 위쪽이면 위에 선을, 아래쪽이면 아래에 선을 주면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function applyDropIndicator(element, position) {

clearDropIndicator(element);

if (position === 'before') {
element.style.borderTop = '2px solid #222';
} else {
element.style.borderBottom = '2px solid #222';
}
}

function clearDropIndicator(element) {
element.style.borderTop = '';
element.style.borderBottom = '';
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 dragover 때마다 전체 목록을 통째로 다시 그리기보다, &lt;b&gt;현재 지나가고 있는 요소에만 가볍게 표시를 주는 편이 더 안정적&lt;/b&gt;이라는 점입니다. dragover는 매우 자주 발생하기 때문에, 매번 전체 렌더링을 돌리면 드래그가 버벅이거나 상태가 흔들릴 수 있습니다. 첫 버전에서는 표시선 업데이트를 가능한 한 단순하게 유지하는 편이 좋습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁&lt;/b&gt;&lt;br /&gt;드롭 표시선은 화려한 UI가 아니라 현재 해석 결과를 사용자와 개발자 모두에게 보여주는 기준선에 가깝다. 처음에는 얇은 선 하나만 있어도 충분하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&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;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;target id만 저장하고 before&amp;middot;after를 안 저장한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항상 같은 방향으로만 들어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스 위치 정보가 재배치에 반영되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;target과 position을 함께 기록한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;가운데 선 계산 없이 감으로 나눈다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;큰 항목과 작은 항목에서 드롭 감각이 들쭉날쭉하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요소 높이를 기준으로 판단하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;요소 높이의 절반을 기준으로 먼저 단순하게 나눈다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover마다 전체를 다시 그린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그가 버벅이거나 표시가 흔들린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지나치게 무거운 갱신이 반복된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서는 표시선 업데이트를 가볍게 유지한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시선을 지우지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭이 끝난 뒤에도 선이 남아 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragleave, drop, dragend 이후 정리가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 완료와 종료 시점에 표시 상태를 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before&amp;middot;after는 계산했지만 index 보정을 빼먹는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;방향은 맞는데 한 칸씩 어긋난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source 제거 이후 target 위치 변화를 반영하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이 글의 위치 판단과 지난 글의 index 보정을 같이 봐야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬 모드에서도 그대로 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;표시선은 보이는데 결과가 기대와 다르게 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;custom 순서와 자동 정렬 규칙이 겹쳐 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 custom 모드에서만 드롭 재배치를 허용하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 세 번째 실수는 실제 체감이 큽니다. dragover는 드래그 중에 매우 자주 발생하는 이벤트라서, 여기에 무거운 렌더링을 붙이면 기능은 맞더라도 드래그 자체가 불편해질 수 있습니다. 처음에는 전체 구조를 갈아엎기보다 &lt;b&gt;판단은 정확하게, 화면 업데이트는 가볍게&lt;/b&gt; 가져가는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;드롭 위치를 자연스럽게 만드는 핵심은 이벤트를 많이 아는 것이 아니라, target&amp;middot;position&amp;middot;index 보정&amp;middot;표시선이 각각 무슨 역할을 하는지 구분해서 보는 데 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. &lt;br /&gt;첫째, target 항목만으로는 자연스러운 삽입을 설명할 수 없고 before&amp;middot;after가 함께 필요하다는 점. &lt;br /&gt;둘째, 위쪽 절반과 아래쪽 절반이라는 단순한 기준만으로도 드롭 감각이 크게 좋아질 수 있다는 점. &lt;br /&gt;셋째, 표시선은 보기 좋은 장식이 아니라 현재 드롭 해석을 사용자와 개발자 모두에게 보여주는 중요한 기준이라는 점입니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 드래그 앤 드롭은 이제 단순한 이벤트 묶음이 아니라, 꽤 설명 가능한 구조로 보이기 시작합니다. 그다음부터는 기능 자체보다 &lt;b&gt;왜 어떤 때 버벅이고, 왜 리스트가 길어질수록 드래그가 불안정해지는지&lt;/b&gt; 같은 문제가 남습니다. 이제는 구조뿐 아니라 성능과 이벤트 연결 방식까지 같이 봐야 할 단계입니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>beforeafter</category>
      <category>dropindicator</category>
      <category>드롭위치판단</category>
      <category>마우스위치</category>
      <category>삽입선계산</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/26</guid>
      <comments>https://story86025.tistory.com/26#entry26comment</comments>
      <pubDate>Thu, 26 Mar 2026 09:00:28 +0900</pubDate>
    </item>
    <item>
      <title>로컬AI에이전트 준비중</title>
      <link>https://story86025.tistory.com/75</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1372&quot; data-origin-height=&quot;1005&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JMkSR/dJMcagkAQ4d/qvxXka35iy2yunDYuoNDlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JMkSR/dJMcagkAQ4d/qvxXka35iy2yunDYuoNDlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JMkSR/dJMcagkAQ4d/qvxXka35iy2yunDYuoNDlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJMkSR%2FdJMcagkAQ4d%2FqvxXka35iy2yunDYuoNDlK%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;1372&quot; height=&quot;1005&quot; data-origin-width=&quot;1372&quot; data-origin-height=&quot;1005&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>개인프로젝트/로컬 AI에이전트</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/75</guid>
      <comments>https://story86025.tistory.com/75#entry75comment</comments>
      <pubDate>Wed, 25 Mar 2026 17:42:57 +0900</pubDate>
    </item>
    <item>
      <title>[ChatGPT] Codex 스킬이 뭐길래? CLI&amp;middot;IDE&amp;middot;윈도우 앱 입문 가이드</title>
      <link>https://story86025.tistory.com/72</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_Codex_202603241430.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mjWXh/dJMcajuOh6x/BKvM49fkJdJqwX4wiljAv1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mjWXh/dJMcajuOh6x/BKvM49fkJdJqwX4wiljAv1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mjWXh/dJMcajuOh6x/BKvM49fkJdJqwX4wiljAv1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmjWXh%2FdJMcajuOh6x%2FBKvM49fkJdJqwX4wiljAv1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_Codex_202603241430.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 &lt;span data-ui=&quot;term&quot; data-note=&quot;메인 에이전트가 여러 보조 에이전트에 일을 나눠 맡기는 방식입니다.&quot;&gt;서브에이전트&lt;/span&gt;를 먼저 본 이유는 단순합니다. Codex를 이해할 때는 &amp;ldquo;누가 일하느냐&amp;rdquo;와 &amp;ldquo;어떻게 일하느냐&amp;rdquo;를 분리해서 보는 편이 훨씬 덜 헷갈리기 때문입니다. 이번 글에서 다룰 &lt;span data-ui=&quot;term&quot; data-note=&quot;반복 작업을 안정적으로 수행하게 만드는 재사용 가능한 작업 매뉴얼입니다.&quot;&gt;skill&lt;/span&gt;은 바로 그 두 번째, 즉 &lt;b&gt;일하는 방식&lt;/b&gt; 쪽에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 skill을 처음 보면 대개 이렇게 받아들입니다. &amp;ldquo;아, 자주 쓰는 프롬프트 저장해두는 기능이구나.&amp;rdquo; 완전히 틀린 말은 아니지만, 그렇게만 보면 중요한 부분을 놓치게 됩니다. skill은 그냥 문장을 저장하는 메모장이 아니라, &lt;b&gt;반복되는 작업 흐름을 Codex가 다시 꺼내 쓸 수 있게 묶어두는 작은 업무 카드&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 버그를 고칠 때마다 늘 비슷한 순서를 밟는 사람이 있습니다. 재현 조건을 먼저 보고, 관련 파일을 좁히고, 최소 수정으로 끝내고, 마지막에 원인과 검증 결과를 정리하는 식입니다. 이런 패턴이 한두 번이 아니라 계속 반복된다면, 그때부터는 긴 프롬프트를 매번 다시 쓰는 것보다 skill로 정리하는 쪽이 훨씬 편해집니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한 줄로 먼저 잡아두면&lt;/b&gt;&lt;br /&gt;서브에이전트가 &lt;b&gt;역할 분담&lt;/b&gt;이라면, skill은 &lt;b&gt;작업 절차 재사용&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스킬은 정확히 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서 기준으로 skill은 &lt;span data-ui=&quot;term&quot; data-note=&quot;Codex가 스킬의 존재를 판단할 때 가장 먼저 보는 핵심 파일입니다.&quot;&gt;SKILL.md&lt;/span&gt;를 중심으로, 설명서와 자료, 필요하면 스크립트까지 함께 묶어놓은 디렉터리입니다. 중요한 건 &amp;ldquo;한 파일&amp;rdquo;이 아니라 &amp;ldquo;한 묶음&amp;rdquo;이라는 점입니다. 그래서 skill은 단순 문구 저장보다 조금 더 실무적인 단위로 생각하는 편이 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 Codex는 skill을 처음부터 전부 길게 읽는 방식이 아닙니다. 먼저 이름과 설명, 경로 같은 메타데이터를 보고, 지금 작업과 맞아떨어질 때만 본문을 깊게 읽습니다. 그래서 초보자에게 정말 중요한 건 거창한 구성보다도 &lt;b&gt;이 skill이 언제 쓰여야 하는지 설명을 분명하게 적는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;구성 요소&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;무슨 역할인가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;초보자식 비유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;프롬프트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;지금 당장 원하는 요청&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;메신저로 보내는 즉석 지시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;프로젝트에서 Codex가 항상 참고할 지속형 지침 파일입니다.&quot;&gt;AGENTS.md&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;프로젝트 공통 규칙&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;팀 운영 수칙&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;skill&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;반복 업무 절차와 기준&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;자주 꺼내 쓰는 작업 카드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;외부 문서, 브라우저, 로그 도구 같은 외부 시스템과 연결하는 규격입니다.&quot;&gt;MCP&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;외부 도구 연결&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;외부 공구함 연결선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;서브에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;역할을 나눈 작업자&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&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;skill은 Codex를 다른 사람으로 바꾸는 기능이 아닙니다.&lt;/b&gt; 이미 있는 에이전트가, 특정 일을 할 때 더 일정한 방식으로 움직이게 만드는 기능에 가깝습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 초보자에게도 스킬이 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill은 고급 사용자 전용 옵션처럼 보이지만, 의외로 초보자에게 더 유용한 면이 있습니다. 코딩 경험이 많지 않을수록 같은 설명을 여러 번 반복하면서도, 매번 조금씩 다른 말을 하게 되기 쉽기 때문입니다. 그러면 결과도 흔들립니다. 어떤 날은 테스트를 챙기고, 어떤 날은 건너뛰고, 어떤 날은 설명이 너무 길어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 skill을 써두면 작업의 기준이 눈에 보이기 시작합니다. &amp;ldquo;이건 버그 수정용&amp;rdquo;, &amp;ldquo;이건 PR 리뷰용&amp;rdquo;, &amp;ldquo;이건 작업 로그 정리용&amp;rdquo;처럼 역할이 분리되고, 결과물의 결도 조금씩 맞춰집니다. Codex를 그때그때 말 잘 듣는 채팅창처럼 쓰는 단계에서, &lt;b&gt;반복 작업을 다루는 도구처럼 쓰는 단계&lt;/b&gt;로 넘어가는 느낌이라고 보면 이해가 쉽습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프롬프트를 매번 길게 다시 쓰지 않아도 됩니다.&lt;/li&gt;
&lt;li&gt;작업 결과가 들쭉날쭉해지는 문제를 줄이기 좋습니다.&lt;/li&gt;
&lt;li&gt;혼자 쓸 때도 편하고, 팀으로 쓸 때는 더 편해집니다.&lt;/li&gt;
&lt;li&gt;CLI, IDE, 앱을 오가도 작업 방식이 덜 흔들립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이럴 때 skill을 떠올리면 됩니다&lt;/b&gt;&lt;br /&gt;같은 프롬프트를 자꾸 재활용하고 있거나, 같은 수정 방식을 매번 다시 설명하고 있다면, 이미 skill로 만들 시점에 가까워진 경우가 많습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스킬은 실제로 어떻게 생겼나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 가장 먼저 보면 좋은 건 거창한 구조가 아니라, &amp;ldquo;아, 결국 폴더 단위로 관리되는구나&amp;rdquo; 하는 감각입니다. 공식 문서 기준으로 프로젝트 안에서는 보통 &lt;code&gt;.agents/skills&lt;/code&gt; 계열을 떠올리면 됩니다. 사용자 개인 스킬은 홈 디렉터리 아래에 두고, 프로젝트 전용 스킬은 저장소 안에 두는 식입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;269&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/72Pqv/dJMcafTwdyP/DlV7PVYQlpKFd99pHINjBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/72Pqv/dJMcafTwdyP/DlV7PVYQlpKFd99pHINjBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/72Pqv/dJMcafTwdyP/DlV7PVYQlpKFd99pHINjBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F72Pqv%2FdJMcafTwdyP%2FDlV7PVYQlpKFd99pHINjBK%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;478&quot; height=&quot;269&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;269&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;isbl&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;my-project/
└─ .agents/
   └─ skills/
      └─ bugfix-helper/
         ├─ SKILL.md
         ├─ references/
         └─ scripts/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 여기서 겁먹을 필요가 없습니다. 사실상 처음 필요한 건 &lt;code&gt;SKILL.md&lt;/code&gt; 하나입니다. &lt;code&gt;references&lt;/code&gt;나 &lt;code&gt;scripts&lt;/code&gt;는 나중에 정말 필요할 때 붙여도 됩니다. 시작을 무겁게 만들수록, 초보자는 skill 자체보다 구조에 먼저 질리기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아주 단순한 형태는 이렇게 시작해도 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;---
name: bugfix-helper
description: &amp;gt;
  화면 오류를 원인 중심으로 찾고
  최소 수정으로 고칠 때 쓰는 스킬
---

1. 먼저 재현 조건을 확인한다.
2. 관련 파일 범위를 좁힌다.
3. 최소 수정으로 해결한다.
4. 필요한 테스트나 확인 포인트를 정리한다.
5. 마지막에 원인과 수정 내용을 짧게 요약한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예시를 보면 감이 옵니다. skill은 &amp;ldquo;멋진 문장&amp;rdquo;보다 &lt;b&gt;반복 순서가 보이는 설명&lt;/b&gt;이 더 중요합니다. 스킬 이름도 중요하지만, 실제로는 &lt;code&gt;description&lt;/code&gt;이 호출 조건에 가깝습니다. 그래서 이름을 예쁘게 짓는 것보다, 언제 써야 하고 언제 쓰지 말아야 하는지를 또렷하게 적는 편이 훨씬 낫습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CLI에서 스킬 쓰는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;터미널에서 직접 Codex를 실행해 쓰는 방식입니다.&quot;&gt;CLI&lt;/span&gt;는 가장 직선적인 표면입니다. 터미널을 열고 프로젝트 폴더로 들어간 뒤 Codex를 실행하면, 그 디렉터리를 기준으로 읽고 수정하고 명령을 돌릴 수 있습니다. 터미널이 익숙한 사람에겐 제일 빠르고, 낯선 사람에겐 조금 무뚝뚝해 보일 수 있지만, 구조를 이해하기에는 오히려 선명한 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 시작은 단순합니다. 설치하고, 프로젝트 폴더로 가고, 실행하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;npm i -g @openai/codex

cd my-project
codex&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 실행할 때는 로그인 과정이 따라오고, 이후부터는 그 자리에서 바로 작업을 이어갈 수 있습니다. skill도 여기서 바로 만들 수 있습니다. 초보자라면 수동 작성보다 먼저 내장 생성기를 한 번 써보는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;$skill-creator&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 생성기는 &amp;ldquo;무슨 일을 하는 스킬인지&amp;rdquo;, &amp;ldquo;언제 켜져야 하는지&amp;rdquo;, &amp;ldquo;스크립트가 필요한지&amp;rdquo;를 순서대로 물어보는 방식이라 입문자에게 특히 편합니다. 처음에는 &lt;b&gt;instruction-only&lt;/b&gt; 스타일로 시작하는 편이 무난합니다. 즉, 자동화 욕심부터 내지 말고 먼저 설명서형 스킬부터 만드는 쪽이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출도 어렵지 않습니다. CLI에서는 &lt;code&gt;/skills&lt;/code&gt;로 목록을 보거나, &lt;code&gt;$&lt;/code&gt;로 skill을 직접 언급해 불러올 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;$bugfix-helper
로그인 버튼을 누르면 흰 화면이 뜹니다.
원인부터 찾고,
최소 수정으로 고쳐주세요.
마지막에 바뀐 파일과
검증 방법도 같이 정리해주세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정도 알아두면 좋습니다. CLI와 IDE는 &lt;code&gt;~/.codex/config.toml&lt;/code&gt; 계층을 함께 쓰기 때문에, 승인 정책이나 기본 모델 같은 공통 습관을 한곳에서 관리할 수 있습니다. 특정 skill을 지우지 않고 잠시 끄고 싶을 때는 아래처럼 다룰 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;467&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdtR8x/dJMcaaxUAip/E6C6QJcnbrtDuATamix0z1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdtR8x/dJMcaaxUAip/E6C6QJcnbrtDuATamix0z1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdtR8x/dJMcaaxUAip/E6C6QJcnbrtDuATamix0z1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdtR8x%2FdJMcaaxUAip%2FE6C6QJcnbrtDuATamix0z1%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;844&quot; height=&quot;467&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;467&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;lua&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;[[skills.config]]
path = &quot;/project/.agents/skills/bugfix-helper/SKILL.md&quot;
enabled = false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CLI를 조금 더 오래 쓰게 되면 유용한 명령도 생깁니다. 예를 들어 이전 작업을 다시 이어가고 싶을 때는 &lt;code&gt;codex resume&lt;/code&gt;가 꽤 쓸 만합니다. 세션을 완전히 새로 시작하기보다, 같은 저장소 상태와 맥락을 끌고 오는 쪽이 더 자연스러운 경우가 많기 때문입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;윈도우에서 CLI를 쓸 때&lt;/b&gt;&lt;br /&gt;윈도우에서도 Codex를 돌릴 수는 있지만, 공식 문서는 CLI 쪽에서 WSL 기준 흐름을 꽤 중요하게 다룹니다. 특히 저장소가 이미 WSL 안에 있거나 리눅스 도구 체인이 필요한 경우라면, 처음부터 WSL 기준으로 잡는 편이 덜 꼬입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;IDE에서 스킬 쓰는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;코드를 보면서 바로 Codex를 붙여 쓰는 에디터 환경입니다.&quot;&gt;IDE&lt;/span&gt;는 초보자에게 가장 무난한 시작점입니다. 코드가 눈앞에 있고, 어떤 파일을 보고 있는지도 분명하고, 선택한 코드 조각을 그대로 문맥에 태우기 쉽기 때문입니다. 공식 문서 기준으로 Codex IDE 확장은 VS Code 계열 에디터에서 쓰는 흐름을 중심으로 안내되고, Cursor나 Windsurf 같은 VS Code 호환 환경도 포함합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 흐름은 보통 이렇게 잡으면 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;에디터에 Codex 확장을 설치합니다.&lt;/li&gt;
&lt;li&gt;프로젝트 폴더를 연 상태에서 Codex 패널을 엽니다.&lt;/li&gt;
&lt;li&gt;ChatGPT 계정이나 API 키로 로그인합니다.&lt;/li&gt;
&lt;li&gt;현재 열린 파일, 선택 영역, &lt;code&gt;@file&lt;/code&gt; 참조를 활용해 작업을 시작합니다.&lt;/li&gt;
&lt;li&gt;필요하면 &lt;code&gt;/skills&lt;/code&gt; 또는 &lt;code&gt;$skill-name&lt;/code&gt;으로 skill을 불러옵니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IDE의 장점은 문맥이 따라붙는다는 데 있습니다. &amp;ldquo;이 파일을 보고 얘기하는지&amp;rdquo;, &amp;ldquo;지금 선택한 함수가 뭔지&amp;rdquo;가 눈에 보이기 때문에, CLI보다 짧은 프롬프트로도 훨씬 자연스럽게 작업이 이어지는 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;@src/components/LoginForm.tsx
$bugfix-helper

선택한 코드 기준으로
오류 원인을 먼저 설명하고,
최소 수정으로 고쳐주세요.
테스트가 필요하면
어떤 명령을 돌릴지도 적어주세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 면에서는 IDE도 CLI와 멀리 떨어져 있지 않습니다. 일부 UI 옵션은 에디터 설정에서 바꾸지만, 기본 모델, 승인 방식, 샌드박스 같은 핵심 동작은 공유 &lt;code&gt;config.toml&lt;/code&gt;에서 관리합니다. 그래서 CLI에서 잡아둔 습관이 IDE에도 이어지고, 반대로 IDE에서 바꾼 공통 설정이 CLI에도 영향을 주는 식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 초보자가 꼭 알아둘 건 &lt;span data-ui=&quot;term&quot; data-note=&quot;Codex가 어디까지 스스로 읽고 수정하고 실행할 수 있는지 정하는 모드입니다.&quot;&gt;승인 모드&lt;/span&gt;입니다. IDE에서는 보통 &lt;b&gt;Agent&lt;/b&gt;가 기본입니다. 이 모드에서는 작업 디렉터리 안에서 읽기, 수정, 명령 실행을 비교적 자연스럽게 처리합니다. 다만 그보다 넓은 범위나 네트워크 접근까지 자동으로 허용하는 &lt;b&gt;Agent (Full Access)&lt;/b&gt;는 말 그대로 권한이 커지므로, 초보자라면 급할 때만 신중하게 쓰는 편이 좋습니다. 설계만 하고 싶을 때는 &lt;b&gt;Chat&lt;/b&gt; 모드가 더 편한 경우도 많습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;윈도우에서 IDE를 쓸 때는 이 점이 중요합니다&lt;/b&gt;&lt;br /&gt;공식 문서 기준으로 Windows에서 IDE의 agent mode는 현재 WSL 흐름이 사실상 핵심입니다. 단순히 &amp;ldquo;선택 옵션&amp;rdquo; 정도로 보지 말고, 윈도우 사용자라면 초기에 함께 세팅해야 할 기본 조건으로 생각하는 편이 덜 헷갈립니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 IDE에서는 slash command도 꽤 유용합니다. 예를 들어 &lt;code&gt;/local&lt;/code&gt;, &lt;code&gt;/cloud&lt;/code&gt;, &lt;code&gt;/review&lt;/code&gt;, &lt;code&gt;/status&lt;/code&gt; 같은 명령으로 작업 모드를 바꾸거나 현재 상태를 확인할 수 있습니다. 초보자에게는 기능을 다 외우는 것보다, &lt;b&gt;채팅 입력창에서 슬래시를 눌러 어떤 제어가 가능한지 한 번씩 훑어보는 습관&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;윈도우 앱에서는 스킬을 어떻게 쓰나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;여러 프로젝트와 작업 스레드를 한곳에서 관리하는 Codex 데스크톱 앱입니다.&quot;&gt;Codex 앱&lt;/span&gt;은 에디터보다는 조금 더 관제실 같은 표면입니다. 공식 문서 기준으로 Windows 앱은 네이티브로 돌아가고, PowerShell과 Windows 샌드박스를 쓰는 흐름이 기본이며 필요하면 WSL 방식도 잡을 수 있습니다. 코드 한 줄 한 줄을 에디터에서 보는 느낌보다, 여러 작업을 병렬로 돌리고 검토하는 감각이 더 잘 살아납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱에서는 보통 이런 흐름으로 이해하면 쉽습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;프로젝트를 추가합니다.&lt;/li&gt;
&lt;li&gt;새 스레드를 열면서 작업 방식을 고릅니다.&lt;/li&gt;
&lt;li&gt;&lt;span data-ui=&quot;term&quot; data-note=&quot;현재 프로젝트 디렉터리를 직접 건드리는 작업 방식입니다.&quot;&gt;Local&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;기존 작업과 분리된 Git 작업 공간에서 변경을 격리하는 방식입니다.&quot;&gt;Worktree&lt;/span&gt;, &lt;span data-ui=&quot;term&quot; data-note=&quot;설정된 원격 환경에서 작업을 돌리는 방식입니다.&quot;&gt;Cloud&lt;/span&gt; 중 하나를 선택합니다.&lt;/li&gt;
&lt;li&gt;사이드바의 Skills에서 현재 프로젝트와 연결된 skill을 확인합니다.&lt;/li&gt;
&lt;li&gt;diff, Git, 터미널, 스레드 기록을 보면서 작업을 이어갑니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 중에서 skill과 가장 직접적으로 연결되는 부분은 &lt;b&gt;Skills 사이드바&lt;/b&gt;입니다. 앱은 CLI와 IDE에서 쓰는 것과 같은 skill을 지원하고, 팀에서 만든 skill도 사이드바에서 둘러보기 쉽게 보여줍니다. 그래서 skill이 파일 시스템 어딘가에 숨어 있는 설정처럼 느껴지지 않고, 실제로 꺼내 쓰는 도구처럼 보이기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 스레드를 만들 때 나오는 모드도 초보자에게 꽤 중요합니다. 작은 수정이거나 지금 열어둔 프로젝트를 바로 손보고 싶다면 Local이 편합니다. 반대로 현재 작업을 건드리지 않고 시도해보고 싶다면 Worktree가 더 안전합니다. 긴 작업이나 따로 돌려두고 싶은 일은 Cloud가 잘 맞습니다. 처음에는 Local로 시작하고, 조금 익숙해지면 Worktree를 붙이는 순서가 대체로 무난합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱의 장점은 skill만 보이는 게 아니라, 그 skill이 만든 결과를 검토하는 흐름도 함께 있다는 점입니다. diff 창에서 변경 내용을 보고, Git 기능으로 커밋이나 푸시를 하고, 내장 터미널로 빌드나 테스트까지 확인할 수 있습니다. 즉, &amp;ldquo;스킬을 불러 작업시키는 것&amp;rdquo;에서 끝나지 않고, &lt;b&gt;그 결과를 점검하는 자리까지 한 화면 안에서 이어지는 편&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬을 앱에서 조금 더 보기 좋게 다루고 싶다면, 나중에는 &lt;code&gt;agents/openai.yaml&lt;/code&gt;도 붙여볼 수 있습니다. 이 파일은 필수는 아니지만, 앱에서 skill의 표시 이름이나 짧은 설명, 기본 프롬프트를 더 보기 좋게 다듬는 데 도움이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;&quot;&gt;&lt;code&gt;interface:
  display_name: &quot;Bugfix Helper&quot;
  short_description: &quot;버그 수정용 체크리스트&quot;
  default_prompt: &quot;원인부터 찾고 최소 수정으로 고쳐줘&quot;

policy:
  allow_implicit_invocation: false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 마지막 줄도 꽤 실용적입니다. 어떤 skill은 자동으로 불리기보다, 내가 명시적으로만 쓰고 싶은 경우가 있습니다. 그럴 때는 암묵 호출을 끄고, 필요할 때만 직접 불러도 됩니다. 초보자 단계에서는 필수는 아니지만, skill이 많아질수록 이런 구분이 꽤 유용해집니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;앱에서 기억해둘 한 줄&lt;/b&gt;&lt;br /&gt;skill이 &lt;b&gt;방법&lt;/b&gt;을 정리해둔 것이라면, 앱은 그 방법을 여러 프로젝트와 스레드 위에서 &lt;b&gt;관리하고 검토하는 자리&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;좋은 스킬은 어떻게 쓰는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill을 처음 만들 때 가장 흔한 실수는 한 skill에 너무 많은 일을 넣는 것입니다. 버그 수정, 코드 리뷰, 릴리스 노트 초안, 로그 정리까지 전부 한곳에 넣어버리면 결국 아무 상황에도 애매한 스킬이 되기 쉽습니다. 공식 가이드도 반복해서 강조하는 방향은 비슷합니다. &lt;b&gt;스킬 하나에는 일 하나&lt;/b&gt;, 이 원칙이 초보자에게도 가장 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 description은 대충 써도 되는 부가 정보가 아닙니다. Codex가 지금 작업과 이 skill이 맞는지를 판단할 때 핵심 단서가 되기 때문입니다. 그래서 &amp;ldquo;React 관련 작업&amp;rdquo;처럼 넓게 쓰기보다, &amp;ldquo;화면 오류를 원인 중심으로 찾고 최소 수정으로 끝낼 때&amp;rdquo;처럼 범위를 좁히는 편이 더 낫습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모든 걸 자동화하려 하지 않는 것도 중요합니다. instruction-only skill 하나를 먼저 만들고, 실제로 몇 번 써본 뒤에 부족한 부분만 references나 scripts를 붙이는 편이 훨씬 덜 헷갈립니다. 시작 단계에서 필요한 건 화려함보다 재현성입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;좋은 시작&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #d0d0d0; padding: 10px; text-align: left;&quot;&gt;피하는 편이 좋은 시작&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;버그 수정용, 리뷰용처럼 역할을 좁혀서 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;하나의 skill에 여러 일을 한꺼번에 몰아넣기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;description에 언제 써야 하는지 분명히 적기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;이름만 멋있고 설명은 두루뭉술하게 쓰기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;instruction-only로 가볍게 시작&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;처음부터 스크립트와 자동화까지 한 번에 넣기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&gt;몇 번 써본 뒤 필요한 자료만 덧붙이기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #d0d0d0; padding: 10px;&quot;&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;AGENTS.md&lt;/code&gt;로 올리고, 특정 작업 절차라면 skill로 두는 식입니다. 바깥 도구나 실시간 정보가 꼭 필요하면 MCP를 붙이고, 역할을 나눠야 할 만큼 일이 복잡하면 서브에이전트를 붙이는 식으로 층을 나눠 생각하면 훨씬 정리가 잘 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;skill이 안 보이면 재시작도 의심해보세요.&lt;/b&gt; 문서상 자동 감지는 되지만, 변경이 바로 안 잡히면 재시작으로 해결되는 경우가 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기본 제공 skill도 있습니다.&lt;/b&gt; 예를 들어 &lt;code&gt;$skill-creator&lt;/code&gt;는 첫 skill을 만들 때 꽤 유용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예전 글에서 custom prompts를 봤다면 업데이트가 필요할 수 있습니다.&lt;/b&gt; 현재 공식 흐름은 재사용 가능한 지시를 skill 쪽으로 보는 편입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정은 한 군데에서 습관처럼 관리하는 편이 좋습니다.&lt;/b&gt; 개인 기본값은 &lt;code&gt;~/.codex/config.toml&lt;/code&gt;, 프로젝트 전용 동작은 저장소 쪽 설정으로 나누면 덜 꼬입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;작업이 안정된 뒤에야 자동화를 붙이는 편이 좋습니다.&lt;/b&gt; skill이 아직 들쭉날쭉한데 자동화부터 돌리면, 불편이 반복될 뿐입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;권한은 처음부터 넓히지 않는 편이 안전합니다.&lt;/b&gt; 특히 Full Access는 편해 보여도, 초반에는 기본 승인 흐름으로 감을 잡는 쪽이 낫습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Windows에서는 표면마다 느낌이 다릅니다.&lt;/b&gt; 앱은 네이티브 사용 흐름이 비교적 자연스럽고, CLI나 IDE는 WSL 기준으로 잡을 때 편한 경우가 많습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;외워두면 좋은 말&lt;/b&gt;&lt;br /&gt;skill은 &lt;b&gt;방법&lt;/b&gt;을 정리하고, automation은 &lt;b&gt;일정을&lt;/b&gt; 정리합니다. 작업이 아직 흔들리면 automation보다 skill부터 다듬는 편이 맞습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex skill은 대단한 비밀 기능이라기보다, 내가 반복하는 작업 방식을 정리해두는 습관에 가깝습니다. 그래서 오히려 초보자에게 더 중요할 수 있습니다. 프롬프트를 길게 쓰는 재주보다, &lt;b&gt;반복되는 일의 구조를 알아보는 눈&lt;/b&gt;이 먼저 생기기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇게 볼 수 있습니다. CLI는 가장 직선적이고, IDE는 가장 무난하며, 윈도우 앱은 여러 작업을 함께 관리하기 좋습니다. 그런데 어느 표면을 쓰든 skill의 본질은 같습니다. &lt;b&gt;자주 반복하는 일을, Codex가 다시 꺼내 쓸 수 있게 정리해두는 것.&lt;/b&gt; 그 감만 잡히면 skill이라는 말이 훨씬 덜 어렵게 느껴질 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계로 넘어간다면, 거창한 자동화보다 작은 실험이 더 좋습니다. 예를 들어 &amp;ldquo;버그 수정용&amp;rdquo;, &amp;ldquo;작업 로그 정리용&amp;rdquo;처럼 하나만 골라 &lt;code&gt;SKILL.md&lt;/code&gt;를 직접 써보는 겁니다. 그 순간부터 Codex는 그냥 코드 물어보는 창이 아니라, 조금 더 일하는 방식이 잡힌 도구처럼 보이기 시작할 가능성이 큽니다.&lt;/p&gt;</description>
      <category>AI에이전트/Chatgpt</category>
      <category>chatGPT</category>
      <category>Codex skill</category>
      <category>Codex가이드</category>
      <category>codex스킬</category>
      <category>Codex스킬가이드</category>
      <category>codex스킬설정</category>
      <category>gpt스킬가이드</category>
      <category>gpt스킬설정</category>
      <category>Skills</category>
      <category>코덱스skill</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/72</guid>
      <comments>https://story86025.tistory.com/72#entry72comment</comments>
      <pubDate>Wed, 25 Mar 2026 15:46:22 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 11편: 기능은 그대로 두고 할 일 앱 구조만 다듬기</title>
      <link>https://story86025.tistory.com/31</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_code_202603241420.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zmDxb/dJMcagdPb7z/dmsUfq8ttyXRXTSgqjsI6K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zmDxb/dJMcagdPb7z/dmsUfq8ttyXRXTSgqjsI6K/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zmDxb/dJMcagdPb7z/dmsUfq8ttyXRXTSgqjsI6K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzmDxb%2FdJMcagdPb7z%2FdmsUfq8ttyXRXTSgqjsI6K%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_code_202603241420.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;배포까지 한 번 끝낸 프로젝트를 다시 열어보면, 이제는 다른 종류의 아쉬움이 보이기 시작합니다. 기능은 되는데 코드가 길게 몰려 있다거나, 수정 지점을 찾기가 조금 불편하다거나, 비슷한 스타일이 여러 군데 반복되는 식입니다. 이때 많은 초보자가 &quot;그럼 새 기능부터 더 넣어야 하나?&quot;라고 생각하는데, 오히려 지금은 그 반대가 더 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서는 &lt;span data-ui=&quot;term&quot; data-note=&quot;겉으로 보이는 기능은 그대로 두고, 코드를 읽기 쉽고 수정하기 편하게 다시 정리하는 작업입니다.&quot;&gt;리팩터링&lt;/span&gt;을 해보겠습니다. 다만 거창하게 갈아엎는 방향은 아닙니다. 지금 잘 돌아가는 상태를 유지한 채, &lt;b&gt;읽기 좋고 손대기 쉬운 구조&lt;/b&gt;로 조금만 다듬는 쪽에 집중하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 바이브코딩에서는 이 단계가 중요합니다. AI가 코드를 빠르게 만들어줄수록, 어느 순간부터는 &quot;만드는 속도&quot;보다 &quot;읽을 수 있는 구조인가&quot;가 더 중요해지기 쉽기 때문입니다. 코드가 길게 늘어나면, 다음 수정 때 어디부터 건드려야 하는지 흐려지기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;지금 바꾸는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;지금은 안 바꾸는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 이렇게 가나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;HTML 구역 이름과 배치&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;할 일 추가, 체크, 삭제 기능&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조를 읽기 좋게 만들되, 동작은 그대로 두기 위해서입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;CSS 반복 규칙 정리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;디자인 전면 개편&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;겉모습보다 수정 동선을 먼저 정리하는 쪽이 낫기 때문입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;JavaScript 함수 분리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새 기능 추가&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금은 &quot;더 만들기&quot;보다 &quot;다음 수정이 덜 힘들게 만들기&quot;가 우선이기 때문입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;리팩터링은 멋있게 다시 쓰는 작업이 아니라, &lt;b&gt;지금 잘 되던 기능을 망치지 않으면서 다음 수정이 덜 힘들어지게 만드는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 지금 리팩터링을 해봐야 하는가&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1401&quot; data-origin-height=&quot;779&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2jBV4/dJMcai3H38Q/jUVhXH0X6GRwEXzDiSkASK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2jBV4/dJMcai3H38Q/jUVhXH0X6GRwEXzDiSkASK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2jBV4/dJMcai3H38Q/jUVhXH0X6GRwEXzDiSkASK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2jBV4%2FdJMcai3H38Q%2FjUVhXH0X6GRwEXzDiSkASK%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;1401&quot; height=&quot;779&quot; data-origin-width=&quot;1401&quot; data-origin-height=&quot;779&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 만든 할 일 앱은 이미 기본 기능이 돌아갑니다. 그러면 얼핏 보기에는 더 이상 손댈 이유가 없어 보일 수도 있습니다. 그런데 실제로는 여기서 한 단계 더 가는 경험이 중요합니다. &lt;b&gt;돌아가는 코드를 유지하면서 구조를 정리해보는 경험&lt;/b&gt;이 있어야, 다음 프로젝트에서도 &quot;어디를 어떻게 정리하면 되는지&quot; 감이 생기기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 바이브코딩에서는 이 단계가 더 중요합니다. AI가 코드를 빠르게 만들어줄수록, 어느 순간부터는 &quot;만드는 속도&quot;보다 &quot;읽을 수 있는 구조인가&quot;가 더 중요해지기 쉽습니다. 코드가 한 파일 안에서 길게 늘어나면, 다음 수정 때 어디부터 건드려야 하는지 흐려지기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 구조와 역할이 더 또렷하게 보입니다.&lt;/li&gt;
&lt;li&gt;비슷한 CSS가 여러 번 반복되는 문제를 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;JavaScript를 수정할 때 어느 함수부터 봐야 하는지 쉬워집니다.&lt;/li&gt;
&lt;li&gt;Codex에게 수정 요청을 할 때도 범위를 더 정확하게 자를 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;새 기능이 없더라도, 구조를 정리하는 경험은 다음 기능을 더 안전하게 붙이기 위한 바닥을 만드는 일입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;리팩터링은 무엇을 바꾸고 무엇은 바꾸지 않는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 리팩터링에서 가장 많이 헷갈리는 부분은, 기능 추가와 구조 정리를 한 번에 섞어버리는 점입니다. 이러면 무엇 때문에 문제가 생겼는지 구분하기 어려워집니다. 그래서 이번 글에서는 선을 먼저 분명하게 긋고 가는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 바꾸는 것은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML 구역 이름과 배치를 더 읽기 좋게 정리합니다.&lt;/li&gt;
&lt;li&gt;CSS에서 반복되는 버튼 스타일을 공통 형태로 묶습니다.&lt;/li&gt;
&lt;li&gt;JavaScript에서 역할이 큰 함수를 작은 &lt;span data-ui=&quot;term&quot; data-note=&quot;하나의 목적을 수행하도록 묶어놓은 코드 블록입니다. 입력을 받고 결과를 돌려주거나, 특정 동작을 실행합니다.&quot;&gt;함수&lt;/span&gt;로 나눕니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이번 글에서 바꾸지 않는 것은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;할 일 추가 기능&lt;/li&gt;
&lt;li&gt;완료 체크 기능&lt;/li&gt;
&lt;li&gt;개별 삭제 기능&lt;/li&gt;
&lt;li&gt;전체 삭제 기능&lt;/li&gt;
&lt;li&gt;브라우저 저장 유지 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번 리팩터링의 목적은 &quot;새로운 앱 만들기&quot;가 아니라, &lt;b&gt;지금 있는 앱을 같은 기능으로 더 읽기 좋게 정리하는 것&lt;/b&gt;입니다. 이 기준이 흔들리면 금방 범위가 커집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리팩터링을 할 때는 &quot;동작은 그대로, 구조만 정리&quot;라는 선을 먼저 그어두는 편이 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시작 전에 체크포인트부터 남긴다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링은 기능을 새로 넣는 작업이 아니기 때문에, 겉으로 보기엔 변화가 작아 보일 수 있습니다. 그런데 오히려 그래서 더 체크포인트가 중요합니다. 구조를 정리하다 보면 생각보다 여러 줄이 한꺼번에 바뀌고, 잘못하면 &quot;왜 안 되지?&quot;가 아니라 &quot;어디가 바뀌었지?&quot;부터 흐려질 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 시작 전에는 먼저 안정 상태를 하나 남겨두는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;git status
git add .
git commit -m &quot;리팩터링 전 안정 버전 저장&quot;&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 data-ke-size=&quot;size16&quot;&gt;이 단계에서는 VS Code의 &lt;span data-ui=&quot;term&quot; data-note=&quot;두 버전의 차이를 나란히 보여주는 비교 화면입니다. 어떤 줄이 추가되거나 수정됐는지 빠르게 파악할 수 있습니다.&quot;&gt;diff&lt;/span&gt; 화면도 같이 보는 편이 좋습니다. 리팩터링은 기능 변화보다 &quot;무엇이 얼마나 정리됐는지&quot;가 더 중요하기 때문에, 저장하기 전에 바뀐 줄을 차분하게 보는 습관이 꽤 도움이 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리팩터링 전 커밋은 &quot;나중에 올리기 위한 기록&quot;이 아니라, &lt;b&gt;문제가 생겼을 때 다시 돌아갈 수 있는 바닥&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;15분 실습: 먼저 냄새부터 찾기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링은 코드부터 막 건드린다고 잘 되는 게 아닙니다. 먼저 &quot;어디가 불편한지&quot;를 눈으로 찾아야 합니다. 초보자 기준에서는 아래 세 가지가 가장 찾기 쉽습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 구조가 여러 번 반복된다.&lt;/li&gt;
&lt;li&gt;한 함수 안에서 하는 일이 너무 많다.&lt;/li&gt;
&lt;li&gt;클래스 이름이나 구역 이름이 역할을 잘 설명하지 못한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 JavaScript는 돌아가긴 하지만, 한 함수 안에서 너무 많은 일을 한꺼번에 처리하고 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function renderTodos() {
  todoList.innerHTML = &quot;&quot;;

  if (todos.length === 0) {
    message.textContent = &quot;아직 등록된 할 일이 없습니다.&quot;;
    return;
  }

  todos.forEach(function (todo) {
    const item = document.createElement(&quot;li&quot;);
    const left = document.createElement(&quot;div&quot;);
    const checkbox = document.createElement(&quot;input&quot;);
    const text = document.createElement(&quot;span&quot;);
    const deleteButton = document.createElement(&quot;button&quot;);

    item.className = &quot;todo-item&quot;;
    left.className = &quot;todo-left&quot;;

    if (todo.done) {
      item.classList.add(&quot;done&quot;);
    }

    checkbox.type = &quot;checkbox&quot;;
    checkbox.checked = todo.done;

    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos();
    });

    text.textContent = todo.text;

    deleteButton.type = &quot;button&quot;;
    deleteButton.className = &quot;delete-button&quot;;
    deleteButton.textContent = &quot;삭제&quot;;

    deleteButton.addEventListener(&quot;click&quot;, function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todo.id;
      });

      saveTodos();
      renderTodos();
    });

    left.append(checkbox, text);
    item.append(left, deleteButton);
    todoList.append(item);
  });

  message.textContent = &quot;체크하거나 삭제해보세요.&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드가 틀린 건 아닙니다. 다만 초보자 기준에서는 이런 질문을 먼저 던져볼 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;체크박스 만드는 일과 삭제 버튼 만드는 일을 따로 뺄 수 없을까&lt;/li&gt;
&lt;li&gt;목록 한 줄 만드는 일을 별도 함수로 나눌 수 없을까&lt;/li&gt;
&lt;li&gt;안내 문구 바꾸는 일은 따로 떼어낼 수 없을까&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링은 바로 이런 질문에서 시작하는 편이 좋습니다. &quot;코드가 길다&quot;가 아니라, &lt;b&gt;무엇이 한데 몰려 있는지부터 보는 것&lt;/b&gt;이 먼저입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리팩터링은 &quot;이 코드는 이상하다&quot;보다 &lt;b&gt;&quot;이 역할을 따로 뺄 수 있을까?&quot;&lt;/b&gt;라는 질문에서 시작하는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 구조를 조금 더 읽기 좋게 정리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 HTML부터 정리해보겠습니다. 이번에는 화면이 완전히 달라지지는 않습니다. 대신 역할이 드러나도록 구역을 조금 더 분명하게 나누겠습니다. 이 변화가 중요한 이유는, 나중에 JavaScript가 어떤 영역을 업데이트하는지 읽기가 훨씬 쉬워지기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 전 HTML이 아래처럼 있었다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;main class=&quot;app&quot;&amp;gt;
  &amp;lt;section class=&quot;panel&quot;&amp;gt;
    &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Mini Project Update&amp;lt;/p&amp;gt;
    &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
    &amp;lt;p class=&quot;description&quot;&amp;gt;
      배포한 앱을 크게 뜯지 않고, 작은 개선부터 붙여보는 단계입니다.
    &amp;lt;/p&amp;gt;

    &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
      &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
      &amp;lt;div class=&quot;input-row&quot;&amp;gt;
        &amp;lt;input id=&quot;todoInput&quot; type=&quot;text&quot;&amp;gt;
        &amp;lt;button type=&quot;submit&quot;&amp;gt;추가&amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/form&amp;gt;

    &amp;lt;section class=&quot;toolbar&quot;&amp;gt;
      &amp;lt;p id=&quot;summaryText&quot; class=&quot;summary&quot;&amp;gt;남은 할 일 0개&amp;lt;/p&amp;gt;
      &amp;lt;button id=&quot;clearAllButton&quot; type=&quot;button&quot; class=&quot;secondary-button&quot;&amp;gt;
        전체 삭제
      &amp;lt;/button&amp;gt;
    &amp;lt;/section&amp;gt;

    &amp;lt;p id=&quot;message&quot; class=&quot;message&quot;&amp;gt;할 일을 입력하고 추가 버튼을 눌러보세요.&amp;lt;/p&amp;gt;
    &amp;lt;ul id=&quot;todoList&quot; class=&quot;todo-list&quot;&amp;gt;&amp;lt;/ul&amp;gt;
  &amp;lt;/section&amp;gt;
&amp;lt;/main&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조도 괜찮지만, 역할이 조금 더 드러나게 정리하면 아래처럼 바꿔볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;main class=&quot;app&quot;&amp;gt;
  &amp;lt;section class=&quot;panel&quot;&amp;gt;
    &amp;lt;div class=&quot;panel-header&quot;&amp;gt;
      &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Mini Project Update&amp;lt;/p&amp;gt;
      &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
      &amp;lt;p class=&quot;description&quot;&amp;gt;
        배포한 앱을 크게 뜯지 않고, 작은 개선부터 붙여보는 단계입니다.
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;div class=&quot;composer&quot;&amp;gt;
      &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
        &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;input-row&quot;&amp;gt;
          &amp;lt;input id=&quot;todoInput&quot; type=&quot;text&quot;&amp;gt;
          &amp;lt;button type=&quot;submit&quot;&amp;gt;추가&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;div class=&quot;toolbar&quot;&amp;gt;
      &amp;lt;p id=&quot;summaryText&quot; class=&quot;summary&quot;&amp;gt;남은 할 일 0개&amp;lt;/p&amp;gt;
      &amp;lt;button id=&quot;clearAllButton&quot; type=&quot;button&quot; class=&quot;secondary-button&quot;&amp;gt;
        전체 삭제
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;p id=&quot;message&quot; class=&quot;message&quot;&amp;gt;할 일을 입력하고 추가 버튼을 눌러보세요.&amp;lt;/p&amp;gt;

    &amp;lt;div class=&quot;list-section&quot;&amp;gt;
      &amp;lt;ul id=&quot;todoList&quot; class=&quot;todo-list&quot;&amp;gt;&amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/section&amp;gt;
&amp;lt;/main&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 눈여겨볼 부분은 세 군데입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;panel-header&lt;/b&gt; : 제목과 설명 영역을 따로 묶었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;composer&lt;/b&gt; : 입력 폼 구역을 따로 분리했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;list-section&lt;/b&gt; : 실제 목록이 들어가는 영역을 이름으로 드러냈습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 화면이 크게 달라지지 않아도 구조가 훨씬 또렷해집니다. 특히 나중에 Codex에게 수정 요청을 할 때도 &quot;입력 구역만 건드려줘&quot; 같은 식으로 더 정확하게 말하기 쉬워집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML 리팩터링의 핵심은 태그를 복잡하게 늘리는 게 아니라, &lt;b&gt;구역의 역할이 이름만 봐도 보이게 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS를 공통 스타일 중심으로 정리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 CSS를 보겠습니다. 지금까지의 CSS도 동작에는 문제가 없었지만, 버튼 스타일처럼 비슷한 규칙이 조금씩 흩어져 있으면 다음 수정 때 손이 여러 군데로 갑니다. 그래서 이번에는 &lt;b&gt;반복되는 스타일을 조금 더 공통적으로 묶는 방향&lt;/b&gt;으로 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 전 CSS가 아래처럼 나뉘어 있었다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.delete-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 공통 규칙을 조금 더 분명하게 묶으면 아래처럼 읽히게 바꿀 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;button {
  font: inherit;
}

button:not(.secondary-button):not(.delete-button) {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.secondary-button,
.delete-button {
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS 리팩터링에서 중요한 건 디자인 자체보다 &lt;b&gt;중복을 줄이는 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버튼 공통 규칙을 한 번에 묶었습니다.&lt;/li&gt;
&lt;li&gt;구역 간 간격을 역할별로 나눠서 읽기 쉽게 했습니다.&lt;/li&gt;
&lt;li&gt;입력, 툴바, 목록 영역이 각각 어디서 시작되는지 보이게 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리하면 다음에 버튼 스타일을 바꾸고 싶을 때도 여러 군데를 뒤질 필요가 줄어듭니다. 초보자일수록 이 차이가 꽤 크게 느껴집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;CSS 리팩터링은 예쁘게 만드는 작업이 아니라, &lt;b&gt;같은 성격의 규칙을 한데 모아 다음 수정이 덜 번거롭게 만드는 작업&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript를 작은 함수로 나누기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 사실 JavaScript입니다. 지금까지의 스크립트도 동작에는 문제가 없었지만, 렌더링과 이벤트 처리와 상태 변경이 한 군데에 길게 몰려 있으면 읽는 쪽이 먼저 지칩니다. 그래서 이번에는 &lt;b&gt;역할별로 작은 함수로 나누는 방식&lt;/b&gt;으로 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 전에는 이런 식으로 한 함수 안에 몰려 있었을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function renderTodos() {
  todoList.innerHTML = &quot;&quot;;

  if (todos.length === 0) {
    showMessage(&quot;아직 등록된 할 일이 없습니다.&quot;);
    return;
  }

  todos.forEach(function (todo) {
    const item = document.createElement(&quot;li&quot;);
    const left = document.createElement(&quot;div&quot;);
    const checkbox = document.createElement(&quot;input&quot;);
    const text = document.createElement(&quot;span&quot;);
    const deleteButton = document.createElement(&quot;button&quot;);

    item.className = &quot;todo-item&quot;;
    left.className = &quot;todo-left&quot;;

    if (todo.done) {
      item.classList.add(&quot;done&quot;);
    }

    checkbox.type = &quot;checkbox&quot;;
    checkbox.checked = todo.done;
    checkbox.addEventListener(&quot;change&quot;, function () {
      todo.done = checkbox.checked;
      saveTodos();
      renderTodos();
    });

    text.textContent = todo.text;

    deleteButton.type = &quot;button&quot;;
    deleteButton.className = &quot;delete-button&quot;;
    deleteButton.textContent = &quot;삭제&quot;;
    deleteButton.addEventListener(&quot;click&quot;, function () {
      todos = todos.filter(function (currentTodo) {
        return currentTodo.id !== todo.id;
      });
      saveTodos();
      renderTodos();
    });

    left.append(checkbox, text);
    item.append(left, deleteButton);
    todoList.appendChild(item);
  });

  showMessage(&quot;체크하거나 삭제해보세요.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1381&quot; data-origin-height=&quot;767&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rIrjq/dJMcagSqAJW/cUPQm3jZMHptBjA4nSC6mK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rIrjq/dJMcagSqAJW/cUPQm3jZMHptBjA4nSC6mK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rIrjq/dJMcagSqAJW/cUPQm3jZMHptBjA4nSC6mK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrIrjq%2FdJMcagSqAJW%2FcUPQm3jZMHptBjA4nSC6mK%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;1381&quot; height=&quot;767&quot; data-origin-width=&quot;1381&quot; data-origin-height=&quot;767&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 역할별로 나누면 아래처럼 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function createCheckbox(todo) {
  const checkbox = document.createElement(&quot;input&quot;);

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;

  checkbox.addEventListener(&quot;change&quot;, function () {
    todo.done = checkbox.checked;
    saveTodos();
    renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
  });

  return checkbox;
}

function createDeleteButton(todoId) {
  const button = document.createElement(&quot;button&quot;);

  button.type = &quot;button&quot;;
  button.className = &quot;delete-button&quot;;
  button.textContent = &quot;삭제&quot;;

  button.addEventListener(&quot;click&quot;, function () {
    todos = todos.filter(function (currentTodo) {
      return currentTodo.id !== todoId;
    });

    saveTodos();
    renderTodos(&quot;항목을 삭제했습니다.&quot;);
  });

  return button;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const text = document.createElement(&quot;span&quot;);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  text.textContent = todo.text;

  left.append(
    createCheckbox(todo),
    text
  );

  item.append(
    left,
    createDeleteButton(todo.id)
  );

  return item;
}

function renderTodos(messageText) {
  todoList.innerHTML = &quot;&quot;;

  if (todos.length === 0) {
    showMessage(messageText || &quot;아직 등록된 할 일이 없습니다.&quot;);
    return;
  }

  todos.forEach(function (todo) {
    todoList.appendChild(
      createTodoItem(todo)
    );
  });

  showMessage(
    messageText ||
      &quot;체크해서 완료 처리하거나, 필요 없으면 삭제해보세요.&quot;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 읽을 때는 처음부터 끝까지 문법으로 보지 않는 편이 좋습니다. 대신 아래처럼 역할 단위로 보면 훨씬 편합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;createCheckbox&lt;/b&gt; : 완료 상태를 바꾸는 일만 맡습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createDeleteButton&lt;/b&gt; : 삭제 동작만 맡습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createTodoItem&lt;/b&gt; : 목록 한 줄을 조립하는 일만 맡습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;renderTodos&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;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;저장 관련 함수
화면 표시 함수
목록 항목 생성 함수
사용자 동작 처리 함수

이렇게 역할을 나누면
다음 수정에서 어디부터 봐야 할지가 훨씬 쉬워집니다.&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;JavaScript 리팩터링에서 중요한 건 문법을 어렵게 쓰는 게 아니라, &lt;b&gt;기능을 역할별로 나눠서 다음 수정이 덜 불편한 상태를 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;VS Code에서는 diff부터 본다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 단계에서는 VS Code 사용 방식도 조금 달라지는 편이 좋습니다. 기능 추가 때는 &quot;무엇이 생겼는지&quot;가 더 중요했다면, 리팩터링에서는 &lt;b&gt;무엇이 어떤 줄에서 어떻게 정리됐는지&lt;/b&gt;를 보는 게 더 중요합니다. 그래서 이때는 전체 파일보다 &lt;span data-ui=&quot;term&quot; data-note=&quot;두 버전의 차이를 나란히 보여주는 비교 화면입니다.&quot;&gt;diff&lt;/span&gt;부터 여는 습관이 훨씬 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 순서는 단순합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Source Control을 엽니다.&lt;/li&gt;
&lt;li&gt;변경된 파일을 클릭합니다.&lt;/li&gt;
&lt;li&gt;왼쪽과 오른쪽을 비교하면서 어떤 줄이 사라지고, 어떤 줄이 새로 생겼는지 봅니다.&lt;/li&gt;
&lt;li&gt;&quot;기능이 바뀐 줄&quot;이 아니라 &quot;구조가 정리된 줄&quot;에 먼저 시선을 둡니다.&lt;/li&gt;
&lt;/ol&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;/li&gt;
&lt;li&gt;이름이 더 분명해졌는가&lt;/li&gt;
&lt;li&gt;한 함수가 너무 많은 일을 안 하게 되었는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이 단계에서는 &quot;몇 줄이 바뀌었나&quot;보다 &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;VS_Code_Diff_202603241615.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uEFA2/dJMcaaLrb98/7kiY6YgXgB5mVkbWmNqXHk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uEFA2/dJMcaaLrb98/7kiY6YgXgB5mVkbWmNqXHk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uEFA2/dJMcaaLrb98/7kiY6YgXgB5mVkbWmNqXHk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuEFA2%2FdJMcaaLrb98%2F7kiY6YgXgB5mVkbWmNqXHk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;VS_Code_Diff_202603241615.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리팩터링을 할 때는 전체 파일보다 &lt;b&gt;diff로 바뀐 줄만 보면서 구조가 더 또렷해졌는지&lt;/b&gt;를 확인하는 편이 훨씬 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 잘라서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 단계에서도 Codex를 쓸 수 있습니다. 다만 이때는 기능 추가 요청보다 훨씬 더 조심스럽게 범위를 잘라야 합니다. &quot;예쁘게 정리해줘&quot;처럼 넓게 말하면, 구조 정리보다 기능까지 건드릴 가능성이 커집니다. 그래서 이번 단계에서는 &lt;b&gt;어디를, 무엇만, 어디까지 바꿀지&lt;/b&gt;를 더 분명하게 말하는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 할 일 앱은 기능이 정상 동작하는 상태야.
추가, 완료 체크, 삭제, 전체 삭제, 저장 기능은 그대로 유지해야 해.
이번에는 새 기능을 넣지 말고
HTML 구조 이름 정리, CSS 공통 스타일 정리,
JavaScript 함수 분리만 해줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript만 더 좁게 요청하고 싶다면 이런 식이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
지금 기능은 그대로 두고
renderTodos에 몰린 역할을
작은 함수들로 나눠줘.
저장 방식은 바꾸지 말고
추가, 삭제, 전체 삭제 동작도 유지해줘.
수정 전후에 어떤 함수가 새로 생기는지 먼저 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS만 다듬고 싶다면 이렇게 더 잘라서 말할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@style.css
디자인을 완전히 바꾸지 말고
버튼 스타일 중복과 섹션 간격만 정리해줘.
모바일 동작은 그대로 유지해야 하고
클래스 이름이 바뀌면 HTML도 같이 맞춰줘.
먼저 바뀌는 선택자부터 짧게 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 요청하면 AI도 구조 정리와 기능 변경을 섞어버릴 가능성이 줄어듭니다. 초보자 입장에서는 이 차이가 꽤 큽니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;리팩터링을 Codex에게 맡길 때는 &quot;정리해줘&quot;보다 &lt;b&gt;무엇은 유지하고, 무엇만 정리할지&lt;/b&gt;를 같이 주는 편이 훨씬 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;span data-ui=&quot;term&quot; data-note=&quot;겉으로 보이는 기능은 그대로 두고, 코드를 읽기 쉽고 수정하기 편하게 다시 정리하는 작업입니다.&quot;&gt;리팩터링&lt;/span&gt;은 &quot;잘하는 사람만 하는 작업&quot;이 아닙니다. 오히려 지금처럼 작은 프로젝트일 때부터 조금씩 해보는 편이 훨씬 좋습니다. 구조가 작으니 무엇이 달라졌는지 따라가기 쉽고, 실수했을 때도 돌아가기 쉽기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 직접 해보면, 새 기능을 넣지 않아도 프로젝트가 충분히 더 좋아질 수 있다는 감각이 생깁니다. 이 감각이 붙으면 다음부터는 무조건 뭔가를 더 붙이기보다, 지금 있는 구조를 한 번 더 읽고 정리해보는 눈도 같이 생기기 시작합니다. 그 차이가 생각보다 큽니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>Git</category>
      <category>VSCode</category>
      <category>리팩터링</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>코딩독학</category>
      <category>코딩초보</category>
      <category>할일앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/31</guid>
      <comments>https://story86025.tistory.com/31#entry31comment</comments>
      <pubDate>Wed, 25 Mar 2026 12:40:07 +0900</pubDate>
    </item>
    <item>
      <title>[기초-2] 한 번에 다 시키지 마세요: 바이브 코딩에서 큰 요청을 잘게 나누는 법</title>
      <link>https://story86025.tistory.com/47</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;기초 2.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lvkLU/dJMcabpYlt3/BGQUxZWN9Z0wmA66yeiPG1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lvkLU/dJMcabpYlt3/BGQUxZWN9Z0wmA66yeiPG1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lvkLU/dJMcabpYlt3/BGQUxZWN9Z0wmA66yeiPG1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlvkLU%2FdJMcabpYlt3%2FBGQUxZWN9Z0wmA66yeiPG1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;기초 2.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 편에서는 좋은 요청의 기본 구조로 &lt;b&gt;목표&amp;middot;맥락&amp;middot;제약&amp;middot;출력 형식&lt;/b&gt;을 정리했습니다. 그런데 이 네 가지를 알고 있어도, 막상 AI에게 &lt;b&gt;&amp;ldquo;할 일 앱 하나 만들어줘&amp;rdquo;&lt;/b&gt;처럼 큰 요구를 한 번에 던지면 결과가 다시 흐려지기 시작합니다. 답변은 길어지고, 내가 지금 어디부터 확인해야 하는지도 애매해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 여기서 자주 오해하는 부분이 있습니다. 요청을 크게 던지면 오히려 빨리 끝날 것 같다는 생각입니다. 하지만 실제로는 그 반대인 경우가 많습니다. 한 번에 너무 많은 기능이 묶이면, 어떤 부분이 핵심이고 어떤 부분이 부가 기능인지 구분하기 어려워지고, 문제가 생겼을 때도 어디서부터 틀어졌는지 좁혀가기 힘들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 그 지점을 다뤄보겠습니다. 핵심은 복잡한 앱을 멋지게 설명하는 데 있지 않습니다. &lt;b&gt;큰 요구를 작은 작업 단위로 나눠서, AI가 이번 단계에서 무엇만 하면 되는지 분명하게 만드는 법&lt;/b&gt;을 익히는 데 있습니다. 예시는 할 일 앱으로 들겠지만, 실제로는 다른 웹페이지나 자동화 작업에도 거의 그대로 적용할 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 변화&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 달라지는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 지점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;답변이 짧아지고 또렷해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI가 이번 단계 범위만 보고 답하게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 단계 목표가 한 문장으로 분명한지 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제가 생겨도 원인을 좁히기 쉬워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어느 단계에서 틀어졌는지 확인 구간이 작아진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;방금 추가한 기능만 따로 테스트할 수 있는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 기능을 덜 흔들게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;지금 되는 건 유지하고 이번에는 이것만&amp;rdquo;이라는 조건을 붙이기 쉬워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇을 바꾸고 무엇은 건드리지 말지 같이 적었는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;작업을 잘게 나누는 건 일을 느리게 만드는 방법이 아니라, 어디서 틀렸는지 빨리 알기 위한 방법에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 큰 요청이 자꾸 꼬이는가&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;기초 예제이미지.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9G0Ij/dJMcaio8S5F/883P6kv8mphFyhkkxgQihK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9G0Ij/dJMcaio8S5F/883P6kv8mphFyhkkxgQihK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9G0Ij/dJMcaio8S5F/883P6kv8mphFyhkkxgQihK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9G0Ij%2FdJMcaio8S5F%2F883P6kv8mphFyhkkxgQihK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;기초 예제이미지.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 &lt;b&gt;&amp;ldquo;할 일 앱 만들어줘&amp;rdquo;&lt;/b&gt;라고만 하면 겉보기에는 간단해 보입니다. 하지만 실제로 그 안에는 생각보다 많은 결정이 숨어 있습니다. 화면은 어떻게 구성할지, 입력값 검사는 어떻게 할지, 완료 상태는 어디에 저장할지, 새로고침 뒤에도 남겨둘지, 검색과 필터는 함께 넣을지, 수정 기능은 어떻게 다룰지 같은 문제들이 한꺼번에 따라옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 초보자일수록 이 결정들이 서로 다른 종류의 작업이라는 점을 잘 구분하기 어렵다는 데 있습니다. 화면을 그리는 일과 데이터를 저장하는 일은 난이도도 다르고, 확인하는 방법도 다릅니다. 그런데 이걸 한 요청에 다 묶어버리면 AI는 빈칸을 스스로 메우기 시작합니다. 그 결과 답변은 그럴듯해 보여도, 지금 내 수준에서 바로 따라가기에는 지나치게 넓어질 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML 구조를 만드는 일&lt;/li&gt;
&lt;li&gt;버튼 클릭과 입력 처리를 연결하는 일&lt;/li&gt;
&lt;li&gt;현재 상태를 따로 기억하는 일&lt;/li&gt;
&lt;li&gt;필터나 검색처럼 추가 조건을 얹는 일&lt;/li&gt;
&lt;li&gt;브라우저 저장소나 서버와 연결하는 일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 다섯 가지는 같은 &amp;ldquo;앱 만들기&amp;rdquo; 안에 들어가지만, 실제로는 서로 다른 단계로 보는 편이 훨씬 낫습니다. 초보자가 한 번에 큰 요청을 던졌을 때 자주 막히는 이유도 여기에 있습니다. 답변이 길어서가 아니라, &lt;b&gt;서로 다른 종류의 작업이 한 덩어리로 들어왔기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해하기 쉬운 지점&lt;/b&gt;&lt;br /&gt;한 번에 많이 시킨다고 해서 항상 빨라지지는 않습니다. 오히려 수정 범위가 커지고, 문제 지점을 좁히기 어려워져서 다시 돌아가는 시간이 더 길어질 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;입니다. 예를 들어 &amp;ldquo;기본 화면만 먼저 만들기&amp;rdquo;라고 요청하면, 이번 단계에서 볼 것은 화면 구조와 배치 정도입니다. 그다음 &amp;ldquo;할 일 추가 기능만 붙이기&amp;rdquo;라고 나누면, 이번에는 입력값 처리와 목록 렌더링만 보면 됩니다. 한 단계에 한 종류의 고민만 남기게 되는 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 좋은 점이 꽤 많습니다. 먼저, 결과를 읽는 부담이 줄어듭니다. 어떤 코드를 왜 넣었는지 따라가기 쉬워지고, 내가 바로 확인할 수 있는 범위도 선명해집니다. 둘째, 문제가 생겨도 마지막으로 건드린 구간이 좁기 때문에 디버깅이 훨씬 수월합니다. 셋째, 이미 잘 돌아가는 부분을 유지한 채 다음 단계만 얹는 흐름을 만들 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;답변이 선명해진다&lt;/b&gt;&lt;br /&gt;이번 단계에서 무엇이 핵심인지 분명해진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정 요청이 쉬워진다&lt;/b&gt;&lt;br /&gt;&amp;ldquo;여기만 고쳐 달라&amp;rdquo;는 말이 실제로 가능해진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기존 기능을 지키기 쉬워진다&lt;/b&gt;&lt;br /&gt;잘 되는 부분을 계속 갈아엎지 않아도 된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;초보자 기준으로 학습 흐름이 좋아진다&lt;/b&gt;&lt;br /&gt;보이는 변화와 내부 동작을 단계별로 연결해볼 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 바이브 코딩에서는 이 흐름이 중요합니다. AI는 말을 많이 해줄 수 있지만, 이해는 사용자가 해야 합니다. 그래서 한 번에 많은 기능을 받기보다, &lt;b&gt;작은 성공을 여러 번 확인하는 방식&lt;/b&gt;이 더 오래 갑니다. 실제로는 그쪽이 덜 지치고, 나중에 고칠 때도 훨씬 편합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 먼저 나눠야 할 기준 4가지&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;b&gt;저장&lt;/b&gt;입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;초보자가 먼저 나눠보면 좋은 4가지 기준&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;기준&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 뜻하나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;할 일 앱 예시&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;먼저 확인할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;화면&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무엇이 어디에 보일지 정하는 단계&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력창, 추가 버튼, 목록 영역, 안내 문구 배치&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;브라우저에서 구조가 읽기 쉽게 보이는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;동작&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼이나 입력이 실제 기능으로 연결되는 단계&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;할 일 추가, 완료 체크, 삭제&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;버튼을 눌렀을 때 기대한 변화가 바로 보이는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;상태&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금 어떤 조건으로 화면을 보여주는지 기억하는 단계&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 필터, 검색어, 수정 중인 항목&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;무슨 값을 따로 들고 있고 언제 다시 그리는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&lt;b&gt;저장&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;새로고침 뒤에도 데이터를 남기거나 외부와 연결하는 단계&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;localStorage 저장, 서버 API 연결&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;화면 &amp;rarr; 기본 동작 &amp;rarr; 추가 상태 &amp;rarr; 저장&lt;/b&gt; 순서가 가장 안정적입니다. 반대로 저장을 너무 일찍 붙이면, 지금 막힌 게 화면 문제인지, 자바스크립트 문제인지, 저장소 문제인지 한꺼번에 섞일 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;초보자에게는 기능을 많이 붙이는 순서보다, 어떤 종류의 문제를 먼저 다룰지 정하는 순서가 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;할 일 앱 예시로 보는 단계 분해&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Roadmap_with_five_202603212227.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QikMa/dJMcaiCD3xr/KL9mxO9Cud22Ah2sH3OVBK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QikMa/dJMcaiCD3xr/KL9mxO9Cud22Ah2sH3OVBK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QikMa/dJMcaiCD3xr/KL9mxO9Cud22Ah2sH3OVBK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQikMa%2FdJMcaiCD3xr%2FKL9mxO9Cud22Ah2sH3OVBK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Roadmap_with_five_202603212227.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;이제 같은 할 일 앱을 실제로 어떻게 나눌 수 있는지 보겠습니다. 중요한 건 단계 수가 정답이라는 뜻이 아니라, &lt;b&gt;한 단계에 한 가지 성격의 작업만 남기려는 감각&lt;/b&gt;입니다. 아래 정도로 나누면 입문자도 따라가기 훨씬 편합니다.&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;1단계. 기본 화면만 만든다 &lt;/b&gt;입력창, 추가 버튼, 목록 영역, 안내 문구가 보이게 합니다. 아직 아무 기능도 연결하지 않아도 됩니다. 이 단계의 목표는 &amp;ldquo;브라우저에서 구조가 보이는가&amp;rdquo;입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2단계. 할 일 추가 기능만 붙인다 &lt;/b&gt;입력한 텍스트가 목록에 추가되게 만듭니다. 빈 입력값은 막고, 추가 후 입력창을 비우는 정도까지 보면 충분합니다. 아직 삭제나 완료는 넣지 않아도 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3단계. 완료 체크와 삭제를 붙인다 &lt;/b&gt;이제 목록 안의 각 항목에 체크박스와 삭제 버튼을 넣습니다. 여기서부터는 &amp;ldquo;항목 하나의 상태가 바뀐다&amp;rdquo;는 감각이 생깁니다. 그래도 검색이나 필터는 아직 안 들어가도 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;4단계. 필터 같은 상태 기능을 추가한다 &lt;/b&gt;전체, 진행 중, 완료 같은 보기 조건을 붙입니다. 이 단계는 단순 동작보다 &lt;b&gt;현재 어떤 상태로 목록을 보고 있는가&lt;/b&gt;를 다루는 연습에 가깝습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;5단계. 저장 기능을 마지막에 붙인다 &lt;/b&gt;localStorage처럼 새로고침 뒤에도 남는 기능은 앞 단계들이 안정된 뒤에 넣는 편이 좋습니다. 저장을 너무 이르게 붙이면, 문제가 생겼을 때 원인을 좁히기 어려워집니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 것은 2단계와 3단계를 굳이 합치지 않는 이유입니다. &amp;ldquo;추가, 완료, 삭제까지 한 번에&amp;rdquo;도 가능은 합니다. 하지만 초보자에게는 입력값 처리와 항목별 조작이 서로 다른 감각으로 다가옵니다. 그래서 눈에 보이는 변화가 분명한 작은 단계로 나누는 편이 더 배우기 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마찬가지로 검색, 수정, 순서 변경 같은 기능도 각각 따로 떼는 편이 낫습니다. 기능 수가 많아서가 아니라, 그 안에서 새로 등장하는 상태와 예외 처리가 다르기 때문입니다. 즉, &lt;b&gt;같은 &amp;ldquo;기능 추가&amp;rdquo;처럼 보여도 내부에서 다루는 문제가 다르면 단계도 나누는 게 맞다&lt;/b&gt;고 보는 편이 좋습니다.&lt;/p&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;체크리스트_이미지_AI_202603212229.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SpGS4/dJMb996NCCG/bYmP1k2y1JY4h3zQq4pGek/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SpGS4/dJMb996NCCG/bYmP1k2y1JY4h3zQq4pGek/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SpGS4/dJMb996NCCG/bYmP1k2y1JY4h3zQq4pGek/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSpGS4%2FdJMb996NCCG%2FbYmP1k2y1JY4h3zQq4pGek%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;체크리스트_이미지_AI_202603212229.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&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;h3 data-ke-size=&quot;size23&quot;&gt;1. 전체 계획부터 먼저 뽑아달라고 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 구현으로 들어가기 전에, 먼저 단계를 나눠달라고 요청하는 방식입니다. 초보자에게는 이 단계가 특히 유용합니다. 내가 무엇부터 해야 할지 감을 잡는 데 도움이 되기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;너는 코딩 초보에게 설명하는 웹 개발 튜터다.

할 일 앱을 HTML, CSS, JavaScript만으로 만들고 싶다.
앱을 한 번에 완성하지 말고,
초보자가 따라가기 쉬운 5단계로 나눠서 계획을 제안해줘.

각 단계마다 아래 형식으로 정리해줘.

이번 단계 목표

바뀌는 파일

완료 기준

아직 넣지 않을 것

다음 단계로 넘어가기 전 확인할 점&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 지금 단계만 구현해달라고 요청하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계획이 잡혔다면, 그다음에는 현재 단계만 좁혀서 요청하면 됩니다. 이때는 &amp;ldquo;무엇을 하지 말아야 하는가&amp;rdquo;를 같이 적는 편이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;지금은 1단계만 진행하자.

할 일 앱의 기본 화면만 만들어줘.
입력창, 추가 버튼, 목록 영역, 안내 문구가 보이면 된다.

아직 JavaScript 기능 연결은 하지 말고,
localStorage, 검색, 필터, 수정 기능도 넣지 말아줘.

답변은 아래 순서로 정리해줘.

바뀌는 파일 설명

전체 코드

직접 확인할 체크리스트&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 다음 단계로 이어갈 때는 현재 상태를 짧게 요약하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 많이 놓치는 부분이 여기입니다. 다음 단계로 넘어갈 때는, 방금까지 어디까지 되었는지 짧게 정리해주는 편이 좋습니다. AI는 내 화면을 보고 있는 것이 아니기 때문에, 현재 상태를 한두 문단으로 다시 넘겨주는 것이 중요합니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;지금까지는

기본 화면이 보이는 상태이고

입력창과 추가 버튼도 이미 있다.

이번에는 할 일 추가 기능만 연결해줘.
입력값이 비어 있으면 추가되지 않게 해줘.

기존 HTML 구조는 최대한 유지하고,
이번 답변에서는 삭제, 완료 체크, 검색, 필터는 넣지 말아줘.

기존 코드 전체를 처음부터 다시 쓰기보다
바뀌는 부분 위주로 설명해줘.
마지막에는 내가 직접 눌러볼 테스트 순서도 적어줘.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 단계가 끝난 뒤에는 검토도 따로 부탁하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 요청과 검토 요청을 한 번에 다 하지 말고, 구현이 끝난 뒤 검토를 따로 한 번 더 돌리는 것도 좋습니다. 특히 입문자에게는 &amp;ldquo;이번 목표 밖의 변경이 들어갔는지&amp;rdquo;를 다시 확인하는 과정이 꽤 도움이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 14px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto; line-height: 1.7; white-space: pre-wrap;&quot;&gt;&lt;code&gt;방금 구현한 결과를 기준으로 다시 검토해줘.

이번 단계 목표 밖의 변경이 들어갔는지

기존 기능을 깨뜨릴 만한 부분이 있는지

초보자가 복붙했을 때 바로 막힐 수 있는 부분이 있는지

위 세 가지를 체크리스트로 정리해줘.&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;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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 style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;작업 분해를 할 때 자주 하는 실수&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 꼬이는가&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 바꾸면 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 단계에 기능을 너무 많이 묶는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;문제가 생겼을 때 어디서 깨졌는지 다시 넓어진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;추가, 삭제, 필터, 저장을 한 번에 넣지 말고 기능 성격별로 나눈다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 상태를 설명하지 않고 바로 다음 단계로 넘어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;AI가 이미 구현된 구조를 추정하게 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;지금은 어디까지 됐다&amp;rdquo;를 짧게라도 먼저 써준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기존 기능 유지 조건을 빼먹는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잘 되던 부분까지 통째로 다시 바뀔 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;기존 구조는 유지하고 이번에는 이것만&amp;rdquo;을 같이 적는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;단계가 끝났는데 테스트 없이 넘어간다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;작은 오류가 다음 단계에서 더 크게 번질 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;/li&gt;
&lt;li&gt;이번 단계에서 바뀌는 파일이 무엇인지 알고 있는가&lt;/li&gt;
&lt;li&gt;아직 넣지 않을 기능을 적어두었는가&lt;/li&gt;
&lt;li&gt;기존에 되던 기능은 유지하라고 명시했는가&lt;/li&gt;
&lt;li&gt;구현 뒤에 직접 눌러볼 테스트 3가지를 정했는가&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 정리&lt;/b&gt;&lt;br /&gt;작업 분해의 목적은 요청을 잘게 자르는 데서 끝나지 않습니다. 각 단계마다 무엇이 성공인지, 무엇은 아직 하지 않을지 같이 정해두는 데 의미가 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&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;목표&amp;middot;맥락&amp;middot;제약&amp;middot;출력 형식&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;이 더 중요합니다. 그렇게 해야 AI가 준 답을 그냥 복붙하는 수준을 넘어서, 왜 이렇게 나눴는지까지 조금씩 감이 잡히기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편에서는 여기서 자연스럽게 이어서, &lt;b&gt;현재 코드&amp;middot;에러 메시지&amp;middot;화면 상태를 AI에게 어떻게 전달해야 답이 더 정확해지는지&lt;/b&gt;를 다루면 흐름이 좋습니다. 작업을 잘게 나누는 법을 익혔다면, 이제는 그 작업의 현재 상태를 덜 헷갈리게 넘기는 법도 같이 알아둘 차례입니다&lt;/p&gt;
&lt;/footer&gt;</description>
      <category>바이브코딩/기초</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/47</guid>
      <comments>https://story86025.tistory.com/47#entry47comment</comments>
      <pubDate>Tue, 24 Mar 2026 18:29:24 +0900</pubDate>
    </item>
    <item>
      <title>스킬보다 먼저 봐야 할 Codex 서브에이전트: 멀티 에이전트 입문 가이드</title>
      <link>https://story86025.tistory.com/73</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Infographic_about_Codex_202603241526.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dzO4AY/dJMcaiJqlS8/FJFmfzRPpg66NMREAGkLV1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dzO4AY/dJMcaiJqlS8/FJFmfzRPpg66NMREAGkLV1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dzO4AY/dJMcaiJqlS8/FJFmfzRPpg66NMREAGkLV1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdzO4AY%2FdJMcaiJqlS8%2FFJFmfzRPpg66NMREAGkLV1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;Infographic_about_Codex_202603241526.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 초보자가 Codex의 &lt;span data-ui=&quot;term&quot; data-note=&quot;반복 작업을 안정적으로 수행하게 해주는 재사용 가능한 작업 설명서입니다. 다음 글에서 자세히 다룰 내용입니다.&quot;&gt;skill&lt;/span&gt;을 먼저 보면서 &amp;ldquo;이게 그냥 프롬프트 저장이랑 뭐가 다르지?&amp;rdquo;에서 막히곤 합니다. 그런데 사실 그 전에 봐야 할 건, Codex가 일을 &lt;b&gt;혼자&lt;/b&gt; 처리하는지, 아니면 &lt;span data-ui=&quot;term&quot; data-note=&quot;메인 에이전트가 여러 보조 에이전트에 일을 나눠 병렬로 진행하는 방식입니다.&quot;&gt;멀티 에이전트&lt;/span&gt;처럼 역할을 나눠 처리하는지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 공식 문서 기준으로 Codex는 &lt;span data-ui=&quot;term&quot; data-note=&quot;메인 에이전트가 특정 하위 작업만 맡기기 위해 새로 띄우는 보조 에이전트입니다.&quot;&gt;서브에이전트&lt;/span&gt;를 병렬로 띄워서 각각 탐색&amp;middot;분석&amp;middot;구현 같은 일을 나눠 맡길 수 있고, 그 결과를 다시 한 응답으로 합쳐 보여줄 수 있습니다. 또 이 흐름은 현재 기본 활성화되어 있으며, 서브에이전트 활동은 CLI와 Codex 앱에서 먼저 잘 드러나고, IDE 쪽 표시는 확장 중인 흐름으로 안내되고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하면 이런 느낌입니다. 한 명에게 &amp;ldquo;코드도 읽고, 버그도 찾고, 문서도 확인하고, 수정안도 내고, 테스트 포인트도 정리해줘&amp;rdquo;를 한 번에 던지는 대신, 메인 에이전트가 팀장처럼 움직이면서 일을 나눠 맡기는 방식입니다. 그래서 skill을 이해하기 전에 서브에이전트를 먼저 보면, 나중에 &amp;ldquo;누가 일하느냐&amp;rdquo;와 &amp;ldquo;어떻게 일하느냐&amp;rdquo;를 훨씬 쉽게 분리해서 이해할 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;용어&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;초보자식 한 줄 설명&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;멀티 에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;일을 한 명이 다 하는 대신, 여러 에이전트가 나눠서 처리하는 방식&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;Codex를 &amp;ldquo;채팅창&amp;rdquo;이 아니라 &amp;ldquo;작업 팀&amp;rdquo;처럼 이해하게 해줍니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서브에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;메인 에이전트가 하위 작업을 맡기기 위해 따로 띄운 보조 작업자&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;PR 리뷰, 버그 분석, 문서 확인처럼 역할 분리를 이해하는 출발점입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;커스텀 에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;내가 직접 역할과 규칙을 정해 만든 전용 에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;&amp;ldquo;리뷰 담당&amp;rdquo;, &amp;ldquo;문서 확인 담당&amp;rdquo;처럼 일을 분업하기 쉬워집니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;skill&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;특정 작업을 잘 수행하게 해주는 전용 작업 매뉴얼&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 글에서 다룰 내용이고, 서브에이전트와 역할이 다릅니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;서브에이전트는 &lt;b&gt;누가 일을 나눠서 하는가&lt;/b&gt;에 관한 개념이고, skill은 &lt;b&gt;그 일을 어떤 방식으로 하게 만들 것인가&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 skill보다 먼저 서브에이전트를 이해하는 게 좋은가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;skill부터 보면 자칫 이렇게 받아들이기 쉽습니다. &amp;ldquo;아, Codex에게 지시문을 더 잘 먹게 만드는 기능이구나.&amp;rdquo; 이 말이 완전히 틀린 건 아니지만, 조금 평면적입니다. 공식 문서 기준으로 skill은 &lt;b&gt;instructions, resources, optional scripts&lt;/b&gt;를 묶어 특정 작업을 안정적으로 수행하게 만드는 구성이고, 반대로 서브에이전트는 역할이 다른 에이전트를 병렬로 돌려 일 자체를 분담하는 쪽에 가깝습니다. 즉, skill은 매뉴얼이고, 서브에이전트는 작업자 쪽에 더 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이를 먼저 잡아두면, 뒤에서 skill을 볼 때도 훨씬 덜 꼬입니다. 예를 들어 &amp;ldquo;리뷰 담당 서브에이전트&amp;rdquo;와 &amp;ldquo;리뷰 체크리스트 skill&amp;rdquo;은 이름이 비슷해 보여도 역할이 다릅니다. 전자는 &lt;b&gt;누가 리뷰하느냐&lt;/b&gt;이고, 후자는 &lt;b&gt;리뷰를 어떤 기준으로 하느냐&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자에게 이 구분이 중요한 이유는 간단합니다. 처음부터 모든 걸 한 덩어리로 이해하려 하면, Codex가 똑똑한 채팅창인지, 자동화 도구인지, 작은 팀인지 감이 흐려지기 쉽기 때문입니다. 서브에이전트를 먼저 이해하면 Codex를 &amp;ldquo;역할을 나눠 일하는 작업 환경&amp;rdquo;으로 보게 되고, 그다음 skill이 왜 필요한지도 자연스럽게 이어집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex 멀티 에이전트는 정확히 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 개념 문서 기준으로 Codex의 서브에이전트 워크플로는, 여러 특화 에이전트를 병렬로 띄워 탐색&amp;middot;분석&amp;middot;검증 같은 일을 동시에 진행한 뒤 메인 흐름으로 요약 결과를 되돌리는 방식입니다. 여기서 핵심은 &amp;ldquo;여러 명이 동시에 일한다&amp;rdquo;는 것보다, &lt;b&gt;메인 대화를 지저분하게 만들지 않는다&lt;/b&gt;는 데 있습니다. 공식 문서도 중간 탐색 로그, 테스트 로그, 스택 트레이스가 메인 대화에 계속 섞이면 &lt;span data-ui=&quot;term&quot; data-note=&quot;중간 로그와 탐색 흔적이 메인 대화에 너무 많이 섞여 중요한 정보가 묻히는 상태입니다.&quot;&gt;컨텍스트 오염&lt;/span&gt;이나 &lt;span data-ui=&quot;term&quot; data-note=&quot;대화가 길어지며 덜 중요한 정보까지 쌓여 응답 품질이 떨어지는 현상을 가리키는 표현입니다.&quot;&gt;컨텍스트 로트&lt;/span&gt;가 생길 수 있다고 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 비유하면, 책상 하나에 모든 서류를 다 펼쳐놓고 일하는 대신, 사람마다 맡은 자료만 들고 따로 검토한 뒤 핵심만 보고받는 구조에 가깝습니다. 그래서 멀티 에이전트의 장점은 단순히 &amp;ldquo;병렬이라 빠를 수 있다&amp;rdquo;에서 끝나지 않습니다. &lt;b&gt;중간 잡음을 메인 흐름 밖으로 빼는 것&lt;/b&gt; 자체가 꽤 큰 장점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서가 쓰는 핵심 용어를 초보자 말로 옮기면 이렇습니다. &lt;span data-ui=&quot;term&quot; data-note=&quot;에이전트가 하나의 하위 작업을 맡아 처리하는 전체 흐름입니다.&quot;&gt;서브에이전트 워크플로&lt;/span&gt;는 일을 나눠 돌리는 전체 구조이고, &lt;span data-ui=&quot;term&quot; data-note=&quot;메인 에이전트가 맡긴 특정 작업만 처리하는 보조 에이전트입니다.&quot;&gt;서브에이전트&lt;/span&gt;는 그 안에서 실제 하위 작업을 맡는 개별 작업자입니다. 또 &lt;span data-ui=&quot;term&quot; data-note=&quot;에이전트 하나가 작업하는 독립된 대화 흐름입니다. CLI에서는 /agent로 오갈 수 있습니다.&quot;&gt;에이전트 스레드&lt;/span&gt;는 각 에이전트가 따로 움직이는 작업 줄기라고 이해하면 됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;한 명에게 다 시키는 경우와 서브에이전트로 나누는 경우&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이럴 때 괜찮다&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;주의할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;한 에이전트로 처리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;작업 범위가 작고, 파일 몇 개 안에서 끝나는 수정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;탐색&amp;middot;검증&amp;middot;수정이 한 대화에 몰리면 흐름이 탁해질 수 있습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서브에이전트로 분업&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;리뷰 포인트가 여러 개이거나, 코드&amp;middot;브라우저&amp;middot;문서를 같이 봐야 하는 작업&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;각 서브에이전트가 따로 모델&amp;middot;도구 작업을 하므로 토큰과 시간이 더 들 수 있습니다&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex 안에서 실제로는 어떻게 움직이나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자가 가장 오해하기 쉬운 지점이 여기입니다. &amp;ldquo;Codex가 알아서 똑똑하게 여러 에이전트를 띄워주겠지?&amp;rdquo;라고 생각하기 쉬운데, 공식 문서 기준으로 Codex는 서브에이전트를 &lt;b&gt;자동으로 막 생성하지 않습니다.&lt;/b&gt; 사용자가 분명하게 요청했을 때만 새 에이전트를 띄우는 쪽입니다. 다시 말해, 그냥 &amp;ldquo;검토해줘&amp;rdquo;라고 쓰는 것보다 &amp;ldquo;보안, 버그, 테스트 관점으로 한 항목당 한 서브에이전트를 나눠 병렬 검토해줘&amp;rdquo;처럼 적는 편이 훨씬 명확합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 에이전트는 이 과정에서 조율자처럼 움직입니다. 공식 문서 설명대로라면 새로운 서브에이전트를 띄우고, 추가 지시를 전달하고, 결과를 기다리고, 끝난 스레드를 닫는 일까지 메인 흐름이 관리합니다. 그리고 여러 결과가 다 모이면 마지막에 한 번에 정리된 응답으로 돌려줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CLI에서는 이 흐름을 보는 감이 비교적 직접적입니다. &lt;code&gt;/agent&lt;/code&gt; 명령으로 활성화된 &lt;span data-ui=&quot;term&quot; data-note=&quot;에이전트 하나가 작업하는 독립된 대화 흐름입니다. CLI에서는 /agent로 오갈 수 있습니다.&quot;&gt;에이전트 스레드&lt;/span&gt;를 오가며 확인할 수 있고, 진행 중인 서브에이전트를 멈추거나 추가 지시를 주는 것도 가능합니다. 반면 Codex 앱은 여러 프로젝트와 작업 스레드를 병렬로 다루는 데 초점이 맞춰져 있어서, 여러 에이전트를 한꺼번에 감독하는 감이 더 잘 살아납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 실전에서 중요한 점은 &lt;span data-ui=&quot;term&quot; data-note=&quot;에이전트가 파일 읽기&amp;middot;수정&amp;middot;명령 실행을 어디까지 할 수 있는지 제한하는 안전 장치입니다.&quot;&gt;샌드박스&lt;/span&gt;와 승인 정책입니다. 공식 문서에 따르면 서브에이전트는 현재 세션의 샌드박스 정책을 상속하고, CLI에서 상호작용 중일 때는 메인 스레드를 보고 있어도 다른 서브에이전트에서 승인 요청이 올라올 수 있습니다. 처음 보면 &amp;ldquo;왜 갑자기 승인창이 뜨지?&amp;rdquo; 싶을 수 있는데, 꼭 현재 보고 있는 창에서만 나온 요청은 아닐 수 있다는 뜻입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;초보자 주의 포인트&lt;/b&gt;&lt;br /&gt;서브에이전트는 &amp;ldquo;알아서 많이 띄우는 기능&amp;rdquo;이 아니라, &lt;b&gt;내가 분명하게 역할 분담을 요청했을 때 켜지는 기능&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자가 가장 쉽게 감을 잡을 수 있는 프롬프트는 이런 식입니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;현재 브랜치를 main과 비교해서 검토해줘. 
보안, 버그, 테스트 취약성, 유지보수성 항목마다 서브에이전트 1개씩 나눠 병렬로 확인하고,
모든 결과가 모이면 항목별 핵심만 요약해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프롬프트가 좋은 이유는 두 가지입니다. 하나는 &amp;ldquo;나눠라&amp;rdquo;가 명확하고, 다른 하나는 &amp;ldquo;마지막엔 요약만 달라&amp;rdquo;가 분명하다는 점입니다. 공식 예시도 비슷하게 항목별 검토를 각각의 에이전트로 분리해서 다시 합치는 방식을 보여줍니다.&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 PR을 여러 관점으로 동시에 리뷰할 때&lt;/li&gt;
&lt;li&gt;브라우저 재현, 코드 경로 추적, 실제 수정 작업을 나눠서 처리할 때&lt;/li&gt;
&lt;li&gt;문서 확인과 코드 검토를 분리해서 보고 싶을 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;b&gt;PR 리뷰&lt;/b&gt;는 서브에이전트와 꽤 잘 맞습니다. 보안, 코드 품질, 버그 가능성, 테스트 누락처럼 서로 다른 관점은 동시에 볼 수 있기 때문입니다. 공식 문서도 코드 경로를 먼저 훑는 역할, 실제 위험을 찾는 역할, 외부 문서를 확인하는 역할을 분리한 예시를 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 &lt;b&gt;UI 버그&lt;/b&gt;도 잘 맞습니다. 브라우저에서 실제로 재현하는 사람, 코드 경로를 좁히는 사람, 최소 수정으로 고치는 사람을 나눠 생각해보면 왜 그런지 바로 보입니다. 공식 예시에서도 &lt;span data-ui=&quot;term&quot; data-note=&quot;코드에서 실제로 문제가 이어지는 경로를 좁혀보는 탐색 작업입니다.&quot;&gt;코드 매핑&lt;/span&gt;, 브라우저 재현, 수정 담당을 분리하는 흐름을 보여줍니다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;설정 모달이 저장되지 않는 이유를 찾아줘. 
1) 한 서브에이전트는 브라우저에서 실제 재현 
2) 한 서브에이전트는 관련 코드 경로 추적 
3) 마지막 서브에이전트는 원인이 확인된 뒤 최소 수정안 제안 이 순서로 진행하고, 최종 답변에는 원인과 수정 후보만 깔끔하게 정리해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, 파일 한두 개만 보면 되는 작은 수정은 굳이 서브에이전트까지 갈 필요가 없는 경우가 많습니다. 그냥 메인 에이전트 하나로 빠르게 처리하는 편이 단순하고, 토큰도 덜 쓰고, 추적도 쉽습니다. 멀티 에이전트는 &amp;ldquo;항상 더 좋은 상위 기능&amp;rdquo;이 아니라, &lt;b&gt;일이 분리될 때 좋은 기능&lt;/b&gt;이라고 보는 편이 현실적입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원하면 직접 역할을 나눈 커스텀 에이전트도 만들 수 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 익숙해지면 여기서 한 단계 더 갈 수 있습니다. 공식 문서 기준으로 Codex에는 기본 제공 에이전트가 있고, 여기에 내가 직접 만든 &lt;span data-ui=&quot;term&quot; data-note=&quot;기본 에이전트와 별개로 역할을 정해 만든 사용자 정의 에이전트입니다.&quot;&gt;커스텀 에이전트&lt;/span&gt;를 추가할 수도 있습니다. 기본 제공 에이전트는 일반용 &lt;code&gt;default&lt;/code&gt;, 구현&amp;middot;수정 중심의 &lt;code&gt;worker&lt;/code&gt;, 탐색 중심의 &lt;code&gt;explorer&lt;/code&gt;가 안내되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커스텀 에이전트 파일은 개인용이면 &lt;code&gt;~/.codex/agents/&lt;/code&gt;, 프로젝트 전용이면 &lt;code&gt;.codex/agents/&lt;/code&gt; 아래에 두는 방식입니다. 그리고 각 파일에는 최소한 &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;developer_instructions&lt;/code&gt;가 들어가야 합니다. 나머지 모델, 샌드박스, &lt;span data-ui=&quot;term&quot; data-note=&quot;에이전트가 외부 문서 서버, 브라우저, 로그 도구 같은 외부 시스템과 연결될 때 쓰는 규격입니다.&quot;&gt;MCP&lt;/span&gt;, skills 설정 등은 필요할 때만 얹는 식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자라면 처음부터 거창하게 만들 필요는 없습니다. 아래 정도만 있어도 &amp;ldquo;리뷰만 하는 전용 작업자&amp;rdquo;라는 감은 충분히 잡힙니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;969&quot; data-origin-height=&quot;181&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAR4QA/dJMcahX5qX4/ndbc5cnJeCPUIaK9g72Rc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAR4QA/dJMcahX5qX4/ndbc5cnJeCPUIaK9g72Rc0/img.png&quot; data-alt=&quot;&amp;amp;lt; config.toml &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAR4QA/dJMcahX5qX4/ndbc5cnJeCPUIaK9g72Rc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAR4QA%2FdJMcahX5qX4%2Fndbc5cnJeCPUIaK9g72Rc0%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;969&quot; height=&quot;181&quot; data-origin-width=&quot;969&quot; data-origin-height=&quot;181&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; config.toml &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# .codex/config.toml 
[agents]
max_threads = 4 
max_depth = 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;475&quot; data-origin-height=&quot;222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VX5SK/dJMcabjiwdS/OEg4lG9nNkKHZtbctKk5j1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VX5SK/dJMcabjiwdS/OEg4lG9nNkKHZtbctKk5j1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VX5SK/dJMcabjiwdS/OEg4lG9nNkKHZtbctKk5j1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVX5SK%2FdJMcabjiwdS%2FOEg4lG9nNkKHZtbctKk5j1%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;475&quot; height=&quot;222&quot; data-origin-width=&quot;475&quot; data-origin-height=&quot;222&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;321&quot; data-origin-height=&quot;603&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAgIaU/dJMcabp355K/lUge3IJdreJqTniaiiCCok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAgIaU/dJMcabp355K/lUge3IJdreJqTniaiiCCok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAgIaU/dJMcabp355K/lUge3IJdreJqTniaiiCCok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAgIaU%2FdJMcabp355K%2FlUge3IJdreJqTniaiiCCok%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;321&quot; height=&quot;603&quot; data-origin-width=&quot;321&quot; data-origin-height=&quot;603&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# .codex/agents/reviewer.toml name = &quot;reviewer&quot; 
description = &quot;현재 브랜치의 실제 위험을 찾는 리뷰 전용 에이전트&quot;
developer_instructions =&quot;스타일 취향보다 버그, 보안, 테스트 누락을 먼저 본다. 가능하면 근거가 되는 파일과 재현 힌트를 함께 적는다.&quot;
model = &quot;gpt-5.4&quot;
model_reasoning_effort = &quot;high&quot;
sandbox_mode = &quot;read-only&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;max_depth = 1&lt;/code&gt;은 특히 초보자에게 중요합니다. 공식 문서도 기본값이 1이고, 이 값은 직계 하위 에이전트까지만 허용합니다. 괜히 이 숫자를 올리면 에이전트가 또 다른 에이전트를 낳는 식으로 fan-out이 커질 수 있고, 그만큼 토큰 사용량, 지연 시간, 로컬 자원 소비도 커질 수 있다고 안내합니다. 처음에는 기본값을 그대로 두는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아주 사소하지만 재밌는 팁도 하나 있습니다. 공식 문서에는 &lt;code&gt;nickname_candidates&lt;/code&gt;를 넣어 같은 종류의 에이전트가 많이 뜰 때 더 읽기 쉬운 표시 이름을 줄 수 있다고 나옵니다. 꼭 필요한 기능은 아니지만, 여러 리뷰 에이전트를 돌릴 때 화면이 덜 복잡하게 느껴질 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자 추천선&lt;/b&gt;&lt;br /&gt;처음에는 &lt;b&gt;역할 하나, 에이전트 하나&lt;/b&gt;만 만드세요. &amp;ldquo;리뷰용&amp;rdquo;, &amp;ldquo;문서 확인용&amp;rdquo; 정도면 충분합니다. 한 파일에 욕심을 많이 넣기 시작하면 오히려 왜 그 에이전트를 만든 건지 흐려집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서브에이전트와 skill은 어떻게 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다음 글과 연결되는 핵심입니다. 공식 문서 기준으로 skill은 특정 작업을 안정적으로 수행하게 해주는 구성 묶음이고, &lt;code&gt;SKILL.md&lt;/code&gt;를 중심으로 instructions와 자료, 선택적인 스크립트를 담습니다. Codex는 skill의 이름과 설명 같은 메타데이터를 먼저 보고, 필요할 때만 본문을 읽는 방식으로 동작합니다. 반면 서브에이전트는 &amp;ldquo;어떤 역할의 작업자를 몇 명 둘 것인가&amp;rdquo;에 더 가깝습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;헷갈리기 쉬운 개념 정리&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구성 요소&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 정하나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;쉬운 비유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;서브에이전트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;누가 어떤 역할로 일할지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;리뷰 담당, 탐색 담당, 수정 담당&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;skill&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그 역할이 어떤 절차로 일할지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;체크리스트, 작업 매뉴얼, 전용 레시피&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;MCP&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;외부 도구를 무엇에 연결할지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;브라우저, 문서 서버, 로그 도구 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 보면 skill을 먼저 안 다뤄도 되는 이유가 보입니다. 서브에이전트를 먼저 이해하면, skill은 그 위에 얹는 &lt;b&gt;행동 방식&lt;/b&gt;으로 훨씬 자연스럽게 들어옵니다. 다음 글에서 skill을 설명할 때도 &amp;ldquo;스킬이 에이전트를 대체한다&amp;rdquo;가 아니라 &amp;ldquo;에이전트가 더 일관되게 움직이게 만든다&amp;rdquo;는 식으로 이해하면 덜 헷갈립니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;/li&gt;
&lt;li&gt;탐색이나 리뷰 역할은 먼저 &lt;code&gt;read-only&lt;/code&gt;에 가깝게 두는 편이 안전합니다. 공식 예시도 탐색용&amp;middot;리뷰용 에이전트를 읽기 중심으로 두는 흐름이 많습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;병렬로 봐줘&amp;rdquo;만 쓰지 말고, &lt;b&gt;각 에이전트가 맡을 관점&lt;/b&gt;을 적어주는 편이 훨씬 낫습니다. 보안, 버그, 테스트, 문서 확인처럼 역할을 또렷하게 나눌수록 결과가 읽기 쉬워집니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;가벼운 서브에이전트에는 더 빠른 모델을 붙이는 선택지도 있습니다. 공식 모델 문서는 &lt;code&gt;gpt-5.4-mini&lt;/code&gt;를 반응성이 중요한 코딩 작업과 서브에이전트에 잘 맞는 빠른 모델로 소개합니다.&lt;/li&gt;
&lt;li&gt;CLI에서 승인 요청이 갑자기 뜨면, 지금 보고 있는 메인 스레드 말고 다른 서브에이전트 쪽 요청일 수도 있습니다. 당황하지 말고 어느 스레드에서 올라왔는지 먼저 확인하는 습관이 좋습니다.&lt;/li&gt;
&lt;li&gt;깊이부터 키우지 마세요. &lt;code&gt;max_depth&lt;/code&gt;를 높이면 &amp;ldquo;에이전트가 에이전트를 또 낳는&amp;rdquo; 구조가 생길 수 있고, 초보자에겐 추적이 금방 어려워집니다. 공식 문서도 기본값 유지 쪽을 권하는 흐름입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁 하나&lt;/b&gt;&lt;br /&gt;처음에는 &amp;ldquo;보안 1명, 버그 1명&amp;rdquo;처럼 &lt;b&gt;사람 나누듯&lt;/b&gt; 프롬프트를 써보세요. 멀티 에이전트는 개념을 추상적으로 이해하는 것보다, 역할을 말로 분리해보는 순간 훨씬 빨리 체감됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex의 서브에이전트를 이해한다는 건, 결국 Codex를 &amp;ldquo;대답 잘하는 한 명&amp;rdquo;으로 보는 시선에서 &amp;ldquo;역할을 나눠 일할 수 있는 작업 팀&amp;rdquo;으로 보는 시선으로 옮겨가는 일에 가깝습니다. 그래서 이 개념을 먼저 잡아두면, 뒤에서 skill을 볼 때도 훨씬 편해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. &lt;b&gt;서브에이전트는 역할 분담&lt;/b&gt;이고, &lt;b&gt;skill은 작업 방식&lt;/b&gt;입니다. 이 순서만 머리에 들어오면, Codex가 왜 점점 &amp;ldquo;프롬프트 도구&amp;rdquo;보다 &amp;ldquo;작업 환경&amp;rdquo;에 가까워지는지 감이 잡히기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 바로 그 다음 단계인 skill로 넘어가 보겠습니다. 이번 글에서 &amp;ldquo;누가 일하느냐&amp;rdquo;를 정리했다면, 다음에는 &amp;ldquo;그 일을 어떤 순서와 기준으로 하게 만들 것인가&amp;rdquo;를 보게 될 겁니다.&lt;/p&gt;</description>
      <category>AI에이전트/Chatgpt</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/73</guid>
      <comments>https://story86025.tistory.com/73#entry73comment</comments>
      <pubDate>Tue, 24 Mar 2026 15:27:40 +0900</pubDate>
    </item>
    <item>
      <title>13. 드롭은 됐는데 왜 한 칸씩 밀릴까: 바이브코딩에서 끼워 넣기와 index 보정</title>
      <link>https://story86025.tistory.com/25</link>
      <description>&lt;article&gt;&lt;header&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 드래그 앤 드롭의 첫 버전으로, &lt;b&gt;잡은 항목과 놓은 항목의 자리를 서로 바꾸는 방식&lt;/b&gt;을 다뤘습니다. 이 방식은 이해하기 쉽고 구현도 비교적 단순합니다. 하지만 실제로 써보면 금방 아쉬운 점이 보입니다. 내가 원하는 건 &amp;ldquo;이 항목을 저 항목 자리와 교환&amp;rdquo;하는 것이 아니라, &lt;b&gt;목록 사이에 자연스럽게 끼워 넣는 것&lt;/b&gt;인 경우가 더 많기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 A, B, C, D, E 순서가 있을 때 B를 E 쪽으로 옮기고 싶다고 해보겠습니다. 자리 바꾸기 방식이라면 B와 E가 교환됩니다. 결과는 A, E, C, D, B 같은 모양이 되기 쉽습니다. 그런데 사용자가 실제로 기대하는 건 보통 이런 게 아닙니다. 대부분은 &lt;b&gt;A, C, D, B, E&lt;/b&gt;처럼 B가 E 앞쪽으로 들어가고, 나머지가 한 칸씩 밀리는 결과를 더 자연스럽게 느낍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 여기서부터입니다. 자리 바꾸기는 두 항목만 보면 되지만, 끼워 넣기는 &lt;b&gt;기존 위치에서 한 번 빼고&lt;/b&gt;, &lt;b&gt;새 위치에 다시 넣는 과정&lt;/b&gt;이 필요합니다. 그러다 보니 index, 즉 현재 배열에서 몇 번째 자리에 있는지를 나타내는 값이 갑자기 중요해집니다. 그리고 바로 여기서 초보자가 많이 헷갈리는 &lt;b&gt;index 보정&lt;/b&gt;이 등장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 왜 끼워 넣기가 자리 바꾸기보다 더 까다로운지, index 보정이 정확히 무슨 뜻인지, 그리고 첫 버전에서는 어떤 규칙으로 구현하는 편이 가장 덜 흔들리는지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭은 되는데 결과가 예상보다 한 칸씩 어긋난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 위치에서 항목을 빼낸 뒤 target 위치가 달라졌는데 그대로 계산했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source index와 target index를 removal 이후 기준으로 다시 보는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자연스럽게 끼워 넣고 싶은데 계속 자리만 바뀐다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;swap 방식으로만 처리하고 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;교환과 삽입을 다른 문제로 구분해서 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서는 바뀌는데 저장 후 다시 열면 이상하다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간 배열만 바꿨고 order 값을 다시 정리하지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재배치 후 order를 다시 매기고 저장하는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;바로 아래 항목에 드롭했는데 아무 변화가 없어 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 규칙이 &amp;ldquo;대상 앞에 넣기&amp;rdquo;인데, 바로 다음 항목 앞은 사실 현재 위치와 같을 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서 before 규칙을 쓰는지, after 규칙까지 동시에 하려는지 먼저 구분한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;자리 바꾸기는 두 항목의 순서를 교환하는 문제이고, 끼워 넣기는 하나를 빼서 다른 위치에 다시 넣는 문제다. 그래서 index 보정이 필요해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;자리 바꾸기만으로는 왜 부족할까&lt;/li&gt;
&lt;li&gt;끼워 넣기는 무엇이 다른가&lt;/li&gt;
&lt;li&gt;index 보정은 왜 필요한가&lt;/li&gt;
&lt;li&gt;가장 단순한 구현 흐름: 꺼내고, 넣고, order 다시 매기기&lt;/li&gt;
&lt;li&gt;첫 버전에서 정해야 할 규칙&lt;/li&gt;
&lt;li&gt;초보자가 자주 하는 실수 6가지&lt;/li&gt;
&lt;li&gt;마무리&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자리 바꾸기만으로는 왜 부족할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자리 바꾸기 방식은 첫 단계로 아주 좋습니다. 드래그 이벤트가 무엇인지 이해하기 쉽고, dragged 항목과 target 항목의 관계도 명확하기 때문입니다. 두 항목의 order 값만 바꾸면 바로 결과가 눈에 보입니다. 디버깅도 비교적 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제 사용 흐름에서는 점점 이 방식이 거칠게 느껴집니다. 사용자는 보통 &amp;ldquo;저 항목 자리에 가라&amp;rdquo;보다 &amp;ldquo;저 항목 &lt;b&gt;앞이나 뒤에&lt;/b&gt; 들어가라&amp;rdquo;를 더 자연스럽게 기대하기 때문입니다. 즉, 사람은 순서를 바꿀 때 교환보다 &lt;b&gt;삽입&lt;/b&gt;에 더 가까운 동작을 머릿속에 그립니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;같은 드래그라도 기대 결과는 다를 수 있다&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;상황&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;자리 바꾸기 결과&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;사용자가 흔히 기대하는 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D, E에서 B를 E 쪽으로 옮김&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, E, C, D, B&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, D, B, E&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, B, C, D에서 D를 B 위로 옮김&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, D, C, B&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, D, B, C&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;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;자리 바꾸기는 &amp;ldquo;둘의 위치를 바꾼다&amp;rdquo;는 단순한 규칙이고, 끼워 넣기는 &amp;ldquo;하나를 빼고 나머지를 한 칸씩 밀면서 재배치한다&amp;rdquo;는 규칙이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;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;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 단계를 분리해서 이해하는 순간부터 흐름이 조금 더 선명해집니다. 예를 들어 아래처럼 생각해볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;원래 순서

A, B, C, D, E

B를 E 앞에 넣고 싶다

B를 원래 자리에서 뺀다
A, C, D, E

E 앞에 다시 넣는다
A, C, D, B, E&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 끼워 넣기는 눈으로 볼 때는 한 번의 마우스 동작처럼 보이지만, 데이터에서는 &lt;b&gt;remove + insert&lt;/b&gt;라는 두 단계로 설명됩니다. 그리고 հենց 이 사이에서 index 값이 달라지기 때문에 보정이 필요해집니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;자리 바꾸기와 끼워 넣기의 데이터 관점 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;데이터에서 실제로 하는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;초보자에게 느껴지는 난도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자리 바꾸기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;두 항목의 order 값만 교환한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;비교적 단순하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;끼워 넣기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;하나를 빼고, 그 결과 배열에서 다시 삽입한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;index 흐름을 함께 봐야 해서 더 까다롭다&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; 이 관점을 잡아두면 드롭 위치가 이상하게 한 칸 어긋날 때도, &amp;ldquo;이벤트가 이상한가?&amp;rdquo;보다 &amp;ldquo;빼낸 뒤의 배열 기준으로 다시 계산했나?&amp;rdquo;를 먼저 떠올릴 수 있습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;교환은 두 사람의 자리를 맞바꾸는 것이고, 삽입은 한 사람을 줄에서 빼서 다른 위치에 다시 세우는 것이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;index 보정은 왜 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 가장 많이 헷갈리는 부분으로 들어가겠습니다. &lt;b&gt;index&lt;/b&gt;는 배열에서 현재 몇 번째 위치인지를 나타내는 값입니다. 보통 0부터 시작합니다. 예를 들어 A, B, C, D, E가 있으면 A는 0, B는 1, C는 2, D는 3, E는 4라고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 항목 하나를 먼저 빼는 순간, 그 뒤에 있던 항목들의 index가 전부 한 칸씩 당겨진다는 점입니다. 바로 이 변화 때문에 &lt;b&gt;source index&lt;/b&gt;와 &lt;b&gt;target index&lt;/b&gt;를 그대로 믿으면 한 칸 어긋난 결과가 나오기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 B를 E 앞에 넣고 싶다고 해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;B를 E 앞에 넣을 때 무슨 일이 생기나&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;배열 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A(0), B(1), C(2), D(3), E(4)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source index는 1, target index는 4다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;B 제거 후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A(0), C(1), D(2), E(3)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원래 E의 index 4는 이제 3이 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삽입 시점&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, C, D, B, E&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source가 target보다 앞에 있었으므로 insert index를 한 칸 줄여야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, source index가 target index보다 작다면, 먼저 source를 제거하는 순간 target 위치가 왼쪽으로 한 칸 당겨집니다. 그래서 첫 버전의 &amp;ldquo;target 앞에 넣기&amp;rdquo; 규칙에서는 아래처럼 보는 편이 가장 이해하기 쉽습니다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;sourceIndex &amp;lt; targetIndex 이면

insertIndex = targetIndex - 1

sourceIndex &amp;gt; targetIndex 이면
insertIndex = targetIndex&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 바로 index 보정입니다. &amp;ldquo;원래 target 위치&amp;rdquo;를 그대로 믿지 말고, &lt;b&gt;source를 먼저 제거한 뒤 달라진 배열 기준으로 한 번 더 생각하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 D를 B 앞에 넣는 경우는 조금 다릅니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;D를 B 앞에 넣을 때는 왜 보정이 다를까&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;배열 상태&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;처음&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A(0), B(1), C(2), D(3), E(4)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source index는 3, target index는 1이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;D 제거 후&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A(0), B(1), C(2), E(3)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;target B는 여전히 1 위치다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삽입 시점&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;A, D, B, C, E&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source가 target보다 뒤에 있었으므로 insert index는 그대로 targetIndex다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 잡히면 &amp;ldquo;왜 한 칸 어긋났지?&amp;rdquo;라는 문제가 훨씬 덜 막막해집니다. 드래그 자체가 어려운 게 아니라, &lt;b&gt;배열에서 하나를 뺐을 때 나머지 index가 변한다는 사실&lt;/b&gt;을 놓친 경우가 대부분이기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;처음엔 이렇게 외워도 충분하다&lt;/b&gt;&lt;br /&gt;위에서 아래로 내릴 때는 target이 한 칸 당겨질 수 있고, 아래에서 위로 올릴 때는 보통 target 위치를 그대로 써도 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 단순한 구현 흐름: 꺼내고, 넣고, order 다시 매기기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 첫 버전에서 가장 무난한 구현 흐름을 정리해보겠습니다. 핵심은 복잡한 수학이 아니라, &lt;b&gt;정렬된 복사본 하나를 만들고&lt;/b&gt;, &lt;b&gt;source를 꺼낸 뒤&lt;/b&gt;, &lt;b&gt;보정된 위치에 다시 넣고&lt;/b&gt;, &lt;b&gt;order를 1부터 다시 매기는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 이유가 하나 있습니다. 자리 바꾸기에서는 두 항목의 order만 바꿔도 됐지만, 끼워 넣기에서는 중간 항목들이 함께 밀립니다. 그래서 order를 부분적으로만 바꾸려 하기보다, &lt;b&gt;새 순서대로 다시 1, 2, 3, 4처럼 재정렬하는 편&lt;/b&gt;이 훨씬 이해하기 쉽고 버그도 줄어듭니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function moveTodoBefore(draggedId, targetId) {

const ordered = [...todos].sort((a, b) =&amp;gt; a.order - b.order);

const sourceIndex = ordered.findIndex(todo =&amp;gt; todo.id === draggedId);
const targetIndex = ordered.findIndex(todo =&amp;gt; todo.id === targetId);

if (sourceIndex === -1 || targetIndex === -1) return;
if (sourceIndex === targetIndex) return;

const next = [...ordered];
const removed = next.splice(sourceIndex, 1)[0];

const insertIndex =
sourceIndex &amp;lt; targetIndex
? targetIndex - 1
: targetIndex;

next.splice(insertIndex, 0, removed);

todos = next.map((todo, index) =&amp;gt; {
return {
...todo,
order: index + 1
};
});

saveTodos();
renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 초보자 관점에서 다시 풀면 아래 순서입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 custom 순서대로 정렬된 목록을 하나 만든다.&lt;/li&gt;
&lt;li&gt;잡은 항목과 대상 항목이 각각 몇 번째인지 찾는다.&lt;/li&gt;
&lt;li&gt;잡은 항목을 먼저 빼낸다.&lt;/li&gt;
&lt;li&gt;위에서 아래로 내리는 경우에는 target index를 한 칸 줄인다.&lt;/li&gt;
&lt;li&gt;그 위치에 다시 넣는다.&lt;/li&gt;
&lt;li&gt;새 배열 순서대로 order를 1부터 다시 매긴다.&lt;/li&gt;
&lt;li&gt;저장하고 다시 그린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이 방식이 첫 버전에서 특히 좋은 이유&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;구성&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 도움이 되나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;복사본에서 작업&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;원본 흐름을 바로 망가뜨리지 않고 계산을 분리해서 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;remove 후 insert&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;끼워 넣기의 본질을 가장 정직하게 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;order 다시 매기기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;부분 수정보다 읽기 쉽고 저장도 안정적이다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 겉보기에 조금 투박할 수 있습니다. 하지만 초보자에게는 오히려 장점이 큽니다. &amp;ldquo;왜 이 항목이 여기로 갔는지&amp;rdquo;를 설명하기가 훨씬 쉽기 때문입니다. 처음부터 너무 영리한 계산을 쓰기보다, 배열을 꺼내고 다시 넣는 흐름을 눈으로 따라갈 수 있는 편이 낫습니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 구현에서 가장 안정적인 원칙&lt;/b&gt;&lt;br /&gt;삽입 재정렬은 부분적으로 몇 개 값만 만지기보다, 최종 배열을 만든 뒤 order를 처음부터 다시 붙이는 편이 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 버전에서 정해야 할 규칙&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끼워 넣기 방식은 생각보다 규칙이 중요합니다. 코드가 헷갈리는 이유 중 하나는, 개발자가 아직 &amp;ldquo;드롭이 정확히 무슨 뜻인지&amp;rdquo;를 정하지 않았는데 구현부터 시작하는 경우가 많기 때문입니다. 첫 버전에서는 일부러 규칙을 좁게 잡는 편이 낫습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 기준으로는 아래 정도가 가장 무난합니다.&lt;/p&gt;
&lt;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;br /&gt;처음에는 before 규칙 하나만 정하는 편이 단순합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;custom 모드에서만 허용한다.&lt;/b&gt;&lt;br /&gt;최신순, 오래된순 같은 자동 정렬 모드에서는 끼워 넣기 결과가 바로 보이지 않을 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 보기에서만 허용한다.&lt;/b&gt;&lt;br /&gt;검색이나 필터가 걸린 상태에서는 숨겨진 항목 때문에 결과 해석이 더 어려워집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;같은 항목 위에 드롭하면 아무 일도 하지 않는다.&lt;/b&gt;&lt;br /&gt;불필요한 저장을 막을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지는 미리 알고 가는 편이 좋습니다. &amp;ldquo;대상 앞에 넣기&amp;rdquo; 규칙을 쓰면, 어떤 경우에는 사용자가 움직였다고 느끼는데 결과가 눈에 거의 안 보일 수 있습니다. 예를 들어 B를 바로 다음 항목 C 앞에 놓는다면, 실제 결과는 그대로 B, C가 될 수 있습니다. 이건 버그라기보다 &lt;b&gt;규칙이 단순해서 생기는 자연스러운 결과&lt;/b&gt;입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 일부러 단순하게 두는 규칙&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;규칙&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 이렇게 두는가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;target 앞에 넣기만 지원&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before와 after를 동시에 처리하려 들면 조건이 갑자기 늘어난다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;custom 모드 한정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬이 order 결과를 덮어쓰는 혼란을 줄일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 보기 한정&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;숨겨진 항목까지 고려한 재배치는 한 단계 더 어렵다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;같은 항목 드롭은 무시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;의미 없는 변경과 저장을 줄일 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 버전이 조금 단순해 보여도 괜찮습니다. 드래그 앤 드롭에서는 &amp;ldquo;자연스러운 느낌&amp;rdquo;보다 먼저, &lt;b&gt;규칙이 분명한가&lt;/b&gt;가 더 중요합니다. 그래야 나중에 위쪽 절반은 앞에 넣고 아래쪽 절반은 뒤에 넣는 식으로 확장할 때도 어디서부터 바꿔야 할지 보입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 팁&lt;/b&gt;&lt;br /&gt;첫 버전에서는 규칙이 다소 투박해도 괜찮다. &amp;ldquo;무슨 뜻으로 드롭을 해석할 것인가&amp;rdquo;가 먼저 분명해야 다음 단계도 덜 흔들린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끼워 넣기 방식으로 넘어오면 자주 나오는 실수도 조금 달라집니다. 드래그 이벤트 자체보다, 제거와 삽입 사이의 배열 변화에서 흔들리는 경우가 많습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;swap 함수 그대로 재사용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기대한 것과 달리 항목이 서로 교환만 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삽입 문제를 교환 문제처럼 다뤘다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;remove 후 insert 흐름으로 바꿔서 생각한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source를 뺀 뒤에도 target index를 그대로 쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과가 한 칸 어긋난다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;index 보정이 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;source가 앞에 있었는지 뒤에 있었는지 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;DOM 순서만 바꾸고 order는 안 바꾼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그 순간엔 맞지만 다시 열면 원래대로다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 순서가 저장되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;재배치 후 order를 다시 매기고 저장한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;부분적으로 몇 개 order만 수정한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;몇 번 옮기다 보면 order 값이 이해하기 어려워진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간 항목 이동을 충분히 반영하지 못했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전에서는 전체 배열 기준으로 order를 다시 매기는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬 모드나 검색 상태에서도 그대로 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;결과가 예상과 다르게 보이거나 일부 목록만 움직인 것처럼 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면 계산 규칙과 실제 순서 변경이 섞였다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;custom 모드와 전체 보기에서 먼저 안정화한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;바로 다음 항목 위에 드롭했을 때 변화가 없으면 버그라고 생각한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;동작이 실패한 것처럼 느껴진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;before 규칙에서는 같은 결과가 나올 수 있다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 규칙을 먼저 분명히 정하고 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 마지막 실수는 실제로 꽤 자주 나옵니다. 드래그 앤 드롭은 눈에 보이는 행동이라서, 사용자는 항상 뭔가 극적인 변화가 있어야 한다고 기대하기 쉽습니다. 하지만 첫 버전의 &amp;ldquo;대상 앞에 넣기&amp;rdquo; 규칙에서는 어떤 드롭은 사실상 같은 결과가 될 수 있습니다. 이건 구현 실패라기보다 &lt;b&gt;규칙이 단순해서 생기는 자연스러운 제한&lt;/b&gt;입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;삽입 재정렬은 이벤트보다 배열 계산이 핵심이다. 드롭이 됐는데 결과가 이상하면, 대부분 drag 이벤트보다 index 보정과 order 재계산 쪽을 먼저 보는 편이 빠르다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭에서 자리 바꾸기에서 한 걸음 더 나아가 끼워 넣기를 이해하기 시작하면, 목록 앱의 구조도 한 단계 더 정교해집니다. 겉으로는 같은 드래그처럼 보여도, 실제로는 &amp;ldquo;교환&amp;rdquo;과 &amp;ldquo;삽입&amp;rdquo;이 전혀 다른 문제라는 점을 구분하기 시작했기 때문입니다. 이 구분이 생기면 드래그 결과가 왜 한 칸 어긋나는지, 왜 order를 다시 매겨야 하는지, 왜 첫 버전에서는 규칙을 일부러 단순하게 두는 편이 좋은지도 훨씬 선명해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. &lt;br /&gt;첫째, 끼워 넣기는 remove와 insert라는 두 단계로 봐야 한다는 점.&lt;br /&gt;둘째, source를 먼저 제거하면 target 위치가 바뀔 수 있기 때문에 index 보정이 필요하다는 점. &lt;br /&gt;셋째, 첫 버전에서는 target 앞에 넣기 같은 단순한 규칙을 먼저 안정화하고, 그다음에 더 자연스러운 동작으로 확장하는 편이 훨씬 낫다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 다음 단계가 보이기 시작합니다. 실제로 사용자가 기대하는 드래그 앤 드롭은 보통 &lt;b&gt;항목의 위쪽 절반에 놓으면 앞에, 아래쪽 절반에 놓으면 뒤에 들어가는 방식&lt;/b&gt;에 더 가깝습니다. 다음 글에서는 이때 왜 마우스 위치를 같이 봐야 하는지, 그리고 드롭 표시선을 어떻게 붙이면 훨씬 덜 헷갈리는지를 다룹니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>index보정</category>
      <category>reorder알고리즘</category>
      <category>splice사용법</category>
      <category>끼워넣기정렬</category>
      <category>드래그삽입</category>
      <category>배열재배치</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/25</guid>
      <comments>https://story86025.tistory.com/25#entry25comment</comments>
      <pubDate>Tue, 24 Mar 2026 09:00:48 +0900</pubDate>
    </item>
    <item>
      <title>바이브코딩 10편: 배포한 할 일 앱을 작게 개선해보기</title>
      <link>https://story86025.tistory.com/29</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;실습 10화.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k39Uj/dJMcaf6Z3Rz/4Kaw5UM3yIFezkMvSWT8D1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k39Uj/dJMcaf6Z3Rz/4Kaw5UM3yIFezkMvSWT8D1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k39Uj/dJMcaf6Z3Rz/4Kaw5UM3yIFezkMvSWT8D1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk39Uj%2FdJMcaf6Z3Rz%2F4Kaw5UM3yIFezkMvSWT8D1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;실습 10화.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포까지 한 번 끝내고 나면, 그다음에는 묘하게 손이 커지기 쉽습니다. 여기까지 왔으니 필터도 넣고, 카테고리도 넣고, 날짜도 붙이고 싶어지죠. 그런데 초보자일수록 이 시점에서는 오히려 반대로 가는 편이 좋습니다. &lt;b&gt;큰 기능 추가보다 작은 개선을 분명하게 붙이는 것&lt;/b&gt;이 훨씬 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 지금 단계에서 정말 필요한 건 &quot;뭔가 더 대단한 앱&quot;이 아니라, &lt;b&gt;이미 돌아가는 프로젝트를 안전하게 다듬는 경험&lt;/b&gt;이기 때문입니다. 배포까지 끝낸 프로젝트를 다시 열어보고, 불편한 지점을 하나씩 줄이고, 변경 전후를 비교하고, 다시 반영하는 흐름이 쌓여야 다음 프로젝트도 덜 흔들립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 지난 편에서 배포한 할 일 앱을 기준으로, 눈에 보이는 개선을 세 가지만 붙여보겠습니다. 범위는 일부러 작게 잡겠습니다. 그래야 &quot;작은 개선을 정확하게 넣는 감각&quot;이 남기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 편에서 바꾸는 것과 바꾸지 않는 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이번에 추가하는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이번에는 일부러 안 하는 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 이렇게 자르나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;남은 할 일 개수 표시&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;카테고리 분류&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;지금은 정보 하나를 더 잘 보여주는 연습이 먼저입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 삭제 버튼&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마감일, 정렬, 필터 확장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;기능을 많이 늘리기보다 작은 불편을 줄이는 편이 더 중요합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상태 문구 다듬기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;구조 전체 리팩터링&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 편은 기능을 갈아엎는 글이 아니라 사용감을 조금 더 낫게 만드는 글입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;배포 다음 단계에서 중요한 건 &quot;더 많은 기능&quot;보다 &lt;b&gt;지금 돌아가는 앱을 조금 더 읽기 쉽고, 누르기 쉽고, 실수하기 덜한 상태로 만드는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 배포 다음에는 크게 뜯지 말고 작게 고쳐야 하는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 만든 프로젝트를 배포까지 해봤다면, 이제부터는 &quot;만드는 단계&quot;와 &quot;다듬는 단계&quot;를 조금 구분해서 보는 편이 좋습니다. 초보자는 보통 기능을 더 넣는 쪽만 성취처럼 느끼기 쉬운데, 실제로는 &lt;b&gt;불편한 지점을 줄이고, 위험한 동작을 안전하게 바꾸고, 상태를 더 잘 보이게 만드는 일&lt;/b&gt;도 아주 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 지금까지 만든 할 일 앱은 기본 기능은 이미 있습니다. 추가, 완료 체크, 삭제, 새로고침 후 유지까지 되니까, 앱으로서 아주 기초적인 역할은 해내고 있는 셈입니다. 그런데 막상 직접 써보면 이런 아쉬움이 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금 남은 할 일이 몇 개인지 한눈에 안 보입니다.&lt;/li&gt;
&lt;li&gt;항목이 많아졌을 때 한 번에 비우는 동작이 없습니다.&lt;/li&gt;
&lt;li&gt;아무것도 없을 때와 뭔가 저장된 뒤의 안내 문구가 조금 더 분명했으면 좋겠습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 개선은 겉으로 보기엔 작아 보여도, 실제로는 꽤 좋은 연습입니다. 구조를 완전히 뒤집지 않으면서도 사용자 입장에서 느껴지는 차이를 만들 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;배포 다음 단계에서 중요한 건 &quot;더 큰 기능&quot;보다 &lt;b&gt;작은 불편을 정확하게 줄이는 경험&lt;/b&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 편에서 개선할 것 3가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 욕심내지 않고 딱 세 가지만 하겠습니다.&lt;/p&gt;
&lt;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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 세 기능을 묶어서 보는 방식입니다. 각각 따로 보면 사소해 보이지만, 합치면 앱의 사용감이 꽤 달라집니다. 그리고 이 정도 범위면 HTML, CSS, JavaScript가 각각 어떻게 연결되는지 다시 보기에도 좋습니다.&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTML 수정: 요약 영역과 전체 삭제 버튼 추가&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;UI_layout_design_202603212221.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/M08hs/dJMcafTt9NP/dWJUjFK0qGcwl2r9f3yfJk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/M08hs/dJMcafTt9NP/dWJUjFK0qGcwl2r9f3yfJk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/M08hs/dJMcafTt9NP/dWJUjFK0qGcwl2r9f3yfJk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FM08hs%2FdJMcafTt9NP%2FdWJUjFK0qGcwl2r9f3yfJk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1376&quot; height=&quot;768&quot; data-filename=&quot;UI_layout_design_202603212221.jpeg&quot; data-origin-width=&quot;1376&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 화면 구조부터 조금 손보겠습니다. 이번 수정에서 HTML은 크게 바뀌지 않습니다. 입력창과 목록은 그대로 두고, 그 사이에 &lt;b&gt;현재 상태를 보여주는 작은 툴바&lt;/b&gt;를 하나 더 넣는 정도면 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.html&lt;/b&gt;은 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta
    name=&quot;viewport&quot;
    content=&quot;width=device-width, initial-scale=1.0&quot;
  &amp;gt;
  &amp;lt;title&amp;gt;오늘 할 일 앱&amp;lt;/title&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&amp;gt;
  &amp;lt;script defer src=&quot;script.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class=&quot;app&quot;&amp;gt;
    &amp;lt;section class=&quot;panel&quot;&amp;gt;
      &amp;lt;p class=&quot;eyebrow&quot;&amp;gt;Mini Project Update&amp;lt;/p&amp;gt;
      &amp;lt;h1&amp;gt;오늘 할 일&amp;lt;/h1&amp;gt;
      &amp;lt;p class=&quot;description&quot;&amp;gt;
        배포한 앱을 크게 뜯지 않고, 작은 개선부터 붙여보는 단계입니다.
      &amp;lt;/p&amp;gt;

      &amp;lt;form id=&quot;todoForm&quot; class=&quot;todo-form&quot;&amp;gt;
        &amp;lt;label for=&quot;todoInput&quot;&amp;gt;할 일 입력&amp;lt;/label&amp;gt;
        &amp;lt;div class=&quot;input-row&quot;&amp;gt;
          &amp;lt;input
            id=&quot;todoInput&quot;
            type=&quot;text&quot;
            placeholder=&quot;예: 배포 버전 점검하기&quot;
          &amp;gt;
          &amp;lt;button type=&quot;submit&quot;&amp;gt;추가&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/form&amp;gt;

      &amp;lt;section class=&quot;toolbar&quot; aria-label=&quot;할 일 요약 및 관리&quot;&amp;gt;
        &amp;lt;p id=&quot;summaryText&quot; class=&quot;summary&quot;&amp;gt;남은 할 일 0개&amp;lt;/p&amp;gt;
        &amp;lt;button
          id=&quot;clearAllButton&quot;
          type=&quot;button&quot;
          class=&quot;secondary-button&quot;
        &amp;gt;
          전체 삭제
        &amp;lt;/button&amp;gt;
      &amp;lt;/section&amp;gt;

      &amp;lt;p id=&quot;message&quot; class=&quot;message&quot;&amp;gt;
        할 일을 입력하고 추가 버튼을 눌러보세요.
      &amp;lt;/p&amp;gt;

      &amp;lt;ul id=&quot;todoList&quot; class=&quot;todo-list&quot;&amp;gt;&amp;lt;/ul&amp;gt;
    &amp;lt;/section&amp;gt;
  &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 HTML 수정에서 핵심은 딱 두 군데입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;summaryText&lt;/b&gt; : 남은 할 일 개수를 보여줄 자리입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;clearAllButton&lt;/b&gt; : 전체 삭제를 실행할 버튼입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이번에는 새로운 입력창을 더 만드는 게 아니라, &lt;b&gt;현재 상태를 보여주는 정보와 관리 버튼을 추가&lt;/b&gt;하는 쪽으로 구조를 보강한 셈입니다. 이 정도 수정은 기존 앱을 크게 흔들지 않으면서도 꽤 실용적입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;HTML 단계에서는 새로운 기능을 다 넣으려 하기보다, &lt;b&gt;JavaScript가 나중에 바꿀 자리와 눌릴 버튼을 분명하게 추가하는 것&lt;/b&gt;이 더 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS 수정: 툴바와 상태 표현 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 CSS를 손보겠습니다. 이번 수정은 디자인을 새로 하는 작업이 아닙니다. 입력창과 목록 사이에 들어간 새 영역이 자연스럽게 보이도록 하고, 완료 상태와 버튼 상태가 더 잘 읽히게 만드는 정도면 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;style.css&lt;/b&gt;는 아래처럼 정리해두면 무난합니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Noto Sans KR&quot;, sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0 0 24px;
  line-height: 1.7;
  color: #374151;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input[type=&quot;text&quot;] {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  font: inherit;
}

button:not(.secondary-button):not(.delete-button) {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.summary {
  margin: 0;
  color: #111827;
  font-weight: 700;
  line-height: 1.6;
}

.secondary-button {
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

.secondary-button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.message {
  margin: 14px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.todo-list {
  list-style: none;
  margin: 24px 0 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

.delete-button {
  border-radius: 14px;
  padding: 14px 18px;
  font-weight: 700;
  cursor: pointer;
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CSS에서 봐야 할 포인트도 많지 않습니다.&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;toolbar&lt;/b&gt; : 남은 개수와 전체 삭제 버튼을 한 줄에 묶습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;summary&lt;/b&gt; : 상태 문구가 제목처럼 읽히도록 조금 더 또렷하게 보이게 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;secondary-button&lt;/b&gt; : 주 버튼과 성격이 다른 보조 버튼처럼 보이게 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;disabled 상태&lt;/b&gt; : 비활성 버튼이 눌릴 것처럼 보이지 않게 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 연결이 보이면 CSS도 갑자기 덜 뿌옇게 느껴집니다. 코드 양보다 &quot;어느 부분을 보기 좋게 만들려는지&quot;가 먼저 보이기 시작하기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 CSS는 &quot;더 예쁘게&quot;보다 &lt;b&gt;상태와 역할이 더 잘 읽히게&lt;/b&gt; 만드는 쪽에 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript 수정: 남은 개수, 전체 삭제 확인, 안내 문구 반영&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 핵심은 JavaScript입니다. 실제 개선이 체감되는 부분이 거의 여기서 일어나기 때문입니다. 다만 이번에도 원칙은 같습니다. 기존 흐름을 완전히 바꾸는 게 아니라, &lt;b&gt;지금 있던 배열과 렌더링 구조 위에 작은 기능을 더하는 방식&lt;/b&gt;으로 가겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 JavaScript에서 먼저 봐야 할 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이름&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;역할&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 중요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;updateSummary&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;남은 개수와 버튼 상태를 갱신합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이번 편의 가장 눈에 띄는 개선이 여기서 생깁니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;clearAllButton 이벤트&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;전체 삭제 전 확인을 받고 처리합니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실수로 한 번에 지우는 위험을 줄입니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;renderTodos(messageText)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;상황에 따라 문구를 다르게 보여줍니다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;script.js&lt;/b&gt;는 아래처럼 정리해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;const STORAGE_KEY = &quot;vibe-todos&quot;;

const todoForm = document.querySelector(&quot;#todoForm&quot;);
const todoInput = document.querySelector(&quot;#todoInput&quot;);
const todoList = document.querySelector(&quot;#todoList&quot;);
const message = document.querySelector(&quot;#message&quot;);
const summaryText = document.querySelector(&quot;#summaryText&quot;);
const clearAllButton = document.querySelector(&quot;#clearAllButton&quot;);

let todos = loadTodos();

function loadTodos() {
  try {
    const savedTodos = localStorage.getItem(STORAGE_KEY);

    return savedTodos ? JSON.parse(savedTodos) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function showMessage(text) {
  message.textContent = text;
}

function updateSummary() {
  const remainingCount = todos.filter(function (todo) {
    return !todo.done;
  }).length;

  summaryText.textContent = &quot;남은 할 일 &quot; + remainingCount + &quot;개&quot;;
  clearAllButton.disabled = todos.length === 0;
}

function createTodoItem(todo) {
  const item = document.createElement(&quot;li&quot;);
  const left = document.createElement(&quot;div&quot;);
  const checkbox = document.createElement(&quot;input&quot;);
  const text = document.createElement(&quot;span&quot;);
  const deleteButton = document.createElement(&quot;button&quot;);

  item.className = &quot;todo-item&quot;;
  left.className = &quot;todo-left&quot;;

  if (todo.done) {
    item.classList.add(&quot;done&quot;);
  }

  checkbox.type = &quot;checkbox&quot;;
  checkbox.checked = todo.done;

  checkbox.addEventListener(&quot;change&quot;, function () {
    todo.done = checkbox.checked;
    saveTodos();
    renderTodos(&quot;할 일 상태를 업데이트했습니다.&quot;);
  });

  text.textContent = todo.text;

  deleteButton.type = &quot;button&quot;;
  deleteButton.className = &quot;delete-button&quot;;
  deleteButton.textContent = &quot;삭제&quot;;

  deleteButton.addEventListener(&quot;click&quot;, function () {
    todos = todos.filter(function (currentTodo) {
      return currentTodo.id !== todo.id;
    });

    saveTodos();
    renderTodos(&quot;항목을 삭제했습니다.&quot;);
  });

  left.append(checkbox, text);
  item.append(left, deleteButton);

  return item;
}

function renderTodos(messageText) {
  todoList.innerHTML = &quot;&quot;;
  updateSummary();

  if (todos.length === 0) {
    showMessage(messageText || &quot;아직 등록된 할 일이 없습니다.&quot;);
    return;
  }

  todos.forEach(function (todo) {
    todoList.appendChild(
      createTodoItem(todo)
    );
  });

  showMessage(
    messageText ||
      &quot;체크해서 완료 처리하거나, 필요 없으면 삭제해보세요.&quot;
  );
}

todoForm.addEventListener(&quot;submit&quot;, function (event) {
  event.preventDefault();

  const text = todoInput.value.trim();

  if (!text) {
    showMessage(&quot;할 일을 먼저 입력해보세요.&quot;);
    todoInput.focus();
    return;
  }

  todos.unshift({
    id: Date.now(),
    text: text,
    done: false
  });

  todoInput.value = &quot;&quot;;
  saveTodos();
  renderTodos(&quot;할 일을 추가했습니다.&quot;);
  todoInput.focus();
});

clearAllButton.addEventListener(&quot;click&quot;, function () {
  if (todos.length === 0) {
    return;
  }

  const shouldClear = window.confirm(
    &quot;정말 모든 할 일을 삭제하시겠습니까?&quot;
  );

  if (!shouldClear) {
    showMessage(&quot;전체 삭제를 취소했습니다.&quot;);
    return;
  }

  todos = [];
  saveTodos();
  renderTodos(&quot;모든 항목을 삭제했습니다.&quot;);
});

renderTodos();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 읽을 때는 한 줄씩 전부 붙잡기보다, 이번 편에서 새로 생긴 역할을 먼저 보면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 남은 개수를 따로 계산합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;updateSummary&lt;/b&gt;가 이번 편의 첫 번째 핵심입니다. 여기서는 완료되지 않은 항목만 세어서 상단 문구에 보여줍니다. 즉, 사용자가 목록을 볼 때마다 &quot;지금 실제로 남은 일이 몇 개인가&quot;를 바로 읽을 수 있게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 전체 삭제 버튼은 아무 때나 활성화되지 않습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록이 하나도 없을 때 전체 삭제 버튼이 계속 살아 있으면, 사용자 입장에서는 조금 이상합니다. 그래서 항목이 없을 때는 버튼을 비활성화해두는 쪽이 자연스럽습니다. 이건 작은 차이 같지만, 실제 사용감은 꽤 좋아집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 전체 삭제 전에 한 번 더 묻습니다&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOEyWU/dJMcadaeevG/HKsIRcDiqkDeKev7kzzHtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOEyWU/dJMcadaeevG/HKsIRcDiqkDeKev7kzzHtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOEyWU/dJMcadaeevG/HKsIRcDiqkDeKev7kzzHtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOEyWU%2FdJMcadaeevG%2FHKsIRcDiqkDeKev7kzzHtK%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;1273&quot; height=&quot;728&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편의 두 번째 핵심은 확인창입니다. 삭제는 되돌리기 전에 한 번쯤 멈춰보게 하는 편이 좋습니다. 특히 전체 삭제처럼 영향이 큰 동작은 더 그렇습니다. 그래서 버튼을 누르자마자 바로 비우지 않고, 한 번 더 확인하는 흐름을 넣었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 안내 문구도 상태에 따라 조금씩 바뀝니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지는 목록만 바뀌는 쪽에 더 집중했다면, 이번에는 사용자에게 &quot;무슨 일이 일어났는지&quot;를 조금 더 분명하게 보여주도록 바꿨습니다. 추가했을 때, 삭제했을 때, 전체 삭제를 취소했을 때 문구가 조금씩 다르게 보이도록 한 이유가 여기 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름을 짧게 줄이면 이렇게 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;883&quot; data-origin-height=&quot;738&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/61oNq/dJMcaaLpbSg/PXqpJakbPwCz4DNRFrimc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/61oNq/dJMcaaLpbSg/PXqpJakbPwCz4DNRFrimc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/61oNq/dJMcaaLpbSg/PXqpJakbPwCz4DNRFrimc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F61oNq%2FdJMcaaLpbSg%2FPXqpJakbPwCz4DNRFrimc1%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;883&quot; height=&quot;738&quot; data-origin-width=&quot;883&quot; data-origin-height=&quot;738&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;clean&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;배열 상태를 바꾼다
-&amp;gt; 저장한다
-&amp;gt; 남은 개수를 다시 계산한다
-&amp;gt; 목록을 다시 그린다
-&amp;gt; 현재 상황에 맞는 문구를 보여준다&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;이번 편의 JavaScript 핵심은 새로운 문법보다 &lt;b&gt;기존 흐름을 유지하면서 상태 표시를 더 분명하게 만든다&lt;/b&gt;는 데 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직접 눌러보며 테스트하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 개선은 얼핏 보면 쉬워 보여서, 오히려 확인을 대충 넘기기 쉽습니다. 그런데 이번 편처럼 &quot;상태를 더 잘 보이게 만드는 기능&quot;은 직접 눌러보지 않으면 어색한 부분을 놓치기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 앱이라면 아래 정도만 확인해도 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;할 일을 두세 개 추가했을 때 남은 개수가 맞게 보이는가&lt;/li&gt;
&lt;li&gt;체크를 하면 남은 개수가 줄어드는가&lt;/li&gt;
&lt;li&gt;체크를 다시 풀면 남은 개수가 늘어나는가&lt;/li&gt;
&lt;li&gt;전체 삭제 버튼이 목록이 있을 때만 활성화되는가&lt;/li&gt;
&lt;li&gt;전체 삭제를 눌렀을 때 바로 지워지지 않고 한 번 더 묻는가&lt;/li&gt;
&lt;li&gt;전체 삭제를 취소하면 목록이 그대로 남는가&lt;/li&gt;
&lt;li&gt;전체 삭제 후 새로고침해도 비어 있는 상태가 유지되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 확인이 중요한 이유는, AI가 코드를 잘 만들어줬는지보다 &lt;b&gt;내가 지금 이 기능을 어떤 기준으로 검증하는지&lt;/b&gt;를 익히는 데 더 도움이 되기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;작은 개선일수록 &quot;어디가 편해졌는지&quot;를 직접 눌러봐야 체감이 남습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Git 체크포인트까지 남겨보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 수정이 마음에 들고 기본 동작도 다시 확인됐다면, 이제는 체크포인트를 남기고 배포 버전에도 반영하면 됩니다. 여기서도 순서는 단순합니다. 먼저 로컬 상태를 확인하고, 기록을 남깁니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;git status
git add .
git commit -m &quot;할 일 앱 요약과 전체 삭제 기능 추가&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 커밋 메시지도 꽤 중요합니다. 단순히 &quot;수정&quot;이라고 적기보다, 무엇이 달라졌는지가 보여야 나중에 기록을 다시 볼 때 훨씬 편합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 아직 로컬에서만 작업 중인 상태라면, 이후 배포할 때 이 버전이 기준점이 됩니다. 즉, &quot;지금 잘 되는 개선 버전&quot;을 하나 남겨두는 셈입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;작은 개선도 잘 됐다면 커밋으로 남기는 편이 좋습니다. 나중에 봐도 &quot;언제 사용감이 좋아졌는지&quot;를 기록으로 다시 읽을 수 있기 때문입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Codex에게는 이렇게 나눠서 요청하면 된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서도 Codex를 쓸 수 있습니다. 다만 여전히 중요한 건 한 번에 다 시키지 않는 겁니다. 특히 지금처럼 이미 배포한 프로젝트는 더 그렇습니다. 큰 변경보다 &lt;b&gt;작고 분명한 수정 요청&lt;/b&gt;이 훨씬 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 요청은 아래 정도면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;지금 배포한 할 일 앱을 작게 개선하고 싶어.
기능은 남은 할 일 개수 표시, 전체 삭제 버튼, 안내 문구 정리만 넣어줘.
HTML, CSS, JavaScript를 모두 바꿀 수는 있지만 구조는 크게 뒤집지 말아줘.
먼저 어떤 파일을 왜 바꿀지부터 설명해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 요청이 좋은 이유는 범위가 작고, 바꾸지 말아야 할 기준도 같이 들어 있기 때문입니다. 초보자가 가장 자주 놓치는 부분도 여기입니다. &quot;무엇을 넣을지&quot;만 말하고, &quot;어디까지는 건드리지 말아야 하는지&quot;를 같이 주지 않는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 기본 개선이 들어간 뒤, 한 부분만 더 다듬고 싶다면 이렇게 더 좁게 물을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
전체 삭제 버튼을 누르면 바로 지우지 말고 확인창을 먼저 띄워줘.
취소했을 때는 현재 목록이 그대로 유지되게 해줘.
수정 범위는 필요한 부분만 최소한으로 잡아줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 안내 문구만 더 자연스럽게 바꾸고 싶다면 이렇게 나눌 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;@script.js
현재 기능은 그대로 두고 안내 문구만 조금 더 자연스럽게 다듬어줘.
문구가 바뀌는 지점이 어디인지 먼저 짧게 설명한 뒤 수정해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 요청을 작게 쪼개면, AI가 결과를 넓게 흔들 가능성도 줄고, 초보자가 무엇이 바뀌었는지 따라가기도 훨씬 쉬워집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;Codex를 잘 쓰는 핵심은 긴 설명보다 &lt;b&gt;수정 범위를 작게 자르는 것&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 한 일은 거창하지 않습니다. 남은 개수를 보여주고, 전체 삭제를 더 안전하게 만들고, 문구를 조금 더 분명하게 다듬었을 뿐입니다. 그런데 바로 이런 수정이 프로젝트를 한 단계 더 &quot;사용 가능한 상태&quot;로 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보자에게 지금 중요한 건 커다란 기능을 무리해서 붙이는 것이 아닙니다. &lt;b&gt;이미 돌아가는 프로젝트를 조금 더 읽기 좋고, 누르기 좋고, 실수하기 덜한 상태로 다듬는 것&lt;/b&gt;이 더 중요합니다. 이 감각이 붙어야 다음 프로젝트도 덜 급해지고, AI에게도 훨씬 또렷하게 요청할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 직접 해보면 왜 &quot;배포 다음 단계&quot;가 중요한지도 감이 옵니다. 새로운 기능을 많이 붙이지 않아도, 이미 있는 프로젝트를 조금 더 나은 상태로 만들 수 있다는 걸 직접 느끼게 되기 때문입니다. 그 경험이 쌓일수록 앱을 보는 눈도 조금씩 달라집니다.&lt;/p&gt;</description>
      <category>바이브코딩/실습</category>
      <category>AI코딩</category>
      <category>codex</category>
      <category>localStorage</category>
      <category>VSCode</category>
      <category>미니프로젝트</category>
      <category>바이브코딩</category>
      <category>바이브코딩입문</category>
      <category>웹개발입문</category>
      <category>코딩초보</category>
      <category>할입앱</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/29</guid>
      <comments>https://story86025.tistory.com/29#entry29comment</comments>
      <pubDate>Mon, 23 Mar 2026 12:08:11 +0900</pubDate>
    </item>
    <item>
      <title>[잡담]로컬 LLM이 결국 커질 수밖에 없다고 보는 이유</title>
      <link>https://story86025.tistory.com/71</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 LLM과 클라우드 LLM을 같이 놓고 생각하다 보면, 저는 결국 이 문제를 성능 경쟁보다 &lt;b&gt;통제권의 문제&lt;/b&gt;로 보게 됩니다. 어디서 모델이 돌아가느냐는 단순히 속도나 품질의 문제가 아니라, 내 데이터가 어디에 남는지, 누가 정책을 바꾸는지, 누가 사용 조건을 정하는지까지 같이 묶여 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제 생각은 비교적 분명합니다. 장기적으로는 클라우드 LLM 일변도보다 로컬 LLM의 비중이 점점 더 커질 가능성이 높다고 봅니다. 다만 이 말은 &amp;ldquo;클라우드가 완전히 끝난다&amp;rdquo;는 뜻보다는, &lt;b&gt;일상적인 작업과 민감한 데이터 처리는 로컬로 내려오고, 정말 무거운 계산만 클라우드에 남는 방향&lt;/b&gt;에 더 가깝습니다. 저는 오히려 그쪽이 더 현실적이라고 생각합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제 생각을 한 줄로 줄이면&lt;/b&gt;&lt;br /&gt;앞으로의 AI는 로컬과 클라우드 중 하나가 완전히 이기는 싸움이라기보다, &lt;b&gt;가벼운 일은 로컬, 무거운 일은 클라우드&lt;/b&gt;로 역할이 갈리는 쪽으로 갈 가능성이 더 커 보입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로컬 LLM이 끌리는 이유는 성능보다 통제감입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 LLM이 매력적으로 보이는 이유는 단순히 &amp;ldquo;내 컴퓨터에서 돌아간다&amp;rdquo;가 아닙니다. 그보다 더 큰 건 &lt;b&gt;밖으로 안 보내도 된다는 감각&lt;/b&gt;입니다. 규제가 강한 조직이든, 민감한 문서를 다루는 개인이든, 결국 불안한 지점은 비슷합니다. 모델이 똑똑하냐보다 내 데이터가 내 손을 떠나느냐가 먼저 걸립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지는 표현을 조금 정교하게 잡을 필요가 있습니다. 클라우드 사업자가 데이터를 &amp;ldquo;무조건 학습에 써버린다&amp;rdquo;고 단정하는 건 사실관계상 너무 강한 말입니다. 현재 OpenAI 안내 기준으로는 개인용 ChatGPT는 대화가 모델 개선에 활용될 수 있지만 사용자가 학습 사용을 끌 수 있고, Business&amp;middot;Enterprise&amp;middot;API는 기본적으로 입력&amp;middot;출력을 학습에 사용하지 않는다고 되어 있습니다. 다만 사용자 입장에서는 그 사실만으로 불안이 완전히 사라지지 않습니다. &lt;b&gt;학습에 쓰느냐&lt;/b&gt;와 &lt;b&gt;내 기기 바깥으로 나가느냐&lt;/b&gt;는 다른 문제이기 때문입니다. 저장, 전송, 보관, 설정 실수까지 생각하면 로컬 쪽에 마음이 가는 건 아주 자연스러운 반응이라고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 앞으로 규제와 보안 요구가 강해질수록 로컬 LLM의 설득력이 더 커질 거라고 봅니다. 클라우드는 계속 강하겠지만, &amp;ldquo;민감한 건 아예 밖으로 내보내지 않는다&amp;rdquo;는 선택지가 갖는 무게도 점점 커질 겁니다. 이건 기술 낙관론보다도, 사용자의 심리와 조직의 책임 구조 쪽에서 더 강하게 밀어주는 변화라고 생각합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이미 시장도 완전한 클라우드 일변도는 아닌 방향으로 움직이고 있습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름은 이미 대형 플랫폼 회사들의 방향에서도 읽힙니다. 애플은 Apple Intelligence를 온디바이스 처리 중심으로 설명하고, 더 복잡한 요청만 Private Cloud Compute로 넘기는 구조를 전면에 내세우고 있습니다. 마이크로소프트도 Copilot+ PC를 40 TOPS 이상 NPU 기반의 AI PC로 정의하면서, 기기 안에서 돌리는 AI 경험을 하드웨어 분류 자체로 밀고 있습니다. 2026년의 큰 방향은 &amp;ldquo;모든 걸 서버에서만 처리한다&amp;rdquo;보다는 &amp;ldquo;기기 안에서 처리할 수 있는 건 먼저 기기 안에서 처리한다&amp;rdquo;에 더 가까워 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 의미에서 저전력 장비로 시작하고 필요할 때 더 큰 장비로 올라가는 흐름도 충분히 자연스럽습니다. 지금 애플 라인업만 봐도 Mac mini는 M4와 M4 Pro, Mac Studio는 M4 Max와 M3 Ultra 구성으로 나뉘어 있어서, 가벼운 로컬 추론과 더 무거운 작업 사이에 단계적인 선택지를 줍니다. 아직은 이 구성이 완전히 대중적이라기보다 매니악한 쪽에 가깝지만, 시간이 지나면 NAS를 두듯이 &amp;ldquo;집에 AI용 장비 한 대쯤 둔다&amp;rdquo;는 감각도 지금보다 훨씬 낯설지 않을 수 있다고 봅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다만 로컬 LLM의 첫 번째 약점은 학습이라기보다 운영 난이도에 가깝습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 저는 한 가지를 조금 다르게 봅니다. 로컬 LLM의 한계를 단순히 &amp;ldquo;학습이 어렵다&amp;rdquo;로 묶으면 핵심이 살짝 빗나갈 수 있습니다. 일반 사용자 기준에서 진짜 병목은 대개 처음부터 모델을 다시 학습시키는 데 있지 않습니다. 오히려 &lt;b&gt;무슨 모델을 골라야 하는지, 어느 정도로 양자화해야 하는지, 메모리가 얼마나 필요한지, 어떻게 업데이트하고 개인화할지&lt;/b&gt; 같은 운영 문제에 더 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 로컬 LLM의 1차 과제는 &amp;ldquo;집에서도 모델을 처음부터 훈련시킬 수 있느냐&amp;rdquo;가 아니라, &lt;b&gt;얼마나 쉽게 깔고, 쉽게 바꾸고, 쉽게 유지할 수 있느냐&lt;/b&gt;라고 보는 편이 더 현실적입니다. 2차 과제는 표준 장비가 아직 완전히 굳지 않았다는 점입니다. 맥 계열, AI PC 계열, GPU 데스크톱 계열이 서로 다른 방향으로 서 있어서 지금은 아직 과도기처럼 보입니다. 다만 저는 오히려 이런 과도기가 지나면, 표준이 정리되는 속도도 생각보다 빠를 수 있다고 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 로컬 LLM이 대중화되려면 &amp;ldquo;엄청난 고사양&amp;rdquo;보다 &amp;ldquo;어느 정도면 충분한지 감이 오는 보편 사양&amp;rdquo;이 먼저 생겨야 합니다. 저는 그 시점이 오면 분위기가 꽤 많이 바뀔 거라고 생각합니다. 지금은 취미처럼 보이는 것이, 어느 순간부터는 그냥 평범한 개인용 인프라처럼 느껴질 가능성이 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래도 클라우드가 완전히 지는 해가 된다고는 보지 않습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여기서 저는 한 걸음은 빼고 싶습니다. 클라우드 LLM이 앞으로 정말로 &amp;ldquo;지는 해&amp;rdquo;가 될 거라고까지는 단정하지 않겠습니다. 최상위 모델, 대규모 협업, 기업용 통합, 초대형 추론, 지속적인 최신 모델 접근 같은 영역에서는 클라우드가 여전히 강할 수밖에 없습니다. 이쪽은 로컬이 쉽게 대체할 수 있는 종류의 힘이 아니기 때문입니다.&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최근 OpenAI 가격 이야기를 보면 이런 생각이 더 강해집니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드 AI의 비용 구조도 지금 형태로 영원히 고정돼 있다고 보기는 어렵습니다. 현재 OpenAI의 ChatGPT 요금 체계는 Free, Go, Plus, Pro, Business, Enterprise로 나뉘어 있고, Business와 Enterprise는 추가 크레딧 구매를 통해 사용 한도를 늘릴 수 있게 안내되어 있습니다. 여기에 최근 보도에서는 OpenAI가 &amp;ldquo;무제한&amp;rdquo; 구독이 장기적으로 유지되지 않을 수 있다며 가격 구조를 재검토하고 있다는 신호까지 나왔습니다. 이게 곧바로 로컬 LLM의 승리를 뜻하는 건 아닙니다. 다만 적어도 &lt;b&gt;클라우드 AI의 경제성 자체가 아직 흔들리는 판&lt;/b&gt;이라는 점은 분명히 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 앞으로의 경쟁을 단순한 성능 대결로만 보지 않습니다. 가격이 어떻게 바뀌는지, 어느 기능이 로컬로 내려오는지, 사용자가 자기 데이터와 비용을 어디까지 통제할 수 있는지가 같이 중요해질 겁니다. 클라우드가 강한 건 여전히 사실이지만, 그 강함이 예전처럼 너무 당연한 기본값으로 남을지는 조금 다른 문제라고 생각합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 저는 로컬 LLM이 앞으로 점점 더 상용화될 거라고 봅니다. 이유는 단순히 성능이 따라잡아서가 아니라, 규제와 보안, 통제권, 비용, 심리적 안심감 같은 요소가 같이 밀어주기 때문입니다. 다만 그 미래는 &amp;ldquo;로컬이 클라우드를 완전히 대체한다&amp;rdquo;기보다는, &lt;b&gt;로컬이 기본값이 되고 클라우드는 무거운 계산을 맡는 쪽&lt;/b&gt;에 더 가까울 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 저는 오히려 그쪽이 더 현실적이라고 봅니다. 사람들은 성능만으로 도구를 고르지 않습니다. 내 데이터가 어디에 남는지, 가격이 갑자기 바뀌지 않는지, 인터넷이 끊겨도 최소한의 작업은 되는지, 내가 원할 때 내가 직접 통제할 수 있는지가 점점 더 중요해지고 있습니다. 로컬 LLM이 커진다면, 그 이유는 더 멋있어서가 아니라 &lt;b&gt;더 내 것처럼 느껴지기 때문&lt;/b&gt;일 가능성이 크다고 생각합니다.&lt;/p&gt;</description>
      <category>주저리 주저리</category>
      <category>AI보안</category>
      <category>ai생각</category>
      <category>AI칼럼</category>
      <category>AI트렌드</category>
      <category>llm비교</category>
      <category>개인정보보호</category>
      <category>로컬llm</category>
      <category>생성형AI</category>
      <category>온디바이스AI</category>
      <category>클라우드LLM</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/71</guid>
      <comments>https://story86025.tistory.com/71#entry71comment</comments>
      <pubDate>Mon, 23 Mar 2026 01:58:35 +0900</pubDate>
    </item>
    <item>
      <title>12. 마우스로 옮겼는데 왜 순서가 안 남을까: 바이브코딩 드래그 앤 드롭 입문</title>
      <link>https://story86025.tistory.com/22</link>
      <description>&lt;article&gt;&lt;header&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;문제는 여기서부터입니다. 드래그 앤 드롭은 겉보기에는 더 자연스럽지만, 코드로 옮기면 오히려 버튼 방식보다 생각할 것이 늘어납니다. 어떤 항목을 잡았는지, 어디 위에 올려놨는지, drop이 왜 안 되는지, 마우스를 놓은 뒤에는 어떤 상태를 비워야 하는지까지 한꺼번에 붙기 때문입니다. 그래서 초보자 입장에서는 &amp;ldquo;&lt;b&gt;버튼보다 더 쉬울 줄 알았는데 왜 갑자기 더 복잡하지?&lt;/b&gt;&amp;rdquo;라는 느낌을 받기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 드래그 앤 드롭을 너무 거창하게 보지 않고, &lt;b&gt;결국 어떤 항목을 잡아서 어떤 항목 자리와 바꾸는가&lt;/b&gt;라는 데이터 문제로 다시 풀어보겠습니다. 처음부터 완벽한 드래그 정렬을 만들기보다, 어떤 이벤트가 왜 필요한지, 첫 버전은 어디까지 단순하게 만드는 게 좋은지, 그리고 왜 첫 버전에서는 &amp;ldquo;끼워 넣기&amp;rdquo;보다 &amp;ldquo;자리 바꾸기&amp;rdquo;가 더 이해하기 쉬운지 차근차근 정리해보겠습니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;이번 글에서 먼저 잡아둘 핵심&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 문제&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 일어나는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;가장 먼저 봐야 할 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마우스로 옮겼는데 새로고침하면 원래대로다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면만 잠깐 바뀌었고 order 값은 저장되지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서 변경이 데이터에서 일어났는지 먼저 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭이 아예 안 된다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover 단계에서 drop을 허용하는 처리가 빠졌을 가능성이 크다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;이벤트 흐름이 연결됐는지 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다른 항목 위에 놓았는데 엉뚱한 항목이 움직인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;보이는 위치를 믿고 있고, 실제 대상은 id로 찾지 않았다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잡은 항목과 대상 항목을 둘 다 id로 추적하는지 확인한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;정렬 모드가 섞이면서 더 헷갈린다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬과 사용자 순서 변경을 동시에 허용했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 custom 모드에서만 드래그를 허용하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 글의 핵심 한 줄&lt;/b&gt;&lt;br /&gt;드래그 앤 드롭은 마우스 동작처럼 보이지만, 결국은 어떤 항목의 order를 어떻게 바꿀지 정하는 데이터 문제다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/header&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;버튼 이동과 드래그 앤 드롭의 차이&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;초보자 눈에는&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;코드에서는 실제로&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;위로/아래로 버튼&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;조금 투박해 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;대상 항목과 방향만 알면 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 앤 드롭&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;훨씬 직관적으로 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잡기, 지나가기, 놓기, 상태 정리까지 단계가 늘어난다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 드래그 앤 드롭이 &amp;ldquo;더 고급 기능&amp;rdquo;이라서 어려운 게 아니라는 점입니다. 오히려 &lt;b&gt;사용자 행동이 더 길어졌기 때문에 상태를 더 많이 추적해야 한다&lt;/b&gt;는 쪽에 가깝습니다. 그래서 처음에는 화면상 느낌보다 데이터 흐름을 먼저 보는 편이 훨씬 이해가 잘 됩니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;&lt;br /&gt;버튼 이동은 &amp;ldquo;한 칸 위&amp;rdquo;처럼 방향이 이미 정해져 있지만, 드래그 앤 드롭은 &amp;ldquo;무엇을 잡았고 어디에 놓았는가&amp;rdquo;를 따로 기록해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&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;마우스 기능처럼 보이지만, 결국 바뀌는 건 order 값이다.&lt;/b&gt; 즉, 브라우저 안에서 벌어지는 움직임을 전부 따라가려 하기보다, 마지막에 어떤 데이터 변화가 일어나야 하는지만 먼저 보는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 첫 버전에서는 이렇게 생각하면 충분합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;내가 어떤 항목을 잡았는지 기억한다.&lt;/li&gt;
&lt;li&gt;어느 항목 위에 놓았는지 기억한다.&lt;/li&gt;
&lt;li&gt;잡은 항목과 대상 항목의 order 값을 바꾼다.&lt;/li&gt;
&lt;li&gt;저장하고 다시 그린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름은 &amp;ldquo;끼워 넣기&amp;rdquo;보다 단순합니다. 사용자가 A를 끌어서 B 위에 놓으면, 첫 버전에서는 A와 B의 자리를 바꾸는 방식으로 처리하면 됩니다. 실제 제품에서는 더 자연스럽게 중간에 끼워 넣는 동작을 만들 수도 있지만, 입문자에게는 &lt;b&gt;일단 drag와 drop 이벤트가 데이터 변경으로 이어지는 흐름&lt;/b&gt;을 이해하는 편이 훨씬 중요합니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 단순하게 생각할 수 있는 흐름&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;무엇을 기억하나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잡을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;누가 움직이는지 알아야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;놓을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;targetId&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;누구 자리와 바꿀지 알아야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데이터 변경&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;두 항목의 order 값&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;실제 순서 변화가 저장 대상이 되기 때문이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마무리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId 초기화&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;&amp;ldquo;잡은 항목 id&amp;rdquo;와 &amp;ldquo;대상 항목 id&amp;rdquo;를 알아낸 뒤, order를 바꾸는 기능&lt;/b&gt;으로 다시 설명할 수 있기 때문입니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 말하면&lt;/b&gt;&lt;br /&gt;드래그 앤 드롭은 손으로 움직이는 것처럼 보여도, 안쪽에서는 id 두 개와 order 변경 하나로 정리할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;꼭 알아야 할 이벤트 4개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭이 갑자기 어려워 보이는 이유 중 하나는 이벤트 이름이 한꺼번에 등장하기 때문입니다. 그런데 초보자가 첫 버전에서 꼭 이해하면 좋은 것은 많지 않습니다. 실제로는 네 가지 정도만 잡아도 흐름이 크게 보입니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 먼저 이해하면 좋은 이벤트 4개&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;이벤트&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;언제 일어나나&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;첫 버전에서 하는 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragstart&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;사용자가 항목을 잡기 시작할 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId를 기억한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다른 항목 위를 지나갈 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop을 허용하는 처리를 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 위에 마우스를 놓을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;targetId를 기준으로 순서를 바꾼다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragend&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그가 끝났을 때&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId 같은 임시 상태를 정리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 특히 많이 놓치는 것이 dragover입니다. 처음 보는 사람 입장에서는 그냥 지나가는 이벤트처럼 보입니다. 그런데 이 단계에서 drop을 허용하는 처리가 없으면, 드롭이 아예 일어나지 않는 경우가 많습니다. 그래서 drop이 안 될 때는 drop 함수부터 의심하기보다 dragover에서 필요한 처리가 들어갔는지 먼저 보는 편이 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 버전에서는 아래 정도 흐름이면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;let draggedId = null;

function handleDragStart(event, id) {
draggedId = id;
event.dataTransfer.setData('text/plain', String(id));
event.dataTransfer.effectAllowed = 'move';
}

function handleDragOver(event) {
event.preventDefault();
}

function handleDrop(event, targetId) {
event.preventDefault();

if (draggedId === null) return;
if (draggedId === targetId) return;

swapTodoOrder(draggedId, targetId);
draggedId = null;
}

function handleDragEnd() {
draggedId = null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 처음 볼 때는 event 객체나 dataTransfer가 눈에 걸릴 수 있습니다. 그런데 지금 단계에서는 깊게 파고들 필요는 없습니다. &lt;b&gt;dragstart는 누구를 잡았는지 기억하는 단계, dragover는 drop을 허용하는 단계, drop은 실제 데이터 변경 단계, dragend는 마무리 단계&lt;/b&gt; 정도로 받아들이면 충분합니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 이해는 이 정도면 충분하다&lt;/b&gt;&lt;br /&gt;드래그 이벤트는 어려운 문법이 아니라, 시작-지나감-놓기-정리라는 순서를 가진 흐름이라고 생각하는 편이 훨씬 덜 복잡하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 단순한 첫 버전: 자리 바꾸기부터 시작하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭을 처음 붙일 때 많은 사람이 곧바로 &amp;ldquo;대상 사이에 끼워 넣기&amp;rdquo;를 떠올립니다. 실제 서비스에서는 그 방식이 더 자연스러울 때가 많습니다. 하지만 초보자 기준에서는 이 단계가 अचानक 복잡해집니다. 잡은 항목이 위에서 내려왔는지 아래에서 올라왔는지, 어디 앞에 넣을지, 어디 뒤에 넣을지까지 계산이 붙기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 첫 버전에서는 좀 더 단순하게, &lt;b&gt;잡은 항목과 놓은 항목의 order 값을 서로 바꾸는 방식&lt;/b&gt;으로 시작하는 편이 이해하기 쉽습니다. 이 방식은 드롭 동작이 약간 투박해 보여도, 드래그 이벤트가 실제 데이터 변경으로 이어지는 흐름을 잡기에는 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 순서 바꾸기 함수는 이렇게 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function swapTodoOrder(firstId, secondId) {

const first = todos.find(todo =&amp;gt; todo.id === firstId);
const second = todos.find(todo =&amp;gt; todo.id === secondId);

if (!first || !second) return;

const temp = first.order;
first.order = second.order;
second.order = temp;

saveTodos();
renderTodos();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 렌더링할 때 각 항목에 드래그 관련 이벤트를 연결합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;padding: 12px; background: #f8f8f8; border: 1px solid #dddddd; overflow: auto;&quot;&gt;&lt;code&gt;function renderTodos() {

const visibleTodos = getVisibleTodos();

todoList.innerHTML = '';

visibleTodos.forEach(todo =&amp;gt; {
const li = document.createElement('li');
li.draggable = true;

li.textContent = todo.text;

li.addEventListener('dragstart', (event) =&amp;gt; {
  handleDragStart(event, todo.id);
});

li.addEventListener('dragover', (event) =&amp;gt; {
  handleDragOver(event);
});

li.addEventListener('drop', (event) =&amp;gt; {
  handleDrop(event, todo.id);
});

li.addEventListener('dragend', () =&amp;gt; {
  handleDragEnd();
});

todoList.appendChild(li);

});
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 실제로 바뀌는 것은 두 가지뿐입니다. 하나는 draggedId라는 임시 상태이고, 다른 하나는 항목 두 개의 order 값입니다. 즉, 화면상으로는 마우스로 끌어 옮기는 복잡한 기능처럼 보여도, 데이터 입장에서는 여전히 &lt;b&gt;누구와 누구의 순서를 바꿀까&lt;/b&gt;라는 문제로 정리됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 정말 필요한 데이터 흐름&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;코드에서 하는 일&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;왜 충분한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;잡기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId 저장&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;현재 움직이는 항목을 잊지 않게 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;놓기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;targetId 확인&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;어느 자리와 관계를 맺을지 정해진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;순서 변경&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;두 항목의 order 교환&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;직관적이고 디버깅이 쉽다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;마무리&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장하고 렌더링하고 상태 초기화&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 실행과 다음 드래그까지 안정적으로 이어진다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;초보자에게 특히 좋은 이유&lt;/b&gt;&lt;br /&gt;첫 버전에서 자리 바꾸기 방식으로 시작하면, 드래그 이벤트와 order 저장을 먼저 이해할 수 있고, 그다음에야 더 자연스러운 끼워 넣기 방식으로 넘어갈 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 버전에서는 어디까지 만드는 게 좋을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭은 욕심을 내기 시작하면 아주 빠르게 복잡해집니다. 시각적인 강조, 위아래 위치에 따른 삽입선, 자동 스크롤, 모바일 터치 대응, 검색 상태에서의 부분 목록 재정렬까지 붙이기 시작하면 초보자가 한 번에 통제하기 어려워집니다. 그래서 첫 버전에서는 일부러 범위를 줄이는 편이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 붙일 때는 아래 정도로 선을 그어두면 훨씬 덜 흔들립니다.&lt;/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;custom 모드에서만 드래그 허용&lt;/b&gt;&lt;br /&gt;최신순이나 오래된순 같은 자동 정렬 모드에서는 드래그를 비활성화하는 편이 낫습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 보기에서만 재정렬 허용&lt;/b&gt;&lt;br /&gt;검색이나 필터가 걸린 상태에서는 숨겨진 항목 때문에 결과가 더 헷갈릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자리 바꾸기 방식부터 시작&lt;/b&gt;&lt;br /&gt;첫 버전은 swap이 훨씬 디버깅하기 쉽습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데스크톱 기준으로 먼저 이해&lt;/b&gt;&lt;br /&gt;모바일 터치 환경은 동작 방식이 달라질 수 있어, 초반에는 별도 단계로 보는 편이 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;caption style=&quot;caption-side: top; text-align: left; font-weight: bold; padding-bottom: 8px;&quot;&gt;첫 버전에서 넣는 편이 좋은 것과 미루는 편이 좋은 것&lt;/caption&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;첫 버전에 넣는 편이 좋은 것&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;다음 단계로 미루는 편이 좋은 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragstart, dragover, drop, dragend 흐름 이해&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;삽입선 표시, hover 강조 같은 세밀한 UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;항목 두 개의 order 바꾸기&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;중간 위치 끼워 넣기와 index 보정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;저장 후 새로고침해도 순서 유지&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;검색 결과 화면에서 부분 재정렬 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;custom 모드 한정 사용&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬 모드와의 완전한 통합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 범위를 줄이면 좋은 점이 분명합니다. 문제가 생겼을 때 원인을 훨씬 빨리 좁힐 수 있습니다. 드롭이 안 되면 이벤트 흐름을 보면 되고, 순서가 안 남으면 order 저장을 보면 되고, 보기는 바뀌는데 다시 열면 풀리면 save와 load를 보면 됩니다. 반대로 처음부터 모든 حالت을 한 번에 허용하면 어디서부터 꼬였는지 감이 잘 안 잡힙니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입문자에게 가장 현실적인 전략&lt;/b&gt;&lt;br /&gt;드래그 앤 드롭은 멋있게 보이는 것보다 먼저, custom 모드에서 순서가 실제로 저장되고 다시 열어도 유지되는지부터 확인하는 편이 훨씬 낫다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;section&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초보자가 자주 하는 실수 6가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭을 붙일 때는 비슷한 실수가 반복됩니다. 겉으로 보면 작은 차이 같지만, 실제로는 대부분 데이터와 이벤트의 경계를 섞는 순간부터 시작됩니다.&lt;/p&gt;
&lt;table style=&quot;width: 100%; border-collapse: collapse; border: 2px solid #8d8d8d; margin: 18px 0;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실수&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;겉으로 보이는 증상&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;실제로 문제인 부분&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #9c9c9c; padding: 10px; background: #f3f3f3; text-align: left; vertical-align: top;&quot;&gt;어떻게 보는 게 좋은가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;dragover 처리를 빼먹는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop이 전혀 동작하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop 허용 단계가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드롭 문제는 dragover부터 확인하는 편이 빠르다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그된 항목 id를 기억하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;놓았을 때 누가 움직여야 하는지 모른다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId 같은 상태가 없다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;드래그 시작 시 누가 움직이는지 반드시 저장해둔다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;DOM 위치만 바꾸고 데이터를 안 바꾼다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;그 순간엔 움직이지만 새로고침하면 원래대로다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;order 값 저장이 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;화면보다 먼저 데이터에서 순서를 바꾼다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop 후 상태를 초기화하지 않는다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;다음 드래그에서 이전 항목 정보가 남아 이상하게 움직인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;draggedId 정리가 빠졌다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;drop과 dragend 모두에서 상태 정리를 의식한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;자동 정렬 모드에서도 그대로 재정렬을 허용한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;움직인 것 같다가도 화면이 다시 원래 규칙대로 보인다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;createdAt 같은 자동 정렬 규칙이 화면을 다시 덮어쓴다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;첫 버전은 custom 모드에서만 허용하는 편이 낫다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;모바일까지 처음부터 한 번에 맞추려 한다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;데스크톱도 불안정한데 전체가 더 복잡해진다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&gt;입력 방식이 다른 환경을 동시에 해결하려 했다&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #9c9c9c; padding: 10px; vertical-align: top;&quot;&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;order 값이 바뀌고 저장돼야 그게 진짜 순서 변경&lt;/b&gt;입니다. 이 차이를 이해하는 순간 드래그 앤 드롭도 훨씬 덜 막막해집니다.&lt;/p&gt;
&lt;blockquote style=&quot;margin: 18px 0; padding: 12px 16px; border-left: 4px solid #c8c8c8; background: #fafafa;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전에서 가장 많이 흔들리는 부분&lt;/b&gt;&lt;br /&gt;드래그 앤 드롭은 눈으로 보기에는 화면 기능 같지만, 실제로는 이벤트와 order 저장이 연결되지 않으면 완성된 기능이라고 보기 어렵다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/section&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;footer&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 앤 드롭은 입문자에게 꽤 매력적인 기능입니다. 눈에 잘 보이고, &amp;ldquo;내가 앱을 직접 만지고 있다&amp;rdquo;는 느낌도 강하기 때문입니다. 하지만 그만큼 착각하기 쉬운 기능이기도 합니다. 마우스로 움직이는 동작이 전부인 것처럼 보이지만, 실제로는 &lt;b&gt;잡은 항목을 기억하고, 놓인 대상을 확인하고, order 값을 바꾸고, 저장하고, 다시 그리는 흐름&lt;/b&gt;이 모두 맞물려야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 꼭 가져가면 좋은 건 세 가지입니다. &lt;br /&gt;첫째, 드래그 앤 드롭은 결국 id와 order를 다루는 데이터 문제라는 점. &lt;br /&gt;둘째, 첫 버전에서는 끼워 넣기보다 자리 바꾸기 방식이 훨씬 이해하기 쉽다는 점. &lt;br /&gt;셋째, custom 모드와 전체 보기처럼 조건을 일부러 줄여서 시작하는 편이 초보자에게 훨씬 안정적이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 오면 이제 드래그 이벤트 자체는 더 이상 낯선 덩어리가 아닙니다. 다음 편에서는 &lt;b&gt;자리 바꾸기에서 한 단계 더 나아가, 항목을 자연스럽게 사이에 끼워 넣는 방식&lt;/b&gt;이 왜 더 어렵고, index 보정이라는 개념이 왜 여기서부터 중요해지는지를 다룹니다.&lt;/p&gt;
&lt;/footer&gt;&lt;/article&gt;</description>
      <category>바이브코딩/이론</category>
      <category>DragAndDrop</category>
      <category>드래그앤드롭입문</category>
      <category>순서바꾸기</category>
      <category>재정렬기능</category>
      <category>할일앱드래그</category>
      <author>이음(Ieum)</author>
      <guid isPermaLink="true">https://story86025.tistory.com/22</guid>
      <comments>https://story86025.tistory.com/22#entry22comment</comments>
      <pubDate>Sat, 21 Mar 2026 09:00:18 +0900</pubDate>
    </item>
  </channel>
</rss>