<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>everything in sesam</title>
    <link>https://sesam-dev.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 24 Jul 2026 22:01:47 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>sesam</managingEditor>
    <image>
      <title>everything in sesam</title>
      <url>https://tistory1.daumcdn.net/tistory/6125656/attach/d289624ac49f4d1297a63d42dba5ad35</url>
      <link>https://sesam-dev.tistory.com</link>
    </image>
    <item>
      <title>Maze Bench &amp;mdash; AI 모델의 미로 풀이 능력을 겨루는 오픈소스 벤치마크</title>
      <link>https://sesam-dev.tistory.com/326</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;리플레이:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://hehee9.github.io/maze-bench/public/index.html&quot;&gt;https://hehee9.github.io/maze-bench/public/index.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;리더보드:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://hehee9.github.io/maze-bench/public/leaderboard.html&quot;&gt;https://hehee9.github.io/maze-bench/public/leaderboard.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Hugging Face Space:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://huggingface.co/spaces/Hehee-dev/maze-bench&quot;&gt;https://huggingface.co/spaces/Hehee-dev/maze-bench&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://github.com/hehee9/maze-bench&quot;&gt;https://github.com/hehee9/maze-bench&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(제작: hehee9, Apache 2.0)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;background-color: #fdfdfc; color: #0073e6; text-align: start;&quot; href=&quot;https://hehee9.github.io/maze-bench/public/index.html?size=4x4&amp;amp;maze=maze_04x04_adjacent_01&amp;amp;model=MiniMax+M3+%28thinking%29&quot;&gt;바로가기&lt;/a&gt; &lt;/p&gt;
&lt;figure id=&quot;og_1784520578664&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Maze Bench Replay &amp;mdash; Compare Model Maze-Solving Paths&quot; data-og-description=&quot;Replay and compare how LLMs navigate visual mazes, including optimal paths, movement logs, and outcomes.&quot; data-og-host=&quot;hehee9.github.io&quot; data-og-source-url=&quot;https://hehee9.github.io/maze-bench/public/index.html?size=4x4&amp;amp;maze=maze_04x04_adjacent_01&amp;amp;model=MiniMax+M3+%28thinking%29&quot; data-og-url=&quot;https://hehee9.github.io/maze-bench/public/index.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/RQgaC/dJMb8Xksrds/J9t3uGjPbX6sWCuQKqEexK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/m7Q1x/dJMb8U86CmZ/sKdZdUJBH1KrekVkwR0aa1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://hehee9.github.io/maze-bench/public/index.html?size=4x4&amp;amp;maze=maze_04x04_adjacent_01&amp;amp;model=MiniMax+M3+%28thinking%29&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://hehee9.github.io/maze-bench/public/index.html?size=4x4&amp;amp;maze=maze_04x04_adjacent_01&amp;amp;model=MiniMax+M3+%28thinking%29&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/RQgaC/dJMb8Xksrds/J9t3uGjPbX6sWCuQKqEexK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/m7Q1x/dJMb8U86CmZ/sKdZdUJBH1KrekVkwR0aa1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Maze Bench Replay &amp;mdash; Compare Model Maze-Solving Paths&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Replay and compare how LLMs navigate visual mazes, including optimal paths, movement logs, and outcomes.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;hehee9.github.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;한 줄 소개&lt;/h2&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Maze Bench&lt;/b&gt;는 AI 모델에게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;미로 이미지&lt;/b&gt;를 보여주고 탈출 경로를 만들게 한 뒤, 그 풀이를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;한 수씩 재생(replay)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;하며 점수화&amp;middot;비교하는 독립 오픈소스 벤치마크입니다. 핵심 목적은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&quot;연속적인 시각&amp;middot;공간 추론(continuous visual and spatial reasoning)&quot;&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;능력 측정.&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 흔한 오해 정정: 좌표&amp;middot;벽 정보를 텍스트로 주는 게 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;미로를 이미지로&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;줍니다. 그래서 이건 순수 논리 퍼즐이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&quot;그림을 보고(vision) + 길을 계획하는(reasoning)&quot;&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&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;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1. 과제 방식 &amp;mdash; 모델은 무엇을 하나이동 명령 (4방향, 상대 방향)
&lt;div style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;명령의미
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;S&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;앞으로 (forward)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;B&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;뒤로 (backward)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;R&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;오른쪽 (right)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;L&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;왼쪽 (left)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
이 &quot;상대 방향&quot;이 난이도를 확 올립니다. &quot;오른쪽으로 가&quot;가 절대 동쪽이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;지금 향한 쪽 기준의 오른쪽&lt;/b&gt;이라, 모델이 매 스텝 자기&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;방향(heading) 상태를 머릿속에서 갱신&lt;/b&gt;해야 합니다. 방향을 놓치면 그 뒤 경로가 통째로 어긋납니다.한 줄 문자열로 반환. (코드블록으로 감싸는 것도 허용) 이 문자열을 채점기가 그대로 실행해 점수를 냅니다.&lt;hr data-ke-style=&quot;style1&quot; /&gt;2. 미로 구성 &amp;mdash; 난이도와 경로 유형
&lt;div style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;난이도크기
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 85px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 25px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;Easy&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;4x4, 6x6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 25px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;Medium&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;9x9, 12x12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 25px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;Hard&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left; height: 25px;&quot;&gt;15x15, 18x18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
경로 유형 (출발-도착 변의 관계)
&lt;div style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;유형의미
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;adjacent&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;출발&amp;middot;도착이&amp;nbsp;&lt;b&gt;인접한 변&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;opposite&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;&lt;b&gt;반대편 변&lt;/b&gt;&amp;nbsp;(보통 가장 긴 경로)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;same&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; text-align: left;&quot;&gt;&lt;b&gt;같은 변&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;3. 점수 계산 &amp;mdash; 왜 &quot;성공/실패&quot;만 보지 않나
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;P (진행도, Progress)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;=&lt;span&gt;&amp;nbsp;&lt;/span&gt;m / (m + r)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;E (효율성, Efficiency)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;=&lt;span&gt;&amp;nbsp;&lt;/span&gt;D / (m + r)&lt;/li&gt;
&lt;/ul&gt;
기호:
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;D&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= 출발&amp;rarr;목표&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;최소 이동 수&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(정답 경로 길이)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;m&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= 벽에 안 부딪히고 성공한 이동 수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;r&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= 멈춘&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;최종 위치에서 목표까지 남은 최소 이동 수&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;b&gt;전체 점수 = 모든 미로 점수의 평균.&lt;/b&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;4. 결과 &amp;mdash; 현재 리더보드에서 드러난 것&lt;b&gt;종합 상위&lt;/b&gt;: Claude Fable 5 &amp;rarr; Kimi K3 &amp;rarr; GPT-5.6 Sol &amp;rarr; Gemini 3.5 Flash핵심 발견
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;가장 잘하는 Claude Fable 5조차 종합 40% 미만.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 현행 최상위 모델에게도 시각&amp;middot;공간 추론은 여전히 어려운 영역이라는 뜻. (미로는 사람에겐 쉽지만 LLM에겐 아직 난제)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모델별 강점이 갈림&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Claude 계열:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;작은 미로에 강하고 비용 효율 좋음.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;GPT / Kimi:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;큰 미로에서 상대적으로 우수하지만 비용 효율은 낮음.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;난이도가 Easy&amp;rarr;Hard로 가면 점수가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;급락&lt;/b&gt;(90%대 &amp;rarr; 10%대). 격자가 커질수록 방향&amp;middot;경로를 끝까지 유지하기가 급격히 어려워짐.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;5. 리플레이 뷰어 (님이 준 링크가 이것)
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;미로 시각화&lt;/b&gt;: 벽&amp;middot;출발&amp;middot;도착&amp;middot;모델이 지나간 경로.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단계별 재생&lt;/b&gt;: 이전 / 다음 / 재생, 속도 0.5&amp;times;~4&amp;times;.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최단 경로 오버레이&lt;/b&gt;: 정답 경로와 겹쳐 보며 어디서 돌아갔는지 확인.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이동 로그(MOVEMENT LOG)&lt;/b&gt;: 각 스텝의 명령 표시.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;URL 상태 공유&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;?size=4x4&amp;amp;maze=maze_04x04_adjacent_01&amp;amp;model=MiniMax+M3+(thinking)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 특정 모델의 특정 미로 풀이 장면을 링크로 공유.&lt;/li&gt;
&lt;/ul&gt;
리플레이의 가치: 점수표 숫자만으론 &quot;왜 40%인지&quot; 모릅니다. 되감아 보면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;어디서 벽에 박고, 어느 지점에서 방향을 잃고 헤맸는지&lt;/b&gt;가 눈에 보입니다. thinking 모델과 일반 모델의 계획 차이도 직관적으로 비교됩니다.&lt;hr data-ke-style=&quot;style1&quot; /&gt;요약
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정체&lt;/b&gt;: LLM이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;미로 이미지&lt;/b&gt;를 보고 탈출 경로를 짜는 능력을 측정하는 시각&amp;middot;공간 추론 벤치마크.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;입력/출력&lt;/b&gt;: 미로 이미지 &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;S/B/R/L(상대 방향) 한 줄 명령.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;채점&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;100 &amp;times; 진행도(P) &amp;times; 효율(E)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 성공/실패가 아닌 연속 점수.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;난이도&lt;/b&gt;: Easy(4&lt;s&gt;6) / Medium(9&lt;/s&gt;12) / Hard(15~18), 경로유형 adjacent&amp;middot;opposite&amp;middot;same.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;핵심 결론&lt;/b&gt;: 최상위 모델도 종합 40% 미만 &amp;rarr; 아직 미해결 난제. 작은 미로는 Claude, 큰 미로는 GPT/Kimi.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;차별점&lt;/b&gt;: 결과를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;replay로 시각화&lt;/b&gt;해 &quot;어떻게 풀었는지&quot;를 드러냄&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;
&lt;div&gt;
&lt;div data-code-text=&quot;S R L S R R L L ...&quot;&gt;
&lt;div style=&quot;background-color: #fdfdfc;&quot; data-gutter=&quot;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;background-color: #fdfdfc;&quot; data-content=&quot;&quot;&gt;
&lt;div style=&quot;background-color: #fdfdfc; color: #1a1a1a;&quot; data-line-index=&quot;0&quot; data-line-type=&quot;context&quot; data-line=&quot;1&quot;&gt;&lt;span style=&quot;background-color: #000000; color: #1a1a1a;&quot;&gt;S R L S R R L L ...&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>기타/관심(●'◡'●)</category>
      <category>ai/llm</category>
      <category>Benchmark</category>
      <category>reference</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/326</guid>
      <comments>https://sesam-dev.tistory.com/326#entry326comment</comments>
      <pubDate>Mon, 20 Jul 2026 13:18:46 +0900</pubDate>
    </item>
    <item>
      <title>NestJS에서 Zod로 스키마 유효성 검사하기 (커스텀 파이프 만들기)</title>
      <link>https://sesam-dev.tistory.com/325</link>
      <description>&lt;h1&gt;NestJS에서 Zod로 스키마 유효성 검사하기 (커스텀 파이프 만들기)&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 &lt;code&gt;ParseIntPipe&lt;/code&gt;로 URL 파라미터 하나가 숫자인지 확인했습니다. 값 하나 검사하는 건 쉽죠. 그런데 &lt;b&gt;회원가입&lt;/b&gt;처럼 이메일&amp;middot;비밀번호&amp;middot;나이가 한꺼번에 넘어오는 경우는요? 이걸 전부 &lt;code&gt;if&lt;/code&gt;문으로 검사하는 건 지옥입니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;//   이렇게 하고 싶지 않습니다
if (!body.email) throw ...
if (typeof body.age !== 'number') throw ...
if (body.age &amp;lt; 0) throw ...
// ... 필드마다 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 &lt;b&gt;Zod&lt;/b&gt;로 데이터의 &quot;청사진(스키마)&quot;을 미리 정의하고, 그 스키마로 요청 데이터를 한 방에 검증하는 방법을 다룹니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 스키마 유효성 검사란?&lt;/h2&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;Zod&lt;/b&gt;는 TypeScript에 최적화된 인기 스키마 검증 라이브러리입니다. 전체 흐름은 3단계예요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Zod로 스키마 정의 &amp;rarr; &quot;이름은 string, 나이는 number&quot;&lt;/li&gt;
&lt;li&gt;그 스키마를 받는 &lt;b&gt;커스텀 파이프&lt;/b&gt; 생성&lt;/li&gt;
&lt;li&gt;파이프 안에서 Zod의 &lt;code&gt;parse()&lt;/code&gt;로 검증 &amp;rarr; 실패하면 Zod가 에러를 던지고, 파이프가 그걸 NestJS의 &lt;code&gt;BadRequestException&lt;/code&gt;(400)으로 변환&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;파이프(Pipe)가 뭔가요?&lt;/b&gt; NestJS에서 파이프는 &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;1. 설치 + 필수 설정&lt;/h2&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install zod&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기서 주니어가 자주 놓치는 함정 하나.&lt;/b&gt; Zod가 제대로 동작하려면 &lt;code&gt;tsconfig.json&lt;/code&gt;에 &lt;code&gt;strictNullChecks&lt;/code&gt;가 켜져 있어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// tsconfig.json
{
  &quot;compilerOptions&quot;: {
    &quot;strictNullChecks&quot;: true
    // 또는 &quot;strict&quot;: true (이 안에 strictNullChecks가 포함됨)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 옵션은 TypeScript가 &lt;code&gt;null&lt;/code&gt;과 &lt;code&gt;undefined&lt;/code&gt;를 엄격하게 구분하도록 만듭니다. 꺼져 있으면 Zod의 타입 추론이 헐거워져서, 검증은 되는데 타입이 이상하게 나오는 혼란을 겪을 수 있어요. NestJS 프로젝트는 보통 &lt;code&gt;strict: true&lt;/code&gt;가 기본이라 이미 켜져 있을 가능성이 높습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 커스텀 파이프 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NestJS의 동작 방식을 이해하기 좋은 예제입니다. &lt;code&gt;PipeTransform&lt;/code&gt; 인터페이스를 구현합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// zod-validation.pipe.ts
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  // 어떤 스키마든 외부에서 주입받아 재사용 가능
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    try {
      // 검증 성공 시: 파싱된(정제된) 값을 반환
      return this.schema.parse(value);
    } catch (error) {
      // 검증 실패 시: Zod가 던진 에러를 400으로 변환
      throw new BadRequestException('Validation failed');
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 정리:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;constructor(private schema)&lt;/code&gt;&lt;/b&gt; &amp;mdash; 스키마를 밖에서 주입받기 때문에, 이 파이프 &lt;b&gt;하나로 모든 스키마&lt;/b&gt;를 처리할 수 있습니다. 스키마마다 파이프를 새로 만들 필요가 없어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;transform&lt;/code&gt;이 반환하는 값이 중요&lt;/b&gt; &amp;mdash; 컨트롤러가 받는 &lt;code&gt;body&lt;/code&gt;는 원본이 아니라 이 &lt;code&gt;transform&lt;/code&gt;이 &lt;b&gt;반환한 값&lt;/b&gt;입니다. Zod의 &lt;code&gt;parse&lt;/code&gt;는 검증만 하는 게 아니라 값을 정제(coercion, 기본값 채우기 등)해서 돌려주므로, 반드시 그 반환값을 &lt;code&gt;return&lt;/code&gt; 해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;parse&lt;/code&gt; vs &lt;code&gt;safeParse&lt;/code&gt;&lt;/b&gt; &amp;mdash; &lt;code&gt;parse&lt;/code&gt;는 실패 시 &lt;b&gt;예외를 던집니다&lt;/b&gt;(그래서 &lt;code&gt;try/catch&lt;/code&gt;가 필요). 예외 없이 &lt;code&gt;{ success, data, error }&lt;/code&gt; 객체로 받고 싶으면 &lt;code&gt;safeParse&lt;/code&gt;를 씁니다. 여기선 파이프가 예외로 흐름을 끊는 게 자연스러워 &lt;code&gt;parse&lt;/code&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;code&gt;catch&lt;/code&gt;에서 그냥 &lt;code&gt;'Validation failed'&lt;/code&gt; 문자열만 던지면 클라이언트는 &lt;b&gt;어떤 필드가 왜 틀렸는지&lt;/b&gt; 알 수 없습니다. &lt;code&gt;error&lt;/code&gt;가 &lt;code&gt;ZodError&lt;/code&gt;일 때 &lt;code&gt;error.issues&lt;/code&gt;(또는 &lt;code&gt;error.format()&lt;/code&gt;)를 응답에 담아주면 훨씬 친절합니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;import { ZodError } from 'zod';
// ...
catch (error) {
  if (error instanceof ZodError) {
    throw new BadRequestException(error.issues); // 필드별 에러 목록
  }
  throw new BadRequestException('Validation failed');
}&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 스키마(DTO) 정의하기 &amp;mdash; 스키마와 타입을 동시에&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고양이를 생성하는 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;// create-cat.dto.ts
import { z } from 'zod';

export const createCatSchema = z.object({
  name: z.string(),
  age: z.number(),
  breed: z.string(),
});

// 스키마로부터 TypeScript 타입을 자동 추론
export type CreateCatDto = z.infer&amp;lt;typeof createCatSchema&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기가 Zod의 진짜 매력 포인트입니다. &lt;b&gt;&lt;code&gt;z.infer&lt;/code&gt;&lt;/b&gt; 를 쓰면 스키마에서 TypeScript 타입을 &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;Zod는 스키마 하나에서 타입을 파생시키므로 &lt;b&gt;둘이 절대 어긋나지 않습니다.&lt;/b&gt; 스키마에 &lt;code&gt;age&lt;/code&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;code&gt;createCatSchema&lt;/code&gt;(값, 런타임에 실제로 검증하는 객체)와 &lt;code&gt;CreateCatDto&lt;/code&gt;(타입, 컴파일 후 사라짐)는 &lt;b&gt;다른 것&lt;/b&gt;입니다. 이름은 비슷하지만, 검증에 쓰는 건 스키마이고, 타입 표기(&lt;code&gt;body: CreateCatDto&lt;/code&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;4. 컨트롤러에 연결하기 &amp;mdash; @UsePipes&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 만든 것들을 하나로 잇습니다. 핵심은 &lt;code&gt;@UsePipes&lt;/code&gt; 데코레이터입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// cats.controller.ts
import { Controller, Post, Body, UsePipes } from '@nestjs/common';
import { ZodValidationPipe } from './zod-validation.pipe';
import { createCatSchema, CreateCatDto } from './create-cat.dto';

@Controller('cats')
export class CatsController {
  @Post()
  @UsePipes(new ZodValidationPipe(createCatSchema))
  create(@Body() createCatDto: CreateCatDto) {
    // 여기 도달했다면 createCatDto는 스키마에 100% 부합함이 보장됨
    return createCatDto;
  }
}&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;code&gt;@UsePipes(new ZodValidationPipe(createCatSchema))&lt;/code&gt; &amp;rarr; &quot;&lt;code&gt;create&lt;/code&gt;가 실행되기 &lt;b&gt;전에&lt;/b&gt; 요청 body를 이 파이프로 검증해라.&quot;&lt;/li&gt;
&lt;li&gt;그래서 &lt;code&gt;create&lt;/code&gt; 메서드 안에 들어온 순간, &lt;code&gt;createCatDto&lt;/code&gt;는 &lt;b&gt;이미 유효함이 보장&lt;/b&gt;됩니다. 메서드 안에서 다시 &lt;code&gt;if&lt;/code&gt;로 검사할 필요가 없어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 흔한 실수 &amp;mdash; &lt;code&gt;new&lt;/code&gt;로 만든 파이프는 DI를 못 받습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예제는 &lt;code&gt;@UsePipes&lt;/code&gt; 안에서 &lt;code&gt;new&lt;/code&gt; 키워드로 파이프 인스턴스를 &lt;b&gt;직접&lt;/b&gt; 만들었습니다. 스키마만 넘기는 단순한 경우엔 문제없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 만약 파이프가 &lt;b&gt;DB 서비스 같은 다른 프로바이더를 주입받아야 한다면&lt;/b&gt;, 이 방식은 못 씁니다. &lt;code&gt;new&lt;/code&gt;로 직접 생성한 인스턴스는 &lt;b&gt;NestJS의 의존성 주입(DI) 컨테이너를 거치지 않기 때문&lt;/b&gt;입니다. 생성자에 &lt;code&gt;@Injectable()&lt;/code&gt; 서비스를 넣어도 NestJS가 채워주지 못해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 경우엔 클래스 자체를 넘겨 NestJS가 생성하게 하는 방식을 씁니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 파이프가 DI가 필요할 때: 인스턴스(new) 대신 클래스를 넘김
@UsePipes(ZodValidationPipe)          // 메서드/컨트롤러 레벨
// 또는 전역 바인딩:
// app.useGlobalPipes(new ZodValidationPipe()) &amp;mdash; main.ts
// 또는 프로바이더 바인딩(APP_PIPE 토큰) &amp;mdash; DI 지원 + 전역 적용&lt;/code&gt;&lt;/pre&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; &amp;rarr; &lt;code&gt;new ZodValidationPipe(schema)&lt;/code&gt; (지금 방식, OK). &lt;b&gt;다른 서비스 주입 필요&lt;/b&gt; &amp;rarr; DI를 타는 전역/프로바이더 바인딩 방식. 지금 단계에선 &quot;&lt;code&gt;new&lt;/code&gt;는 DI를 못 탄다&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;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;한 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Zod로 가독성 높은 데이터 &lt;b&gt;스키마&lt;/b&gt; 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PipeTransform&lt;/code&gt;을 구현한 &lt;b&gt;커스텀 파이프&lt;/b&gt; 작성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;파이프의 &lt;code&gt;transform&lt;/code&gt; 안에서 &lt;code&gt;schema.parse()&lt;/code&gt;로 &lt;b&gt;실제 검증&lt;/b&gt; 수행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@UsePipes&lt;/code&gt;로 파이프를 특정 컨트롤러 메서드에 &lt;b&gt;연결&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Zod vs class-validator&lt;/b&gt; &amp;mdash; Zod는 강력하고 모던하지만, NestJS 생태계엔 또 다른 표준인 &lt;b&gt;&lt;code&gt;class-validator&lt;/code&gt; + &lt;code&gt;class-transformer&lt;/code&gt;&lt;/b&gt; 방식도 있습니다. DTO &lt;b&gt;클래스&lt;/b&gt; 안에 &lt;code&gt;@IsEmail()&lt;/code&gt;, &lt;code&gt;@IsInt()&lt;/code&gt; 같은 데코레이터를 직접 붙이는 방식이죠. 많은 NestJS 프로젝트가 이 방식을 쓰므로, 다음엔 이쪽도 함께 익혀두면 좋습니다.&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;Zod&lt;/b&gt;: 스키마가 곧 타입(&lt;code&gt;z.infer&lt;/code&gt;). 함수형&amp;middot;프레임워크 중립적. 프론트/백 공유하기 좋음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;class-validator&lt;/b&gt;: 클래스 + 데코레이터. NestJS의 &lt;code&gt;ValidationPipe&lt;/code&gt;가 기본 내장이라 파이프를 직접 안 만들어도 됨. NestJS 관례에 가장 잘 녹아듦.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>공부/추가공부</category>
      <category>zod</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/325</guid>
      <comments>https://sesam-dev.tistory.com/325#entry325comment</comments>
      <pubDate>Thu, 2 Jul 2026 01:50:27 +0900</pubDate>
    </item>
    <item>
      <title>회원가입&amp;middot;로그인 직접 설계하며 배운 것들 (GS인증 / KISA 기준)</title>
      <link>https://sesam-dev.tistory.com/324</link>
      <description>&lt;h1&gt;주니어 개발자가 회원가입&amp;middot;로그인 직접 설계하며 배운 것들 (GS인증 / KISA 기준)&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증(회원가입&amp;middot;로그인) 기능을 처음부터 설계하면서 계속 막히고 헷갈렸다. 비밀번호는 어떻게 저장하지? 이메일은? SHA-256이면 되나? 소셜이랑 로컬 계정이 겹치면? 탈퇴는 어떻게 처리하지? 하나씩 파고들면서 알게 된 것들을 &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;1. 계정 통합 &amp;mdash; &quot;1 이메일 = 1 계정&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 &lt;b&gt;이메일/비밀번호(로컬)&lt;/b&gt; 로도, &lt;b&gt;구글&amp;middot;카카오&amp;middot;네이버(소셜)&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; 로컬로 가입한 사람이 나중에 같은 이메일로 소셜 로그인하면? 계정이 2개 생기나?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답은 &lt;b&gt;&quot;이메일 하나 = 계정 하나&quot;&lt;/b&gt;. 같은 이메일이면 계정(User)은 하나로 두고, &lt;b&gt;로그인 수단(Provider)만 추가로 연결&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;User (1) ──&amp;lt; (N) Provider
User      = 신원 (이메일&amp;middot;이름&amp;middot;사진)
Provider  = 로그인 수단 1개 (LOCAL / GOOGLE / KAKAO / NAVER)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이미 가입된 이메일이면?&lt;/h3&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;/p&gt;
&lt;/blockquote&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;&quot;통합할까요?&quot;는 아무 때나 띄우는 게 아니라 &quot;다른(미연결) 수단을 새로 붙일 때만&quot;&lt;/b&gt; 띄운다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;처리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;새 이메일&lt;/td&gt;
&lt;td&gt;그냥 가입 / 소셜은 자동가입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기존 계정 + &lt;b&gt;새 수단&lt;/b&gt; 추가&lt;/td&gt;
&lt;td&gt;&lt;b&gt;&quot;연결할까요?&quot; &amp;rarr; 연결 후 로그인&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기존 계정 + &lt;b&gt;같은 수단&lt;/b&gt; 재시도&lt;/td&gt;
&lt;td&gt;그냥 로그인 (&quot;이미 가입됨, 로그인하세요&quot;)&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; &quot;이 이메일은 구글로 가입됐어요&quot;처럼 &lt;b&gt;어떤 걸로 가입했는지 알려줘야 하나?&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;b&gt;로그인 화면에선 알려줘도 되지만, 가입/이메일 입력 단계에선 숨기는 게 안전&lt;/b&gt;하다. 이메일만 넣어보고 &quot;얘는 구글 쓰네&quot;를 알아낼 수 있으면 정보 노출이니까. 우리는 &lt;b&gt;&quot;소셜 로그인으로 가입돼 비밀번호가 없어요&quot;&lt;/b&gt; 정도로만 안내하기로 했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심:&lt;/b&gt; 통합의 기준은 &lt;b&gt;이메일&lt;/b&gt;이다. 소셜이 넘겨주는 이메일이 기존 이메일과 같으면 같은 사람으로 보고 연결한다. (소셜의 고유 id인 &lt;code&gt;sub&lt;/code&gt;는 매칭 키가 아니라, 그냥 그 수단을 식별하려고 저장하는 값)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 회원 탈퇴와 soft delete &amp;mdash; FK 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탈퇴는 데이터를 진짜 지우지 않고 &lt;b&gt;&lt;code&gt;IsDeleted = true&lt;/code&gt;로 표시만&lt;/b&gt; 하는 &lt;b&gt;soft delete&lt;/b&gt;로 처리한다. (복구&amp;middot;감사&amp;middot;재가입 때문에)&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; 회원(User) 지우면 그 사람의 로그인 수단(Provider)도 같이 지워져야 하잖아. &lt;b&gt;FK에 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; 걸면 되는 거 아냐?&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&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;code&gt;ON DELETE CASCADE&lt;/code&gt;는 진짜 &lt;code&gt;DELETE&lt;/code&gt; 문에만 작동한다. soft delete는 사실 &lt;code&gt;UPDATE&lt;/code&gt;(IsDeleted=true)라서 CASCADE가 안 터진다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;UPDATE &quot;User&quot; SET &quot;IsDeleted&quot;=true WHERE ...   -- DELETE가 아니라 UPDATE!&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;그럼 &lt;code&gt;ON UPDATE CASCADE&lt;/code&gt;로 바꾸면?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 그것도 안 된다. &lt;code&gt;ON UPDATE CASCADE&lt;/code&gt;는 &lt;b&gt;부모의 키 값(ID)이 바뀔 때&lt;/b&gt; 따라가는 거지, &lt;code&gt;IsDeleted&lt;/code&gt; 같은 다른 컬럼은 안 본다. FK는 &lt;b&gt;키 컬럼만&lt;/b&gt; 쫓아간다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정답:&lt;/b&gt; soft delete는 DB가 아니라 &lt;b&gt;코드(서비스)에서&lt;/b&gt; 처리한다. 탈퇴할 때 User와 그 사람의 Provider들을 &lt;b&gt;코드로 같이 &lt;code&gt;IsDeleted=true&lt;/code&gt;&lt;/b&gt; 해줘야 한다. (안 그러면 살아남은 소셜 수단 때문에 나중에 같은 소셜로 재가입이 안 된다 &amp;mdash; UNIQUE 제약에 걸림)&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; &quot;Provider를 탈퇴처리한다&quot;는 게 &lt;b&gt;다른 회원까지 영향 주는 거야?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 아니다. 탈퇴는 항상 &lt;b&gt;&lt;code&gt;WHERE UserID = 그 사람&lt;/code&gt;&lt;/b&gt; 으로 스코프된다. UserID가 울타리라서, 1번 회원의 일이 2번 회원에게 절대 안 넘어간다. &quot;회원 탈퇴 = 그 회원 + 그 회원의 수단들&quot;만.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;회원 탈퇴 &amp;ne; 소셜 연결 해제&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;회원 탈퇴 = User + 그 사람의 &lt;b&gt;모든&lt;/b&gt; 수단 정리&lt;/li&gt;
&lt;li&gt;소셜 연결 해제 = &lt;b&gt;그 수단 1개만&lt;/b&gt; 정리 (계정&amp;middot;다른 수단은 유지)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그럼 FK CASCADE는 왜 걸어둠?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;물리 삭제(개인정보 파기)&lt;/b&gt; 때 쓰려고. 평소 탈퇴(soft)는 코드가, 나중에 진짜 &lt;code&gt;DELETE&lt;/code&gt; 할 때는 CASCADE가 Provider를 자동 정리한다. 두 층이 각자 담당.&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;mdash; &lt;code&gt;ON DELETE SET NULL&lt;/code&gt;:&lt;/b&gt; 부모 삭제 시 자식 행은 남기고 FK 칸만 NULL로 비운다. 감사 로그처럼 &quot;보존은 하되 사람 연결만 끊고 싶을 때&quot; 쓴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 코드에서 &lt;code&gt;cascade=REMOVE&lt;/code&gt;(엔티티) 대신 벌크 UPDATE?&lt;/b&gt; 이 프로젝트는 이미 &quot;전용 벌크 soft-delete&quot; 패턴으로 통일돼 있다. 엔티티 cascade는 자식을 다 로딩해 하나씩 UPDATE(N번)하고 동작이 숨겨지는 반면, 벌크는 &lt;code&gt;UPDATE ... WHERE UserID=?&lt;/code&gt; 한 방이고 서비스 코드에 명시적으로 보인다. &amp;rarr; 컨벤션과 성능&amp;middot;가독성 모두 벌크가 낫다.&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;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 평문 저장은 당연히 X. 그럼 SHA-256으로 해시하면?&lt;/h3&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; 데이터를 SHA-256으로 해시해서 저장하면 &lt;b&gt;남이 풀 수 있어?&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;되돌리기(복호화)&quot;는 불가능하다.&lt;/b&gt; SHA-256은 단방향이라 &lt;code&gt;해시 &amp;rarr; 원본&lt;/code&gt;으로 가는 역함수가 없다. 근데 함정이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;되돌리기&quot;는 못 해도 &quot;맞춰보기&quot;는 된다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;공격자: &quot;1234&quot; &amp;rarr; SHA-256 &amp;rarr; 저장된 해시랑 같나?
        &quot;password&quot; &amp;rarr; SHA-256 &amp;rarr; 같나?
        (흔한 값 수십억 개 대입&amp;hellip;)  &amp;larr; 하나 맞으면 원본 알아냄&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SHA-256은 &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;3-2. salt &amp;mdash; 왜 필요하고, 왜 사용자마다 달라야 하나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;salt&lt;/b&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;salt가 없으면: 같은 비밀번호 쓰는 사람들의 해시가 &lt;b&gt;전부 동일&lt;/b&gt; &amp;rarr; 하나 뚫리면 전원 뚫림 + 레인보우 테이블에 취약.&lt;/li&gt;
&lt;li&gt;salt가 &lt;b&gt;사용자마다 다르면&lt;/b&gt;: 같은 &lt;code&gt;password123&lt;/code&gt;이라도 해시가 전부 달라짐 &amp;rarr; 레인보우 테이블 무력화.&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; salt를 &lt;b&gt;저장 안 하고&lt;/b&gt; 그냥 &lt;code&gt;SHA-256(salt+password)&lt;/code&gt; 하면 안 돼?&lt;br /&gt;&amp;rarr; 안 된다. 랜덤 salt를 &lt;b&gt;저장 안 하면 로그인 검증 자체가 불가능&lt;/b&gt;하다(그 salt를 다시 못 구하니까). salt는 어떤 형태로든 &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;salt가 매번 랜덤 + 저장 안 함 &amp;rarr; &lt;b&gt;검증 불가&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;salt가 앱 고정 전역값(pepper) &amp;rarr; 되지만 전원 같은 salt라 &lt;b&gt;약함&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;salt가 사용자별 &amp;rarr; &lt;b&gt;저장해야 함&lt;/b&gt; (또는 이미 있는 값에서 유도)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. bcrypt는 이 문제를 어떻게? (이전 프로젝트 확인)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사내 이전 프로젝트를 열어보니 &lt;b&gt;bcrypt&lt;/b&gt;를 쓰고 있었다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;return new BCryptPasswordEncoder();
// 가입: encode(password) / 검증: matches(입력, 저장값)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bcrypt는 &lt;b&gt;별도 salt 컬럼이 없는데도&lt;/b&gt; 사용자별 랜덤 salt를 쓴다. &lt;b&gt;비밀!&lt;/b&gt; &amp;mdash; &lt;b&gt;salt를 해시 문자열 안에 같이 넣어&lt;/b&gt; 저장하기 때문.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$2a$10$N9qo8uLOickgx2ZMRZoMy.Mrq4t...   (60자)
 │  │  └── 22자 salt ──┘└──── 31자 hash ────┘
알고리즘  반복 강도&lt;/code&gt;&lt;/pre&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; &quot;salt를 저장 안 함&quot;의 진짜 의미는 &lt;b&gt;&quot;별도 컬럼 없이 해시 문자열에 같이 저장&quot;&lt;/b&gt;이었다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-4. 빠른 해시의 약점 &amp;rarr; 반복(key stretching)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SHA-256은 &lt;b&gt;너무 빨라서&lt;/b&gt; salt를 붙여도 무차별 대입에 약하다. 그래서 &lt;b&gt;일부러 수십만 번 반복&lt;/b&gt;해서 느리게 만든다. 정상 로그인은 1번뿐이라 체감 안 되지만, 공격자는 후보마다 그 반복을 다 돌아야 해서 수십만 배 느려진다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;SHA-256 + salt + 반복&quot; = 그게 바로 PBKDF2다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-5. PBKDF2-HMAC-SHA256&lt;/h3&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;code&gt;PBKDF2-salt-SHA256&lt;/code&gt;인가?&lt;br /&gt;&amp;rarr; 아니다. 정확한 이름은 &lt;b&gt;&lt;code&gt;PBKDF2-HMAC-SHA256&lt;/code&gt;&lt;/b&gt;. salt는 이름이 아니라 &lt;b&gt;입력값&lt;/b&gt;이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;PBKDF2( PRF=HMAC-SHA256,  password,  salt,  반복횟수 )  &amp;rarr;  해시
   │         │             입력      입력    입력
알고리즘   내부 해시함수&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PBKDF2가 &lt;b&gt;사용자별 랜덤 salt(&amp;ge;16바이트)&lt;/b&gt; 를 만들어 넣고, 수십만 번 반복하고, &lt;b&gt;salt&amp;middot;반복 정보를 결과 문자열에 같이 인코딩&lt;/b&gt;한다 &amp;rarr; 별도 salt 컬럼 불필요.&lt;/li&gt;
&lt;li&gt;검증은 저장값에서 salt를 꺼내 같은 방식으로 재계산해 비교. Spring 내장 &lt;code&gt;Pbkdf2PasswordEncoder&lt;/code&gt;를 쓰면 &lt;code&gt;encode()&lt;/code&gt; / &lt;code&gt;matches()&lt;/code&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; 그럼 사용자마다 salt가 달라?&lt;br /&gt;&amp;rarr; &lt;b&gt;그렇다.&lt;/b&gt; 비밀번호 저장할 때마다 새 랜덤 salt를 만든다. 그래서 같은 비번이어도 해시가 전부 다르다. 그 salt는 &lt;code&gt;PasswordHash&lt;/code&gt;에 같이 저장돼서 검증도 문제없다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-6. crypto는 직접 짜지 마라&lt;/h3&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; crypto가 뭐야?&lt;br /&gt;&amp;rarr; &lt;b&gt;cryptography(암호학)의 줄임말.&lt;/b&gt; &quot;커스텀 crypto 짜지 마라&quot; = salt 생성&amp;middot;해시&amp;middot;비교 같은 &lt;b&gt;보안 계산을 직접 구현하지 마라&lt;/b&gt;는 뜻.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호 코드는 사소한 실수(예측 가능한 난수, &lt;code&gt;==&lt;/code&gt; 비교로 인한 timing attack, 반복 처리 실수)가 그대로 보안 구멍이 된다. &lt;b&gt;검증된 라이브러리(PBKDF2/bcrypt 인코더)를 쓰는 게 원칙.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-7. 근데 GS인증은 SHA-256을 쓰라던데? (KISA)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 제품 문서엔 &quot;SHA-256+salt&quot;라고 적혀 있었고, bcrypt로 갔다가 다시 SHA-256으로 돌아온 이력이 있었다. 알고 보니 이유가 있었다.&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;GS인증의 핵심 = &quot;제품이 문서대로 동작하는가&quot;&lt;/b&gt; (문서 == 구현).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;bcrypt는 KISA/KCMVP 승인 알고리즘 목록에 없다&lt;/b&gt; &amp;rarr; 국내 인증엔 부적합. (이전 프로젝트 bcrypt는 이 관점에선 아쉬운 선택이었던 것)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;KISA 가이드가 권장하는 정석 = &lt;code&gt;PBKDF2-HMAC-SHA256&lt;/code&gt;&lt;/b&gt; (salt &amp;ge;16B, 반복 ~10만+, 개인정보는 AES-256-GCM).&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; PBKDF2-HMAC-SHA256은 &lt;b&gt;&quot;SHA-256 기반&quot; 구현의 표준 방식&lt;/b&gt;이다. 즉 PBKDF2를 쓰는 게 SHA-256 요구에서 &lt;b&gt;벗어나는 게 아니라, 그걸 제대로 구현하는 것.&lt;/b&gt; 제품설명서에 &quot;SHA-256 기반 PBKDF2-HMAC-SHA256&quot;이라고만 적으면 GS 문서와도 완전 정합.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 결정: 비밀번호 = PBKDF2-HMAC-SHA256 (Spring 내장 인코더).&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 이메일은 어떻게 저장할까 &amp;mdash; 암호화 vs 해시&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;&lt;b&gt;검색&amp;middot;중복확인&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;b&gt;컬럼 2개&lt;/b&gt;에 저장한다고?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맞다. 둘을 나눠서 저장한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컬럼&lt;/th&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;방향&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EmailEnc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;AES-256-GCM&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;양방향(복원 O)&lt;/td&gt;
&lt;td&gt;화면 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EmailHash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;HMAC-SHA256(비밀키, 이메일)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단방향(복원 X)&lt;/td&gt;
&lt;td&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;b&gt;정규화&lt;/b&gt; &amp;mdash; 소문자화 + 공백 제거 &amp;mdash; 를 먼저 한다. 대소문자 다르다고 별개 계정 되면 안 되니까.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 해시 개념 총정리 (계속 헷갈렸던 것들)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. SHA-256 vs HMAC-SHA256 &amp;mdash; 아예 다른 거야?&lt;/h3&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;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아니다. HMAC-SHA256이 SHA-256을 &quot;안에서 사용&quot;한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;SHA-256      = 기본 해시 함수 (부품)
HMAC-SHA256  = 그 SHA-256을 비밀키와 함께 감싼 것 (조립품)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이는 딱 하나 &amp;mdash; &lt;b&gt;비밀키&lt;/b&gt;. 출력 길이는 둘 다 256비트(64 hex)로 같다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. 키가 있으면 무조건 HMAC이야?&lt;/h3&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;code&gt;SHA-256(키+데이터)&lt;/code&gt;도 키가 있으니 HMAC 아냐?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아니다.&lt;/b&gt; 키를 붙였다고 HMAC이 되는 게 아니라, &lt;b&gt;키를 &quot;HMAC 방식으로&quot; 써야&lt;/b&gt; HMAC이다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;① SHA-256(데이터)          &amp;larr; 키 없음                = 그냥 SHA-256
② SHA-256(키 + 데이터)      &amp;larr; 키 있음, 그냥 붙임      = HMAC 아님 (❌ 취약)
③ HMAC-SHA256(키, 데이터)   &amp;larr; 키 있음 + 올바른 구조   = HMAC ⭕&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;②의 &quot;키&quot;도 &lt;b&gt;비밀키로 쓰려던 것&lt;/b&gt;이고 작동은 한다. 다만 &lt;b&gt;구조적 허점(length-extension)&lt;/b&gt; 이 있어서 안 쓰고, 같은 목적을 안전하게 만든 ③(HMAC)을 쓴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. HMAC의 키 = 비밀키&lt;/h3&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; HMAC의 &quot;키&quot;가 비밀키 맞아?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;맞다.&lt;/b&gt; 서버 설정/KMS에만 보관하고 &lt;b&gt;DB엔 저장 안 한다.&lt;/b&gt; 키가 유출되면 HMAC 보호 효과는 0.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;salt와 HMAC 키는 성격이 정반대라 헷갈리면 안 된다:&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;비밀번호 salt&lt;/th&gt;
&lt;th&gt;HMAC 키&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;공개 여부&lt;/td&gt;
&lt;td&gt;&lt;b&gt;공개&lt;/b&gt; (DB에 같이 저장)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;비밀&lt;/b&gt; (DB 저장 X)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개수&lt;/td&gt;
&lt;td&gt;사용자마다 다름 (랜덤)&lt;/td&gt;
&lt;td&gt;전역 1개 (고정)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 입력 &amp;rarr;&lt;/td&gt;
&lt;td&gt;매번 다른 해시&lt;/td&gt;
&lt;td&gt;&lt;b&gt;항상 같은 해시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;목적&lt;/td&gt;
&lt;td&gt;숨기기 (검색 불필요)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;검색되게&lt;/b&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;  이메일에 salt 대신 HMAC(고정 비밀키)을 쓰는 이유가 여기 있다. 같은 이메일 &amp;rarr; &lt;b&gt;항상 같은 EmailHash&lt;/b&gt;여야 검색이 되니까. 그러면서 키가 비밀이라 DB만 털려선 역추적 불가.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-4. length-extension attack이 뭔데?&lt;/h3&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;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;length-extension = &quot;해시값만 보고, 원본 뒤에 내용을 몰래 이어붙인 새 해시를 (키 없이) 만들어내는&quot; 공격.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SHA-256은 데이터를 블록 단위로 이어 처리하고 &lt;b&gt;최종 출력 = 마지막 내부 상태&lt;/b&gt;다. 그래서 그 출력(해시)을 &lt;b&gt;이어받아 계산을 계속&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;서버:   해시 = SHA-256(비밀키 + &quot;user=guest&quot;)
공격자: 그 해시를 &quot;중간 상태&quot;로 삼아 이어감
       &amp;rarr; SHA-256(비밀키 + &quot;user=guest&quot; + 패딩 + &quot;&amp;amp;admin=true&quot;) 위조!
       (비밀키를 몰라도 유효한 새 해시 생성 &amp;rarr; 권한 위조)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HMAC은 키로 한 번 더 바깥을 감싸서&lt;/b&gt; 이 이어붙이기를 원천 차단한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;HMAC = SHA-256( 키2 + SHA-256(키1 + 데이터) )
                       └ 이어붙여도 여기까지 ┘
       └ 바깥 해시가 덮어서 차단 ┘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고: 우리 EmailHash 용도에선 위조가 목적이 아니라 실제 위협은 작지만, 어차피 HMAC 함수 쓰면 공짜로 안전해지니 그냥 HMAC을 쓴다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;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;JWKS 엔드포인트&lt;/b&gt; &amp;mdash; 외부 서버가 우리 JWT를 검증하려면 공개키를 내줘야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OAuth2 로그인 성공 핸들러&lt;/b&gt; &amp;mdash; 소셜 인증 성공 후 우리 JWT 발급&amp;middot;쿠키 세팅하는 지점&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인증 진입점(EntryPoint)&lt;/b&gt; &amp;mdash; 보안 &lt;b&gt;필터 단계&lt;/b&gt;에서 터지는 401은 &lt;code&gt;@ControllerAdvice&lt;/code&gt;가 못 잡음 &amp;rarr; 따로 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스케줄러&lt;/b&gt; &amp;mdash; 감사 로그 2년 후 purge&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프론트에서 놓쳤던 것:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;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;토큰 저장소(store)&lt;/b&gt; 와 &lt;b&gt;인증 가드&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; &quot;핵심 흐름&quot;만 그리면 &lt;b&gt;외부 검증&amp;middot;성공 핸들러&amp;middot;예외 응답&amp;middot;스케줄러&lt;/b&gt; 같은 &quot;이음새&quot;를 빼먹기 쉽다. API 목록 &amp;harr; 컴포넌트 &amp;harr; 보안 요소를 &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;7. 최종 설계 요약&lt;/h2&gt;
&lt;table style=&quot;height: 150px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 24px;&quot;&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;항목&lt;/th&gt;
&lt;th style=&quot;height: 24px;&quot;&gt;결정&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;계정 모델&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1 이메일 = 1 계정 (User 1 : Provider N), 통합은 &quot;물어보고 연결&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;비밀번호&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;PBKDF2-HMAC-SHA256&lt;/b&gt; (사용자별 salt&amp;ge;16B + 반복, Spring 내장 인코더)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;이메일&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;code&gt;EmailEnc&lt;/code&gt; = AES-256-GCM(표시) + &lt;code&gt;EmailHash&lt;/code&gt; = HMAC-SHA256(검색)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;탈퇴&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;soft delete, 코드에서 User+Provider 벌크 UPDATE (FK CASCADE는 물리파기용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;토큰&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;JWT RS256 + refresh 회전 + access 블랙리스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;인증 기준&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;KISA/GS 정석 준수 (bcrypt ❌ 미승인)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며 &amp;mdash; 내가 얻은 교훈&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;해시는 &quot;풀리는&quot; 게 아니라 &quot;찍어서 맞히는&quot; 게 문제다.&lt;/b&gt; 그래서 salt&amp;middot;반복&amp;middot;비밀키로 그 맞추기를 막는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;저장 안 함&quot;의 진짜 의미&lt;/b&gt;는 대개 &quot;해시 문자열에 같이 저장&quot;이었다 (bcrypt&amp;middot;PBKDF2).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;암호는 직접 짜지 말고 검증된 라이브러리를 쓴다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;soft delete는 UPDATE라 FK CASCADE가 안 먹는다.&lt;/b&gt; DB 규칙과 앱 로직의 역할을 구분하자.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인증 요건은 &quot;무엇이 더 안전하냐&quot;가 아니라 &quot;문서&amp;middot;가이드(KISA/GS)와 맞느냐&quot;도 같이 봐야 한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;모르는 개념(salt, HMAC, PBKDF2, length-extension)은 &lt;b&gt;&quot;왜 이게 필요한가&quot;&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;처음엔 &quot;그냥 비밀번호 해시해서 저장하면 되는 거 아냐?&quot;였는데, 파고들수록 salt &amp;rarr; 반복 &amp;rarr; PBKDF2 &amp;rarr; HMAC &amp;rarr; 인증 가이드까지 줄줄이 이어졌다. 하나씩 &quot;왜?&quot;를 던진 게 제일 도움이 됐다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관련 설계 문서: &lt;code&gt;01_file/03_auth/01_obsidian/auth-design.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;참고 글: KISA 가이드 정리 (Medium, sungwon-eni) &amp;mdash; &quot;왜 한국 SW 인증엔 bcrypt보다 SHA-256+salt일까&quot;&lt;/li&gt;
&lt;li&gt;관련 노트: [[auth-client-structure]], [[tanstack-router-라우트트리]], [[zod-resolver]]&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>공부/Spring</category>
      <category>GS인증</category>
      <category>로그인</category>
      <category>회원가입</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/324</guid>
      <comments>https://sesam-dev.tistory.com/324#entry324comment</comments>
      <pubDate>Thu, 2 Jul 2026 01:44:55 +0900</pubDate>
    </item>
    <item>
      <title>React Hook Form으로 로그인 폼 만들기 (입력 검증 + 접근성까지)</title>
      <link>https://sesam-dev.tistory.com/323</link>
      <description>&lt;h1&gt;React Hook Form으로 로그인 폼 만들기 (입력 검증 + 접근성까지)&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React로 폼을 만들다 보면 &lt;code&gt;useState&lt;/code&gt;가 입력칸 개수만큼 늘어나고, &lt;code&gt;onChange&lt;/code&gt; 핸들러가 덕지덕지 붙는 경험을 하게 됩니다. &lt;b&gt;React Hook Form(RHF)&lt;/b&gt;은 이 보일러플레이트를 걷어내 주는 라이브러리입니다. 이 글에서는 로그인 폼 하나를 만들면서 RHF의 핵심 3가지 &amp;mdash; &lt;code&gt;register&lt;/code&gt;, &lt;code&gt;handleSubmit&lt;/code&gt;, &lt;code&gt;formState&lt;/code&gt; &amp;mdash; 를 익히고, 중복 제출 방지&amp;middot;입력 검증&amp;middot;웹 접근성까지 붙여봅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 설치&lt;/h2&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;npm install react-hook-form&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CodeSandbox 같은 온라인 에디터라면 Dependencies에서 &lt;code&gt;react-hook-form&lt;/code&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;1. 왜 RHF가 빠를까? (먼저 알고 가면 이해가 쉽습니다)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 폼에는 크게 두 방식이 있습니다.&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;Controlled(제어) 컴포넌트&lt;/b&gt;: &lt;code&gt;value={state}&lt;/code&gt; + &lt;code&gt;onChange&lt;/code&gt;로 입력값을 React state에 묶는 방식. 키를 누를 때마다 컴포넌트가 리렌더링됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Uncontrolled(비제어) 컴포넌트&lt;/b&gt;: 값을 React state가 아니라 DOM(input 자체)에 맡기고, 필요할 때 &lt;code&gt;ref&lt;/code&gt;로 읽어오는 방식.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RHF는 기본적으로 비제어 방식&lt;/b&gt;입니다. 그래서 타이핑할 때마다 리렌더가 발생하지 않아 빠릅니다. 뒤에서 나오는 &lt;code&gt;register&lt;/code&gt;가 바로 이 &lt;code&gt;ref&lt;/code&gt; 연결을 대신 해주는 함수예요. &quot;왜 &lt;code&gt;value&lt;/code&gt;/&lt;code&gt;onChange&lt;/code&gt;를 안 쓰지?&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;2. 기본 마크업부터&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;code&gt;&amp;lt;label htmlFor=&quot;email&quot;&amp;gt;&lt;/code&gt; 의 값과 &lt;code&gt;&amp;lt;input id=&quot;email&quot;&amp;gt;&lt;/code&gt; 의 값을 &lt;b&gt;똑같이&lt;/b&gt; 맞춰야 라벨 클릭 시 인풋에 포커스가 갑니다. (접근성 기본)&lt;/li&gt;
&lt;li&gt;제출 버튼은 &lt;code&gt;type=&quot;submit&quot;&lt;/code&gt; 이어야 폼의 &lt;code&gt;onSubmit&lt;/code&gt;이 발동합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;export default function LoginForm() {
  return (
    &amp;lt;form&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;email&quot;&amp;gt;이메일&amp;lt;/label&amp;gt;
        &amp;lt;input id=&quot;email&quot; type=&quot;email&quot; placeholder=&quot;test@test.com&quot; /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;password&quot;&amp;gt;비밀번호&amp;lt;/label&amp;gt;
        &amp;lt;input id=&quot;password&quot; type=&quot;password&quot; placeholder=&quot;********&quot; /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;button type=&quot;submit&quot;&amp;gt;로그인&amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. useForm 연결하기 &amp;mdash; register와 handleSubmit&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;useForm()&lt;/code&gt;을 호출하면 여러 함수/속성이 담긴 객체가 나옵니다. 그중 가장 기본은 &lt;code&gt;register&lt;/code&gt;와 &lt;code&gt;handleSubmit&lt;/code&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;import { useForm } from &quot;react-hook-form&quot;;

export default function LoginForm() {
  const { register, handleSubmit } = useForm();

  const onSubmit = (data) =&amp;gt; {
    // data = { email: &quot;...&quot;, password: &quot;...&quot; }
    alert(JSON.stringify(data));
  };

  return (
    &amp;lt;form onSubmit={handleSubmit(onSubmit)} noValidate&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;email&quot;&amp;gt;이메일&amp;lt;/label&amp;gt;
        &amp;lt;input id=&quot;email&quot; type=&quot;email&quot; placeholder=&quot;test@test.com&quot;
          {...register(&quot;email&quot;)} /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;password&quot;&amp;gt;비밀번호&amp;lt;/label&amp;gt;
        &amp;lt;input id=&quot;password&quot; type=&quot;password&quot; placeholder=&quot;********&quot;
          {...register(&quot;password&quot;)} /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;button type=&quot;submit&quot;&amp;gt;로그인&amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&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;code&gt;{...register(&quot;email&quot;)}&lt;/code&gt; 의 정체&lt;/b&gt;&lt;br /&gt;&lt;code&gt;register(&quot;email&quot;)&lt;/code&gt;는 &lt;code&gt;{ name, onChange, onBlur, ref }&lt;/code&gt; 객체를 반환합니다. 이걸 &lt;code&gt;{...}&lt;/code&gt;로 인풋에 펼쳐서(spread) 꽂는 겁니다. &lt;code&gt;name&lt;/code&gt;으로 필드를 식별하고, &lt;code&gt;ref&lt;/code&gt;로 DOM에 연결해요. 그래서 &lt;code&gt;onSubmit&lt;/code&gt;으로 넘어오는 &lt;code&gt;data&lt;/code&gt;의 키가 바로 이 &lt;code&gt;&quot;email&quot;&lt;/code&gt;, &lt;code&gt;&quot;password&quot;&lt;/code&gt; 문자열입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② &lt;code&gt;handleSubmit(onSubmit)&lt;/code&gt; 이 하는 일&lt;/b&gt;&lt;br /&gt;&lt;code&gt;handleSubmit&lt;/code&gt;은 우리 콜백(&lt;code&gt;onSubmit&lt;/code&gt;)을 &lt;b&gt;감싸는&lt;/b&gt; 함수입니다. 제출 시 브라우저 기본 새로고침을 막아주고(&lt;code&gt;preventDefault&lt;/code&gt;), 값을 모아서 검증한 뒤, &lt;b&gt;검증을 통과했을 때만&lt;/b&gt; &lt;code&gt;onSubmit(data)&lt;/code&gt;을 호출합니다. &lt;code&gt;onSubmit={onSubmit}&lt;/code&gt;이 아니라 &lt;code&gt;onSubmit={handleSubmit(onSubmit)}&lt;/code&gt; 인 점 주의하세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;③ &lt;code&gt;noValidate&lt;/code&gt; 는 왜?&lt;/b&gt;&lt;br /&gt;&lt;code&gt;type=&quot;email&quot;&lt;/code&gt; 같은 인풋은 브라우저가 자체 검증 팝업을 띄웁니다. RHF로 검증을 직접 다룰 거라 이 브라우저 기본 검증이 방해가 됩니다. &lt;code&gt;&amp;lt;form noValidate&amp;gt;&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;4. 중복 제출 막기 &amp;mdash; formState.isSubmitting&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인은 보통 서버 통신이라 시간이 걸립니다. 그 사이 사용자가 버튼을 두 번 누르면 중복 제출이 됩니다. &lt;code&gt;formState.isSubmitting&lt;/code&gt;으로 제출 중인지 알 수 있으니, 그동안 버튼을 비활성화하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const {
  register,
  handleSubmit,
  formState: { isSubmitting },
} = useForm();

// 데모용 지연 (실제로는 여기서 fetch/axios 호출)
const onSubmit = async (data) =&amp;gt; {
  await new Promise((resolve) =&amp;gt; setTimeout(resolve, 1000));
  alert(JSON.stringify(data));
};

// ...
&amp;lt;button type=&quot;submit&quot; disabled={isSubmitting}&amp;gt;
  로그인
&amp;lt;/button&amp;gt;&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;code&gt;onSubmit&lt;/code&gt;을 &lt;code&gt;async&lt;/code&gt;로 만들면 RHF가 &lt;b&gt;Promise가 끝날 때까지&lt;/b&gt; &lt;code&gt;isSubmitting&lt;/code&gt;을 &lt;code&gt;true&lt;/code&gt;로 유지해줍니다. 그래서 별도 로딩 state를 만들 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isSubmitting&lt;/code&gt;을 그대로 &lt;code&gt;disabled&lt;/code&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;code&gt;new Promise(setTimeout)&lt;/code&gt;은 &lt;b&gt;데모용 가짜 지연&lt;/b&gt;입니다. 실제 코드에서는 이 자리에 &lt;code&gt;await fetch(...)&lt;/code&gt; 같은 실제 API 호출이 들어갑니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 입력 검증 추가하기 &amp;mdash; register의 두 번째 인자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;register&lt;/code&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;비밀번호: 필수 + 최소 8자&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;mel&quot;&gt;&lt;code&gt;&amp;lt;input
  id=&quot;email&quot;
  type=&quot;email&quot;
  placeholder=&quot;test@test.com&quot;
  {...register(&quot;email&quot;, {
    required: &quot;이메일은 필수 입력입니다.&quot;,
    pattern: {
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: &quot;이메일 형식에 맞지 않습니다.&quot;,
    },
  })}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;input
  id=&quot;password&quot;
  type=&quot;password&quot;
  placeholder=&quot;********&quot;
  {...register(&quot;password&quot;, {
    required: &quot;비밀번호는 필수 입력입니다.&quot;,
    minLength: {
      value: 8,
      message: &quot;8자리 이상 비밀번호를 사용하세요.&quot;,
    },
  })}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헷갈리는 지점 &amp;mdash; &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;code&gt;required: &quot;메시지&quot;&lt;/code&gt; 처럼 &lt;b&gt;문자열만&lt;/b&gt; 주면: 규칙은 켜지고 그 문자열이 에러 메시지가 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;minLength: { value: 8, message: &quot;...&quot; }&lt;/code&gt; 처럼 &lt;b&gt;객체&lt;/b&gt;로 주면: &lt;code&gt;value&lt;/code&gt;가 실제 규칙, &lt;code&gt;message&lt;/code&gt;가 에러 메시지입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;required&lt;/code&gt;도 객체 형태(&lt;code&gt;{ value: true, message: &quot;...&quot; }&lt;/code&gt;)로 쓸 수 있지만, 필수는 그냥 문자열로 쓰는 게 간결합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 에러 메시지 보여주기 &amp;mdash; formState.errors&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증이 실패하면 그 정보가 &lt;code&gt;formState.errors&lt;/code&gt;에 담깁니다. 필드명을 키로 접근합니다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;const {
  register,
  handleSubmit,
  formState: { isSubmitting, errors },
} = useForm();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;&amp;lt;input id=&quot;email&quot; /* ...register... */ /&amp;gt;
{errors.email &amp;amp;&amp;amp; &amp;lt;small role=&quot;alert&quot;&amp;gt;{errors.email.message}&amp;lt;/small&amp;gt;}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;errors.email&lt;/code&gt;이 존재한다 = 이메일 검증에 실패했다는 뜻.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;errors.email.message&lt;/code&gt;가 우리가 규칙에 적어둔 메시지입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;role=&quot;alert&quot;&lt;/code&gt;을 붙이면 스크린 리더가 이 메시지를 &lt;b&gt;자동으로 읽어줍니다&lt;/b&gt;. 접근성을 위해 꼭 넣으세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 시각적 피드백 + 접근성 &amp;mdash; aria-invalid&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력칸 자체에도 유효/무효 상태를 표시하면 좋습니다. &lt;code&gt;aria-invalid&lt;/code&gt; 속성이 스크린 리더에게 &quot;이 칸이 잘못됐다&quot;를 알려주고, 동시에 CSS 스타일링 훅으로도 쓸 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;&amp;lt;input
  id=&quot;email&quot;
  aria-invalid={errors.email ? &quot;true&quot; : &quot;false&quot;}
  {...register(&quot;email&quot;, { /* ... */ })}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;input[aria-invalid=&quot;true&quot;] {
  border-color: red;
}
input[aria-invalid=&quot;false&quot;] {
  border-color: green;
}&lt;/code&gt;&lt;/pre&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;8. 마지막 함정 &amp;mdash; 처음부터 초록불이 켜지는 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 상태 그대로 새로고침하면, &lt;b&gt;아무것도 입력 안 했는데 초록 테두리(통과)&lt;/b&gt; 로 보입니다. &lt;code&gt;errors.email&lt;/code&gt;이 아직 없으니 &lt;code&gt;aria-invalid&lt;/code&gt;가 &lt;code&gt;&quot;false&quot;&lt;/code&gt;가 되기 때문이죠. 부자연스럽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책: &lt;code&gt;formState.isSubmitted&lt;/code&gt;를 활용합니다. 이 값은 &lt;b&gt;사용자가 제출 버튼을 한 번이라도 눌렀는지&lt;/b&gt;를 알려줍니다. 제출한 적이 없으면 피드백을 아예 주지 않게 합니다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;const {
  register,
  handleSubmit,
  formState: { isSubmitting, isSubmitted, errors },
} = useForm();&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;aria-invalid={isSubmitted ? (errors.email ? &quot;true&quot; : &quot;false&quot;) : undefined}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제출 전(&lt;code&gt;isSubmitted === false&lt;/code&gt;): &lt;code&gt;aria-invalid&lt;/code&gt;를 &lt;code&gt;undefined&lt;/code&gt;로 &amp;rarr; 속성 자체가 안 붙어 &lt;b&gt;평범한 회색 인풋&lt;/b&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;code&gt;isSubmitted&lt;/code&gt; vs &lt;code&gt;isSubmitting&lt;/code&gt; 헷갈리지 마세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;isSubmitting&lt;/code&gt;: &lt;b&gt;지금 제출 처리 중&lt;/b&gt;인가 (버튼 비활성화용)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isSubmitted&lt;/code&gt;: &lt;b&gt;한 번이라도 제출을 시도&lt;/b&gt;했는가 (피드백 시점 제어용)&lt;/li&gt;
&lt;/ul&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;pre class=&quot;verilog&quot;&gt;&lt;code&gt;import { useForm } from &quot;react-hook-form&quot;;

export default function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitted, errors },
  } = useForm();

  const onSubmit = async (data) =&amp;gt; {
    // 데모용 지연 &amp;mdash; 실제로는 여기서 로그인 API 호출
    await new Promise((resolve) =&amp;gt; setTimeout(resolve, 1000));
    alert(JSON.stringify(data));
  };

  return (
    &amp;lt;form onSubmit={handleSubmit(onSubmit)} noValidate&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;email&quot;&amp;gt;이메일&amp;lt;/label&amp;gt;
        &amp;lt;input
          id=&quot;email&quot;
          type=&quot;email&quot;
          placeholder=&quot;test@test.com&quot;
          aria-invalid={isSubmitted ? (errors.email ? &quot;true&quot; : &quot;false&quot;) : undefined}
          {...register(&quot;email&quot;, {
            required: &quot;이메일은 필수 입력입니다.&quot;,
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: &quot;이메일 형식에 맞지 않습니다.&quot;,
            },
          })}
        /&amp;gt;
        {errors.email &amp;amp;&amp;amp; &amp;lt;small role=&quot;alert&quot;&amp;gt;{errors.email.message}&amp;lt;/small&amp;gt;}
      &amp;lt;/div&amp;gt;

      &amp;lt;div&amp;gt;
        &amp;lt;label htmlFor=&quot;password&quot;&amp;gt;비밀번호&amp;lt;/label&amp;gt;
        &amp;lt;input
          id=&quot;password&quot;
          type=&quot;password&quot;
          placeholder=&quot;********&quot;
          aria-invalid={isSubmitted ? (errors.password ? &quot;true&quot; : &quot;false&quot;) : undefined}
          {...register(&quot;password&quot;, {
            required: &quot;비밀번호는 필수 입력입니다.&quot;,
            minLength: {
              value: 8,
              message: &quot;8자리 이상 비밀번호를 사용하세요.&quot;,
            },
          })}
        /&amp;gt;
        {errors.password &amp;amp;&amp;amp; &amp;lt;small role=&quot;alert&quot;&amp;gt;{errors.password.message}&amp;lt;/small&amp;gt;}
      &amp;lt;/div&amp;gt;

      &amp;lt;button type=&quot;submit&quot; disabled={isSubmitting}&amp;gt;
        로그인
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리 &amp;mdash; 핵심 3가지&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;요소&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;register(&quot;name&quot;, 규칙)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인풋을 폼에 연결(ref) + 검증 규칙 등록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;handleSubmit(onSubmit)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;기본 새로고침 방지 + 검증 통과 시에만 &lt;code&gt;onSubmit(data)&lt;/code&gt; 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;formState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;errors&lt;/code&gt;(에러), &lt;code&gt;isSubmitting&lt;/code&gt;(제출 중), &lt;code&gt;isSubmitted&lt;/code&gt;(제출 이력) 등 폼 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한 걸음 더 나아가려면:&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;code&gt;zod&lt;/code&gt; + &lt;code&gt;@hookform/resolvers&lt;/code&gt;&lt;/b&gt;: 검증 규칙이 복잡해지면 &lt;code&gt;register&lt;/code&gt; 인라인 규칙 대신 스키마로 분리하는 게 유지보수에 좋습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Controller&lt;/code&gt; 컴포넌트&lt;/b&gt;: MUI, Ant Design 같은 비제어를 지원하지 않는 UI 라이브러리와 연결할 때 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트&lt;/b&gt;: React Testing Library로 &quot;빈 값 제출 &amp;rarr; 에러 노출&quot;, &quot;올바른 값 제출 &amp;rarr; onSubmit 호출&quot; 같은 시나리오를 작성해두면 안심입니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>공부/frontend</category>
      <category>react hook form</category>
      <category>zod</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/323</guid>
      <comments>https://sesam-dev.tistory.com/323#entry323comment</comments>
      <pubDate>Thu, 2 Jul 2026 01:41:46 +0900</pubDate>
    </item>
    <item>
      <title>비밀번호 저장, 그냥 암호화하면 되는 거 아닌가요? &amp;mdash; KISA 가이드라인 앞에서 멘붕한 이야기</title>
      <link>https://sesam-dev.tistory.com/322</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저는 얼마 전까지만 해도 비밀번호 암호화를 &quot;그냥 bcrypt 쓰면 되는 것&quot;이라고 생각했습니다. 강의에서 그렇게 배웠고, 다들 그렇게 쓰는 것 같았으니까요. 그런데 최근 프로젝트에서 보안 관련 기능을 맡으면서 암호화를 처음부터 제대로 공부하게 됐고, 제가 알고 있던 게 전부가 아니었다는 걸 알았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 과정에서 배운 것들을 정리한 글입니다. &quot;왜?&quot;라는 질문을 계속 던지면서 공부했기 때문에, 개념 하나하나를 최대한 쉽게 풀어보려고 했습니다. 저처럼 암호화가 막막하게 느껴지는 분들께 도움이 됐으면 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;암호화하면 되는 거 아니야?&quot; &amp;mdash; 시작은 이 질문이었습니다&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;b&gt;해시(Hash)&lt;/b&gt; 라고 부릅니다. 비밀번호에 쓰는 방식이 바로 이겁니다. 서버 입장에서 사용자의 실제 비밀번호를 알 필요가 없습니다. 로그인할 때 &quot;입력값을 해시한 결과&quot;와 &quot;DB에 저장된 해시값&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;이 차이를 모르면 잘못된 설계를 하게 됩니다. 비밀번호를 양방향으로 암호화하면 서버가 언제든 원문 비밀번호를 복원할 수 있다는 뜻인데, 이건 보안상 있어서는 안 되는 구조입니다. DB가 털리는 순간 모든 사용자의 실제 비밀번호가 노출됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;bcrypt, 너는 왜 여기서 안 돼?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀번호 해시 알고리즘을 공부하면서 bcrypt 외에 &lt;b&gt;Argon2&lt;/b&gt; 라는 알고리즘도 알게 됐습니다. Argon2는 2015년에 열린 국제 비밀번호 해싱 대회(PHC)에서 1위를 차지한 알고리즘인데, 연산할 때 메모리를 많이 잡아먹는 구조 덕분에 GPU로 수백만 개의 비밀번호를 병렬로 뚫으려는 공격에 특히 강합니다. 요즘 글로벌 기준에서는 bcrypt의 자리를 점점 대체하고 있는 추세입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 보면 bcrypt도, Argon2도 충분히 안전한 선택입니다. 실제로 해외 서비스에서는 두 알고리즘 모두 광범위하게 쓰입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 제가 작업하던 프로젝트는 국내 공공 기관 납품용이었고, &lt;b&gt;GS 인증&lt;/b&gt;과 &lt;b&gt;ISMS 인증&lt;/b&gt;이 필요했습니다. 이 순간부터 &quot;안전한가&quot;의 문제가 아니라 &quot;규격에 맞는가&quot;의 문제가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;국내에서 공공 기관에 납품하는 소프트웨어는 &lt;b&gt;KCMVP(한국 암호 모듈 검증 제도)&lt;/b&gt; 라는 기준을 충족해야 합니다. KCMVP는 간단히 말하면 &quot;이 제품이 암호화할 때 사용하는 엔진이 국가가 정한 표준대로 만들어졌는지&quot;를 국가가 직접 검사하는 제도입니다. 그리고 이 제도에는 KISA(한국인터넷진흥원)가 승인한 알고리즘 목록이 있는데, bcrypt도 Argon2도 그 목록에 없습니다.&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;bcrypt가 목록에 없는 이유 ① &amp;mdash; 블록 크기와 생일 공격&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bcrypt는 내부적으로 &lt;b&gt;Blowfish&lt;/b&gt; 라는 알고리즘을 기반으로 동작합니다. 암호화 알고리즘은 데이터를 잘게 쪼개 블록 단위로 처리하는데, Blowfish는 이 블록 하나의 크기가 &lt;b&gt;64비트&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 현재 국제 표준인 AES와 국내 표준인 ARIA는 블록 크기가 &lt;b&gt;128비트&lt;/b&gt;입니다. 두 배 차이인데, 이게 왜 중요한지 이해하려면 &lt;b&gt;생일 공격(Birthday Attack)&lt;/b&gt; 이라는 개념을 알아야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생일 공격은 수학의 &lt;b&gt;&quot;생일의 역설&quot;&lt;/b&gt; 을 암호 해킹에 응용한 기법입니다. 생일의 역설을 먼저 설명하면 이렇습니다. 1년은 365일이니까 생일이 겹치는 두 사람을 찾으려면 366명이 있어야 할 것 같습니다. 그런데 실제로 계산해보면 겨우 &lt;b&gt;23명만 있어도&lt;/b&gt; 그 중 두 명의 생일이 같을 확률이 50%가 넘습니다. 왜냐하면 &quot;나와 생일이 같은 사람&quot;을 찾는 게 아니라, &quot;아무나 서로 생일이 같은 쌍&quot;을 찾는 것이기 때문에 비교의 경우의 수가 사람 수의 제곱에 가깝게 불어나기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호학에서의 생일 공격은 이 원리를 이용해 서로 같은 결과(충돌)를 만들어내는 두 입력값을 찾아내는 공격입니다. 블록 크기가 64비트면 가능한 경우의 수가 2⁶⁴개인데, 생일의 역설에 의해 약 2&amp;sup3;&amp;sup2;번, 즉 43억 번의 연산만으로 충돌이 발생할 확률이 절반을 넘습니다. 현대 서버 환경에서 대용량 데이터를 처리하다 보면 43억 번의 연산은 충분히 도달 가능한 수준이라 현실적인 위협이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;128비트(AES)라면 충돌 확률이 50%가 되는 지점이 2⁶⁴번 연산인데, 이건 현재 기술로는 현실적으로 불가능한 수준입니다. 블록 크기가 두 배 늘어나는 것이 안전성 면에서는 훨씬 큰 차이를 만드는 이유입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;bcrypt가 목록에 없는 이유 ② &amp;mdash; 암호 표준 경쟁의 역사&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bcrypt가 KCMVP 목록에 없는 데는 기술적 이유 외에도 역사적인 배경이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2000년대 초반, 미국 NIST가 당시 표준이던 DES를 대체할 새로운 암호 표준을 공개 경쟁 방식으로 선정했습니다. 전 세계의 암호학자들이 알고리즘을 제출했고, Blowfish의 후속 버전인 &lt;b&gt;Twofish&lt;/b&gt;도 최종 후보 5개 안에 들었습니다. 그런데 치열한 심사 끝에 &lt;b&gt;Rijndael&lt;/b&gt;이 최종 우승하며 &lt;b&gt;AES(Advanced Encryption Standard)&lt;/b&gt; 로 채택됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결정 이후 세계 각국은 AES를 기준으로 자국의 암호화 표준을 재정립하기 시작했습니다. 한국도 이 흐름에 맞춰 AES와 함께, 국내에서 자체 개발한 &lt;b&gt;ARIA&lt;/b&gt; 와 &lt;b&gt;SEED&lt;/b&gt; 를 국가 표준 알고리즘으로 공식화했습니다. 표준 경쟁에서 밀려난 Blowfish 계열의 알고리즘인 bcrypt가 KISA 권고 목록에서 빠지게 된 건 이 역사적인 흐름의 결과입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;bcrypt가 목록에 없는 이유 ③ &amp;mdash; 심사에서의 현실적인 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 행정적인 이유도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;KISA 가이드라인에는 &quot;SHA-256 이상의 해시 알고리즘을 사용할 것&quot;이라고 명시되어 있습니다. SHA-256은 국내 TTA(한국정보통신기술협회)와 미국 NIST 모두에서 공식 승인된 알고리즘입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 보안 심사 자리에서 SHA-256을 썼다면 &quot;KISA 가이드라인 기준을 따랐습니다&quot;한 줄로 끝납니다. 반면 bcrypt를 썼다면 승인 목록에 없는 알고리즘을 선택한 이유와 근거를 처음부터 직접 제출하고 심사위원을 설득해야 합니다. 기술적 안전성과 별개로, 인증 통과 가능성과 소명 비용 면에서 SHA-256이 압도적으로 유리합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SHA-256 하나면 충분할까? &amp;mdash; 레인보우 테이블의 존재&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그럼 비밀번호를 그냥 SHA-256으로 해시하면 되겠네?&quot;라고 생각했는데, 이게 또 단순하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SHA-256만 단독으로 쓰면 &lt;b&gt;레인보우 테이블 공격(Rainbow Table Attack)&lt;/b&gt; 에 무너집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레인보우 테이블이란, 공격자가 수천만 개의 흔한 비밀번호와 그 비밀번호를 SHA-256으로 해시한 결과값을 미리 전부 계산해서 만들어 놓은 거대한 대조표입니다. 예를 들어 DB가 해킹돼서 해시값 &quot;ef92...&quot; 가 유출됐다고 해봅시다. 공격자는 이 값을 레인보우 테이블에서 검색하면 됩니다. 표에 &quot;1234&quot; &amp;rarr; &quot;ef92...&quot;가 등록되어 있다면 단 1초 만에 원문이 &quot;1234&quot;임을 알아냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무리 복잡한 해시 알고리즘을 써도, 미리 계산된 표에 있기만 하면 원문이 바로 노출됩니다. &quot;강력한 자물쇠를 달았지만, 그 자물쇠의 모든 패턴이 이미 열쇠 사전에 있는&quot; 상황과 같습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레인보우 테이블을 막는 두 가지 무기 &amp;mdash; Salt와 Key Stretching&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;KISA는 이 문제를 해결하기 위해 SHA-256에 반드시 두 가지를 추가하도록 권고합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Salt &amp;mdash; &quot;같은 비밀번호도 다르게 보이게&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Salt는 해시하기 전에 원문 비밀번호에 덧붙이는 무작위 문자열입니다. 예를 들면 이런 식입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;사용자 A의 비밀번호: &quot;1234&quot; + Salt &quot;x9Qm...&quot; &amp;rarr; 해시 &amp;rarr; &quot;a3f9...&quot;
사용자 B의 비밀번호: &quot;1234&quot; + Salt &quot;p2Wk...&quot; &amp;rarr; 해시 &amp;rarr; &quot;71bc...&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 사람이 똑같은 비밀번호 &quot;1234&quot;를 쓰더라도 Salt가 다르기 때문에 해시 결과가 완전히 달라집니다. 공격자가 &quot;1234&quot;의 해시값을 레인보우 테이블에 미리 계산해 놓았더라도, Salt가 포함된 이 결과값은 표에 없기 때문에 공격이 무력화됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 실무 포인트가 하나 있습니다. &lt;b&gt;Salt는 반드시 해시값과 함께 DB에 저장해야 합니다.&lt;/b&gt; 나중에 사용자가 로그인할 때 같은 Salt를 꺼내와서 입력값에 다시 붙여 해시해야 비교가 가능하기 때문입니다. Salt는 감추어야 할 비밀 값이 아니라, 해시 연산을 사용자마다 유일하게 만드는 재료입니다. KISA는 Salt를 최소 &lt;b&gt;16바이트 이상&lt;/b&gt;, 사용자마다 &lt;b&gt;반드시 다른 값&lt;/b&gt; 으로 생성하도록 권고합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Key Stretching &amp;mdash; &quot;해커의 시도를 의도적으로 느리게&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Key Stretching은 해시 연산을 수만 번 반복해서 실행하는 기법입니다. &quot;왜 일부러 느리게 만들지?&quot; 싶겠지만, 그게 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적인 사용자 입장에서는 로그인할 때 해시 연산이 한 번 일어나는 것과 10만 번 반복되는 것의 차이는 체감하기 어렵습니다. 수십 밀리초 차이입니다. 하지만 공격자 입장에서는 차원이 다른 이야기입니다. 비밀번호 10억 개를 대입해서 시도하려면 기존에 1시간 걸리던 것이 10만 배 느려지면 수천 년이 걸리는 계산이 됩니다. 현실적으로 불가능해지는 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SHA-256 + Salt + Key Stretching = PBKDF2&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 가지를 하나로 합친 것이 &lt;b&gt;PBKDF2-HMAC-SHA256&lt;/b&gt; 입니다. PBKDF2는 &quot;Password-Based Key Derivation Function 2&quot;의 약자로, 특정 해시 알고리즘과 조합해서 쓸 수 있는 유연한 구조를 가집니다. 국내 인증 환경에서는 SHA-256과 결합한 PBKDF2-HMAC-SHA256이 비밀번호 저장의 사실상 표준입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름을 한눈에 보면 이렇습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[사용자가 입력한 비밀번호]
         &amp;darr;
  무작위 Salt 생성 (16바이트 이상, 사용자마다 고유한 값)
         &amp;darr;
  (비밀번호 + Salt)를 SHA-256 기반으로 100,000회 반복 해시
         &amp;darr;
  [최종 해시값] + [Salt] &amp;rarr; DB에 함께 저장&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;Node.js 기준 구현 예시입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const crypto = require('crypto');

// 비밀번호 해싱 함수
function hashPassword(password) {
  // 1. 사용자마다 고유한 Salt를 무작위로 생성 (16바이트)
  const salt = crypto.randomBytes(16).toString('hex');

  // 2. SHA-256 기반으로 100,000회 반복하여 64바이트 해시값 생성
  const hash = crypto
    .pbkdf2Sync(password, salt, 100000, 64, 'sha256')
    .toString('hex');

  // 3. 해시값과 Salt를 함께 반환 &amp;mdash; DB에 같이 저장해야 로그인 시 검증 가능
  return { hash, salt };
}

// 로그인 시 검증 함수
function verifyPassword(inputPassword, storedHash, storedSalt) {
  // 저장된 Salt를 꺼내와서 동일한 조건으로 해시 후 비교
  const hash = crypto
    .pbkdf2Sync(inputPassword, storedSalt, 100000, 64, 'sha256')
    .toString('hex');

  return hash === storedHash;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복 횟수를 &lt;b&gt;10만 번&lt;/b&gt;으로 설정한 것은 임의로 정한 숫자가 아닙니다. &lt;b&gt;NIST SP 800-132&lt;/b&gt; 문서에서 PBKDF2의 최소 반복 횟수 권고 기준을 제시하고 있으며, 현재 일반적인 서버 하드웨어 성능을 기준으로 10만 번이 보안과 성능 사이의 합리적인 출발점으로 통용됩니다. 보안을 더 강화하려면 반복 횟수를 늘릴 수 있지만, 그만큼 로그인마다 서버 부하도 커지기 때문에 서비스 상황에 맞게 조율이 필요합니다.&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;AES-256-GCM&lt;/b&gt; 을 사용합니다. KISA는 AES-256 또는 ARIA-256을 권고합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;운영 모드&lt;/b&gt;를 꼭 짚고 넘어가야 합니다. AES는 데이터를 블록 단위로 처리하는데, 이 블록들을 어떻게 연결해서 암호화하느냐에 따라 운영 모드가 나뉩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ECB 모드(Electronic Codebook)&lt;/b&gt; 는 각 블록을 독립적으로 암호화하는 방식인데, 같은 평문 블록은 항상 같은 암호문 블록이 나옵니다. 예를 들어 이미지를 ECB로 암호화하면 암호화됐음에도 원본 이미지의 윤곽이 그대로 드러나는 유명한 사례가 있습니다. 패턴이 노출되는 치명적인 약점 때문에 &lt;b&gt;ECB 모드는 절대 사용해서는 안 됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GCM 모드(Galois/Counter Mode)&lt;/b&gt; 는 암호화와 동시에 데이터가 중간에 변조되지 않았음을 검증하는 &lt;b&gt;인증 태그(Auth Tag)&lt;/b&gt; 를 함께 생성합니다. 복호화할 때 이 태그가 일치하지 않으면 즉시 오류를 발생시켜 변조된 데이터를 차단합니다. 현재 가장 안전하고 권장되는 운영 모드입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const crypto = require('crypto');

// 32바이트(256비트) 대칭키 &amp;mdash; 절대 코드에 하드코딩하지 말 것
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');

// 암호화 함수
function encrypt(plainText) {
  // IV(Initialization Vector): 같은 평문 + 같은 키로 암호화해도
  // 매번 다른 암호문이 나오게 만드는 무작위 값
  const iv = crypto.randomBytes(12);

  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);

  const encrypted = Buffer.concat([
    cipher.update(plainText, 'utf8'),
    cipher.final(),
  ]);

  // 인증 태그: 복호화 시 데이터 무결성 검증에 필요
  const authTag = cipher.getAuthTag();

  return {
    iv: iv.toString('hex'),
    encrypted: encrypted.toString('hex'),
    authTag: authTag.toString('hex'),
  };
}

// 복호화 함수
function decrypt({ iv, encrypted, authTag }) {
  const decipher = crypto.createDecipheriv(
    'aes-256-gcm',
    ENCRYPTION_KEY,
    Buffer.from(iv, 'hex')
  );

  // 저장해둔 인증 태그를 설정 &amp;mdash; 값이 다르면 복호화 자체를 거부
  decipher.setAuthTag(Buffer.from(authTag, 'hex'));

  return Buffer.concat([
    decipher.update(Buffer.from(encrypted, 'hex')),
    decipher.final(),
  ]).toString('utf8');
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;① ENCRYPTION_KEY&lt;/b&gt;: AES-256에서 256은 키의 길이를 뜻합니다. 즉 32바이트(256비트) 키를 씁니다. 이 키 하나가 유출되면 암호화된 모든 데이터가 복호화될 수 있습니다. 그래서 소스 코드나 DB에 직접 넣는 건 위험합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② IV(Initialization Vector)&lt;/b&gt;: 같은 전화번호를 두 번 암호화하더라도 IV가 매번 새로 생성되기 때문에 암호문도 매번 달라집니다. 이렇게 해야 암호문만 봐도 같은 값임을 알아챌 수 있는 패턴 노출을 막을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;③ 인증 태그(Auth Tag)&lt;/b&gt;: GCM 모드가 생성하는 서명과 같은 역할입니다. 암호문이 전송 또는 저장 중에 단 1비트라도 바뀌면 복호화 시 태그 검증에서 오류가 발생하고, 시스템은 변조된 데이터라고 판단해 복호화를 거부합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;암호화 키는 어디에 저장해야 할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ENCRYPTION_KEY를 코드에 직접 쓰면 절대 안 된다고 했는데, 그럼 어디에 써야 할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 환경&lt;/b&gt;에서는 .env 파일에 저장하고 dotenv 같은 라이브러리로 불러오는 방식을 씁니다. 이때 .env 파일은 반드시 .gitignore에 추가해서 깃허브 같은 버전 관리 시스템에 올라가지 않도록 해야 합니다. 실수로 올라가는 순간 키가 공개되는 것과 다름없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로덕션 환경&lt;/b&gt;에서는 AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault 같은 전용 시크릿 관리 서비스를 쓰는 것이 안전합니다. 키를 코드와 완전히 분리하고, 어떤 서버가 어떤 키에 접근할 수 있는지 권한도 세밀하게 제어할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리 &amp;mdash; 어느 상황에 어떤 알고리즘을?&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;비밀번호 저장 (단방향)
├── 국내 공공 인증 필요 &amp;rarr; PBKDF2-HMAC-SHA256 (KISA 권고)
└── 공공 인증 불필요   &amp;rarr; bcrypt / Argon2 (글로벌 표준, 둘 다 안전)

개인정보 저장 (양방향, 복호화 필요)
├── 국내 공공 인증 필요 &amp;rarr; AES-256-GCM 또는 ARIA-256 (KISA 권고)
└── 운영 모드 선택     &amp;rarr; GCM 사용, ECB는 절대 금지

암호화 키 관리
├── 개발 환경 &amp;rarr; .env 파일 (gitignore 필수)
└── 운영 환경 &amp;rarr; AWS Secrets Manager 등 시크릿 관리 서비스&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;이 글을 쓰기 전까지 저는 bcrypt만 쓸 줄 알았고, 암호화가 이렇게 깊은 주제인지 몰랐습니다. 공부하면서 가장 많이 느낀 건 &quot;왜?&quot;라는 질문을 멈추지 않는 것의 중요성입니다. 강의에서 &quot;이거 쓰세요&quot;라고 하면 그냥 쓰는 게 아니라, 왜 이걸 쓰는지, 어떤 상황에서는 다른 선택이 필요한지를 한 번씩 생각해보는 습관이 결국 더 나은 개발자로 만들어 준다고 생각했습니다.&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;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;KISA 암호 이용 안내서 (한국인터넷진흥원)&lt;/li&gt;
&lt;li&gt;NIST SP 800-132: Recommendation for Password-Based Key Derivation&lt;/li&gt;
&lt;li&gt;KCMVP 검증 알고리즘 목록 (KISA)&lt;/li&gt;
&lt;li&gt;PHC (Password Hashing Competition) &amp;mdash; Argon2 공식 발표 자료&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://medium.com/sungwon-eni/%EC%99%9C-%ED%95%9C%EA%B5%AD-%EC%86%8C%ED%94%84%ED%8A%B8%EC%9B%A8%EC%96%B4-%EC%9D%B8%EC%A6%9D%EC%97%94-bcrypt%EB%B3%B4%EB%8B%A4-sha-256-salt%EC%9D%BC%EA%B9%8C-kisa-%EA%B0%80%EC%9D%B4%EB%93%9C%EB%9D%BC%EC%9D%B8-a145f6324ebd&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;왜 bcrypt는 왜 한국 공공 인증을 통과하지 못할까?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>공부/Spring</category>
      <category>gs</category>
      <category>암호화</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/322</guid>
      <comments>https://sesam-dev.tistory.com/322#entry322comment</comments>
      <pubDate>Wed, 1 Jul 2026 13:25:07 +0900</pubDate>
    </item>
    <item>
      <title>setState를 썼는데 값이 왜 이상하지? &amp;mdash; async 완전 이해하기</title>
      <link>https://sesam-dev.tistory.com/321</link>
      <description>&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;일반 손님 (동기, sync):&lt;/b&gt; 주문 &amp;rarr; 커피 만들기 &amp;rarr; 다음 손님 받기. 무조건 한 번에 한 명씩 순서대로 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;카페 직원 (비동기, async):&lt;/b&gt; 주문을 받은 뒤 커피 기계에 버튼 눌러두고 &amp;rarr; 커피가 나오는 동안 기다리지 않고 &amp;rarr; 다음 손님 주문을 먼저 받습니다. 커피는 나중에 완성되면 가져다줍니다.&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;  1단계 &amp;mdash; 자바스크립트의 기본: 코드는 위에서 아래로 실행된다 (동기)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 코드는 그냥 위에서 아래로 순서대로 실행됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;js&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;console.log(1 + 1)  // 2 출력
console.log(2 + 1)  // 3 출력
console.log(3 + 1)  // 4 출력&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘솔에 2, 3, 4 순서대로 출력됩니다. 당연한 얘기처럼 들리죠?&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  2단계 &amp;mdash; 그런데 특별한 함수들은 다르게 동작한다 (비동기)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ajax, 이벤트리스너, setTimeout 같은 함수들은 &lt;b&gt;처리하는 데 시간이 걸립니다.&lt;/b&gt; 예를 들어 서버에서 데이터를 가져오는 ajax는 인터넷 상황에 따라 1초, 10초도 걸릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트는 이런 코드를 만나면 &lt;b&gt;&quot;이거 오래 걸리니까 일단 제쳐두고 아래 코드 먼저 실행할게!&quot;&lt;/b&gt; 합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;js&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;console.log(2)              // ① 먼저 실행 &amp;rarr; 2 출력
axios.get('/data').then(() =&amp;gt; {
  console.log(3)            // ③ ajax 완료되고 나서 &amp;rarr; 3 출력
})
console.log(4)              // ② ajax 기다리지 않고 먼저 실행 &amp;rarr; 4 출력&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 2 &amp;rarr; 4 &amp;rarr; 3 순으로 출력됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심지어 ajax가 0.001초밖에 안 걸려도 2 &amp;rarr; 4 &amp;rarr; 3 입니다. 자바스크립트의 특성이 그렇습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  3단계 &amp;mdash; 리액트의 setState도 비동기입니다!&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터가 진짜 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트에서 state를 바꿀 때 쓰는 setState 함수 (setCount, setName 등)도 &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;  실제 버그 예시 &amp;mdash; &quot;왜 한 번 더 실행되지?&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 같은 기능을 만들고 싶다고 해봅시다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼을 누를 때마다 나이가 1씩 늘어나는데, &lt;b&gt;3번 이상 누르면 나이가 더 이상 늘지 않아야 한다.&lt;/b&gt;&lt;br /&gt;(20살에서 시작해서 22살에서 멈춰야 함)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function App() {
  let [count, setCount] = useState(0)  // 버튼 누른 횟수
  let [age, setAge] = useState(20)     // 나이

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;나이: {age}살&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; {
        setCount(count + 1)        // ① count를 +1

        if (count &amp;lt; 3) {           // ② count가 3 미만이면
          setAge(age + 1)          // ③ 나이도 +1
        }
      }}&amp;gt;
        누르면 한 살 먹기
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언뜻 보면 맞는 코드 같습니다. 그런데 실제로 돌려보면 &lt;b&gt;23살까지 증가합니다.&lt;/b&gt; 22살에서 멈춰야 하는데 왜 그럴까요?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  버그의 원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼을 &lt;b&gt;세 번째&lt;/b&gt; 눌렀을 때를 분석해보면:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;① setCount(count + 1) 실행 &amp;rarr; count를 3으로 바꿔달라고 요청
   &amp;darr; 그런데 이 작업이 비동기라서 아직 완료가 안 됨!
   &amp;darr; count는 아직 2인 상태

② if (count &amp;lt; 3) 을 체크 &amp;rarr; count가 아직 2니까 조건이 참!
   &amp;darr; setAge(age + 1) 실행됨 &amp;rarr; 나이가 또 +1

③ 나중에 setCount가 완료되어 count가 3이 됨&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;&quot;count를 3으로 바꿔라&quot;&lt;/b&gt; 는 요청을 해놨지만 아직 완료되기 전에 if (count &amp;lt; 3) 을 체크해버리니까, count가 여전히 2로 보여서 나이가 한 번 더 증가하는 겁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 해결책 &amp;mdash; useEffect 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 이렇습니다. &lt;b&gt;&quot;count가 완전히 바뀌고 난 다음에 age를 바꾸는 코드를 실행해라&quot;&lt;/b&gt; 고 순서를 보장해주면 됩니다. 이때 useEffect를 씁니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function App() {
  let [count, setCount] = useState(0)
  let [age, setAge] = useState(20)

  // count가 변경된 이후에 실행됨
  useEffect(() =&amp;gt; {
    if (count !== 0 &amp;amp;&amp;amp; count &amp;lt; 3) {  // count가 0(초기값)일 때는 제외
      setAge(age + 1)
    }
  }, [count])  // count가 바뀔 때만 이 코드 실행

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;나이: {age}살&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; {
        setCount(count + 1)  // 버튼 누르면 count만 바꿈
      }}&amp;gt;
        누르면 한 살 먹기
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 순서&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;① 버튼 클릭 &amp;rarr; setCount(count + 1) 실행
② count가 완전히 변경됨
③ useEffect 발동 &amp;rarr; 이 시점의 count는 진짜 새 값
④ if (count !== 0 &amp;amp;&amp;amp; count &amp;lt; 3) 체크 &amp;rarr; 정확한 값으로 판단
⑤ 조건에 맞으면 setAge(age + 1) 실행&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 count가 3이 된 순간부터 count &amp;lt; 3 조건이 거짓이 되어 나이가 더 늘지 않습니다. 22살에서 정확히 멈춥니다!&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  count !== 0 조건을 추가한 이유: useEffect는 &lt;b&gt;페이지가 처음 로드될 때도 한 번 실행&lt;/b&gt;됩니다. 그때 count는 0인데, 이 시점에 나이가 늘어버리면 안 되니까 count가 0일 때는 건너뛰게 한 겁니다.&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;div&gt;개념쉬운 설명
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;동기 (sync)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;코드가 위에서 아래로 순서대로 실행됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;비동기 (async)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;오래 걸리는 작업은 제쳐두고 다음 코드 먼저 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;setState는 비동기&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;state 변경 요청 후 완료 전에 다음 줄이 실행될 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;해결책&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;useEffect로 &quot;이 state가 바뀐 다음에 실행해줘&quot; 순서 보장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setState 쓰다가 값이 이상하게 동작한다면, &lt;b&gt;&quot;비동기라서 아직 값이 안 바뀐 상태일 수 있다&quot;&lt;/b&gt; 는 걸 먼저 떠올려보세요!&lt;/p&gt;</description>
      <category>공부/frontend</category>
      <category>async</category>
      <category>react</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/321</guid>
      <comments>https://sesam-dev.tistory.com/321#entry321comment</comments>
      <pubDate>Wed, 1 Jul 2026 02:10:30 +0900</pubDate>
    </item>
    <item>
      <title>React 성능 개선2 &amp;mdash; memo와 useMemo 완전 정리</title>
      <link>https://sesam-dev.tistory.com/320</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;버튼 하나 눌렀을 뿐인데 왜 전체가 버벅이지?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트는 부모 컴포넌트가 재렌더링되면 자식 컴포넌트도 &lt;b&gt;무조건 함께 재렌더링&lt;/b&gt;됩니다. 평소엔 별문제가 없지만, 자식 컴포넌트가 무거운 연산을 하거나 렌더링 시간이 오래 걸리는 경우엔 심각한 성능 저하로 이어집니다. 이걸 해결하는 도구가 바로 memo와 useMemo입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  먼저 알아야 할 것 &amp;mdash; 리액트의 재렌더링 규칙&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;state가 변경&lt;/b&gt;될 때&lt;/li&gt;
&lt;li&gt;부모로부터 받은 &lt;b&gt;props가 변경&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;b&gt;자식과 전혀 관계없는 이유라도&lt;/b&gt; 자식이 덩달아 재렌더링됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Child() {
  console.log(&quot;재렌더링됨&quot;)  // 부모 버튼 누를 때마다 이게 찍힘
  return &amp;lt;div&amp;gt;자식임&amp;lt;/div&amp;gt;
}

function Cart() {
  let [count, setCount] = useState(0);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;Child /&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; { setCount(count + 1) }}&amp;gt;+&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+ 버튼을 누르면 Cart의 count state가 바뀌어 Cart가 재렌더링됩니다. 그런데 Child는 count와 전혀 관계가 없는데도 함께 재렌더링됩니다. Child가 무거운 컴포넌트라면 버튼 누를 때마다 버벅이는 현상이 생기는 것입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 해결책 1 &amp;mdash; memo : 불필요한 자식 재렌더링 막기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;memo로 컴포넌트를 감싸면 &lt;b&gt;&quot;props가 바뀔 때만 재렌더링해주세요&quot;&lt;/b&gt; 라는 의미가 됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { memo, useState } from 'react'

// ✅ memo로 감싸기
let Child = memo(function() {
  console.log(&quot;재렌더링됨&quot;)
  return &amp;lt;div&amp;gt;자식임&amp;lt;/div&amp;gt;
})

function Cart() {
  let [count, setCount] = useState(0);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;Child /&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; { setCount(count + 1) }}&amp;gt;+&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 + 버튼을 눌러도 Child로 전달되는 props가 없으니 Child는 재렌더링되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 props를 전달하는 경우라면, &lt;b&gt;해당 props가 실제로 바뀔 때만&lt;/b&gt; 재렌더링됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;axapta&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// count가 바뀔 때만 Child 재렌더링됨
&amp;lt;Child count={count} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ memo를 남발하면 안 되는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;memo로 감싼 컴포넌트는 재렌더링을 막으려고 매번 &lt;b&gt;&quot;기존 props == 새 props&quot;&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;props가 단순한 숫자나 문자열이면 비교 비용이 거의 없음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;props가 크고 복잡한 객체/배열이면&lt;/b&gt; 비교 자체가 오히려 성능 손해가 될 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 memo는 &lt;b&gt;렌더링 비용이 크고 props 변화가 적은 컴포넌트&lt;/b&gt;에만 선택적으로 사용하는 게 좋습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 해결책 2 &amp;mdash; useMemo : 무거운 연산 결과를 기억해두기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;memo는 컴포넌트 자체의 재렌더링을 막는 것이고, useMemo는 &lt;b&gt;컴포넌트 안의 특정 연산 결과를 기억(메모이제이션)해두는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ada&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function 함수() {
  return 반복문을_10억번_돌린_결과
}

function Cart() {
  let result = 함수()  // ❌ 재렌더링될 때마다 매번 10억 번 반복문 실행!

  return ( ... )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cart가 재렌더링될 때마다 함수()가 매번 새로 실행됩니다. 연산이 무거울수록 심각한 성능 문제가 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useMemo로 해결&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { useMemo, useState } from 'react'

function 함수() {
  return 반복문을_10억번_돌린_결과
}

function Cart() {
  // ✅ 처음 1회만 실행하고 결과를 기억해둠
  let result = useMemo(() =&amp;gt; { return 함수() }, [])

  return ( ... )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useMemo의 구조는 useEffect와 비슷합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useMemo(() =&amp;gt; { 실행할 함수 }, [의존성 배열])&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;첫 번째 인자&lt;/b&gt;: 기억해둘 연산을 담은 함수. 반드시 값을 return해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;두 번째 인자(의존성 배열)&lt;/b&gt;: useEffect처럼 특정 state/props가 바뀔 때만 재실행하도록 설정. 빈 배열 []이면 컴포넌트 최초 로드 시 딱 1회만 실행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// state가 바뀔 때만 함수 재실행
let result = useMemo(() =&amp;gt; { return 함수() }, [state])

// 컴포넌트 최초 로드 시 1회만 실행
let result = useMemo(() =&amp;gt; { return 함수() }, [])&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  memo vs useMemo vs useEffect 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지가 비슷해 보여서 헷갈리기 쉽습니다. 아래 표로 차이를 정리하면 이렇습니다.&lt;/p&gt;
&lt;div&gt;목적대상실행 시점
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;memo&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;불필요한 재렌더링 방지&lt;/td&gt;
&lt;td&gt;컴포넌트 전체&lt;/td&gt;
&lt;td&gt;props가 바뀔 때만 렌더링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;useMemo&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;무거운 연산 결과 재사용&lt;/td&gt;
&lt;td&gt;컴포넌트 내 특정 값&lt;/td&gt;
&lt;td&gt;렌더링과 &lt;b&gt;동시에&lt;/b&gt; 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;useEffect&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;렌더링 후 사이드이펙트 처리&lt;/td&gt;
&lt;td&gt;렌더링 후 동작&lt;/td&gt;
&lt;td&gt;return(HTML) 실행이 &lt;b&gt;끝난 뒤&lt;/b&gt; 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&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;: useEffect는 화면이 그려진 &lt;b&gt;이후&lt;/b&gt;에 실행되고, useMemo는 화면이 그려지는 &lt;b&gt;과정에&lt;/b&gt; 함께 실행됩니다. 따라서 렌더링 결과물에 영향을 주는 연산이라면 useMemo를 써야 합니다.&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;mdash; 장바구니 컴포넌트에 적용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모에 있던 코드를 기반으로 실제 장바구니 컴포넌트에서 함께 쓰이는 모습을 살펴봅시다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { memo, useMemo, useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { increase } from './store/cartSlice'

// ✅ memo로 감싸서 불필요한 재렌더링 방지
let Child = memo(function({ count }) {
  console.log(&quot;재렌더링됨&quot;)
  return &amp;lt;div&amp;gt;자식 컴포넌트 (count: {count})&amp;lt;/div&amp;gt;
})

// 무거운 연산 함수
function 무거운함수() {
  // 반복문 10억 번 같은 무거운 연산
  return 결과값
}

function Cart() {
  // ✅ useMemo로 무거운 연산 결과 캐싱
  let result = useMemo(() =&amp;gt; { return 무거운함수() }, [])

  // Redux에서 전체 state 가져오기
  let state = useSelector((state) =&amp;gt; { return state })
  // Redux에서 cart 배열만 가져오기
  let cart = useSelector((state) =&amp;gt; { return state.cart })

  // dispatch: Redux store에 액션을 보내는 함수
  let dispatch = useDispatch()

  let [count, setCount] = useState(0)

  return (
    &amp;lt;div&amp;gt;
      {/* count가 바뀔 때만 Child 재렌더링 */}
      &amp;lt;Child count={count} /&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; { setCount(count + 1) }}&amp;gt;+&amp;lt;/button&amp;gt;

      {state.user.name} {state.user.age}의 장바구니

      &amp;lt;button onClick={() =&amp;gt; {
        dispatch(increase(100))
      }}&amp;gt;수량 증가&amp;lt;/button&amp;gt;

      &amp;lt;table&amp;gt;
        &amp;lt;thead&amp;gt;
          &amp;lt;tr&amp;gt;
            &amp;lt;th&amp;gt;#&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;상품명&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;수량&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;변경하기&amp;lt;/th&amp;gt;
          &amp;lt;/tr&amp;gt;
        &amp;lt;/thead&amp;gt;
        &amp;lt;tbody&amp;gt;
          {cart.map((element) =&amp;gt; (
            &amp;lt;tr key={element.id}&amp;gt;
              &amp;lt;td&amp;gt;{element.id}&amp;lt;/td&amp;gt;
              &amp;lt;td&amp;gt;{element.name}&amp;lt;/td&amp;gt;
            &amp;lt;/tr&amp;gt;
          ))}
        &amp;lt;/tbody&amp;gt;
      &amp;lt;/table&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  정리&lt;/h2&gt;
&lt;div&gt;상황해결책
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;부모 재렌더링 시 자식이 불필요하게 같이 렌더링됨&lt;/td&gt;
&lt;td&gt;memo로 자식 컴포넌트 감싸기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;렌더링마다 무거운 연산이 반복 실행됨&lt;/td&gt;
&lt;td&gt;useMemo로 연산 결과 캐싱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;props가 크고 복잡해서 memo 비교 자체가 부담스러움&lt;/td&gt;
&lt;td&gt;memo 사용 자제, 꼭 필요한 곳에만 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;렌더링 후에 뭔가 실행시키고 싶음&lt;/td&gt;
&lt;td&gt;useMemo 대신 useEffect 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 최적화는 무조건 처음부터 모든 곳에 적용하는 게 아니라, &lt;b&gt;Profiler로 실제 느린 곳을 찾은 뒤&lt;/b&gt; 필요한 곳에만 적용하는 것이 올바른 순서입니다!&lt;/p&gt;</description>
      <category>공부/frontend</category>
      <category>memo</category>
      <category>react</category>
      <category>useMemo</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/320</guid>
      <comments>https://sesam-dev.tistory.com/320#entry320comment</comments>
      <pubDate>Wed, 1 Jul 2026 02:09:31 +0900</pubDate>
    </item>
    <item>
      <title>React 성능 개선1 &amp;mdash; 개발자도구 활용 &amp;amp; lazy import</title>
      <link>https://sesam-dev.tistory.com/319</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;내 리액트 앱이 느린 것 같은데... 어디서부터 시작해야 하지?&quot;&lt;/p&gt;
&lt;/blockquote&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;  React Developer Tools &amp;mdash; 컴포넌트를 직접 들여다보는 도구&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 기본 개발자도구(F12)의 Elements 탭을 열면 우리가 짠 JSX 코드가 실제 HTML로 변환된 모습을 볼 수 있습니다. 하지만 이건 리액트 컴포넌트 구조와는 다르게 보여서 디버깅이 불편합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결해주는 것이 &lt;b&gt;React Developer Tools&lt;/b&gt; 크롬 확장프로그램입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설치 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롬 웹스토어에서 &lt;b&gt;&quot;React Developer Tools&quot;&lt;/b&gt; 를 검색해 설치하면 됩니다. 설치 후 리액트로 만든 사이트에서 개발자도구(F12)를 열면 &lt;b&gt;Components&lt;/b&gt; 탭과 &lt;b&gt;Profiler&lt;/b&gt; 탭이 새로 생깁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Components 탭 &amp;mdash; 컴포넌트 구조 파악&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components 탭에서는 현재 화면을 구성하는 컴포넌트 트리를 한눈에 볼 수 있습니다.&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;: 선택한 컴포넌트의 state와 props 값을 실시간으로 확인하고 직접 수정도 가능&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; props를 보냈는데 화면에 출력이 안 된다면? Components 탭에서 해당 컴포넌트를 찾아 props가 제대로 전달됐는지 바로 확인해보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Profiler 탭 &amp;mdash; 성능 병목 찾기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Profiler 탭&lt;/b&gt;은 어떤 컴포넌트가 얼마나 오래 렌더링되는지 측정해주는 성능 분석 도구입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 방법&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Profiler 탭으로 이동&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;li&gt;방금 렌더링된 모든 컴포넌트의 렌더링 시간 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&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; 성능 저하의 대부분은 &amp;lt;div&amp;gt;를 너무 많이 만든 것보다 &lt;b&gt;서버에서 ajax 응답이 늦게 오는 경우&lt;/b&gt;가 훨씬 많습니다. 서버 응답이 느린 건 프론트에서 고치기 어렵습니다. Profiler에서 특이하게 느린 컴포넌트가 없다면 서버 쪽을 확인해보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ Redux Developer Tools &amp;mdash; Redux 상태 추적&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redux를 사용하고 있다면 &lt;b&gt;Redux Developer Tools&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;Redux store에 보관된 &lt;b&gt;모든 state를 실시간으로 확인&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;dispatch를 날릴 때마다 &lt;b&gt;어떤 값이 어떻게 바뀌었는지 로그로 기록&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;store가 복잡해질수록 가치가 올라가는 도구&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅이 어려웠던 Redux 상태 관리 문제를 이 도구 하나로 대부분 해결할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  lazy import &amp;mdash; 첫 페이지 로딩 속도 개선&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 리액트 앱은 첫 로딩이 느릴까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npm run build로 리액트 프로젝트를 빌드하면 작성했던 모든 코드가 HTML, CSS, JS 파일로 변환됩니다. 그런데 리액트로 만드는 &lt;b&gt;SPA(Single Page Application)의 특성상 JS 파일이 단 하나로 합쳐집니다.&lt;/b&gt; App, Detail, Cart 등 모든 컴포넌트가 이 하나의 파일 안에 들어가기 때문에 파일 크기가 커지고, 첫 페이지 로딩이 느려집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 메인 페이지만 보러 왔는데도 Detail 페이지, Cart 페이지의 코드까지 모두 다운로드 받아야 하는 상황이 되는 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결책 &amp;mdash; lazy import로 JS 파일 쪼개기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 일반 import를 &lt;b&gt;lazy import&lt;/b&gt;로 바꾸면 컴포넌트를 별도의 JS 파일로 분리할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ❌ 기존 방식: 앱 시작 시 모두 한꺼번에 로드
import Detail from './routes/Detail.js';
import Cart from './routes/Cart.js';&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ✅ lazy import 방식: 필요할 때만 로드
import { lazy, Suspense } from 'react';

const Detail = lazy(() =&amp;gt; import('./routes/Detail.js'));
const Cart   = lazy(() =&amp;gt; import('./routes/Cart.js'));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lazy로 감싼 컴포넌트는 &lt;b&gt;&quot;이 컴포넌트가 실제로 화면에 필요해지는 순간에 그때서야 파일을 불러오세요&quot;&lt;/b&gt; 라는 의미가 됩니다. 덕분에 첫 페이지 진입 시 불필요한 JS 파일을 다운받지 않아 &lt;b&gt;초기 로딩 속도가 빨라집니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Suspense &amp;mdash; 로딩 중 화면 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lazy로 컴포넌트를 불러오는 동안 잠깐의 지연이 생길 수 있습니다. 이때 빈 화면이 보이면 사용자 경험이 좋지 않으니 &lt;b&gt;Suspense&lt;/b&gt; 를 사용해 로딩 중 대체 화면을 보여줍니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;&amp;lt;Suspense fallback={&amp;lt;div&amp;gt;로딩중입니다...&amp;lt;/div&amp;gt;}&amp;gt;
  &amp;lt;Detail shoes={shoes} /&amp;gt;
&amp;lt;/Suspense&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fallback 속성에 로딩 중 보여줄 JSX를 작성합니다.&lt;/li&gt;
&lt;li&gt;귀찮다면 &amp;lt;Suspense&amp;gt;로 &amp;lt;Routes&amp;gt; 전체를 한꺼번에 감싸도 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;lazy import를 쓰면 좋은 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 컴포넌트에 lazy를 적용할 필요는 없습니다. 아래 조건에 해당하는 컴포넌트에 우선 적용해보세요.&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; 하위 페이지 컴포넌트 (Detail, Cart, MyPage 등)&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;반대로 메인 페이지 진입 시 즉시 보여야 하는 컴포넌트는 일반 import를 유지하는 게 좋습니다.&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;div&gt;도구 / 기법역할
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;React Developer Tools&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;컴포넌트 구조, state/props 실시간 확인 및 수정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Profiler 탭&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;컴포넌트별 렌더링 시간 측정, 성능 병목 탐지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Redux Developer Tools&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Redux store 상태 확인, dispatch 히스토리 추적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;lazy import&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;컴포넌트를 별도 JS 파일로 분리해 초기 로딩 속도 개선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Suspense&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;lazy 컴포넌트 로딩 중 대체 UI 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 개선은 무조건 처음부터 최적화하려는 것보다, &lt;b&gt;먼저 개발자도구로 문제를 파악하고&lt;/b&gt; 실제로 느린 부분을 찾아서 개선하는 순서로 접근하는 것이 효율적입니다. lazy import는 별다른 로직 변경 없이 import 한 줄만 바꾸면 되니 부담 없이 시도해볼 수 있는 첫 번째 성능 개선 방법입니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Suspense의 동작 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense의 동작 흐름을 단계별로 설명하면 이렇습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  화면에 보이는 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계 &amp;mdash; Detail 컴포넌트 파일을 아직 다운로드 중일 때&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 화면에는 fallback에 넣은 내용이 보입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;jboss-cli&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;┌──────────────────────────┐
│                          │
│      로딩중입니다...      │
│                          │
└──────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계 &amp;mdash; 파일 다운로드가 완료되면&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;div&amp;gt;로딩중입니다...&amp;lt;/div&amp;gt; 가 사라지고 실제 Detail 컴포넌트가 그 자리에 렌더링됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;┌──────────────────────────┐
│                          │
│   [Detail 컴포넌트 내용] │
│   신발 이름: Nike Air    │
│   가격: 129,000원        │
│   ...                    │
│                          │
└──────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  핵심 포인트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fallback에는 어떤 JSX든 넣을 수 있습니다. 단순한 텍스트 대신 스피너(로딩 애니메이션)나 스켈레톤 UI를 넣는 것이 실무에서 더 흔한 방식입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 텍스트
&amp;lt;Suspense fallback={&amp;lt;div&amp;gt;로딩중입니다...&amp;lt;/div&amp;gt;}&amp;gt;

// 스피너 컴포넌트
&amp;lt;Suspense fallback={&amp;lt;Spinner /&amp;gt;}&amp;gt;

// 스켈레톤 UI
&amp;lt;Suspense fallback={&amp;lt;SkeletonCard /&amp;gt;}&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 lazy import로 쪼개진 파일은 &lt;b&gt;최초 1회만 다운로드&lt;/b&gt;됩니다. 사용자가 Detail 페이지를 한 번 방문한 뒤 다시 돌아오면 이미 브라우저에 캐싱되어 있어서 로딩 화면이 다시 뜨지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>공부/frontend</category>
      <category>lazy</category>
      <category>react</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/319</guid>
      <comments>https://sesam-dev.tistory.com/319#entry319comment</comments>
      <pubDate>Wed, 1 Jul 2026 02:07:43 +0900</pubDate>
    </item>
    <item>
      <title>Tanstack Query(React Query) 완전 정복 &amp;mdash; 실시간 데이터를 쉽게 다루는 법</title>
      <link>https://sesam-dev.tistory.com/318</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;  Tanstack Query가 뭔가요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 애플리케이션을 만들다 보면 서버에서 데이터를 가져와야 할 일이 정말 많습니다. 예를 들어 쇼핑몰의 상품 목록, SNS의 게시글, 사용자 정보 같은 것들이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 데이터 요청(ajax)을 직접 처리하면 아래처럼 생각할 게 한두 가지가 아닙니다.&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;이 모든 걸 자동으로 처리해주는 라이브러리가 바로 &lt;b&gt;Tanstack Query&lt;/b&gt;입니다. 예전 이름은 &lt;b&gt;React Query&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;h3 data-ke-size=&quot;size23&quot;&gt;1단계 &amp;mdash; 패키지 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에 아래 명령어를 입력합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;npm install @tanstack/react-query&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계 &amp;mdash; main.jsx 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryClientProvider로 앱 전체를 감싸줘야 합니다. 이 작업은 &quot;앱 전체에서 Tanstack Query를 사용할 수 있게 허락해주는&quot; 설정이라고 생각하면 됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;;

const queryClient = new QueryClient();

createRoot(document.getElementById(&quot;root&quot;)).render(
  &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
    &amp;lt;App /&amp;gt;
  &amp;lt;/QueryClientProvider&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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; Redux의 &amp;lt;Provider&amp;gt;처럼, &amp;lt;QueryClientProvider&amp;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;mdash; useQuery&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tanstack Query의 핵심은 useQuery 훅입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { useQuery } from &quot;@tanstack/react-query&quot;;
import axios from &quot;axios&quot;;

function App() {
  let result = useQuery({
    queryKey: [&quot;userdata&quot;],
    queryFn: () =&amp;gt;
      axios.get(&quot;https://example.com/userdata.json&quot;).then((a) =&amp;gt; a.data),
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;queryKey : 이 요청의 &lt;b&gt;이름표&lt;/b&gt;입니다. 캐싱, 중복 제거 모두 이 값을 기준으로 동작합니다.&lt;/li&gt;
&lt;li&gt;queryFn : 실제 데이터를 가져오는 함수입니다. 반드시 데이터를 return해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 장점 1 &amp;mdash; 로딩/에러/성공 상태를 자동으로 알려줍니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 state를 만들어서 관리할 필요가 없습니다. result 변수 하나에 상태가 모두 담깁니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function App() {
  let result = useQuery({
    queryKey: [&quot;userdata&quot;],
    queryFn: () =&amp;gt;
      axios.get(&quot;https://example.com/userdata.json&quot;).then((a) =&amp;gt; a.data),
  });

  return (
    &amp;lt;div&amp;gt;
      {result.isPending &amp;amp;&amp;amp; &amp;lt;p&amp;gt;로딩 중입니다...&amp;lt;/p&amp;gt;}
      {result.isError &amp;amp;&amp;amp; &amp;lt;p&amp;gt;에러가 발생했습니다!&amp;lt;/p&amp;gt;}
      {result.isSuccess &amp;amp;&amp;amp; &amp;lt;p&amp;gt;이름: {result.data.name}&amp;lt;/p&amp;gt;}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;상태 변수의미
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;result.isPending&lt;/td&gt;
&lt;td&gt;데이터 불러오는 중&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;result.isError&lt;/td&gt;
&lt;td&gt;요청 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;result.isSuccess&lt;/td&gt;
&lt;td&gt;요청 성공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;result.data&lt;/td&gt;
&lt;td&gt;실제 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&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; 원래는 const [loading, setLoading] = useState(true) 같은 state를 직접 만들고 관리해야 했습니다. Tanstack Query는 이걸 자동으로 해줍니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 장점 2 &amp;mdash; 알아서 데이터를 최신 상태로 유지해줍니다&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;요청이 실패하면 자동으로 3~4번 재시도&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Tanstack Query를 &lt;b&gt;&quot;서버 상태 실시간 동기화 라이브러리&quot;&lt;/b&gt; 라고도 부릅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 장점 3 &amp;mdash; 중복 요청을 자동으로 없애줍니다 (Deduplication &amp;amp; 캐싱)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분이 Tanstack Query의 가장 강력한 기능 중 하나입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Deduplication (중복 제거)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Deduplication&lt;/b&gt;이란 같은 요청이 동시에 여러 번 발생해도 실제 네트워크 요청은 딱 한 번만 보내는 것을 말합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 App 컴포넌트와 Detail 컴포넌트가 동시에 사용자 정보를 필요로 한다고 가정해봅시다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// App 컴포넌트
function App() {
  let result = useQuery({
    queryKey: [&quot;userdata&quot;],  //   이름표가 같다!
    queryFn: () =&amp;gt; axios.get(&quot;https://example.com/userdata.json&quot;).then((a) =&amp;gt; a.data),
  });
}

// Detail 컴포넌트
function Detail() {
  let result = useQuery({
    queryKey: [&quot;userdata&quot;],  //   이름표가 같다!
    queryFn: () =&amp;gt; axios.get(&quot;https://example.com/userdata.json&quot;).then((a) =&amp;gt; a.data),
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 컴포넌트가 완전히 동일한 URL로 요청을 보내고 있지만, Tanstack Query는 queryKey가 같은 요청을 보고 &lt;b&gt;&quot;이미 처리 중인 요청이네!&quot;&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;b&gt;URL 주소가 아니라 queryKey&lt;/b&gt; 입니다! URL이 같아도 queryKey가 다르면 별개의 요청으로 취급합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ❌ queryKey가 달라서 중복 제거 안 됨 (요청 2번 발생)
useQuery({ queryKey: [&quot;user-info&quot;], queryFn: () =&amp;gt; axios.get(&quot;/userdata.json&quot;) });
useQuery({ queryKey: [&quot;user-data&quot;], queryFn: () =&amp;gt; axios.get(&quot;/userdata.json&quot;) });

// ✅ queryKey가 같아서 중복 제거 됨 (요청 1번만 발생)
useQuery({ queryKey: [&quot;userdata&quot;], queryFn: () =&amp;gt; axios.get(&quot;/userdata.json&quot;) });
useQuery({ queryKey: [&quot;userdata&quot;], queryFn: () =&amp;gt; axios.get(&quot;/userdata.json&quot;) });&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  캐싱 (Caching)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 한 번 성공하면 그 결과를 &lt;b&gt;메모리에 저장(캐싱)&lt;/b&gt; 해둡니다. 이후 동일한 queryKey로 요청이 들어오면 서버에 다시 묻지 않고 캐싱된 데이터를 즉시 반환합니다. 덕분에 페이지 이동 후 다시 돌아왔을 때 데이터가 즉시 표시되는 경험을 만들 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  캐싱된 데이터만 꺼내 쓰기 &amp;mdash; useQueryClient + getQueryData&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useQuery를 사용하면 요청 + 캐싱 + 상태관리가 한꺼번에 이루어지는데, 경우에 따라서는 &lt;b&gt;새로운 요청 없이 캐싱된 데이터만 꺼내 쓰고 싶을 때&lt;/b&gt;가 있습니다. 이럴 때 useQueryClient와 getQueryData를 사용합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { useQueryClient } from &quot;@tanstack/react-query&quot;;

function SomeComponent() {
  let q = useQueryClient();
  let result = q.getQueryData([&quot;getName&quot;]); // 캐싱된 데이터만 꺼내옴

  return &amp;lt;div&amp;gt;{result?.name}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getQueryData는 이전에 queryKey: ['getName']으로 요청해서 캐싱된 데이터를 바로 꺼내줍니다. 서버에 다시 요청하지 않기 때문에 매우 빠릅니다.&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; getQueryData는 캐싱된 값을 그냥 꺼내오는 것이기 때문에 isPending, isError, isSuccess 같은 상태 정보가 없습니다. 아직 데이터가 캐싱되지 않은 상황(요청 전이거나 실패한 경우)이라면 result가 undefined가 됩니다. 따라서 이 방식을 쓸 때는 예외 상황을 직접 처리해줘야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function SomeComponent() {
  let q = useQueryClient();
  let result = q.getQueryData([&quot;getName&quot;]);

  // 캐싱 데이터가 없는 경우를 직접 처리해야 함!
  if (!result) return &amp;lt;p&amp;gt;데이터가 아직 없습니다.&amp;lt;/p&amp;gt;;

  return &amp;lt;div&amp;gt;{result.name}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;언제 getQueryData를 쓰면 좋을까요?&lt;/b&gt; 이미 다른 컴포넌트에서 useQuery로 데이터를 확실히 불러온 상황이고, 지금 이 컴포넌트에서는 단순히 그 데이터를 읽기만 하면 되는 경우에 적합합니다. 불필요한 재요청이나 상태 구독 없이 가볍게 데이터를 참조하고 싶을 때 유용합니다.&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;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;let result = useQuery({
  queryKey: [&quot;userdata&quot;],
  queryFn: () =&amp;gt; axios.get(&quot;https://example.com/userdata.json&quot;).then((a) =&amp;gt; a.data),
  refetchOnWindowFocus: false, // 창 포커스 시 재요청 끄기
  retry: 1,                    // 실패 시 재시도 횟수
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Tanstack Query, 언제 써야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리를 배웠다고 해서 무조건 쓰는 건 좋지 않습니다. Tanstack Query가 진가를 발휘하는 상황과 굳이 필요 없는 상황을 구분하는 게 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ 이럴 때 쓰세요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실시간성이 중요한 데이터를 다룰 때&lt;/b&gt;가 가장 핵심입니다. 주식 시세, 실시간 채팅 메시지, 경쟁이 치열한 재고 수량처럼 서버 데이터가 자주 바뀌고 항상 최신 상태를 보여줘야 하는 경우에 자동 재요청 기능이 큰 힘을 발휘합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;여러 컴포넌트에서 같은 서버 데이터를 필요로 할 때&lt;/b&gt;도 좋습니다. 예를 들어 헤더의 유저 이름, 사이드바의 프로필 이미지, 본문의 유저 정보가 모두 같은 API에서 온다면, Deduplication과 캐싱 덕분에 요청은 한 번만 하고 여러 곳에서 공유할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로딩/에러 처리를 일관되게 관리하고 싶을 때&lt;/b&gt;도 효과적입니다. ajax 요청마다 state를 만들고 try-catch를 짜는 반복 작업이 줄어들어 코드가 훨씬 깔끔해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✖️ 이럴 때는 굳이 안 써도 됩니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;앱 시작 시 딱 한 번만 불러오고 거의 바뀌지 않는 데이터&lt;/b&gt;라면 굳이 Tanstack Query가 필요하지 않습니다. 예를 들어 카테고리 목록이나 설정값처럼 변경이 거의 없는 정적인 데이터는 그냥 useEffect + axios로 충분합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버와 통신이 거의 없는 단순한 UI 상태&lt;/b&gt;(모달 열고 닫기, 탭 전환, 입력값 등)는 그냥 useState로 관리하는 게 오히려 더 적합합니다. Tanstack Query는 어디까지나 서버에서 오는 데이터를 다루는 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &lt;b&gt;&quot;서버 데이터를 자주, 여러 곳에서 다룬다&quot;&lt;/b&gt; 면 Tanstack Query가 큰 도움이 되고, &lt;b&gt;&quot;단순히 한 번 불러오고 끝&quot;&lt;/b&gt; 이라면 굳이 도입할 필요는 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  RTK Query와 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redux Toolkit을 이미 사용하고 있다면 &lt;b&gt;RTK Query&lt;/b&gt;도 비슷한 기능을 제공합니다.&lt;/p&gt;
&lt;div&gt;비교 항목Tanstack QueryRTK Query
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;설치 필요&lt;/td&gt;
&lt;td&gt;별도 설치&lt;/td&gt;
&lt;td&gt;Redux Toolkit에 내장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코드 가독성&lt;/td&gt;
&lt;td&gt;깔끔함&lt;/td&gt;
&lt;td&gt;다소 복잡함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redux state 연동&lt;/td&gt;
&lt;td&gt;별도 작업 필요&lt;/td&gt;
&lt;td&gt;편리하게 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ajax 요청 후 Redux state까지 변경해야 하는 복잡한 케이스라면 RTK Query가 유리하고, 그냥 데이터를 가져와 화면에 보여주는 용도라면 &lt;b&gt;Tanstack Query가 더 간결하고 추천&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;Tanstack Query는 한 마디로 &lt;b&gt;&quot;ajax 요청을 더 쉽고 스마트하게 해주는 라이브러리&quot;&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;b&gt;Deduplication&lt;/b&gt;: queryKey 기준으로 중복 요청을 자동으로 하나로 합쳐줌&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐싱&lt;/b&gt;: 한 번 가져온 데이터를 재사용해 불필요한 네트워크 요청 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getQueryData&lt;/b&gt;: 새 요청 없이 캐싱된 데이터만 꺼내 쓸 수 있음 (단, 예외 처리는 직접)&lt;/li&gt;
&lt;li&gt;서버 데이터를 실시간으로, 여러 컴포넌트에서 다룰 때 특히 강력&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>공부/frontend</category>
      <category>react</category>
      <category>tanstack query</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/318</guid>
      <comments>https://sesam-dev.tistory.com/318#entry318comment</comments>
      <pubDate>Wed, 1 Jul 2026 02:04:24 +0900</pubDate>
    </item>
    <item>
      <title>리액트에서 자주 쓰는 if문 작성 패턴 5가지</title>
      <link>https://sesam-dev.tistory.com/317</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 컴포넌트 안에서 쓰는 if/else&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSX의 return() 안에서는 일반 if문을 쓸 수 없어서, return 전체를 if/else로 감쌉니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Component() {
  if (true) {
    return &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt;
  } else {
    return null
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;else는 생략 가능합니다 (return을 만나면 함수 실행이 멈추기 때문):&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Component() {
  if (true) {
    return &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt;
  }
  return null
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. JSX 안에서 쓰는 삼항연산자 (Ternary Operator)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSX 내부에서 바로 조건부 렌더링이 가능합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ada&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Component() {
  return (
    &amp;lt;div&amp;gt;
      { 1 === 1 ? &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt; : null }
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중첩도 가능하지만 가독성이 나빠지므로 비추천:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Component() {
  return (
    &amp;lt;div&amp;gt;
      { 1 === 1 ? &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt; : ( 2 === 2 ? &amp;lt;h4&amp;gt;안녕&amp;lt;/h4&amp;gt; : &amp;lt;h4&amp;gt;반갑&amp;lt;/h4&amp;gt; ) }
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &amp;amp;&amp;amp; 연산자로 if 역할 대신하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건 ? &amp;lt;JSX&amp;gt; : null 대신 &amp;amp;&amp;amp;로 더 간결하게 쓸 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 기존 방식
function Component() {
  return (
    &amp;lt;div&amp;gt;{ 1 === 1 ? &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt; : null }&amp;lt;/div&amp;gt;
  )
}

// &amp;amp;&amp;amp; 활용 방식 (동일한 기능)
function Component() {
  return (
    &amp;lt;div&amp;gt;{ 1 === 1 &amp;amp;&amp;amp; &amp;lt;h4&amp;gt;참이면 보여줄 HTML&amp;lt;/h4&amp;gt; }&amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;왼쪽이 true &amp;rarr; 오른쪽 JSX 렌더링&lt;/li&gt;
&lt;li&gt;왼쪽이 false &amp;rarr; false가 남아 렌더링 안 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. switch / case 조건문&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;if/else가 여러 개 연달아 있을 때 코드를 줄일 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// if/else 여러 개
function Component2() {
  var user = 'seller';
  if (user === 'seller') {
    return &amp;lt;h4&amp;gt;판매자 로그인&amp;lt;/h4&amp;gt;
  } else if (user === 'customer') {
    return &amp;lt;h4&amp;gt;구매자 로그인&amp;lt;/h4&amp;gt;
  } else {
    return &amp;lt;h4&amp;gt;그냥 로그인&amp;lt;/h4&amp;gt;
  }
}

// switch/case로 변환
function Component2() {
  var user = 'seller';
  switch (user) {
    case 'seller' :
      return &amp;lt;h4&amp;gt;판매자 로그인&amp;lt;/h4&amp;gt;
    case 'customer' :
      return &amp;lt;h4&amp;gt;구매자 로그인&amp;lt;/h4&amp;gt;
    default :
      return &amp;lt;h4&amp;gt;그냥 로그인&amp;lt;/h4&amp;gt;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점: 조건식에서 변수 하나만 비교 가능&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. object/array 자료형 응용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보여줄 HTML들을 object에 미리 저장해두고, 변수로 꺼내 쓰는 방식입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;function Component() {
  var 현재상태 = 'info';
  return (
    &amp;lt;div&amp;gt;
      { { info: &amp;lt;p&amp;gt;상품정보&amp;lt;/p&amp;gt;, shipping: &amp;lt;p&amp;gt;배송관련&amp;lt;/p&amp;gt;, refund: &amp;lt;p&amp;gt;환불약관&amp;lt;/p&amp;gt; }[현재상태] }
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 변수에 저장하면 더 깔끔합니다:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;jsx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;var 탭UI = {
  info: &amp;lt;p&amp;gt;상품정보&amp;lt;/p&amp;gt;,
  shipping: &amp;lt;p&amp;gt;배송관련&amp;lt;/p&amp;gt;,
  refund: &amp;lt;p&amp;gt;환불약관&amp;lt;/p&amp;gt;
}

function Component() {
  var 현재상태 = 'info';
  return (
    &amp;lt;div&amp;gt;{ 탭UI[현재상태] }&amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재상태가 'info'면 &amp;lt;p&amp;gt;상품정보&amp;lt;/p&amp;gt;, 'refund'면 &amp;lt;p&amp;gt;환불약관&amp;lt;/p&amp;gt;이 렌더링됩니다.&lt;/p&gt;</description>
      <category>공부/frontend</category>
      <category>react</category>
      <author>sesam</author>
      <guid isPermaLink="true">https://sesam-dev.tistory.com/317</guid>
      <comments>https://sesam-dev.tistory.com/317#entry317comment</comments>
      <pubDate>Wed, 17 Jun 2026 01:01:23 +0900</pubDate>
    </item>
  </channel>
</rss>