<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>스미스 연구소</title>
    <link>https://tool.smithlab.co.kr/</link>
    <description>계산기·변환기·성향 테스트·랭킹, 매일 쓰는 무료 도구 모음</description>
    <language>ko</language>
    <lastBuildDate>Wed, 11 Nov 2026 09:00:00 +0900</lastBuildDate>
    <atom:link href="https://tool.smithlab.co.kr/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>수능 표준점수·백분위·등급 차이 — 계산 공식과 성적표 읽는 법…</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/suneung-standard-score-percentile-grade/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/suneung-standard-score-percentile-grade/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>수능이 끝나고 가채점을 하면 "원점수 88점인데 표준점수는 몇 점이지?", "백분위 95면 몇 등급이지?" 같은 질문이 한꺼번에 몰려와요. 결론부터 말하면 <strong>표준점수는 '평균에서 얼마나 떨어졌는지'를, 백분위는 '나보다 낮은 사람이 몇 %인지'를, 등급은 '위에서부터 몇 % 안에 들었는지'를 나타내요.</strong> 세 숫자는 같은 시험 결과를 다른 눈금으로 잰 것이라, 하나만 보면 판단이 틀어지기 쉬워요.</p>
<p>2027학년도 수능은 2026년 11월 19일(목)에 치르고 성적은 12월 11일(금)에 통지돼요. 성적표가 나오기 전까지는 공식 표준점수를 알 수 없지만, 계산 원리를 알면 가채점 단계에서 "내 점수가 어디쯤인지"를 훨씬 침착하게 읽을 수 있어요. 이 글은 공식을 하나씩 풀고, 같은 원점수가 시험 난이도에 따라 얼마나 다른 표준점수가 되는지를 직접 계산한 예시로 보여 줘요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>표준점수 = (원점수 − 평균) ÷ 표준편차 × 20 + 100 (국어·수학), 탐구·제2외국어/한문은 × 10 + 50이에요.</li><li>백분위는 해당 영역 응시자 중 나보다 표준점수가 낮은 수험생의 비율(동점자는 절반을 더함)이에요.</li><li>등급은 표준점수 순으로 상위 4%·11%·23%·40%·60%·77%·89%·96%에서 끊고, 경계의 동점자는 위 등급으로 묶어요.</li><li>영어·한국사·제2외국어/한문은 절대평가라 원점수 구간으로 등급만 나와요.</li></ul></aside>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/suneung-standard-score-percentile-grade-1.svg" alt="원점수가 표준점수, 백분위, 등급으로 바뀌는 순서와 절대평가 영역을 나눈 흐름도" width="800" height="450" loading="lazy" decoding="async"><figcaption>상대평가 영역은 원점수 → 표준점수 → 백분위·등급 순서로, 절대평가 영역은 원점수 구간으로 바로 등급이 정해져요</figcaption></figure>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">수능 성적표에 나오는 세 가지 점수, 한눈에 비교</h2>
<p>수능 성적표에는 원점수가 적혀 있지 않아요. 대신 영역마다 표준점수·백분위·등급이 나오고, 절대평가 영역은 등급만 나와요. 원점수는 가채점으로 내가 알고 있는 숫자일 뿐, 대학에 제출되는 숫자는 아니에요.</p>
<div class="table-wrap"><table><thead><tr><th>구분</th><th>무엇을 뜻하나</th><th>나오는 영역</th><th>대학이 주로 쓰는 곳</th></tr></thead><tbody><tr><td>표준점수</td><td>평균과의 거리를 표준편차 단위로 바꾼 점수</td><td>국어·수학·탐구·제2외국어/한문</td><td>정시 환산점수 (주요 대학 국어·수학)</td></tr><tr><td>백분위</td><td>나보다 낮은 수험생 비율 (0~100)</td><td>국어·수학·탐구·제2외국어/한문</td><td>탐구 변환표준점수의 기준, 일부 대학 정시</td></tr><tr><td>등급</td><td>상대 비율 또는 원점수 구간으로 나눈 1~9</td><td>전 영역</td><td>수시 수능 최저학력기준, 영어·한국사 반영</td></tr></tbody></table></div>
<p>같은 성적이라도 어떤 숫자를 쓰는 대학이냐에 따라 유불리가 달라지는 이유가 여기에 있어요. 수시 최저는 거의 등급으로, 정시는 표준점수나 백분위로 계산하는 경우가 많아요.</p>
<h2 id="s2">표준점수 계산 공식, 원점수를 평균·표준편차로 바꾸기</h2>
<p>표준점수는 통계에서 쓰는 z점수(표준화 점수)를 수능에 맞게 늘린 값이에요. 먼저 내 원점수가 평균에서 표준편차 몇 개만큼 떨어져 있는지 구하고, 그 값을 보기 좋은 눈금으로 옮겨요.</p>
<ul><li>z = (내 원점수 − 그 영역 평균) ÷ 그 영역 표준편차</li><li>국어·수학 표준점수 = 100 + 20 × z</li><li>탐구·제2외국어/한문 표준점수 = 50 + 10 × z</li></ul>
<p>그래서 국어·수학은 <strong>평균이 100점, 한 표준편차가 20점</strong>인 눈금이 되고, 탐구는 <strong>평균 50점, 한 표준편차 10점</strong>인 눈금이 돼요. 평균과 정확히 같은 점수를 받으면 국어·수학은 100점, 탐구는 50점이 되는 셈이에요.</p>
<h3>계산 예시: 원점수 90점, 평균 60점, 표준편차 20점</h3>
<p>z = (90 − 60) ÷ 20 = 1.5 → 표준점수 = 100 + 20 × 1.5 = <strong>130점</strong>이에요. "평균보다 표준편차 1.5개만큼 높다"는 뜻이죠.</p>
<p>탐구 과목에서 50점 만점 중 45점, 평균 30점, 표준편차 10점이라면 z = 1.5 → 50 + 10 × 1.5 = <strong>65점</strong>이 돼요. 영역이 달라도 z가 같으면 '위치'는 같다는 점이 표준점수의 핵심이에요.</p>
<p>평균과 표준편차를 직접 구해 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/standard-deviation-calculator/">표준편차 계산기</a>에 반 친구들 모의고사 점수를 넣어 보면 공식이 손에 잡혀요. 다만 실제 수능의 평균·표준편차는 전체 응시자 기준이라 평가원이 채점 후에만 알 수 있어요.</p>
<aside class="callout tip"><b class="callout-h">참고</b><p>2027학년도 수능의 국어·수학은 공통과목과 선택과목(국어 화법과 작문·언어와 매체, 수학 확률과 통계·미적분·기하)으로 나뉘어요. 선택과목 점수는 공통과목 성적을 이용해 조정한 뒤 표준점수를 내기 때문에, 위의 단순 공식으로 계산한 값과 실제 성적표 점수는 다를 수 있어요. 원리를 이해하는 용도로 쓰세요.</p></aside>
<h2 id="s3">같은 원점수가 해마다 다른 표준점수가 되는 이유</h2>
<p>표준점수는 평균과 표준편차에 따라 움직여요. 시험이 어려워서 평균이 낮아지고 점수가 몰리면(표준편차가 작아지면), 같은 원점수라도 표준점수가 크게 올라가요. 아래는 원점수 70·80·90·100점이 시험 난이도(가상의 평균·표준편차)에 따라 어떤 표준점수가 되는지 직접 계산한 값이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">같은 원점수, 난이도에 따라 달라지는 표준점수 (국어·수학 공식, 가상 예시)</figcaption><div class="viz-legend"><span><i class="s1"></i>쉬운 시험 (평균 60·표준편차 20)</span><span><i class="s2"></i>보통 시험 (평균 55·표준편차 18)</span><span><i class="s3"></i>어려운 시험 (평균 50·표준편차 15)</span></div><svg viewBox="0 0 640 300" role="img" aria-label="같은 원점수, 난이도에 따라 달라지는 표준점수 (국어·수학 공식, 가상 예시)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">200</text><rect class="s1" x="73.6" y="124.2" width="32.3" height="129.8" rx="3"><title>원점수 70 · 쉬운 시험 (평균 60·표준편차 20) 110점</title></rect><rect class="s2" x="107.9" y="115.9" width="32.3" height="138.1" rx="3"><title>원점수 70 · 보통 시험 (평균 55·표준편차 18) 117점</title></rect><rect class="s3" x="142.1" y="104.1" width="32.3" height="149.9" rx="3"><title>원점수 70 · 어려운 시험 (평균 50·표준편차 15) 127점</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">원점수 70</text><rect class="s1" x="217.6" y="112.4" width="32.3" height="141.6" rx="3"><title>원점수 80 · 쉬운 시험 (평균 60·표준편차 20) 120점</title></rect><rect class="s2" x="251.9" y="103.0" width="32.3" height="151.0" rx="3"><title>원점수 80 · 보통 시험 (평균 55·표준편차 18) 128점</title></rect><rect class="s3" x="286.1" y="88.8" width="32.3" height="165.2" rx="3"><title>원점수 80 · 어려운 시험 (평균 50·표준편차 15) 140점</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">원점수 80</text><rect class="s1" x="361.6" y="100.6" width="32.3" height="153.4" rx="3"><title>원점수 90 · 쉬운 시험 (평균 60·표준편차 20) 130점</title></rect><rect class="s2" x="395.9" y="90.0" width="32.3" height="164.0" rx="3"><title>원점수 90 · 보통 시험 (평균 55·표준편차 18) 139점</title></rect><rect class="s3" x="430.1" y="73.5" width="32.3" height="180.5" rx="3"><title>원점수 90 · 어려운 시험 (평균 50·표준편차 15) 153점</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">원점수 90</text><rect class="s1" x="505.6" y="88.8" width="32.3" height="165.2" rx="3"><title>원점수 100 · 쉬운 시험 (평균 60·표준편차 20) 140점</title></rect><rect class="s2" x="539.9" y="77.0" width="32.3" height="177.0" rx="3"><title>원점수 100 · 보통 시험 (평균 55·표준편차 18) 150점</title></rect><rect class="s3" x="574.1" y="56.9" width="32.3" height="197.1" rx="3"><title>원점수 100 · 어려운 시험 (평균 50·표준편차 15) 167점</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">원점수 100</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">평균·표준편차는 설명을 위한 가상 값이에요. 실제 수능 값은 평가원 채점 결과 발표로 확인해요.</p><div class="viz-cap">단위: 점 · 자료: 표준점수 = 100 + 20 × (원점수 − 평균) ÷ 표준편차, 반올림해 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>쉬운 시험 (평균 60·표준편차 20)</th><th>보통 시험 (평균 55·표준편차 18)</th><th>어려운 시험 (평균 50·표준편차 15)</th></tr></thead><tbody><tr><td>원점수 70</td><td>110</td><td>117</td><td>127</td></tr><tr><td>원점수 80</td><td>120</td><td>128</td><td>140</td></tr><tr><td>원점수 90</td><td>130</td><td>139</td><td>153</td></tr><tr><td>원점수 100</td><td>140</td><td>150</td><td>167</td></tr></tbody></table></div></details></figure>
<p>같은 만점(100점)이라도 쉬운 시험에선 140점, 어려운 시험에선 167점이 돼요. 그래서 뉴스에서 말하는 <strong>'표준점수 최고점'은 그해 시험이 얼마나 어려웠는지를 보여 주는 지표</strong>로 쓰여요. 최고점이 높을수록 어려운 시험이었다는 뜻이에요.</p>
<p>이 차이는 정시에서 실제로 영향을 줘요. 국어가 어렵고 수학이 쉬웠던 해에는 국어를 잘 본 수험생이 같은 원점수 차이로도 더 큰 표준점수 차이를 얻게 돼요. "어려운 과목을 잘 본 사람이 유리하다"는 말이 여기서 나와요.</p>
<h2 id="s4">수능 백분위 뜻과 계산 방법</h2>
<p>백분위는 해당 영역(또는 과목) 응시자 가운데 <strong>내 표준점수보다 낮은 점수를 받은 수험생이 몇 %인지</strong>를 나타내요. 동점자가 있으면 그 인원의 절반을 아래쪽에 더해서 계산해요.</p>
<ul><li>백분위 = (나보다 표준점수가 낮은 인원 + 나와 같은 점수 인원 ÷ 2) ÷ 전체 응시자 × 100</li></ul>
<p>예를 들어 응시자 1,000명 중 나보다 낮은 사람이 940명, 나와 같은 점수가 20명이면 (940 + 10) ÷ 1,000 × 100 = <strong>백분위 95</strong>예요. "상위 5%쯤"이라고 읽으면 돼요.</p>
<p>점수가 정규분포를 따른다고 가정하면 표준점수와 백분위를 대략 이어 볼 수 있어요. 아래는 국어·수학 눈금(평균 100, 표준편차 20)에서 정규분포로 계산한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>표준점수 (국어·수학)</th><th>z</th><th>정규분포 가정 백분위</th></tr></thead><tbody><tr><td>80</td><td>−1.0</td><td>약 15.9</td></tr><tr><td>100</td><td>0</td><td>50.0</td></tr><tr><td>110</td><td>0.5</td><td>약 69.1</td></tr><tr><td>120</td><td>1.0</td><td>약 84.1</td></tr><tr><td>130</td><td>1.5</td><td>약 93.3</td></tr><tr><td>140</td><td>2.0</td><td>약 97.7</td></tr></tbody></table></div>
<p>실제 수능 점수는 정확한 정규분포가 아니고 만점 근처나 특정 점수에 몰리기도 해서, 위 표는 <strong>감을 잡는 용도</strong>예요. 실제 표준점수와 백분위의 대응은 평가원이 채점 후 발표하는 영역별 도수분포로만 알 수 있어요.</p>
<h3>표준점수와 백분위, 어느 쪽이 유리할까</h3>
<p>표준점수는 점수 간격을 그대로 살리고, 백분위는 순서만 남겨요. 그래서 상위권에서는 표준점수 1~2점 차이가 백분위로는 같은 숫자로 뭉개지는 일이 흔해요. 어려운 과목에서 고득점을 했다면 표준점수를 반영하는 대학이, 과목 간 점수 편차가 크지 않다면 백분위를 반영하는 대학이 상대적으로 유리할 수 있어요. 대학별 반영 방식은 모집요강으로 확인해야 해요.</p>
<h2 id="s5">수능 등급 비율, 1등급 4%부터 9등급까지</h2>
<p>상대평가 영역(국어·수학·탐구)의 등급은 표준점수 순서로 정해요. 위에서부터 누적 비율로 끊어요.</p>
<div class="table-wrap"><table><thead><tr><th>등급</th><th>누적 비율 (상위)</th><th>이 등급 안의 비율</th><th>정규분포 가정 국어·수학 표준점수 경계</th></tr></thead><tbody><tr><td>1</td><td>4%</td><td>4%</td><td>약 135</td></tr><tr><td>2</td><td>11%</td><td>7%</td><td>약 125</td></tr><tr><td>3</td><td>23%</td><td>12%</td><td>약 115</td></tr><tr><td>4</td><td>40%</td><td>17%</td><td>약 105</td></tr><tr><td>5</td><td>60%</td><td>20%</td><td>약 95</td></tr><tr><td>6</td><td>77%</td><td>17%</td><td>약 85</td></tr><tr><td>7</td><td>89%</td><td>12%</td><td>약 76</td></tr><tr><td>8</td><td>96%</td><td>7%</td><td>약 65</td></tr><tr><td>9</td><td>100%</td><td>4%</td><td>—</td></tr></tbody></table></div>
<figure class="viz viz-hbar"><figcaption class="viz-title">수능 상대평가 등급별 인원 비율</figcaption><svg viewBox="0 0 640 322" role="img" aria-label="수능 상대평가 등급별 인원 비율" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="50" y="29" text-anchor="end">1등급</text><rect class="s1" x="60" y="15" width="102.0" height="20" rx="3"><title>1등급 · 4%</title></rect><text class="viz-val" x="168.0" y="29">4%</text><text class="viz-ax lg" x="50" y="63" text-anchor="end">2등급</text><rect class="s1" x="60" y="49" width="178.5" height="20" rx="3"><title>2등급 · 7%</title></rect><text class="viz-val" x="244.5" y="63">7%</text><text class="viz-ax lg" x="50" y="97" text-anchor="end">3등급</text><rect class="s1" x="60" y="83" width="306.0" height="20" rx="3"><title>3등급 · 12%</title></rect><text class="viz-val" x="372.0" y="97">12%</text><text class="viz-ax lg" x="50" y="131" text-anchor="end">4등급</text><rect class="s1" x="60" y="117" width="433.5" height="20" rx="3"><title>4등급 · 17%</title></rect><text class="viz-val" x="499.5" y="131">17%</text><text class="viz-ax lg" x="50" y="165" text-anchor="end">5등급</text><rect class="s1" x="60" y="151" width="510.0" height="20" rx="3"><title>5등급 · 20%</title></rect><text class="viz-val" x="576.0" y="165">20%</text><text class="viz-ax lg" x="50" y="199" text-anchor="end">6등급</text><rect class="s1" x="60" y="185" width="433.5" height="20" rx="3"><title>6등급 · 17%</title></rect><text class="viz-val" x="499.5" y="199">17%</text><text class="viz-ax lg" x="50" y="233" text-anchor="end">7등급</text><rect class="s1" x="60" y="219" width="306.0" height="20" rx="3"><title>7등급 · 12%</title></rect><text class="viz-val" x="372.0" y="233">12%</text><text class="viz-ax lg" x="50" y="267" text-anchor="end">8등급</text><rect class="s1" x="60" y="253" width="178.5" height="20" rx="3"><title>8등급 · 7%</title></rect><text class="viz-val" x="244.5" y="267">7%</text><text class="viz-ax lg" x="50" y="301" text-anchor="end">9등급</text><rect class="s1" x="60" y="287" width="102.0" height="20" rx="3"><title>9등급 · 4%</title></rect><text class="viz-val" x="168.0" y="301">4%</text></svg><p class="viz-note">등급 경계에 동점자가 있으면 모두 위 등급으로 묶어서 실제 비율은 조금 달라질 수 있어요.</p><div class="viz-cap">단위: % · 자료: 수능 등급 구분 누적 비율(4·11·23·40·60·77·89·96%)에서 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>비율</th></tr></thead><tbody><tr><td>1등급</td><td>4</td></tr><tr><td>2등급</td><td>7</td></tr><tr><td>3등급</td><td>12</td></tr><tr><td>4등급</td><td>17</td></tr><tr><td>5등급</td><td>20</td></tr><tr><td>6등급</td><td>17</td></tr><tr><td>7등급</td><td>12</td></tr><tr><td>8등급</td><td>7</td></tr><tr><td>9등급</td><td>4</td></tr></tbody></table></div></details></figure>
<p>오른쪽 '표준점수 경계' 열은 정규분포를 가정한 계산값이라 실제 등급컷과는 달라요. 실제 등급 구분 표준점수는 성적 통지와 함께 평가원이 발표해요.</p>
<h3>동점자는 위 등급, 그래서 1등급이 4%를 넘기도</h3>
<p>등급 경계에 같은 표준점수를 받은 수험생이 여러 명이면 모두 위 등급을 받아요. 특히 탐구처럼 만점자가 많이 나오는 과목은 만점자만으로 4%를 넘으면 2등급이 아예 없어지는 이른바 '등급 블랭크'가 생기기도 해요. 반대로 1등급 비율이 4%보다 넉넉하게 나오기도 하고요. 가채점 단계에서 사설 기관이 내놓는 '예상 등급컷'이 1~2점씩 흔들리는 이유도 이 동점자 처리와 실제 분포를 미리 알 수 없기 때문이에요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">영어·한국사·제2외국어/한문은 절대평가 등급만</h2>
<p>영어·한국사·제2외국어/한문은 다른 사람과 비교하지 않고 <strong>원점수 구간으로 등급을 정해요.</strong> 성적표에도 등급만 나와요.</p>
<div class="table-wrap"><table><thead><tr><th>영역</th><th>만점</th><th>1등급</th><th>등급 간격</th></tr></thead><tbody><tr><td>영어</td><td>100점</td><td>90점 이상</td><td>10점 (80점 이상 2등급…)</td></tr><tr><td>한국사</td><td>50점</td><td>40점 이상</td><td>5점 (35점 이상 2등급…)</td></tr><tr><td>제2외국어/한문</td><td>50점</td><td>45점 이상</td><td>5점 (40점 이상 2등급…)</td></tr></tbody></table></div>
<p>절대평가라고 비율이 일정한 건 아니에요. 시험이 어려우면 1등급이 크게 줄어요. 2026학년도 수능 영어는 90점 이상 1등급 비율이 3.11%로, 상대평가 1등급(4%)보다도 적어서 평가원장이 출제 취지에 미치지 못했다고 밝힌 바 있어요(<a href="https://www.newsis.com/view/NISX20251204_0003428579" target="_blank" rel="noopener">뉴시스 보도</a>). 그래서 영어도 "90점만 넘으면 된다"고 느슨하게 보지 말고 실제 점수 구간을 기준으로 수시 최저를 따져야 해요.</p>
<h2 id="s7">가채점 점수로 내 위치 읽는 법 (성적 발표 전)</h2>
<p>성적 통지일(12월 11일) 전에는 공식 표준점수가 없어요. 이 기간에 할 수 있는 건 <strong>원점수 → 예상 등급</strong>을 넓게 잡아 두는 거예요.</p>
<ol><li><strong>수능 당일 저녁 가채점</strong>: 문제지에 표시한 답으로 원점수를 계산해요. 평가원 정답 발표 뒤에 다시 확인해요.</li><li><strong>예상 등급은 '구간'으로</strong>: 사설 기관 예상 등급컷과 2점 이내면 아래 등급 가능성도 같이 적어 둬요. 동점자·분포 차이로 흔들리는 폭이 그 정도예요.</li><li><strong>수시 최저부터 판단</strong>: 등급만 쓰는 수시 최저는 가채점으로도 판단이 가능해요. 탐구 2과목 평균처럼 계산 규칙이 있는 경우는 <a href="https://tool.smithlab.co.kr/calculator/average-calculator/">평균 계산기</a>로 경우의 수를 미리 따져 보세요.</li><li><strong>정시는 성적표를 받은 뒤</strong>: 표준점수·백분위·대학별 변환표준점수는 실제 성적이 나와야 계산할 수 있어요.</li></ol>
<p>수능 이후의 일정(정답 확정, 성적 통지, 수시 발표, 정시 원서 접수)은 <a href="https://tool.smithlab.co.kr/posts/date/after-suneung-2027-schedule-score-jeongsi/">수능 끝나고 할 일 2027, 가채점·성적 발표·정시 원서 일정 총정리</a>에 날짜별로 정리해 두었어요. 시험까지 남은 날짜는 <a href="https://tool.smithlab.co.kr/date/suneung-dday/">수능 D-day 계산기</a>로 확인할 수 있어요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 틀리는 지점</h2>
<ul><li><strong>원점수가 높으면 표준점수도 무조건 높다?</strong> 같은 시험·같은 과목 안에서만 맞아요. 선택과목이 다르거나 해가 다르면 원점수 비교는 의미가 작아요.</li><li><strong>백분위 90 = 90점?</strong> 백분위는 점수가 아니라 비율이에요. 백분위 90은 '나보다 낮은 사람이 90%'라는 뜻이에요.</li><li><strong>1등급 = 백분위 96 이상?</strong> 대체로 맞지만 동점자 처리 때문에 경계가 조금씩 달라져요.</li><li><strong>영어 1등급은 늘 넉넉하다?</strong> 절대평가라도 시험 난이도에 따라 비율이 크게 바뀌어요.</li><li><strong>표준점수 최고점 = 만점자 점수가 늘 같다?</strong> 해마다 평균·표준편차가 달라 최고점도 달라져요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>수능 표준점수는 어떻게 계산하나요?</h3>
<p>국어·수학은 (원점수 − 평균) ÷ 표준편차 × 20 + 100, 탐구·제2외국어/한문은 × 10 + 50으로 계산해요. 평균과 표준편차는 전체 응시자 기준이라 성적 통지 때 공식 값이 나와요. 국어·수학 선택과목은 공통과목 성적으로 조정하는 단계가 더 있어서 단순 공식과 결과가 다를 수 있어요.</p>
<h3>표준점수와 백분위의 차이는 무엇인가요?</h3>
<p>표준점수는 평균에서 얼마나 떨어졌는지를 점수 간격 그대로 보여 주고, 백분위는 나보다 낮은 수험생 비율만 보여 줘요. 상위권에서는 표준점수 차이가 백분위로는 같은 숫자로 묶이는 일이 많아요. 어느 쪽을 쓰는지는 대학 모집요강마다 달라요.</p>
<h3>수능 등급 비율은 어떻게 되나요?</h3>
<p>상대평가 영역은 표준점수 순으로 상위 4%까지 1등급, 11%까지 2등급, 23%까지 3등급, 40%·60%·77%·89%·96%까지 각각 4~8등급, 나머지가 9등급이에요. 경계의 동점자는 모두 위 등급을 받아서 실제 비율은 조금 달라질 수 있어요.</p>
<h3>영어는 몇 점이면 1등급인가요?</h3>
<p>영어는 절대평가라 원점수 90점 이상이면 1등급, 이후 10점 간격으로 등급이 내려가요. 한국사는 50점 만점에 40점 이상, 제2외국어/한문은 50점 만점에 45점 이상이 1등급이에요. 비율이 아니라 점수로 정하므로 시험 난이도에 따라 1등급 비율이 크게 달라져요.</p>
<h3>2027 수능 성적은 언제 나오나요?</h3>
<p>2027학년도 수능 성적은 2026년 12월 11일(금)에 통지돼요. 재학생은 학교에서, 졸업생은 원서를 낸 곳에서 받고, 대학 제출용 성적증명서는 <a href="https://csatreportcard.kice.re.kr/?m=0502&s=suneung" target="_blank" rel="noopener">평가원 수능 성적증명서 발급 시스템</a>에서 발급할 수 있어요.</p>
<h3>가채점으로 표준점수를 미리 알 수 있나요?</h3>
<p>정확히는 알 수 없어요. 그해 평균과 표준편차가 채점 후에야 정해지기 때문이에요. 가채점 단계에서는 예상 등급을 1등급 폭으로 넓게 잡고, 수시 최저 충족 여부부터 판단하는 게 현실적이에요.</p>
<h2 id="s10">정리</h2>
<p>표준점수는 '평균에서의 거리', 백분위는 '내 아래 비율', 등급은 '위에서부터의 누적 비율'이에요. 국어·수학 표준점수는 100 + 20z, 탐구는 50 + 10z로 계산하고, 등급은 4·11·23·40·60·77·89·96%에서 끊어요. 영어·한국사·제2외국어/한문은 원점수 구간으로 등급만 나와요.</p>
<p>시험 직후에는 가채점으로 예상 등급을 넓게 잡고 수시 최저부터 판단한 뒤, 12월 11일 성적표가 나오면 지원 대학 모집요강의 반영 방식에 맞춰 다시 계산하세요. 시험 날짜와 시간표는 <a href="https://tool.smithlab.co.kr/posts/date/2027-suneung-dday-timetable/">2027 수능 D-day·시간표 총정리</a>에서, 공식 안내는 <a href="https://www.suneung.re.kr/" target="_blank" rel="noopener">한국교육과정평가원 대학수학능력시험 누리집</a>에서 확인할 수 있어요. 시험에서 남은 문제를 찍을 때의 기대 점수가 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/calculator/guess-all-objective-questions-probability/">객관식 찍기 확률</a> 글도 함께 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>크리스마스 선물 예산 정하는 법 — 사람별로 나누고 내 선물 스타일로…</title>
      <link>https://tool.smithlab.co.kr/posts/survey/christmas-gift-budget-by-person-style-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/christmas-gift-budget-by-person-style-guide/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>12월에 선물 살 사람이 일곱 명이라고 해 볼게요. 한 명당 "이 정도면 되겠지" 하고 3만 원씩만 잡아도 합계는 21만 원이고, 거기에 포장비와 배송비가 붙으면 25만 원 가까이 돼요. 결론부터 말하면 <strong>선물 예산은 한 명당 금액이 아니라 총액을 먼저 정하고, 거기서 사람별로 나누는 쪽이 덜 흔들려요.</strong> 순서가 바뀌면 마지막 사람쯤에서 예산이 바닥나거나, 반대로 아끼느라 정작 가까운 사람 선물이 초라해지는 일이 생겨요.</p>
<p>이 글은 그 계산을 예시 숫자로 풀어 보고, 선물을 고르는 방식이 사람마다 어떻게 다른지(그래서 어디서 실패하는지), 받는 사람이 연인·친구·가족·직장 동료일 때 무엇부터 정하면 좋은지를 정리해요. 내 선물 스타일이 궁금하면 <a href="https://tool.smithlab.co.kr/survey/christmas-gift-type/">크리스마스 선물 유형 테스트</a>를 먼저 해 봐도 좋아요. 재미로 보는 테스트예요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>총액을 먼저 정하고, 가까운 정도에 가중치를 줘서 사람별로 나누면 예산이 덜 흔들려요.</li><li>선물 스타일은 크게 정성형·실용형·아이디어형·경험형·가성비형으로 나눌 수 있고, 유형마다 자주 놓치는 지점이 달라요.</li><li>교환 선물 모임은 금액 상한을 지키는 게 가장 중요하고, 같이 쓰는 물건이나 간식이 무난해요.</li><li>선물 값은 마음의 크기가 아니에요. 상대가 한 말을 기억했다는 신호가 값보다 오래 남아요.</li></ul></aside>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/christmas-gift-budget-by-person-style-guide-1.svg" alt="총액을 사람별로 나누는 순서: 총액 정하기, 가까운 정도로 가중치 주기, 사람별 상한 적기, 남은 돈은 예비비" width="800" height="450" loading="lazy" decoding="async"><figcaption>총액을 먼저 정하고 사람별 상한으로 나누는 네 단계</figcaption></figure>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">총액부터 정하고 사람별로 나누는 계산</h2>
<p>가장 단순한 방법은 "올해 선물에 쓸 수 있는 돈"을 한 줄로 적는 거예요. 예를 들어 20만 원이라고 해 볼게요. 그다음 가까운 정도에 따라 <strong>가중치</strong>를 줘요. 연인은 3, 가족 둘은 각 2, 친한 친구 둘은 각 1.5, 직장 동료 둘은 각 1이라고 두면 합계는 3 + 4 + 3 + 2 = 12가 돼요.</p>
<p>20만 원을 12로 나누면 가중치 1당 약 16,667원이에요. 그러면 사람별 상한은 이렇게 나와요.</p>
<div class="table-wrap"><table><thead><tr><th>받는 사람</th><th>가중치</th><th>상한(약)</th></tr></thead><tbody><tr><td>연인 1명</td><td>3</td><td>50,000원</td></tr><tr><td>가족 2명</td><td>각 2</td><td>각 33,333원</td></tr><tr><td>친한 친구 2명</td><td>각 1.5</td><td>각 25,000원</td></tr><tr><td>직장 동료 2명</td><td>각 1</td><td>각 16,667원</td></tr></tbody></table></div>
<p>합계를 검산하면 50,000 + 66,667 + 50,000 + 33,333 = 200,000원이에요. 여기에 포장·배송비로 <strong>총액의 10% 정도를 예비비</strong>로 따로 빼 두면 마지막에 덜 당황해요. 18만 원을 사람별로 나누고 2만 원을 예비로 두는 식이에요. 이 숫자는 어디까지나 계산 예시예요. 가중치는 내 관계에 맞게 바꿔서 쓰세요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">총액 20만 원을 가중치로 나눈 사람별 상한 (예시 계산)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="총액 20만 원을 가중치로 나눈 사람별 상한 (예시 계산)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="117" y="29" text-anchor="end">연인</text><rect class="s1" x="127" y="15" width="443.0" height="20" rx="3"><title>연인 · 50,000원</title></rect><text class="viz-val" x="576.0" y="29">50,000원</text><text class="viz-ax lg" x="117" y="63" text-anchor="end">가족(1명)</text><rect class="s1" x="127" y="49" width="295.3" height="20" rx="3"><title>가족(1명) · 33,333원</title></rect><text class="viz-val" x="428.3" y="63">33,333원</text><text class="viz-ax lg" x="117" y="97" text-anchor="end">친한 친구(1명)</text><rect class="s1" x="127" y="83" width="221.5" height="20" rx="3"><title>친한 친구(1명) · 25,000원</title></rect><text class="viz-val" x="354.5" y="97">25,000원</text><text class="viz-ax lg" x="117" y="131" text-anchor="end">직장 동료(1명)</text><rect class="s1" x="127" y="117" width="147.7" height="20" rx="3"><title>직장 동료(1명) · 16,667원</title></rect><text class="viz-val" x="280.7" y="131">16,667원</text></svg><p class="viz-note">가중치와 총액은 각자 상황에 맞게 바꿔 쓰는 예시예요</p><div class="viz-cap">단위: 원 · 자료: 가중치 3·2·1.5·1 로 직접 계산한 예시</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>상한</th></tr></thead><tbody><tr><td>연인</td><td>50,000</td></tr><tr><td>가족(1명)</td><td>33,333</td></tr><tr><td>친한 친구(1명)</td><td>25,000</td></tr><tr><td>직장 동료(1명)</td><td>16,667</td></tr></tbody></table></div></details></figure>
<h3>가중치가 애매할 때</h3>
<p>가까운 정도로 정하기 어려우면 "올해 얼마나 자주 만났나"나 "내가 받은 게 있나"를 기준으로 삼아도 돼요. 다만 받은 만큼 돌려주려고 금액을 맞추기 시작하면 서로 부담이 커지니, 금액을 맞추기보다 <strong>상대가 부담 없이 받을 수 있는 선</strong>을 기준으로 삼는 게 편해요.</p>
<h2 id="s2">선물을 고르는 방식은 다섯 가지로 나뉘어요</h2>
<p>같은 3만 원이어도 어떻게 쓰느냐는 사람마다 달라요. 아래는 선물을 고르는 습관을 다섯 가지로 나눈 거예요. 학술적인 분류가 아니라 이 사이트 테스트에서 쓰는 재미용 구분이고, 한 사람이 여러 유형을 섞어 쓰는 게 보통이에요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/christmas-gift-budget-by-person-style-guide-2.svg" alt="정성 손편지형, 실용 취향저격형, 센스 아이디어형, 함께하는 경험형, 가성비 합리형 다섯 가지와 각각 놓치기 쉬운 점" width="800" height="450" loading="lazy" decoding="async"><figcaption>선물 스타일 다섯 가지와 자주 놓치는 지점</figcaption></figure>
<ul><li><strong>정성 손편지형</strong>: 편지, 수제 소품, 앨범처럼 시간을 쓰는 선물을 좋아해요. 놓치기 쉬운 건 마감이에요. 만들다 보면 12월 24일 밤이 돼요. 11월 말에 시작하고 완성 기한을 12월 10일로 잡아 두면 좋아요.</li><li><strong>실용 취향저격형</strong>: 대화에서 나온 힌트를 기억해서 꼭 필요한 물건을 골라요. 놓치기 쉬운 건 '선물 느낌'이에요. 실용템에 한 줄 메시지를 붙이면 훨씬 선물답게 돼요.</li><li><strong>센스 아이디어형</strong>: 받는 순간의 표정을 먼저 상상해요. 놓치기 쉬운 건 쓸모예요. 웃기는 포장 속에 쓸 수 있는 물건 하나를 넣어 보세요.</li><li><strong>함께하는 경험형</strong>: 전시나 공연, 식사처럼 같이 보내는 시간을 줘요. 놓치기 쉬운 건 일정 조율이에요. 날짜를 같이 정해야 선물이 완성돼요.</li><li><strong>가성비 합리형</strong>: 예산 안에서 만족을 최대로 만들어요. 놓치기 쉬운 건 정성이 안 보이는 거예요. 가격표를 떼고 쪽지를 한 줄 더하면 달라져요.</li></ul>
<p>내가 어느 쪽에 가까운지는 <a href="https://tool.smithlab.co.kr/survey/christmas-gift-type/">크리스마스 선물 유형 테스트</a>로 가볍게 볼 수 있고, 평소 선물 취향 자체가 궁금하면 <a href="https://tool.smithlab.co.kr/survey/gift-style/">선물 스타일 테스트</a>와 같이 해 보면 두 결과가 겹치는지 달라지는지 비교하는 재미가 있어요.</p>
<h2 id="s3">받는 사람별로 고르는 순서</h2>
<h3>연인</h3>
<p>선물 고르기 가장 어려운 상대예요. 기대치가 높고 정보도 많거든요. 이럴 땐 <strong>가장 최근 3개월 동안 한 말</strong>을 떠올려 보세요. "이거 괜찮네", "저건 불편하다" 같은 말이 후보가 돼요. 후보가 세 개 나오면 그중 쓸모 있는 것 하나, 마음이 보이는 것 하나를 조합하는 식이 무난해요. 예를 들면 필요하다고 했던 장갑에 직접 쓴 카드를 같이 건네는 거예요.</p>
<h3>친구</h3>
<p>친구에게는 부담이 가장 큰 변수예요. 받는 사람이 답례를 고민하게 되는 금액이면 선물이 아니라 숙제가 돼요. 비슷한 금액대로 서로 주고받는 사이라면 괜찮지만, 한 사람만 일방적으로 크게 쓰면 어색해져요. 금액을 모르겠으면 <strong>같이 먹는 음식</strong>이나 <strong>간식 세트</strong>가 안전해요.</p>
<h3>가족</h3>
<p>가족은 선물이 이미 많아서 겹치기 쉬워요. 이럴 땐 "새로 사기"보다 "있는 걸 더 편하게 만들기"가 잘 맞아요. 예를 들어 매일 쓰는 물건의 업그레이드 버전이나, 함께 앉아 먹을 수 있는 음식이에요. 나이 든 가족에게는 사용법이 복잡한 기기보다 바로 쓸 수 있는 물건이 낫고, 사이즈가 필요한 건 몰래 확인해 두세요.</p>
<h3>직장 동료</h3>
<p>직장에서는 <strong>규정이 먼저</strong>예요. 회사에 선물 금액이나 교환 규칙이 있다면 그걸 따르는 게 가장 안전해요. 규칙이 없을 때는 나누어 먹을 수 있는 간식이나 소소한 소품처럼 받아도 부담이 없는 선물이 좋아요. 교환 선물 모임이라면 <a href="https://tool.smithlab.co.kr/posts/life/secret-santa-manito-rules-gift-budget/">마니또·시크릿 산타 규칙과 선물 예산 글</a>을 같이 보면 규칙 정하기가 쉬워져요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/christmas-gift-budget-by-person-style-guide-3.svg" alt="받는 사람별 우선순위: 연인은 최근 한 말, 친구는 부담 없는 선, 가족은 겹침 피하기, 동료는 규정 확인" width="800" height="450" loading="lazy" decoding="async"><figcaption>받는 사람에 따라 먼저 확인할 것이 달라요</figcaption></figure>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s4">예산을 지키는 데 도움이 되는 작은 습관</h2>
<p>계산은 한 번이면 끝나지만 지키는 건 12월 내내 이어져요. 첫째, 후보를 <strong>정할 때 가격을 같이 적어</strong> 두세요. 메모 앱에 "연인 — 장갑 32,000원, 카드 3,000원"처럼 한 줄씩 쌓이면 합계가 눈에 보여요. 둘째, 결제는 사람별로 쪼개서 하기보다 <strong>하루에 몰아서</strong> 하면 배송 일정과 총액을 한 번에 확인할 수 있어요. 셋째, 예비비를 쓰고 나면 남은 금액을 다시 가중치로 나누지 말고, 가장 가까운 한두 사람에게 마음 쓸 몫으로 남겨 두세요.</p>
<p>예산이 이미 넘었다면 줄이는 순서도 있어요. 가중치가 가장 낮은 관계의 상한부터 줄이고, 그래도 부족하면 물건 대신 카드 한 장이나 간식 한 봉지로 바꿔요. 선물은 줄여도 인사는 줄이지 않아도 된다는 점을 기억하세요. 가까운 사람일수록 값보다 "내 얘기를 기억하고 있구나" 하는 신호가 더 오래 가요.</p>
<h2 id="s5">선물 고르다 흔히 하는 실수</h2>
<ul><li><strong>마지막 주에 몰아서 사기</strong>: 12월 셋째 주쯤부터는 원하는 상품이 품절되거나 배송이 늦어질 수 있어요. 배송 일정은 판매처 안내를 확인하세요.</li><li><strong>받는 사람이 이미 가진 것 사기</strong>: 비슷한 물건이 있는지 한 번만 물어보거나 슬쩍 확인해 보세요.</li><li><strong>사이즈·색상 확인 안 하기</strong>: 옷이나 장갑은 교환이 어려울 수 있어요. 교환·환불 규정을 구매 전에 확인해 두세요.</li><li><strong>예비비 없이 계산하기</strong>: 포장지, 카드, 배송비가 생각보다 커요.</li><li><strong>금액만 맞추려다 마음을 빼먹기</strong>: 쪽지 한 줄이 가격표를 가려 줘요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s6">자주 묻는 질문</h2>
<h3>선물 예산은 한 명당 얼마가 적당한가요?</h3>
<p>정해진 정답은 없어요. 올해 쓸 수 있는 총액을 먼저 정하고 가까운 정도에 가중치를 줘서 나누면 내 상황에 맞는 금액이 나와요. 위의 20만 원·12 가중치 계산처럼 해 보세요.</p>
<h3>교환 선물 금액은 어떻게 정하나요?</h3>
<p>모임에서 상한을 한 번 정하고 지키는 게 가장 중요해요. 금액이 들쭉날쭉하면 받는 사람이 서운해지기 쉬워서, 상한을 정한 뒤 그 안에서 고르는 방식이 편해요.</p>
<h3>선물이 마음에 안 들까 걱정돼요.</h3>
<p>교환이 가능한 상품을 고르거나 선물에 영수증 대신 교환 가능 안내를 붙이는 방법이 있어요. 아니면 물건보다 같이 하는 경험으로 바꿔 보세요. 다만 이 경우도 상대의 일정과 체력을 미리 확인해야 해요.</p>
<h3>선물 유형 테스트 결과는 믿어도 되나요?</h3>
<p>재미로 보는 테스트예요. 상황이나 받는 사람에 따라 내 스타일이 달라질 수 있어서, 결과를 이야깃거리나 선물 준비 방식의 힌트 정도로 봐 주세요.</p>
<h3>직접 만든 선물은 부담스러울까요?</h3>
<p>받는 사람에 따라 달라요. 가까운 사이에서는 반기는 경우가 많지만, 관계가 얕으면 부담이 될 수 있어요. 소모품이나 작은 간식처럼 가볍게 줄 수 있는 형태가 무난해요.</p>
<h2 id="s7">정리</h2>
<p>선물 예산은 총액 → 가중치 → 사람별 상한 → 예비비 순서로 정하면 흔들리지 않아요. 선물을 고르는 습관은 사람마다 다르고, 유형마다 놓치기 쉬운 지점이 있으니 그 부분만 보완해 보세요. 마지막으로 가격보다 오래 남는 건 상대가 한 말을 기억했다는 신호예요. 내 스타일이 궁금하면 <a href="https://tool.smithlab.co.kr/survey/christmas-gift-type/">크리스마스 선물 유형 테스트</a>로 가볍게 확인해 보세요. 선물 교환 확률이 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/calculator/gift-exchange-own-gift-probability-derangement/">선물 교환에서 내 선물을 내가 받을 확률</a> 글도 재미있어요.</p>
    ]]></description>
    </item>
    <item>
      <title>강아지상 고양이상 구별법, 동물상 얼굴 특징과 인상이 만들어지는 이유</title>
      <link>https://tool.smithlab.co.kr/posts/survey/dog-cat-face-animal-types/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/dog-cat-face-animal-types/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연예인 이야기를 하다 보면 "저 사람은 완전 강아지상이야", "고양이상이라 차가워 보여" 같은 말이 자연스럽게 나와요. <strong>강아지상과 고양이상은 주로 눈매로 갈려요. 눈꼬리가 수평이거나 살짝 내려가고 눈과 얼굴선이 둥글면 강아지상, 눈꼬리가 위로 올라가고 눈매가 가로로 길며 턱선이 갸름하면 고양이상이라고 부르는 경우가 많아요.</strong></p>
<p>동물상은 학술 용어가 아니라 한국에서 외모의 인상을 짧게 표현하려고 쓰는 대중적인 말이에요. 그래서 정해진 기준표가 있는 건 아니지만, 사람들이 공통으로 떠올리는 얼굴 단서는 꽤 비슷해요. 이 글에서는 강아지상과 고양이상을 가르는 단서, 여우상·곰상·토끼상 같은 다른 동물상과의 차이, 그리고 "얼굴로 성격을 알 수 있을까"라는 질문에 대해 심리학 연구가 말하는 내용을 정리했어요. 재미로 내 동물상을 확인하고 싶다면 <a href="https://tool.smithlab.co.kr/survey/dog-cat-face/">강아지상 고양이상 테스트</a>를 해 보세요. 결과는 재미로만 봐 주세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>강아지상은 수평이거나 살짝 처진 눈꼬리, 둥근 눈과 얼굴선, 완만한 눈썹이 대표 단서예요. 순하고 친근한 인상으로 묘사돼요.</li><li>고양이상은 올라간 눈꼬리, 가로로 긴 눈매, 갸름한 턱선, 또렷한 눈썹이 대표 단서예요. 시크하고 도도한 인상으로 묘사돼요.</li><li>둥글고 큰 눈이 귀엽고 다정해 보이는 건 '베이비 스키마'라는 심리 반응과 관련이 있어요. 하지만 인상은 인상일 뿐, 실제 성격과는 별개예요.</li><li>대부분의 얼굴은 두 쪽 단서를 섞어 가지고 있고, 표정·화장·각도에 따라 인상이 쉽게 바뀌어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">강아지상 고양이상 뜻, 어디서 나온 말일까</h2>
<p>동물상은 사람의 얼굴 인상을 특정 동물의 이미지에 빗대어 부르는 말이에요. 강아지는 처진 눈과 둥근 얼굴, 꼬리를 흔들며 반기는 이미지 때문에 "순하고 다정한", 고양이는 올라간 눈과 날렵한 얼굴, 독립적인 습성 때문에 "도도하고 세련된" 이미지와 연결돼요. 결국 동물상은 얼굴 생김새와 그 동물에 대해 우리가 가진 이미지를 함께 합친 표현이에요.</p>
<p>이 말은 연예 기사와 방송, 소셜미디어에서 아이돌과 배우의 외모를 소개할 때 자주 쓰이면서 널리 퍼졌어요. 지금은 강아지상·고양이상 외에도 여우상, 곰상, 토끼상, 사슴상, 공룡상처럼 범위가 계속 늘어나고 있어요. 다만 이런 분류는 정해진 기준을 가진 체계가 아니라, 사람들이 공통으로 느끼는 인상을 가볍게 이름 붙인 놀이에 가까워요.</p>
<h2 id="s2">강아지상 특징: 둥글고 순한 인상</h2>
<p>강아지상으로 불리는 얼굴에서 자주 보이는 단서는 다음과 같아요.</p>
<ul><li><strong>눈꼬리</strong>: 수평이거나 바깥쪽이 살짝 내려가요. 이 단서가 강아지상 인상에서 가장 크게 작용해요.</li><li><strong>눈 모양</strong>: 세로 폭이 넉넉하고 둥근 편이에요. 웃을 때 반달처럼 휘는 눈도 강아지상으로 자주 묘사돼요.</li><li><strong>얼굴선</strong>: 볼과 턱이 둥글고 부드러운 곡선이에요.</li><li><strong>눈썹</strong>: 각이 크지 않고 완만한 일자나 아치형이에요.</li><li><strong>코와 입</strong>: 코끝이 둥글고 입꼬리가 부드럽게 올라간 경우가 많아요.</li></ul>
<p>이런 얼굴은 흔히 "착해 보인다", "말 걸기 편해 보인다", "어려 보인다"는 인상으로 이어져요. 처음 만난 사람이 경계를 덜 느끼는 인상이라는 장점이 있고, 반대로 "만만해 보인다"거나 "진지해 보이지 않는다"는 고민을 하는 경우도 있어요.</p>
<h2 id="s3">고양이상 특징: 날렵하고 또렷한 인상</h2>
<p>고양이상으로 불리는 얼굴에서는 아래 단서가 자주 언급돼요.</p>
<ul><li><strong>눈꼬리</strong>: 바깥쪽이 위로 올라가요. 아이라인을 위로 빼는 화장을 '캣 아이'라고 부르는 것도 같은 이미지예요.</li><li><strong>눈 모양</strong>: 가로로 길고 앞트임이 또렷한 아몬드형이 많아요.</li><li><strong>얼굴선</strong>: 광대 아래가 갸름하고 턱선이 날렵한 편이에요.</li><li><strong>눈썹</strong>: 또렷하고 눈썹산에 각이 있는 경우가 많아요.</li><li><strong>표정</strong>: 무표정일 때 입꼬리가 일자에 가까워 차분하고 도도한 인상을 줘요.</li></ul>
<p>고양이상은 "세련돼 보인다", "카리스마 있다", "분위기 있다"는 인상으로 이어지는 경우가 많아요. 반대로 처음 보는 사람에게 "차가워 보인다", "화난 것 같다"는 오해를 받기도 해요. 실제로는 그냥 무표정이었을 뿐인데요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/dog-cat-face-animal-types-1.svg" alt="강아지상과 고양이상 비교 카드. 강아지상은 수평이거나 살짝 처진 눈꼬리, 둥근 눈매와 얼굴선, 완만한 눈썹, 웃을 때 휘는 눈. 고양이상은 올라간 눈꼬리, 가로로 긴 눈매와 갸름한 턱선, 각진 눈썹, 무표정일 때 도도한 인상" width="800" height="500" loading="lazy" decoding="async"><figcaption>두 동물상을 가르는 가장 큰 단서는 눈꼬리 방향이에요</figcaption></figure>
<h2 id="s4">강아지상 고양이상 자가 구별법</h2>
<p>동물상 테스트는 보통 몇 가지 얼굴 단서를 묻고, 강아지 쪽과 고양이 쪽 중 어느 단서가 더 많은지 세는 방식이에요. 집에서 해 본다면 이렇게 하면 돼요.</p>
<ol><li><strong>정면 사진 준비</strong>: 화장을 하지 않은 상태로, 카메라를 눈높이에 두고 무표정으로 찍어요. 위나 아래에서 찍으면 눈꼬리 각도가 달라 보여요.</li><li><strong>눈꼬리 선 긋기</strong>: 눈 앞머리와 눈꼬리를 잇는 선을 상상해 봐요. 눈꼬리 쪽이 올라가면 고양이 단서, 수평이거나 내려가면 강아지 단서예요.</li><li><strong>눈 모양 비율 보기</strong>: 눈의 가로 길이에 비해 세로 폭이 넉넉하면 강아지, 가로로 길쭉하면 고양이 단서예요.</li><li><strong>얼굴선과 눈썹 보기</strong>: 볼과 턱이 둥근지 갸름한지, 눈썹이 완만한지 각이 있는지 봐요.</li><li><strong>웃는 사진과 비교</strong>: 웃을 때 눈이 휘면서 인상이 확 순해지는지도 함께 봐요. 무표정과 웃는 얼굴의 동물상이 다른 사람도 많아요.</li></ol>
<p>아래는 이 방법을 어떤 한 사람의 답에 적용해 본 예시 계산이에요. 단서마다 더 가까운 쪽에 점수를 주고 합치는 방식이에요.</p>
<figure class="viz viz-stacked"><figcaption class="viz-title">동물상 단서별 점수 예시 (가상의 응답 1명)</figcaption><div class="viz-legend"><span><i class="s1"></i>강아지 쪽</span><span><i class="s2"></i>고양이 쪽</span></div><svg viewBox="0 0 640 300" role="img" aria-label="동물상 단서별 점수 예시 (가상의 응답 1명)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">0.5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">1.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">2</text><rect class="s1" x="66.4" y="18.0" width="67.2" height="236.0" rx="3"><title>눈꼬리 · 강아지 쪽 2점</title></rect><rect class="s2" x="66.4" y="18.0" width="67.2" height="0.5" rx="3"><title>눈꼬리 · 고양이 쪽 0점</title></rect><text class="viz-ax" x="100.0" y="272" text-anchor="middle">눈꼬리</text><rect class="s1" x="162.4" y="136.0" width="67.2" height="118.0" rx="3"><title>눈 모양 · 강아지 쪽 1점</title></rect><rect class="s2" x="162.4" y="18.0" width="67.2" height="116.0" rx="3"><title>눈 모양 · 고양이 쪽 1점</title></rect><text class="viz-ax" x="196.0" y="272" text-anchor="middle">눈 모양</text><rect class="s1" x="258.4" y="18.0" width="67.2" height="236.0" rx="3"><title>얼굴선 · 강아지 쪽 2점</title></rect><rect class="s2" x="258.4" y="18.0" width="67.2" height="0.5" rx="3"><title>얼굴선 · 고양이 쪽 0점</title></rect><text class="viz-ax" x="292.0" y="272" text-anchor="middle">얼굴선</text><rect class="s1" x="354.4" y="254.0" width="67.2" height="0.5" rx="3"><title>눈썹 · 강아지 쪽 0점</title></rect><rect class="s2" x="354.4" y="18.0" width="67.2" height="234.0" rx="3"><title>눈썹 · 고양이 쪽 2점</title></rect><text class="viz-ax" x="388.0" y="272" text-anchor="middle">눈썹</text><rect class="s1" x="450.4" y="254.0" width="67.2" height="0.5" rx="3"><title>무표정 인상 · 강아지 쪽 0점</title></rect><rect class="s2" x="450.4" y="18.0" width="67.2" height="234.0" rx="3"><title>무표정 인상 · 고양이 쪽 2점</title></rect><text class="viz-ax" x="484.0" y="272" text-anchor="middle">무표정 인상</text><rect class="s1" x="546.4" y="18.0" width="67.2" height="236.0" rx="3"><title>웃는 인상 · 강아지 쪽 2점</title></rect><rect class="s2" x="546.4" y="18.0" width="67.2" height="0.5" rx="3"><title>웃는 인상 · 고양이 쪽 0점</title></rect><text class="viz-ax" x="580.0" y="272" text-anchor="middle">웃는 인상</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">합계 강아지 7점, 고양이 5점으로 강아지 쪽이 조금 우세한 &#x27;반반상&#x27;에 가까운 예시예요. 실제로도 한쪽 단서만 가진 얼굴은 드물어요.</p><div class="viz-cap">단위: 점 · 자료: 단서마다 2점을 나눠 주는 방식의 예시 계산 (실제 통계 아님)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>강아지 쪽</th><th>고양이 쪽</th></tr></thead><tbody><tr><td>눈꼬리</td><td>2</td><td>0</td></tr><tr><td>눈 모양</td><td>1</td><td>1</td></tr><tr><td>얼굴선</td><td>2</td><td>0</td></tr><tr><td>눈썹</td><td>0</td><td>2</td></tr><tr><td>무표정 인상</td><td>0</td><td>2</td></tr><tr><td>웃는 인상</td><td>2</td><td>0</td></tr></tbody></table></div></details></figure>
<p>결과가 한쪽으로 확 쏠리지 않는다고 이상한 게 아니에요. 오히려 위 예시처럼 눈매는 강아지, 눈썹과 무표정은 고양이처럼 섞인 경우가 흔하고, 이런 얼굴을 흔히 "강아지와 고양이 사이"라고 표현해요.</p>
<h2 id="s5">여우상 곰상 토끼상, 다른 동물상과 비교</h2>
<p>강아지상과 고양이상이 가장 대표적이지만, 다른 동물상도 많이 쓰여요. 각 동물상이 어떤 단서를 가리키는지 대략 정리하면 아래와 같아요. 사람마다 쓰는 기준이 조금씩 다르다는 점은 감안해 주세요.</p>
<div class="table-wrap"><table><thead><tr><th>동물상</th><th>자주 언급되는 얼굴 단서</th><th>흔히 연결되는 인상</th></tr></thead><tbody><tr><td>강아지상</td><td>처지거나 수평인 눈꼬리, 둥근 눈과 얼굴</td><td>순함, 친근함</td></tr><tr><td>고양이상</td><td>올라간 눈꼬리, 긴 눈매, 갸름한 턱</td><td>도도함, 세련됨</td></tr><tr><td>여우상</td><td>가늘고 길게 올라간 눈, 뾰족한 턱</td><td>영리함, 매혹적</td></tr><tr><td>곰상</td><td>작은 눈, 넓고 둥근 얼굴, 큰 체격과 함께 쓰임</td><td>듬직함, 푸근함</td></tr><tr><td>토끼상</td><td>동그란 눈, 살짝 도드라진 앞니, 작은 얼굴</td><td>귀여움, 발랄함</td></tr><tr><td>사슴상</td><td>크고 촉촉해 보이는 눈, 긴 목, 가는 얼굴선</td><td>청순함, 맑음</td></tr></tbody></table></div>
<p>이 중 여우상은 고양이상과, 토끼상과 사슴상은 강아지상과 단서가 많이 겹쳐요. 그래서 같은 사람을 두고도 누구는 고양이상, 누구는 여우상이라고 부르는 일이 흔해요. 동물상을 정확히 맞히는 것보다 "어떤 단서 때문에 그렇게 느꼈는지"를 이야기하는 게 더 재미있는 이유예요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">왜 둥근 얼굴은 순해 보이고 올라간 눈은 차가워 보일까</h2>
<h3>베이비 스키마: 아기 같은 얼굴에 끌리는 반응</h3>
<p>동물행동학자 콘라트 로렌츠는 큰 머리, 높은 이마, 큰 눈, 작은 코와 턱 같은 아기의 특징이 어른에게 "보살피고 싶다"는 반응을 일으킨다고 설명했어요. 이를 <a href="https://en.wikipedia.org/wiki/Baby_schema" target="_blank" rel="noopener">베이비 스키마(baby schema)</a>라고 불러요. 이후 연구들에서도 어른들이 아기 같은 특징을 가진 얼굴을 더 귀엽게 느끼는 경향이 보고됐어요.</p>
<p>강아지상의 둥근 눈과 얼굴선은 이 아기 같은 특징과 겹치는 부분이 많아요. 그래서 강아지상이 "어려 보인다", "순해 보인다"는 인상과 쉽게 연결되는 거예요. 반대로 날렵한 턱선과 긴 눈매는 성인 얼굴의 특징에 가까워서 "성숙하다", "세련됐다"는 인상으로 이어지기 쉬워요.</p>
<h3>첫인상은 아주 빠르게 만들어져요</h3>
<p><a href="https://en.wikipedia.org/wiki/First_impression_%28psychology%29" target="_blank" rel="noopener">첫인상(First impression) 위키백과 문서</a>에 소개된 윌리스와 토도로프의 2006년 연구에서는, 얼굴 사진을 0.1초(100밀리초)만 보여 줘도 사람들이 매력·신뢰감·능력 같은 판단을 내렸고, 그 판단이 시간 제한 없이 본 판단과 꽤 비슷했다고 보고했어요. 우리 뇌가 눈꼬리 방향이나 얼굴선 같은 단서로 순식간에 인상을 만든다는 뜻이에요. 동물상이라는 말이 쉽게 통하는 것도 이런 빠른 인상 판단 덕분이에요.</p>
<h2 id="s7">동물상으로 성격을 알 수 있을까</h2>
<p>결론부터 말하면 <strong>알 수 없어요.</strong> 첫인상이 빠르게 만들어진다는 것과, 그 인상이 정확하다는 건 전혀 다른 이야기예요. 얼굴 생김새로 성격이나 운명을 읽으려는 시도는 오래전부터 '관상학'이라는 이름으로 있었지만, <a href="https://en.wikipedia.org/wiki/Physiognomy" target="_blank" rel="noopener">관상학(Physiognomy) 위키백과 문서</a>에서는 이를 과학적 근거가 없는 유사과학으로 분류해요.</p>
<p>강아지상이라서 다정하고 고양이상이라서 차가운 게 아니라, 우리가 그렇게 <strong>느끼는</strong> 것일 뿐이에요. 실제로 고양이상인 사람이 처음 만난 자리에서 "차가워 보였는데 알고 보니 다정하다"는 말을 듣는 일이 흔한 것도 이 때문이에요. 동물상은 외모를 재미있게 표현하는 말로 즐기고, 사람을 판단하는 기준으로 쓰지 않는 게 좋아요. 특히 외모를 이유로 성격을 단정하는 건 상대에게 상처가 될 수 있어요.</p>
<aside class="callout tip"><b class="callout-h">참고</b><p>이 사이트의 동물상 테스트는 얼굴을 분석하지 않고, 스스로 고른 답으로 재미있는 결과를 보여 주는 방식이에요. 성격 판단이나 외모 평가의 근거로 쓰지 말아 주세요.</p></aside>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">인상은 바꿀 수 있어요: 표정·화장·스타일의 영향</h2>
<p>동물상은 타고난 생김새만으로 정해지지 않아요. 같은 얼굴도 몇 가지 요소로 인상이 크게 달라져요.</p>
<ul><li><strong>표정</strong>: 웃으면 눈꼬리가 내려가고 볼이 올라가서 대부분 강아지상 쪽으로 가요. 고양이상인 사람이 "웃으면 완전 다르다"는 말을 듣는 이유예요.</li><li><strong>아이라인과 눈썹</strong>: 아이라인을 위로 빼면 고양이상, 아래로 처지게 그리면 강아지상 느낌이 나요. 눈썹을 일자로 그리면 순한 인상, 각을 살리면 또렷한 인상이 돼요.</li><li><strong>헤어스타일</strong>: 앞머리와 둥근 단발은 얼굴을 둥글어 보이게 하고, 넘긴 머리와 긴 생머리는 얼굴선을 길고 날렵해 보이게 해요.</li><li><strong>촬영 각도와 조명</strong>: 위에서 찍으면 눈이 커 보이고 턱이 작아 보여 강아지상 쪽으로, 아래에서 찍으면 반대로 보이기 쉬워요.</li></ul>
<p>그래서 "고양이상이라 차가워 보여서 고민"이라면 웃는 표정을 자주 보여 주거나 눈썹과 아이라인을 부드럽게 바꾸는 것만으로도 첫인상이 꽤 달라질 수 있어요. 반대로 강아지상인데 좀 더 또렷해 보이고 싶다면 눈썹 각과 아이라인 끝을 살짝 올려 보는 방법이 있어요.</p>
<h2 id="s9">자주 묻는 질문</h2>
<h3>강아지상과 고양이상을 가르는 가장 큰 기준은 뭔가요?</h3>
<p>가장 많이 쓰이는 기준은 눈꼬리 방향이에요. 눈꼬리가 수평이거나 살짝 내려가면 강아지상, 위로 올라가면 고양이상으로 보는 경우가 많아요. 그다음으로 눈 모양(둥근지 긴지)과 얼굴선(둥근지 갸름한지)을 함께 봐요.</p>
<h3>강아지상이면서 고양이상일 수도 있나요?</h3>
<p>네, 아주 흔해요. 눈매는 강아지상인데 턱선이 갸름하거나, 무표정은 고양이상인데 웃으면 강아지상이 되는 경우가 많아요. 이런 얼굴을 흔히 '강아지와 고양이 사이' 또는 '반반상'이라고 불러요.</p>
<h3>동물상 테스트 결과가 성격과 관련이 있나요?</h3>
<p>아니에요. 동물상은 외모가 주는 인상을 표현하는 말이고, 얼굴 생김새로 성격을 알 수 있다는 과학적 근거는 없어요. 성격이 궁금하다면 행동과 선호를 묻는 성격 테스트를 따로 해 보는 게 맞아요.</p>
<h3>나이가 들면 동물상이 바뀌나요?</h3>
<p>바뀔 수 있어요. 나이가 들면서 눈썹과 눈꺼풀이 내려가고 얼굴 지방 분포가 달라져서, 젊을 때 고양이상이던 사람이 순한 인상으로 바뀌는 경우도 있어요. 화장법과 헤어스타일 변화도 인상에 영향을 줘요.</p>
<h3>어떤 동물과 잘 맞는지도 알 수 있나요?</h3>
<p>동물상과 실제로 잘 맞는 반려동물은 별개의 문제예요. 생활 패턴, 집에 있는 시간, 성향에 따라 잘 맞는 반려동물이 달라요. 이건 <a href="https://tool.smithlab.co.kr/survey/pet-match/">반려동물 궁합 테스트</a>로 재미있게 확인해 볼 수 있어요.</p>
<h2 id="s10">정리</h2>
<p>강아지상은 수평이거나 처진 눈꼬리와 둥근 얼굴선, 고양이상은 올라간 눈꼬리와 갸름한 턱선이 대표 단서예요. 둥근 얼굴이 순해 보이는 건 아기 같은 특징에 끌리는 베이비 스키마 반응과 관련이 있고, 사람은 0.1초 만에도 얼굴로 인상을 만들어요. 하지만 그 인상이 성격을 말해 주지는 않아요. 동물상은 외모를 재미있게 표현하는 말로 즐기고, 표정과 스타일로 얼마든지 달라질 수 있다는 점을 기억해 두면 좋아요.</p>
<p><a href="https://tool.smithlab.co.kr/survey/dog-cat-face/">강아지상 고양이상 테스트</a>로 내 동물상을 가볍게 확인해 보세요. 테스트 결과가 유난히 잘 맞는 것처럼 느껴진다면 <a href="https://tool.smithlab.co.kr/posts/survey/why-personality-tests-feel-accurate/">성격 테스트가 잘 맞는 것처럼 느껴지는 이유</a>도 함께 읽어 볼 만해요.</p>
    ]]></description>
    </item>
    <item>
      <title>첫인상은 0.1초 만에 정해질까? — 100밀리초 실험이 말해 주는…</title>
      <link>https://tool.smithlab.co.kr/posts/survey/first-impression-100ms-research-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/first-impression-100ms-research-guide/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>"첫인상은 0.1초 만에 결정된다"는 문장은 자기소개서 특강부터 소개팅 팁까지 어디서나 보여요. 결론부터 말하면 <strong>이 문장은 반은 맞고 반은 과장이에요.</strong> 0.1초(100밀리초) 동안 낯선 얼굴을 본 사람들의 판단이, 시간 제한 없이 오래 본 사람들의 판단과 꽤 비슷하게 나온다는 실험이 있는 건 사실이에요. 하지만 그 실험이 "그 판단이 맞다"거나 "그 인상이 평생 간다"고 말해 주는 건 아니에요.</p>
<p>이 글은 이 문장의 출처가 된 연구가 무엇을 어떻게 했는지, 거기서 무엇을 말할 수 있고 말할 수 없는지, 그리고 그래서 면접이나 소개팅, 새 모임에서 첫인상을 어떻게 다루는 게 현실적인지를 정리해요. 내 평소 인상이 어떻게 읽히는지 가볍게 보고 싶다면 <a href="https://tool.smithlab.co.kr/survey/first-impression/">첫인상 유형 테스트</a>를 먼저 해 봐도 좋아요. 결과는 재미로 보는 용도예요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>0.1초 이야기의 출처는 Willis와 Todorov가 2006년 Psychological Science에 낸 실험이에요. 얼굴을 100밀리초 보여 준 뒤 매력·호감·신뢰·능력·공격성을 평가하게 했어요.</li><li>짧게 본 평가와 시간 제한 없이 본 평가는 서로 상관이 높았어요. 특히 신뢰도 판단이 그랬어요.</li><li>하지만 이는 '빨리 형성되는 인상'을 보여 줄 뿐 '그 인상이 사실과 맞다'는 증거는 아니에요.</li><li>첫인상은 말투·표정·행동이 쌓이면서 바뀌기도 해서, 첫 몇 분을 준비하는 것과 그 뒤를 이어 가는 것을 나눠 생각하는 게 좋아요.</li></ul></aside>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/first-impression-100ms-research-guide-1.svg" alt="얼굴을 0.1초, 0.5초, 1초 보여 주고 평가한 값을 시간 제한 없이 본 평가와 비교하는 실험 구조" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 얼굴을 시간만 다르게 보여 주고, 짧게 본 판단이 충분히 본 판단과 얼마나 비슷한지 비교한 실험이에요</figcaption></figure>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">0.1초 실험은 정확히 무엇을 했나</h2>
<p>이 이야기의 가장 많이 인용되는 출처는 Janine Willis와 Alexander Todorov가 2006년 학술지 Psychological Science에 발표한 논문 "First impressions: Making up your mind after a 100-ms exposure to a face"예요. 실험의 뼈대는 단순해요.</p>
<ul><li>참가자에게 낯선 사람의 얼굴 사진을 <strong>100밀리초(0.1초), 500밀리초, 1,000밀리초</strong> 중 한 가지 시간만 보여 줘요.</li><li>사진이 사라진 뒤 그 얼굴이 얼마나 <strong>매력적인지, 호감이 가는지, 믿을 만한지, 유능해 보이는지, 공격적으로 보이는지</strong>를 점수로 답하게 해요.</li><li>다른 참가자들은 같은 얼굴을 시간 제한 없이 보고 같은 항목을 평가했어요. 이 점수가 '기준'이 돼요.</li><li>그리고 짧게 본 평가가 기준 평가와 얼마나 같은 방향으로 움직이는지(상관)를 계산했어요.</li></ul>
<p>결과의 핵심은 <strong>0.1초만 본 평가도 기준 평가와 상관이 높았다</strong>는 점이에요. 시간을 더 주면 평가가 크게 달라지기보다, 자기 판단에 대한 확신이 커지는 쪽이었어요. 이 논문 덕분에 "첫인상은 눈 깜짝할 사이에 정해진다"는 말이 퍼졌어요.</p>
<h3>여기서 확인된 것</h3>
<ul><li>사람은 아주 짧은 시간에도 얼굴에서 신뢰·호감 같은 인상을 <strong>만들어 낸다</strong>는 것.</li><li>그 인상이 오래 본 뒤의 인상과 <strong>방향이 비슷</strong>하다는 것. 즉 처음 인상이 뒤에 크게 뒤집히지 않는 경향이 실험 조건에서는 있었다는 것.</li></ul>
<h3>여기서 확인되지 않은 것</h3>
<ul><li><strong>그 인상이 맞는지</strong>: 신뢰해 보이는 얼굴이 실제로 더 믿을 만하다는 증거가 아니에요. 실험은 '평가가 서로 얼마나 비슷한가'만 봤어요.</li><li><strong>얼굴 말고 다른 정보</strong>: 사진 한 장이라서 말투, 목소리, 옷차림, 소개 방식, 대화 내용은 들어 있지 않아요. 실제 만남은 이런 정보가 몇 분 안에 계속 더해져요.</li><li><strong>평생 간다는 말</strong>: 인상이 얼마나 오래 유지되는지는 이 실험의 질문이 아니에요.</li></ul>
<h2 id="s2">첫인상이 빨리 만들어지는 이유</h2>
<p>짧은 시간에 판단을 내리는 건 성격이 급해서가 아니라 뇌가 정보를 절약하는 방식이라고 설명되는 경우가 많아요. 낯선 사람을 만났을 때 "다가가도 되나, 조심해야 하나"를 빨리 가려내는 게 도움이 됐을 거라는 해석이에요. 이런 설명은 가설이라서, 확정된 사실처럼 받아들이기보다 <strong>'사람은 정보가 적어도 판단을 만든다'</strong> 정도로 이해하는 게 안전해요.</p>
<p>중요한 건 이렇게 만든 판단이 <strong>근거가 얇다</strong>는 점이에요. 판단이 빠르다고 해서 정확한 건 아니고, 오히려 얼굴 생김새의 일반적인 느낌(둥근 얼굴은 친근해 보인다, 눈썹이 낮으면 진지해 보인다 같은)이 판단에 크게 섞여요. 이런 느낌은 개인의 성격과는 상관이 약해서, 첫인상이 틀리는 경우가 생겨요.</p>
<h2 id="s3">첫인상이 틀리는 흔한 경우</h2>
<p>실제 생활에서 첫인상이 어긋나는 장면은 꽤 예측 가능해요.</p>
<div class="table-wrap"><table><thead><tr><th>첫인상</th><th>실제로 이어지는 오해</th><th>시간이 지나면 보이는 것</th></tr></thead><tbody><tr><td>조용하고 차가워 보임</td><td>'나를 싫어하나 보다'</td><td>낯을 가리는 것일 뿐, 편해지면 말이 많아지는 경우</td></tr><tr><td>말이 빠르고 활발함</td><td>'가볍고 깊이가 없다'</td><td>긴장을 풀려고 말이 많아졌고, 약속은 잘 지키는 경우</td></tr><tr><td>표정 변화가 적음</td><td>'관심이 없다'</td><td>표정보다 행동(메모, 질문)으로 반응하는 경우</td></tr><tr><td>옷차림이 단정함</td><td>'완벽주의자·깐깐하다'</td><td>단지 격식이 편한 사람</td></tr><tr><td>목소리가 작음</td><td>'자신감이 없다'</td><td>실력과 무관하게 말하는 방식이 조용한 경우</td></tr></tbody></table></div>
<p>표에서 보듯 첫인상은 <strong>'그 사람의 상태(긴장, 낯섦)'와 '그 사람의 성향'을 구별하지 못하고 섞어서</strong> 읽을 때가 많아요. 그래서 처음의 어색함은 성격의 증거라기보다 상황의 신호일 때가 많아요.</p>
<h2 id="s4">첫인상을 다룰 때 현실적으로 할 수 있는 것</h2>
<p>첫인상이 빠르게 만들어진다고 해서 얼굴을 바꿀 수는 없어요. 대신 <strong>바꿀 수 있는 신호</strong>와 <strong>시간이 지나며 채워지는 신호</strong>를 나눠 보면 준비할 게 분명해져요.</p>
<h3>첫 몇 분에 바꿀 수 있는 것</h3>
<ol><li><strong>인사와 표정</strong>: 눈을 맞추고 가볍게 웃으며 이름을 말하는 것만으로 '접근해도 되는 사람'이라는 신호가 전해져요. 억지 미소보다 편한 표정이 오래가요.</li><li><strong>말의 속도와 크기</strong>: 긴장하면 말이 빨라지거나 작아져요. 한 박자 쉬고 천천히 말하면 신뢰감이 달라 보여요. 나의 말투 경향은 <a href="https://tool.smithlab.co.kr/survey/speech-style/">말투 유형 테스트</a>로 가볍게 살펴볼 수 있어요.</li><li><strong>첫 질문</strong>: 자기 이야기만 길게 하기보다 상대에게 구체적인 질문 하나를 던지면 '나에게 관심이 있다'는 인상이 남아요.</li><li><strong>정리된 첫 문장</strong>: 면접이나 자기소개에서 "저는 ○○를 하는 ○○입니다"처럼 한 문장을 미리 정해 두면 긴장해도 첫 인상이 흐트러지지 않아요.</li></ol>
<h3>시간이 지나며 채워지는 것</h3>
<ul><li><strong>일관성</strong>: 약속 시간, 답장 속도, 말과 행동이 맞는지는 몇 번 만나야 드러나요. 이 신호가 처음 인상과 다르면 인상이 수정돼요.</li><li><strong>구체적인 행동</strong>: 도움을 요청받았을 때의 반응, 실수를 인정하는 방식 같은 건 첫 만남에선 보이지 않아요.</li><li><strong>공통 경험</strong>: 같이 한 일이 쌓일수록 얼굴·옷차림 같은 표면 신호보다 경험이 인상을 차지해요.</li></ul>
<aside class="callout tip"><b class="callout-h">팁</b><p>첫인상이 불리했다고 느껴지면, 두 번째 만남에서 "지난번엔 좀 긴장했어요" 같은 한마디가 의외로 효과가 있어요. 상대가 처음의 어색함을 성향이 아니라 상황으로 다시 읽을 여지를 주거든요.</p></aside>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">상황별 첫인상 준비 포인트</h2>
<h3>면접</h3>
<p>면접은 몇 분 안에 많은 정보를 보여 줘야 하는 자리라서 첫인상의 비중이 커 보이지만, 실제 평가는 대부분 <strong>질문에 대한 답의 내용</strong>에서 갈려요. 첫인상은 문을 열어 주는 역할이고, 답의 구체성(사례, 숫자, 역할)이 이어받아요. 입장·인사·자리에 앉는 동작, 첫 문장만 연습해 두고 나머지 시간은 답변 내용에 쓰는 편이 효율적이에요.</p>
<h3>소개팅과 첫 만남</h3>
<p>소개팅에서는 외모 신호가 크게 작용하지만, 만남이 길어질수록 <strong>대화의 리듬</strong>(주고받는지, 끊기는지)이 인상을 좌우해요. 첫 10분에 자기소개를 길게 하기보다 공통 관심사를 찾는 질문을 몇 개 준비해 두면 어색한 정적이 줄어요. 연애 상대와의 대화 스타일이 궁금하다면 <a href="https://tool.smithlab.co.kr/survey/couple-talk/">연애 연락·대화 관련 테스트</a>도 참고할 만해요.</p>
<h3>새 직장·새 모임</h3>
<p>새로 합류했을 때는 첫날 인상보다 <strong>첫 2~3주의 행동 패턴</strong>이 평판이 돼요. 인사, 질문하는 태도, 약속한 일을 기한 안에 끝내는지가 쌓여서 '저 사람은 어떤 사람'이라는 인상이 굳어요. 처음에 조용했더라도 이 부분이 안정적이면 인상은 충분히 좋은 쪽으로 정리돼요.</p>
<h3>성향이 조용한 사람의 경우</h3>
<p>말수가 적은 사람은 첫인상에서 '무뚝뚝하다'는 오해를 받기 쉬워요. 말을 늘리기보다 <strong>표정·끄덕임·짧은 반응어</strong>를 늘리면 오해가 크게 줄어요. 내향·외향 경향은 <a href="https://tool.smithlab.co.kr/survey/introvert-extrovert/">내향형·외향형 테스트</a>로 살펴볼 수 있고, 어느 쪽이든 좋고 나쁨이 아니라 소통 방식의 차이예요.</p>
<h2 id="s6">첫인상을 읽는 쪽에서 조심할 것</h2>
<p>내가 남을 판단하는 입장일 때도 같은 한계가 적용돼요. 첫인상이 빨리 생긴다는 건 <strong>내가 만든 판단이 근거가 얇을 수 있다</strong>는 뜻이기도 하니까요.</p>
<ul><li>첫 만남에서 느낀 호감·비호감을 '결론'이 아니라 <strong>'가설'</strong>로 두고, 두 번째·세 번째 만남에서 확인해 보세요.</li><li>긴장한 사람의 행동(말이 많아짐, 말이 없어짐, 표정 굳음)을 성격으로 단정하지 않아요.</li><li>외모·옷차림·목소리 같은 표면 신호가 능력이나 성품과 직접 이어진다고 보지 않아요. 채용·평가처럼 결과가 큰 판단일수록 질문 목록과 기준표를 미리 정해 같은 질문을 모두에게 하는 편이 편향을 줄여 줘요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">자주 묻는 질문</h2>
<h3>첫인상이 정말 0.1초 만에 결정되나요?</h3>
<p>실험에서는 0.1초만 얼굴을 본 사람들의 평가가 오래 본 사람들의 평가와 비슷하게 나왔어요. 하지만 그건 '빠르게 인상이 만들어진다'는 뜻이지 '그 인상이 고정되고 정확하다'는 뜻은 아니에요. 실제 만남에서는 말과 행동이 더해지며 인상이 바뀌기도 해요.</p>
<h3>첫인상이 나쁘면 만회할 수 없나요?</h3>
<p>만회할 수 있어요. 인상은 새 정보가 쌓이면 수정돼요. 다만 처음 인상과 반대되는 행동을 <strong>여러 번, 일관되게</strong> 보여 줘야 눈에 띄고, 한두 번으로는 '예외'로 처리되기도 해요. 그래서 처음의 어색함을 가볍게 인정하고 이후 행동으로 보여 주는 것이 효율적이에요.</p>
<h3>첫인상 테스트 결과를 믿어도 되나요?</h3>
<p>성향 테스트는 재미와 대화의 계기로 쓰는 도구예요. 어떤 사람인지 판정하거나 채용·평가의 근거로 쓰는 용도가 아니에요. 내 습관을 돌아보는 참고로만 가볍게 활용해 주세요.</p>
<h3>사진 한 장으로 사람을 판단하는 건 안전한가요?</h3>
<p>연구가 보여 준 것은 '사람이 그렇게 판단한다'는 것이지 '그 판단이 옳다'는 것이 아니에요. 사진만 보고 성격·신뢰도를 단정하기보다, 대화나 행동 같은 추가 정보를 기다리는 편이 정확해요.</p>
<h3>면접에서 첫인상을 좋게 하려면 뭘 먼저 준비하나요?</h3>
<p>입장할 때의 인사, 앉는 자세, 첫 문장 한 줄을 정해 두는 것부터 시작해요. 그다음 시간은 질문별로 구체적인 사례 한두 개를 준비하는 데 쓰는 게 합격 가능성에 더 직접적이에요.</p>
<h2 id="s8">정리</h2>
<p>첫인상이 빨리 만들어진다는 건 사실에 가까워요. 하지만 그것이 정확하다거나 변하지 않는다는 근거는 아니에요. 그래서 <strong>첫 몇 분의 인사·표정·첫 문장은 준비하고, 이후엔 일관된 행동으로 인상을 채워 가는 것</strong>이 현실적인 접근이에요. 평소 내 인상이 어떻게 읽히는지 궁금하다면 <a href="https://tool.smithlab.co.kr/survey/first-impression/">첫인상 유형 테스트</a>와 <a href="https://tool.smithlab.co.kr/survey/speech-style/">말투 유형 테스트</a>를 가볍게 해 보세요. 결과는 재미로 보는 참고용이에요.</p>
    ]]></description>
    </item>
    <item>
      <title>홀랜드 직업 적성 6유형(RIASEC) 뜻과 유형별 어울리는 직업 정리</title>
      <link>https://tool.smithlab.co.kr/posts/survey/holland-riasec-career-types/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/holland-riasec-career-types/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>진로를 고민할 때 "나는 뭘 잘하지?"보다 먼저 물어볼 질문이 있어요. "나는 어떤 일을 할 때 시간 가는 줄 모르지?"예요. 이 질문을 체계적으로 정리한 게 <strong>홀랜드 직업 흥미 이론(RIASEC)</strong>이에요. <strong>홀랜드 이론은 사람의 직업 흥미를 현실형(R), 탐구형(I), 예술형(A), 사회형(S), 진취형(E), 관습형(C) 여섯 가지로 나누고, 흥미와 맞는 환경에서 일할수록 만족하고 오래 일할 가능성이 높다고 봐요.</strong></p>
<p>이 이론은 미국 심리학자 존 홀랜드가 1950년대 후반부터 발전시켰고, 지금도 진로 상담과 직업 정보 시스템에서 가장 널리 쓰이는 틀 중 하나예요. 이 글에서는 여섯 유형의 특징과 어울리는 일, 육각형 모델을 읽는 법, 결과로 나오는 세 글자 '홀랜드 코드'를 해석하는 법을 정리했어요. 내 유형이 궁금하다면 <a href="https://tool.smithlab.co.kr/survey/career-type/">직업 적성 6유형 테스트</a>를 먼저 해 보세요. 이 사이트의 테스트는 재미와 자기 이해를 위한 참고용이고, 공식 검사를 대신하지 않아요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>RIASEC은 흥미를 현실형·탐구형·예술형·사회형·진취형·관습형 6가지로 나눠요. '능력'이 아니라 '좋아하는 활동'을 재는 이론이에요.</li><li>여섯 유형은 육각형 위에 R-I-A-S-E-C 순서로 놓여요. 이웃한 유형끼리는 닮았고, 마주 보는 R-S, I-E, A-C는 가장 달라요.</li><li>결과는 보통 점수가 높은 세 유형을 이어 'SAE', 'RIC' 같은 세 글자 코드로 표현해요. 첫 글자가 가장 강한 흥미예요.</li><li>미국 노동부 직업 정보망 O*NET이 이 틀로 직업을 분류하고 있어서, 코드로 어울리는 직업을 찾아볼 수 있어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">홀랜드 RIASEC 이론이란?</h2>
<p>존 홀랜드(1919~2008)는 "사람들의 직업 선택에는 성격과 흥미에 따른 일정한 패턴이 있다"는 생각에서 이론을 출발시켰어요. <a href="https://en.wikipedia.org/wiki/Holland_Codes" target="_blank" rel="noopener">위키백과 Holland Codes 문서</a>에 따르면 이 이론의 출발점은 1958년과 1959년에 발표한 논문이고, 이후 대표 저서 『Making Vocational Choices』(3판 1997년)로 정리됐어요. 홀랜드는 이 업적으로 2008년 미국심리학회의 응용 공로상을 받았어요.</p>
<p>이론의 핵심 가정은 네 가지로 요약할 수 있어요.</p>
<ol><li>대부분의 사람은 여섯 가지 성격·흥미 유형의 조합으로 설명할 수 있다.</li><li>직업 환경도 같은 여섯 가지 유형으로 나눌 수 있다. 환경의 성격은 그곳에서 일하는 사람들이 만든다.</li><li>사람은 자기 능력을 쓰고 가치관을 표현할 수 있는 환경을 찾아간다.</li><li>사람과 환경의 유형이 잘 맞을수록(일치도가 높을수록) 만족도와 안정성이 높아지는 경향이 있다.</li></ol>
<p>여기서 중요한 건 RIASEC이 <strong>흥미</strong>를 다룬다는 점이에요. "수학을 잘한다"가 아니라 "문제를 분석하는 활동이 즐겁다"를 보는 거예요. 흥미와 능력은 관련이 있지만 같지 않아서, 흥미가 높은 분야라도 필요한 능력은 따로 키워야 해요.</p>
<h2 id="s2">직업 적성 6유형별 특징과 어울리는 일</h2>
<p>미국 노동부 산하 직업 정보망 O*NET은 <a href="https://www.onetonline.org/find/descriptor/browse/1.B.1" target="_blank" rel="noopener">O*NET OnLine 흥미 유형 설명</a>에서 여섯 유형의 일을 아래처럼 정의해요. 표는 그 설명을 바탕으로 정리한 거예요.</p>
<div class="table-wrap"><table><thead><tr><th>유형</th><th>좋아하는 활동</th><th>일하는 환경</th><th>어울리는 일 예시</th></tr></thead><tbody><tr><td>현실형 (R)</td><td>만들고 고치기, 몸 쓰기, 야외 활동</td><td>도구·기계·자연</td><td>정비사, 엔지니어, 목수, 농업, 운동 관련 직업</td></tr><tr><td>탐구형 (I)</td><td>관찰하고 분석하고 연구하기</td><td>데이터·이론·실험</td><td>연구원, 데이터 분석가, 의료·과학 분야</td></tr><tr><td>예술형 (A)</td><td>새로운 것을 만들고 표현하기</td><td>자유롭고 규칙이 적은 곳</td><td>디자이너, 작가, 음악가, 영상 제작, 요리</td></tr><tr><td>사회형 (S)</td><td>돕고 가르치고 상담하기</td><td>사람과 함께하는 곳</td><td>교사, 간호·돌봄, 상담, 사회복지</td></tr><tr><td>진취형 (E)</td><td>설득하고 이끌고 결정하기</td><td>목표와 경쟁이 있는 곳</td><td>영업, 마케팅, 경영, 창업, 법·정치</td></tr><tr><td>관습형 (C)</td><td>정해진 절차로 정리하고 관리하기</td><td>체계와 규칙이 분명한 곳</td><td>회계, 사무 관리, 데이터 관리, 인사</td></tr></tbody></table></div>
<h3>현실형(R): 손으로 결과를 만드는 사람</h3>
<p>말보다 행동, 이론보다 실물을 좋아해요. 기계를 분해해 보거나 가구를 조립하는 게 즐겁고, 결과가 눈에 보이는 일에서 보람을 느껴요. 반대로 사람을 설득하거나 감정을 다루는 일이 길게 이어지면 피곤해하는 경우가 많아요.</p>
<h3>탐구형(I): "왜?"를 끝까지 파고드는 사람</h3>
<p>호기심이 강하고 원리를 이해해야 직성이 풀려요. 혼자 깊이 생각하는 시간을 좋아하고, 정답이 바로 보이지 않는 문제에 도전하는 걸 즐겨요. 반복적인 업무나 근거 없이 밀어붙이는 결정은 답답하게 느끼기 쉬워요.</p>
<h3>예술형(A): 정해진 틀보다 나만의 방식</h3>
<p>상상력과 감수성이 풍부하고, 자기만의 방식으로 표현하는 걸 중요하게 여겨요. 그림·글·음악만이 아니라 기획, 공간 연출, 요리처럼 창의성을 쓰는 모든 일이 여기에 포함돼요. 규칙과 절차가 너무 촘촘한 환경에서는 쉽게 지쳐요.</p>
<h3>사회형(S): 사람을 돕는 데서 에너지를 얻는 사람</h3>
<p>다른 사람의 이야기를 듣고 가르치고 돕는 일에서 보람을 느껴요. 협력하는 분위기를 좋아하고 갈등을 중재하는 역할을 자주 맡아요. 사람과의 접촉이 거의 없는 일을 오래 하면 의미를 찾기 어려워할 수 있어요.</p>
<h3>진취형(E): 목표를 정하고 사람을 움직이는 사람</h3>
<p>설득하고 협상하고 앞장서는 걸 좋아해요. 목표와 성과가 분명할 때 힘이 나고, 위험을 감수하더라도 기회를 잡으려는 편이에요. 결정권 없이 지시만 따르는 역할에서는 답답함을 느끼기 쉬워요.</p>
<h3>관습형(C): 체계를 만들고 지키는 사람</h3>
<p>정확하고 꼼꼼하며, 정보를 정리하고 절차를 지키는 데 강해요. 기준이 명확하고 예측 가능한 환경에서 안정감을 느껴요. 매번 규칙이 바뀌거나 즉흥적으로 움직여야 하는 일은 스트레스가 될 수 있어요.</p>
<h2 id="s3">홀랜드 육각형 모델 읽는 법</h2>
<p>홀랜드는 여섯 유형을 육각형 꼭짓점에 R-I-A-S-E-C 순서로 배치했어요. 이 순서가 이론의 이름이 됐고, 배치에는 의미가 있어요. <strong>바로 이웃한 유형은 서로 비슷하고, 한 칸 건너뛴 유형은 중간 정도, 정반대에 있는 유형은 가장 다르다</strong>는 거예요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/holland-riasec-career-types-1.svg" alt="홀랜드 RIASEC 육각형. 위에서부터 시계 방향으로 현실형 R, 탐구형 I, 예술형 A, 사회형 S, 진취형 E, 관습형 C가 놓이고, 마주 보는 R-S, I-E, A-C가 점선으로 연결됨" width="800" height="560" loading="lazy" decoding="async"><figcaption>이웃한 유형은 닮고, 마주 보는 유형은 가장 달라요</figcaption></figure>
<p>예를 들어 현실형(R)은 이웃한 탐구형(I)·관습형(C)과 잘 어울려요. "기계를 다루면서 분석도 하는" 엔지니어, "장비를 다루면서 절차를 지키는" 품질 관리 같은 일이 그렇죠. 반면 현실형과 정반대인 사회형(S)은 "물건과 도구"보다 "사람과 관계"에 관심을 두는 유형이라, 두 흥미를 동시에 강하게 가진 사람은 상대적으로 드물어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">현실형(R)에서 다른 유형까지의 육각형 거리</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="현실형(R)에서 다른 유형까지의 육각형 거리" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="65" y="29" text-anchor="end">탐구형 I</text><rect class="s1" x="75" y="15" width="165.0" height="20" rx="3"><title>탐구형 I · 1칸</title></rect><text class="viz-val" x="246.0" y="29">1칸</text><text class="viz-ax lg" x="65" y="63" text-anchor="end">관습형 C</text><rect class="s1" x="75" y="49" width="165.0" height="20" rx="3"><title>관습형 C · 1칸</title></rect><text class="viz-val" x="246.0" y="63">1칸</text><text class="viz-ax lg" x="65" y="97" text-anchor="end">예술형 A</text><rect class="s1" x="75" y="83" width="330.0" height="20" rx="3"><title>예술형 A · 2칸</title></rect><text class="viz-val" x="411.0" y="97">2칸</text><text class="viz-ax lg" x="65" y="131" text-anchor="end">진취형 E</text><rect class="s1" x="75" y="117" width="330.0" height="20" rx="3"><title>진취형 E · 2칸</title></rect><text class="viz-val" x="411.0" y="131">2칸</text><text class="viz-ax lg" x="65" y="165" text-anchor="end">사회형 S</text><rect class="s1" x="75" y="151" width="495.0" height="20" rx="3"><title>사회형 S · 3칸</title></rect><text class="viz-val" x="576.0" y="165">3칸</text></svg><p class="viz-note">거리가 1이면 이웃(비슷함), 3이면 정반대(가장 다름)예요. 다른 유형도 같은 방식으로 이웃 둘, 중간 둘, 정반대 하나를 가져요.</p><div class="viz-cap">단위: 칸 · 자료: 홀랜드 육각형 배치(R-I-A-S-E-C)에서 꼭짓점 사이 최단 칸 수를 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>거리</th></tr></thead><tbody><tr><td>탐구형 I</td><td>1</td></tr><tr><td>관습형 C</td><td>1</td></tr><tr><td>예술형 A</td><td>2</td></tr><tr><td>진취형 E</td><td>2</td></tr><tr><td>사회형 S</td><td>3</td></tr></tbody></table></div></details></figure>
<p>뒤에 나온 연구자들은 이 육각형 안에 두 개의 축이 숨어 있다고 해석하기도 해요. 사람을 다루는 일과 사물을 다루는 일(사람-사물 축), 아이디어를 다루는 일과 데이터를 다루는 일(아이디어-데이터 축)이에요. 사회형은 사람 쪽, 현실형은 사물 쪽, 예술형·탐구형은 아이디어 쪽, 관습형·진취형은 데이터와 실무 쪽에 가깝다고 보면 육각형이 훨씬 직관적으로 읽혀요.</p>
<h2 id="s4">세 글자 홀랜드 코드 해석하는 법</h2>
<p>검사 결과는 보통 점수가 높은 세 유형을 순서대로 이어 'SAE', 'IRC' 같은 세 글자로 표현해요. 이걸 <strong>홀랜드 코드</strong>라고 불러요. 해석할 때 볼 것은 세 가지예요.</p>
<ol><li><strong>첫 글자</strong>: 가장 강한 흥미예요. 직업을 고를 때 가장 먼저 고려할 방향이에요.</li><li><strong>일관성</strong>: 첫 두 글자가 육각형에서 이웃한 사이인지 봐요. 'RI', 'SE'처럼 이웃이면 관심사가 한 방향으로 모여 있다는 뜻이고, 'RS', 'AC'처럼 정반대면 서로 다른 두 관심을 함께 가진 거예요. 정반대 조합이 나쁜 건 아니고, 두 흥미를 함께 쓸 수 있는 일을 찾는 게 과제가 돼요.</li><li><strong>변별도</strong>: 가장 높은 점수와 가장 낮은 점수의 차이예요. 차이가 크면 흥미가 뚜렷한 편이고, 여섯 점수가 비슷하면 아직 흥미가 분화되지 않았거나 여러 분야에 두루 관심이 있는 상태로 볼 수 있어요.</li></ol>
<div class="table-wrap"><table><thead><tr><th>예시 코드</th><th>해석</th><th>어울릴 수 있는 일 방향</th></tr></thead><tbody><tr><td>SAE</td><td>사람을 돕되 창의적으로, 앞에 나서는 것도 괜찮음</td><td>교육 콘텐츠 기획, 강사, 상담 프로그램 운영</td></tr><tr><td>IRC</td><td>분석을 좋아하고 실물 다루기를 즐기며 꼼꼼함</td><td>실험·검사 업무, 데이터 엔지니어링, 품질 관리</td></tr><tr><td>ECS</td><td>목표 지향적이고 체계적이며 사람과 일하는 걸 좋아함</td><td>영업 관리, 운영 매니저, 인사</td></tr><tr><td>AIR</td><td>표현을 좋아하면서 원리 탐구와 손작업도 즐김</td><td>제품 디자인, 건축, 사진·영상 기술</td></tr></tbody></table></div>
<p>예시는 이해를 돕기 위한 방향일 뿐, 코드 하나가 하나의 직업으로 정해지는 건 아니에요. 같은 직업 안에서도 맡는 역할에 따라 필요한 흥미가 달라요. 예를 들어 같은 개발자라도 연구 개발은 탐구형, 서비스 기획은 예술형·진취형, 운영 관리는 관습형 흥미가 더 많이 쓰여요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">홀랜드 검사 결과를 진로에 활용하는 방법</h2>
<p><strong>공식 도구로 한 번 더 확인해요.</strong> 미국 O*NET은 홀랜드 이론에 기반한 무료 흥미 검사 <a href="https://www.onetcenter.org/IP.html" target="_blank" rel="noopener">O*NET Interest Profiler</a>를 공개하고 있어요. 웹용 Mini-IP는 30문항, 종이용 Short Form은 60문항이에요. 영어 버전이지만 <a href="https://www.mynextmove.org/explore/ip" target="_blank" rel="noopener">My Next Move 흥미 검사</a>에서 바로 해 볼 수 있고, 결과 코드로 관련 직업 목록까지 볼 수 있어요. 국내에서도 공공 진로 정보 사이트와 학교 진로 상담에서 홀랜드 기반 흥미 검사를 제공하니, 진지하게 진로를 정해야 한다면 표준화된 검사를 받아 보는 걸 권해요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">O*NET 흥미 검사 형식별 문항 수</figcaption><svg viewBox="0 0 640 84" role="img" aria-label="O*NET 흥미 검사 형식별 문항 수" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">Mini-IP (웹·모바일)</text><rect class="s1" x="220" y="15" width="175.0" height="20" rx="3"><title>Mini-IP (웹·모바일) · 30문항</title></rect><text class="viz-val" x="401.0" y="29">30문항</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">IP Short Form (종이)</text><rect class="s1" x="220" y="49" width="350.0" height="20" rx="3"><title>IP Short Form (종이) · 60문항</title></rect><text class="viz-val" x="576.0" y="63">60문항</text></svg><p class="viz-note">두 형식 모두 같은 RIASEC 6가지 흥미를 재요.</p><div class="viz-cap">단위: 문항 · 자료: O*NET Resource Center, O*NET Interest Profiler 안내 페이지 (2026년 11월 확인)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>문항 수</th></tr></thead><tbody><tr><td>Mini-IP (웹·모바일)</td><td>30</td></tr><tr><td>IP Short Form (종이)</td><td>60</td></tr></tbody></table></div></details></figure>
<p><strong>흥미 + 능력 + 가치관을 같이 봐요.</strong> 흥미가 높아도 그 일을 하려면 어떤 능력이 필요한지, 그 일이 내가 중요하게 여기는 가치(안정성, 수입, 자율성, 사회적 기여)와 맞는지를 함께 따져야 해요. 흥미 검사는 세 가지 중 하나를 알려 줄 뿐이에요.</p>
<p><strong>작게 경험해 봐요.</strong> 관심 직업이 생기면 관련 강의 하나 듣기, 현직자 인터뷰 읽기, 짧은 프로젝트나 봉사 활동처럼 작은 경험을 해 보세요. 실제로 해 보면 "생각보다 재밌다" 또는 "생각과 다르다"를 빨리 알 수 있어요.</p>
<p><strong>코드는 바뀔 수 있어요.</strong> 흥미는 나이와 경험에 따라 달라져요. 특히 10대와 20대 초반에는 변화가 큰 편이라, 몇 년에 한 번 다시 검사해 비교해 보면 내 관심이 어디로 움직이는지 볼 수 있어요. 성격이 나이에 따라 어떻게 바뀌는지는 <a href="https://tool.smithlab.co.kr/posts/survey/personality-change-with-age/">나이 들면 성격이 바뀔까</a>에 정리해 두었어요.</p>
<h2 id="s6">홀랜드 이론에 대한 흔한 오해</h2>
<p><strong>"유형이 정해지면 그 직업만 해야 한다"</strong>는 오해가 가장 커요. 홀랜드 코드는 방향을 알려 주는 나침반이지 정해진 길이 아니에요. 같은 코드를 가진 사람도 전혀 다른 직업에서 만족하며 일할 수 있어요.</p>
<p><strong>"점수가 낮은 유형의 일은 못 한다"</strong>도 사실이 아니에요. 점수는 흥미의 상대적 크기일 뿐 능력을 재지 않아요. 관습형 점수가 낮아도 회계를 잘할 수 있고, 다만 그 일을 오래 하면 상대적으로 에너지가 더 들 수 있다는 정도로 이해하면 돼요.</p>
<p><strong>"흥미 검사 = 성격 검사"</strong>도 조금 달라요. RIASEC은 성격 이론과 관련이 있지만 일과 활동에 대한 선호를 중심으로 만들어졌어요. MBTI나 에니어그램처럼 성격 전반을 다루는 도구와 함께 보면 서로 다른 면을 볼 수 있어요. 성격의 동기를 다루는 틀이 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/survey/enneagram-9-types-wings/">에니어그램 9가지 유형 특징과 날개</a>도 참고해 보세요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">자주 묻는 질문</h2>
<h3>RIASEC은 무슨 뜻인가요?</h3>
<p>현실형(Realistic), 탐구형(Investigative), 예술형(Artistic), 사회형(Social), 진취형(Enterprising), 관습형(Conventional)의 영어 첫 글자를 육각형 순서대로 이은 말이에요. 홀랜드 이론의 여섯 흥미 유형을 한 번에 부르는 이름이에요.</p>
<h3>홀랜드 코드는 왜 세 글자인가요?</h3>
<p>사람의 흥미는 한 가지 유형으로 설명되지 않는 경우가 대부분이라, 점수가 높은 세 유형을 순서대로 적어 조합을 표현해요. 첫 글자가 가장 강하고, 두세 번째 글자가 그 흥미를 어떤 방식으로 쓰고 싶은지 보완해 준다고 보면 돼요.</p>
<h3>여섯 유형 점수가 다 비슷하게 나왔어요. 문제가 있나요?</h3>
<p>문제는 아니에요. 아직 흥미가 한 방향으로 모이지 않았거나, 여러 분야에 두루 관심이 있는 상태일 수 있어요. 다양한 활동을 경험해 본 뒤 다시 검사하거나, 점수보다 "어떤 문항에 강하게 끌렸는지"를 살펴보는 게 도움이 돼요.</p>
<h3>이 사이트의 직업 적성 테스트로 진로를 정해도 되나요?</h3>
<p>재미와 자기 이해를 위한 참고용으로 써 주세요. 진로처럼 중요한 결정을 할 때는 O*NET Interest Profiler 같은 표준화된 검사나 학교·공공기관의 진로 상담을 함께 받아 보는 걸 권해요. 테스트 결과는 대화와 탐색을 시작하는 출발점으로 쓰기 좋아요.</p>
<h3>회사 생활 스타일도 홀랜드 유형과 관련이 있나요?</h3>
<p>흥미는 어떤 일에서 에너지를 얻는지와 연결돼서, 회사에서 편하게 느끼는 역할과도 관련이 있을 수 있어요. 다만 회사 생활은 조직 문화와 대인 관계의 영향도 커요. 직장에서의 내 모습은 <a href="https://tool.smithlab.co.kr/survey/work-survival/">회사 생존 스타일 테스트</a>로 따로 확인해 볼 수 있어요.</p>
<h2 id="s8">정리</h2>
<p>홀랜드 RIASEC 이론은 직업 흥미를 현실형, 탐구형, 예술형, 사회형, 진취형, 관습형 여섯 가지로 나누고, 이들을 육각형에 배치해 이웃한 유형은 닮고 마주 보는 유형은 가장 다르다고 설명해요. 결과는 상위 세 유형을 이은 홀랜드 코드로 읽고, 첫 글자와 일관성, 변별도를 함께 보면 해석이 훨씬 풍부해져요. 다만 흥미는 능력이나 가치관과 다르고 시간이 지나며 바뀔 수 있으니, 코드를 정답이 아니라 탐색의 출발점으로 쓰는 게 좋아요.</p>
<p><a href="https://tool.smithlab.co.kr/survey/career-type/">직업 적성 6유형 테스트</a>로 내 흥미 유형을 가볍게 확인해 보고, 동물에 빗대어 일하는 스타일을 보고 싶다면 <a href="https://tool.smithlab.co.kr/survey/animal-job/">동물 직업 테스트</a>도 함께 해 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>결혼관 체크리스트, 결혼 전 꼭 맞춰 볼 가치관 질문 6가지 영역</title>
      <link>https://tool.smithlab.co.kr/posts/survey/marriage-values-checklist/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/marriage-values-checklist/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연애할 때는 취향만 맞아도 즐겁지만, 결혼을 이야기하기 시작하면 질문이 달라져요. 통장은 합칠지, 아이는 원하는지, 명절은 어떻게 보낼지처럼 생활 전체가 걸린 질문들이에요. <strong>결혼 전에 맞춰 볼 가치관은 크게 돈, 집안일과 시간, 자녀 계획, 양가 가족, 일과 사는 곳, 갈등과 애정 표현 여섯 영역으로 정리할 수 있어요. 중요한 건 모든 답이 같아야 한다는 게 아니라, 다른 부분을 미리 알고 어떻게 맞춰 갈지 이야기해 두는 거예요.</strong></p>
<p>결혼관은 정답이 있는 시험이 아니에요. 같은 질문에도 자라 온 환경과 경험에 따라 당연하다고 느끼는 기준이 다르고, 그 차이가 결혼 뒤에 갑자기 드러나면 "이런 사람인 줄 몰랐다"는 갈등이 되기 쉬워요. 이 글에서는 영역별로 꼭 나눠 볼 질문을 체크리스트로 정리하고, 커플 관계 연구로 잘 알려진 가트맨 연구소의 개념을 빌려 생각이 다를 때 어떻게 대화하면 좋은지 정리했어요. 내 결혼관이 어느 쪽에 가까운지는 <a href="https://tool.smithlab.co.kr/survey/marriage-values/">결혼관 테스트</a>로 먼저 가볍게 확인해 보세요. 결과는 재미와 대화의 계기로만 써 주세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>결혼 전 가치관 대화는 돈, 집안일과 시간, 자녀 계획, 양가 가족, 일과 사는 곳, 갈등과 애정 표현 여섯 영역으로 나누면 빠지는 주제 없이 정리할 수 있어요.</li><li>가트맨 연구소는 커플 갈등의 69%가 성격과 생활 방식의 근본적인 차이에서 오는 '영속적 문제'라고 설명해요. 모든 차이를 없애기보다 다루는 방법을 정하는 게 현실적이에요.</li><li>생각이 다를 때는 "누가 맞냐"보다 "왜 그게 나한테 중요한지"를 먼저 나누면 타협점이 보이기 쉬워요.</li><li>결혼관 테스트 결과는 정답이 아니라 대화를 시작하는 질문지로 쓰는 게 가장 좋아요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">결혼관이란? 왜 결혼 전에 맞춰 봐야 할까</h2>
<p>결혼관은 결혼이라는 관계와 생활에 대해 내가 가진 기대와 기준의 묶음이에요. "결혼하면 당연히 이렇게 하는 거지"라고 생각하는 것들이 여기에 다 들어가요. 문제는 이 기준이 대부분 말로 꺼내 본 적 없는 '당연함'이라는 점이에요. 연애할 때는 각자 생활이 분리돼 있어서 잘 부딪히지 않다가, 한집에서 돈과 시간을 함께 쓰기 시작하면 차이가 드러나요.</p>
<p>미국 심리학자 <a href="https://en.wikipedia.org/wiki/John_Gottman" target="_blank" rel="noopener">존 가트맨</a>은 수십 년 동안 부부의 대화와 갈등을 관찰하는 연구를 해 왔고, 1996년 아내 줄리 가트맨과 함께 가트맨 연구소를 세웠어요. 가트맨 연구소가 강조하는 개념 중 하나가 <strong>해결 가능한 문제와 영속적인 문제의 구분</strong>이에요. <a href="https://www.gottman.com/blog/managing-conflict-solvable-vs-perpetual-problems/" target="_blank" rel="noopener">가트맨 연구소의 갈등 관리 글</a>에 따르면 커플 갈등의 69%는 성격이나 생활 방식의 근본적인 차이에서 오는 영속적 문제이고, 모든 커플에게 이런 문제가 있다고 해요.</p>
<figure class="viz viz-donut"><figcaption class="viz-title">가트맨 연구소가 말하는 커플 갈등의 구성</figcaption><svg viewBox="0 0 640 260" role="img" aria-label="가트맨 연구소가 말하는 커플 갈등의 구성" preserveAspectRatio="xMidYMid meet"><path class="s1" d="M131.26,25.01 A105,105 0 1 1 32.84,169.82 L64.30,156.93 A71,71 0 1 0 130.85,59.01 Z"><title>영속적 문제 · 69% (69.0%)</title></path><path class="s2" d="M31.92,167.48 A105,105 0 0 1 128.74,25.01 L129.15,59.01 A71,71 0 0 0 63.68,155.34 Z"><title>해결 가능한 문제 · 31% (31.0%)</title></path><text class="viz-center" x="130" y="137" text-anchor="middle">영속적 69%</text><rect class="s1" x="290" y="29" width="14" height="14" rx="3"/><text class="viz-ax lg" x="314" y="41">영속적 문제</text><text class="viz-val" x="630" y="41" text-anchor="end">69%</text><rect class="s2" x="290" y="61" width="14" height="14" rx="3"/><text class="viz-ax lg" x="314" y="73">해결 가능한 문제</text><text class="viz-val" x="630" y="73" text-anchor="end">31%</text></svg><p class="viz-note">영속적 문제는 성격·생활 방식의 근본적 차이에서 와서 완전히 사라지기보다 계속 다뤄 가야 하는 문제예요. 나머지 비율은 69%에서 계산한 값이에요.</p><div class="viz-cap">단위: % · 자료: The Gottman Institute, Managing Conflict: Solvable vs. Perpetual Problems (2026년 11월 확인)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>비율</th></tr></thead><tbody><tr><td>영속적 문제</td><td>69</td></tr><tr><td>해결 가능한 문제</td><td>31</td></tr></tbody></table></div></details></figure>
<p>이 숫자가 말하는 건 "결혼 전에 모든 차이를 없애야 한다"가 아니라 "어차피 남을 차이가 있으니, 미리 알고 다루는 방법을 정해 두자"예요. 결혼 전 가치관 대화의 목적도 여기에 있어요. 상대를 내 기준에 맞추는 게 아니라, 서로의 기준을 지도처럼 펼쳐 놓고 어디가 겹치고 어디가 다른지 아는 거예요.</p>
<h2 id="s2">결혼 전 맞춰 볼 가치관 6가지 영역</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/marriage-values-checklist-1.svg" alt="결혼 전에 맞춰 볼 가치관 6가지 영역 카드. 1 돈: 통장 합치기, 저축 비율, 큰 지출 기준. 2 집안일과 시간: 가사 분담, 혼자 시간. 3 자녀 계획: 원하는지, 몇 명, 언제. 4 양가 가족: 명절, 방문 빈도, 부양. 5 일과 사는 곳: 이직, 이사, 지역. 6 갈등과 애정 표현: 싸운 뒤 푸는 방식, 연락 빈도" width="800" height="540" loading="lazy" decoding="async"><figcaption>여섯 영역을 하나씩 짚으면 빠지는 주제가 줄어요</figcaption></figure>
<p>여섯 영역은 서로 이어져 있어요. 예를 들어 자녀 계획은 돈과 일, 사는 곳 문제와 함께 움직이고, 양가 가족 문제는 갈등을 푸는 방식과 깊이 얽혀 있어요. 그래서 한 번에 다 정하려 하기보다 영역별로 나눠 여러 번 이야기하는 게 좋아요.</p>
<h2 id="s3">영역별 결혼관 체크리스트 질문</h2>
<h3>1. 돈: 가장 먼저, 가장 구체적으로</h3>
<ul><li>결혼 후 통장은 완전히 합칠까, 생활비만 모을까, 각자 관리할까?</li><li>저축과 소비의 비율은 어느 정도가 편한가? 한 달 생활비 기준은?</li><li>상의 없이 써도 되는 금액의 기준은 얼마인가?</li><li>지금 있는 빚이나 대출, 앞으로의 큰 지출 계획(집, 차, 학업)은?</li><li>양가 부모님께 드리는 용돈이나 경조사비는 어떻게 정할까?</li></ul>
<p>돈 이야기는 꺼내기 어렵지만 가장 구체적으로 해야 하는 주제예요. 금액을 말하기 어색하다면 "한 달에 이 정도는 저축하고 싶다" 같은 비율이나 원칙부터 맞춰도 좋아요. 평소 소비 성향 차이는 <a href="https://tool.smithlab.co.kr/survey/spending-style/">소비 성향 테스트</a>로 함께 확인해 보면 대화가 한결 가벼워져요.</p>
<h3>2. 집안일과 시간: 매일 부딪히는 문제</h3>
<ul><li>집안일은 어떻게 나눌까? 요리, 설거지, 빨래, 청소 중 서로 싫은 일은?</li><li>깨끗함의 기준은 어느 정도인가? 매일 정리파인가, 주말 몰아서파인가?</li><li>퇴근 후와 주말에 혼자 있는 시간이 얼마나 필요한가?</li><li>친구 모임, 취미 활동에 쓰는 시간은 어느 정도가 적당한가?</li></ul>
<p>집안일 갈등은 일의 양보다 "기준이 다르다"에서 오는 경우가 많아요. 누가 무엇을 할지 정하는 것만큼, "어느 정도면 된 걸로 칠지"를 맞추는 게 중요해요.</p>
<h3>3. 자녀 계획: 미루면 가장 크게 어긋나는 주제</h3>
<ul><li>아이를 원하는가? 원한다면 몇 명, 언제쯤?</li><li>아이가 생기지 않는다면 어떻게 하고 싶은가?</li><li>육아휴직이나 일 조정은 누가, 어떻게 할까?</li><li>어떤 방식으로 키우고 싶은가? 교육에서 중요하게 생각하는 건?</li></ul>
<p>자녀 계획은 "그때 가서 생각하자"로 넘기기 쉽지만, 원하는지 아닌지 자체가 다르면 타협이 가장 어려운 주제예요. 결혼 전에 최소한 서로의 기본 입장은 분명히 확인해 두는 걸 권해요.</p>
<h3>4. 양가 가족: 거리와 경계 정하기</h3>
<ul><li>명절에는 어떻게 보낼까? 양가를 번갈아 갈까, 나눠 갈까?</li><li>양가 방문과 연락은 얼마나 자주가 적당한가?</li><li>부모님이 나이 드시면 부양이나 돌봄은 어떻게 할까?</li><li>배우자와 내 가족 사이에 갈등이 생기면 나는 어떻게 행동할까?</li></ul>
<p>양가 가족 문제는 "누구 편을 드느냐"로 번지기 쉬워요. 결혼 전에 "갈등이 생기면 각자 자기 가족에게 먼저 이야기한다" 같은 원칙을 정해 두면 실제 상황에서 훨씬 덜 흔들려요.</p>
<h3>5. 일과 사는 곳: 앞으로 5년, 10년의 그림</h3>
<ul><li>각자 커리어에서 중요하게 생각하는 건? 이직이나 공부 계획이 있는가?</li><li>한 사람의 일 때문에 이사해야 한다면 어떻게 결정할까?</li><li>어느 지역에서 살고 싶은가? 도시, 지방, 부모님 근처?</li><li>집은 사는 게 우선인가, 빌리는 것도 괜찮은가?</li></ul>
<h3>6. 갈등과 애정 표현: 관계를 지키는 방식</h3>
<ul><li>싸우면 바로 이야기하는 편인가, 시간이 필요한 편인가?</li><li>사과는 어떻게 하고 어떻게 받고 싶은가?</li><li>연락 빈도, 스킨십, 기념일은 어느 정도가 편한가?</li><li>이성 친구, 회식, 술자리에 대한 기준은?</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s4">결혼관이 다를 때 대화하는 방법</h2>
<p>여섯 영역을 다 이야기해 보면 분명히 다른 부분이 나와요. 그게 정상이에요. 중요한 건 다른 부분을 어떻게 다루느냐예요.</p>
<p><strong>"무엇을"보다 "왜"를 먼저 물어봐요.</strong> 가트맨 연구소는 영속적 문제가 성격이나 생활 방식의 근본적인 차이에서 온다고 설명해요. 그런 차이 밑에는 각자 중요하게 여기는 가치가 있는 경우가 많아요. 예를 들어 "저축을 많이 하고 싶다"는 입장 밑에는 "어릴 때 돈 걱정을 많이 해서 안정감이 중요하다"는 이유가 있을 수 있어요. 이유를 알면 금액은 조정할 수 있어도 안정감이라는 가치는 지켜 주는 방식으로 타협할 수 있어요.</p>
<p><strong>비판 대신 내 감정과 필요를 말해요.</strong> 가트맨 연구소는 관계를 위협하는 대화 패턴으로 비난, 경멸, 방어, 담쌓기를 꼽고 이를 '네 기수'라고 불러요(<a href="https://www.gottman.com/blog/the-four-horsemen-recognizing-criticism-contempt-defensiveness-and-stonewalling/" target="_blank" rel="noopener">가트맨 연구소의 네 기수 설명</a>). "당신은 항상 돈을 막 써"는 상대의 성격을 공격하는 비난이고, "이번 달 지출이 예상보다 많아서 나는 좀 불안해, 같이 예산을 다시 보자"는 내 감정과 필요를 말하는 방식이에요. 같은 내용이라도 두 번째가 대화를 이어 가기 훨씬 쉬워요.</p>
<div class="table-wrap"><table><thead><tr><th>피하면 좋은 말</th><th>바꿔 말하기</th></tr></thead><tbody><tr><td>"너는 항상 집안일을 안 해"</td><td>"요즘 설거지가 계속 쌓여서 나 혼자 하는 느낌이라 지쳐. 요일을 나눠 볼까?"</td></tr><tr><td>"너희 집은 왜 그렇게 간섭해?"</td><td>"주말마다 방문하는 게 나한테는 좀 부담돼. 한 달에 몇 번이 좋을지 같이 정하고 싶어."</td></tr><tr><td>"아이 문제는 나중에 얘기해"</td><td>"지금 당장 정하긴 어렵지만 서로 기본 생각은 알고 싶어. 이번 주말에 얘기해 볼래?"</td></tr></tbody></table></div>
<p><strong>정할 수 있는 것과 계속 다룰 것을 나눠요.</strong> 통장 관리 방식처럼 규칙을 정하면 해결되는 문제도 있고, 혼자 시간이 필요한 정도처럼 계속 조율해야 하는 문제도 있어요. 후자는 "한 번에 해결"이 아니라 "정기적으로 다시 이야기하기"를 약속하는 게 현실적이에요. 대화 방식 자체가 궁금하다면 <a href="https://tool.smithlab.co.kr/survey/couple-talk/">커플 대화 스타일 테스트</a>를 둘이 같이 해 보는 것도 좋아요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p>대화 중 한쪽이 겁을 주거나, 돈·연락을 통제하거나, 신체적·언어적 폭력이 있다면 그건 가치관 차이가 아니라 관계의 안전 문제예요. 이럴 때는 혼자 감당하지 말고 주변의 믿을 만한 사람이나 상담 전문가의 도움을 받으세요.</p></aside>
<h2 id="s5">결혼관 테스트 결과를 활용하는 방법</h2>
<p>결혼관 테스트는 여섯 영역에서 내가 어떤 쪽에 가까운지 가볍게 보여 줘요. 활용 순서를 추천하면 이래요.</p>
<ol><li><strong>각자 따로 해 봐요.</strong> 상대 답을 보면 맞춰 가고 싶어지니, 먼저 각자 솔직하게 답해요.</li><li><strong>결과를 나란히 놓고 비교해요.</strong> 같은 부분은 확인하고 넘어가고, 다른 부분에 표시를 해요.</li><li><strong>다른 부분 중 하나만 골라 이야기해요.</strong> 한 번에 다 하려면 지치고 싸우기 쉬워요. 이번 주는 돈, 다음 주는 명절처럼 나눠요.</li><li><strong>"왜 그렇게 답했는지"를 물어봐요.</strong> 답 자체보다 이유를 들을 때 상대를 더 잘 이해하게 돼요.</li><li><strong>정한 건 짧게 적어 둬요.</strong> 나중에 기억이 엇갈리지 않도록 메모로 남겨요.</li></ol>
<p>연인과 사랑을 표현하는 방식이 다르다면 <a href="https://tool.smithlab.co.kr/posts/survey/five-love-languages/">사랑의 언어 5가지</a>를, 가까워지는 방식의 차이가 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/survey/attachment-styles-in-love/">애착 유형 4가지</a>도 함께 읽어 보세요. 결혼관이 "어떤 생활을 원하는가"를 다룬다면, 이 두 글은 "어떻게 사랑을 주고받는가"를 다뤄서 서로 보완이 돼요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s6">자주 묻는 질문</h2>
<h3>결혼 전에 꼭 이야기해야 할 주제는 뭔가요?</h3>
<p>돈, 집안일과 시간, 자녀 계획, 양가 가족, 일과 사는 곳, 갈등과 애정 표현 여섯 영역을 추천해요. 그중에서도 자녀를 원하는지, 돈을 어떻게 관리할지, 양가와의 거리를 어떻게 둘지는 결혼 뒤에 바꾸기 어려워서 미리 기본 입장을 확인해 두는 게 좋아요.</p>
<h3>결혼관이 많이 다르면 결혼하면 안 되나요?</h3>
<p>그렇지 않아요. 가트맨 연구소도 모든 커플에게 근본적인 차이가 있다고 설명해요. 차이가 있느냐보다 그 차이를 존중하며 대화할 수 있느냐가 더 중요해요. 다만 아이를 원하는지처럼 타협이 거의 불가능한 핵심 문제가 정반대라면 충분히 시간을 두고 진지하게 이야기해 보는 게 필요해요.</p>
<h3>돈 이야기를 꺼내기가 너무 어색해요.</h3>
<p>금액보다 원칙부터 시작해 보세요. "한 달 수입의 어느 정도는 저축하고 싶어", "큰돈 쓸 땐 서로 미리 말하자" 같은 기준을 먼저 맞추면 구체적인 숫자 이야기가 한결 쉬워져요. 테스트 결과를 계기로 꺼내는 것도 자연스러운 방법이에요.</p>
<h3>결혼관 테스트 결과가 연인과 정반대로 나왔어요.</h3>
<p>걱정하지 않아도 돼요. 테스트는 짧은 질문으로 경향만 보여 주는 재미용이라, 실제 생각은 대화해 보면 생각보다 가까운 경우도 많아요. 정반대로 나온 문항을 골라 "왜 그렇게 답했는지" 이야기해 보는 대화 재료로 써 보세요.</p>
<h3>결혼 전 상담이나 교육을 받는 게 도움이 되나요?</h3>
<p>커플이 스스로 대화하기 어려운 주제가 있거나 갈등이 반복된다면 전문 상담이 도움이 될 수 있어요. 결혼 전 교육 프로그램은 대화 방법과 갈등 관리를 연습할 기회를 줘요. 상담이나 교육을 받는다고 문제가 있는 커플인 게 아니라, 관계를 미리 챙기는 방법 중 하나로 보면 돼요.</p>
<h2 id="s7">정리</h2>
<p>결혼관은 말로 꺼내 본 적 없는 '당연함'의 묶음이라, 결혼 전에 돈, 집안일과 시간, 자녀 계획, 양가 가족, 일과 사는 곳, 갈등과 애정 표현 여섯 영역으로 나눠 이야기해 보는 게 좋아요. 가트맨 연구소의 설명처럼 커플 갈등의 상당수는 사라지지 않는 근본적 차이에서 오니, 목표는 차이를 없애는 게 아니라 미리 알고 다루는 방법을 정하는 거예요. 다를 때는 "누가 맞냐"보다 "왜 중요한지"를 먼저 묻고, 비난 대신 내 감정과 필요를 말해 보세요.</p>
<p><a href="https://tool.smithlab.co.kr/survey/marriage-values/">결혼관 테스트</a>를 각자 해 본 뒤 결과를 나란히 놓고 이야기를 시작해 보세요. 싸울 때 각자 어떻게 반응하는지는 <a href="https://tool.smithlab.co.kr/survey/fight-style/">싸움 스타일 테스트</a>로 함께 확인해 볼 수 있어요.</p>
    ]]></description>
    </item>
    <item>
      <title>퍼스널컬러 자가진단 방법, 웜톤 쿨톤 구별법과 4계절 유형 특징</title>
      <link>https://tool.smithlab.co.kr/posts/survey/personal-color-self-check/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/survey/personal-color-self-check/</guid>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>같은 옷인데 어떤 날은 얼굴이 환해 보이고 어떤 날은 칙칙해 보인 적 있으시죠. 이럴 때 많이 찾아보는 게 <strong>퍼스널컬러</strong>예요. <strong>퍼스널컬러 자가진단의 핵심은 두 단계예요. 먼저 피부 바탕색이 노란 기가 도는 웜톤인지, 푸르거나 붉은 기가 도는 쿨톤인지 보고, 그다음 밝고 맑은 색과 깊고 차분한 색 중 어느 쪽이 더 잘 받는지 보면 봄·여름·가을·겨울 네 유형으로 나눌 수 있어요.</strong></p>
<p>다만 퍼스널컬러는 의학 검사나 과학적으로 표준화된 측정이 아니라, 패션·뷰티 업계에서 발전한 "어울리는 색을 고르는 방법"이에요. 집에서 하는 자가진단은 조명과 화장, 카메라 설정에 따라 결과가 쉽게 흔들려요. 이 글에서는 퍼스널컬러가 어디서 왔는지, 4계절 유형이 무엇을 기준으로 나뉘는지, 집에서 최대한 덜 틀리게 확인하는 방법을 순서대로 정리했어요. 가볍게 먼저 확인해 보고 싶다면 <a href="https://tool.smithlab.co.kr/survey/personal-color/">퍼스널컬러 자가진단 테스트</a>를 해 보세요. 결과는 재미와 참고용이에요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>퍼스널컬러는 피부·눈동자·머리카락 색과 조화를 이루는 색을 찾는 방법으로, 1980년대 미국에서 크게 유행한 '계절 컬러 분석'이 바탕이에요.</li><li>첫 번째 기준은 온도(웜·쿨), 두 번째 기준은 명도(밝기)와 채도(선명도)예요. 이 조합으로 봄 웜, 여름 쿨, 가을 웜, 겨울 쿨이 나뉘어요.</li><li>자가진단은 화장을 지운 얼굴로 흐린 날의 자연광 아래에서, 흰 종이·금은 액세서리·주황과 분홍 천을 얼굴 아래에 번갈아 대 보는 방식이 가장 덜 흔들려요.</li><li>사계절 분류는 체계마다 기준이 달라서, 웜·쿨 한 가지만 확실히 알아도 옷과 화장품 고르는 데 충분히 도움이 돼요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">퍼스널컬러란? 계절 컬러 분석이 생긴 과정</h2>
<p>퍼스널컬러는 영어권에서 'color analysis' 또는 'seasonal color analysis'라고 불러요. <a href="https://en.wikipedia.org/wiki/Color_analysis" target="_blank" rel="noopener">위키백과 Color analysis 문서</a>에 정리된 흐름을 따라가면 출발점은 19세기 색채 이론이에요. 프랑스 화학자 미셸 외젠 슈브뢸은 1839년 『색의 동시 대비 법칙』에서 "두 색이 나란히 있으면 서로의 색을 다르게 보이게 한다"는 동시 대비를 정리했는데, 얼굴 옆에 어떤 색을 두느냐에 따라 안색이 달라 보인다는 퍼스널컬러의 기본 아이디어가 여기서 나와요.</p>
<p>20세기 초 미국 화가 앨버트 먼셀은 색을 색상(hue), 명도(value), 채도(chroma) 세 가지 속성으로 정리한 <a href="https://en.wikipedia.org/wiki/Munsell_color_system" target="_blank" rel="noopener">먼셀 색체계</a>를 만들었어요. 지금 퍼스널컬러에서 "밝다·어둡다", "선명하다·탁하다"를 따지는 것도 이 세 가지 속성을 쓰는 거예요. 바우하우스에서 색채를 가르친 <a href="https://en.wikipedia.org/wiki/Johannes_Itten" target="_blank" rel="noopener">요하네스 이텐</a>은 1961년 『색채의 예술』에서 사람마다 끌리는 '주관적 색'이 있고 이것이 봄·여름·가을·겨울과 닮았다고 설명했어요. 이 생각이 계절 분류의 뿌리가 됐어요.</p>
<p>이후 로버트 도어는 피부를 푸른 기가 도는 키 1과 노란 기가 도는 키 2로 나누는 '컬러 키' 체계를 만들었고, 수잔 케이길은 계절 안에 세부 유형을 두는 방식을 발전시켰어요. 1980년 캐롤 잭슨의 『Color Me Beautiful』이 큰 인기를 얻으면서 사계절 분류가 대중에게 퍼졌고, 2010년대 이후 한국과 일본, 소셜미디어를 중심으로 다시 유행하고 있어요. 잭슨 스스로도 계절 이름은 "A형, B형이라고 불러도 되지만 더 시적인 표현일 뿐"이라고 썼어요. 계절 이름에 큰 의미를 둘 필요는 없다는 뜻이에요.</p>
<h2 id="s2">퍼스널컬러를 나누는 기준: 온도, 명도, 채도</h2>
<p>퍼스널컬러 분류는 결국 두 가지 질문에 답하는 과정이에요.</p>
<ol><li><strong>온도</strong>: 피부 바탕색(언더톤)에 노란 기가 많은가(웜), 푸르거나 붉은 기가 많은가(쿨)?</li><li><strong>명도와 채도</strong>: 얼굴 옆에 밝고 맑은 색이 잘 받는가, 깊고 진한 색이 잘 받는가? 선명한 원색이 잘 받는가, 회색이 섞인 부드러운 색이 잘 받는가?</li></ol>
<p>여기서 헷갈리기 쉬운 게 <strong>피부가 하얀지 까만지(표면 색)와 웜·쿨(바탕색)은 다른 이야기</strong>라는 점이에요. 피부가 밝아도 노란 기가 강한 웜톤일 수 있고, 피부가 어두워도 붉은 기가 도는 쿨톤일 수 있어요. 피부 밝기는 오히려 두 번째 기준인 명도와 더 관련이 있어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/survey/personal-color-self-check-1.svg" alt="퍼스널컬러 4계절을 가로 웜톤·쿨톤, 세로 밝음·깊음으로 나눈 2x2 그림. 봄 웜은 밝고 맑은 코랄·피치, 여름 쿨은 밝고 부드러운 라벤더·파우더 블루, 가을 웜은 깊고 차분한 머스터드·카키, 겨울 쿨은 진하고 선명한 트루 레드·로열 블루" width="800" height="520" loading="lazy" decoding="async"><figcaption>온도와 밝기, 두 축을 조합하면 네 계절이 나와요</figcaption></figure>
<h2 id="s3">퍼스널컬러 4계절 유형별 특징과 어울리는 색</h2>
<div class="table-wrap"><table><thead><tr><th>유형</th><th>온도</th><th>밝기·선명도</th><th>잘 받는 색 예시</th><th>피하면 좋은 색 예시</th></tr></thead><tbody><tr><td>봄 웜</td><td>웜</td><td>밝고 맑음</td><td>코랄, 피치, 웜 옐로, 애플 그린</td><td>탁한 회색, 새까만 블랙</td></tr><tr><td>여름 쿨</td><td>쿨</td><td>밝고 부드러움</td><td>라벤더, 파우더 블루, 로즈 핑크, 소프트 그레이</td><td>주황, 진한 갈색</td></tr><tr><td>가을 웜</td><td>웜</td><td>깊고 차분함</td><td>머스터드, 카키, 테라코타, 브라운</td><td>형광색, 차가운 파스텔</td></tr><tr><td>겨울 쿨</td><td>쿨</td><td>진하고 선명함</td><td>트루 레드, 로열 블루, 퓨시아, 블랙과 화이트</td><td>베이지, 흐린 파스텔</td></tr></tbody></table></div>
<p><strong>봄 웜</strong>은 밝고 생기 있는 색이 잘 받아서, 맑은 코랄 립이나 아이보리 니트를 입으면 얼굴이 화사해 보이는 경우가 많아요. 반대로 무채색이나 탁한 색을 얼굴 가까이에 두면 혈색이 없어 보이기 쉬워요.</p>
<p><strong>여름 쿨</strong>은 회색이 살짝 섞인 부드러운 파스텔이 잘 어울려요. 쨍한 원색보다는 라벤더, 하늘색, 장밋빛 핑크처럼 차분한 색에서 피부가 깨끗해 보여요. 노란 기가 강한 주황이나 금색은 얼굴이 누렇게 떠 보일 수 있어요.</p>
<p><strong>가을 웜</strong>은 깊고 차분한 흙빛, 낙엽빛 색이 잘 받아요. 카멜 코트, 올리브 셔츠, 브릭 립처럼 채도가 낮고 따뜻한 색에서 분위기가 살아나요. 차가운 핑크나 형광색은 떠 보이기 쉬워요.</p>
<p><strong>겨울 쿨</strong>은 대비가 강하고 선명한 색이 잘 어울려요. 새하얀 셔츠와 검은 재킷, 쨍한 레드 립처럼 명확한 색이 얼굴을 또렷하게 만들어요. 베이지나 흐린 파스텔은 얼굴이 흐릿해 보일 수 있어요.</p>
<p>아래 차트는 위 표에 나온 대표 색 4개씩을 디지털 색 값(HSL)으로 바꿔 평균을 낸 거예요. 숫자 자체보다 네 유형이 "밝기"와 "선명도"에서 어떻게 갈리는지 모양을 보면 돼요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">4계절 대표 색 예시의 평균 밝기와 선명도 (HSL 기준)</figcaption><div class="viz-legend"><span><i class="s1"></i>밝기(L)</span><span><i class="s2"></i>선명도(S)</span></div><svg viewBox="0 0 640 300" role="img" aria-label="4계절 대표 색 예시의 평균 밝기와 선명도 (HSL 기준)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="73.6" y="110.0" width="49.4" height="144.0" rx="3"><title>봄 웜 · 밝기(L) 61%</title></rect><rect class="s2" x="125.0" y="18.0" width="49.4" height="236.0" rx="3"><title>봄 웜 · 선명도(S) 100%</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">봄 웜</text><rect class="s1" x="217.6" y="77.0" width="49.4" height="177.0" rx="3"><title>여름 쿨 · 밝기(L) 75%</title></rect><rect class="s2" x="269.0" y="166.7" width="49.4" height="87.3" rx="3"><title>여름 쿨 · 선명도(S) 37%</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">여름 쿨</text><rect class="s1" x="361.6" y="152.5" width="49.4" height="101.5" rx="3"><title>가을 웜 · 밝기(L) 43%</title></rect><rect class="s2" x="413.0" y="138.4" width="49.4" height="115.6" rx="3"><title>가을 웜 · 선명도(S) 49%</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">가을 웜</text><rect class="s1" x="505.6" y="162.0" width="49.4" height="92.0" rx="3"><title>겨울 쿨 · 밝기(L) 39%</title></rect><rect class="s2" x="557.0" y="39.2" width="49.4" height="214.8" rx="3"><title>겨울 쿨 · 선명도(S) 91%</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">겨울 쿨</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">봄은 밝고 선명, 여름은 밝고 부드러움, 가을은 어둡고 부드러움, 겨울은 어둡고 선명한 쪽으로 모여요. HSL 채도는 먼셀 채도와 계산 방식이 달라 경향만 보는 용도예요.</p><div class="viz-cap">단위: % · 자료: 표의 대표 색 HEX 값(유형별 4개)을 HSL로 직접 계산해 평균 (반올림)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>밝기(L)</th><th>선명도(S)</th></tr></thead><tbody><tr><td>봄 웜</td><td>61</td><td>100</td></tr><tr><td>여름 쿨</td><td>75</td><td>37</td></tr><tr><td>가을 웜</td><td>43</td><td>49</td></tr><tr><td>겨울 쿨</td><td>39</td><td>91</td></tr></tbody></table></div></details></figure>
<h2 id="s4">집에서 하는 퍼스널컬러 자가진단 6단계</h2>
<p>전문 진단에서는 보통 수십 장의 색 천을 얼굴 아래에 대 보며 비교해요. 집에서는 그만큼 정밀할 수 없지만, 조건을 맞추면 웜·쿨 정도는 꽤 안정적으로 볼 수 있어요.</p>
<ol><li><strong>조명 맞추기</strong>: 해가 직접 들지 않는 낮 시간, 창가의 자연광 아래가 가장 좋아요. 노란 전구색 조명이나 형광등 아래에서는 피부색이 실제와 다르게 보여요.</li><li><strong>맨얼굴 준비</strong>: 파운데이션과 립을 지우고, 머리를 염색했다면 흰 수건이나 머리띠로 가려요. 하얀 옷이나 무채색 옷을 입으면 옷 색의 영향도 줄일 수 있어요.</li><li><strong>흰 종이 vs 아이보리 종이</strong>: 새하얀 복사 용지와 아이보리색 종이를 번갈아 턱 아래에 대 봐요. 새하얀 쪽에서 얼굴이 맑아 보이면 쿨, 아이보리 쪽이 편안해 보이면 웜 쪽이에요.</li><li><strong>금색 vs 은색 액세서리</strong>: 금색과 은색 목걸이나 천을 번갈아 대 봐요. 금색에서 혈색이 살면 웜, 은색에서 깨끗해 보이면 쿨일 가능성이 높아요.</li><li><strong>주황 vs 분홍 천</strong>: 도어의 컬러 키 체계에서는 주황을 노란 기(웜), 자홍(마젠타)을 푸른 기(쿨)의 지표 색으로 썼어요. 비슷한 밝기의 주황 천과 분홍 천을 대 보면 차이가 잘 드러나요.</li><li><strong>밝기·선명도 확인</strong>: 온도를 정했다면 같은 온도 안에서 밝은 파스텔과 진한 색, 쨍한 원색과 탁한 색을 비교해 계절을 좁혀요.</li></ol>
<p>비교할 때는 "어느 쪽이 예쁜가"보다 <strong>"다크서클·잡티·붉은 기가 어느 쪽에서 덜 보이는가"</strong>, <strong>"얼굴과 옷 중 어디에 먼저 눈이 가는가"</strong>를 보는 게 좋아요. 잘 맞는 색은 얼굴이 먼저 보이고, 안 맞는 색은 옷 색이 먼저 튀어 보여요. 혼자 판단하기 어렵다면 사진을 찍어 두고 친구에게 어느 쪽이 나은지 물어보는 것도 방법이에요. 다만 사진은 아래에서 설명하는 카메라 보정이 개입하니 실물과 함께 비교해요.</p>
<aside class="callout tip"><b class="callout-h">팁</b><p>손목 혈관이 초록빛이면 웜, 파란빛이면 쿨이라는 방법도 많이 쓰이지만, 혈관 색은 피부 두께와 조명의 영향을 크게 받아요. 다른 방법과 결과가 다르면 천이나 종이를 대 보는 비교 쪽을 더 믿는 게 좋아요.</p></aside>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">퍼스널컬러 자가진단 결과가 자꾸 바뀌는 이유</h2>
<p>같은 사람이 테스트를 할 때마다 봄이 나왔다가 가을이 나왔다가 하는 건 흔한 일이에요. 가장 큰 이유는 <strong>빛</strong>이에요. 조명은 <a href="https://en.wikipedia.org/wiki/Color_temperature" target="_blank" rel="noopener">색온도</a>에 따라 노란빛(낮은 색온도)부터 푸른빛(높은 색온도)까지 다양해서, 전구색 조명 아래에서는 누구나 웜톤처럼, 푸른빛이 도는 조명 아래에서는 쿨톤처럼 보이기 쉬워요.</p>
<p><strong>카메라와 화면</strong>도 변수예요. 스마트폰 카메라는 자동 화이트밸런스로 색을 보정하고, 화면마다 색 표현이 달라요. 셀카로 하는 자가진단이나 앱 진단이 실물과 다르게 나오는 이유예요. <strong>피부 상태</strong>도 영향을 줘요. 햇볕에 타거나, 붉은 기·건조함이 심하거나, 화장품이 남아 있으면 바탕색이 가려져요. 머리 염색도 얼굴 전체의 인상을 바꿔서 계절 판단을 흔들어요.</p>
<p>체계 자체의 한계도 있어요. 위키백과 문서에 정리된 2007년 연구는 세 가지 퍼스널컬러 체계를 비교해, 색상·명도·채도를 모두 쓴다고 하면서도 실제 팔레트 다수가 두 가지 속성만 활용하는 등 체계마다 기준이 일관되지 않다고 지적했어요. 일본 인플루언서들의 립스틱 분류를 분석한 최근 연구에서는 웜·쿨 구분이 측정 가능한 색 특성과 비교적 잘 맞았지만 사계절 구분은 그보다 덜 일치했다는 결과도 소개돼 있어요. 그래서 <strong>웜·쿨을 먼저 확실히 하고, 계절은 넓은 참고 범위로</strong> 보는 게 현실적이에요.</p>
<h2 id="s6">퍼스널컬러 결과 활용법: 어디에 쓰면 효과가 클까</h2>
<p>퍼스널컬러가 가장 크게 작용하는 곳은 <strong>얼굴과 가까운 부분</strong>이에요. 립·블러셔 같은 색조 화장, 상의와 머플러, 안경테, 귀걸이, 머리 염색 색이 여기에 해당해요. 바지나 신발, 가방처럼 얼굴과 먼 곳은 퍼스널컬러와 상관없이 좋아하는 색을 써도 인상에 큰 차이가 없어요.</p>
<div class="table-wrap"><table><thead><tr><th>활용 위치</th><th>영향</th><th>활용 팁</th></tr></thead><tbody><tr><td>립·블러셔</td><td>큼</td><td>같은 빨강이라도 웜은 오렌지 레드, 쿨은 블루 레드 쪽을 비교</td></tr><tr><td>상의·머플러</td><td>큼</td><td>안 맞는 색을 입고 싶으면 목 주변에 맞는 색 스카프를 더해요</td></tr><tr><td>안경테·귀걸이</td><td>중간</td><td>금속은 금색·은색, 뿔테는 갈색·검정으로 온도 맞추기</td></tr><tr><td>머리 염색</td><td>큼</td><td>웜은 브라운·오렌지 계열, 쿨은 애쉬·블랙 계열을 먼저 비교</td></tr><tr><td>하의·신발</td><td>작음</td><td>퍼스널컬러보다 전체 코디 균형을 우선</td></tr></tbody></table></div>
<p>퍼스널컬러가 "이 색은 입으면 안 된다"는 규칙은 아니에요. 좋아하는 색이 맞지 않는 계절에 속해도 채도나 명도를 조금 조정하거나, 얼굴과 먼 곳에 쓰면 충분히 즐길 수 있어요. 내가 왜 그 색에 끌리는지는 <a href="https://tool.smithlab.co.kr/survey/color-personality/">컬러 성격 테스트</a>로, 평소 옷 입는 스타일은 <a href="https://tool.smithlab.co.kr/survey/outfit-style/">패션 스타일 테스트</a>로 함께 확인해 보면 재미있어요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">퍼스널컬러에 대한 흔한 오해</h2>
<p><strong>"피부가 하얀 사람은 쿨톤이다"</strong>는 가장 흔한 오해예요. 앞에서 말했듯 피부 밝기와 온도는 별개예요. 하얀 피부의 봄 웜도, 어두운 피부의 겨울 쿨도 얼마든지 있어요.</p>
<p><strong>"한 번 정해진 계절은 평생 같다"</strong>는 주장도 있어요. 초기 계절 분석 책들은 태닝을 해도 계절은 바뀌지 않는다고 봤지만, 나이가 들면서 머리카락과 피부의 대비가 줄거나 염색을 하면 잘 받는 색이 달라질 수 있어요. 결과를 한 번 받고 끝내기보다 몇 년에 한 번쯤 다시 비교해 보는 게 좋아요.</p>
<p><strong>"전문 진단은 무조건 정확하다"</strong>도 조심해야 해요. 퍼스널컬러 진단에는 표준화된 자격이나 공인 기준이 따로 없어서, 진단하는 곳의 체계와 조명에 따라 결과가 다를 수 있어요. 전문 진단도 하나의 의견으로 받아들이고, 실제로 입고 발랐을 때 주변 반응과 내 만족도를 함께 보는 게 가장 확실해요.</p>
<h2 id="s8">자주 묻는 질문</h2>
<h3>웜톤 쿨톤을 가장 간단하게 구별하는 방법은 뭔가요?</h3>
<p>흐린 날 창가의 자연광 아래에서 화장을 지우고, 새하얀 종이와 아이보리색 종이를 턱 아래에 번갈아 대 보세요. 새하얀 쪽에서 얼굴이 맑아 보이면 쿨, 아이보리 쪽에서 편안해 보이면 웜일 가능성이 높아요. 금색과 은색 액세서리로 한 번 더 비교하면 더 확실해요.</p>
<h3>뉴트럴톤도 있나요?</h3>
<p>네, 웜과 쿨 어느 쪽 특징도 강하지 않은 경우를 흔히 뉴트럴톤이라고 불러요. 이런 경우 온도보다 밝기·선명도가 더 중요하게 작용하는 경우가 많아요. 두 계절 사이에 걸쳐 있다면 양쪽 팔레트에서 공통으로 잘 받는 색부터 활용하면 돼요.</p>
<h3>퍼스널컬러 앱이나 사진 진단은 믿을 만한가요?</h3>
<p>참고용으로는 괜찮지만 정확도에는 한계가 있어요. 스마트폰 카메라는 자동으로 색을 보정하고, 촬영 조명과 화면에 따라 피부색이 달라 보여요. 앱 결과는 방향을 잡는 용도로 쓰고, 실물로 천이나 옷을 대 보며 확인하는 걸 권해요.</p>
<h3>계절 유형이 두 개 사이에 걸쳐 나오면 어떻게 하나요?</h3>
<p>계절 분류를 처음 대중화한 책에서도 두 계절 사이에 걸친 사람이 있다고 설명해요. 이럴 땐 웜·쿨 중 확실한 쪽을 먼저 정하고, 밝기나 선명도는 두 계절의 공통 영역을 쓰면 실패가 적어요. 요즘 많이 쓰는 '봄 브라이트', '여름 라이트' 같은 세부 유형도 이런 경계를 나누려는 시도예요.</p>
<h3>퍼스널컬러 테스트 결과로 피부 상태를 알 수 있나요?</h3>
<p>아니에요. 퍼스널컬러는 어울리는 색을 고르는 미용·패션 방법일 뿐, 피부 건강이나 질환을 판단하는 검사가 아니에요. 피부색이 갑자기 변하거나 발진·착색이 생겼다면 테스트가 아니라 피부과 진료를 받는 게 맞아요.</p>
<h2 id="s9">정리</h2>
<p>퍼스널컬러는 피부 바탕색의 온도(웜·쿨)와 잘 받는 색의 밝기·선명도를 조합해 봄 웜, 여름 쿨, 가을 웜, 겨울 쿨로 나누는 방법이에요. 슈브뢸의 색 대비, 먼셀의 색 속성, 이텐의 계절 비유에서 출발해 1980년대에 대중화됐어요. 자가진단은 자연광, 맨얼굴, 흰 종이·금은 액세서리·주황 분홍 천 비교 순서로 하면 덜 흔들리고, 사계절보다 웜·쿨 구분이 더 안정적이라는 점을 기억해 두면 좋아요.</p>
<p><a href="https://tool.smithlab.co.kr/survey/personal-color/">퍼스널컬러 자가진단 테스트</a>로 내 유형을 가볍게 확인한 뒤, 실제 옷과 립으로 비교해 보세요. 테스트 결과가 왜 이렇게 잘 맞는 것처럼 느껴지는지 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/survey/why-personality-tests-feel-accurate/">성격 테스트가 잘 맞는 것처럼 느껴지는 이유</a>도 함께 읽어 볼 만해요.</p>
    ]]></description>
    </item>
    <item>
      <title>보험료 세액공제 2026 — 실손·자동차보험 연 100만원 12% 한도…</title>
      <link>https://tool.smithlab.co.kr/posts/money/insurance-premium-tax-credit-2026-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/insurance-premium-tax-credit-2026-guide/</guid>
      <pubDate>Tue, 10 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>결론부터 말하면 <strong>일반 보장성 보험료는 연 100만 원까지의 12%, 최대 12만 원</strong>이에요. 지방소득세까지 합쳐도 13만 2,000원이 상한이라서, 보험료를 많이 낸다고 환급이 계속 늘어나지는 않아요.</p>
<p>그래서 핵심은 "한도를 채웠는가"와 <strong>"표준세액공제 13만 원과 비교해 무엇이 유리한가"</strong>인데, 여기서 흔한 설명이 자주 틀려요. 표준세액공제를 고르면 보험료 12만 원만 포기하는 게 아니라 <strong>건강보험·고용보험료 소득공제까지 같이 포기</strong>해야 해서, 이 글이 직접 계산한 사례에서는 총급여 4,000만 원 이상이면 보험료가 0원이어도 항목별 공제가 유리했고, 3,000만 원이면 보험료가 연 약 25만 원(월 약 2만 원)만 넘어도 항목별이 유리했어요(기준: 2026년 귀속분, 기준일 2026-11-10). 바로 계산해 보고 싶다면 <a href="https://tool.smithlab.co.kr/money/insurance-premium-tax-credit-calculator/">보장성 보험료 세액공제 계산기</a>에 연간 납입액만 넣으면 돼요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>일반 보장성 보험료는 연 100만 원 한도에 12%라 최대 12만 원, 장애인전용 보장성 보험료는 연 100만 원 한도에 15%라 최대 15만 원이에요. 두 한도는 따로 적용돼요.</li><li>지방소득세 10%가 더 줄어서 합계 상한은 일반 13만 2,000원, 둘 다 채우면 29만 7,000원이에요.</li><li>월 약 8만 3,334원(연 100만 원)을 넘게 내도 공제는 같아요. 공제 때문에 보험을 더 들 이유는 거의 없어요.</li><li>표준세액공제 13만 원은 건강·고용보험료 소득공제, 보험료·의료비·교육비·기부금 세액공제, 월세 세액공제를 모두 신청하지 않을 때만 받아요. 그래서 "보험료 12만 원 &lt; 13만 원"으로 비교하면 틀리고, 건강·고용보험료 소득공제의 절세액까지 더해서 비교해야 해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">보장성 보험료 세액공제 구조 (2026년 기준)</h2>
<p>연말정산에서 보험과 관련된 공제는 크게 세 가지예요. 이 중 사람이 직접 가입한 보험에 해당하는 것이 이 글의 주제예요.</p>
<div class="table-wrap"><table><thead><tr><th>구분</th><th>성격</th><th>내용</th></tr></thead><tbody><tr><td>국민건강보험료·고용보험료 등</td><td>소득공제</td><td>월급에서 자동으로 빠지는 보험료를 소득에서 빼요</td></tr><tr><td><strong>일반 보장성 보험료</strong></td><td><strong>세액공제</strong></td><td><strong>연 100만 원 한도, 12%</strong></td></tr><tr><td><strong>장애인전용 보장성 보험료</strong></td><td><strong>세액공제</strong></td><td><strong>연 100만 원 한도, 15%</strong></td></tr><tr><td>연금저축·IRP</td><td>세액공제(연금계좌)</td><td>별도 한도와 공제율 (보장성 보험과 별개)</td></tr></tbody></table></div>
<p>소득공제는 소득에서 빼기 때문에 세율이 높을수록 효과가 커요. 반면 세액공제는 계산된 세금에서 직접 빼므로 <strong>연봉이 높든 낮든 같은 금액</strong>이 줄어요. 보험료 세액공제 12만 원은 세율 6%인 사람에게나 24%인 사람에게나 똑같아요. 이 구분은 <a href="https://tool.smithlab.co.kr/posts/money/child-tax-credit-2026-dependents-guide/">가족 공제를 정리한 글</a>에서도 같은 방식으로 설명했어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/insurance-premium-tax-credit-2026-guide-1.svg" alt="보험료 세액공제 계산 구조: 일반 보장성 100만 원 한도 12%, 장애인전용 100만 원 한도 15%, 지방소득세 10% 추가" width="800" height="450" loading="lazy" decoding="async"><figcaption>보험료 세액공제 = min(보험료, 100만 원) × 공제율, 지방소득세는 별도로 10%</figcaption></figure>
<h2 id="s2">계산 공식과 직접 계산한 예시</h2>
<p>공식은 단순해요.</p>
<ul><li><strong>일반 보장성 세액공제 = min(일반 보험료, 100만 원) × 12%</strong></li><li><strong>장애인전용 세액공제 = min(장애인전용 보험료, 100만 원) × 15%</strong></li><li><strong>지방소득세 = 소득세 공제액 × 10%</strong></li></ul>
<h3>예시 1: 월 8만 원을 12개월 내는 경우</h3>
<ol><li>연간 보험료: 8만 원 × 12 = 96만 원 (한도 100만 원 이내)</li><li>소득세 세액공제: 96만 × 12% = <strong>11만 5,200원</strong></li><li>지방소득세: 1만 1,520원</li><li>합계: <strong>12만 6,720원</strong></li></ol>
<h3>예시 2: 실손보험 월 3만 원 + 자동차보험 연 80만 원</h3>
<ol><li>실손보험: 3만 원 × 12 = 36만 원</li><li>자동차보험: 80만 원 → 합계 116만 원</li><li>한도 100만 원까지만 인정: 100만 × 12% = <strong>12만 원</strong></li><li>한도를 넘은 16만 원은 공제되지 않아요. 지방소득세 포함 13만 2,000원이에요.</li></ol>
<h3>예시 3: 장애인전용 보험이 있는 경우</h3>
<p>일반 보장성 100만 원과 장애인전용 100만 원을 모두 한도까지 내면 12만 원 + 15만 원 = <strong>27만 원</strong>, 지방소득세 2만 7,000원을 더해 <strong>29만 7,000원</strong>이에요. 장애인전용 보험은 일반 보험과 한도가 따로 계산돼요.</p>
<h2 id="s3">월 보험료별 공제액 (직접 계산표)</h2>
<p>월 납입액별로 공제액이 어떻게 달라지는지 계산한 표예요. 12개월 납입, 일반 보장성 보험 기준이에요.</p>
<div class="table-wrap"><table><thead><tr><th>월 보험료</th><th>연간 납입</th><th>소득세 공제</th><th>지방소득세 포함</th><th>한도 초과분</th></tr></thead><tbody><tr><td>3만 원</td><td>36만 원</td><td>4만 3,200원</td><td>4만 7,520원</td><td>0원</td></tr><tr><td>5만 원</td><td>60만 원</td><td>7만 2,000원</td><td>7만 9,200원</td><td>0원</td></tr><tr><td>8만 원</td><td>96만 원</td><td>11만 5,200원</td><td>12만 6,720원</td><td>0원</td></tr><tr><td>10만 원</td><td>120만 원</td><td>12만 원</td><td>13만 2,000원</td><td>20만 원</td></tr><tr><td>15만 원</td><td>180만 원</td><td>12만 원</td><td>13만 2,000원</td><td>80만 원</td></tr><tr><td>20만 원</td><td>240만 원</td><td>12만 원</td><td>13만 2,000원</td><td>140만 원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">월 보험료별 보험료 세액공제 (소득세 기준)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="월 보험료별 보험료 세액공제 (소득세 기준)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">6</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">8</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">12</text><rect class="s1" x="69.3" y="169.0" width="80.6" height="85.0" rx="3"><title>월 3만 원 · 세액공제 4.3만원</title></rect><text class="viz-val" x="109.6" y="164.0" text-anchor="middle">4.3</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">월 3만 원</text><rect class="s1" x="184.5" y="112.4" width="80.6" height="141.6" rx="3"><title>월 5만 원 · 세액공제 7.2만원</title></rect><text class="viz-val" x="224.8" y="107.4" text-anchor="middle">7.2</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">월 5만 원</text><rect class="s1" x="299.7" y="27.4" width="80.6" height="226.6" rx="3"><title>월 8만 원 · 세액공제 11.5만원</title></rect><text class="viz-val" x="340.0" y="22.4" text-anchor="middle">11.5</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">월 8만 원</text><rect class="s1" x="414.9" y="18.0" width="80.6" height="236.0" rx="3"><title>월 10만 원 · 세액공제 12만원</title></rect><text class="viz-val" x="455.2" y="13.0" text-anchor="middle">12</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">월 10만 원</text><rect class="s1" x="530.1" y="18.0" width="80.6" height="236.0" rx="3"><title>월 15만 원 · 세액공제 12만원</title></rect><text class="viz-val" x="570.4" y="13.0" text-anchor="middle">12</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">월 15만 원</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">월 약 8만 3,334원을 넘으면 공제액이 12만 원에서 멈춰요.</p><div class="viz-cap">단위: 만원 · 자료: 연 100만 원 한도·12%로 직접 계산 (12개월 납입, 일반 보장성 기준)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>세액공제</th></tr></thead><tbody><tr><td>월 3만 원</td><td>4.3</td></tr><tr><td>월 5만 원</td><td>7.2</td></tr><tr><td>월 8만 원</td><td>11.5</td></tr><tr><td>월 10만 원</td><td>12</td></tr><tr><td>월 15만 원</td><td>12</td></tr></tbody></table></div></details></figure>
<p>표에서 보듯 월 10만 원부터는 공제액이 같아요. 보험료가 월 20만 원이어도 공제는 12만 원이라서, <strong>공제를 더 받으려고 보험을 늘리는 것은 계산상 이득이 아니에요.</strong> 오히려 한도 안에서 내는 보험료 1만 원당 환급은 1,200원(지방소득세 포함 1,320원)뿐이라, 내는 보험료의 약 87%는 공제로 돌아오지 않는 내 돈이에요. 공제는 보험을 유지하는 데 따라오는 작은 보너스로 보는 게 맞아요.</p>
<h2 id="s4">표준세액공제 13만 원과 비교: 흔한 계산이 틀리는 지점</h2>
<p>직장인은 특별소득공제(건강보험·고용보험료 등), 특별세액공제(보험료·의료비·교육비·기부금), 월세 세액공제를 하나도 신청하지 않으면 대신 <strong>표준세액공제 13만 원</strong>을 받아요. 흔히 "보험료 공제 12만 원이 13만 원보다 작으니 표준세액공제가 낫다"고 설명하는데, 이건 건강·고용보험료 소득공제를 같이 잃는다는 점을 빼먹은 계산이에요.</p>
<p>아래는 <a href="https://tool.smithlab.co.kr/posts/money/year-end-tax-refund-calculation/">연말정산 환급액 계산 글</a>과 같은 가정(미혼, 총급여의 38%를 카드 등으로 사용, 2026년 귀속 세율·근로소득세액공제 한도)으로 두 방식의 결정세액을 직접 계산해 비교한 표예요. '차이'는 지방소득세를 포함해 항목별 공제가 얼마나 유리한지예요(음수면 표준세액공제가 유리).</p>
<div class="table-wrap"><table><thead><tr><th>총급여</th><th>건강·고용보험료(본인)</th><th>보험료 0원</th><th>보험료 연 30만 원</th><th>보험료 연 60만 원</th><th>보험료 연 100만 원</th></tr></thead><tbody><tr><td>3,000만 원</td><td>약 149만 원</td><td>표준이 약 3만 2천 원 유리</td><td>항목별이 약 7천 원 유리</td><td>항목별이 약 4만 7천 원 유리</td><td>항목별이 약 10만 원 유리</td></tr><tr><td>4,000만 원</td><td>약 199만 원</td><td>항목별이 약 18만 5천 원 유리</td><td>항목별이 약 22만 4천 원 유리</td><td>항목별이 약 26만 4천 원 유리</td><td>항목별이 약 31만 7천 원 유리</td></tr><tr><td>5,000만 원</td><td>약 248만 원</td><td>항목별이 약 26만 7천 원 유리</td><td>항목별이 약 30만 6천 원 유리</td><td>항목별이 약 34만 6천 원 유리</td><td>항목별이 약 39만 9천 원 유리</td></tr></tbody></table></div>
<p>판단 기준은 이래요.</p>
<ul><li><strong>총급여 4,000만 원 이상</strong>: 건강·고용보험료 소득공제만으로 세금이 13만 원보다 많이 줄어서, 보험료가 0원이어도 항목별 공제가 유리했어요.</li><li><strong>총급여 3,000만 원 안팎</strong>: 산출세액이 작아 근로소득세액공제(55%)에 상당 부분이 먼저 상쇄되기 때문에 건강·고용보험료 공제의 효과가 약 10만 원으로 작아요. 이 사례에서는 보장성 보험료가 <strong>연 약 25만 원(월 약 2만 원)</strong>을 넘으면 항목별이 유리해졌어요.</li><li><strong>결정세액이 이미 0원 근처인 사람</strong>: 어느 쪽을 골라도 줄일 세금이 없어서 결과가 같을 수 있어요.</li></ul>
<p>아래는 이해를 돕기 위한 <strong>가상의 사회초년생</strong> 예시예요. 총급여 3,000만 원, 미혼, 실손보험 월 3만 원(연 36만 원)만 있다고 해 볼게요. 표준세액공제를 고르면 결정세액은 약 40만 4,600원, 항목별(건강·고용보험료 소득공제 + 보험료 세액공제 4만 3,200원)이면 약 39만 800원이라 항목별이 지방소득세 포함 약 1만 5천 원 유리해요. 같은 사람이 보험이 하나도 없다면 반대로 표준세액공제가 약 3만 2천 원 유리해요.</p>
<p>실무에서는 회사 연말정산 프로그램이나 홈택스가 유리한 쪽을 계산해 주는 경우가 많지만, 의료비·교육비가 있다면 <a href="https://tool.smithlab.co.kr/money/medical-expense-tax-credit-calculator/">의료비 세액공제 계산기</a>나 <a href="https://tool.smithlab.co.kr/money/education-tax-credit-calculator/">교육비 세액공제 계산기</a>로 먼저 확인해 두면 비교가 쉬워요.</p>
<aside class="callout tip"><b class="callout-h">참고</b><p>월세 세액공제도 신청하면 표준세액공제를 받을 수 없는 항목이에요. 반면 연금계좌(연금저축·IRP)·자녀 세액공제는 표준세액공제와 함께 받을 수 있어요. 월세 공제 금액은 <a href="https://tool.smithlab.co.kr/real-estate/monthly-rent-tax-credit-calculator/">월세 세액공제 계산기</a>로 따로 확인하세요.</p></aside>
<h2 id="s5">대상이 되는 보험과 제외되는 보험</h2>
<p>보장성 보험은 <strong>만기에 돌려받는 금액이 없거나, 있어도 낸 보험료를 넘지 않는 보험</strong>이에요. 이 구분이 가장 자주 헷갈려요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/insurance-premium-tax-credit-2026-guide-2.svg" alt="보험료 공제 판단 흐름: 보장성이면 공제, 저축성이면 제외, 피보험자 요건 확인" width="800" height="450" loading="lazy" decoding="async"><figcaption>보장성이면 대상, 저축성이면 제외 — 연금저축·IRP는 별도 항목</figcaption></figure>
<ul><li><strong>대상</strong>: 실손의료보험, 암·질병·상해보험, 종신·정기보험, 자동차보험, 화재·배상책임 같은 손해보험.</li><li><strong>제외</strong>: 연금보험·저축보험처럼 만기환급금이 납입보험료를 넘는 저축성 보험.</li><li><strong>연금저축·IRP</strong>: 보험처럼 보여도 연금계좌 세액공제 항목이라 이 계산에 넣지 않아요. <a href="https://tool.smithlab.co.kr/money/irp-tax-credit-calculator/">IRP 세액공제 계산기</a>에서 따로 확인하세요.</li><li><strong>건강보험료·고용보험료</strong>: 월급에서 빠지는 건강보험·고용보험·노인장기요양보험료는 소득공제라 12% 세액공제에 넣지 않아요.</li><li><strong>피보험자</strong>: 근로자 본인이나 기본공제대상자(배우자, 요건을 갖춘 자녀·부모 등)가 피보험자인 보험료가 대상이에요. 나이·소득 요건을 못 맞추는 가족의 보험료는 빠질 수 있어서 <a href="https://tool.smithlab.co.kr/money/dependent-deduction-calculator/">부양가족 공제 계산기</a>의 요건과 함께 보세요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">부부·가족 단위로 따질 때</h2>
<p>부부가 맞벌이라면 <strong>보험료를 실제로 낸 사람 기준으로</strong> 공제를 받아요. 각자 한도 100만 원을 모두 쓰려면 각자 계약하고 각자 낸 보험료가 있어야 해요. 한 사람이 가족 보험료를 전부 내면 그 사람의 한도 100만 원까지만 인정돼요.</p>
<p>예를 들어 부부가 모두 직장인이고 합쳐서 연 200만 원의 보험료를 낸다고 할 때, 한쪽이 전부 내면 공제는 12만 원, 100만 원씩 따로 내면 각자 12만 원씩 합계 24만 원이에요. 다만 이를 위해 계약 명의를 바꾸는 것은 보험 보장 내용과 계약 조건에 영향을 줄 수 있으니, <strong>세금 때문에 계약을 바꾸기보다 가입 단계에서 누가 낼지 정하는 정도</strong>로 보는 것이 안전해요.</p>
<h2 id="s7">연말정산에서 이렇게 챙기세요</h2>
<ol><li><strong>홈택스 연말정산 간소화에서 보험료 자료를 조회</strong>해요. 자동차보험이나 일부 보험사 자료는 빠질 수 있어 보험사 납입증명서가 필요할 수 있어요.</li><li><strong>피보험자와 가족 공제 요건을 확인</strong>해요. 요건이 안 맞으면 해당 보험료가 빠져요.</li><li><strong>다른 특별세액공제 합계를 먼저 계산</strong>하고 표준세액공제 13만 원과 비교해요.</li><li><strong>저축성 보험이 섞이지 않았는지</strong> 확인해요. 상품 이름이 비슷해도 만기환급금 구조가 다를 수 있어요.</li><li>연말정산 전체 흐름은 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>에서 한 번에 확인하세요.</li></ol>
<p>제도 개편 사항은 정책브리핑의 <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148956909" target="_blank" rel="noopener">2025 연말정산, 작년과 뭐가 달라졌을까?</a>처럼 매년 안내가 나와요. 보험료 공제율과 한도는 <a href="https://www.banksalad.com/articles/%EB%B3%B4%ED%97%98%EB%A3%8C-%EC%84%B8%EC%95%A1%EA%B3%B5%EC%A0%9C-%EC%97%B0%EA%B8%88%EC%A0%80%EC%B6%95-%EC%97%B0%EB%A7%90%EC%A0%95%EC%82%B0-%EC%84%B8%EC%95%A1%EA%B3%B5%EC%A0%9C-%ED%95%9C%EB%8F%84" target="_blank" rel="noopener">뱅크샐러드 보험료 세액공제 정리</a>와 <a href="https://wikidocs.net/blog/@TaxSave/28707/" target="_blank" rel="noopener">보장성 보험료 세액공제 조건</a> 등 여러 해설을 교차 확인해 정리했고, 최종 금액은 홈택스 연말정산 결과를 기준으로 하세요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 하는 실수</h2>
<ul><li><strong>월 보험료가 많을수록 공제가 늘어난다고 생각하는 실수</strong>: 연 100만 원을 넘는 부분은 공제되지 않아요.</li><li><strong>저축성 보험·연금보험을 넣는 실수</strong>: 만기환급금이 보험료를 넘는 보험은 제외예요.</li><li><strong>건강보험료를 12% 공제로 계산하는 실수</strong>: 월급에서 자동으로 빠지는 보험료는 소득공제로 따로 처리돼요.</li><li><strong>표준세액공제를 보험료 12만 원과만 비교하는 실수</strong>: 표준세액공제를 고르면 건강·고용보험료 소득공제도 같이 포기해서, 총급여 4,000만 원 이상이면 대부분 항목별이 유리해요.</li><li><strong>지방소득세를 빼먹는 실수</strong>: 소득세 공제의 10%가 더 줄어서 일반 보험은 13만 2,000원이 상한이에요.</li><li><strong>낼 세금이 없는데 환급을 기대하는 실수</strong>: 세액공제는 이미 낸 세금(결정세액)에서 빼는 방식이라 결정세액이 0이면 돌려받을 돈이 없어요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>보험료가 적으면 표준세액공제 13만 원이 더 낫지 않나요?</h3>
<p>보험료 12만 원과 13만 원만 비교하면 그렇게 보이지만, 표준세액공제를 고르면 건강·고용보험료 소득공제도 빠져요. 이 글 계산으로 총급여 4,000만 원 이상은 보험료가 0원이어도 항목별이 18만 원 이상 유리했고, 3,000만 원은 보험료가 연 약 25만 원을 넘으면 항목별이 유리했어요.</p>
<h3>보장성 보험료 세액공제는 최대 얼마인가요?</h3>
<p>일반 보장성 보험료는 연 100만 원까지의 12%라 최대 12만 원이고, 지방소득세까지 합치면 13만 2,000원이에요. 장애인전용 보장성 보험료는 연 100만 원까지의 15%라 최대 15만 원(지방소득세 포함 16만 5,000원)이에요.</p>
<h3>월 보험료가 얼마여야 한도를 채우나요?</h3>
<p>100만 원을 12개월로 나눈 월 약 8만 3,334원이에요. 이 이상 내면 공제는 12만 원에서 멈춰요.</p>
<h3>자동차보험도 공제되나요?</h3>
<p>네, 자동차보험은 보장성 손해보험이라 일반 보장성 보험료에 합쳐서 100만 원 한도 안에서 공제돼요. 간소화 자료에서 빠지면 보험사 납입증명서를 확인하세요.</p>
<h3>보험료 공제를 신청하면 표준세액공제는 못 받나요?</h3>
<p>건강·고용보험료 소득공제, 보험료·의료비·교육비·기부금 세액공제, 월세 세액공제 중 하나라도 신청하면 표준세액공제 13만 원은 적용되지 않아요. 비교할 때는 보험료 공제만이 아니라 건강·고용보험료 소득공제로 줄어드는 세금까지 더해서 13만 원과 견줘야 해요.</p>
<h3>부부가 함께 가입한 보험은 누구 공제인가요?</h3>
<p>보험료를 실제로 낸 사람 기준이에요. 한 사람이 전부 내면 그 사람 한도(100만 원)만 적용되고, 각자 낸 부분이 있으면 각자 한도를 써요.</p>
<h2 id="s10">정리</h2>
<p>보장성 보험료 세액공제는 <strong>연 100만 원 한도, 12%(장애인전용 15%)</strong>로 규모가 작고, 한도는 월 약 8만 3,334원이면 채워요. 환급을 늘리려고 보험을 더 드는 것은 계산상 이득이 아니고, 표준세액공제 13만 원과의 비교가 오히려 더 중요해요. 연간 보험료를 넣어 바로 확인하려면 <a href="https://tool.smithlab.co.kr/money/insurance-premium-tax-credit-calculator/">보장성 보험료 세액공제 계산기</a>를, 연말정산 전체를 보려면 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>와 <a href="https://tool.smithlab.co.kr/money/donation-tax-credit-calculator/">기부금 세액공제 계산기</a>를 함께 활용하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>자녀세액공제 2026 — 1명 25만·2명 55만·3명 95만 원…</title>
      <link>https://tool.smithlab.co.kr/posts/money/child-tax-credit-2026-dependents-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/child-tax-credit-2026-dependents-guide/</guid>
      <pubDate>Mon, 09 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연말정산 시즌이면 "아이 둘 있으면 세금이 얼마나 줄어요?"라는 질문이 가장 먼저 나와요. 결론부터 말하면 <strong>가족 공제는 두 갈래</strong>예요. 가족 1명당 150만 원을 소득에서 빼 주는 기본공제(소득공제)와, 8세 이상 20세 이하 자녀가 있을 때 세금에서 바로 빼 주는 자녀세액공제예요. 자녀세액공제는 <strong>1명 25만 원, 2명 55만 원, 3명 95만 원</strong>이고 3명째부터 1명당 40만 원이 더해져요.</p>
<p>맞벌이라면 판단 기준은 한 줄이에요. <strong>부양가족 1명을 세율 24% 쪽으로 옮기면 15% 쪽보다 지방소득세 포함 148,500원 더 줄고</strong>, 자녀세액공제는 누가 받든 같아요. 이 글에는 가족 구성별 절세액 표, 세율 차이별 1인당 손익표, 가상 맞벌이 가구를 처음부터 끝까지 계산한 예시, 다른 설명에서 자주 틀리는 가정(세율 구간 경계, 결정세액 한도)을 담았어요. 내 가족 구성으로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/dependent-deduction-calculator/">부양가족 공제 계산기</a>에 배우자·부모·자녀 수를 넣으면 돼요. 이 글의 숫자는 같은 식으로 파이썬으로 따로 계산해 확인했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>기본공제는 본인과 가족 1인당 150만 원이고, 70세 이상 100만 원·장애인 200만 원이 추가돼요.</li><li>자녀세액공제는 8세 이상 20세 이하 자녀 기준 1명 25만 원, 2명 55만 원, 3명 95만 원이에요.</li><li>올해 출생·입양 신고한 자녀는 첫째 30만 원, 둘째 50만 원, 셋째 이상 70만 원이 따로 공제돼요.</li><li>같은 가족을 부부 둘이 중복해서 공제받을 수 없고, 소득이 기준을 넘는 가족은 대상에서 빠져요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">가족 공제의 구조: 소득공제와 세액공제 두 갈래</h2>
<p>연말정산에서 가족과 관련된 공제는 성격이 다른 두 종류가 섞여 있어서 헷갈려요.</p>
<div class="table-wrap"><table><thead><tr><th>종류</th><th>공제 방식</th><th>금액</th><th>환급 계산</th></tr></thead><tbody><tr><td>기본공제</td><td>소득공제</td><td>1인 150만 원</td><td>공제액 × 내 한계세율</td></tr><tr><td>경로우대 추가공제</td><td>소득공제</td><td>70세 이상 1인 100만 원</td><td>공제액 × 한계세율</td></tr><tr><td>장애인 추가공제</td><td>소득공제</td><td>1인 200만 원</td><td>공제액 × 한계세율</td></tr><tr><td>자녀세액공제</td><td>세액공제</td><td>1명 25만·2명 55만·3명 95만 원</td><td>금액 그대로 차감</td></tr><tr><td>출산·입양 공제</td><td>세액공제</td><td>첫째 30만·둘째 50만·셋째 70만 원</td><td>금액 그대로 차감</td></tr></tbody></table></div>
<p>소득공제는 세금을 계산하기 전에 소득을 줄여 주고, 세액공제는 계산된 세금에서 직접 빼 줘요. 그래서 소득공제의 환급은 세율에 따라 달라지고 세액공제는 세율과 관계없이 똑같아요. 여기에 소득세의 10%인 지방소득세가 함께 줄어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/child-tax-credit-2026-dependents-guide-1.svg" alt="가족 공제 구조: 기본공제 150만 원은 소득에서, 자녀세액공제는 세금에서 빼는 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>소득에서 빼는 공제와 세금에서 빼는 공제</figcaption></figure>
<h2 id="s2">기본공제 대상 조건: 나이와 소득</h2>
<p>기본공제는 가족이라고 모두 받는 것이 아니라 <strong>나이와 소득 두 가지 조건</strong>을 모두 채워야 해요.</p>
<div class="table-wrap"><table><thead><tr><th>구분</th><th>나이 기준</th><th>소득 기준</th></tr></thead><tbody><tr><td>본인</td><td>제한 없음</td><td>제한 없음</td></tr><tr><td>배우자</td><td>제한 없음</td><td>연 소득금액 100만 원 이하</td></tr><tr><td>직계존속(부모·조부모)</td><td>60세 이상</td><td>같은 소득 기준</td></tr><tr><td>직계비속(자녀·손자녀)</td><td>20세 이하</td><td>같은 소득 기준</td></tr><tr><td>장애인</td><td>나이 제한 없음</td><td>같은 소득 기준</td></tr></tbody></table></div>
<p>연 소득금액 100만 원은 근로소득만 있는 경우 <strong>총급여 500만 원 이하</strong>로 환산해서 보는 것이 일반적이에요. 사업소득·임대소득·이자와 배당·연금소득이 있으면 이들을 합친 소득금액이 기준이라 부모님 연금이 있는 경우 대상이 안 될 수 있어요. 소득이 기준을 넘는 가족은 기본공제도, 경로우대 같은 추가공제도 받을 수 없어요.</p>
<h2 id="s3">자녀세액공제: 8세 이상 20세 이하부터</h2>
<p>자녀세액공제는 <strong>기본공제 대상인 8세 이상 20세 이하 자녀</strong>를 기준으로 해요. 8세 미만 자녀는 기본공제(150만 원)만 받고 자녀세액공제는 받지 않아요.</p>
<p>금액은 2025년 귀속(2026년 초 연말정산)부터 1명 15만 원 → 25만 원, 2명 35만 원 → 55만 원으로 10만 원씩 올랐어요(기준일: 2026-11-10). 정리하면 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>자녀 수(8세 이상 20세 이하)</th><th>공제액</th><th>마지막 1명이 더하는 금액</th></tr></thead><tbody><tr><td>1명</td><td>250,000원</td><td>250,000원</td></tr><tr><td>2명</td><td>550,000원</td><td>300,000원</td></tr><tr><td>3명</td><td>950,000원</td><td>400,000원</td></tr><tr><td>4명</td><td>1,350,000원</td><td>400,000원</td></tr><tr><td>5명</td><td>1,750,000원</td><td>400,000원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">자녀 수별 자녀세액공제 (8세 이상 20세 이하)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="자녀 수별 자녀세액공제 (8세 이상 20세 이하)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">200</text><rect class="s1" x="69.3" y="224.5" width="80.6" height="29.5" rx="3"><title>1명 · 공제액 25만원</title></rect><text class="viz-val" x="109.6" y="219.5" text-anchor="middle">25</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">1명</text><rect class="s1" x="184.5" y="189.1" width="80.6" height="64.9" rx="3"><title>2명 · 공제액 55만원</title></rect><text class="viz-val" x="224.8" y="184.1" text-anchor="middle">55</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">2명</text><rect class="s1" x="299.7" y="141.9" width="80.6" height="112.1" rx="3"><title>3명 · 공제액 95만원</title></rect><text class="viz-val" x="340.0" y="136.9" text-anchor="middle">95</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">3명</text><rect class="s1" x="414.9" y="94.7" width="80.6" height="159.3" rx="3"><title>4명 · 공제액 135만원</title></rect><text class="viz-val" x="455.2" y="89.7" text-anchor="middle">135</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">4명</text><rect class="s1" x="530.1" y="47.5" width="80.6" height="206.5" rx="3"><title>5명 · 공제액 175만원</title></rect><text class="viz-val" x="570.4" y="42.5" text-anchor="middle">175</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">5명</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">8세 미만 자녀는 이 수에 세지 않아요.</p><div class="viz-cap">단위: 만원 · 자료: 1명 25만·2명 55만·3명째부터 1명당 40만 원으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>공제액</th></tr></thead><tbody><tr><td>1명</td><td>25</td></tr><tr><td>2명</td><td>55</td></tr><tr><td>3명</td><td>95</td></tr><tr><td>4명</td><td>135</td></tr><tr><td>5명</td><td>175</td></tr></tbody></table></div></details></figure>
<p>한 가지 조심할 점이 있어요. 일부 블로그에는 이전 연도 금액(1명 15만 원, 2명 35만 원)이 그대로 남아 있어서, 최신 금액과 섞여 보이는 글이 있어요. 신고 전에 반드시 올해 기준 안내와 홈택스를 한 번 더 확인하세요.</p>
<h2 id="s4">올해 출산·입양했다면 따로 받는 공제</h2>
<p>해당 연도에 출생·입양 신고를 한 자녀가 있으면 자녀세액공제와 별도로 <strong>출산·입양 세액공제</strong>가 있어요.</p>
<div class="table-wrap"><table><thead><tr><th>출생·입양 순서</th><th>공제액</th></tr></thead><tbody><tr><td>첫째</td><td>300,000원</td></tr><tr><td>둘째</td><td>500,000원</td></tr><tr><td>셋째 이상</td><td>700,000원</td></tr></tbody></table></div>
<p>올해 태어난 아이는 8세 미만이라 자녀세액공제와 겹치지 않고, 기본공제 150만 원(소득공제)과 출산·입양 공제를 함께 받아요. 다음 해에는 출산·입양 공제가 없어지고 기본공제만 남고, 아이가 8세가 되는 해부터 자녀세액공제가 시작돼요.</p>
<h2 id="s5">직접 계산: 가족 구성별로 세금이 얼마나 줄까</h2>
<p>본인 기본공제 150만 원을 포함해, 세율 15%와 24%일 때 가족 때문에 줄어드는 세금(지방소득세 포함)을 계산한 표예요. 소득공제 × 세율에 세액공제를 더하고 지방소득세 10%를 가산했어요.</p>
<div class="table-wrap"><table><thead><tr><th>가족 구성</th><th>공제 인원</th><th>소득공제 합계</th><th>세율 15%</th><th>세율 24%</th></tr></thead><tbody><tr><td>본인만</td><td>1명</td><td>1,500,000원</td><td>247,500원</td><td>396,000원</td></tr><tr><td>본인 + 자녀 1명(10세)</td><td>2명</td><td>3,000,000원</td><td>770,000원</td><td>1,067,000원</td></tr><tr><td>부부 + 자녀 2명(10세·13세)</td><td>4명</td><td>6,000,000원</td><td>1,595,000원</td><td>2,189,000원</td></tr><tr><td>부부 + 자녀 3명(5세·10세·13세)</td><td>5명</td><td>7,500,000원</td><td>1,842,500원</td><td>2,585,000원</td></tr><tr><td>부부 + 자녀 2명 + 70세 이상 부모 1명</td><td>5명</td><td>8,500,000원</td><td>2,007,500원</td><td>2,849,000원</td></tr><tr><td>부부 + 자녀 2명 + 올해 셋째 출산</td><td>5명</td><td>7,500,000원</td><td>2,612,500원</td><td>3,355,000원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">가족 구성별 세금 감소 (세율 15%·24%, 지방소득세 포함)</figcaption><div class="viz-legend"><span><i class="s1"></i>세율 15%</span><span><i class="s2"></i>세율 24%</span></div><svg viewBox="0 0 640 300" role="img" aria-label="가족 구성별 세금 감소 (세율 15%·24%, 지방소득세 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">250</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">300</text><rect class="s1" x="73.6" y="234.5" width="49.4" height="19.5" rx="3"><title>본인만 · 세율 15% 24.8만원</title></rect><rect class="s2" x="125.0" y="222.8" width="49.4" height="31.2" rx="3"><title>본인만 · 세율 24% 39.6만원</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">본인만</text><rect class="s1" x="217.6" y="193.4" width="49.4" height="60.6" rx="3"><title>자녀1명 · 세율 15% 77만원</title></rect><rect class="s2" x="269.0" y="170.1" width="49.4" height="83.9" rx="3"><title>자녀1명 · 세율 24% 107만원</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">자녀1명</text><rect class="s1" x="361.6" y="128.5" width="49.4" height="125.5" rx="3"><title>부부+자녀2명 · 세율 15% 160만원</title></rect><rect class="s2" x="413.0" y="81.8" width="49.4" height="172.2" rx="3"><title>부부+자녀2명 · 세율 24% 219만원</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">부부+자녀2명</text><rect class="s1" x="505.6" y="109.1" width="49.4" height="144.9" rx="3"><title>부부+자녀3명(5세 포함) · 세율 15% 184만원</title></rect><rect class="s2" x="557.0" y="50.6" width="49.4" height="203.4" rx="3"><title>부부+자녀3명(5세 포함) · 세율 24% 258만원</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">부부+자녀3명(5세 포함)</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">본인 기본공제 150만 원이 모든 사례에 들어 있어요.</p><div class="viz-cap">단위: 만원 · 자료: 기본공제 150만 원·자녀세액공제 금액을 직접 계산, 지방소득세 10% 포함</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>세율 15%</th><th>세율 24%</th></tr></thead><tbody><tr><td>본인만</td><td>24.8</td><td>39.6</td></tr><tr><td>자녀1명</td><td>77</td><td>107</td></tr><tr><td>부부+자녀2명</td><td>160</td><td>219</td></tr><tr><td>부부+자녀3명(5세 포함)</td><td>184</td><td>258</td></tr></tbody></table></div></details></figure>
<h3>계산 과정 한 번 풀어 보기: 부부 + 자녀 2명(10세·13세), 세율 15%</h3>
<ol><li>기본공제 대상은 본인·배우자·자녀 2명으로 4명이라 4 × 150만 원 = 600만 원이에요.</li><li>소득공제로 줄어드는 소득세는 600만 원 × 15% = 900,000원이에요.</li><li>자녀세액공제는 8세 이상 2명이라 550,000원이에요.</li><li>합계 1,450,000원에 지방소득세 10%(145,000원)를 더하면 1,595,000원이에요.</li></ol>
<p>이 금액은 회사가 이미 뗀 세금을 얼마나 돌려받는지가 아니라, 가족이 없을 때에 비해 세금이 얼마나 줄어드는지를 뜻해요. 월급에서 매달 원천징수되는 세금에는 이미 기본적인 가족 수가 반영되어 있어서, 연말정산 때 추가로 돌려받는 금액은 신고한 가족 수와 매달 반영된 가족 수의 차이에 따라 달라져요.</p>
<h2 id="s6">세율에 따라 달라지는 소득공제의 가치</h2>
<p>같은 가족 1명이라도 소득공제 가치는 내 세율에 따라 달라져요. 지방소득세를 포함하면 이렇게 돼요.</p>
<div class="table-wrap"><table><thead><tr><th>한계세율</th><th>기본공제 150만 원</th><th>경로우대 100만 원</th><th>장애인 200만 원</th></tr></thead><tbody><tr><td>6%</td><td>99,000원</td><td>66,000원</td><td>132,000원</td></tr><tr><td>15%</td><td>247,500원</td><td>165,000원</td><td>330,000원</td></tr><tr><td>24%</td><td>396,000원</td><td>264,000원</td><td>528,000원</td></tr><tr><td>35%</td><td>577,500원</td><td>385,000원</td><td>770,000원</td></tr><tr><td>38%</td><td>627,000원</td><td>418,000원</td><td>836,000원</td></tr></tbody></table></div>
<p>세율 24%에서는 가족 1명이 약 39만 원, 6%에서는 약 9만 9천 원이에요. 4배 차이라서 부부 중 한계세율이 높은 쪽이 부양가족을 올리는 것이 소득공제 측면에서는 유리한 경우가 많아요. 다만 자녀세액공제와 출산·입양 공제는 세액공제라 누가 받아도 금액이 같아요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s7">맞벌이 판단 기준: 세율 차이 × 150만 원</h2>
<p>부양가족 1명을 어느 쪽에 올리느냐로 달라지는 건 <strong>소득공제분뿐</strong>이에요. 그래서 차이는 <code>150만 원 × (두 사람 한계세율 차이) × 1.1</code>로 바로 나와요. 직접 계산한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>두 사람 한계세율</th><th>기본공제 1명 옮길 때 차이</th><th>2명</th><th>3명</th></tr></thead><tbody><tr><td>6% vs 15%</td><td>148,500원</td><td>297,000원</td><td>445,500원</td></tr><tr><td>15% vs 24%</td><td>148,500원</td><td>297,000원</td><td>445,500원</td></tr><tr><td>6% vs 24%</td><td>297,000원</td><td>594,000원</td><td>891,000원</td></tr><tr><td>24% vs 35%</td><td>181,500원</td><td>363,000원</td><td>544,500원</td></tr><tr><td>35% vs 38%</td><td>49,500원</td><td>99,000원</td><td>148,500원</td></tr></tbody></table></div>
<p>두 사람의 한계세율이 같은 구간이면 누가 받든 차이가 0원이에요. 이때는 아래의 의료비·카드 문턱을 기준으로 정하면 돼요. 70세 이상 부모는 경로우대 100만 원까지 250만 원이 옮겨 가니 위 1명 칸의 5/3배가 돼요.</p>
<h3>가상 예시: 맞벌이 A·B 부부(가상 인물)</h3>
<p>가상의 맞벌이 부부예요. A는 가족 공제 전 과세표준이 6,500만 원(24% 구간), B는 3,000만 원(15% 구간)이에요. 자녀는 10세·5세 두 명, 함께 사는 72세 어머니(소득 없음)가 있어요.</p>
<ol><li>옮길 수 있는 소득공제: 자녀 2명 300만 + 어머니 150만 + 경로우대 100만 = 550만 원</li><li>A가 받을 때: 550만 × 24% × 1.1 = 1,452,000원</li><li>B가 받을 때: 550만 × 15% × 1.1 = 907,500원</li><li>자녀세액공제: 8세 이상은 10세 한 명이라 25만 원 × 1.1 = 275,000원 — 자녀를 올린 쪽이 받고, 누가 받든 같아요</li><li>합계: A 쪽 1,727,000원, B 쪽 1,182,500원 → <strong>A 쪽이 544,500원 유리</strong></li></ol>
<p>만약 A의 공제 전 과세표준이 5,200만 원이었다면 이야기가 달라져요. 24% 구간은 5,000만 원 초과분부터라, 550만 원 중 200만 원만 24%로 빠지고 나머지 350만 원은 15%로 빠져요. 그러면 A 쪽 합계는 1,380,500원으로 줄고 B 쪽과의 차이는 198,000원이에요.</p>
<h2 id="s8">다른 설명과 결과가 달라지는 지점</h2>
<ul><li><strong>세율 구간 경계</strong>: "세율 높은 쪽이 유리"는 공제 뒤에도 같은 구간에 머물 때만 그대로 맞아요. 위 5,200만 원 예처럼 경계 근처면 공제액 일부가 낮은 세율로 계산돼요.</li><li><strong>결정세액 한도</strong>: 자녀세액공제·출산입양 공제는 산출세액에서 빼는 금액이라, 다른 공제를 다 빼고 남은 세금보다 크면 남는 부분은 돌려받지 못하고 사라져요. 총급여가 낮은 쪽에 자녀를 몰면 55만 원이 다 쓰이지 않을 수 있어요.</li><li><strong>"환급액"과 "절세액"의 차이</strong>: 이 글의 표는 가족이 없을 때보다 세금이 얼마나 줄어드는지예요. 실제 환급은 매달 원천징수에 반영된 가족 수와의 차이라 더 작게 나오는 경우가 많아요.</li><li><strong>나이 기준일</strong>: 이 글은 해당 연도 중 8세·20세에 해당하면 대상인 것으로 계산했어요. 기준은 해마다 안내를 다시 확인하세요.</li></ul>
<h2 id="s9">부부 중 누가 공제받아야 할까</h2>
<p>기본공제 대상 가족은 부부 중 <strong>한 사람만</strong> 올릴 수 있어요. 같은 자녀를 둘 다 올리면 나중에 중복 공제로 확인되어 세금을 추징당할 수 있어요. 정할 때는 이렇게 보면 돼요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/child-tax-credit-2026-dependents-guide-2.svg" alt="부부 중 누가 부양가족 공제를 받을지 판단하는 순서 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>소득공제는 세율이 높은 쪽, 문턱 있는 항목은 따로 계산</figcaption></figure>
<ol><li><strong>한계세율 확인</strong>: 기본공제와 추가공제는 소득공제라 세율이 높은 쪽이 환급이 커요.</li><li><strong>의료비·카드 문턱 확인</strong>: 의료비는 총급여의 3%를 넘는 부분만, 신용카드는 총급여의 25%를 넘는 부분만 공제돼요. 총급여가 낮은 쪽에서 문턱을 더 빨리 넘길 수 있어요. 항목별로 <a href="https://tool.smithlab.co.kr/money/medical-expense-tax-credit-calculator/">의료비 세액공제 계산기</a>와 <a href="https://tool.smithlab.co.kr/money/card-deduction-calculator/">카드 소득공제 계산기</a>로 따로 확인하세요.</li><li><strong>교육비·기부금</strong>: 교육비는 자녀를 공제받는 쪽에서만 받을 수 있는 항목이 많아 <a href="https://tool.smithlab.co.kr/money/education-tax-credit-calculator/">교육비 세액공제 계산기</a>로 확인하세요.</li><li><strong>소득금액 합산</strong>: 가족 중에 연금·임대 소득이 있는 사람은 소득요건에서 빠질 수 있으니 미리 확인해요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s10">상황별로 이렇게 읽어 보세요</h2>
<h3>맞벌이 부부</h3>
<p>자녀와 부모 같은 가족은 둘 중 한 사람만 올려요. 두 사람의 한계세율이 다르면 소득공제는 높은 쪽이 유리하지만, 의료비처럼 문턱이 있는 항목은 총급여가 낮은 쪽에 몰아주는 편이 유리할 수 있어요. 한 번에 정하지 말고 두 경우를 각각 계산해 비교하세요.</p>
<h3>부모님을 모시거나 부양하는 경우</h3>
<p>부모님이 60세 이상이고 소득요건을 채우면 150만 원이 공제되고, 70세 이상이면 100만 원이 더 붙어요. 부모님이 국민연금이나 임대 수입이 있으면 소득금액 계산에 따라 대상이 되는지 달라져요. 따로 살아도 생계를 같이한다고 인정되는 경우가 있지만 요건이 까다로우니 회사 안내와 홈택스를 확인하세요.</p>
<h3>자녀가 8세가 되는 해, 그리고 20세를 넘는 해</h3>
<p>자녀세액공제는 8세 이상부터 시작해요. 나이를 계산하는 기준일(연도 말 기준인지 등)은 해마다 안내가 달라질 수 있으니 홈택스에서 확인하세요. 20세를 넘기면 기본공제와 자녀세액공제 모두 나이 기준을 벗어날 수 있어요. 대학생 자녀가 있다면 올해 나이를 꼭 확인하세요.</p>
<h3>장애인 가족이 있는 경우</h3>
<p>기본공제 대상자인 장애인은 1인당 200만 원이 추가돼요. 나이 제한 없이 소득요건만 채우면 되고, 장애인증명서나 복지카드 사본이 필요해요. 장애인 한 명이 세율 24% 근로자에게 주는 공제 가치는 기본공제 150만 원과 합쳐 지방소득세 포함 약 92만 4천 원이에요.</p>
<p>교육비·의료비·기부금·월세 공제까지 합친 전체 규모는 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 이어서 계산해 보세요.</p>
<h2 id="s11">자주 하는 실수</h2>
<ul><li><strong>자녀세액공제에 세율을 곱하는 실수</strong>: 세액공제는 계산된 세금에서 금액 그대로 빼요.</li><li><strong>8세 미만 자녀를 자녀세액공제 인원에 넣는 실수</strong>: 8세 미만은 기본공제만 받아요.</li><li><strong>부부가 같은 자녀를 중복 공제하는 실수</strong>: 한 사람만 받을 수 있고, 중복은 추징 대상이 될 수 있어요.</li><li><strong>소득이 있는 부모님을 확인 없이 올리는 실수</strong>: 연금·임대소득이 소득금액 100만 원을 넘으면 대상에서 빠져요.</li><li><strong>이전 연도 금액을 기준으로 계산하는 실수</strong>: 블로그 글의 오래된 금액(1명 15만 원, 2명 35만 원)이 남아 있어요.</li></ul>
<h2 id="s12">자주 묻는 질문</h2>
<h3>자녀세액공제는 몇 살부터 받나요?</h3>
<p>8세 이상 20세 이하 자녀가 대상이에요. 8세 미만은 기본공제만 받아요. 나이 기준일은 해마다 안내가 있으니 홈택스에서 확인하세요.</p>
<h3>자녀 3명이면 공제가 얼마인가요?</h3>
<p>8세 이상 3명이면 95만 원이에요. 1명 25만 원, 2명 55만 원에 3명째부터 1명당 40만 원이 더해져요.</p>
<h3>출산·입양 공제와 자녀세액공제는 같이 받나요?</h3>
<p>해당 연도 출생·입양 자녀는 보통 8세 미만이라 자녀세액공제와 겹치지 않아요. 그 자녀에게는 출산·입양 공제(첫째 30만·둘째 50만·셋째 70만 원)와 기본공제를 함께 받아요.</p>
<h3>70세 이상 부모님을 모시면 얼마나 더 공제되나요?</h3>
<p>기본공제 150만 원에 경로우대 100만 원이 더해져 소득공제 250만 원이에요. 세율 15%라면 지방소득세 포함 약 41만 2,500원이에요.</p>
<h3>부부가 같은 자녀를 둘 다 공제받아도 되나요?</h3>
<p>안 돼요. 한 사람만 받을 수 있고, 중복이 확인되면 가산세와 함께 추징될 수 있어요.</p>
<h3>자녀를 아빠·엄마에게 한 명씩 나눠 올릴 수 있나요?</h3>
<p>자녀마다 한 사람에게만 올리면 나눠도 돼요. 다만 자녀세액공제는 각자 올린 자녀 수로 따로 계산되니, 8세 이상 2명을 나누면 25만 + 25만 = 50만 원으로 한 사람이 받는 55만 원보다 5만 원(지방소득세 포함 55,000원) 줄어요.</p>
<h3>부모님이 따로 살면 공제가 안 되나요?</h3>
<p>생계를 같이하는 직계존속은 주거를 달리해도 대상이 될 수 있다는 해설이 있어요. 요건이 까다로워 홈택스와 회사 안내를 꼭 확인하세요.</p>
<h2 id="s13">정리</h2>
<p>가족 공제는 소득에서 빼는 기본공제(1인 150만 원, 70세 이상·장애인 추가)와 세금에서 빼는 자녀세액공제(1명 25만·2명 55만·3명 95만 원), 올해 출산·입양 공제로 이뤄져요. 부부 중 한 명만 가족을 올릴 수 있으니 세율과 문턱 항목을 같이 보고 정하세요. 내 가족 구성으로 계산해 보려면 <a href="https://tool.smithlab.co.kr/money/dependent-deduction-calculator/">부양가족 공제 계산기</a>를, 전체 환급 규모는 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 이어서 계산해 보세요.</p>
<p>금액은 정책브리핑의 <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148956909" target="_blank" rel="noopener">2025 연말정산, 작년과 뭐가 달라졌을까?</a>로 확인했고, 요건은 <a href="https://blog.iquest.co.kr/entry/%EC%97%B0%EB%A7%90%EC%A0%95%EC%82%B0" target="_blank" rel="noopener">2025년 귀속 연말정산 새롭게 바뀌는 공제 정리</a>와 <a href="https://www.taxnet.co.kr/contents/practicecommentary/practicecommentary-detail?id=10656&practiceCode=PG0020" target="_blank" rel="noopener">2025년 귀속 연말정산 개정세법 실무해설</a>과 교차 확인했어요. 세법은 해마다 바뀌니 신고 전 국세청 홈택스 안내로 확인하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>교육비 세액공제 2026 — 학원비·등록금 어디까지 되나 1인당 한도…</title>
      <link>https://tool.smithlab.co.kr/posts/money/education-tax-credit-2026-limit-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/education-tax-credit-2026-limit-guide/</guid>
      <pubDate>Mon, 09 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><strong>교육비는 인정되는 금액의 15%가 세금에서 빠지고</strong>, 누구의 교육비냐에 따라 한도가 달라요. 본인 교육비는 한도 없이 전액, 취학 전 아동과 초중고생은 1인당 연 300만 원, 대학생은 1인당 연 900만 원까지만 인정돼요. 지방소득세까지 합치면 실제 환급은 인정 금액의 16.5%예요.</p>
<p>한도만큼 중요한 게 <strong>누가 공제받느냐</strong>예요. 공제액은 그 사람이 낼 세금(결정세액)을 넘을 수 없어서, 대학생 자녀 등록금 900만 원(공제 135만 원)을 결정세액 60만 원인 배우자가 받으면 환급이 66만 원, 결정세액 135만 원 이상인 배우자가 받으면 148만 5천 원이에요. 아래에 가상의 맞벌이 가구로 이 배분을 처음부터 계산해 두었어요. 내 숫자로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/education-tax-credit-calculator/">교육비 세액공제 계산기</a>에 대상별 금액을 넣으면 돼요. 이 글의 환급액은 모두 같은 식으로 직접 계산했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>공제율은 15%이고 소득세 공제액의 10%가 지방소득세에서 더 줄어 실제로는 16.5%예요.</li><li>본인(대학원 포함)은 한도 없음, 취학 전·초중고는 1인 300만 원, 대학생은 1인 900만 원이 한도예요.</li><li>한도를 넘는 금액은 이월되지 않고 버려져요. 300만 원이 넘는 초중고 지출은 환급을 늘리지 못해요.</li><li>낼 세금(결정세액)이 없으면 세액공제를 받을 수 없어요. (2026년 11월 기준)</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">교육비 세액공제 구조: 한도는 누구냐에 따라 세 갈래</h2>
<div class="table-wrap"><table><thead><tr><th>대상</th><th>연 한도</th><th>공제율</th><th>최대 공제(소득세)</th></tr></thead><tbody><tr><td>본인 (대학원 포함)</td><td>없음</td><td>15%</td><td>낸 금액의 15%</td></tr><tr><td>취학 전 아동</td><td>1인 300만 원</td><td>15%</td><td>45만 원</td></tr><tr><td>초중고생</td><td>1인 300만 원</td><td>15%</td><td>45만 원</td></tr><tr><td>대학생</td><td>1인 900만 원</td><td>15%</td><td>135만 원</td></tr><tr><td>장애인 특수교육비</td><td>없음</td><td>15%</td><td>낸 금액의 15%</td></tr></tbody></table></div>
<p>여기서 "1인당"이라는 말이 중요해요. 자녀가 둘이면 한도도 아이마다 따로 있어서 합쳐서 쓰지 못해요. 예를 들어 한 아이가 한도를 넘게 썼고 다른 아이는 적게 썼다면, 한도를 넘은 쪽의 초과분을 다른 아이 몫으로 옮길 수 없어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/education-tax-credit-2026-limit-guide-1.svg" alt="교육비 세액공제 대상별 한도와 최대 환급액 비교 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>본인은 한도 없음, 자녀는 1인 한도까지</figcaption></figure>
<h2 id="s2">지방소득세까지 합치면 얼마가 돌아오나</h2>
<p>세액공제는 소득세에서 빠진 금액의 10%만큼 지방소득세도 같이 줄어요. 그래서 15%가 아니라 16.5%로 보는 게 현실적이에요. 초등학생 1명에게 교육비 250만 원을 썼다면 한도 안이라 전액이 대상이에요. 250만 원 × 15% = 375,000원이고, 지방소득세 37,500원이 더해져 <strong>412,500원</strong>이 환급 예상이에요.</p>
<p>대학생 1명에게 등록금 1,000만 원을 냈다면 한도 900만 원이 적용돼요. 900만 원 × 15% = 1,350,000원이고 지방소득세 135,000원을 더해 <strong>1,485,000원</strong>이에요. 100만 원은 한도를 넘어 버려져요. 같은 방식으로 본인이 대학원 등록금 600만 원을 냈다면 한도가 없어서 600만 원 × 15% = 900,000원, 지방소득세 90,000원을 더해 <strong>990,000원</strong>이에요.</p>
<h2 id="s3">초중고 1인당 교육비별 환급액 표</h2>
<p>아래 표는 초중고생 1명에게 쓴 교육비를 100만 원부터 500만 원까지 바꿔 가며 계산한 거예요(소득세+지방소득세).</p>
<div class="table-wrap"><table><thead><tr><th>낸 교육비</th><th>인정 금액</th><th>환급 예상</th><th>한도 초과분</th></tr></thead><tbody><tr><td>100만 원</td><td>100만 원</td><td>165,000원</td><td>0원</td></tr><tr><td>200만 원</td><td>200만 원</td><td>330,000원</td><td>0원</td></tr><tr><td>300만 원</td><td>300만 원</td><td>495,000원</td><td>0원</td></tr><tr><td>400만 원</td><td>300만 원</td><td>495,000원</td><td>100만 원</td></tr><tr><td>500만 원</td><td>300만 원</td><td>495,000원</td><td>200만 원</td></tr></tbody></table></div>
<p>300만 원을 넘으면 환급액이 495,000원에서 멈춰요. 세금을 이유로 교육비를 더 쓸 필요는 없다는 뜻이에요. 반대로 한도 안에서는 영수증을 빠짐없이 챙길수록 환급이 정확히 늘어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">초중고 1명, 낸 교육비별 환급 예상 (지방소득세 포함)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="초중고 1명, 낸 교육비별 환급 예상 (지방소득세 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">50</text><rect class="s1" x="69.3" y="176.1" width="80.6" height="77.9" rx="3"><title>100만 · 환급 예상 16.5만원</title></rect><text class="viz-val" x="109.6" y="171.1" text-anchor="middle">16.5</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">100만</text><rect class="s1" x="184.5" y="98.2" width="80.6" height="155.8" rx="3"><title>200만 · 환급 예상 33만원</title></rect><text class="viz-val" x="224.8" y="93.2" text-anchor="middle">33</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">200만</text><rect class="s1" x="299.7" y="20.4" width="80.6" height="233.6" rx="3"><title>300만 · 환급 예상 49.5만원</title></rect><text class="viz-val" x="340.0" y="15.4" text-anchor="middle">49.5</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">300만</text><rect class="s1" x="414.9" y="20.4" width="80.6" height="233.6" rx="3"><title>400만 · 환급 예상 49.5만원</title></rect><text class="viz-val" x="455.2" y="15.4" text-anchor="middle">49.5</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">400만</text><rect class="s1" x="530.1" y="20.4" width="80.6" height="233.6" rx="3"><title>500만 · 환급 예상 49.5만원</title></rect><text class="viz-val" x="570.4" y="15.4" text-anchor="middle">49.5</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">500만</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">300만 원부터 환급이 늘지 않아요.</p><div class="viz-cap">단위: 만원 · 자료: 인정 금액 × 15% × 1.1, 1인 300만 원 한도 적용(직접 계산)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>환급 예상</th></tr></thead><tbody><tr><td>100만</td><td>16.5</td></tr><tr><td>200만</td><td>33</td></tr><tr><td>300만</td><td>49.5</td></tr><tr><td>400만</td><td>49.5</td></tr><tr><td>500만</td><td>49.5</td></tr></tbody></table></div></details></figure>
<h2 id="s4">대학생과 본인 교육비는 얼마나 돌려받나</h2>
<p>대학생은 한도가 900만 원이라 등록금이 높은 학과는 한도에서 잘려요. 대학생 1명 기준으로 계산하면 500만 원은 825,000원, 700만 원은 1,155,000원, 900만 원은 1,485,000원이고, 1,200만 원을 내도 1,485,000원 그대로예요. 한도를 넘는 300만 원은 공제되지 않아요.</p>
<p>본인 교육비는 한도가 없어서 낸 만큼 비례해요. 200만 원은 330,000원, 400만 원은 660,000원, 600만 원은 990,000원, 1,000만 원은 1,650,000원이에요. 직장인이 대학원에 다니거나 자격 과정을 밟는 경우 가장 효과가 커요. 다만 회사가 학비를 지원해 줬다면 그 금액은 낸 교육비에서 빠져요.</p>
<h2 id="s5">가족 구성별 환급 예시</h2>
<div class="table-wrap"><table><thead><tr><th>구성</th><th>인정 금액</th><th>환급 예상</th></tr></thead><tbody><tr><td>초등 1명 250만 원</td><td>250만 원</td><td>412,500원</td></tr><tr><td>초등 250만 원 + 취학 전 300만 원</td><td>550만 원</td><td>907,500원</td></tr><tr><td>중학생 2명 각 350만 원</td><td>600만 원</td><td>990,000원</td></tr><tr><td>대학생 1명 1,000만 원</td><td>900만 원</td><td>1,485,000원</td></tr><tr><td>고등학생 300만 원 + 대학생 800만 원</td><td>1,100만 원</td><td>1,815,000원</td></tr><tr><td>본인 대학원 600만 원 + 대학생 900만 원</td><td>1,500만 원</td><td>2,475,000원</td></tr></tbody></table></div>
<p>중학생 2명이 각 350만 원을 썼다면 아이마다 50만 원씩 한도를 넘어요. 합계로는 700만 원을 썼지만 인정은 600만 원이라는 점을 기억하면 영수증 정리 순서를 잡기 쉬워요.</p>
<h2 id="s6">누가 공제받을까: 결정세액으로 정하는 판단 기준</h2>
<p>교육비 세액공제는 낼 세금에서 빼는 방식이라, 공제받는 사람의 결정세액(교육비 공제 전)이 공제액보다 작으면 남는 부분은 사라져요. 이월도, 배우자에게 넘기기도 안 돼요. 그래서 판단 기준은 하나예요. <strong>공제받을 사람의 결정세액이 '인정 금액 × 15%' 이상인가.</strong></p>
<div class="table-wrap"><table><thead><tr><th>대상 교육비</th><th>공제액(15%)</th><th>다 쓰려면 필요한 결정세액</th></tr></thead><tbody><tr><td>초중고 1명 한도 300만 원</td><td>45만 원</td><td>45만 원 이상</td></tr><tr><td>대학생 1명 한도 900만 원</td><td>135만 원</td><td>135만 원 이상</td></tr><tr><td>본인 대학원 600만 원</td><td>90만 원</td><td>90만 원 이상</td></tr></tbody></table></div>
<p>대학생 등록금 900만 원을 기준으로, 공제받는 사람의 결정세액만 바꾸면 환급(지방소득세 포함)이 이렇게 달라져요.</p>
<div class="table-wrap"><table><thead><tr><th>공제받는 사람의 결정세액</th><th>실제로 쓰는 공제</th><th>환급 예상</th><th>잃는 금액</th></tr></thead><tbody><tr><td>60만 원</td><td>60만 원</td><td>66만 원</td><td>82만 5천 원</td></tr><tr><td>100만 원</td><td>100만 원</td><td>110만 원</td><td>38만 5천 원</td></tr><tr><td>135만 원</td><td>135만 원</td><td>148만 5천 원</td><td>0원</td></tr><tr><td>200만 원</td><td>135만 원</td><td>148만 5천 원</td><td>0원</td></tr></tbody></table></div>
<p>자녀 교육비는 그 자녀를 기본공제(부양가족)로 올린 사람이 받아요. 그래서 실제로는 자녀를 어느 쪽에 올리느냐에 따라 기본공제 150만 원의 효과도 같이 움직여요. 이 글의 비교는 교육비 공제만 떼어 본 것이고, 최종 판단은 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 두 경우를 모두 넣어 보는 게 정확해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s7">가상의 맞벌이 가구로 처음부터 계산해 보기</h2>
<p>아래는 이해를 돕기 위한 <strong>가상의 가구</strong> 예시예요. 엄마는 직장에 다니며 대학원 등록금 500만 원을 냈고, 고1 자녀는 급식비·교복·방과후 수업료로 120만 원, 대학 2학년 자녀는 등록금 850만 원 중 장학금 200만 원을 받았어요. 교육비 공제 전 결정세액은 엄마 180만 원, 아빠 90만 원이라고 가정했어요.</p>
<ol><li>인정 금액: 엄마 대학원 500만 원(본인, 한도 없음), 고1 120만 원(한도 300만 원 안), 대학생 850만 − 200만 = 650만 원(한도 900만 원 안)</li><li>공제액(15%): 대학원 75만 원, 고1 18만 원, 대학생 97만 5천 원, 합계 190만 5천 원</li><li>대학원 공제는 본인만 받을 수 있어서 엄마 몫으로 고정이에요. 자녀 두 명을 어떻게 나누느냐만 고르면 돼요.</li></ol>
<div class="table-wrap"><table><thead><tr><th>배분</th><th>엄마 공제</th><th>아빠 공제</th><th>결정세액에 막혀 잃는 공제</th><th>환급 예상(지방소득세 포함)</th></tr></thead><tbody><tr><td>모두 엄마</td><td>190만 5천 원</td><td>0원</td><td>10만 5천 원</td><td>198만 원</td></tr><tr><td>엄마 = 대학원 + 고1, 아빠 = 대학생</td><td>93만 원</td><td>97만 5천 원</td><td>7만 5천 원</td><td>201만 3천 원</td></tr><tr><td>엄마 = 대학원 + 대학생, 아빠 = 고1</td><td>172만 5천 원</td><td>18만 원</td><td>0원</td><td>209만 5,500원</td></tr></tbody></table></div>
<p>같은 영수증인데 배분만으로 11만 5,500원 차이가 나요. 흔히 "소득이 많은 쪽이 다 받는다"고 하지만, 이 가구처럼 큰 쪽의 결정세액을 넘치면 오히려 나눠야 다 써요. 장학금 200만 원을 빼지 않고 850만 원으로 넣었다면 공제가 30만 원 많게 잡혀 나중에 가산세와 함께 돌려내야 할 수 있어요.</p>
<h2 id="s8">어떤 교육비가 포함되고 어떤 건 빠지나</h2>
<p>교육비는 "교육과 관련된 모든 돈"이 아니에요. 일반적으로 알려진 범위는 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>대상</th><th>대체로 포함</th><th>대체로 제외</th></tr></thead><tbody><tr><td>본인</td><td>대학·대학원 등록금, 직업능력개발훈련비, 학자금대출 원리금 상환액</td><td>일반 학원비, 교재비 일부</td></tr><tr><td>취학 전 아동</td><td>어린이집·유치원 수업료, 급식비, 방과후 과정, 예체능 학원비</td><td>교과 학원비(2027년부터 제외된다는 개편안 보도 있음, 확정 아님)</td></tr><tr><td>초중고</td><td>수업료, 급식비, 방과후 학교 수업료·도서 구입비, 교복 구입비, 체험학습비</td><td>학원비, 과외비, 학습지, 참고서</td></tr><tr><td>대학생</td><td>등록금, 입학금, 수강료</td><td>기숙사비, 교재비, 학원비</td></tr><tr><td>부모·조부모</td><td>해당 없음</td><td>직계존속 교육비</td></tr></tbody></table></div>
<p>초중고 학원비는 공제 대상이 아니에요. 다만 2026년부터 초등 1~2학년 예체능(음악·미술·무용) 학원비와 체육시설 이용료도 포함된다는 정부 보도가 있어요. 취학 전 아동의 교과 학원비는 2027년부터 빠지는 쪽으로 세제 개편안이 보도됐지만 국회를 거쳐 확정돼야 해요. 연말정산 직전에 홈택스 안내로 확인하세요.</p>
<h2 id="s9">연말정산 전에 챙길 서류와 순서</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/education-tax-credit-2026-limit-guide-2.svg" alt="교육비 영수증 확인 4단계 체크 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>대상 확인, 서류, 장학금 제외, 한도 확인</figcaption></figure>
<ol><li><strong>대상 확인</strong>: 본인, 배우자, 자녀, 형제자매 등 부양가족의 교육비가 대상이에요. 부모님은 안 돼요.</li><li><strong>간소화 자료 조회</strong>: 학교와 학원이 국세청에 자료를 내면 홈택스에 자동으로 올라와요. 빠진 곳은 납입증명서를 직접 받아 회사에 내요.</li><li><strong>장학금 제외</strong>: 장학금이나 회사 학자금 지원으로 충당한 금액은 낸 교육비가 아니라서 빼야 해요.</li><li><strong>한도 확인</strong>: 1인당 한도를 넘는지 계산기로 확인하세요.</li><li><strong>누가 공제받을지 정하기</strong>: 같은 자녀의 교육비는 한 명만 공제받아요. 소득세를 더 내는 쪽이 받는 게 일반적이에요. 한도는 자녀 1인당이라 둘이 나눠도 한도가 늘지 않아요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s10">자주 틀리는 지점: 다른 계산과 결과가 달라지는 가정</h2>
<ul><li><strong>장학금을 빼지 않은 금액</strong>: 등록금 1,000만 원에 장학금 300만 원이면 인정 금액은 700만 원, 환급은 115만 5천 원이에요. 고지서 금액 그대로 넣어 한도 900만 원으로 계산하면 148만 5천 원이 나와 33만 원이 부풀어요. 이 페이지와 계산기는 장학금을 뺀 금액을 넣는 걸 전제로 해요.</li><li><strong>학자금 대출로 낸 등록금</strong>: 대출로 낸 등록금은 낸 해가 아니라 갚는 해에 본인 교육비(학자금대출 원리금 상환액)로 공제돼요. 대출받은 해에 등록금 전액을 넣으면 중복이에요.</li><li><strong>자녀 소득 요건</strong>: 교육비는 나이 제한은 없지만 소득 요건이 있어요. 대학생 자녀의 연간 소득금액이 100만 원(근로소득만 있으면 총급여 500만 원)을 넘으면 그 자녀 교육비는 공제할 수 없어요. 아르바이트를 많이 한 해에 자주 놓쳐요.</li><li><strong>결정세액 한도</strong>: 다른 계산기가 '공제액 = 환급액'으로 보여 주는 경우가 많은데, 결정세액이 그보다 작으면 실제 환급은 결정세액까지예요. 이 글의 표는 결정세액이 충분하다는 가정이에요.</li><li><strong>한도는 1인당, 부모님은 제외</strong>: 자녀 둘의 한도를 합칠 수 없고, 직계존속 교육비는 대상이 아니에요. 초중고 학원비도 예외(초등 1~2학년 예체능 보도)를 빼면 대상이 아니에요.</li></ul>
<h2 id="s11">자주 묻는 질문</h2>
<h3>교육비 세액공제율은 몇 퍼센트인가요?</h3>
<p>인정되는 교육비의 15%예요. 소득세 공제액의 10%만큼 지방소득세가 더 줄어서 합치면 16.5%예요.</p>
<h3>대학원 등록금은 누구 것이 공제되나요?</h3>
<p>근로자 본인의 대학원 등록금만 공제돼요. 자녀나 형제자매의 대학원 등록금은 대상이 아니에요.</p>
<h3>한도를 넘으면 이월되나요?</h3>
<p>이월되지 않아요. 한도를 넘는 금액은 그해에 버려져요.</p>
<h3>초등학생 학원비는 공제되나요?</h3>
<p>일반적으로 초중고 학원비는 대상이 아니에요. 초등 1~2학년 예체능 학원비는 2026년부터 포함된다는 보도가 있으니 홈택스 안내를 확인하세요.</p>
<h3>부부가 나눠서 공제받을 수 있나요?</h3>
<p>같은 자녀의 교육비는 그 자녀를 기본공제로 올린 한 명만 받아요. 다만 자녀가 둘 이상이면 자녀별로 다른 사람이 받을 수 있어서, 한쪽 결정세액이 모자랄 때 나누는 게 유리할 수 있어요.</p>
<h3>대학생 자녀가 아르바이트를 하면 교육비 공제가 안 되나요?</h3>
<p>자녀의 연간 소득금액이 100만 원을 넘으면 부모가 그 자녀 교육비를 공제받을 수 없어요. 근로소득만 있으면 총급여 500만 원이 기준이에요. 나이 제한은 없어서 대학생이어도 소득 요건만 맞으면 돼요.</p>
<h3>세금을 안 내면 환급이 있나요?</h3>
<p>세액공제는 낸 세금에서 빼는 방식이라 결정세액이 0이면 돌려받을 금액이 없어요. <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 먼저 확인하세요.</p>
<h2 id="s12">정리</h2>
<p>교육비 세액공제는 15%(실제 16.5%)이고, 본인은 한도 없이, 자녀는 취학 전·초중고 1인 300만 원, 대학생 1인 900만 원까지예요. 초중고 학원비는 대체로 빠지고, 한도를 넘는 금액은 이월되지 않아요. 내 금액으로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/education-tax-credit-calculator/">교육비 세액공제 계산기</a>를 쓰고, 기부금은 <a href="https://tool.smithlab.co.kr/money/donation-tax-credit-calculator/">기부금 세액공제 계산기</a>, 전체 환급은 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 이어서 확인하세요.</p>
<p>공제율과 한도는 <a href="https://wikidocs.net/blog/@TaxSave/26449/" target="_blank" rel="noopener">교육비 세액공제 조건·제외 대상 정리</a>와 <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148974171" target="_blank" rel="noopener">정책브리핑 보도</a>, 학원비 개편 방향은 <a href="https://www.mt.co.kr/policy/2026/08/04/2026080413121427372" target="_blank" rel="noopener">머니투데이 보도</a>를 교차 확인했어요. 세법은 해마다 바뀌니 신고 전 국세청 홈택스 안내로 확인하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>주택청약·전세대출 소득공제 2026, 400만원 한도 얼마 돌려받나</title>
      <link>https://tool.smithlab.co.kr/posts/money/housing-fund-deduction-2026-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/housing-fund-deduction-2026-guide/</guid>
      <pubDate>Mon, 09 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><strong>주택청약저축은 연 300만 원까지의 40%(최대 120만 원), 전세대출 원리금 상환액은 40%를 소득에서 빼 주고, 두 공제를 합쳐 연 400만 원까지만 인정돼요.</strong> 소득공제라서 돌려받는 돈은 공제액에 내 세율을 곱한 값이에요. 세율 15%인 직장인이 청약만 월 25만 원 넣으면 지방소득세까지 약 19만 8천 원이에요.</p>
<p>합산 한도 때문에 생기는 판단 기준도 하나 있어요. <strong>전세대출 원리금이 연 700만 원을 넘으면 청약 공제가 일부 버려지기 시작하고, 1,000만 원 이상이면 청약 공제는 환급을 한 푼도 늘리지 못해요.</strong> 아래에 원리금별로 청약 공제가 실제로 더해 주는 금액을 계산해 두었어요. 내 숫자로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/housing-fund-deduction-calculator/">주택자금 소득공제 계산기</a>에 납입액과 상환액을 넣으면 돼요. 이 글의 환급액은 모두 같은 식으로 직접 계산했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>청약저축은 연 300만 원까지 인정, 그 40%인 최대 120만 원이 공제돼요. 총급여 7천만 원 이하 무주택 세대주가 대상이에요.</li><li>전세대출은 갚은 원금과 이자의 40%가 공제돼요. 두 공제를 더한 금액이 연 400만 원을 넘으면 넘는 부분은 버려져요.</li><li>소득공제라서 환급은 공제액 × 세율이에요. 세율 6%면 400만 원 공제가 26만 4천 원, 24%면 105만 6천 원이에요(지방소득세 포함).</li><li>세부 요건은 해마다 바뀔 수 있어 신고 전 홈택스 안내로 확인하세요. (2026년 11월 기준)</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">주택자금 소득공제의 구조: 두 공제, 한도 하나</h2>
<p>소득공제라서 같은 금액을 공제받아도 세율이 높은 사람이 더 많이 돌려받아요.</p>
<div class="table-wrap"><table><thead><tr><th>항목</th><th>인정 범위</th><th>공제율</th><th>최대 공제</th><th>주요 요건</th></tr></thead><tbody><tr><td>주택청약저축</td><td>연 납입 300만 원까지</td><td>40%</td><td>120만 원</td><td>총급여 7천만 원 이하, 무주택 세대주</td></tr><tr><td>전세대출 원리금</td><td>갚은 원리금 전액</td><td>40%</td><td>합산 한도 안에서</td><td>무주택 세대주, 임차 목적 대출</td></tr><tr><td>두 공제 합계</td><td>-</td><td>-</td><td>연 400만 원</td><td>넘는 부분은 공제 안 됨</td></tr></tbody></table></div>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/housing-fund-deduction-2026-guide-1.svg" alt="주택자금 소득공제 구조: 청약저축과 전세대출 원리금, 합산 400만원 한도 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>두 공제를 더해 400만 원까지</figcaption></figure>
<p>여기서 놓치기 쉬운 건 한도가 <strong>따로가 아니라 합쳐서</strong> 400만 원이라는 점이에요. 청약 공제 120만 원을 이미 채웠다면 전세대출 공제는 280만 원어치까지만 더 인정돼요. 전세대출 원리금으로 700만 원을 갚았다면 40%인 280만 원이 되어 딱 맞아떨어져요.</p>
<h2 id="s2">청약저축, 월 얼마를 넣으면 얼마 돌려받나</h2>
<p>청약저축 공제는 연 납입액 300만 원이 상한이라, 월 25만 원이 공제를 꽉 채우는 금액이에요. 총급여 5,000만 원 직장인 기준으로 월 납입액별 환급을 계산하면 아래와 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>월 납입액</th><th>연 납입액</th><th>소득공제(40%)</th><th>환급(세율 15%)</th><th>환급(세율 24%)</th></tr></thead><tbody><tr><td>10만 원</td><td>120만 원</td><td>48만 원</td><td>79,200원</td><td>126,720원</td></tr><tr><td>15만 원</td><td>180만 원</td><td>72만 원</td><td>118,800원</td><td>190,080원</td></tr><tr><td>20만 원</td><td>240만 원</td><td>96만 원</td><td>158,400원</td><td>253,440원</td></tr><tr><td>25만 원</td><td>300만 원</td><td>120만 원</td><td>198,000원</td><td>316,800원</td></tr><tr><td>30만 원</td><td>360만 원</td><td>120만 원</td><td>198,000원</td><td>316,800원</td></tr></tbody></table></div>
<p>월 25만 원을 넘기면 공제는 더 늘지 않아요. 계산은 이렇게 해요. 월 20만 원이면 연 240만 원이고 40%는 96만 원이에요. 세율 15%를 곱하면 소득세 14만 4천 원, 여기에 지방소득세 10%인 1만 4,400원을 더해 158,400원이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">월 청약 납입액별 환급 예상 (세율 15%, 지방소득세 포함)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="월 청약 납입액별 환급 예상 (세율 15%, 지방소득세 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">20</text><rect class="s1" x="69.3" y="160.5" width="80.6" height="93.5" rx="3"><title>월10만 · 환급 예상 7.9만원</title></rect><text class="viz-val" x="109.6" y="155.5" text-anchor="middle">7.9</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">월10만</text><rect class="s1" x="184.5" y="113.8" width="80.6" height="140.2" rx="3"><title>월15만 · 환급 예상 11.9만원</title></rect><text class="viz-val" x="224.8" y="108.8" text-anchor="middle">11.9</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">월15만</text><rect class="s1" x="299.7" y="67.1" width="80.6" height="186.9" rx="3"><title>월20만 · 환급 예상 15.8만원</title></rect><text class="viz-val" x="340.0" y="62.1" text-anchor="middle">15.8</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">월20만</text><rect class="s1" x="414.9" y="20.4" width="80.6" height="233.6" rx="3"><title>월25만 · 환급 예상 19.8만원</title></rect><text class="viz-val" x="455.2" y="15.4" text-anchor="middle">19.8</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">월25만</text><rect class="s1" x="530.1" y="20.4" width="80.6" height="233.6" rx="3"><title>월30만 · 환급 예상 19.8만원</title></rect><text class="viz-val" x="570.4" y="15.4" text-anchor="middle">19.8</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">월30만</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">월 25만 원부터 환급이 늘지 않아요.</p><div class="viz-cap">단위: 만원 · 자료: 연 납입(300만 원 한도) × 40% × 15% × 1.1, 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>환급 예상</th></tr></thead><tbody><tr><td>월10만</td><td>7.9</td></tr><tr><td>월15만</td><td>11.9</td></tr><tr><td>월20만</td><td>15.8</td></tr><tr><td>월25만</td><td>19.8</td></tr><tr><td>월30만</td><td>19.8</td></tr></tbody></table></div></details></figure>
<h2 id="s3">세율에 따라 환급이 이렇게 달라요</h2>
<p>같은 120만 원 공제여도 세율에 따라 받는 돈이 크게 달라요. 과세표준이 1,400만 원 이하면 6%, 5,000만 원 이하면 15%, 8,800만 원 이하면 24%예요. 월 25만 원 청약, 공제 120만 원 기준으로 계산하면 아래와 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>세율</th><th>소득세</th><th>지방소득세</th><th>합계</th></tr></thead><tbody><tr><td>6%</td><td>72,000원</td><td>7,200원</td><td>79,200원</td></tr><tr><td>15%</td><td>180,000원</td><td>18,000원</td><td>198,000원</td></tr><tr><td>24%</td><td>288,000원</td><td>28,800원</td><td>316,800원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">청약 공제 120만 원, 세율별 환급 예상 (지방소득세 포함)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="청약 공제 120만 원, 세율별 환급 예상 (지방소득세 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">40</text><rect class="s1" x="80.8" y="207.3" width="134.4" height="46.7" rx="3"><title>세율 6% · 환급 예상 7.9만원</title></rect><text class="viz-val" x="148.0" y="202.3" text-anchor="middle">7.9</text><text class="viz-ax" x="148.0" y="272" text-anchor="middle">세율 6%</text><rect class="s1" x="272.8" y="137.2" width="134.4" height="116.8" rx="3"><title>세율 15% · 환급 예상 19.8만원</title></rect><text class="viz-val" x="340.0" y="132.2" text-anchor="middle">19.8</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">세율 15%</text><rect class="s1" x="464.8" y="67.1" width="134.4" height="186.9" rx="3"><title>세율 24% · 환급 예상 31.7만원</title></rect><text class="viz-val" x="532.0" y="62.1" text-anchor="middle">31.7</text><text class="viz-ax" x="532.0" y="272" text-anchor="middle">세율 24%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">소득공제는 세율이 높을수록 환급이 커요.</p><div class="viz-cap">단위: 만원 · 자료: 120만 원 × 세율 × 1.1, 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>환급 예상</th></tr></thead><tbody><tr><td>세율 6%</td><td>7.9</td></tr><tr><td>세율 15%</td><td>19.8</td></tr><tr><td>세율 24%</td><td>31.7</td></tr></tbody></table></div></details></figure>
<p>사회초년생처럼 세율이 6%인 경우 청약 공제로 돌려받는 돈은 8만 원 안팎이에요. 공제만 보고 청약을 시작할 이유는 크지 않고, 당첨 가능성과 금리 같은 청약 자체의 쓰임을 먼저 보는 게 맞아요.</p>
<h2 id="s4">전세대출 원리금 40%, 한도 400만 원에 닿는 지점</h2>
<p>전세대출은 갚은 원금과 이자를 합친 원리금 상환액의 40%가 공제돼요. 이자만 내는 만기 일시상환 대출이라면 그해 낸 이자가 곧 상환액이에요. 은행 상환 내역에서 실제 상환액을 확인하세요. 청약이 없을 때 원리금별 환급(총급여 5,000만 원)은 아래와 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>연 원리금 상환</th><th>40% 공제</th><th>적용 공제(400만 한도)</th><th>환급(15%)</th><th>환급(24%)</th></tr></thead><tbody><tr><td>300만 원</td><td>120만 원</td><td>120만 원</td><td>198,000원</td><td>316,800원</td></tr><tr><td>500만 원</td><td>200만 원</td><td>200만 원</td><td>330,000원</td><td>528,000원</td></tr><tr><td>800만 원</td><td>320만 원</td><td>320만 원</td><td>528,000원</td><td>844,800원</td></tr><tr><td>1,000만 원</td><td>400만 원</td><td>400만 원</td><td>660,000원</td><td>1,056,000원</td></tr><tr><td>1,500만 원</td><td>600만 원</td><td>400만 원</td><td>660,000원</td><td>1,056,000원</td></tr></tbody></table></div>
<p>원리금 1,000만 원이 한도에 닿는 지점이에요. 그 이상을 갚아도 공제는 늘지 않아요. 청약 공제와 함께 받는다면 한도에 더 빨리 닿아요. 예를 들어 청약 300만 원(공제 120만 원)과 전세 원리금 1,200만 원(공제 480만 원)을 더하면 600만 원이지만 400만 원만 인정돼요. 세율 15%면 환급은 660,000원이에요.</p>
<h2 id="s5">판단 기준: 전세대출이 크면 청약 공제는 얼마나 남나</h2>
<p>두 공제가 400만 원 한도를 같이 쓰기 때문에, 청약 공제가 실제로 환급을 늘리는 크기는 <strong>min(120만 원, 400만 원 − 전세 원리금 × 40%)</strong>예요. 청약을 월 25만 원(연 300만 원) 꽉 채운다고 보고 원리금만 바꿔 계산했어요.</p>
<div class="table-wrap"><table><thead><tr><th>연 전세 원리금</th><th>전세 공제</th><th>청약 공제가 더해 주는 금액</th><th>늘어나는 환급(세율 15%)</th></tr></thead><tbody><tr><td>0원</td><td>0원</td><td>120만 원</td><td>198,000원</td></tr><tr><td>500만 원</td><td>200만 원</td><td>120만 원</td><td>198,000원</td></tr><tr><td>700만 원</td><td>280만 원</td><td>120만 원</td><td>198,000원</td></tr><tr><td>800만 원</td><td>320만 원</td><td>80만 원</td><td>132,000원</td></tr><tr><td>900만 원</td><td>360만 원</td><td>40만 원</td><td>66,000원</td></tr><tr><td>1,000만 원 이상</td><td>400만 원</td><td>0원</td><td>0원</td></tr></tbody></table></div>
<p>또 하나, 청약 납입을 늘릴 때의 한계 효과는 세율로 정해져요. 연 300만 원 안에서 청약 1만 원을 더 넣으면 공제가 4천 원 늘고, 환급은 세율 6%면 264원, 15%면 660원, 24%면 1,056원이에요(지방소득세 포함). 납입액의 2.6~10.6% 정도라, 청약 납입액을 정할 때 세금보다는 당첨 전략과 다른 저축과의 금리 비교가 먼저예요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">가상의 세대주 두 명으로 끝까지 계산해 보기</h2>
<p>아래는 이해를 돕기 위한 <strong>가상의 직장인</strong> 예시예요. 둘 다 무주택 세대주이고 총급여 7천만 원 이하, 한계세율 15%로 가정했어요.</p>
<p><strong>A씨</strong>: 청약 월 20만 원(연 240만 원), 전세대출 1억 원을 연 4% 이자만 내는 만기 일시상환으로 써서 그해 이자 400만 원을 냈어요.</p>
<ol><li>청약 공제: 240만 원 × 40% = 96만 원</li><li>전세 공제: 400만 원 × 40% = 160만 원</li><li>합계 256만 원, 한도 400만 원 안이라 전액 인정</li><li>환급: 256만 원 × 15% × 1.1 = <strong>422,400원</strong></li></ol>
<p>A씨가 청약을 월 25만 원으로 올리면 공제가 280만 원이 되어 환급이 462,000원, 39,600원 늘어요.</p>
<p><strong>B씨</strong>: 청약은 같고, 전세대출이 2억 원이라 그해 이자 800만 원을 냈어요.</p>
<ol><li>청약 공제 96만 원 + 전세 공제 320만 원 = 416만 원</li><li>한도 400만 원이 적용돼 16만 원은 버려짐</li><li>환급: 400만 원 × 15% × 1.1 = <strong>660,000원</strong></li></ol>
<p>B씨는 청약을 하나도 안 넣었어도 공제 320만 원으로 528,000원을 받았을 거예요. 청약 연 240만 원이 실제로 더해 준 환급은 132,000원이고, 월 25만 원으로 올려도 환급은 그대로예요.</p>
<h2 id="s7">받기 전에 확인할 요건</h2>
<p>첫째, 두 공제 모두 <strong>무주택 세대주</strong>가 기본이에요. 과세연도 12월 31일 기준으로 판단하니 연말에 주택을 사거나 세대 구성이 바뀌면 달라질 수 있어요. 세대원이 대신 받는 예외는 요건이 따로 있으니 홈택스나 회사 안내를 확인하세요.</p>
<p>둘째, 청약저축은 총급여 7천만 원 이하일 때만 공제돼요. 이 총급여에는 상여와 성과급도 들어가요. 7천만 원을 조금 넘는다면 공제가 0이 되니 연봉이 경계선 근처인지 먼저 보세요.</p>
<p>셋째, 전세대출은 임차 목적 대출이어야 하고 국민주택규모 이하 주택(주거용 오피스텔 포함)을 빌리는 경우가 일반적이에요. 대출 기관, 차입 시기, 대환대출 같은 세부 조건과 소득 요건은 해설마다 설명이 달라서 이 글과 계산기는 요건 판정을 하지 않아요. 반드시 홈택스 안내로 확인하세요.</p>
<p>넷째, 서류는 청약 납입증명서, 대출 상환 내역, 임대차계약서, 주민등록등본이 필요해요. 간소화 자료에 안 보이면 은행에서 직접 발급받아 회사에 내야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/housing-fund-deduction-2026-guide-2.svg" alt="주택자금 소득공제 신청 전 서류 체크리스트 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>네 가지만 챙기면 돼요</figcaption></figure>
<h2 id="s8">월세 세액공제와 비교하면</h2>
<p>집 관련 공제 중 월세 세액공제는 소득공제가 아니라 세액공제라서 세율과 상관없이 공제액에 일정 비율이 적용돼요. 월세를 내는 사람은 <a href="https://tool.smithlab.co.kr/real-estate/monthly-rent-tax-credit-calculator/">월세 세액공제 계산기</a>로 따로 계산해 보세요. 전세대출 공제와 월세 공제는 목적이 달라 둘 다 해당되는 경우도 있지만, 요건이 서로 달라서 각각 확인해야 해요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 틀리는 지점: 다른 계산과 결과가 달라지는 가정</h2>
<ul><li><strong>납입액 전체를 공제로 보기</strong>: 청약 300만 원을 넣으면 공제는 40%인 120만 원이에요. 300만 원을 공제로 넣으면 환급이 2.5배로 부풀어요.</li><li><strong>소득공제를 세액공제처럼 계산하기</strong>: 공제 120만 원은 120만 원이 돌아온다는 뜻이 아니라 120만 원 × 세율만큼 세금이 준다는 뜻이에요. 세율 15%면 지방소득세 포함 198,000원이에요.</li><li><strong>한도를 항목별로 400만 원씩 보기</strong>: 두 공제를 합쳐 400만 원이에요. 위 B씨처럼 전세대출이 크면 청약 공제가 먼저 잘려요.</li><li><strong>이자만 낸 해는 공제가 없다고 보기</strong>: 원리금 상환액에는 이자도 들어가요. 이자만 낸 해도 그 이자의 40%가 공제 대상이에요.</li><li><strong>총급여에 성과급 빼기</strong>: 청약 공제의 7천만 원 기준은 상여·성과급까지 넣은 총급여예요. 연봉 6,800만 원에 성과급 300만 원이면 기준을 넘어 청약 공제가 0원이 돼요.</li><li><strong>세대주 아닌 배우자가 받기</strong>: 두 공제는 세대주 기준이라, 세대주가 아닌 배우자가 대출 명의자여도 기본적으로는 받지 못해요(예외 요건은 홈택스 안내 확인).</li></ul>
<h2 id="s10">자주 묻는 질문</h2>
<h3>청약저축 소득공제는 얼마까지 받나요?</h3>
<p>연 납입액 300만 원까지 인정되고 그 40%인 최대 120만 원이 공제돼요. 무주택 세대주이면서 총급여 7천만 원 이하여야 해요.</p>
<h3>청약과 전세대출 공제는 한도가 따로인가요?</h3>
<p>따로가 아니에요. 두 공제를 더한 금액에 연 400만 원 한도가 적용돼요.</p>
<h3>환급액은 어떻게 계산하나요?</h3>
<p>소득공제라서 공제액에 세율을 곱하고 지방소득세 10%가 더해져요. 공제 400만 원이면 세율 15%는 66만 원, 24%는 105만 6천 원이에요.</p>
<h3>월 25만 원보다 더 넣으면 공제가 늘어나나요?</h3>
<p>늘어나지 않아요. 연 300만 원을 넘는 납입액은 공제에 반영되지 않아요.</p>
<h3>이자만 내는 전세대출도 공제되나요?</h3>
<p>원리금 상환액에는 이자도 들어가서, 원금을 갚지 않고 이자만 낸 해도 낸 이자의 40%가 공제 대상이에요. 다른 요건(무주택 세대주, 임차 목적 대출 등)은 같아요. 은행 상환 내역에서 그해 낸 이자 합계를 확인하세요.</p>
<h3>전세대출이 크면 청약은 넣지 말아야 하나요?</h3>
<p>연말정산만 보면 전세 원리금이 연 1,000만 원 이상일 때 청약 공제는 환급을 늘리지 못해요. 하지만 청약통장은 당첨 자격을 위한 것이라 세금만으로 해지를 정할 일은 아니고, 납입액을 줄일지 정할 때 참고하면 돼요.</p>
<h2 id="s11">정리</h2>
<p>주택청약저축은 연 300만 원의 40%(최대 120만 원), 전세대출 원리금은 40%이고 둘을 합쳐 연 400만 원까지만 소득공제돼요. 환급은 공제액에 세율을 곱한 값이라 세율 15%에서 400만 원 공제는 지방소득세 포함 66만 원이에요. 내 숫자는 <a href="https://tool.smithlab.co.kr/money/housing-fund-deduction-calculator/">주택자금 소득공제 계산기</a>로, 연말정산 전체는 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로 이어서 확인해 보세요.</p>
<p>공제율과 한도는 <a href="https://wikidocs.net/blog/@TaxSave/25791/" target="_blank" rel="noopener">주택청약저축 소득공제 조건 정리</a>와 <a href="https://wikidocs.net/blog/@TaxSave/26436/" target="_blank" rel="noopener">전세대출 원리금 소득공제 조건·절차</a>를 교차 확인했어요. 정부 정책 소식은 <a href="https://www.korea.kr/" target="_blank" rel="noopener">정책브리핑</a>에서 볼 수 있어요. 세법은 해마다 바뀌니 신고 전 국세청 홈택스 안내로 확인하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>프로메테우스 히스토그램 버킷 정하는 법 — p99 오차 계산과 네이티브…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/prometheus-histogram-bucket-layout-native-migration-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/prometheus-histogram-bucket-layout-native-migration-guide/</guid>
      <pubDate>Sun, 08 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>대시보드에 지연 시간 히스토그램을 붙이려는데 <code>buckets=</code>에 무슨 숫자를 넣어야 할지, 기본값을 그대로 둬도 되는지, 요즘 말하는 네이티브 히스토그램으로 바로 가야 하는지부터 막히죠. 결론부터 말하면 <strong>클래식 히스토그램은 "보고 싶은 백분위수와 SLO 값 근처에 경계를 두는 것"이 전부이고, 쓰는 언어 라이브러리가 네이티브 히스토그램을 내보낼 수 있다면 버킷 계수 1.1 안팎의 네이티브 히스토그램이 비용과 정확도 모두 유리해요.</strong> 클래식의 p99 오차는 그 값이 떨어진 버킷의 폭을 넘지 않지만, 폭이 0.4초면 오차도 0.4초까지 열려 있어요.</p>
<p><code>histogram_quantile</code>의 보간 원리는 알고 있다고 보고, 이 글은 <strong>설정을 고르고 문제를 찾는 데 필요한 숫자</strong>만 다뤄요. 기준은 2026-11-08 현재 최신 안정판 <a href="https://github.com/prometheus/prometheus/releases" target="_blank" rel="noopener">Prometheus 3.16.0</a>과 공식 문서 <code>latest</code>예요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>공식 <a href="https://prometheus.io/docs/practices/histograms/" target="_blank" rel="noopener">Histograms and summaries 문서</a>는 네이티브를 쓸 수 있으면 네이티브, 집계가 필요한데 못 쓰면 클래식, 집계가 필요 없을 때만 서머리를 고려하라고 권해요.</li><li>클래식 히스토그램은 버킷 하나가 시계열 하나라서, 라벨 조합 2,000개에 버킷 15개면 시계열이 34,000개예요. 네이티브는 같은 조건에서 2,000개예요.</li><li>버킷 배치만 바꿔 같은 데이터의 p50을 추정했더니 오차가 2%에서 49%까지 벌어졌어요. 맨 위 유한 경계가 0.5초면 p99.9가 0.5초에 붙어 실제보다 35% 낮게 나왔어요.</li><li>SLO 판정은 백분위수보다 "임계값 이하 요청 비율"이 정확해요. 그 값에 버킷 경계가 있으면 보간 없이 계산돼요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">서머리·클래식·네이티브, 무엇을 고르나</h2>
<p>세 형식의 차이는 "백분위수를 어디서 계산하느냐"와 "여러 인스턴스를 합칠 수 있느냐"로 갈려요. 공식 문서의 비교 표를 실무 질문 기준으로 다시 묶으면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>따질 것</th><th>서머리</th><th>클래식 히스토그램</th><th>네이티브 히스토그램</th></tr></thead><tbody><tr><td>파드 여러 개를 합친 p99</td><td>불가</td><td>가능(버킷 경계가 같을 때)</td><td>가능(버킷이 항상 호환)</td></tr><tr><td>나중에 p95→p99.9로 바꾸기</td><td>재배포 필요</td><td>쿼리만 바꾸면 됨</td><td>쿼리만 바꾸면 됨</td></tr><tr><td>계측 때 정할 것</td><td>φ 목록과 시간 창</td><td>버킷 경계 전부</td><td>해상도와 버킷 수 한도</td></tr><tr><td>라벨 조합당 시계열</td><td>φ 수 + 2</td><td>버킷 수 + 2</td><td>1</td></tr><tr><td>오차를 정하는 것</td><td>φ 축 허용 오차</td><td>그 값이 든 버킷의 폭</td><td>설정한 해상도</td></tr><tr><td>앱 쪽 관측 비용</td><td>상대적으로 비쌈</td><td>카운터 증가만</td><td>카운터 증가만</td></tr></tbody></table></div>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/prometheus-histogram-bucket-layout-native-migration-guide-1.svg" alt="여러 인스턴스를 합쳐 봐야 하는지, 라이브러리가 네이티브 히스토그램을 내는지에 따라 서머리·클래식·네이티브를 고르는 순서도" width="800" height="520" loading="lazy" decoding="async"><figcaption>쿠버네티스처럼 파드가 여러 개면 서머리는 거의 후보에서 빠져요</figcaption></figure>
<p>판단은 두 질문이면 끝나요. 첫째, 파드가 둘 이상이거나 리전별로 합쳐 봐야 하면 서머리는 빠져요. 서머리의 <code>quantile</code> 시계열을 평균 내는 건 수학적으로 의미가 없어요. 둘째, 계측 라이브러리가 네이티브 히스토그램을 내보내는지 확인해요. 공식 문서는 현재 네이티브 히스토그램이 protobuf 노출 형식이 필요해서 Go·Java 같은 protobuf 지원 라이브러리 위주로 쓸 수 있다고 적었어요. 못 쓰면 클래식으로 내보내고, 서버에서 NHCB(사용자 지정 경계 네이티브 히스토그램)로 바꿔 받는 길이 있어요. 이건 아래 전환 절에서 다뤄요.</p>
<p>서머리는 합칠 일이 없고 백분위수가 처음부터 정해진 단일 프로세스 작업에 맞아요. 다만 공식 문서의 예처럼 꼬리가 넓은 분포에서는 φ를 0.95±0.01로 잡아도 관측값으로는 270ms~330ms만큼 흔들릴 수 있어요.</p>
<h2 id="s2">클래식 히스토그램 비용, 버킷 수 × 라벨 조합</h2>
<p>클래식 히스토그램은 라벨 조합마다 <code>_bucket</code>이 버킷 수만큼, <code>_sum</code>과 <code>_count</code>가 하나씩 생겨요. <code>+Inf</code> 버킷도 시계열 하나예요. 그래서 버킷을 몇 개 두느냐가 그대로 저장·메모리 비용이에요.</p>
<p>예시로 <code>route</code> 40종 × <code>code</code> 5종에 파드 10개를 곱해 <strong>라벨 조합 2,000개</strong>인 지연 지표를 생각해 볼게요. 파이썬 공식 클라이언트의 <a href="https://github.com/prometheus/client_python/blob/master/prometheus_client/metrics.py" target="_blank" rel="noopener">기본 버킷</a>은 5ms~10초 사이 유한 경계 14개에 <code>+Inf</code>를 더해 15개예요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">버킷 수별 시계열 수 (라벨 조합 2000개 지연 지표)</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="버킷 수별 시계열 수 (라벨 조합 2000개 지연 지표)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="195" y="29" text-anchor="end">클래식 버킷 7개</text><rect class="s1" x="205" y="15" width="82.1" height="20" rx="3"><title>클래식 버킷 7개 · 18,000개</title></rect><text class="viz-val" x="293.1" y="29">18,000개</text><text class="viz-ax lg" x="195" y="63" text-anchor="end">클래식 버킷 11개</text><rect class="s1" x="205" y="49" width="118.6" height="20" rx="3"><title>클래식 버킷 11개 · 26,000개</title></rect><text class="viz-val" x="329.6" y="63">26,000개</text><text class="viz-ax lg" x="195" y="97" text-anchor="end">클래식 버킷 15개(기본값)</text><rect class="s1" x="205" y="83" width="155.1" height="20" rx="3"><title>클래식 버킷 15개(기본값) · 34,000개</title></rect><text class="viz-val" x="366.1" y="97">34,000개</text><text class="viz-ax lg" x="195" y="131" text-anchor="end">클래식 버킷 31개</text><rect class="s1" x="205" y="117" width="301.1" height="20" rx="3"><title>클래식 버킷 31개 · 66,000개</title></rect><text class="viz-val" x="512.1" y="131">66,000개</text><text class="viz-ax lg" x="195" y="165" text-anchor="end">네이티브</text><rect class="s1" x="205" y="151" width="9.1" height="20" rx="3"><title>네이티브 · 2,000개</title></rect><text class="viz-val" x="220.1" y="165">2,000개</text></svg><p class="viz-note">네이티브는 시계열 수가 적은 대신 샘플 하나가 버킷 묶음을 담아 더 커요</p><div class="viz-cap">단위: 개 · 자료: 라벨 조합 × (+Inf 포함 버킷 수 + _sum·_count 2개)로 직접 계산. 네이티브는 조합당 1개</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>시계열 수</th></tr></thead><tbody><tr><td>클래식 버킷 7개</td><td>18,000</td></tr><tr><td>클래식 버킷 11개</td><td>26,000</td></tr><tr><td>클래식 버킷 15개(기본값)</td><td>34,000</td></tr><tr><td>클래식 버킷 31개</td><td>66,000</td></tr><tr><td>네이티브</td><td>2,000</td></tr></tbody></table></div></details></figure>
<p>버킷을 15개에서 31개로 늘리면 시계열이 거의 두 배가 돼요. 시계열 수가 디스크와 메모리를 어떻게 정하는지는 <a href="https://tool.smithlab.co.kr/posts/developer/prometheus-scrape-interval-retention-cardinality-sizing-guide/">프로메테우스 용량 계산, scrape_interval·보존 기간·시계열 수 정하는 법</a>에 계산식이 있어요. <strong>클래식은 버킷을 늘리는 만큼 비용이 선형으로 늘어서</strong>, 경계를 많이 두기보다 필요한 자리에 두는 게 중요해요.</p>
<p>네이티브 히스토그램은 조합당 시계열이 1개라 이 표에서는 압도적으로 적어요. 다만 샘플 하나에 채워진 버킷이 통째로 들어가므로 "17분의 1 비용"으로 단순 환산하면 안 돼요. 공식 문서는 NHCB조차 클래식보다 저장 비용이 훨씬 낮고, 버킷을 하나 더할 때의 추가 비용이 작다고만 적었어요. 실제 비용은 도입 전에 스테이징에서 <code>prometheus_tsdb_head_series</code>와 디스크 증가량을 재 보는 게 정확해요.</p>
<h2 id="s3">버킷 경계 정하기, 배치 4가지 오차 시뮬레이션</h2>
<p>같은 데이터에 버킷 배치만 바꾸면 추정치가 얼마나 달라지는지 직접 계산했어요. 중앙값 120ms, 로그 표준편차 0.6인 로그정규 분포에서 20만 건을 뽑고(시드 7), <code>histogram_quantile</code>의 클래식 규칙(구간 안 직선 보간, <code>+Inf</code>에 걸리면 두 번째로 높은 경계)을 파이썬으로 옮겨 추정했어요. 실제 값은 p50 0.120초, p90 0.259초, p99 0.485초, p99.9 0.766초예요.</p>
<div class="table-wrap"><table><thead><tr><th>배치</th><th>유한 경계</th><th>p50 추정</th><th>p99 추정</th><th>p99.9 추정</th></tr></thead><tbody><tr><td>기본값</td><td>0.005~10초 14개</td><td>0.135초 (+12.7%)</td><td>0.497초 (+2.6%)</td><td>0.791초 (+3.2%)</td></tr><tr><td>성긴 4개</td><td>0.1·0.5·1·5</td><td>0.178초 (+48.5%)</td><td>0.499초 (+3.0%)</td><td>0.952초 (+24.2%)</td></tr><tr><td>SLO 300ms 중심</td><td>0.05~1초 9개</td><td>0.122초 (+2.1%)</td><td>0.533초 (+10.1%)</td><td>0.903초 (+17.8%)</td></tr><tr><td>위쪽이 0.5초에서 끝</td><td>0.025~0.5초 6개</td><td>0.128초 (+6.9%)</td><td>0.495초 (+2.3%)</td><td>0.500초 (−34.8%)</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">버킷 배치별 백분위수 추정 오차 (로그정규 20만 건, 절댓값)</figcaption><div class="viz-legend"><span><i class="s1"></i>p50</span><span><i class="s2"></i>p99</span><span><i class="s3"></i>p99.9</span></div><svg viewBox="0 0 640 300" role="img" aria-label="버킷 배치별 백분위수 추정 오차 (로그정규 20만 건, 절댓값)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">50</text><rect class="s1" x="73.6" y="194.1" width="32.3" height="59.9" rx="3"><title>기본값 14개 · p50 12.7%</title></rect><rect class="s2" x="107.9" y="241.7" width="32.3" height="12.3" rx="3"><title>기본값 14개 · p99 2.6%</title></rect><rect class="s3" x="142.1" y="238.9" width="32.3" height="15.1" rx="3"><title>기본값 14개 · p99.9 3.2%</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">기본값 14개</text><rect class="s1" x="217.6" y="25.1" width="32.3" height="228.9" rx="3"><title>성긴 4개 · p50 48.5%</title></rect><rect class="s2" x="251.9" y="239.8" width="32.3" height="14.2" rx="3"><title>성긴 4개 · p99 3%</title></rect><rect class="s3" x="286.1" y="139.8" width="32.3" height="114.2" rx="3"><title>성긴 4개 · p99.9 24.2%</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">성긴 4개</text><rect class="s1" x="361.6" y="244.1" width="32.3" height="9.9" rx="3"><title>SLO 중심 9개 · p50 2.1%</title></rect><rect class="s2" x="395.9" y="206.3" width="32.3" height="47.7" rx="3"><title>SLO 중심 9개 · p99 10.1%</title></rect><rect class="s3" x="430.1" y="170.0" width="32.3" height="84.0" rx="3"><title>SLO 중심 9개 · p99.9 17.8%</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">SLO 중심 9개</text><rect class="s1" x="505.6" y="221.4" width="32.3" height="32.6" rx="3"><title>0.5초에서 끝 6개 · p50 6.9%</title></rect><rect class="s2" x="539.9" y="243.1" width="32.3" height="10.9" rx="3"><title>0.5초에서 끝 6개 · p99 2.3%</title></rect><rect class="s3" x="574.1" y="89.7" width="32.3" height="164.3" rx="3"><title>0.5초에서 끝 6개 · p99.9 34.8%</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">0.5초에서 끝 6개</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">0.5초에서 끝나는 배치의 p99.9는 실제보다 낮게(−34.8%) 나왔어요. 나머지는 모두 높게 나왔어요</p><div class="viz-cap">단위: % · 자료: random.seed(7) 로그정규(중앙값 0.12초, σ 0.6) 20만 건으로 직접 시뮬레이션</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>p50</th><th>p99</th><th>p99.9</th></tr></thead><tbody><tr><td>기본값 14개</td><td>12.7</td><td>2.6</td><td>3.2</td></tr><tr><td>성긴 4개</td><td>48.5</td><td>3</td><td>24.2</td></tr><tr><td>SLO 중심 9개</td><td>2.1</td><td>10.1</td><td>17.8</td></tr><tr><td>0.5초에서 끝 6개</td><td>6.9</td><td>2.3</td><td>34.8</td></tr></tbody></table></div></details></figure>
<p>이 표에서 읽을 점은 세 가지예요.</p>
<ul><li><strong>점 추정치보다 버킷 폭을 보세요.</strong> 성긴 배치의 p99가 3.0%로 우연히 잘 맞았지만, p99가 든 버킷은 0.1~0.5초라 최악이면 0.4초까지 틀릴 수 있어요. 분포가 조금만 바뀌어도 48%짜리 p50처럼 크게 어긋나요.</li><li><strong>경계가 촘촘한 구간 밖은 오히려 나빠져요.</strong> SLO 중심 배치는 0.3초 아래가 촘촘해 p50·p90은 1~2%로 맞췄지만, 0.4~0.6초 버킷이 넓어 p99는 10% 틀렸어요.</li><li><strong>맨 위 유한 경계보다 큰 백분위수는 그 경계에 붙어요.</strong> 0.5초에서 끝나는 배치는 p99.9가 정확히 0.500초로 나왔어요. 실제 0.766초 요청이 있어도 그래프는 0.5에서 평평해요.</li></ul>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/prometheus-histogram-bucket-layout-native-migration-guide-2.svg" alt="성긴 4개·기본값·SLO 중심 9개 배치에서 실제 p99 0.485초가 든 버킷이 각각 0.1~0.5초, 0.25~0.5초, 0.4~0.6초임을 보여 주는 수평 축 그림" width="800" height="470" loading="lazy" decoding="async"><figcaption>색 칸의 폭이 그 배치에서 p99 추정이 틀릴 수 있는 최대 범위예요</figcaption></figure>
<p>그래서 경계는 이 순서로 정해요.</p>
<ol><li><strong>SLO 값을 경계로 넣어요.</strong> 300ms가 목표면 <code>0.3</code>이 꼭 있어야 해요. 그래야 아래 절의 비율 쿼리가 정확해요.</li><li><strong>알림·대시보드에 쓰는 백분위수가 평소 떨어지는 자리 앞뒤에 경계를 둬요.</strong> 위 예라면 p99가 0.45~0.55초 근처라 <code>0.4, 0.5, 0.6</code>을 두면 오차 상한이 0.1초로 줄어요.</li><li><strong>맨 위 유한 경계는 타임아웃 값 이상으로 둬요.</strong> 게이트웨이 타임아웃이 30초면 <code>30</code>이 있어야 장애 때 p99가 경계에 붙지 않아요.</li><li><strong>지연은 배수 간격이 효율적이에요.</strong> 1·2.5·5 반복이나 2배씩 늘리는 배치가 적은 버킷으로 넓은 범위를 덮어요. 파이썬 클라이언트 기본값도 이 패턴이에요.</li></ol>
<h2 id="s4">SLO 판정은 백분위수 대신 비율로</h2>
<p>"p95가 300ms 이하인가"는 "300ms 이하 요청 비율이 95% 이상인가"와 같은 질문이에요. 그런데 뒤쪽은 보간이 필요 없어요. <code>le="0.3"</code> 버킷이 있으면 그 버킷의 증가량을 전체 개수로 나누면 끝이에요.</p>
<pre class="code-block" data-lang="promql"><code># 최근 5분 300ms 이하 요청 비율 — 클래식, 보간 없이 정확
sum(rate(http_request_duration_seconds_bucket{le=&quot;0.3&quot;}[5m]))
/
sum(rate(http_request_duration_seconds_count[5m]))

# 네이티브(NHCB 포함) — 0.3 이 버킷 경계와 맞지 않으면 추정치
histogram_fraction(0, 0.3, sum(rate(http_request_duration_seconds[5m])))</code></pre>
<p>위 시뮬레이션 데이터에서 0.3초 이하 비율은 93.67%였어요. 백분위수로 보면 "p95가 0.3초를 조금 넘는다"인데, 각 배치의 p95 추정치는 버킷 폭만큼 흔들리지만 비율은 경계가 맞으면 정확히 93.67%로 나와요. <a href="https://prometheus.io/docs/prometheus/latest/querying/functions/" target="_blank" rel="noopener">함수 문서</a>도 <code>histogram_fraction()</code>을 클래식 히스토그램에 쓰면 경계에서 먼 값을 고르기 쉬워 오차가 커진다며, 버킷 시계열을 직접 다루는 방식을 더 안정적이라고 권해요. 공식 문서의 Apdex 예제도 같은 원리로 목표값과 허용값 버킷을 바로 나눠요.</p>
<h2 id="s5">네이티브 히스토그램 전환 설정과 해상도 고르기</h2>
<p><a href="https://prometheus.io/docs/specs/native_histograms/" target="_blank" rel="noopener">네이티브 히스토그램 스펙</a>에 따르면 v3.8.0부터 정식 기능이고, v3.9부터는 <code>--enable-feature=native-histograms</code> 플래그가 아무 효과가 없어요. 대신 스크레이프 설정에서 직접 켜야 해요. v4부터는 <code>scrape_native_histograms</code>와 원격 쓰기의 <code>send_native_histograms</code> 기본값이 <code>true</code>가 될 예정이에요.</p>
<pre class="code-block" data-lang="yaml"><code># prometheus.yml — 전환 기간 예시
scrape_configs:
  - job_name: api
    scrape_native_histograms: true          # v3 기본 false
    always_scrape_classic_histograms: true  # 전환 중 대시보드용으로 클래식도 같이 받기
    native_histogram_bucket_limit: 160      # 히스토그램 하나당 버킷 수 상한 (기본 0 = 제한 없음)
  - job_name: legacy-python
    convert_classic_histograms_to_nhcb: true # 클래식만 내는 앱을 NHCB로 받기</code></pre>
<p>설정별 판단 기준은 이래요. 값과 기본값은 <a href="https://prometheus.io/docs/prometheus/latest/configuration/configuration/" target="_blank" rel="noopener">설정 문서</a>에서 확인했어요.</p>
<div class="table-wrap"><table><thead><tr><th>설정</th><th>기본값</th><th>언제 켜나</th></tr></thead><tbody><tr><td><code>scrape_native_histograms</code></td><td>false (v3)</td><td>앱이 네이티브를 내보낼 때. 켜면 protobuf 형식을 우선 협상해요</td></tr><tr><td><code>always_scrape_classic_histograms</code></td><td>false</td><td>전환 중 옛 대시보드·알림이 <code>_bucket</code>을 쓸 때만. 끝나면 꺼요</td></tr><tr><td><code>convert_classic_histograms_to_nhcb</code></td><td>false</td><td>앱을 못 고치는데 저장 비용을 줄이고 싶을 때</td></tr><tr><td><code>native_histogram_bucket_limit</code></td><td>0</td><td>버킷 폭주를 막는 상한. 넘으면 해상도를 반씩 낮추고, NHCB가 넘으면 스크레이프가 실패해요</td></tr><tr><td><code>native_histogram_min_bucket_factor</code></td><td>0</td><td>해상도가 쓸데없이 높은 히스토그램을 서버에서 깎을 때</td></tr></tbody></table></div>
<p>해상도는 스키마 번호로 정해지고, 인접 경계의 비율(버킷 계수)이 2^(2^−n)이에요. 버킷 계수에서 1을 뺀 값이 버킷의 상대 폭이라 오차 상한이 돼요. 계측 쪽 예시로 자주 쓰는 계수 1.1은 스키마 3(계수 1.0905, 상대 폭 약 9%)에 해당해요. 위 시뮬레이션 데이터를 스키마 3으로 담으면 채워진 버킷은 60개였고, 지수 보간(버킷 안을 로그 균등으로 가정)으로 추정한 p99 오차는 0.5%였어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">스키마별 1ms~60초 범위를 덮는 데 필요한 버킷 수</figcaption><svg viewBox="0 0 640 220" role="img" aria-label="스키마별 1ms~60초 범위를 덮는 데 필요한 버킷 수" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="169" y="29" text-anchor="end">스키마 0 (계수 2)</text><rect class="s1" x="179" y="15" width="10.4" height="20" rx="3"><title>스키마 0 (계수 2) · 16개</title></rect><text class="viz-val" x="195.4" y="29">16개</text><text class="viz-ax lg" x="169" y="63" text-anchor="end">스키마 1 (1.414)</text><rect class="s1" x="179" y="49" width="20.9" height="20" rx="3"><title>스키마 1 (1.414) · 32개</title></rect><text class="viz-val" x="205.9" y="63">32개</text><text class="viz-ax lg" x="169" y="97" text-anchor="end">스키마 2 (1.189)</text><rect class="s1" x="179" y="83" width="41.7" height="20" rx="3"><title>스키마 2 (1.189) · 64개</title></rect><text class="viz-val" x="226.7" y="97">64개</text><text class="viz-ax lg" x="169" y="131" text-anchor="end">스키마 3 (1.091)</text><rect class="s1" x="179" y="117" width="82.8" height="20" rx="3"><title>스키마 3 (1.091) · 127개</title></rect><text class="viz-val" x="267.8" y="131">127개</text><text class="viz-ax lg" x="169" y="165" text-anchor="end">스키마 4 (1.044)</text><rect class="s1" x="179" y="151" width="165.5" height="20" rx="3"><title>스키마 4 (1.044) · 254개</title></rect><text class="viz-val" x="350.5" y="165">254개</text><text class="viz-ax lg" x="169" y="199" text-anchor="end">스키마 5 (1.022)</text><rect class="s1" x="179" y="185" width="331.0" height="20" rx="3"><title>스키마 5 (1.022) · 508개</title></rect><text class="viz-val" x="516.0" y="199">508개</text></svg><p class="viz-note">실제로는 관측값이 들어온 버킷만 저장돼서 분포가 좁으면 훨씬 적어요</p><div class="viz-cap">단위: 개 · 자료: ln(60 ÷ 0.001) ÷ ln(버킷 계수)를 올림해 직접 계산. 계수 = 2^(2^-n)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>버킷 수</th></tr></thead><tbody><tr><td>스키마 0 (계수 2)</td><td>16</td></tr><tr><td>스키마 1 (1.414)</td><td>32</td></tr><tr><td>스키마 2 (1.189)</td><td>64</td></tr><tr><td>스키마 3 (1.091)</td><td>127</td></tr><tr><td>스키마 4 (1.044)</td><td>254</td></tr><tr><td>스키마 5 (1.022)</td><td>508</td></tr></tbody></table></div></details></figure>
<p>스키마를 하나 올릴 때마다 해상도가 두 배, 최악의 버킷 수도 두 배예요. 1ms에서 60초까지 퍼질 수 있는 지표에 스키마 4 이상을 주면 버킷이 254개를 넘을 수 있어서, <code>native_histogram_bucket_limit: 160</code> 같은 상한을 함께 거는 게 안전해요. 스펙은 Go 라이브러리의 버킷 수 제한 예시로 160을 들면서, 이 값이 OTel 지수 히스토그램의 기본값과 같다고 적었어요.</p>
<p>NHCB는 경계가 다른 히스토그램끼리 합칠 때 <strong>정확히 같은 경계만 남겨요.</strong> 배포 중에 버킷 배치가 크게 바뀌면 공통 경계가 하나도 없어 <code>+Inf</code> 버킷 하나짜리 히스토그램이 될 수 있어요. 지수 스키마는 경계가 수학적으로 정해져 있어서 이 문제가 없어요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">p99 그래프가 이상할 때 확인 순서</h2>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>먼저 볼 것</th><th>고치는 방법</th></tr></thead><tbody><tr><td>p99가 특정 값에 붙어 평평</td><td>그 값이 맨 위 유한 경계인지</td><td>타임아웃 이상으로 경계 추가</td></tr><tr><td>결과가 비거나 NaN</td><td><code>+Inf</code> 버킷이 있는지, 창 안 요청이 0인지</td><td><code>rate</code> 범위를 늘리고 트래픽 없는 시간은 NaN이 정상</td></tr><tr><td>"needed to be fixed for monotonicity" 주석</td><td>배포 중 버킷 경계가 섞였는지</td><td>경계 변경 시 메트릭 이름을 새로 두기</td></tr><tr><td>배포 직후 크게 출렁임</td><td><code>rate</code> 없이 <code>_bucket</code>을 그대로 넣었는지</td><td><code>rate</code> → <code>sum by (le)</code> → <code>histogram_quantile</code> 순서</td></tr><tr><td>파드 합계 p99가 파드별 값과 안 맞음</td><td>파드별 p99를 <code>avg</code>했는지</td><td>버킷을 먼저 합치고 백분위수는 마지막에 한 번</td></tr><tr><td>같은 경계가 두 줄로 갈림</td><td><code>le="1"</code>과 <code>le="1.0"</code>이 섞였는지</td><td>v3은 수집 때 정규화. v2 데이터와 섞을 때 주의</td></tr></tbody></table></div>
<p>첫 줄이 가장 흔해요. 위 시뮬레이션의 마지막 배치처럼 백분위수가 <code>+Inf</code> 버킷에 떨어지면 두 번째로 높은 경계가 그대로 나와요. 이걸 확인하는 쿼리는 이래요.</p>
<pre class="code-block" data-lang="promql"><code># 0 보다 크면 맨 위 유한 경계(여기선 10초)를 넘는 요청이 있다는 뜻
sum(rate(http_request_duration_seconds_bucket{le=&quot;+Inf&quot;}[5m]))
- sum(rate(http_request_duration_seconds_bucket{le=&quot;10.0&quot;}[5m]))</code></pre>
<p>두 번째 줄의 NaN은 대부분 정상이에요. 함수 문서 기준으로 관측값이 0개거나 버킷이 2개 미만이거나 가장 높은 경계가 <code>+Inf</code>가 아니면 NaN이라, 밤사이 트래픽 없는 엔드포인트는 NaN 구간이 생기는 게 맞아요.</p>
<p><code>histogram_quantile</code>은 누적값이 rank 이상인 첫 버킷을 이분 탐색으로 찾아요. 경계 조건(이상·초과)이 하나만 달라져도 결과 버킷이 바뀌는 구조라, 직접 재현 스크립트를 짤 때는 <a href="https://tool.smithlab.co.kr/posts/developer/binary-search-bisect-selection-bug-checklist/">이분 탐색 bisect·lower_bound 경계 버그 체크리스트</a>의 <code>bisect_left</code>·<code>bisect_right</code> 구분을 참고하세요. 원본 구현은 <a href="https://github.com/prometheus/prometheus/blob/main/promql/quantile.go" target="_blank" rel="noopener">promql/quantile.go</a>의 <code>BucketQuantile</code>이에요.</p>
<h2 id="s7">도입 전에 따질 것</h2>
<aside class="callout warn"><b class="callout-h">주의</b><p>네이티브 히스토그램을 켜면 스크레이프가 텍스트 대신 protobuf 형식으로 바뀌어요. 스펙은 직관과 반대로 텍스트 형식 스크레이프가 일반적으로 자원을 훨씬 덜 쓴다고 적었어요. 타깃이 많은 서버라면 켜기 전후의 CPU를 꼭 비교하세요.</p></aside>
<ul><li><strong>원격 저장소가 받는지</strong>: 원격 쓰기는 <code>send_native_histograms</code>를 따로 켜야 하고, 받는 쪽(장기 저장소)이 네이티브 히스토그램을 지원해야 해요. 지원하지 않으면 전환 기간 내내 <code>always_scrape_classic_histograms</code>를 켜 둬야 해서 비용이 오히려 늘어요.</li><li><strong>대시보드·알림 쿼리 개수</strong>: 네이티브는 <code>_bucket</code> 접미사와 <code>le</code> 라벨이 없어서 쿼리를 다시 써야 해요. <code>sum by (le)</code>가 <code>sum</code>으로 바뀌는 정도지만, 패널과 알림 규칙이 수백 개면 그 자체가 작업량이에요.</li><li><strong>라이브러리 지원</strong>: 공식 문서 기준 네이티브는 protobuf 지원 라이브러리 위주예요. 쓰는 언어가 아니라면 앱은 클래식 그대로 두고 <code>convert_classic_histograms_to_nhcb</code>로 서버에서 바꾸는 게 현실적인 첫 단계예요.</li><li><strong>경계 변경 정책</strong>: 클래식·NHCB를 계속 쓴다면 "버킷 경계를 바꿀 때는 새 메트릭 이름"을 팀 규칙으로 정해 두세요. 롤링 배포 중 경계가 섞이면 합계가 깨져요.</li><li><strong>레코딩 규칙</strong>: 라벨 조합이 많은 지표에서 <code>histogram_quantile</code>을 대시보드마다 바로 돌리면 무거워요. 공식 비교 표도 느릴 때는 레코딩 규칙을 쓰라고 해요. 자주 보는 p99는 미리 계산해 두는 편이 싸요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>파이썬 클라이언트 기본 버킷을 그대로 써도 되나요?</h3>
<p>5ms~10초 범위의 일반 웹·RPC 요청이면 출발점으로 괜찮아요. 위 시뮬레이션에서도 p99 오차가 2.6%로 무난했어요. 다만 SLO 값이 0.3초처럼 기본 경계에 없으면 비율 쿼리를 정확히 낼 수 없으니 그 값은 추가하고, 타임아웃이 10초를 넘으면 위쪽 경계도 늘려야 해요.</p>
<h3>버킷은 몇 개가 적당한가요?</h3>
<p>정답은 없지만 클래식은 10~15개 안에서 SLO 값과 자주 보는 백분위수 근처에 몰아 두는 게 비용 대비 효과가 좋아요. 라벨 조합 2,000개 기준으로 버킷 하나를 더할 때마다 시계열이 2,000개씩 늘어요. 15개를 넘겨야 할 만큼 정확도가 필요하면 네이티브나 NHCB를 먼저 검토해요.</p>
<h3>네이티브 히스토그램 해상도는 어떻게 정하나요?</h3>
<p>버킷 계수 1.1(스키마 3, 상대 폭 약 9%)로 시작하는 경우가 많아요. 공식 문서의 예시도 이 값을 써요. 더 정밀하게 보고 싶으면 스키마를 올리되 버킷 수가 두 배씩 늘어나니 <code>native_histogram_bucket_limit</code>으로 상한을 같이 걸어요.</p>
<h3>클래식에서 네이티브로 바꾸면 과거 데이터는 어떻게 되나요?</h3>
<p>클래식 시계열은 <code>_bucket</code> 이름으로, 네이티브는 원래 이름 그대로 저장돼서 서로 충돌하지 않아요. 전환 기간에 <code>always_scrape_classic_histograms</code>로 둘 다 받으면 옛 대시보드를 유지하면서 새 쿼리를 검증할 수 있어요. 보존 기간이 지나 옛 클래식 데이터가 빠지면 그 설정을 끄면 돼요.</p>
<h3>NHCB로 받으면 클래식의 오차 문제도 사라지나요?</h3>
<p>아니요. NHCB는 저장 형식만 네이티브이고 경계는 앱이 정한 클래식 경계 그대로라, 오차 상한도 클래식과 같아요. 얻는 것은 저장 비용 절감과 네이티브용 쿼리 문법이에요. 정확도까지 올리려면 앱이 지수 스키마 네이티브 히스토그램을 직접 내보내야 해요.</p>
<h2 id="s9">정리</h2>
<ul><li>여러 인스턴스를 합쳐 봐야 하면 히스토그램, 라이브러리가 지원하면 네이티브가 기본 선택이에요. 못 쓰면 클래식으로 내보내고 서버에서 NHCB로 받을 수 있어요.</li><li>클래식의 오차 상한은 백분위수가 든 버킷의 폭이에요. SLO 값과 자주 보는 백분위수 근처에 경계를 두고, 맨 위 경계는 타임아웃 이상으로 잡아요.</li><li>버킷을 늘리기 전에 라벨 수부터 줄이는 게 싸요.</li><li>SLO는 <code>le</code> 버킷 ÷ <code>_count</code> 비율로 판정하고, 그래프가 평평하거나 NaN이면 위 확인 순서표의 첫 두 줄부터 봐요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/prometheus-histogram-quantile-internals" target="_blank" rel="noopener">프로메테우스 histogram_quantile 원리 — 버킷과 보간</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>하루 20분 독서로 1년에 몇 권? — 읽는 속도 재는 법과 연…</title>
      <link>https://tool.smithlab.co.kr/posts/life/reading-goal-daily-minutes-books-per-year-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/reading-goal-daily-minutes-books-per-year-guide/</guid>
      <pubDate>Sun, 08 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>새해 목표에 "책 읽기"를 적어 본 사람은 많지만, 연말에 몇 권을 읽었는지 세어 본 사람은 많지 않아요. 목표가 "많이 읽기"처럼 막연하면 바쁜 주에 한 번 빠지고, 두 번 빠지고, 어느새 책이 가방 속 짐이 되기 때문이에요. 결론부터 말하면 <strong>하루 독서 시간만 정하면 1년 독서량은 계산으로 나와요.</strong> 300쪽 책을 1분에 1.5쪽씩 읽는 사람이 하루 20분을 읽으면 1년에 약 36.5권, 하루 10분이면 약 18.2권이에요.</p>
<p>이 글은 그 계산을 직접 해 볼 수 있게 풀어 쓴 가이드예요. 내 읽는 속도를 5분 만에 재는 방법, 읽는 속도와 하루 시간에 따른 1년 권수, 연 12권·24권·52권·100권 목표를 채우는 하루 시간, 책 두께가 계획에 주는 영향, 그리고 하루 시간을 현실적으로 만드는 방법을 순서대로 다뤄요. 모든 숫자는 사용자가 입력하는 값으로 계산한 산수이고 외부 통계를 인용하지 않아요. 자신의 값을 바로 넣어 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/reading-goal-calculator/">독서 목표 계산기</a>를 같이 열어 두세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>1년에 읽는 책 = 365 × 하루 독서 분 ÷ (책 쪽수 ÷ 분당 쪽수)예요. 300쪽을 분당 1.5쪽으로 읽으면 한 권에 200분이 걸려요.</li><li>같은 조건에서 하루 10분은 약 18.2권, 20분은 약 36.5권, 30분은 약 54.8권이에요. 연 12권은 하루 7분, 연 24권은 14분, 연 52권은 29분이면 돼요.</li><li>분당 쪽수는 사람마다 달라서 5분 동안 읽은 쪽수를 5로 나눠 직접 재는 것이 가장 정확해요.</li><li>쪽수가 두 배인 책은 같은 시간에 읽는 권수가 절반이 되니, 권수 목표를 세울 때는 책 두께도 같이 정하세요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">독서량을 계산으로 바꾸면 목표가 쉬워져요</h2>
<p>독서 목표는 보통 두 가지 방식으로 세워요. 하나는 "한 달에 두 권"처럼 <strong>권수 목표</strong>, 다른 하나는 "매일 30분"처럼 <strong>시간 목표</strong>예요. 둘은 서로 변환할 수 있어요. 권수 목표를 시간으로 바꾸면 "이번 달 두 권"이 "오늘 15분"이 되고, 시간 목표를 권수로 바꾸면 "매일 30분"이 "1년에 50권 남짓"이 돼요. 변환에 필요한 값은 세 개뿐이에요.</p>
<ul><li><strong>책 한 권의 쪽수</strong>: 자주 읽는 책이 에세이 중심이면 200쪽 안팎, 소설이나 자기계발서는 300쪽 안팎, 전문서나 두꺼운 소설은 450쪽 이상이에요.</li><li><strong>1분에 읽는 쪽수</strong>: 사람과 책 종류에 따라 크게 달라요. 아래에서 재는 방법을 설명해요.</li><li><strong>하루에 낼 수 있는 독서 시간</strong>: 이상적인 시간이 아니라 "아무리 바빠도 낼 수 있는" 시간이에요.</li></ul>
<p>이 세 값만 있으면 한 권을 읽는 시간은 쪽수를 분당 쪽수로 나눈 값이에요. 300쪽을 분당 1.5쪽으로 읽으면 300 ÷ 1.5 = 200분, 즉 3시간 20분이에요. 하루 20분씩 읽으면 200 ÷ 20 = 10일에 한 권을 끝내는 속도예요. 1년은 365일이므로 365 ÷ 10 = 36.5권이 되고, 달리 말하면 365 × 20 = 7,300분을 한 권 시간 200분으로 나눈 값이에요.</p>
<h2 id="s2">내 읽는 속도 5분 만에 재는 방법</h2>
<p>분당 쪽수는 가장 불확실한 값이라서, 남의 평균을 쓰는 것보다 직접 재는 편이 정확해요. 방법은 간단해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/reading-goal-daily-minutes-books-per-year-guide-1.svg" alt="읽는 속도를 재는 3단계: 5분 읽기, 넘긴 쪽수를 5로 나누기, 쪽수를 분당 쪽수로 나눠 한 권 시간 구하기를 나열한 도식" width="800" height="450" loading="lazy" decoding="async"><figcaption>읽는 속도 재기 3단계</figcaption></figure>
<ol><li>자주 읽는 종류의 책을 펴고 타이머를 5분에 맞춰요.</li><li>평소 읽는 자세와 집중도로 읽어요. 속독하려고 애쓰면 값이 부풀려져요.</li><li>타이머가 울리면 읽은 쪽수(시작 쪽과 끝 쪽의 차이)를 5로 나눠요. 7.5쪽을 읽었다면 분당 1.5쪽이에요.</li></ol>
<p>책 종류마다 따로 재 보면 더 좋아요. 같은 사람도 가벼운 에세이는 분당 2쪽, 낯선 용어가 많은 전문서는 분당 0.7쪽처럼 속도가 크게 달라지기 때문이에요. 두 종류 이상을 읽는다면 "주로 읽는 책"의 속도로 계획을 세우고, 어려운 책은 별도로 시간을 더 잡으세요. 측정은 한 번만 하지 말고 이틀쯤 평균을 내면 컨디션에 따른 오차가 줄어요.</p>
<p>참고로 이 글의 표에서 쓰는 분당 1·1.5·2쪽은 어디까지나 계산을 보여 주기 위한 예시값이지 평균이 아니에요. 한국인의 독서 시간이나 독서량 통계는 한국출판문화산업진흥원 같은 기관의 <a href="https://www.kpipa.or.kr/" target="_blank" rel="noopener">공식 사이트</a>에서 최신 자료를 확인할 수 있어요.</p>
<h2 id="s3">읽는 속도와 하루 시간별 1년 독서량 (300쪽 기준)</h2>
<p>책이 300쪽일 때 하루 독서 시간과 분당 쪽수에 따라 1년에 읽는 권수를 계산했어요. 파이썬 분수 계산으로 검산한 값이고, 소수점 첫째 자리까지 표시했어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">하루 독서 시간별 1년 독서량 (300쪽 책, 365일 매일 읽을 때)</figcaption><div class="viz-legend"><span><i class="s1"></i>분당 1쪽</span><span><i class="s2"></i>분당 1.5쪽</span><span><i class="s3"></i>분당 2쪽</span></div><svg viewBox="0 0 640 300" role="img" aria-label="하루 독서 시간별 1년 독서량 (300쪽 책, 365일 매일 읽을 때)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">125</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">150</text><rect class="s1" x="73.6" y="234.8" width="32.3" height="19.2" rx="3"><title>10분 · 분당 1쪽 12.2권</title></rect><rect class="s2" x="107.9" y="225.4" width="32.3" height="28.6" rx="3"><title>10분 · 분당 1.5쪽 18.2권</title></rect><rect class="s3" x="142.1" y="215.8" width="32.3" height="38.2" rx="3"><title>10분 · 분당 2쪽 24.3권</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">10분</text><rect class="s1" x="217.6" y="215.8" width="32.3" height="38.2" rx="3"><title>20분 · 분당 1쪽 24.3권</title></rect><rect class="s2" x="251.9" y="196.6" width="32.3" height="57.4" rx="3"><title>20분 · 분당 1.5쪽 36.5권</title></rect><rect class="s3" x="286.1" y="177.4" width="32.3" height="76.6" rx="3"><title>20분 · 분당 2쪽 48.7권</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">20분</text><rect class="s1" x="361.6" y="196.6" width="32.3" height="57.4" rx="3"><title>30분 · 분당 1쪽 36.5권</title></rect><rect class="s2" x="395.9" y="167.8" width="32.3" height="86.2" rx="3"><title>30분 · 분당 1.5쪽 54.8권</title></rect><rect class="s3" x="430.1" y="139.1" width="32.3" height="114.9" rx="3"><title>30분 · 분당 2쪽 73권</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">30분</text><rect class="s1" x="505.6" y="139.1" width="32.3" height="114.9" rx="3"><title>60분 · 분당 1쪽 73권</title></rect><rect class="s2" x="539.9" y="81.7" width="32.3" height="172.3" rx="3"><title>60분 · 분당 1.5쪽 110권</title></rect><rect class="s3" x="574.1" y="24.3" width="32.3" height="229.7" rx="3"><title>60분 · 분당 2쪽 146권</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">60분</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">하루 시간이 두 배가 되면 권수도 정확히 두 배, 읽는 속도가 두 배가 되어도 마찬가지예요.</p><div class="viz-cap">단위: 권 · 자료: 365 × 하루 분 × 분당 쪽수 ÷ 300 으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>분당 1쪽</th><th>분당 1.5쪽</th><th>분당 2쪽</th></tr></thead><tbody><tr><td>10분</td><td>12.2</td><td>18.2</td><td>24.3</td></tr><tr><td>20분</td><td>24.3</td><td>36.5</td><td>48.7</td></tr><tr><td>30분</td><td>36.5</td><td>54.8</td><td>73</td></tr><tr><td>60분</td><td>73</td><td>110</td><td>146</td></tr></tbody></table></div></details></figure>
<p>이 표에서 눈여겨볼 점이 세 가지 있어요.</p>
<p>첫째, <strong>시간과 속도는 서로 바꿔 쓸 수 있어요.</strong> 분당 1쪽으로 하루 20분을 읽는 사람(24.3권)과 분당 2쪽으로 하루 10분을 읽는 사람(24.3권)의 1년 독서량은 같아요. 읽는 속도를 두 배로 올리는 것은 어렵지만, 하루 10분을 더 내는 것은 상대적으로 쉬운 편이에요.</p>
<p>둘째, <strong>하루 10분은 생각보다 작지 않아요.</strong> 분당 1.5쪽으로 하루 10분만 읽어도 1년에 18권쯤이에요. 한 달 평균 1.5권이니 직장인이 시작하기에 무리 없는 목표예요.</p>
<p>셋째, <strong>하루 60분은 큰 목표예요.</strong> 분당 1.5쪽으로 하루 한 시간이면 1년에 109.5권인데, 이 시간을 매일 확보하려면 생활 구조를 바꿔야 해요. 처음부터 한 시간을 잡았다가 실패하는 것보다 20~30분으로 시작해 늘리는 편이 오래가요.</p>
<h2 id="s4">목표 권수별 하루 필요 시간: 연 12·24·52·100권</h2>
<p>반대로 "올해 몇 권을 읽겠다"를 먼저 정했다면 하루에 필요한 시간은 이렇게 구해요. 목표 권수 × 한 권 읽는 시간 ÷ 365를 올림하면 돼요. 300쪽, 분당 1.5쪽(한 권 200분) 기준으로 계산했어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">연간 목표 권수별 하루 필요한 독서 시간 (300쪽, 분당 1.5쪽)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="연간 목표 권수별 하루 필요한 독서 시간 (300쪽, 분당 1.5쪽)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="78" y="29" text-anchor="end">연 12권</text><rect class="s1" x="88" y="15" width="56.2" height="20" rx="3"><title>연 12권 · 7분</title></rect><text class="viz-val" x="150.2" y="29">7분</text><text class="viz-ax lg" x="78" y="63" text-anchor="end">연 24권</text><rect class="s1" x="88" y="49" width="112.5" height="20" rx="3"><title>연 24권 · 14분</title></rect><text class="viz-val" x="206.5" y="63">14분</text><text class="viz-ax lg" x="78" y="97" text-anchor="end">연 52권</text><rect class="s1" x="88" y="83" width="233.0" height="20" rx="3"><title>연 52권 · 29분</title></rect><text class="viz-val" x="327.0" y="97">29분</text><text class="viz-ax lg" x="78" y="131" text-anchor="end">연 100권</text><rect class="s1" x="88" y="117" width="441.8" height="20" rx="3"><title>연 100권 · 55분</title></rect><text class="viz-val" x="535.8" y="131">55분</text></svg><p class="viz-note">하루 쪽수로는 각각 10쪽, 20쪽, 43쪽, 83쪽이에요.</p><div class="viz-cap">단위: 분 · 자료: 목표 권수 × 200분 ÷ 365 를 올림해서 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>하루 필요 시간</th></tr></thead><tbody><tr><td>연 12권</td><td>7</td></tr><tr><td>연 24권</td><td>14</td></tr><tr><td>연 52권</td><td>29</td></tr><tr><td>연 100권</td><td>55</td></tr></tbody></table></div></details></figure>
<div class="table-wrap"><table><thead><tr><th>올해 목표</th><th>총 독서 시간</th><th>하루 필요 시간</th><th>하루 필요 쪽수</th></tr></thead><tbody><tr><td>연 12권 (한 달에 한 권)</td><td>40시간</td><td>7분</td><td>10쪽</td></tr><tr><td>연 24권 (한 달에 두 권)</td><td>80시간</td><td>14분</td><td>20쪽</td></tr><tr><td>연 52권 (주에 한 권)</td><td>약 173.3시간</td><td>29분</td><td>43쪽</td></tr><tr><td>연 100권</td><td>약 333.3시간</td><td>55분</td><td>83쪽</td></tr></tbody></table></div>
<p>연 12권은 하루 7분, 즉 책 10쪽이에요. 이 정도면 커피가 식는 시간에도 채울 수 있는 분량이에요. 연 24권은 하루 14분, 20쪽으로 지하철 한 구간쯤이고, 주에 한 권인 연 52권은 하루 29분(43쪽)이에요. 연 100권은 하루 55분이 필요해서, 하루 한 시간 가까이를 안정적으로 확보할 수 있는 사람의 목표예요.</p>
<p>여기서 중요한 것은 <strong>"한 달에 한 권" 같은 막연한 단위를 "하루 몇 쪽"으로 바꾸는 습관</strong>이에요. 한 달에 한 권은 하루 10쪽, 이 쪽수는 책갈피가 어디까지 가야 하는지 눈으로 확인할 수 있는 단위예요. 매일 저녁 책갈피가 10쪽 앞으로 가 있으면 그날 목표를 채운 거예요.</p>
<h2 id="s5">책 두께가 계획을 바꿔요</h2>
<p>권수 목표는 책 두께를 같이 정하지 않으면 의미가 흐려져요. 150쪽 책 24권(3,600쪽)과 500쪽 책 24권(12,000쪽)은 읽는 양이 3배 이상 차이 나요. 분당 1.5쪽으로 읽는 사람이 하루 20분을 읽는다고 할 때 책 두께별로 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>책 쪽수</th><th>한 권 읽는 시간</th><th>하루 20분이면 1년 권수</th><th>연 24권 하루 필요 시간</th></tr></thead><tbody><tr><td>150쪽</td><td>100분</td><td>73.0권</td><td>7분</td></tr><tr><td>250쪽</td><td>약 167분</td><td>43.8권</td><td>11분</td></tr><tr><td>350쪽</td><td>약 233분</td><td>31.3권</td><td>16분</td></tr><tr><td>500쪽</td><td>약 333분</td><td>21.9권</td><td>22분</td></tr></tbody></table></div>
<p>같은 하루 20분이어도 150쪽 책이면 73권, 500쪽 책이면 21.9권이에요. 연 24권 목표를 500쪽 책으로 채우려면 하루 22분이 필요해서 20분으로는 모자라요. 그래서 권수 목표를 세울 때는 "올해는 얇은 책 위주로 권수를 채운다", "올해는 두꺼운 책 3권에 도전한다"처럼 두께 계획을 함께 정하는 것이 좋아요.</p>
<p>독서 습관을 처음 만드는 경우에는 <strong>150~250쪽 책으로 시작</strong>하는 편이 성공 확률이 높아요. 한 권을 끝냈다는 성취감이 빨리 오고, 목표 권수에 가까워지는 속도가 눈에 보이기 때문이에요. 습관이 어느 정도 자리 잡은 뒤에 두꺼운 책을 섞으면 돼요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">하루 시간은 이렇게 만들어요: 틈새 시간 모으기</h2>
<p>하루 30분을 한 번에 내려고 하면 어려워요. 하지만 이미 있는 틈새 시간을 모으면 현실적인 수치가 나와요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/reading-goal-daily-minutes-books-per-year-guide-2.svg" alt="출퇴근 15분, 점심 뒤 5분, 자기 전 10분을 합쳐 하루 30분, 1년 약 54.8권이 되는 계산을 보여 주는 도식" width="800" height="450" loading="lazy" decoding="async"><figcaption>틈새 시간 합산 예시</figcaption></figure>
<p>출퇴근 15분, 점심 뒤 5분, 자기 전 10분을 합치면 하루 30분이에요. 300쪽, 분당 1.5쪽 기준으로 365 × 30 ÷ 200 = 54.75권이니 완독으로는 54권이에요. 연 52권 목표(하루 29분)도 무리 없는 범위예요. 틈새 시간 독서를 오래 하려면 몇 가지만 지키면 돼요.</p>
<ul><li><strong>책을 항상 손 닿는 곳에 두기</strong>: 가방, 침대 머리맡, 거실 소파 옆에 각각 다른 책이나 전자책 앱을 열어 두면 시작하는 데 드는 마찰이 줄어요.</li><li><strong>시간에 붙이기</strong>: "하루 15분 읽기"보다 "저녁 먹고 설거지 끝나면 읽기"처럼 이미 하는 행동 바로 뒤에 붙이면 지켜질 확률이 올라가요.</li><li><strong>화면 시간 일부 바꾸기</strong>: 하루 30분이면 평소 SNS나 영상을 보던 시간의 일부를 옮기는 정도예요. 총량을 늘리는 것이 아니라 비율을 바꾸는 것이에요.</li><li><strong>오디오북 섞기</strong>: 운전이나 걷는 시간에는 오디오북이 편해요. 책 한 권의 재생 시간을 알면 그 시간만큼 한 권으로 계산하고, 읽는 시간과 합쳐서 하루 총 독서 시간으로 넣으면 돼요.</li></ul>
<h2 id="s7">쉬는 날과 실패를 계산에 넣어요</h2>
<p>위 숫자는 365일 하루도 빠지지 않는다는 이론값이에요. 현실에서는 여행, 야근, 컨디션 난조로 쉬는 날이 생겨요. 쉬는 날이 있다면 목표를 낮추기보다 <strong>하루 시간을 조금 늘려 두는 것</strong>이 좋아요.</p>
<ul><li>일주일에 하루 쉰다면 읽는 날은 약 313일이에요. 연 24권(총 4,800분)을 313일에 나누면 하루 약 15.3분이라 16분을 잡아야 해요. 쉬는 날이 없을 때의 14분에서 약 17% 늘어난 값이에요.</li><li>한 달을 통째로 쉬어도 목표를 채우려면 총 독서 시간은 같고 읽는 날이 335일로 줄어서, 하루 시간을 약 9% 늘려야 해요.</li><li>하루를 빠뜨린 날이 이어지면 "오늘 두 배"로 보충하기보다 <strong>다음 날 원래 시간으로 돌아가는 규칙</strong>이 오래가요. 보충 독서는 부담이 되어 다음 날도 빠지기 쉽거든요.</li></ul>
<p>습관이 얼마나 쉽게 끊어지는지는 확률로도 볼 수 있어요. 하루 성공률이 80%라고 해도 30일 연속 성공 확률은 0.8의 30제곱, 약 0.1%에 불과해요. 그래서 "하루도 빠지지 않기"보다 "빠지더라도 다음 날 다시 하기"를 기준으로 목표를 세우는 것이 현실적이에요. 이 관계는 <a href="https://tool.smithlab.co.kr/calculator/habit-streak-probability-calculator/">습관 연속 달성 확률 계산기</a>에서 직접 숫자를 바꿔 볼 수 있어요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">읽는 권수보다 중요한 것들</h2>
<p>권수는 목표를 숫자로 만들어 실천하기 쉽게 하는 도구일 뿐, 독서의 목적은 아니에요. 권수 경쟁에 빠지면 이런 함정이 생겨요.</p>
<ul><li><strong>얇은 책만 고르기</strong>: 권수를 채우려고 쉬운 책만 읽으면 새로운 분야에 도전하지 못해요. 한 해에 몇 권은 어려운 책에 쓰는 시간으로 따로 잡으세요.</li><li><strong>다 읽지 못한 책에 죄책감</strong>: 중간에 덮은 책도 읽은 쪽수만큼은 시간을 쓴 거예요. 이 글의 계산은 읽은 쪽수와 시간 기준이므로 완독 여부로 기록을 포기하지 않아도 돼요.</li><li><strong>이해 없이 넘기기</strong>: 속도를 높이려다 내용이 남지 않으면 의미가 없어요. 중요한 책은 분당 쪽수를 낮게 잡고 메모 시간을 따로 넣으세요.</li><li><strong>기록하기</strong>: 읽은 날과 읽은 쪽수를 달력에 표시하면 진행이 눈에 보여요. 연말에 "올해 읽은 책" 목록은 그 자체로 좋은 기록이 되어요. 연말 목표 점검은 <a href="https://tool.smithlab.co.kr/date/year-end-goal-catchup-calculator/">연말 목표 따라잡기 계산기</a>를 같이 쓰면 남은 기간에 필요한 하루 분량을 계산할 수 있어요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>하루 20분씩 읽으면 1년에 몇 권인가요?</h3>
<p>300쪽 책을 분당 1.5쪽으로 읽는다면 한 권에 200분이 걸려서 365 × 20 ÷ 200 = 36.5권이에요. 분당 1쪽이면 약 24.3권, 분당 2쪽이면 약 48.7권이에요. 정확한 값은 5분 동안 읽은 쪽수를 재서 계산기에 넣어 보세요.</p>
<h3>연 24권을 읽으려면 하루 몇 분이 필요한가요?</h3>
<p>300쪽 책을 분당 1.5쪽으로 읽을 때 하루 14분, 쪽수로는 20쪽이에요. 책이 두껍거나 읽는 속도가 느리면 비례해서 늘어나요. 500쪽 책 위주라면 하루 22분이 필요해요.</p>
<h3>읽는 속도가 느려서 걱정이에요. 속독을 배워야 하나요?</h3>
<p>권수 목표는 속도를 올리는 것보다 하루 시간을 늘리는 쪽이 쉬워요. 분당 1쪽인 사람이 하루 30분을 읽으면 1년에 36.5권으로, 분당 2쪽인 사람이 하루 15분 읽는 것과 같아요. 속도는 습관이 쌓이면 자연스럽게 조금씩 빨라지는 경우가 많으니 처음부터 신경 쓰지 않아도 돼요.</p>
<h3>전자책이나 오디오북도 같은 방식으로 계산하나요?</h3>
<p>전자책은 종이책 쪽수로 환산해서 같은 방식으로 계산하면 되고, 오디오북은 재생 시간을 한 권 시간으로 쓰면 돼요. 예를 들어 재생 시간이 8시간(480분)인 오디오북을 하루 30분 들으면 16일에 한 권을 끝내는 속도예요.</p>
<h3>쉬는 날이 있으면 연 목표가 달라지나요?</h3>
<p>네, 하루도 빠지지 않는 이론값보다 실제 권수는 줄어요. 일주일에 하루 쉰다면 읽는 날이 약 313일이라 같은 목표를 채우려면 하루 시간이 약 17% 늘어나요. 연 24권이면 14분 대신 16분을 잡으세요.</p>
<h3>권수가 많을수록 좋은 독서인가요?</h3>
<p>아니에요. 권수는 목표를 쉽게 만드는 도구일 뿐이고, 같은 책을 두 번 읽거나 한 권을 오래 곱씹는 독서도 가치가 있어요. 이 글의 계산은 내 시간으로 가능한 범위를 확인하는 용도로 쓰고, 목표는 부담이 되지 않는 선에서 정하세요.</p>
<h2 id="s10">정리</h2>
<p>독서 목표는 세 개의 숫자로 바뀌어요. 책 한 권의 쪽수, 내가 1분에 읽는 쪽수, 그리고 하루에 낼 수 있는 시간이에요. 이 값을 곱하고 나누면 1년에 읽을 수 있는 권수, 또는 목표 권수를 채우는 하루 시간이 나와요. 300쪽을 분당 1.5쪽으로 읽을 때 연 12권은 하루 7분, 연 24권은 14분, 연 52권은 29분이 필요해요. 내 속도는 5분만 재면 알 수 있고, 두꺼운 책 위주라면 필요한 시간이 비례해서 늘어나요. 쉬는 날을 감안해 하루 시간을 15~20% 여유 있게 잡으면 목표가 훨씬 안정적이에요.</p>
<p>바로 숫자를 넣어 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/reading-goal-calculator/">독서 목표 계산기</a>에서 쪽수, 읽는 속도, 하루 시간을 바꿔 가며 책장 그림으로 확인해 보세요. 습관 만들기의 현실적인 확률이 궁금하다면 <a href="https://tool.smithlab.co.kr/calculator/habit-streak-probability-calculator/">습관 연속 달성 확률 계산기</a>도 함께 열어 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>고향사랑기부제 2026 세액공제 — 10만원·20만원·30만원 얼마…</title>
      <link>https://tool.smithlab.co.kr/posts/money/hometown-donation-2026-tax-credit-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/hometown-donation-2026-tax-credit-guide/</guid>
      <pubDate>Sun, 08 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연말이 다가오면 "고향사랑기부 얼마를 해야 이득이야?"라는 질문이 가장 많아요. 결론부터 말하면 <strong>10만 원까지는 낸 돈이 그대로 세금에서 줄어들고</strong>, 2026년 1월 1일 이후 기부분부터는 <strong>10만 원을 넘는 20만 원까지의 구간도 44%</strong>가 돌아와요. 20만 원을 넘는 부분은 16.5%예요. 그래서 30만 원을 기부하면 약 16만 500원이 세금에서 줄고, 지자체가 주는 답례품(기부액의 30% 이내)은 따로 받아요.</p>
<p>단, 이 계산은 <strong>낼 세금(소득세 결정세액)이 충분할 때</strong>만 맞아요. 20만 원 기부의 효과를 다 받으려면 소득세 결정세액이 13만 909원 이상 남아 있어야 하고, 그보다 적으면 남는 공제는 버려져요. 이 글에는 금액별 환급표와 함께 결정세액별로 실제 돌려받는 금액표, 그리고 결정세액이 적은 배우자는 얼마까지만 내야 하는지 계산한 맞벌이 예시가 있어요. 내 금액으로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/donation-tax-credit-calculator/">기부금 세액공제 계산기</a>에 고향사랑 금액을 넣으면 돼요. 이 글의 숫자는 모두 같은 식으로 직접 계산했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>고향사랑기부 10만 원까지는 소득세 100/110과 지방소득세 10/110을 합쳐 100% 공제돼요.</li><li>2026년 기부분은 10만 원 초과~20만 원 이하 구간이 소득세 40%(지방소득세 포함 44%), 20만 원 초과분은 15%(포함 16.5%)예요.</li><li>연 기부 한도는 1인 2,000만 원이고, 답례품은 기부액의 30% 이내로 받아요.</li><li>세액공제라서 낼 세금(결정세액)이 없으면 돌려받을 수 없어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">고향사랑기부 세액공제 구조: 세 구간</h2>
<p>고향사랑기부제는 주소지가 아닌 다른 지방자치단체에 기부하면 세액공제를 받고 답례품도 받는 제도예요. 공제는 기부액을 세 구간으로 나눠 따로 계산해요.</p>
<div class="table-wrap"><table><thead><tr><th>기부액 구간</th><th>소득세 공제율</th><th>지방소득세 포함</th></tr></thead><tbody><tr><td>10만 원 이하</td><td>100/110 (약 90.9%)</td><td>100%</td></tr><tr><td>10만 원 초과 ~ 20만 원 이하 (2026년 기부분)</td><td>40%</td><td>44%</td></tr><tr><td>20만 원 초과 (2026년 기부분)</td><td>15%</td><td>16.5%</td></tr></tbody></table></div>
<p>소득세 공제 뒤에 그 공제 세액의 10%만큼 지방소득세가 더 줄어드는 구조라서, 두 가지를 합친 비율이 오른쪽 칸이에요. 2025년까지는 10만 원 초과분 전체가 15%(포함 16.5%)였는데, 2026년 1월 1일 이후 기부분부터 10만~20만 원 구간이 40%로 올랐다고 정리하는 자료가 여럿이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">고향사랑기부 금액별 환급 비율 (2026년 기부분, 지방소득세 포함)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="고향사랑기부 금액별 환급 비율 (2026년 기부분, 지방소득세 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="64.3" y="18.0" width="57.6" height="236.0" rx="3"><title>5만 · 환급 비율 100%</title></rect><text class="viz-val" x="93.1" y="13.0" text-anchor="middle">100</text><text class="viz-ax" x="93.1" y="272" text-anchor="middle">5만</text><rect class="s1" x="146.6" y="18.0" width="57.6" height="236.0" rx="3"><title>10만 · 환급 비율 100%</title></rect><text class="viz-val" x="175.4" y="13.0" text-anchor="middle">100</text><text class="viz-ax" x="175.4" y="272" text-anchor="middle">10만</text><rect class="s1" x="228.9" y="62.1" width="57.6" height="191.9" rx="3"><title>15만 · 환급 비율 81.3%</title></rect><text class="viz-val" x="257.7" y="57.1" text-anchor="middle">81.3</text><text class="viz-ax" x="257.7" y="272" text-anchor="middle">15만</text><rect class="s1" x="311.2" y="84.1" width="57.6" height="169.9" rx="3"><title>20만 · 환급 비율 72%</title></rect><text class="viz-val" x="340.0" y="79.1" text-anchor="middle">72</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">20만</text><rect class="s1" x="393.5" y="127.7" width="57.6" height="126.3" rx="3"><title>30만 · 환급 비율 53.5%</title></rect><text class="viz-val" x="422.3" y="122.7" text-anchor="middle">53.5</text><text class="viz-ax" x="422.3" y="272" text-anchor="middle">30만</text><rect class="s1" x="475.8" y="162.7" width="57.6" height="91.3" rx="3"><title>50만 · 환급 비율 38.7%</title></rect><text class="viz-val" x="504.6" y="157.7" text-anchor="middle">38.7</text><text class="viz-ax" x="504.6" y="272" text-anchor="middle">50만</text><rect class="s1" x="558.1" y="188.9" width="57.6" height="65.1" rx="3"><title>100만 · 환급 비율 27.6%</title></rect><text class="viz-val" x="586.9" y="183.9" text-anchor="middle">27.6</text><text class="viz-ax" x="586.9" y="272" text-anchor="middle">100만</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">20만 원을 넘는 금액부터 효율이 떨어지지만 답례품은 계속 받아요.</p><div class="viz-cap">단위: % · 자료: 소득세 100/110·40%·15% 구간 + 지방소득세 10%로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>환급 비율</th></tr></thead><tbody><tr><td>5만</td><td>100</td></tr><tr><td>10만</td><td>100</td></tr><tr><td>15만</td><td>81.3</td></tr><tr><td>20만</td><td>72</td></tr><tr><td>30만</td><td>53.5</td></tr><tr><td>50만</td><td>38.7</td></tr><tr><td>100만</td><td>27.6</td></tr></tbody></table></div></details></figure>
<h2 id="s2">금액별 환급액: 직접 계산한 표</h2>
<p>각 구간을 따로 계산해 더한 값이에요. 예를 들어 30만 원이면 10만 원은 100/110인 90,909원, 다음 10만 원은 40%인 40,000원, 마지막 10만 원은 15%인 15,000원을 더해 소득세 공제가 145,909원이에요. 여기에 지방소득세 14,590원이 더해져 <strong>약 160,500원</strong>이에요.</p>
<div class="table-wrap"><table><thead><tr><th>기부액</th><th>2026년 기부분 환급</th><th>환급 비율</th><th>2025년 이전 기부분 환급</th></tr></thead><tbody><tr><td>5만 원</td><td>5만 원</td><td>100%</td><td>5만 원</td></tr><tr><td>10만 원</td><td>10만 원</td><td>100%</td><td>10만 원</td></tr><tr><td>15만 원</td><td>약 12만 2,000원</td><td>81.3%</td><td>약 10만 8,250원</td></tr><tr><td>20만 원</td><td>약 14만 4,000원</td><td>72.0%</td><td>약 11만 6,500원</td></tr><tr><td>30만 원</td><td>약 16만 500원</td><td>53.5%</td><td>약 13만 3,000원</td></tr><tr><td>50만 원</td><td>약 19만 3,500원</td><td>38.7%</td><td>약 16만 6,000원</td></tr><tr><td>100만 원</td><td>약 27만 6,000원</td><td>27.6%</td><td>약 24만 8,500원</td></tr></tbody></table></div>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/hometown-donation-2026-tax-credit-guide-1.svg" alt="고향사랑기부 30만 원이 세 구간으로 나뉘어 계산되는 과정" width="800" height="450" loading="lazy" decoding="async"><figcaption>30만 원 = 10만 원(100%) + 10만 원(44%) + 10만 원(16.5%)</figcaption></figure>
<p>표에서 볼 수 있듯 2026년에는 같은 기부액이라도 이전보다 15만 원 기부는 약 1만 3,750원, 20만 원은 약 2만 7,500원, 30만 원은 약 2만 7,500원 더 돌려받아요. 늘어나는 폭이 가장 큰 건 20만 원 근처예요. 그래서 "10만 원으로 끝낼까, 20만 원까지 갈까"를 고민한다면 2026년에는 20만 원까지 가는 쪽이 이전보다 유리해졌어요.</p>
<h2 id="s3">답례품까지 합치면 실질 부담은 얼마일까</h2>
<p>답례품은 기부액의 30% 이내로 지자체가 정해 보내 줘요. 가치를 기부액의 30%로 가정하고 단순 계산해 보면 아래와 같아요. 실제 답례품 가치와 배송비 부담은 지자체마다 달라서 어디까지나 예시예요.</p>
<div class="table-wrap"><table><thead><tr><th>기부액</th><th>세금에서 줄어드는 금액</th><th>답례품(30% 가정)</th><th>실질 부담</th></tr></thead><tbody><tr><td>10만 원</td><td>10만 원</td><td>3만 원</td><td>-3만 원 (이득)</td></tr><tr><td>20만 원</td><td>약 14만 4,000원</td><td>6만 원</td><td>약 -4,000원 (이득)</td></tr><tr><td>30만 원</td><td>약 16만 500원</td><td>9만 원</td><td>약 +5만 500원</td></tr></tbody></table></div>
<p>음수는 "낸 돈보다 얻은 게 많다"는 뜻이에요. 20만 원까지는 세금 환급과 답례품을 합치면 낸 돈을 넘어서고, 30만 원부터는 일부가 실제 부담으로 남아요. 다만 이 계산은 답례품 가치를 30%로 두고 한 예시이고, 실제 답례품은 지자체가 정한 가격 범위를 따라요.</p>
<h2 id="s4">결정세액이 적으면 얼마나 돌려받나: 판단 기준</h2>
<p>세액공제는 소득세 결정세액(다른 공제를 다 빼고 남은 세금)까지만 효과가 있어요. 결정세액별로 실제 줄어드는 세금(지방소득세 포함)을 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>남은 소득세 결정세액</th><th>10만 원 기부</th><th>20만 원 기부</th><th>30만 원 기부</th></tr></thead><tbody><tr><td>3만 원</td><td>3만 3,000원</td><td>3만 3,000원</td><td>3만 3,000원</td></tr><tr><td>9만 원</td><td>9만 9,000원</td><td>9만 9,000원</td><td>9만 9,000원</td></tr><tr><td>13만 909원</td><td>10만 원</td><td>14만 4,000원</td><td>14만 4,000원</td></tr><tr><td>20만 원 이상</td><td>10만 원</td><td>14만 4,000원</td><td>16만 500원</td></tr></tbody></table></div>
<p>판단 기준은 두 줄로 정리돼요.</p>
<ul><li><strong>10만 원 전액 환급의 조건</strong>: 소득세 결정세액 9만 909원 이상(10만 원 × 100/110).</li><li><strong>20만 원까지 효과를 다 받는 조건</strong>: 소득세 결정세액 13만 909원 이상(9만 909원 + 4만 원). 30만 원이면 14만 5,909원 이상이에요.</li><li>결정세액이 이보다 적다면 <strong>기부액 상한 = 결정세액 × 1.1</strong>(10만 원 이하 구간일 때)로 맞추면 남는 공제가 없어요. 결정세액 9만 원이면 9만 9,000원까지만 효과가 있어요.</li></ul>
<p>다른 세액공제(자녀, 연금저축, 월세 등)를 먼저 넣고 남은 결정세액으로 판단해야 해요. 연금저축이나 월세로 결정세액이 이미 바닥난 사람이 많아요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">일반 기부금과 뭐가 다를까</h2>
<p><a href="https://chest.or.kr" target="_blank" rel="noopener">사랑의열매(사회복지공동모금회)</a> 같은 사회복지단체 일반 기부금은 1천만 원까지 15%(지방소득세 포함 16.5%)이고 넘는 부분만 30%예요. 그래서 소액일 때는 고향사랑이 훨씬 유리해요. 100만 원을 비교하면 고향사랑은 2026년 기준 약 27만 6,000원이 돌아오고 일반 기부금은 약 16만 5,000원이에요. 다만 일반 기부금은 한도가 소득금액의 30%라서 큰 금액을 낼 때 별도로 한도를 확인해야 해요.</p>
<div class="table-wrap"><table><thead><tr><th>기부 종류</th><th>100만 원 기부 시 환급(지방소득세 포함)</th><th>답례품</th></tr></thead><tbody><tr><td>고향사랑기부 (2026년)</td><td>약 27만 6,000원</td><td>기부액의 30% 이내</td></tr><tr><td>일반 기부금</td><td>약 16만 5,000원</td><td>없음</td></tr></tbody></table></div>
<p>둘은 한도가 따로라서 함께 낼 수 있어요. 일반 기부금과 합쳐서 계산하려면 <a href="https://tool.smithlab.co.kr/money/donation-tax-credit-calculator/">기부금 세액공제 계산기</a>에 두 칸을 모두 넣으면 돼요.</p>
<h2 id="s6">예시: 가상의 맞벌이 부부가 40만 원을 나눠 내는 법</h2>
<p>아래는 설명을 위해 만든 가상의 예시예요. 부부가 올해 고향사랑기부로 합계 40만 원 안팎을 쓰려고 해요. 다른 공제를 다 넣은 뒤 남은 소득세 결정세액은 배우자 A 120만 원, 배우자 B 9만 원이에요.</p>
<div class="table-wrap"><table><thead><tr><th>나누는 방법</th><th>A 환급</th><th>B 환급</th><th>부부 합계</th></tr></thead><tbody><tr><td>A 40만 원</td><td>17만 7,000원 (30만까지 16만 500원 + 10만 × 16.5%)</td><td>—</td><td>17만 7,000원</td></tr><tr><td>A 20만 · B 20만</td><td>14만 4,000원</td><td>9만 9,000원 (결정세액 한도)</td><td>24만 3,000원</td></tr><tr><td>A 30만 · B 10만</td><td>16만 500원</td><td>9만 9,000원</td><td>25만 9,500원</td></tr><tr><td>A 30만 1,000원 · B 9만 9,000원</td><td>16만 665원</td><td>9만 9,000원</td><td>25만 9,665원</td></tr></tbody></table></div>
<p>공제는 기부한 본인 것만 인정돼요. 그래서 결정세액이 넉넉한 A가 20만 원 구간까지 쓰고, 결정세액이 적은 B는 <strong>결정세액 × 1.1(9만 9,000원)까지만</strong> 내는 게 합계가 가장 커요. B가 20만 원을 내면 10만 1,000원어치 공제가 버려지고, 한 사람이 40만 원을 다 내는 것보다 나눠 내는 쪽이 8만 원 넘게 더 돌아와요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">자주 틀리는 지점</h2>
<ul><li><strong>2025년 기부분 표로 계산하기</strong>: 2025년 이전 기부분은 10만 원 초과가 모두 16.5%라, 20만 원 기부 환급이 11만 6,500원이에요. 2026년 1월 1일 이후 기부분부터 14만 4,000원이에요.</li><li><strong>"10만 원 전액"을 무조건으로 읽기</strong>: 소득세 결정세액이 9만 909원보다 적으면 10만 원도 다 돌려받지 못해요.</li><li><strong>소득세만 보는 계산기와 비교하기</strong>: 이 글의 금액은 지방소득세를 포함해요. 10만 원 기부는 소득세 9만 909원 + 지방소득세 9,091원이에요.</li><li><strong>답례품을 현금으로 계산하기</strong>: 답례품은 기부액의 30% 이내에서 지자체가 정한 상품이라, 필요 없는 물건이면 실질 가치는 그보다 낮아요.</li><li><strong>주소지 지자체에 기부하기</strong>: 주민등록상 주소지 지자체에는 기부할 수 없어요. 연 한도는 1인 2,000만 원이고, 기부는 12월 31일 결제분까지 그해 공제예요.</li><li><strong>간소화 자료 확인 안 하기</strong>: 고향사랑e음 기부는 간소화 자료에 올라오는 게 일반적이지만, 1월에 보이지 않으면 기부금 영수증을 직접 내야 해요.</li></ul>
<p>결정세액은 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>로, 의료비 공제와 합친 전체 환급은 <a href="https://tool.smithlab.co.kr/money/medical-expense-tax-credit-calculator/">의료비 세액공제 계산기</a>로 이어서 볼 수 있어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/hometown-donation-2026-tax-credit-guide-2.svg" alt="고향사랑기부 세액공제 구간별 환급 비율 비교 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>10만 원 이하 100%, 10~20만 원 44%, 20만 원 초과 16.5%</figcaption></figure>
<h2 id="s8">자주 묻는 질문</h2>
<h3>고향사랑기부 10만 원은 정말 전액 돌려받나요?</h3>
<p>네, 10만 원까지는 소득세에서 100/110, 지방소득세에서 10/110이 공제돼 합치면 낸 돈 전부가 줄어요. 단 낼 세금이 있어야 하고 연말정산이나 종합소득세 신고에서 반영해야 해요.</p>
<h3>2026년 20만 원 기부하면 얼마를 돌려받나요?</h3>
<p>10만 원은 전액이고 나머지 10만 원은 44%라 약 14만 4,000원이에요. 2025년 이전 기부분은 10만 원 초과가 16.5%라 약 11만 6,500원이에요.</p>
<h3>20만 원을 넘겨도 이득인가요?</h3>
<p>20만 원을 넘는 부분은 16.5%만 돌아와요. 그래도 답례품(기부액의 30% 이내)을 받으니 필요한 답례품이 있다면 이득일 수 있어요. 세금 환급만 보면 효율이 20만 원 이후 떨어져요.</p>
<h3>부부가 각각 기부하면 더 유리한가요?</h3>
<p>네. 공제는 기부한 본인 기준이라 부부가 각각 10만~20만 원씩 내면 각자 높은 효율 구간을 쓸 수 있어요. 다만 결정세액이 적은 쪽은 결정세액 × 1.1까지만 효과가 있어서, 위 예시처럼 나누는 금액을 맞춰야 해요.</p>
<h3>연말정산 결정세액이 0원이면 고향사랑기부 공제는 이월되나요?</h3>
<p>고향사랑기부 세액공제는 결정세액 범위 안에서만 적용되고, 남는 공제가 다음 해로 넘어간다는 규정은 없어요. 결정세액이 0원이라면 세금 환급 효과는 없고 답례품만 남아요.</p>
<h3>내 주소지 지자체에도 기부할 수 있나요?</h3>
<p>아니요. 주민등록상 주소지 지자체에는 기부할 수 없고, 그 외 지자체에만 가능해요.</p>
<h3>일반 기부금과 같이 낼 수 있나요?</h3>
<p>네, 한도와 공제율이 따로라서 함께 낼 수 있어요. <a href="https://tool.smithlab.co.kr/money/donation-tax-credit-calculator/">기부금 세액공제 계산기</a>에 두 칸을 모두 넣으면 합계를 볼 수 있어요.</p>
<h2 id="s9">정리</h2>
<p>고향사랑기부는 10만 원까지 전액, 2026년 기부분은 20만 원까지 44%, 그 이상은 16.5%가 돌아오는 구조예요. 소액일 때 일반 기부금보다 훨씬 유리하고 답례품까지 받을 수 있지만, 세액공제라서 낼 세금이 있어야 해요. 연말에 결정세액을 확인하고 12월 31일 전에 기부를 마치세요. 공제율은 <a href="https://portal.barogov.kr/policy/donation-tax-credit/" target="_blank" rel="noopener">정부정책포털의 기부금 세액공제 정리</a>와 <a href="https://hometax-go.kr/donation-tax-credit-rates/" target="_blank" rel="noopener">택스고의 구간별 공제율</a>, <a href="https://www.wegive.co.kr/wezine/detail/1375" target="_blank" rel="noopener">위기브의 2026년 고향사랑 변경 안내</a>를 교차 확인해 정리했고, 세법은 바뀔 수 있으니 신고 전 국세청 홈택스 안내를 확인하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>의료비 세액공제 계산 2026 — 총급여 3% 넘겨야 받는다…</title>
      <link>https://tool.smithlab.co.kr/posts/money/medical-expense-tax-credit-3-percent-limit-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/medical-expense-tax-credit-3-percent-limit-guide/</guid>
      <pubDate>Sun, 08 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연말정산 철이 되면 "병원비 많이 썼는데 얼마나 돌려받을까?"가 가장 흔한 질문이에요. 결론부터 말하면 의료비 세액공제는 <strong>쓴 돈에서 총급여의 3%를 뺀 나머지에 15%를 곱한 금액</strong>이에요. 총급여 4,000만 원인 사람이 본인 병원비로 300만 원을 썼다면 문턱 120만 원을 뺀 180만 원의 15%인 27만 원이 세금에서 빠져요. 300만 원을 쓰고 27만 원을 돌려받는다는 뜻이라서, 의료비 공제는 "많이 쓰면 많이 돌려받는 항목"이 아니라 "문턱을 넘은 만큼만 일부 돌려받는 항목"으로 이해하는 게 정확해요.</p>
<p>그래서 확인할 건 세 가지예요. 내 총급여의 3%가 얼마인지, 쓴 의료비가 누구의 것이라 한도가 있는지 없는지, 난임·미숙아처럼 공제율이 높은 항목이 있는지예요. 이 글에서는 이 세 가지를 순서대로 계산해 보고, 안경·산후조리원·실손보험처럼 헷갈리는 항목, 12월에 해 볼 수 있는 일을 정리할게요. 내 숫자로 바로 계산하려면 <a href="https://tool.smithlab.co.kr/money/medical-expense-tax-credit-calculator/">의료비 세액공제 계산기</a>를 쓰면 되고, 이 글의 숫자는 모두 같은 식으로 직접 계산했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>공제액 = (의료비 − 총급여 × 3%) × 공제율. 일반은 15%, 미숙아·선천성이상아 20%, 난임시술 30%예요.</li><li>본인·65세 이상·장애인·6세 이하 의료비는 한도가 없고, 그 밖의 부양가족은 합쳐서 연 700만 원까지만 인정돼요.</li><li>총급여 5,000만 원에 부모님 의료비 900만 원이면 인정액은 700만 원으로 잘려서 공제는 105만 원이 상한이에요.</li><li>실손보험금으로 돌려받은 돈은 의료비에서 빼야 하고, 정확한 금액은 홈택스 간소화 자료로 확인해야 해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">의료비 세액공제 계산 공식: 3% 문턱부터 보세요</h2>
<p>세액공제는 소득에서 빼는 소득공제와 달리 <strong>계산된 세금에서 금액 그대로 빼는 공제</strong>예요. 그래서 공제 대상 금액에 공제율을 곱한 값이 곧 줄어드는 세금이에요. 의료비는 여기에 문턱이 붙어요. 내 총급여의 3%까지는 누구나 쓴다고 보고 공제하지 않고, 그 위로 넘친 부분만 공제 대상이 되는 구조예요.</p>
<p>총급여는 연봉에서 식대 같은 비과세를 뺀 금액이에요. 급여명세서나 원천징수영수증의 '총급여' 칸을 보면 돼요. 총급여별 문턱은 아래와 같아요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">총급여별 의료비 공제 문턱 (총급여의 3%)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="총급여별 의료비 공제 문턱 (총급여의 3%)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">250</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">300</text><rect class="s1" x="66.4" y="183.2" width="67.2" height="70.8" rx="3"><title>3000만 · 문턱 90만원</title></rect><text class="viz-val" x="100.0" y="178.2" text-anchor="middle">90</text><text class="viz-ax" x="100.0" y="272" text-anchor="middle">3000만</text><rect class="s1" x="162.4" y="159.6" width="67.2" height="94.4" rx="3"><title>4000만 · 문턱 120만원</title></rect><text class="viz-val" x="196.0" y="154.6" text-anchor="middle">120</text><text class="viz-ax" x="196.0" y="272" text-anchor="middle">4000만</text><rect class="s1" x="258.4" y="136.0" width="67.2" height="118.0" rx="3"><title>5000만 · 문턱 150만원</title></rect><text class="viz-val" x="292.0" y="131.0" text-anchor="middle">150</text><text class="viz-ax" x="292.0" y="272" text-anchor="middle">5000만</text><rect class="s1" x="354.4" y="112.4" width="67.2" height="141.6" rx="3"><title>6000만 · 문턱 180만원</title></rect><text class="viz-val" x="388.0" y="107.4" text-anchor="middle">180</text><text class="viz-ax" x="388.0" y="272" text-anchor="middle">6000만</text><rect class="s1" x="450.4" y="65.2" width="67.2" height="188.8" rx="3"><title>8000만 · 문턱 240만원</title></rect><text class="viz-val" x="484.0" y="60.2" text-anchor="middle">240</text><text class="viz-ax" x="484.0" y="272" text-anchor="middle">8000만</text><rect class="s1" x="546.4" y="18.0" width="67.2" height="236.0" rx="3"><title>1억 · 문턱 300만원</title></rect><text class="viz-val" x="580.0" y="13.0" text-anchor="middle">300</text><text class="viz-ax" x="580.0" y="272" text-anchor="middle">1억</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">의료비가 이 금액을 넘는 부분부터 세액공제 대상이에요.</p><div class="viz-cap">단위: 만원 · 자료: 총급여 × 3% 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>문턱</th></tr></thead><tbody><tr><td>3000만</td><td>90</td></tr><tr><td>4000만</td><td>120</td></tr><tr><td>5000만</td><td>150</td></tr><tr><td>6000만</td><td>180</td></tr><tr><td>8000만</td><td>240</td></tr><tr><td>1억</td><td>300</td></tr></tbody></table></div></details></figure>
<p>총급여가 4,000만 원이면 의료비가 120만 원 이하일 때는 공제가 0원이에요. 3,500만 원이면 문턱이 105만 원이라서 본인 의료비 100만 원만 쓴 해에는 공제를 못 받아요. 반대로 문턱을 넘은 부분은 얼마를 넘었든 한 구간처럼 15%예요. 문턱보다 100만 원을 더 쓰면 15만 원, 200만 원을 더 쓰면 30만 원이 돌아와요.</p>
<div class="table-wrap"><table><thead><tr><th>총급여</th><th>문턱(3%)</th><th>문턱보다 100만 원 더 썼을 때 공제액</th></tr></thead><tbody><tr><td>3,000만 원</td><td>90만 원</td><td>15만 원</td></tr><tr><td>5,000만 원</td><td>150만 원</td><td>15만 원</td></tr><tr><td>8,000만 원</td><td>240만 원</td><td>15만 원</td></tr></tbody></table></div>
<p>문턱이 높은 고소득자일수록 같은 병원비로 공제가 시작되기까지 더 많이 써야 해요. 반대로 말하면 총급여가 낮은 맞벌이 배우자 쪽이 문턱이 낮아서, 같은 가족의 의료비라도 총급여가 낮은 쪽이 기본공제를 받는다면 유리한 경우가 많아요. 다만 부양가족 기본공제는 한 사람이 한쪽에만 올릴 수 있으니 가족 구성부터 확인해야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/medical-expense-tax-credit-3-percent-limit-guide-1.svg" alt="의료비 세액공제 계산 4단계 흐름도: 순지출, 3% 빼기, 한도 적용, 공제율 곱하기" width="800" height="450" loading="lazy" decoding="async"><figcaption>총급여 5,000만 원, 본인 150만 원 + 부모님 900만 원 예시</figcaption></figure>
<h2 id="s2">한도 계산: 부모님 병원비는 700만 원까지</h2>
<p>의료비는 쓴 사람이 아니라 <strong>누구를 위해 썼는지</strong>로 한도가 나뉘어요. 본인, 65세 이상 가족, 장애인, 6세 이하 자녀의 의료비는 한도가 없고, 그 밖의 기본공제 대상 가족(65세 미만 부모님, 형제 등)의 의료비는 모두 합쳐서 연 700만 원까지만 인정돼요. 6세 이하 자녀의 한도 폐지는 2024년 지출분부터라는 보도를 기준으로 썼어요.</p>
<p>위 그림의 예시를 다시 계산해 볼게요. 총급여 5,000만 원이면 문턱은 150만 원이에요. 한도가 있는 부양가족 의료비 900만 원에서 문턱을 먼저 빼면 750만 원인데 한도 700만 원에 걸려 700만 원만 남아요. 본인 의료비 150만 원은 한도가 없으니 그대로예요. (700만 + 150만) × 15% = 127.5만 원이에요. 3% 문턱을 어느 항목에서 먼저 빼는지는 세무 해설마다 설명이 달라서 이 글은 한도가 있는 부양가족에서 먼저 빼는 방식으로 계산했어요. 본인 의료비가 크면 결과가 달라질 수 있으니 최종 금액은 홈택스 간소화 자료로 확인하세요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">부양가족(65세 미만) 의료비별 세액공제 (총급여 5,000만 원, 본인 의료비 0원)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="부양가족(65세 미만) 의료비별 세액공제 (총급여 5,000만 원, 본인 의료비 0원)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">80</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">120</text><rect class="s1" x="73.6" y="209.8" width="100.8" height="44.2" rx="3"><title>300만 · 세액공제 22.5만원</title></rect><text class="viz-val" x="124.0" y="204.8" text-anchor="middle">22.5</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">300만</text><rect class="s1" x="217.6" y="121.2" width="100.8" height="132.8" rx="3"><title>600만 · 세액공제 67.5만원</title></rect><text class="viz-val" x="268.0" y="116.2" text-anchor="middle">67.5</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">600만</text><rect class="s1" x="361.6" y="47.5" width="100.8" height="206.5" rx="3"><title>900만 · 세액공제 105만원</title></rect><text class="viz-val" x="412.0" y="42.5" text-anchor="middle">105</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">900만</text><rect class="s1" x="505.6" y="47.5" width="100.8" height="206.5" rx="3"><title>1200만 · 세액공제 105만원</title></rect><text class="viz-val" x="556.0" y="42.5" text-anchor="middle">105</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">1200만</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">900만 원부터는 한도 700만 원에 걸려 공제가 더 늘지 않아요.</p><div class="viz-cap">단위: 만원 · 자료: (의료비 − 150만 원, 최대 700만 원) × 15% 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>세액공제</th></tr></thead><tbody><tr><td>300만</td><td>22.5</td></tr><tr><td>600만</td><td>67.5</td></tr><tr><td>900만</td><td>105</td></tr><tr><td>1200만</td><td>105</td></tr></tbody></table></div></details></figure>
<div class="table-wrap"><table><thead><tr><th>부양가족 의료비</th><th>인정 금액(한도 반영)</th><th>세액공제</th></tr></thead><tbody><tr><td>300만 원</td><td>150만 원</td><td>22.5만 원</td></tr><tr><td>600만 원</td><td>450만 원</td><td>67.5만 원</td></tr><tr><td>900만 원</td><td>700만 원</td><td>105만 원</td></tr><tr><td>1,200만 원</td><td>700만 원</td><td>105만 원</td></tr></tbody></table></div>
<p>한도에 이미 닿은 해에는 병원비가 더 나와도 공제가 늘지 않으니 같은 해에 몰아서 쓸 이유가 없어요. 큰 수술이나 치료가 예정돼 있다면 해를 나눌 수 있는 비용(검진, 치과 치료의 일부)을 두 해에 걸쳐 쓰면 두 해 모두 문턱을 넘는지 계산해 보는 게 낫지만, 치료 시기를 세금 때문에 미룰 필요는 없어요.</p>
<h2 id="s3">공제율이 높은 항목: 난임시술비 30%, 미숙아·선천성이상아 20%</h2>
<p>난임시술비는 30%, 미숙아·선천성이상아 의료비는 20%로 일반 15%보다 높은 공제율을 적용하고 한도가 없어요. 난임시술비 30%는 2022년 지출분부터 적용돼요. 총급여 6,000만 원 기준으로 같은 600만 원을 쓴다고 비교하면 이렇게 달라요.</p>
<div class="table-wrap"><table><thead><tr><th>의료비 종류</th><th>문턱 180만 원 뺀 대상</th><th>공제율</th><th>세액공제</th></tr></thead><tbody><tr><td>일반(본인)</td><td>420만 원</td><td>15%</td><td>63만 원</td></tr><tr><td>미숙아·선천성이상아</td><td>420만 원</td><td>20%</td><td>84만 원</td></tr><tr><td>난임시술</td><td>420만 원</td><td>30%</td><td>126만 원</td></tr></tbody></table></div>
<p>난임시술비는 모자보건법에 따른 보조생식술 비용이어야 하고, 병원에서 받은 진료비 전부가 30% 대상은 아니에요. 연말정산 간소화 자료에서 일반 의료비와 구분되지 않아 15%로 계산되는 경우가 있다는 보도가 있으니, 난임시술 증명서나 미숙아 확인서를 병원에서 받아 회사에 따로 제출하는 게 안전해요. 난임시술비 공제율은 <a href="https://www.newsis.com/view/?id=NISX20211230_0001708148" target="_blank" rel="noopener">뉴시스 보도</a>와 <a href="https://www.hankyung.com/article/2024110369851" target="_blank" rel="noopener">한국경제 보도</a>를 참고했어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/medical-expense-tax-credit-3-percent-limit-guide-2.svg" alt="누구의 의료비냐에 따라 달라지는 공제율과 한도 비교 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>일반 15% 한도 없음·700만 원, 미숙아 20%, 난임 30%</figcaption></figure>
<h2 id="s4">안경·산후조리원·보청기, 공제되는 것과 안 되는 것</h2>
<p>의료비로 인정되는 범위가 헷갈려서 영수증을 모아 놓고도 신청을 못 하는 경우가 많아요. 일반적으로 아래와 같이 정리돼요.</p>
<ul><li><strong>인정</strong>: 병원·치과·한의원 진료비와 처방받은 약값, 치과 임플란트·틀니, 라식·라섹 같은 시력 교정 수술, 보청기·휠체어 같은 장애인 보장구, 예방접종비.</li><li><strong>한도가 따로 있는 인정 항목</strong>: 시력 보정용 안경·콘택트렌즈는 1인당 연 50만 원까지, 산후조리원은 출산 1회당 200만 원까지 의료비에 넣을 수 있어요.</li><li><strong>제외</strong>: 미용·성형 목적 수술, 건강증진을 위한 보약·영양제·건강기능식품, 개인이 따로 계약한 간병인 비용, 실손보험금으로 이미 돌려받은 금액.</li></ul>
<p>안경은 안경점에서 '시력 보정용'임을 확인할 수 있는 영수증(성명·사업자번호·품목)을 받아야 해요. 선글라스처럼 시력 보정이 아닌 제품은 인정되지 않아요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">실손보험금을 받았다면 반드시 빼세요</h2>
<p>가장 자주 하는 실수가 보험금을 받았는데도 병원 영수증 금액 전체를 의료비로 쓰는 거예요. 실손보험으로 돌려받은 금액은 내가 부담하지 않은 돈이라서 의료비에서 빼야 해요. 예를 들어 치과 치료비 300만 원 중 실손으로 100만 원을 받았다면 의료비는 200만 원이에요. 간소화 자료에는 실손보험금 수령액이 반영되지 않는 때가 있어서 직접 확인해야 해요. 한 해 보험금을 받은 내역은 보험사 앱에서 연간 지급 내역으로 조회할 수 있어요. 실제 부담액 자료는 <a href="https://www.nhis.or.kr" target="_blank" rel="noopener">국민건강보험공단</a>의 진료비 내역과 맞춰 볼 수 있어요.</p>
<h2 id="s6">12월에 해 볼 수 있는 일: 문턱 근처인지 확인하기</h2>
<p>연말에 할 수 있는 건 많지 않지만 아래는 확인해 볼 가치가 있어요.</p>
<ol><li><strong>지금까지 쓴 의료비 합계를 총급여 3%와 비교</strong>: 이미 문턱을 넘었다면 앞으로 쓴 의료비는 전부 15%가 돌아와요. 문턱에 못 미친다면 이번 해에는 공제가 0이니 뒤로 미룰 수 있는 건강검진·치과 치료는 가족 구성과 함께 고려해 보세요.</li><li><strong>영수증이 빠진 곳 챙기기</strong>: 안경점, 약국(처방전 없는 일반약은 제외), 산후조리원은 간소화 자료에 누락되는 때가 있어요.</li><li><strong>부양가족 정리</strong>: 의료비는 기본공제 대상자만 가능해요. 부모님이 소득 요건을 넘지 않았는지, 형제와 중복으로 올리지 않았는지 확인하세요.</li><li><strong>다른 공제와 함께 계산</strong>: 카드 소득공제, 연금저축·IRP, 월세 같은 다른 공제와 합쳐서 환급액을 보면 의료비의 비중이 보여요. <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>와 <a href="https://tool.smithlab.co.kr/money/card-deduction-calculator/">카드 소득공제 계산기</a>를 같이 써 보세요.</li></ol>
<p>의료비 공제를 받겠다는 목적으로 필요 없는 진료를 받는 건 오히려 손해예요. 병원비 100만 원을 쓰면 15만 원이 돌아오지만 85만 원은 내 돈이니까요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">자주 묻는 질문</h2>
<h3>의료비 세액공제는 얼마부터 받나요?</h3>
<p>총급여의 3%를 넘는 금액부터예요. 총급여 4,000만 원이면 120만 원을 넘는 부분이 대상이고, 일반 의료비는 그 금액에 15%를 곱해요. 병원비 300만 원이면 27만 원이에요.</p>
<h3>부모님 병원비도 공제되나요?</h3>
<p>네, 부모님이 내 기본공제 대상자이면 돼요. 65세 이상이면 한도가 없고 65세 미만이면 다른 부양가족과 합쳐 연 700만 원까지 인정돼요. 나이 요건은 없지만 소득 요건은 있어요.</p>
<h3>실손보험으로 돌려받은 돈도 의료비에 넣나요?</h3>
<p>아니요. 보험금으로 돌려받은 금액은 빼고 내가 실제로 부담한 금액만 의료비예요. 보험금을 받고 전액을 신청하면 나중에 추징될 수 있어요.</p>
<h3>안경과 산후조리원도 공제되나요?</h3>
<p>시력 보정용 안경·콘택트렌즈는 1인당 연 50만 원까지, 산후조리원은 출산 1회당 200만 원까지 의료비에 넣을 수 있어요. 미용 목적이나 건강증진용 제품은 제외예요.</p>
<h3>난임시술비는 정말 30%인가요?</h3>
<p>일반 15%보다 높은 30%가 적용돼요. 다만 보조생식술 비용에 한하고, 간소화 자료에서 일반 의료비와 구분되지 않을 수 있어서 병원 증명서를 회사에 따로 내는 게 안전해요.</p>
<h3>계산기 결과와 실제 환급이 다를 수 있나요?</h3>
<p>네. 3% 문턱을 빼는 순서, 산출세액 한도, 다른 공제와의 한도 상호작용을 단순화했어요. 대략적인 크기를 보는 용도로 쓰고 최종 금액은 홈택스 간소화 자료와 회사 정산 결과를 따르세요.</p>
<h2 id="s8">정리</h2>
<p>의료비 세액공제는 병원비 전체가 아니라 총급여 3%를 넘는 부분의 15%(난임 30%, 미숙아 20%)예요. 한도는 65세 미만 부양가족에만 연 700만 원이 걸리고, 본인·65세 이상·장애인·6세 이하는 한도가 없어요. 실손보험금은 빼고, 안경 50만 원·산후조리원 200만 원 한도는 따로 챙기세요. 내 숫자로 확인하려면 <a href="https://tool.smithlab.co.kr/money/medical-expense-tax-credit-calculator/">의료비 세액공제 계산기</a>를, 전체 환급액은 <a href="https://tool.smithlab.co.kr/money/year-end-tax-refund-calculator/">연말정산 환급액 계산기</a>를 써 보세요. 세법은 해마다 바뀌니 신고 전에 홈택스 안내로 한 번 더 확인하는 게 좋아요.</p>
    ]]></description>
    </item>
    <item>
      <title>AWS NAT 게이트웨이 비용 줄이기, AZ별 배치·엔드포인트 판단표</title>
      <link>https://tool.smithlab.co.kr/posts/developer/aws-nat-gateway-cost-az-endpoint-troubleshooting-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/aws-nat-gateway-cost-az-endpoint-troubleshooting-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>프라이빗 서브넷을 만들고 NAT 게이트웨이를 붙이는 것까지는 콘솔 마법사가 알아서 해 줘요. 운영에서 고민이 시작되는 건 그다음이에요. NAT 를 AZ마다 둘지, 청구서에 찍힌 'NatGateway-Bytes' 를 어떻게 줄일지, 가끔 외부 API 호출이 멈추는 이유가 뭔지 같은 질문이요. 결론부터 말하면 <strong>운영 환경은 AZ마다 NAT 를 두고(또는 리전 NAT), S3·DynamoDB 는 무료 게이트웨이 엔드포인트로 빼고, 나머지 AWS 서비스는 서비스당 월 수백 GB 를 넘을 때만 인터페이스 엔드포인트를 붙이는 것</strong>이 기본값이에요.</p>
<p>이 글은 패킷이 어떻게 변환되는지보다 <strong>어떤 값을 언제 고르나</strong>에 집중해요. 단가는 AWS 공식 가격 파일(Price List, 서울 리전 ap-northeast-2, 2026-11-01 적용분)에서 직접 뽑았고, 한도와 장애 증상은 <a href="https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-basics.html" target="_blank" rel="noopener">AWS VPC 사용 설명서의 NAT 게이트웨이 기본 사항</a>과 <a href="https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-troubleshooting.html" target="_blank" rel="noopener">NAT 게이트웨이 문제 해결 문서</a>로 확인했어요. 계산은 한 달을 730시간으로 잡았어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>서울 리전 NAT 게이트웨이는 시간당 0.059달러, 처리 GB당 0.059달러예요. 켜 두기만 해도 한 대에 월 43달러, 공인 IPv4 요금까지 약 47달러예요.</li><li>AZ 3개에 트래픽이 고르게 퍼져 있으면 NAT 하나를 공유하는 쪽이 월 약 7TB 까지는 싸요. 대신 그 AZ 가 멈추면 나머지 AZ 도 외부 통신을 잃어요.</li><li>S3 트래픽은 게이트웨이 엔드포인트로 빼면 처리 요금이 0이 돼요. 인터페이스 엔드포인트는 AZ 3개 기준 서비스당 월 약 581GB 를 넘어야 NAT 보다 싸져요.</li><li>연결이 350초 놀면 끊기고, 같은 목적지에는 IP 하나당 55,000 연결까지예요. CloudWatch 의 IdleTimeoutCount·ErrorPortAllocation 으로 먼저 확인해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">NAT 게이트웨이 요금 구조 — 무엇에 돈이 나가나</h2>
<p>NAT 게이트웨이 청구서는 네 줄로 나뉘어요. 서울 리전 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>항목</th><th>단가</th><th>월 환산(730시간)</th><th>언제 붙나</th></tr></thead><tbody><tr><td>NAT 게이트웨이 시간</td><td>0.059달러/시간</td><td>43.07달러</td><td>만들어 두면 트래픽이 0이어도</td></tr><tr><td>데이터 처리</td><td>0.059달러/GB</td><td>트래픽 비례</td><td>나가는 것·들어오는 것 모두</td></tr><tr><td>공인 IPv4 주소</td><td>0.005달러/시간</td><td>3.65달러</td><td>NAT 에 붙인 탄력적 IP 하나마다</td></tr><tr><td>AZ 간 전송</td><td>0.01달러/GB, 방향마다</td><td>트래픽 비례</td><td>인스턴스와 NAT 가 다른 AZ 일 때</td></tr></tbody></table></div>
<p>두 가지를 자주 놓쳐요. 첫째, <a href="https://aws.amazon.com/vpc/pricing/" target="_blank" rel="noopener">Amazon VPC 요금 페이지</a>는 데이터 처리 요금이 <strong>트래픽의 출발지·목적지와 관계없이</strong> GB마다 붙는다고 적어요. 패키지를 내려받는 것처럼 "들어오는" 트래픽도 NAT 를 지나면 처리 요금이 나가요. 둘째, NAT 를 지난 뒤 인터넷으로 나가는 데이터에는 일반 데이터 전송 요금이 따로 붙어요. NAT 처리 요금은 그 위에 얹히는 몫이에요.</p>
<p>AZ 간 전송은 <a href="https://aws.amazon.com/ec2/pricing/on-demand/" target="_blank" rel="noopener">Amazon EC2 온디맨드 요금 페이지</a>의 데이터 전송 항목대로 "각 방향 0.01달러"라, 다른 AZ 의 NAT 로 보낸 1GB 는 보내는 쪽과 받는 쪽에서 합쳐 0.02달러가 돼요. 리전 NAT 게이트웨이는 <strong>활성화된 AZ 마다</strong> 시간 요금이 붙어서, AZ 3개에 걸쳐 있으면 영역 NAT 3대와 시간 요금이 같아요.</p>
<h2 id="s2">NAT 하나 공유 vs AZ마다 — 월 7TB 손익분기</h2>
<p>가장 흔한 질문이에요. AZ 3개에 워크로드가 고르게 퍼져 있고, 한 달에 NAT 를 지나는 트래픽이 T GB 라고 해 볼게요.</p>
<ul><li><strong>NAT 1대 공유</strong>: 고정비 46.72달러 + 처리 0.059T + 다른 두 AZ 에서 넘어오는 3분의 2 트래픽의 AZ 간 요금 0.02×(2/3)T</li><li><strong>AZ마다 1대</strong>: 고정비 46.72×3 = 140.16달러 + 처리 0.059T, AZ 간 요금 없음</li></ul>
<p>두 식이 같아지는 지점은 93.44 = 0.0133T, 즉 <strong>T ≈ 7,008GB</strong> 예요. 월 7TB 아래면 1대 공유가 싸고, 위면 AZ마다 두는 쪽이 싸요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">월 NAT 트래픽별 비용 — 1대 공유 vs AZ마다 3대 (서울 리전)</figcaption><div class="viz-legend"><span><i class="s1"></i>1대 공유</span><span><i class="s2"></i>AZ마다 3대</span></div><svg viewBox="0 0 640 300" role="img" aria-label="월 NAT 트래픽별 비용 — 1대 공유 vs AZ마다 3대 (서울 리전)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">400</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">600</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">800</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">1,200</text><rect class="s1" x="73.6" y="237.7" width="49.4" height="16.3" rx="3"><title>500GB · 1대 공유 82.9달러</title></rect><rect class="s2" x="125.0" y="220.6" width="49.4" height="33.4" rx="3"><title>500GB · AZ마다 3대 170달러</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">500GB</text><rect class="s1" x="217.6" y="216.4" width="49.4" height="37.6" rx="3"><title>2TB · 1대 공유 191달러</title></rect><rect class="s2" x="269.0" y="203.2" width="49.4" height="50.8" rx="3"><title>2TB · AZ마다 3대 258달러</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">2TB</text><rect class="s1" x="361.6" y="145.2" width="49.4" height="108.8" rx="3"><title>7TB · 1대 공유 553달러</title></rect><rect class="s2" x="413.0" y="145.2" width="49.4" height="108.8" rx="3"><title>7TB · AZ마다 3대 553달러</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">7TB</text><rect class="s1" x="505.6" y="31.4" width="49.4" height="222.6" rx="3"><title>15TB · 1대 공유 1,132달러</title></rect><rect class="s2" x="557.0" y="52.4" width="49.4" height="201.6" rx="3"><title>15TB · AZ마다 3대 1,025달러</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">15TB</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">7TB 근처에서 두 선이 만나요. 그 아래 차이는 월 최대 87달러예요</p><div class="viz-cap">단위: 달러 · 자료: AWS Price List 서울 리전 단가(2026-11-01 적용분)로 직접 계산, 730시간·트래픽 AZ 3곳 균등 가정, 인터넷 전송 요금 제외</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>1대 공유</th><th>AZ마다 3대</th></tr></thead><tbody><tr><td>500GB</td><td>82.9</td><td>170</td></tr><tr><td>2TB</td><td>191</td><td>258</td></tr><tr><td>7TB</td><td>553</td><td>553</td></tr><tr><td>15TB</td><td>1,132</td><td>1,025</td></tr></tbody></table></div></details></figure>
<p>돈만 보면 작은 서비스는 1대가 맞아 보여요. 하지만 <a href="https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html" target="_blank" rel="noopener">NAT 게이트웨이 문서</a>는 NAT 가 <strong>자기 AZ 안에서만</strong> 이중화된다고 적어요. NAT 가 있는 AZ 가 멈추면 다른 두 AZ 의 서버도 외부 결제 API, 푸시 알림, 패키지 저장소에 닿지 못해요. 멀티 AZ 로 배포한 의미가 외부 통신에서 사라지는 거예요. 그래서 판단은 이렇게 해요.</p>
<div class="table-wrap"><table><thead><tr><th>환경</th><th>권장 구성</th><th>이유</th></tr></thead><tbody><tr><td>개발·스테이징</td><td>NAT 1대(또는 업무 시간만)</td><td>월 90달러 안팎 절약, 장애 영향 작음</td></tr><tr><td>운영, 외부 호출이 핵심 기능</td><td>AZ마다 영역 NAT 또는 리전 NAT</td><td>AZ 하나 장애가 전체 장애로 번지지 않게</td></tr><tr><td>운영, 월 7TB 이상</td><td>AZ마다</td><td>가용성과 비용 둘 다 유리</td></tr><tr><td>AZ 를 자주 늘리고 줄임</td><td>리전 NAT</td><td>퍼블릭 서브넷·AZ별 라우팅 테이블 관리가 사라짐</td></tr><tr><td>온프레미스·다른 VPC 로만 나감</td><td>영역 프라이빗 NAT</td><td>리전 NAT 는 프라이빗 연결을 지원하지 않음</td></tr></tbody></table></div>
<p><a href="https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateways-regional.html" target="_blank" rel="noopener">리전 NAT 게이트웨이 문서</a>에 적힌 제약도 같이 봐요. 새 AZ 에 리소스가 생기면 그 AZ 로 확장하는 데 <strong>최대 60분</strong>이 걸리고, 그동안 트래픽은 다른 AZ 의 NAT 를 거쳐요. 이 시간에는 AZ 간 요금이 붙고, 그 AZ 의 장애 격리도 아직 안 된 상태예요. 대신 AZ 하나에 IP 를 32개까지 붙일 수 있어서 연결 수가 많은 서비스에 여유가 커요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/aws-nat-gateway-cost-az-endpoint-troubleshooting-guide-1.svg" alt="외부로 나가는 출구를 고르는 판단 순서도: 목적지가 S3·DynamoDB 면 게이트웨이 엔드포인트, 다른 AWS 서비스이고 월 수백 GB 이상이면 인터페이스 엔드포인트, IPv6 로 충분하면 egress-only 인터넷 게이트웨이, 운영 환경이면 AZ별 NAT 또는 리전 NAT, 개발 환경이면 NAT 1대" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 답하고 처음 &#x27;예&#x27;가 나오는 칸이 그 트래픽의 출구예요</figcaption></figure>
<h2 id="s3">S3·DynamoDB 는 게이트웨이 엔드포인트로 — 처리 요금 0</h2>
<p>비용을 가장 쉽게 줄이는 곳이에요. <a href="https://docs.aws.amazon.com/vpc/latest/privatelink/gateway-endpoints.html" target="_blank" rel="noopener">게이트웨이 엔드포인트 문서</a>는 사용에 <strong>추가 요금이 없다</strong>고 적어요. 프라이빗 서브넷에서 S3 로 월 5TB(5,000GB)를 올리고 내리는 배치가 있다면, NAT 를 지날 때 처리 요금만 5,000×0.059 = <strong>295달러</strong>예요. 엔드포인트 하나로 이 줄이 0이 돼요.</p>
<p>만들 때 확인할 것은 세 가지예요.</p>
<ol><li><strong>라우팅 테이블 선택</strong>: 엔드포인트를 만들 때 고른 라우팅 테이블에만 프리픽스 목록(<code>pl-…</code>) 경로가 들어가요. AZ마다 프라이빗 라우팅 테이블을 나눴다면 전부 골라야 해요. 하나만 고르면 그 서브넷만 엔드포인트를 타고 나머지는 계속 NAT 요금을 내요.</li><li><strong>같은 리전만</strong>: 프리픽스 목록은 리전마다 달라서, 다른 리전 버킷으로 가는 트래픽은 그대로 NAT 를 지나요. 리전 간 복제나 다른 리전 로그 버킷이 있으면 따로 계산해요.</li><li><strong>버킷 정책</strong>: 엔드포인트를 붙인 뒤 버킷 정책에 <code>aws:sourceVpce</code> 조건을 걸면, 엔드포인트 밖(로컬 PC, 다른 VPC)에서의 접근이 막혀요. 정책은 엔드포인트가 모든 라우팅 테이블에 붙은 것을 확인한 다음에 바꿔요.</li></ol>
<p>컨테이너 이미지를 ECR 에서 받는 경우도 S3 와 엮여 있어요. <a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/vpc-endpoints.html" target="_blank" rel="noopener">Amazon ECR VPC 엔드포인트 문서</a>는 프라이빗 서브넷에서 이미지를 받으려면 <code>ecr.api</code>·<code>ecr.dkr</code> 인터페이스 엔드포인트와 <strong>S3 게이트웨이 엔드포인트가 함께</strong> 필요하다고 적어요. 배포 때마다 수 GB 이미지를 노드 수십 대가 받는 클러스터라면 S3 게이트웨이 엔드포인트만으로도 NAT 처리량이 눈에 띄게 줄어요. 월 몇 TB 인지 감이 안 오면 이미지 크기와 노드 수, 배포 횟수를 곱해 <a href="https://tool.smithlab.co.kr/convert/data-size-converter/">데이터 용량 변환기</a>로 GB 단위를 맞춰 보면 돼요.</p>
<h2 id="s4">인터페이스 엔드포인트 손익분기 — 서비스당 월 581GB</h2>
<p>S3·DynamoDB 말고 다른 AWS 서비스(ECR, CloudWatch Logs, STS, Secrets Manager 등)는 인터페이스 엔드포인트(PrivateLink)를 써요. 이건 공짜가 아니에요. 서울 리전에서 <strong>엔드포인트 하나가 AZ 하나에 시간당 0.013달러</strong>, 처리 GB당 0.01달러(월 1PB 까지)예요. AZ 3개에 걸면 고정비가 0.013×730×3 = 28.47달러예요.</p>
<p>NAT 를 지나면 GB당 0.059달러, 엔드포인트를 지나면 0.01달러라 GB당 0.049달러를 아껴요. 고정비를 메우는 지점은 28.47 ÷ 0.049 ≈ <strong>581GB/월</strong>(AZ 2개면 387GB)이에요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">한 AWS 서비스로 가는 월 트래픽별 비용 — NAT 경유 vs 인터페이스 엔드포인트(AZ 3개)</figcaption><div class="viz-legend"><span><i class="s1"></i>NAT 경유</span><span><i class="s2"></i>인터페이스 엔드포인트</span></div><svg viewBox="0 0 640 300" role="img" aria-label="한 AWS 서비스로 가는 월 트래픽별 비용 — NAT 경유 vs 인터페이스 엔드포인트(AZ 3개)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="570" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="570" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">80</text><line class="viz-grid" x1="52" x2="570" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">120</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">100GB</text><text class="viz-ax" x="181.5" y="272" text-anchor="middle">300GB</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">600GB</text><text class="viz-ax" x="440.5" y="272" text-anchor="middle">1TB</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">2TB</text><polyline class="viz-line s1" points="52.0,242.4 181.5,219.2 311.0,184.4 440.5,138.0 570.0,21.9"/><circle class="viz-dot s1" cx="52.0" cy="242.4" r="4"><title>100GB · NAT 경유 5.9달러</title></circle><circle class="viz-dot s1" cx="181.5" cy="219.2" r="4"><title>300GB · NAT 경유 17.7달러</title></circle><circle class="viz-dot s1" cx="311.0" cy="184.4" r="4"><title>600GB · NAT 경유 35.4달러</title></circle><circle class="viz-dot s1" cx="440.5" cy="138.0" r="4"><title>1TB · NAT 경유 59달러</title></circle><circle class="viz-dot s1" cx="570.0" cy="21.9" r="4"><title>2TB · NAT 경유 118달러</title></circle><text class="viz-val" x="578.0" y="25.9">118</text><polyline class="viz-line s2" points="52.0,196.0 181.5,192.1 311.0,186.2 440.5,178.3 570.0,158.6"/><circle class="viz-dot s2" cx="52.0" cy="196.0" r="4"><title>100GB · 인터페이스 엔드포인트 29.5달러</title></circle><circle class="viz-dot s2" cx="181.5" cy="192.1" r="4"><title>300GB · 인터페이스 엔드포인트 31.5달러</title></circle><circle class="viz-dot s2" cx="311.0" cy="186.2" r="4"><title>600GB · 인터페이스 엔드포인트 34.5달러</title></circle><circle class="viz-dot s2" cx="440.5" cy="178.3" r="4"><title>1TB · 인터페이스 엔드포인트 38.5달러</title></circle><circle class="viz-dot s2" cx="570.0" cy="158.6" r="4"><title>2TB · 인터페이스 엔드포인트 48.5달러</title></circle><text class="viz-val" x="578.0" y="162.6">48.5</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">600GB 근처에서 엔드포인트가 싸지기 시작해요</p><div class="viz-cap">단위: 달러 · 자료: AWS Price List 서울 리전 단가(2026-11-01 적용분)로 직접 계산, NAT 시간 요금은 이미 내고 있다고 보고 제외</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>NAT 경유</th><th>인터페이스 엔드포인트</th></tr></thead><tbody><tr><td>100GB</td><td>5.9</td><td>29.5</td></tr><tr><td>300GB</td><td>17.7</td><td>31.5</td></tr><tr><td>600GB</td><td>35.4</td><td>34.5</td></tr><tr><td>1TB</td><td>59</td><td>38.5</td></tr><tr><td>2TB</td><td>118</td><td>48.5</td></tr></tbody></table></div></details></figure>
<p>여기서 함정은 "엔드포인트를 서비스마다 다 만들자"예요. CloudWatch Logs 로 월 50GB 를 보내는 서비스에 3 AZ 엔드포인트를 걸면 NAT 로 보낼 때(2.95달러)보다 월 26달러 가까이 더 내요. 엔드포인트는 <strong>트래픽이 큰 서비스부터 하나씩</strong>, 아니면 보안 요구(인터넷 경로 금지) 때문에 비용과 상관없이 필요할 때 붙여요. 반대로 모든 AWS 호출을 엔드포인트로 뺄 수 있고 외부 인터넷 호출이 없다면, NAT 자체를 없애 대당 월 47달러의 고정비를 지울 수 있어요.</p>
<h2 id="s5">연결 한도와 350초 끊김 — 숫자로 미리 계산하기</h2>
<h3>같은 목적지 55,000 연결</h3>
<p><a href="https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-basics.html" target="_blank" rel="noopener">NAT 게이트웨이 기본 사항</a>의 한도는 "IP 주소 하나가 <strong>고유 목적지 하나</strong>에 동시 연결 55,000개"예요. 고유 목적지는 목적지 IP·포트·프로토콜의 조합이에요. 전체 연결 수가 아니라 <strong>한 곳에 몰리는 연결 수</strong>가 문제라는 뜻이에요.</p>
<p>계산 예를 들어 볼게요. 파드 200개가 각각 외부 결제 API 하나에 커넥션 풀 300개를 열면 60,000개예요. 그 API 가 IP 하나로 응답한다면 IP 하나짜리 NAT 는 한도를 넘어요. 필요한 IP 수는 올림(60,000 ÷ 55,000) = 2개예요. 영역 NAT 는 기본 1개 + 보조 7개로 최대 8개(440,000 연결), 리전 NAT 는 AZ 하나에 32개까지 붙일 수 있어요. 다만 퍼블릭 NAT 하나에 붙일 수 있는 탄력적 IP 는 기본 2개로 묶여 있어서, 더 붙이려면 서비스 할당량 증가를 먼저 요청해요. IP 를 늘리면 하나마다 공인 IPv4 요금(월 3.65달러)도 붙어요.</p>
<p>한도에 닿았는지는 CloudWatch 의 <code>ErrorPortAllocation</code> 으로 봐요. <a href="https://docs.aws.amazon.com/vpc/latest/userguide/metrics-dimensions-nat-gateway.html" target="_blank" rel="noopener">NAT 게이트웨이 CloudWatch 지표 문서</a>는 이 값이 0보다 크면 "NAT 를 지나는 동시 연결이 너무 많다"는 뜻이라고 적어요. IP 를 늘리기 전에 커넥션 풀 크기부터 줄일 수 있는지 봐요. 파드마다 300개 풀은 대개 과해요.</p>
<h3>350초 유휴 타임아웃</h3>
<p>NAT 를 지나는 연결이 <strong>350초 이상 아무 패킷도 없으면</strong> 끊겨요. 문제 해결 문서는 이때 NAT 가 뒤쪽 리소스에 RST 를 보낸다고 적어요. 앱 입장에서는 커넥션 풀에서 꺼낸 연결로 요청을 보냈더니 "connection reset" 이 나는 증상이에요. 6분 넘게 쉬는 시간대가 있는 배치, 밤새 조용한 관리자 API, 롱 폴링에서 잘 보여요.</p>
<p>리눅스 기본값으로는 막을 수 없어요. <a href="https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html" target="_blank" rel="noopener">리눅스 커널 ip-sysctl 문서</a>의 <code>tcp_keepalive_time</code> 기본값이 <strong>2시간(7,200초)</strong>이라, keepalive 를 켜도 첫 탐지 패킷이 나가기 전에 NAT 가 이미 연결을 지워요. 손볼 곳은 두 군데예요.</p>
<div class="table-wrap"><table><thead><tr><th>설정</th><th>기본값</th><th>권장</th><th>비고</th></tr></thead><tbody><tr><td><code>net.ipv4.tcp_keepalive_time</code></td><td>7,200초</td><td>300초 이하</td><td>350초보다 확실히 짧게</td></tr><tr><td><code>net.ipv4.tcp_keepalive_intvl</code></td><td>75초</td><td>30~60초</td><td>응답 없을 때 재시도 간격</td></tr><tr><td>앱 커넥션 풀 유휴 만료</td><td>라이브러리마다</td><td>300초 이하</td><td>오래 쉰 연결을 풀에서 먼저 버림</td></tr></tbody></table></div>
<p>커널 값은 앱이 소켓에 keepalive 옵션(SO_KEEPALIVE)을 켠 연결에만 적용돼요. 그래서 실무에서는 <strong>풀의 유휴 연결 만료를 350초보다 짧게</strong> 두는 쪽이 더 확실해요. 끊길 연결을 처음부터 쓰지 않게 하는 거예요. 지표로는 <code>IdleTimeoutCount</code> 가 늘어나는지 봐요. 지표 문서는 이 값이 증가하면 "오래된 연결을 다시 쓰고 있을 수 있다"고 설명해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">프라이빗 서브넷에서 인터넷이 안 될 때 확인 순서</h2>
<p>새 서브넷에서 <code>curl</code> 이 타임아웃 나면 원인은 대부분 다섯 군데 중 하나예요. 위에서부터 보면 빠르게 좁혀져요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/aws-nat-gateway-cost-az-endpoint-troubleshooting-guide-2.svg" alt="프라이빗 서브넷 인터넷 연결 장애 확인 순서 다섯 단계: NAT 상태 Available, 프라이빗 라우팅 테이블의 0.0.0.0/0 대상, NAT 가 있는 서브넷의 인터넷 게이트웨이 경로, 보안 그룹 아웃바운드와 네트워크 ACL 양방향, 플로우 로그와 Reachability Analyzer" width="800" height="450" loading="lazy" decoding="async"><figcaption>앞 단계가 정상이어야 다음 단계를 볼 의미가 있어요</figcaption></figure>
<ol><li><strong>NAT 상태가 Available 인가.</strong> Failed 로 끝난 NAT 는 자동 삭제되니 목록에서 사라졌을 수도 있어요. 라우팅 테이블에 대상이 <code>blackhole</code> 로 남아 있으면 이 경우예요.</li><li><strong>인스턴스 서브넷의 라우팅 테이블.</strong> <code>0.0.0.0/0</code> 이 NAT 를 가리키는지, 그리고 더 구체적인 경로(예: 사내망 대역을 VPN 으로)가 목적지를 먼저 가로채지 않는지 봐요. 서브넷을 명시적으로 연결하지 않았다면 메인 라우팅 테이블을 타요.</li><li><strong>NAT 가 놓인 서브넷의 라우팅 테이블.</strong> 여기에 인터넷 게이트웨이 경로가 있어야 해요. NAT 를 실수로 프라이빗 서브넷에 만들면 1·2번이 멀쩡해도 나가지 못해요. 리전 NAT 는 자체 라우팅 테이블을 쓰니 이 단계가 없어요.</li><li><strong>보안 그룹과 네트워크 ACL.</strong> 보안 그룹은 아웃바운드만 열려 있으면 응답이 자동으로 들어와요. ACL 은 응답도 다시 검사하니 퍼블릭·프라이빗 두 서브넷 모두 인바운드에 임시 포트 범위가 열려 있어야 해요. <code>ping</code> 으로 시험한다면 ICMP 도 열어요.</li><li><strong>도구로 확정.</strong> <a href="https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html" target="_blank" rel="noopener">VPC 플로우 로그</a>에서 REJECT 가 찍히는 지점을 찾거나, <a href="https://docs.aws.amazon.com/vpc/latest/reachability/what-is-reachability-analyzer.html" target="_blank" rel="noopener">Reachability Analyzer</a>로 인스턴스에서 인터넷 게이트웨이까지 경로를 분석해요. 분석 1회에 0.10달러(서울 리전)라 추측으로 설정을 여러 번 바꾸는 것보다 싸요.</li></ol>
<p>증상별로는 이렇게 좁혀요.</p>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>먼저 볼 곳</th></tr></thead><tbody><tr><td>처음부터 타임아웃</td><td>위 1~4단계</td></tr><tr><td>잘 되다가 6분쯤 쉬면 reset</td><td>350초 유휴 타임아웃, <code>IdleTimeoutCount</code></td></tr><tr><td>트래픽 많을 때 특정 API 만 새 연결 실패</td><td>55,000 한도, <code>ErrorPortAllocation</code></td></tr><tr><td>IPsec VPN 이 안 맺어짐</td><td>NAT 는 TCP·UDP·ICMP 만 지원, NAT-T(UDP 캡슐화) 사용</td></tr><tr><td>S3 요청이 여전히 NAT 지표에 잡힘</td><td>엔드포인트에 그 라우팅 테이블을 골랐는지, 다른 리전 버킷인지</td></tr></tbody></table></div>
<h2 id="s7">서브넷 크기와 도입 전에 따질 것</h2>
<p>NAT 와 함께 정하는 게 서브넷 크기예요. <a href="https://docs.aws.amazon.com/vpc/latest/userguide/subnet-sizing.html" target="_blank" rel="noopener">서브넷 크기 문서</a>대로 서브넷은 /16~/28 사이이고, 앞 4개와 마지막 1개 주소는 AWS 가 가져가요. 쓸 수 있는 주소는 /24 가 251개, /26 이 59개, /28 은 11개예요. 퍼블릭 서브넷은 NAT·로드 밸런서 정도만 두니 작게, 프라이빗 서브넷은 인스턴스·컨테이너·인터페이스 엔드포인트 ENI 가 모두 주소를 쓰니 넉넉하게 잡아요. 대역 나누기는 <a href="https://tool.smithlab.co.kr/developer/subnet-calculator/">IP 서브넷 계산기</a>로 바로 확인되고, 마스크 계산 원리는 <a href="https://tool.smithlab.co.kr/posts/developer/subnet-cidr-calculation/">서브넷 마스크·CIDR 계산법</a>에 정리돼 있어요.</p>
<p>도입 전 체크리스트예요.</p>
<ul><li><strong>외부로 나가는 트래픽이 실제로 얼마인가.</strong> 처리 요금이 시간 요금보다 큰 경우가 많아요. NAT 지표 <code>BytesOutToDestination</code> 과 <code>BytesInFromDestination</code> 을 한 달 합쳐 보면 처리 GB 가 나와요.</li><li><strong>그중 AWS 서비스 몫은 얼마인가.</strong> S3·DynamoDB 는 무조건 게이트웨이 엔드포인트, 나머지는 서비스당 월 수백 GB 기준으로 판단해요.</li><li><strong>IPv6 로 충분한 목적지인가.</strong> <a href="https://docs.aws.amazon.com/vpc/latest/userguide/egress-only-internet-gateway.html" target="_blank" rel="noopener">egress-only 인터넷 게이트웨이</a>는 게이트웨이 요금이 없어요(데이터 전송 요금은 있음). IPv4 전용 목적지에는 쓸 수 없어요.</li><li><strong>NAT 인스턴스로 아낄 만한가.</strong> <a href="https://docs.aws.amazon.com/vpc/latest/userguide/VPC_NAT_Instance.html" target="_blank" rel="noopener">NAT 인스턴스 문서</a>대로 직접 만들 수는 있지만 패치·이중화·대역폭 관리가 모두 내 몫이에요. 개발 환경 정도에서만 따져 볼 만해요.</li><li><strong>인프라 코드로 AZ 수만큼 만들어지는가.</strong> NAT 를 늘려 놓고 프라이빗 라우팅 테이블을 하나로 두면 모두 한 NAT 로 몰려요. 테라폼이라면 NAT·라우팅 테이블·연결을 같은 AZ 목록으로 반복시켜요. 여러 리소스를 한 번에 바꿀 때의 순서 문제는 <a href="https://tool.smithlab.co.kr/posts/developer/terraform-parallelism-depends-on-create-before-destroy-guide/">테라폼 parallelism·depends_on 판단표</a>를 참고해요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>NAT 게이트웨이 비용이 갑자기 늘었어요. 어디부터 보나요?</h3>
<p>비용 탐색기에서 사용 유형을 <code>NatGateway-Hours</code> 와 <code>NatGateway-Bytes</code> 로 나눠 봐요. 대부분 Bytes 쪽이 원인이에요. 그다음 NAT 의 CloudWatch 처리량 지표가 어느 시간대에 튀는지 보고, 그 시간에 도는 배치·이미지 배포·로그 전송을 찾아요. S3·ECR 트래픽이면 엔드포인트로 바로 줄일 수 있어요.</p>
<h3>개발 환경에서는 NAT 를 아예 꺼 둬도 되나요?</h3>
<p>업무 시간에만 쓴다면 NAT 를 삭제했다가 다시 만드는 자동화로 시간 요금을 줄일 수 있어요. 다만 새로 만들면 NAT ID 가 바뀌니 라우팅 테이블도 함께 갱신해야 하고, 탄력적 IP 를 외부 허용 목록에 등록해 뒀다면 같은 EIP 를 다시 붙여야 해요. NAT 를 지운 뒤 붙어 있지 않은 EIP 도 유휴 공인 IPv4 요금(시간당 0.005달러)이 나가요.</p>
<h3>리전 NAT 게이트웨이로 바꾸면 비용이 줄어드나요?</h3>
<p>요금 단가는 영역 NAT 와 같고 활성 AZ마다 시간 요금이 붙어서, AZ 3개면 영역 NAT 3대와 비슷해요. 얻는 건 비용보다 관리 단순화예요. 퍼블릭 서브넷이 필요 없고, AZ마다 라우팅 테이블을 따로 둘 필요가 없어요. 프라이빗 연결 유형이 필요하거나 새 AZ 확장 대기(최대 60분)가 문제라면 영역 NAT 를 유지해요.</p>
<h3>보안 그룹을 NAT 게이트웨이에 붙일 수 있나요?</h3>
<p>없어요. NAT 게이트웨이에는 보안 그룹을 연결할 수 없고, NAT 가 놓인 서브넷의 네트워크 ACL 만 적용돼요. 나가는 트래픽을 제한하고 싶다면 인스턴스 쪽 보안 그룹의 아웃바운드 규칙이나 서브넷 ACL 로 막아요.</p>
<h3>외부 API 가 우리 IP 를 허용 목록에 넣어 달래요. 어떤 IP 를 주나요?</h3>
<p>NAT 에 붙인 탄력적 IP 를 줘요. AZ마다 NAT 를 두면 IP 가 AZ 수만큼이고, 연결 한도 때문에 보조 IP 를 붙였다면 그것까지 모두 등록해야 해요. 일부만 등록하면 특정 AZ 나 특정 연결에서만 간헐적으로 거부되는, 찾기 어려운 장애가 생겨요.</p>
<h2 id="s9">정리</h2>
<ul><li>서울 리전 NAT 게이트웨이는 대당 월 약 47달러(시간+IPv4)에 처리 GB당 0.059달러예요. 청구서의 큰 몫은 대개 처리 요금이에요.</li><li>AZ 3개에 고른 트래픽이라면 월 7TB 아래는 1대 공유가 싸지만, 운영 환경은 장애 격리를 위해 AZ마다 또는 리전 NAT 로 둬요.</li><li>S3·DynamoDB 는 게이트웨이 엔드포인트(무료)로 빼고, 인터페이스 엔드포인트는 서비스당 월 약 581GB(AZ 3개 기준)를 넘을 때 붙여요.</li><li>운영 사고는 350초 유휴 타임아웃과 목적지당 55,000 연결에서 많이 나요. 풀 유휴 만료를 300초 이하로, <code>IdleTimeoutCount</code>·<code>ErrorPortAllocation</code> 에 경보를 걸어 둬요. 단가는 2026-11-07 확인 기준이니 도입 전에 가격표를 다시 봐요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/aws-vpc-packet-flow-subnet-route-table-nat-gateway" target="_blank" rel="noopener">AWS VPC 패킷 흐름 원리 — 서브넷·라우팅 테이블·NAT</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>비전 LLM 직접 서빙 해상도 고르기 — max_pixels별 GPU…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/self-hosted-vision-llm-resolution-token-budget-gpu-cost-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/self-hosted-vision-llm-resolution-token-budget-gpu-cost-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>사내 문서나 고객 스크린샷을 외부 API 로 보낼 수 없어서 오픈 비전 LLM 을 직접 띄우기로 했다면, 모델 다음으로 정할 값은 <strong>이미지 한 장을 몇 토큰까지 허용할지</strong>예요. 결론부터 말하면 <strong>Qwen2-VL 계열은 <code>max_pixels</code> 를 1280×28×28(약 1,280토큰)로 묶는 것을 기본값으로 두고, 분류·태깅만 하면 256토큰, 아주 작은 글씨가 핵심인 화면만 원본 해상도로 열어 두는 게</strong> 무난해요. 기본값(상한 16,384토큰)을 그대로 두면 1920×1080 한 장이 1,280 상한일 때보다 GPU 연산을 2.5배 써요.</p>
<p>API 는 이미지 토큰 수만큼 요금을 받지만, 직접 서빙하면 그 숫자가 세 군데로 나눠 청구돼요. 비전 인코더 연산(첫 토큰 지연), LLM 프리필 연산(GPU 시간), KV 캐시(동시 요청 수)예요. 이 글은 공개된 모델 설정 파일과 전처리 코드로 세 가지를 직접 계산하고, <a href="https://lambda.ai/pricing" target="_blank" rel="noopener">Lambda GPU 가격표</a>의 시간 단가로 비용까지 바꿔 봐요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>1920×1080 한 장은 LLaVA-1.5 576토큰, LLaVA-NeXT 1,948토큰, Qwen2-VL 기본 설정 2,693토큰, max_pixels 1,280 상한이면 1,224토큰이에요.</li><li>Qwen2-VL 기본 설정에서는 비전 인코더가 전체 연산의 46%를 써요. 패치끼리 전부 어텐션하니 해상도를 올리면 인코더 몫이 패치 수의 제곱으로 커져요.</li><li>KV 캐시는 토큰 수보다 모델 구조가 더 크게 좌우해요. LLaVA-1.5 의 576토큰(288MiB)이 Qwen2-VL 2,693토큰(147MiB)보다 메모리를 더 먹어요.</li><li>H100 시간당 4.89달러·활용률 40% 가정에서 FHD 100만 장 비용은 Qwen2-VL 1,280 상한 95달러, 기본 설정 243달러예요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">직접 서빙하면 이미지 비용이 세 군데로 나뉘어요</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/self-hosted-vision-llm-resolution-token-budget-gpu-cost-guide-1.svg" alt="원본 1920×1080 이미지가 1316×728로 줄고, ViT 에서 패치 4,888개로, 2×2 병합 뒤 1,222토큰으로 LLM 에 들어가며 비전 인코더 10.1 TFLOP, LLM 프리필 17.6 TFLOP, KV 캐시 66.9MiB 를 쓰는 흐름도" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 이미지 토큰 수가 연산 두 번과 메모리 한 번으로 나눠 청구돼요</figcaption></figure>
<ul><li><strong>비전 인코더 연산</strong>: 이미지를 14px 패치로 잘라 트랜스포머에 넣어요. Qwen2-VL 은 패치 4개를 나중에 토큰 1개로 합치니, 인코더가 실제로 처리하는 길이는 <strong>LLM 에 들어가는 이미지 토큰의 4배</strong>예요. <a href="https://arxiv.org/abs/2409.12191" target="_blank" rel="noopener">Qwen2-VL 논문</a>의 비전 인코더는 창 없이 모든 패치끼리 어텐션해서 이 부분이 길이의 제곱으로 커져요.</li><li><strong>LLM 프리필 연산</strong>: 이미지 토큰과 질문을 한꺼번에 읽는 단계예요. 대부분 "파라미터 수 × 2 × 토큰 수"에 비례하고, 이게 GPU 시간과 요금이 돼요.</li><li><strong>KV 캐시</strong>: 프리필이 끝나도 답을 다 만들 때까지 이미지 토큰의 키·값을 GPU 메모리에 들고 있어요. 한 장이 차지하는 크기가 곧 동시에 받을 수 있는 요청 수를 정해요.</li></ul>
<p>셋은 따로 움직여요. 가로세로를 두 배로 하면 토큰과 프리필은 4배, 인코더 어텐션은 16배가 되고, KV 캐시는 같은 토큰 수라도 모델 구조에 따라 9배까지 벌어져요.</p>
<h2 id="s2">모델·설정별 이미지 토큰 수 — 전처리 코드로 직접 계산</h2>
<p>토큰 수는 전처리 규칙이 정해요. <a href="https://github.com/huggingface/transformers/blob/main/src/transformers/models/qwen2_vl/image_processing_qwen2_vl.py" target="_blank" rel="noopener">transformers 의 Qwen2-VL 이미지 프로세서</a>는 가로세로를 28의 배수로 반올림하고, 넓이가 <code>max_pixels</code> 를 넘으면 비율을 지키며 줄여요. <a href="https://huggingface.co/Qwen/Qwen2-VL-7B-Instruct" target="_blank" rel="noopener">Qwen2-VL-7B-Instruct 모델 카드</a>는 기본 범위를 이미지당 4~16,384토큰으로 적고, 속도와 메모리를 위해 256~1,280처럼 좁히라고 안내해요. 배포된 <code>preprocessor_config.json</code> 의 <code>max_pixels</code> 도 12,845,056(=16,384×28×28)이에요.</p>
<p>LLaVA-NeXT 는 다른 길을 써요. <a href="https://llava-vl.github.io/blog/2024-01-30-llava-next/" target="_blank" rel="noopener">LLaVA-NeXT 발표 글</a>대로 336px 타일을 2~4개 붙인 격자(672×672, 336×1008 등) 중 이미지에 가장 맞는 것을 골라 타일마다 576토큰을 만들고, 전체를 줄인 기본 이미지 576토큰을 더해요. 정확한 수는 <a href="https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_next/processing_llava_next.py" target="_blank" rel="noopener">transformers 의 LLaVA-NeXT 프로세서</a>의 계산(여백 제거, 줄바꿈 토큰 포함)을 그대로 옮겨 구했어요. LLaVA-1.5 는 짧은 변을 336에 맞추고 가운데를 잘라 항상 576토큰이에요.</p>
<div class="table-wrap"><table><thead><tr><th>이미지</th><th>LLaVA-1.5</th><th>LLaVA-NeXT</th><th>Qwen2-VL 256 상한</th><th>640 상한</th><th>1,280 상한</th><th>기본(16,384 상한)</th></tr></thead><tbody><tr><td>1920×1080 화면 캡처</td><td>576</td><td>1,948</td><td>254</td><td>596</td><td>1,224</td><td>2,693</td></tr><tr><td>1080×2340 폰 캡처</td><td>576</td><td>1,876</td><td>232</td><td>631</td><td>1,250</td><td>3,278</td></tr><tr><td>800×1100 영수증 사진</td><td>576</td><td>2,256</td><td>236</td><td>611</td><td>1,133</td><td>1,133</td></tr><tr><td>3840×2160 4K 캡처</td><td>576</td><td>1,948</td><td>254</td><td>596</td><td>1,224</td><td>10,551</td></tr></tbody></table></div>
<p>Qwen2-VL 숫자에는 이미지 앞뒤 시작·끝 토큰 2개를 넣었어요. 영수증처럼 원래 작은 이미지는 1,280 상한 안에 들어와서 기본값과 같아요. 상한은 <strong>큰 이미지에만</strong> 걸리는 장치라, 들어오는 이미지가 대부분 작다면 상한을 낮춰도 비용이 거의 안 줄어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">1920×1080 한 장이 LLM 에 들어가는 이미지 토큰 수</figcaption><svg viewBox="0 0 640 220" role="img" aria-label="1920×1080 한 장이 LLM 에 들어가는 이미지 토큰 수" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="208" y="29" text-anchor="end">Qwen2-VL 기본</text><rect class="s1" x="218" y="15" width="316.0" height="20" rx="3"><title>Qwen2-VL 기본 · 2,693토큰</title></rect><text class="viz-val" x="540.0" y="29">2,693토큰</text><text class="viz-ax lg" x="208" y="63" text-anchor="end">LLaVA-NeXT</text><rect class="s1" x="218" y="49" width="228.6" height="20" rx="3"><title>LLaVA-NeXT · 1,948토큰</title></rect><text class="viz-val" x="452.6" y="63">1,948토큰</text><text class="viz-ax lg" x="208" y="97" text-anchor="end">Qwen2-VL 1280 상한</text><rect class="s1" x="218" y="83" width="143.6" height="20" rx="3"><title>Qwen2-VL 1280 상한 · 1,224토큰</title></rect><text class="viz-val" x="367.6" y="97">1,224토큰</text><text class="viz-ax lg" x="208" y="131" text-anchor="end">Qwen2-VL 640 상한</text><rect class="s1" x="218" y="117" width="69.9" height="20" rx="3"><title>Qwen2-VL 640 상한 · 596토큰</title></rect><text class="viz-val" x="293.9" y="131">596토큰</text><text class="viz-ax lg" x="208" y="165" text-anchor="end">LLaVA-1.5</text><rect class="s1" x="218" y="151" width="67.6" height="20" rx="3"><title>LLaVA-1.5 · 576토큰</title></rect><text class="viz-val" x="291.6" y="165">576토큰</text><text class="viz-ax lg" x="208" y="199" text-anchor="end">Qwen2-VL 256 상한</text><rect class="s1" x="218" y="185" width="29.8" height="20" rx="3"><title>Qwen2-VL 256 상한 · 254토큰</title></rect><text class="viz-val" x="253.8" y="199">254토큰</text></svg><p class="viz-note">LLaVA-1.5 는 가운데를 잘라 좌우 44%가 빠진 576토큰이에요</p><div class="viz-cap">단위: 토큰 · 자료: transformers 전처리 코드(smart_resize·LLaVA-NeXT 프로세서)를 옮겨 직접 계산 (2026-11-07)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>토큰</th></tr></thead><tbody><tr><td>Qwen2-VL 기본</td><td>2,693</td></tr><tr><td>LLaVA-NeXT</td><td>1,948</td></tr><tr><td>Qwen2-VL 1280 상한</td><td>1,224</td></tr><tr><td>Qwen2-VL 640 상한</td><td>596</td></tr><tr><td>LLaVA-1.5</td><td>576</td></tr><tr><td>Qwen2-VL 256 상한</td><td>254</td></tr></tbody></table></div></details></figure>
<h2 id="s3">프리필 연산과 1,000장당 GPU 비용</h2>
<p>토큰 수를 연산량으로 바꿨어요. 각 모델의 공개 <code>config.json</code> 에서 층 수·폭·KV 헤드 수를 읽어 선형층은 "2 × 파라미터 × 길이", 어텐션은 "4 × 층 × 길이² × 폭"(LLM 은 인과 마스크라 절반)으로 셌어요. 질문 텍스트는 100토큰으로 두었고, 답을 만드는 디코딩은 설정과 상관없이 비슷해서 빼고 이미지를 읽는 데 드는 몫만 봤어요.</p>
<figure class="viz viz-stacked"><figcaption class="viz-title">1920×1080 한 장의 프리필 연산량 (Qwen2-VL-7B 설정별)</figcaption><div class="viz-legend"><span><i class="s1"></i>비전 인코더</span><span><i class="s2"></i>LLM 프리필</span></div><svg viewBox="0 0 640 300" role="img" aria-label="1920×1080 한 장의 프리필 연산량 (Qwen2-VL-7B 설정별)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">80</text><rect class="s1" x="73.6" y="249.8" width="100.8" height="4.2" rx="3"><title>256 상한 · 비전 인코더 1.4TFLOP</title></rect><rect class="s2" x="73.6" y="236.1" width="100.8" height="11.7" rx="3"><title>256 상한 · LLM 프리필 4.7TFLOP</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">256 상한</text><rect class="s1" x="217.6" y="242.5" width="100.8" height="11.5" rx="3"><title>640 상한 · 비전 인코더 3.9TFLOP</title></rect><rect class="s2" x="217.6" y="215.4" width="100.8" height="25.1" rx="3"><title>640 상한 · LLM 프리필 9.2TFLOP</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">640 상한</text><rect class="s1" x="361.6" y="224.3" width="100.8" height="29.7" rx="3"><title>1280 상한 · 비전 인코더 10.1TFLOP</title></rect><rect class="s2" x="361.6" y="172.3" width="100.8" height="50.0" rx="3"><title>1280 상한 · LLM 프리필 17.6TFLOP</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">1280 상한</text><rect class="s1" x="505.6" y="158.2" width="100.8" height="95.8" rx="3"><title>기본 · 비전 인코더 32.5TFLOP</title></rect><rect class="s2" x="505.6" y="46.1" width="100.8" height="110.1" rx="3"><title>기본 · LLM 프리필 38TFLOP</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">기본</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">기본 설정에서는 인코더가 전체의 46%, 256 상한에서는 23%예요</p><div class="viz-cap">단위: TFLOP · 자료: Qwen2-VL-7B-Instruct config.json 값으로 직접 계산, 텍스트 100토큰 포함</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>비전 인코더</th><th>LLM 프리필</th></tr></thead><tbody><tr><td>256 상한</td><td>1.4</td><td>4.7</td></tr><tr><td>640 상한</td><td>3.9</td><td>9.2</td></tr><tr><td>1280 상한</td><td>10.1</td><td>17.6</td></tr><tr><td>기본</td><td>32.5</td><td>38</td></tr></tbody></table></div></details></figure>
<p>상한을 1,280에서 기본값으로 풀면 토큰은 2.2배인데 인코더 연산은 3.2배가 돼요. 4K 캡처를 기본값으로 넣으면 패치가 42,196개라 인코더 어텐션만 292 TFLOP, 전체 507 TFLOP 이에요. 인코더 어텐션의 점수 행렬을 통째로 만드는 구현이라면 한 층에만 57GB(헤드 16개, 16비트)가 필요하니, FlashAttention 류 구현이 켜져 있는지부터 확인해요. 구현별 메모리 차이는 <a href="https://tool.smithlab.co.kr/posts/developer/attention-implementation-sdpa-flashattention-selection-cost-guide/">어텐션 구현 고르기, SDPA·FlashAttention 메모리와 GPU 비용</a>에 정리돼 있어요.</p>
<p><a href="https://huggingface.co/Qwen/Qwen2.5-VL-7B-Instruct/blob/main/config.json" target="_blank" rel="noopener">Qwen2.5-VL-7B 의 config.json</a>을 보면 인코더에 <code>window_size: 112</code> 와 전체 어텐션 층 4개(<code>fullatt_block_indexes: [7, 15, 23, 31]</code>)가 들어가 있어요. 32층 중 28층을 창 안에서만 어텐션해서 고해상도 인코더 비용의 제곱 항을 줄인 설계예요. 큰 이미지를 자주 받는다면 후속 모델로 옮기는 것도 선택지예요.</p>
<p>연산량을 돈으로 바꾸려면 GPU 성능과 단가가 필요해요. BF16 밀집 연산 최대치는 <a href="https://dam-cdn.nvd.orangelogic.com/AssetLink/705n6ur546g0uk43w0117r17n8042d73.pdf" target="_blank" rel="noopener">NVIDIA H100 아키텍처 백서</a>의 H100 SXM5 989.4 TFLOPS, <a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/a10/pdf/a10-datasheet.pdf" target="_blank" rel="noopener">NVIDIA A10 데이터시트</a>의 125 TFLOPS 를 썼어요. 단가는 Lambda 가격표의 1장 인스턴스 가격(H100 SXM 4.89달러, A10 1.29달러, 2026-11-16 적용분, 2026-11-07 확인, 세금 별도)이에요. 실제로 최대치를 다 쓰지는 못하니 <strong>활용률 40%는 가정</strong>이고, 숫자는 GPU 가 쉬지 않고 이미지만 처리할 때의 하한이에요.</p>
<div class="table-wrap"><table><thead><tr><th>설정 (1920×1080)</th><th>연산량</th><th>H100 한 장</th><th>A10 한 장</th><th>H100 100만 장</th><th>A10 100만 장</th></tr></thead><tbody><tr><td>Qwen2-VL 256 상한</td><td>6.1 TFLOP</td><td>15ms</td><td>122ms</td><td>21달러</td><td>44달러</td></tr><tr><td>LLaVA-1.5</td><td>9.3 TFLOP</td><td>23ms</td><td>186ms</td><td>32달러</td><td>67달러</td></tr><tr><td>Qwen2-VL 640 상한</td><td>13.2 TFLOP</td><td>33ms</td><td>263ms</td><td>45달러</td><td>94달러</td></tr><tr><td>Qwen2-VL 1,280 상한</td><td>27.8 TFLOP</td><td>70ms</td><td>556ms</td><td>95달러</td><td>199달러</td></tr><tr><td>LLaVA-NeXT</td><td>31.7 TFLOP</td><td>80ms</td><td>634ms</td><td>109달러</td><td>227달러</td></tr><tr><td>Qwen2-VL 기본</td><td>70.8 TFLOP</td><td>179ms</td><td>1.42초</td><td>243달러</td><td>507달러</td></tr></tbody></table></div>
<p>두 가지가 보여요. 첫째, <strong>같은 연산이면 A10 이 H100 보다 2배 넘게 비싸요.</strong> 시간당 단가는 A10 이 싸지만 달러당 연산량이 H100 은 202 TFLOPS, A10 은 97 TFLOPS 거든요. 싼 GPU 가 이득인 건 요청이 적어 GPU 가 대부분 놀 때예요. 둘째, 한 장 시간은 첫 토큰 지연의 바닥값이에요. A10 에서 기본 설정이면 이미지를 읽는 데만 1.4초가 걸리니, 채팅처럼 바로 답해야 하는 기능에는 상한을 거는 게 사실상 필수예요. 외부 API 요금과 비교하고 싶다면 같은 이미지의 API 토큰 수와 단가를 정리한 <a href="https://tool.smithlab.co.kr/posts/developer/multimodal-llm-image-token-cost-selection-guide/">멀티모달 LLM 이미지 토큰 비용 비교</a>를 함께 보면 돼요.</p>
<h2 id="s4">KV 캐시와 동시 요청 수 — 토큰 수보다 모델 구조</h2>
<p>KV 캐시 한 토큰 크기는 "2(키·값) × 층 × KV 헤드 × 헤드 차원 × 2바이트"예요. 공개 설정으로 계산하면 LLaVA-1.5-7B(Vicuna-7B, KV 헤드 32개) 512KiB, LLaVA-NeXT-Mistral-7B(KV 헤드 8개) 128KiB, Qwen2-VL-7B(KV 헤드 4개) 56KiB 예요. KV 헤드를 줄이는 GQA 가 있느냐 없느냐로 9배가 차이 나요.</p>
<p>그래서 이미지 토큰이 가장 적은 LLaVA-1.5 가 메모리로는 가장 무거워요. 아래는 H100 80GB 에서 가중치·활성값을 빼고 <strong>KV 캐시에 45GiB 를 쓸 수 있다고 가정</strong>하고, 요청마다 FHD 이미지 1장 + 텍스트 400토큰(질문 100, 답 300)을 담을 때 동시에 들고 있을 수 있는 요청 수예요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">H100 KV 캐시 45GiB 에 동시에 담는 요청 수 (FHD 1장 + 텍스트 400토큰)</figcaption><svg viewBox="0 0 640 220" role="img" aria-label="H100 KV 캐시 45GiB 에 동시에 담는 요청 수 (FHD 1장 + 텍스트 400토큰)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="208" y="29" text-anchor="end">Qwen2-VL 256 상한</text><rect class="s1" x="218" y="15" width="302.3" height="20" rx="3"><title>Qwen2-VL 256 상한 · 1,288건</title></rect><text class="viz-val" x="526.3" y="29">1,288건</text><text class="viz-ax lg" x="208" y="63" text-anchor="end">Qwen2-VL 640 상한</text><rect class="s1" x="218" y="49" width="198.3" height="20" rx="3"><title>Qwen2-VL 640 상한 · 845건</title></rect><text class="viz-val" x="422.3" y="63">845건</text><text class="viz-ax lg" x="208" y="97" text-anchor="end">Qwen2-VL 1280 상한</text><rect class="s1" x="218" y="83" width="121.6" height="20" rx="3"><title>Qwen2-VL 1280 상한 · 518건</title></rect><text class="viz-val" x="345.6" y="97">518건</text><text class="viz-ax lg" x="208" y="131" text-anchor="end">Qwen2-VL 기본</text><rect class="s1" x="218" y="117" width="63.8" height="20" rx="3"><title>Qwen2-VL 기본 · 272건</title></rect><text class="viz-val" x="287.8" y="131">272건</text><text class="viz-ax lg" x="208" y="165" text-anchor="end">LLaVA-NeXT</text><rect class="s1" x="218" y="151" width="36.8" height="20" rx="3"><title>LLaVA-NeXT · 157건</title></rect><text class="viz-val" x="260.8" y="165">157건</text><text class="viz-ax lg" x="208" y="199" text-anchor="end">LLaVA-1.5</text><rect class="s1" x="218" y="185" width="22.1" height="20" rx="3"><title>LLaVA-1.5 · 94건</title></rect><text class="viz-val" x="246.1" y="199">94건</text></svg><p class="viz-note">요청 하나의 KV 는 35.8MiB(256 상한)부터 488MiB(LLaVA-1.5)까지예요</p><div class="viz-cap">단위: 건 · 자료: 각 모델 config.json 의 층·KV 헤드 수로 직접 계산, KV 45GiB 는 가정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>동시 요청</th></tr></thead><tbody><tr><td>Qwen2-VL 256 상한</td><td>1,288</td></tr><tr><td>Qwen2-VL 640 상한</td><td>845</td></tr><tr><td>Qwen2-VL 1280 상한</td><td>518</td></tr><tr><td>Qwen2-VL 기본</td><td>272</td></tr><tr><td>LLaVA-NeXT</td><td>157</td></tr><tr><td>LLaVA-1.5</td><td>94</td></tr></tbody></table></div></details></figure>
<p>이 표는 메모리 상한이고, 실제 처리량은 앞의 연산 시간과 둘 중 작은 쪽에서 멈춰요. 블록 단위 할당과 동시 요청 수를 GPU 대수로 바꾸는 방법은 <a href="https://tool.smithlab.co.kr/posts/developer/vllm-kv-cache-memory-capacity-cost-guide/">vLLM KV 캐시 용량 계산</a>과 같아요. MiB·GiB 단위가 헷갈리면 <a href="https://tool.smithlab.co.kr/convert/data-size-converter/">데이터 용량 변환기</a>로 바로 확인할 수 있어요.</p>
<h2 id="s5">품질은 무엇과 바꾸나 — 남는 해상도와 공개 점수</h2>
<p>토큰을 줄이면 모델이 보는 이미지가 작아져요. 1920×1080 기준으로 모델이 실제로 보는 크기와 원본 대비 축소 배율이에요.</p>
<div class="table-wrap"><table><thead><tr><th>설정</th><th>모델이 보는 크기</th><th>축소 배율</th><th>원본 16px 글자</th></tr></thead><tbody><tr><td>Qwen2-VL 기본</td><td>1932×1092</td><td>1.01</td><td>약 16px</td></tr><tr><td>Qwen2-VL 1,280 상한</td><td>1316×728</td><td>0.69</td><td>약 11px</td></tr><tr><td>Qwen2-VL 640 상한</td><td>924×504</td><td>0.48</td><td>약 8px</td></tr><tr><td>LLaVA-NeXT</td><td>672×378(+전체 축소본)</td><td>0.35</td><td>약 6px</td></tr><tr><td>LLaVA-1.5</td><td>336×336, 가운데만</td><td>0.31</td><td>약 5px, 좌우 잘림</td></tr><tr><td>Qwen2-VL 256 상한</td><td>588×336</td><td>0.31</td><td>약 5px</td></tr></tbody></table></div>
<p>공개 점수도 같은 방향이에요. 글자 읽기 벤치마크 TextVQA 에서 LLaVA-NeXT 발표 글은 LLaVA-1.5-13B 61.3, LLaVA-NeXT-Mistral-7B 65.7 을 적었고, Qwen2-VL-7B 모델 카드는 84.3(DocVQA 94.5)을 적었어요. 다만 각자 발표한 점수라 평가 조건이 완전히 같지 않고, 해상도 상한을 낮춘 설정의 점수는 공개되지 않았어요. <strong>상한별 품질은 내 이미지로 직접 재야 하는 값</strong>이에요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">상황별 추천 — 상한을 어디에 둘까</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/self-hosted-vision-llm-resolution-token-budget-gpu-cost-guide-2.svg" alt="필요한 게 글자뿐이면 OCR과 텍스트 LLM, 분류·태깅이면 256토큰 상한, 보통 크기 글씨면 640~1280토큰 상한, 아주 작은 글씨·좌표면 원본 해상도와 크롭으로 이어지는 순서도" width="800" height="560" loading="lazy" decoding="async"><figcaption>위에서부터 답하고 처음 &#x27;예&#x27;가 나오는 칸을 시작값으로 써요</figcaption></figure>
<ul><li><strong>고객 문의 스크린샷, 사내 화면 Q&amp;A</strong>: Qwen2-VL 계열에 1,280 상한이 출발점이에요. 정답률이 같으면 640으로 내려 연산을 절반으로 줄여요.</li><li><strong>사진 분류·태깅·검수 대량 처리</strong>: 256 상한이면 FHD 100만 장이 H100 기준 21달러(가정)예요. 밤에 몰아서 돌리면 활용률을 높이기도 쉬워요.</li><li><strong>문서 본문·영수증 숫자 추출</strong>: 배치나 색이 필요 없다면 OCR 엔진으로 글자를 뽑고 텍스트 LLM 에 넘기는 편이 단순해요. 글자 400토큰과 질문 100토큰을 7B 모델로 읽으면 약 6.5 TFLOP 로 256 상한 이미지와 비슷하지만, OCR 이 놓친 글자는 복구할 수 없어요.</li><li><strong>각주·도면·빽빽한 표, 화면 좌표 클릭</strong>: 원본 해상도가 필요해요. 이미지 전체를 키우지 말고 필요한 영역을 잘라 원본 배율로 넣어요. 4K 전체를 기본값으로 넣으면 한 장 507 TFLOP 이에요.</li><li><strong>이미 LLaVA-1.5 를 쓰고 있다</strong>: KV 캐시가 무겁고 가장자리를 잘라 내요. 동시 요청이 늘거나 긴 화면을 받기 시작하면 동적 해상도 모델로 옮길 때예요.</li></ul>
<h2 id="s7">흔한 실수 — 비용과 지연이 예상보다 커지는 이유</h2>
<p><strong>상한을 안 걸고 배포한다.</strong> Qwen2-VL 기본 상한은 16,384토큰이라, 누군가 4K 캡처나 긴 웹페이지 전체 캡처를 올리면 한 요청이 수만 패치를 인코더에 밀어 넣어요. 그 한 장 때문에 같은 배치의 다른 요청 첫 토큰까지 늦어져요.</p>
<p><strong>서빙 엔진과 테스트 코드의 상한이 다르다.</strong> 노트북에서는 프로세서에 <code>max_pixels</code> 를 넘겨 테스트하고, 서버에서는 엔진 설정을 빠뜨리는 경우가 많아요. <a href="https://docs.vllm.ai/en/latest/configuration/conserving_memory/" target="_blank" rel="noopener">vLLM 메모리 절약 문서</a>는 <code>mm_processor_kwargs</code> 로 <code>max_pixels</code> 를 주고 <code>limit_mm_per_prompt</code> 로 요청당 이미지 수를 묶으면 미리 잡는 메모리가 줄어든다고 안내해요. 두 곳의 값을 한 설정 파일에서 읽게 해요.</p>
<p><strong>토큰 수만 보고 메모리를 어림한다.</strong> 앞에서 본 것처럼 576토큰짜리 LLaVA-1.5 가 2,693토큰짜리 Qwen2-VL 기본 설정보다 KV 캐시를 2배 가까이 써요. 모델을 바꿀 때는 <code>config.json</code> 의 <code>num_key_value_heads</code> 부터 봐요.</p>
<p><strong>작은 이미지에 상한 효과를 기대한다.</strong> 800×1100 영수증은 1,280 상한 아래라 상한을 바꿔도 1,133토큰 그대로예요. 들어오는 이미지 크기 분포를 먼저 뽑아야 상한 조정의 효과를 알 수 있어요.</p>
<p><strong><code>min_pixels</code> 를 크게 잡는다.</strong> 모델 카드 예시처럼 <code>min_pixels=256×28×28</code> 이면 아이콘만 한 이미지도 256토큰 크기로 키워요.</p>
<h2 id="s8">도입 체크리스트</h2>
<ol><li>서비스에 실제로 들어오는 이미지의 가로·세로를 1,000장 정도 모아 분포를 봐요. 상위 10%가 비용을 정해요.</li><li>아래 함수로 상한 후보(256·640·1,280·기본)별 토큰 수 분포를 계산해요.</li><li>이미지 50~100장과 정답을 만들어 상한별 정답률을 재요. 허용 범위 안의 가장 작은 상한을 골라요.</li><li>서빙 엔진에 같은 값을 넣고, 요청당 이미지 수 상한도 걸어요.</li><li>엔진 로그나 응답 usage 의 입력 토큰 수가 계산값과 맞는지 확인해요.</li><li>첫 토큰 지연과 동시 요청 수를 부하 테스트로 재서 앞의 계산(하한)과 비교해요.</li></ol>
<pre class="code-block" data-lang="python"><code>import math

def qwen2vl_image_tokens(w, h, max_tokens=16384, min_pixels=56 * 56, f=28):
    &quot;&quot;&quot;transformers smart_resize 와 같은 규칙. 시작·끝 토큰 2개 포함.&quot;&quot;&quot;
    max_pixels = max_tokens * f * f
    hb, wb = round(h / f) * f, round(w / f) * f
    if hb * wb &gt; max_pixels:
        b = math.sqrt(h * w / max_pixels)
        hb = max(f, math.floor(h / b / f) * f)
        wb = max(f, math.floor(w / b / f) * f)
    elif hb * wb &lt; min_pixels:
        b = math.sqrt(min_pixels / (h * w))
        hb, wb = math.ceil(h * b / f) * f, math.ceil(w * b / f) * f
    return (hb // f) * (wb // f) + 2, (wb, hb)

for w, h in [(1920, 1080), (1080, 2340), (800, 1100)]:
    row = [qwen2vl_image_tokens(w, h, cap)[0] for cap in (256, 640, 1280, 16384)]
    print(f&quot;{w}x{h}&quot;, row)</code></pre>
<pre class="code-block" data-lang="text"><code>1920x1080 [254, 596, 1224, 2693]
1080x2340 [232, 631, 1250, 3278]
800x1100 [236, 611, 1133, 1133]</code></pre>
<p>vLLM 에서는 엔진을 만들 때 같은 값을 넘겨요.</p>
<pre class="code-block" data-lang="python"><code>from vllm import LLM

llm = LLM(
    model=&quot;Qwen/Qwen2-VL-7B-Instruct&quot;,
    mm_processor_kwargs={&quot;max_pixels&quot;: 1280 * 28 * 28},  # 이미지당 약 1,280토큰 상한
    limit_mm_per_prompt={&quot;image&quot;: 2, &quot;video&quot;: 0},        # 요청당 이미지 2장, 영상 받지 않음
)</code></pre>
<p>OpenAI 호환 서버로 띄워 이미지를 data URL 로 보내면 본문이 원본 바이트보다 약 33% 커져요. 전송 크기는 <a href="https://tool.smithlab.co.kr/developer/base64/">Base64 인코더</a>에 넣어 보면 바로 나와요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 묻는 질문</h2>
<h3>max_pixels 는 어느 값부터 시작하면 되나요?</h3>
<p>글씨가 들어간 화면을 다룬다면 1280×28×28(약 1,280토큰)이 무난한 출발점이에요. 모델 카드도 256~1,280 범위를 예로 들어요. 내 이미지로 정답률을 재서 같으면 640으로 내리고, 떨어지면 해당 영역만 크롭해 원본 배율로 넣는 쪽을 먼저 시도해요.</p>
<h3>해상도를 두 배로 올리면 비용도 두 배인가요?</h3>
<p>아니에요. 가로세로를 두 배로 하면 토큰과 LLM 프리필은 약 4배, Qwen2-VL 비전 인코더의 어텐션은 패치 수의 제곱이라 약 16배가 돼요. FHD 기준으로 상한 1,280에서 기본값으로 풀면 토큰은 2.2배인데 전체 연산은 2.5배가 늘어요.</p>
<h3>LLaVA-1.5 가 토큰이 적으니 가장 싼가요?</h3>
<p>연산은 싼 편이지만 메모리는 가장 비싸요. KV 헤드가 32개라 토큰 하나가 512KiB 라서, 576토큰 이미지 한 장이 288MiB 를 차지해요. 또 가운데를 잘라 넣어서 가로로 긴 화면은 좌우 44%를 보지 못해요.</p>
<h3>A10 같은 싼 GPU 가 더 경제적이지 않나요?</h3>
<p>요청이 적어 GPU 가 대부분 쉬는 서비스라면 시간 단가가 싼 쪽이 유리해요. 하지만 계속 바쁘게 돌린다면 이 글의 계산에서 같은 이미지를 처리하는 비용이 A10 이 H100 의 2배 남짓이었어요. A10 은 24GB 라 7B 모델 가중치를 올리고 나면 KV 캐시 자리도 넉넉하지 않아요.</p>
<h3>이미지 대신 OCR 텍스트를 넣으면 무조건 싼가요?</h3>
<p>LLM 쪽만 보면 비슷하거나 싸요. 글자 400토큰이면 256 상한 이미지와 연산량이 비슷해요. 대신 OCR 엔진을 따로 운영해야 하고, 표의 배치·색·아이콘처럼 글자가 아닌 정보와 OCR 이 놓친 글자는 다음 단계에서 다시 볼 수 없어요.</p>
<h2 id="s10">정리</h2>
<ul><li>직접 서빙하면 이미지 비용은 비전 인코더 연산, LLM 프리필 연산, KV 캐시 세 군데로 나뉘어요. 상한 하나(<code>max_pixels</code>)가 셋을 함께 움직여요.</li><li>Qwen2-VL 계열은 1,280토큰 상한이 기본값, 분류는 256, 작은 글씨는 원본 배율 크롭이에요. 기본값 그대로면 FHD 한 장 연산이 2.5배, 4K 는 18배가 돼요.</li><li>메모리는 토큰 수보다 KV 헤드 수가 크게 좌우하고, GPU 는 시간 단가보다 달러당 연산량으로 비교해요. 단가는 2026-11-07 확인 기준이니 도입 전에 가격표를 다시 봐요.</li></ul>
<h2 id="s11">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/multimodal-llm-vision-encoder-image-tokens" target="_blank" rel="noopener">멀티모달 LLM 원리 — 이미지가 토큰이 되는 과정(ViT·LLaVA)</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>테라폼 상태 잠금 설정 고르기 — S3 use_lockfile 이전과…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/terraform-s3-state-lock-lockfile-timeout-migration-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/terraform-s3-state-lock-lockfile-timeout-migration-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>테라폼 S3 백엔드를 쓰는 팀이 잠금 때문에 정해야 할 값은 세 가지예요. 잠금을 무엇으로 걸지, 잠금을 못 잡았을 때 몇 분 기다릴지, 남은 잠금을 언제 강제로 풀지. 결론부터 말하면 <strong>새로 만드는 백엔드는 테라폼 1.11 이상에서 <code>use_lockfile = true</code> 만 켜고, DynamoDB 를 쓰던 곳은 둘을 함께 켠 기간을 거쳐 옮기며, CI 는 <code>-lock-timeout</code> 을 "가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초" 정도로 주고, 사람이 치는 명령은 기본값 0 으로 두는 게</strong> 무난해요.</p>
<p>이 글은 동작 원리보다 <strong>값을 고르는 기준과 운영에서 볼 순서</strong>를 다뤄요. 근거는 <a href="https://developer.hashicorp.com/terraform/language/backend/s3" target="_blank" rel="noopener">Terraform S3 백엔드 문서</a>, 테라폼 main 브랜치의 <a href="https://github.com/hashicorp/terraform/blob/main/internal/backend/remote-state/s3/client.go" target="_blank" rel="noopener">S3 백엔드 client.go</a>와 <a href="https://github.com/hashicorp/terraform/blob/main/internal/states/statemgr/locker.go" target="_blank" rel="noopener">statemgr/locker.go</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html" target="_blank" rel="noopener">Amazon S3 조건부 쓰기 문서</a>이고, 시간·확률·비용은 조건을 밝힌 직접 계산이에요(2026-11-07 기준). plan 옵션과 상태 분리 기준은 <a href="https://tool.smithlab.co.kr/posts/developer/terraform-plan-refresh-state-operations-guide/">테라폼 plan 옵션 고르는 법</a>에서 따로 다뤘어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li><code>backend "s3"</code> 에 <code>use_lockfile</code> 도 <code>dynamodb_table</code> 도 없으면 잠금 없이 돌아요. 오류도 경고도 없어서 설정 파일을 직접 봐야 알아요.</li><li>DynamoDB 에서 옮길 때는 "둘 다 켜기 → 모든 실행 환경 반영 → DynamoDB 빼기" 3단계로 가요. 한 번에 바꾸면 옛 설정과 새 설정이 서로의 잠금을 못 봐요.</li><li>잠금 재시도 간격은 1·2·4·8초 뒤 16초로 고정돼서, 앞 작업이 끝난 뒤에도 최대 16초를 더 기다릴 수 있어요. 타임아웃은 이 여유를 넣어 잡아요.</li><li>S3 잠금 객체에는 만료가 없어요. 죽은 작업의 잠금은 <code>force-unlock</code> 으로만 풀리니, 오류에 찍힌 Who·Created 를 보고 그 작업이 정말 끝났는지부터 확인해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">잠금 방식 판단표, use_lockfile·DynamoDB·이중 잠금 중 무엇을 켤까</h2>
<p>S3 문서는 잠금이 선택 기능이고 DynamoDB 잠금은 지원 중단(deprecated)이라 향후 마이너 버전에서 빠진다고 적어요. 변경 기록을 보면 S3 자체 잠금은 <a href="https://github.com/hashicorp/terraform/blob/v1.10/CHANGELOG.md" target="_blank" rel="noopener">1.10</a>에 들어왔고 <a href="https://github.com/hashicorp/terraform/blob/v1.11/CHANGELOG.md" target="_blank" rel="noopener">1.11</a>에서 정식 기능이 되면서 DynamoDB 관련 인자가 지원 중단으로 바뀌었어요. 판단은 "지금 어디서 출발하느냐"로 갈려요.</p>
<div class="table-wrap"><table><thead><tr><th>지금 상황</th><th>설정</th><th>이유</th></tr></thead><tbody><tr><td>새 백엔드, 모든 실행 환경이 1.11 이상</td><td><code>use_lockfile = true</code> 만</td><td>테이블·테이블 권한이 필요 없어요</td></tr><tr><td>DynamoDB 잠금을 쓰는 중, 옛 버전 러너가 남음</td><td><code>dynamodb_table</code> 유지 + 러너 먼저 올리기</td><td>1.9 이하 러너는 <code>use_lockfile</code> 을 몰라요</td></tr><tr><td>모든 러너가 1.11 이상, 저장소·파이프라인이 여러 개</td><td><code>use_lockfile</code> + <code>dynamodb_table</code> 함께</td><td>반영이 덜 된 곳이 있어도 DynamoDB 에서 서로 막혀요</td></tr><tr><td>이중 잠금으로 몇 주 돌았고 DynamoDB 만 쓰는 설정이 없음</td><td><code>dynamodb_table</code> 제거</td><td>잠금 한 곳, 권한 한 곳으로 줄어요</td></tr><tr><td>잠금 설정이 아예 없음</td><td>오늘 <code>use_lockfile = true</code> 추가</td><td>지금은 동시 apply 를 아무것도 막지 않아요</td></tr><tr><td>S3 호환 저장소(MinIO 등 자체 운영)</td><td>조건부 쓰기 지원부터 시험</td><td><code>If-None-Match</code> 를 무시하는 저장소면 잠금이 겉으로만 돼요</td></tr></tbody></table></div>
<p>S3 잠금은 <code>.tflock</code> 객체를 <code>If-None-Match: *</code> 조건으로 올리는 방식이라, 저장소가 이 헤더를 지키지 않으면 두 작업이 모두 잠금에 성공해요. 도입 전에 AWS CLI 의 <a href="https://docs.aws.amazon.com/cli/latest/reference/s3api/put-object.html" target="_blank" rel="noopener">put-object <code>--if-none-match</code> 옵션</a>으로 같은 키에 두 번 올려 보고, <strong>두 번째가 412(Precondition Failed)로 실패하는지</strong> 확인해요. 두 번째도 성공하면 그 저장소에서는 S3 잠금을 믿으면 안 돼요.</p>
<pre class="code-block" data-lang="bash"><code>echo &#x27;{}&#x27; &gt; /tmp/probe.json
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
  --body /tmp/probe.json --if-none-match &#x27;*&#x27;      # 첫 번째: 성공해야 함
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
  --body /tmp/probe.json --if-none-match &#x27;*&#x27;      # 두 번째: 412 가 나와야 함
aws s3api delete-object --bucket mycorp-tfstate --key probe.tflock</code></pre>
<p>버킷 버전 관리를 켠 버킷에서도 잠금은 문제없어요. 조건부 쓰기 문서에 따르면 현재 버전이 삭제 마커면 "객체 없음"으로 보고 쓰기를 허용해요. 대신 잠금을 한 번 걸고 풀 때마다 <code>.tflock</code> 의 옛 버전과 삭제 마커가 하나씩 쌓여요. 하루 200번이면 1년에 7만 3천 개지만 잠금 JSON 이 200바이트 남짓이라 합쳐도 약 18MB 예요.</p>
<h2 id="s2">DynamoDB 에서 use_lockfile 로 옮기는 3단계</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/terraform-s3-state-lock-lockfile-timeout-migration-guide-1.svg" alt="1단계 dynamodb_table 만 쓰는 상태에서 러너를 1.11 이상으로 올리고, 2단계 use_lockfile 과 dynamodb_table 을 함께 켜 모든 파이프라인에 반영한 뒤, 3단계 dynamodb_table 을 빼고 테이블을 지우는 이전 순서와 한 번에 건너뛰면 동시 apply 가 가능해지는 이유" width="800" height="450" loading="lazy" decoding="async"><figcaption>2단계를 건너뛰지 않는 게 핵심이에요</figcaption></figure>
<p>소스의 <code>Lock</code> 함수를 보면 이중 잠금에서는 <code>.tflock</code> 을 먼저 잡고 DynamoDB 를 나중에 잡으며, DynamoDB 쪽이 실패하면 방금 만든 <code>.tflock</code> 을 지우고 실패해요. 그래서 2단계 동안 잠금은 "둘 다 잡아야 성공"이에요. 단계별로 확인할 것을 정리하면 이래요.</p>
<ol><li><strong>러너 버전 확인</strong>: CI 이미지, 개발자 노트북, 스케줄 잡, 다른 저장소의 원격 상태 소비자까지 <code>terraform version</code> 을 모아요. <code>required_version = "&gt;= 1.11"</code> 을 먼저 넣어 두면 옛 버전 러너가 init 단계에서 바로 실패해서 빠뜨린 곳을 찾기 쉬워요.</li><li><strong>권한 먼저, 설정 나중</strong>: <code>.tflock</code> 에 <code>s3:GetObject</code>·<code>s3:PutObject</code>·<code>s3:DeleteObject</code> 를 준 정책을 먼저 배포하고, 그다음 <code>use_lockfile = true</code> 를 넣어요. 순서가 바뀌면 첫 실행부터 잠금 단계에서 AccessDenied 가 나요.</li><li><strong><code>terraform init -reconfigure</code></strong>: 백엔드 인자가 바뀌면 다시 초기화해야 해요. 버킷과 키는 그대로라 상태를 옮길 필요는 없어요.</li><li><strong>2단계 유지 기간</strong>: 저장소·파이프라인 목록이 전부 새 설정으로 바뀐 것을 확인할 때까지예요. 2단계에서는 새 설정도 DynamoDB 에 쓰기 때문에 테이블 쓰기 지표로는 남은 곳을 구분할 수 없어요. 대신 저장소 전체에서 <code>dynamodb_table</code> 이 있고 <code>use_lockfile</code> 이 없는 백엔드 블록을 찾아요.</li><li><strong>3단계 뒤 테이블 삭제</strong>: <code>dynamodb_table</code> 을 뺀 뒤 일정 기간 테이블 쓰기가 0 인지 보고 지워요. 테이블에는 잠금 항목 말고도 상태의 MD5 를 담은 <code>-md5</code> 항목이 남아 있지만, S3 잠금만 쓰면 더 읽지 않아요.</li></ol>
<p>이중 잠금 해제는 S3 와 DynamoDB 를 둘 다 시도하고, 한쪽만 실패해도 오류를 돌려줘요. 2단계에서 <code>.tflock</code> Delete 권한이 빠졌다면 DynamoDB 잠금은 풀리는데 <code>.tflock</code> 만 남는 상황이 생기니, 이전 첫날 실행 로그에 <code>failed to unlock S3</code> 가 없는지 봐요.</p>
<h2 id="s3">-lock-timeout 값 고르는 법, 재시도 간격으로 계산하기</h2>
<p><code>-lock-timeout</code> 의 기본값은 0초라 잠금을 못 잡으면 바로 실패해요. 값을 주면 <code>LockWithContext</code> 가 첫 대기 1초에서 시작해 실패할 때마다 두 배로 늘리고 16초에서 멈춰요. 첫 시도부터 따지면 0·1·3·7·15·31·47·63초… 에 다시 시도해요. 재시도는 상대 잠금 정보가 담긴 잠금 오류일 때만 하고, 권한 오류는 기다리지 않고 끝나요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">-lock-timeout 값별 잠금 시도 횟수 (첫 시도 포함)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="-lock-timeout 값별 잠금 시도 횟수 (첫 시도 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">60</text><rect class="s1" x="66.4" y="234.3" width="67.2" height="19.7" rx="3"><title>30초 · 시도 횟수 5회</title></rect><text class="viz-val" x="100.0" y="229.3" text-anchor="middle">5</text><text class="viz-ax" x="100.0" y="272" text-anchor="middle">30초</text><rect class="s1" x="162.4" y="226.5" width="67.2" height="27.5" rx="3"><title>1분 · 시도 횟수 7회</title></rect><text class="viz-val" x="196.0" y="221.5" text-anchor="middle">7</text><text class="viz-ax" x="196.0" y="272" text-anchor="middle">1분</text><rect class="s1" x="258.4" y="210.7" width="67.2" height="43.3" rx="3"><title>2분 · 시도 횟수 11회</title></rect><text class="viz-val" x="292.0" y="205.7" text-anchor="middle">11</text><text class="viz-ax" x="292.0" y="272" text-anchor="middle">2분</text><rect class="s1" x="354.4" y="167.5" width="67.2" height="86.5" rx="3"><title>5분 · 시도 횟수 22회</title></rect><text class="viz-val" x="388.0" y="162.5" text-anchor="middle">22</text><text class="viz-ax" x="388.0" y="272" text-anchor="middle">5분</text><rect class="s1" x="450.4" y="92.7" width="67.2" height="161.3" rx="3"><title>10분 · 시도 횟수 41회</title></rect><text class="viz-val" x="484.0" y="87.7" text-anchor="middle">41</text><text class="viz-ax" x="484.0" y="272" text-anchor="middle">10분</text><rect class="s1" x="546.4" y="18.0" width="67.2" height="236.0" rx="3"><title>15분 · 시도 횟수 60회</title></rect><text class="viz-val" x="580.0" y="13.0" text-anchor="middle">60</text><text class="viz-ax" x="580.0" y="272" text-anchor="middle">15분</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">31초 이후로는 16초마다 한 번씩이라 시간에 거의 비례해서 늘어요.</p><div class="viz-cap">단위: 회 · 자료: locker.go 의 1초 시작·2배·최대 16초 백오프로 직접 계산 (2026-11-07)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>시도 횟수</th></tr></thead><tbody><tr><td>30초</td><td>5</td></tr><tr><td>1분</td><td>7</td></tr><tr><td>2분</td><td>11</td></tr><tr><td>5분</td><td>22</td></tr><tr><td>10분</td><td>41</td></tr><tr><td>15분</td><td>60</td></tr></tbody></table></div></details></figure>
<p>이 간격 때문에 생기는 숫자가 하나 더 있어요. 앞 작업이 잠금을 푼 순간 바로 잡는 게 아니라 <strong>다음 재시도 때</strong> 잡아요. 대기가 31초를 넘긴 뒤라면 풀린 시점부터 최대 16초, 평균 8초쯤 더 기다려요. 로컬 백엔드(테라폼 1.9.8, 재시도 함수는 S3 와 같은 코드)로 40초 걸리는 apply 를 돌리고 3초 뒤 <code>plan -lock-timeout=120s</code> 를 띄웠더니, apply 는 40.4초에 끝났고 plan 은 50.3초에 끝났어요. 3초에 시작한 plan 의 재시도 시각이 3+47=50초라 계산과 맞아요.</p>
<p>그래서 값은 이렇게 잡아요.</p>
<div class="table-wrap"><table><thead><tr><th>실행 주체</th><th>권장 값</th><th>이유</th></tr></thead><tbody><tr><td>사람이 직접 치는 plan·apply</td><td>0(기본)</td><td>누가 잡고 있는지 바로 보고 판단하는 게 나아요</td></tr><tr><td>PR 마다 도는 plan</td><td>2~5분</td><td>같은 상태의 apply 하나가 끝날 정도만 기다려요</td></tr><tr><td>main 머지 후 apply</td><td>가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초</td><td>줄 선 작업이 다 끝나고 재시도 간격까지 넣은 값</td></tr><tr><td>야간 드리프트 점검</td><td>10~15분</td><td>급하지 않으니 여유 있게, 대신 CI 잡 시간 한도 안에서</td></tr></tbody></table></div>
<p>예를 들어 가장 긴 apply 가 6분이고 동시에 두 작업이 앞에 설 수 있으면 6×2 = 12분에 30초를 더해 12분 30초예요. 이때 <strong>CI 잡의 시간 한도는 잠금 대기 + 자기 apply 시간보다 커야</strong> 해요. 잡 한도가 먼저 와서 러너가 강제 종료되면, 기다리던 작업은 잠금을 못 잡은 채 끝나니 괜찮지만 이미 잠금을 쥐고 apply 하던 작업이 잘리면 잠금이 남아요.</p>
<p>줄이 자주 길어지면 CI 에서 직렬화하는 게 깔끔해요. GitHub Actions 라면 <a href="https://docs.github.com/en/actions/using-jobs/using-concurrency" target="_blank" rel="noopener">concurrency 그룹</a>을 상태 키 단위로 걸어 같은 상태를 쓰는 잡이 동시에 시작하지 않게 해요.</p>
<h2 id="s4">잠금 충돌 확률, 상태를 나눌 때인지 숫자로 보기</h2>
<p>같은 상태 파일에 잠금을 거는 작업(plan 포함)이 시간당 λ번 무작위로 시작하고 한 번에 d분 걸린다면, 내 작업이 도는 동안 다른 작업이 들어와 막힐 확률은 대략 1 − e^(−λ × d ÷ 60)이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">작업 길이별 잠금 충돌 확률 (예시 계산)</figcaption><div class="viz-legend"><span><i class="s1"></i>시간당 2번</span><span><i class="s2"></i>시간당 6번</span><span><i class="s3"></i>시간당 20번</span></div><svg viewBox="0 0 640 300" role="img" aria-label="작업 길이별 잠금 충돌 확률 (예시 계산)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="73.6" y="238.9" width="32.3" height="15.1" rx="3"><title>2분 · 시간당 2번 6.4%</title></rect><rect class="s2" x="107.9" y="211.3" width="32.3" height="42.7" rx="3"><title>2분 · 시간당 6번 18.1%</title></rect><rect class="s3" x="142.1" y="139.1" width="32.3" height="114.9" rx="3"><title>2분 · 시간당 20번 48.7%</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">2분</text><rect class="s1" x="217.6" y="217.7" width="32.3" height="36.3" rx="3"><title>5분 · 시간당 2번 15.4%</title></rect><rect class="s2" x="251.9" y="161.3" width="32.3" height="92.7" rx="3"><title>5분 · 시간당 6번 39.3%</title></rect><rect class="s3" x="286.1" y="62.6" width="32.3" height="191.4" rx="3"><title>5분 · 시간당 20번 81.1%</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">5분</text><rect class="s1" x="361.6" y="187.2" width="32.3" height="66.8" rx="3"><title>10분 · 시간당 2번 28.3%</title></rect><rect class="s2" x="395.9" y="104.8" width="32.3" height="149.2" rx="3"><title>10분 · 시간당 6번 63.2%</title></rect><rect class="s3" x="430.1" y="26.5" width="32.3" height="227.5" rx="3"><title>10분 · 시간당 20번 96.4%</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">10분</text><rect class="s1" x="505.6" y="139.1" width="32.3" height="114.9" rx="3"><title>20분 · 시간당 2번 48.7%</title></rect><rect class="s2" x="539.9" y="49.9" width="32.3" height="204.1" rx="3"><title>20분 · 시간당 6번 86.5%</title></rect><rect class="s3" x="574.1" y="18.2" width="32.3" height="235.8" rx="3"><title>20분 · 시간당 20번 99.9%</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">20분</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">plan 도 잠금을 잡으니 PR 이 많은 저장소는 시간당 횟수가 생각보다 커요.</p><div class="viz-cap">단위: % · 자료: 1 − e^(−시간당 횟수 × 작업 분 ÷ 60)으로 직접 계산 (포아송 도착 가정)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>시간당 2번</th><th>시간당 6번</th><th>시간당 20번</th></tr></thead><tbody><tr><td>2분</td><td>6.4</td><td>18.1</td><td>48.7</td></tr><tr><td>5분</td><td>15.4</td><td>39.3</td><td>81.1</td></tr><tr><td>10분</td><td>28.3</td><td>63.2</td><td>96.4</td></tr><tr><td>20분</td><td>48.7</td><td>86.5</td><td>99.9</td></tr></tbody></table></div></details></figure>
<p>시간당 6번, 한 번에 10분이면 셋 중 둘(63.2%)은 누군가를 기다려요. 이 정도면 타임아웃을 늘려 버티기보다 <strong>상태 파일을 쪼갤 신호</strong>예요. 잠금 단위가 상태 파일 하나라서, 워크스페이스나 키가 다르면 서로 막지 않아요. 네트워크처럼 거의 안 바뀌는 것과 하루에도 여러 번 바뀌는 앱 리소스를 나누면 λ 가 각각 줄어요. 작업 길이(d) 자체를 줄이는 방법은 <a href="https://tool.smithlab.co.kr/posts/developer/terraform-parallelism-depends-on-create-before-destroy-guide/">테라폼 parallelism 값 고르는 법</a>에서 다뤘어요.</p>
<h2 id="s5">비용과 한계, DynamoDB 를 빼도 돈은 거의 안 바뀌어요</h2>
<p>AWS 가격 API 의 서울 리전(ap-northeast-2) 값(2026-11-04 게시)으로 실행 1만 번을 계산해 봤어요. S3 는 PUT 1,000건당 0.0045달러, GET 1만 건당 0.0035달러, DynamoDB 온디맨드는 쓰기 요청 단위 100만 개당 0.68달러, 읽기 100만 개당 0.1355달러예요. 실제 요금표는 <a href="https://aws.amazon.com/s3/pricing/" target="_blank" rel="noopener">Amazon S3 요금</a> 페이지에서 볼 수 있어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">실행 1만 번당 상태·잠금 요청 비용 (서울 리전 예시)</figcaption><svg viewBox="0 0 640 84" role="img" aria-label="실행 1만 번당 상태·잠금 요청 비용 (서울 리전 예시)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">S3 잠금(use_lockfile)</text><rect class="s1" x="220" y="15" width="339.5" height="20" rx="3"><title>S3 잠금(use_lockfile) · 9.7센트</title></rect><text class="viz-val" x="565.5" y="29">9.7센트</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">DynamoDB 잠금</text><rect class="s1" x="220" y="49" width="252.0" height="20" rx="3"><title>DynamoDB 잠금 · 7.2센트</title></rect><text class="viz-val" x="478.0" y="63">7.2센트</text></svg><p class="viz-note">둘 다 1달러에 한참 못 미쳐요. 고를 때 기준은 비용이 아니라 관리할 리소스 수예요.</p><div class="viz-cap">단위: 센트 · 자료: AWS Price List API 서울 리전 단가로 직접 계산. 실행당 상태 GET 1·PUT 1 은 공통이고, S3 잠금은 PUT 1·GET 1, DynamoDB 잠금은 쓰기 3·읽기 2 단위를 더한 최소치로 가정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>비용</th></tr></thead><tbody><tr><td>S3 잠금(use_lockfile)</td><td>9.7</td></tr><tr><td>DynamoDB 잠금</td><td>7.2</td></tr></tbody></table></div></details></figure>
<p>S3 잠금 쪽이 조금 더 나오는 건 S3 PUT 단가가 DynamoDB 쓰기 단가보다 비싸서인데, 차이는 실행 1만 번에 3센트도 안 돼요. apply 가 상태를 여러 번 쓰거나 잠금 재시도가 늘어도 자릿수는 그대로예요. DynamoDB 를 빼서 얻는 건 돈이 아니라 <strong>테이블 하나, IAM 문장 하나, 계정마다 반복하던 부트스트랩 코드</strong>가 사라지는 거예요.</p>
<p>반대로 S3 잠금이 해 주지 않는 일도 알아 둬요.</p>
<ul><li><strong>만료 없음</strong>: 잠금 객체에는 TTL 이나 하트비트가 없어요. 레디스 락처럼 시간이 지나면 풀리는 구조가 아니라서, <a href="https://tool.smithlab.co.kr/posts/developer/redis-distributed-lock-ttl-redlock-fencing-selection-guide/">레디스 분산 락 고르는 법</a>에서 다룬 "TTL 이 끝났는데 작업은 계속 도는" 문제는 없는 대신, 죽은 작업의 잠금은 사람이 풀어야 해요.</li><li><strong>펜싱 없음</strong>: 강제로 풀린 뒤 살아 돌아온 옛 작업이 상태를 쓰는 걸 막는 토큰은 없어요. 그래서 <code>force-unlock</code> 전에 작업이 진짜 죽었는지 확인하는 절차가 대신 그 역할을 해요.</li><li><strong>저장된 plan 은 따로</strong>: 잠금은 명령이 도는 동안만 걸려요. plan 을 저장하고 리뷰하는 사이 상태가 바뀌면 apply 단계에서 "Saved plan is stale" 로 거부되는데, 이건 잠금이 아니라 상태 serial 비교가 막는 거예요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">Error acquiring the state lock 확인 순서</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/terraform-s3-state-lock-lockfile-timeout-migration-guide-2.svg" alt="잠금 오류가 나면 Lock Info 가 찍혔는지, Who 가 나 자신인지, 그 작업이 살아 있는지, 상태를 쓰다 말았는지 차례로 물어 권한 확인·Delete 권한 확인·기다리기·force-unlock 후 plan 으로 보내는 판단 순서도" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 물어 처음 &#x27;예&#x27;가 나온 칸으로 가요</figcaption></figure>
<p><strong>1. 오류에 Lock Info 가 있는지 봐요.</strong> ID·Path·Operation·Who·Created 가 찍혔다면 진짜 잠금 충돌이에요. 없이 AccessDenied 만 있다면 <code>.tflock</code> 경로에 Put 권한이 없거나, <code>kms_key_id</code> 를 쓰는 백엔드에서 KMS 권한이 빠진 경우예요. 소스를 보면 잠금 파일도 상태와 같은 KMS 키로 암호화해 올려서, 문서대로 <code>kms:Encrypt</code>·<code>kms:Decrypt</code>·<code>kms:GenerateDataKey</code> 가 필요해요.</p>
<p><strong>2. Who 가 매번 나 자신이면 해제 실패예요.</strong> apply 는 성공했는데 다음 실행부터 같은 러너 이름의 잠금에 걸린다면 <code>.tflock</code> 의 <code>s3:DeleteObject</code> 권한을 의심해요. 상태 파일 ARN 만 허용하는 정책을 그대로 쓰면 이렇게 돼요. 해제는 <code>.tflock</code> 을 GetObject 로 읽어 ID 를 비교한 뒤 DeleteObject 로 지우는 순서라 Get 권한도 같이 필요해요.</p>
<p><strong>3. Created 와 Who 로 작업이 살아 있는지 확인해요.</strong> Created 는 UTC 라서 한국 시각으로 볼 때는 9시간을 더해요. 헷갈리면 <a href="https://tool.smithlab.co.kr/developer/timestamp-converter/">타임스탬프 변환기</a>로 바꿔 보고, <code>.tflock</code> 내용 전체는 <code>aws s3 cp s3://버킷/키.tflock -</code> 로 받아 <a href="https://tool.smithlab.co.kr/developer/json-formatter/">JSON 포매터</a>로 펼쳐 봐요. CI 러너 이름이면 그 잡의 상태를, 사람 이름이면 당사자에게 물어요.</p>
<p><strong>4. 살아 있으면 기다려요.</strong> <code>-lock-timeout</code> 을 주고 다시 돌리거나 CI 큐에 맡겨요. "오래 걸리는 것 같다"는 이유로 푸는 게 가장 흔한 사고예요.</p>
<p><strong>5. 죽은 게 확실하면 <code>terraform force-unlock &lt;ID&gt;</code> 를 쓰고 plan 부터 돌려요.</strong> <a href="https://developer.hashicorp.com/terraform/cli/commands/force-unlock" target="_blank" rel="noopener">force-unlock 문서</a>대로 인프라는 건드리지 않고 잠금만 지워요. Operation 이 Apply 였다면 상태를 쓰다 죽었을 수 있으니, 바로 apply 하지 말고 plan 결과로 상태와 실제가 맞는지 먼저 봐요. 버킷 버전 관리가 켜져 있으면 상태 파일의 직전 버전을 비교할 수 있어요.</p>
<p><strong>6. <code>lock ID ... does not match</code> 가 나면 손을 멈춰요.</strong> 내가 가진 ID 와 저장된 잠금의 ID 가 다르다는 뜻이라, 내가 쥔 잠금을 누가 force-unlock 하고 다른 작업이 새로 잡은 경우가 많아요. 지금 상태를 두 작업이 다 썼을 수 있으니 양쪽 로그를 맞춰 봐요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p><code>.tflock</code> 객체를 S3 콘솔에서 직접 지우는 것도 force-unlock 과 같은 효과예요. ID 확인이 없는 만큼 더 위험하니, 지우더라도 위 3번 확인을 똑같이 거쳐요.</p></aside>
<p>죽은 잠금을 미리 찾아 두고 싶다면 하루 한 번 <code>.tflock</code> 목록을 훑는 점검을 걸 수 있어요. <code>aws s3api list-objects-v2 --bucket mycorp-tfstate --query "Contents[?ends_with(Key, '.tflock')].[Key,LastModified]"</code> 로 뽑아서, 가장 긴 apply 시간보다 오래된 것만 알리면 돼요.</p>
<h2 id="s7">도입 전에 따질 것</h2>
<ul><li><strong>잠금 설정 존재 여부</strong>: 저장소마다 <code>backend "s3"</code> 블록에 <code>use_lockfile</code> 이나 <code>dynamodb_table</code> 이 있는지 봐요. 꺼져 있어도 아무 메시지가 없어요.</li><li><strong>버전·권한·암호화</strong>: 1.11 미만 러너가 남으면 3단계로 못 가요. IAM 을 상태 키마다 적는다면 <code>.tflock</code> ARN 도 짝으로 적고, <code>kms_key_id</code> 를 쓰면 같은 KMS 권한을 줘요.</li><li><strong>S3 호환 저장소</strong>: 412 시험을 통과하지 못하면 S3 잠금을 쓰지 말아요.</li><li><strong>줄 서는 정도</strong>: 충돌 확률이 30% 를 넘는 상태라면 타임아웃 값보다 상태 분리를 먼저 계획해요.</li></ul>
<h3>PR plan 용 읽기 전용 권한과 -lock=false</h3>
<p>PR plan 역할을 읽기 전용으로 두고 싶어도, plan 도 잠금을 잡기 때문에 <code>.tflock</code> 에 Put·Delete 가 없으면 plan 이 잠금 단계에서 실패해요. 선택지는 둘이에요.</p>
<div class="table-wrap"><table><thead><tr><th>방법</th><th>장점</th><th>대가</th></tr></thead><tbody><tr><td>plan 역할에 <code>.tflock</code> Get·Put·Delete 만 추가</td><td>상태 파일과 리소스는 계속 읽기 전용</td><td>plan 이 apply 와 줄을 서서 PR 피드백이 느려질 수 있어요</td></tr><tr><td>plan 에 <code>-lock=false</code></td><td>잠금 권한이 아예 필요 없음</td><td>apply 와 동시에 돌면 쓰는 중인 상태를 읽어 결과가 어긋날 수 있어요</td></tr></tbody></table></div>
<p>PR plan 은 결과를 그대로 적용하지 않는 미리보기라면 <code>-lock=false</code> 도 받아들일 만해요. 다만 <strong>plan 결과를 저장해 apply 로 넘기는 파이프라인이라면 잠금을 켜요.</strong> apply 에 <code>-lock=false</code> 를 쓰는 건 어떤 경우에도 권하지 않아요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>use_lockfile 을 안 켜면 S3 백엔드는 잠금을 안 거나요?</h3>
<p>네. <code>use_lockfile</code> 의 기본값은 false 이고, <code>dynamodb_table</code> 도 없으면 S3 백엔드의 Lock 은 아무것도 하지 않고 성공을 돌려줘요. 오류나 경고가 없으니 설정 파일을 직접 확인해야 해요.</p>
<h3>DynamoDB 잠금은 언제까지 쓸 수 있나요?</h3>
<p>문서는 지원 중단 상태이고 향후 마이너 버전에서 제거된다고만 적고 있어요. 제거 버전이 정해지면 변경 기록에 나오니, 그 전에 이중 잠금 단계를 거쳐 옮겨 두는 게 안전해요.</p>
<h3>-lock-timeout 을 아주 길게 주면 문제가 있나요?</h3>
<p>잠금이 죽은 작업의 것이면 그만큼 헛되이 기다려요. S3 잠금은 만료가 없어서 시간이 지나도 풀리지 않아요. CI 잡 시간 한도 안에서, 가장 긴 apply 와 줄 선 작업 수로 계산한 값만 주세요.</p>
<h3>force-unlock 에 넣을 ID 는 어디서 보나요?</h3>
<p>잠금 오류 메시지의 Lock Info 아래 ID 줄이에요. <code>.tflock</code> 객체 본문의 <code>ID</code> 값과 같아요. 확인 질문 없이 풀려면 <code>-force</code> 를 붙이지만, 사람이 칠 때는 빼는 편이 안전해요.</p>
<h3>워크스페이스를 나누면 잠금도 나뉘나요?</h3>
<p>네. 잠금 파일은 상태 경로 뒤에 <code>.tflock</code> 을 붙인 객체라 워크스페이스나 키가 다르면 서로 막지 않아요. 잠금 대기가 잦다면 상태를 나누는 게 근본적인 해결이에요.</p>
<h2 id="s9">정리</h2>
<ul><li>잠금 설정이 없는 S3 백엔드는 조용히 잠금 없이 돌아요. 새 백엔드는 1.11 이상에서 <code>use_lockfile = true</code> 만 켜요.</li><li>DynamoDB 에서 옮길 때는 버전 통일 → 이중 잠금 → DynamoDB 제거 순서로, 권한을 설정보다 먼저 배포해요.</li><li><code>-lock-timeout</code> 은 사람 0, CI 는 "가장 긴 apply × 앞에 설 작업 수 + 30초". 재시도가 16초 간격이라 풀린 뒤에도 최대 16초를 더 기다려요.</li><li>잠금 오류는 Lock Info 유무 → Who → 생존 여부 → force-unlock 후 plan 순서로 봐요. <code>.tflock</code> 에는 Get·Put·Delete 와 KMS 권한이 필요해요.</li><li>요청 비용은 두 방식 모두 실행 1만 번에 10센트 안팎이라, 고르는 기준은 관리할 리소스와 줄 서는 정도예요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/terraform-state-locking-s3-use-lockfile" target="_blank" rel="noopener">테라폼 상태 잠금 원리 — S3 use_lockfile·조건부 쓰기</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>볼링 점수 계산법, 스트라이크·스페어 보너스와 10프레임 규칙 총정리</title>
      <link>https://tool.smithlab.co.kr/posts/life/bowling-score-calculation-strike-spare-bonus-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/bowling-score-calculation-strike-spare-bonus-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>볼링장 전자 점수판을 보다가 "방금 스트라이크 쳤는데 왜 점수가 안 올라가지?" 하고 고개를 갸웃한 적이 있으시죠. 먼저 답부터 말하면, <strong>스트라이크는 10점에 다음 두 번 투구의 핀 수를 더하고, 스페어는 10점에 다음 한 번 투구의 핀 수를 더해요.</strong> 보너스가 뒤에 나올 투구에 달려 있어서, 그 투구가 끝나기 전에는 점수를 확정할 수 없기 때문이에요.</p>
<p>이 글은 점수판의 표기(X, /, -)부터 프레임별 계산, 마지막 10프레임의 특별 규칙, 퍼펙트 게임이 300점인 이유까지 직접 계산한 숫자로 풀어요. 투구를 입력하면 점수판을 자동으로 채워 주는 <a href="https://tool.smithlab.co.kr/life/bowling-score-calculator/">볼링 점수 계산기</a>도 함께 쓰면 이해가 빨라요. 이 글의 계산은 일반적인 10핀 볼링 기준이고, 리그나 대회의 특수 규정은 소속 단체의 규칙을 따라야 해요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>스트라이크(첫 투구에 10개) = 10점 + 다음 2투구, 스페어(두 투구에 10개) = 10점 + 다음 1투구, 오픈 프레임 = 쓰러뜨린 핀 수 합계예요.</li><li>10프레임은 스트라이크나 스페어를 하면 투구가 최대 3번이고, 이 투구들은 핀 수를 그대로 더해요.</li><li>12번 연속 스트라이크가 퍼펙트 300점이에요. 한 번이라도 놓치면 최대 점수는 267~279점으로 내려가요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">점수판 표기, X·/·- 는 이런 뜻이에요</h2>
<p>볼링 점수판은 프레임마다 작은 칸이 두 개(10프레임은 세 개) 있어요. <strong>X</strong>는 스트라이크로, 첫 투구에 핀 10개를 모두 쓰러뜨린 거예요. 그 프레임은 한 번 투구로 끝나서 보통 오른쪽 칸에 X를 적어요. <strong>/</strong>는 스페어로, 첫 투구에서 남은 핀을 두 번째 투구로 모두 쓰러뜨린 거예요. <strong>-</strong>는 한 개도 못 쓰러뜨린 투구, <strong>숫자</strong>는 쓰러뜨린 핀 수예요. 두 번 던져도 10개를 못 채우면 <strong>오픈 프레임</strong>이라고 하고, 이때는 두 투구의 합이 곧 그 프레임 점수예요.</p>
<aside class="callout tip"><b class="callout-h">참고</b><p>첫 투구 때 0개를 쓰러뜨리고 두 번째 투구에서 10개를 모두 쓰러뜨리면 X가 아니라 스페어(/)예요. 핀이 새로 세워진 첫 투구에서 10개를 쓰러뜨렸는지가 스트라이크의 기준이에요.</p></aside>
<h2 id="s2">프레임 점수 계산 방법</h2>
<p>일반 프레임(1~9)의 점수는 세 가지 경우로 나뉘어요.</p>
<ol><li><strong>오픈 프레임</strong>: 두 투구의 합이에요. 7개와 2개를 쓰러뜨렸다면 9점이에요.</li><li><strong>스페어</strong>: 10점에 <strong>다음 1투구</strong>의 핀 수를 더해요. 7개 뒤에 3개로 스페어를 하고 다음 프레임 첫 투구가 9개였다면 10 + 9 = 19점이에요.</li><li><strong>스트라이크</strong>: 10점에 <strong>다음 2투구</strong>의 핀 수를 더해요. 스트라이크 뒤에 7개와 3개가 나왔다면 10 + 7 + 3 = 20점이에요.</li></ol>
<p>그리고 각 프레임 점수를 앞에서부터 차례로 더한 값이 점수판에 적혀요. 아래 그림은 그 흐름을 한눈에 보여 줘요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/bowling-score-rules-strike-spare-bonus-guide-1.svg" alt="스트라이크가 다음 2투구, 스페어가 다음 1투구를 보너스로 빌려 오는 3개 프레임 예시" width="800" height="450" loading="lazy" decoding="async"><figcaption>1프레임 X는 20점, 2프레임 7/는 19점, 3프레임 9 0은 9점이라 3프레임 누적은 48점이에요</figcaption></figure>
<p>여기서 중요한 건 "다음 투구"가 <strong>다음 프레임의 투구</strong>일 수도, 같은 프레임 안이 아니라 한참 뒤일 수도 있다는 점이에요. 스트라이크가 연속으로 나오면 첫 스트라이크의 보너스는 그다음 두 개의 스트라이크예요.</p>
<h2 id="s3">스트라이크 프레임이 받을 수 있는 점수</h2>
<p>스트라이크 프레임의 점수는 뒤에 어떤 투구가 나오느냐에 따라 10점에서 30점까지 달라져요. 아래는 직접 계산한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>스트라이크 뒤 두 투구</th><th>계산</th><th>프레임 점수</th></tr></thead><tbody><tr><td>X, X (연속 스트라이크)</td><td>10 + 10 + 10</td><td>30점</td></tr><tr><td>9, / (스페어)</td><td>10 + 9 + 1</td><td>20점</td></tr><tr><td>9, 0</td><td>10 + 9 + 0</td><td>19점</td></tr><tr><td>0, 0</td><td>10 + 0 + 0</td><td>10점</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">스트라이크 프레임이 받는 점수, 뒤 두 투구에 따라</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="스트라이크 프레임이 받는 점수, 뒤 두 투구에 따라" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">30</text><rect class="s1" x="73.6" y="18.0" width="100.8" height="236.0" rx="3"><title>X와 X · 프레임 점수 30점</title></rect><text class="viz-val" x="124.0" y="13.0" text-anchor="middle">30</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">X와 X</text><rect class="s1" x="217.6" y="96.7" width="100.8" height="157.3" rx="3"><title>9와 스페어 · 프레임 점수 20점</title></rect><text class="viz-val" x="268.0" y="91.7" text-anchor="middle">20</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">9와 스페어</text><rect class="s1" x="361.6" y="104.5" width="100.8" height="149.5" rx="3"><title>9와 0 · 프레임 점수 19점</title></rect><text class="viz-val" x="412.0" y="99.5" text-anchor="middle">19</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">9와 0</text><rect class="s1" x="505.6" y="175.3" width="100.8" height="78.7" rx="3"><title>0과 0 · 프레임 점수 10점</title></rect><text class="viz-val" x="556.0" y="170.3" text-anchor="middle">10</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">0과 0</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">같은 스트라이크도 뒤에 스트라이크가 이어지면 30점, 모두 놓치면 10점이에요.</p><div class="viz-cap">단위: 점 · 자료: 10 + 다음 두 투구 핀 수를 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>프레임 점수</th></tr></thead><tbody><tr><td>X와 X</td><td>30</td></tr><tr><td>9와 스페어</td><td>20</td></tr><tr><td>9와 0</td><td>19</td></tr><tr><td>0과 0</td><td>10</td></tr></tbody></table></div></details></figure>
<p>연속으로 스트라이크를 치는 사람의 점수가 갑자기 크게 오르는 이유가 이거예요. 스트라이크 하나의 가치는 뒤에서 얼마나 잘 이어 가느냐에 달려 있어요.</p>
<h2 id="s4">직접 계산해 보는 예시 게임</h2>
<p>이제 실제 게임 하나를 끝까지 계산해 볼게요. 투구 순서는 X, 7 /, 9 -, X, X, X, 8 1, 9 /, X, 마지막 10프레임 X 9 - 이에요.</p>
<div class="table-wrap"><table><thead><tr><th>프레임</th><th>투구</th><th>프레임 점수</th><th>누적</th></tr></thead><tbody><tr><td>1</td><td>X</td><td>10+7+3 = 20</td><td>20</td></tr><tr><td>2</td><td>7 /</td><td>10+9 = 19</td><td>39</td></tr><tr><td>3</td><td>9 -</td><td>9</td><td>48</td></tr><tr><td>4</td><td>X</td><td>10+10+10 = 30</td><td>78</td></tr><tr><td>5</td><td>X</td><td>10+10+8 = 28</td><td>106</td></tr><tr><td>6</td><td>X</td><td>10+8+1 = 19</td><td>125</td></tr><tr><td>7</td><td>8 1</td><td>9</td><td>134</td></tr><tr><td>8</td><td>9 /</td><td>10+10 = 20</td><td>154</td></tr><tr><td>9</td><td>X</td><td>10+10+9 = 29</td><td>183</td></tr><tr><td>10</td><td>X 9 -</td><td>10+9+0 = 19</td><td>202</td></tr></tbody></table></div>
<p>최종 점수는 202점이에요. 이 표는 계산기와 별개로 프레임 단위로 다시 계산해 맞춰 보았고, 계산기의 '예시 게임' 버튼으로 같은 점수판을 볼 수 있어요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">예시 게임의 프레임별 누적 점수</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="예시 게임의 프레임별 누적 점수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="570" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="570" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="570" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">250</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">1</text><text class="viz-ax" x="167.1" y="272" text-anchor="middle">3</text><text class="viz-ax" x="282.2" y="272" text-anchor="middle">5</text><text class="viz-ax" x="397.3" y="272" text-anchor="middle">7</text><text class="viz-ax" x="512.4" y="272" text-anchor="middle">9</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">10</text><polyline class="viz-line s1" points="52.0,235.1 109.6,217.2 167.1,208.7 224.7,180.4 282.2,153.9 339.8,136.0 397.3,127.5 454.9,108.6 512.4,81.2 570.0,63.3"/><circle class="viz-dot s1" cx="52.0" cy="235.1" r="4"><title>1 · 누적 점수 20점</title></circle><circle class="viz-dot s1" cx="109.6" cy="217.2" r="4"><title>2 · 누적 점수 39점</title></circle><circle class="viz-dot s1" cx="167.1" cy="208.7" r="4"><title>3 · 누적 점수 48점</title></circle><circle class="viz-dot s1" cx="224.7" cy="180.4" r="4"><title>4 · 누적 점수 78점</title></circle><circle class="viz-dot s1" cx="282.2" cy="153.9" r="4"><title>5 · 누적 점수 106점</title></circle><circle class="viz-dot s1" cx="339.8" cy="136.0" r="4"><title>6 · 누적 점수 125점</title></circle><circle class="viz-dot s1" cx="397.3" cy="127.5" r="4"><title>7 · 누적 점수 134점</title></circle><circle class="viz-dot s1" cx="454.9" cy="108.6" r="4"><title>8 · 누적 점수 154점</title></circle><circle class="viz-dot s1" cx="512.4" cy="81.2" r="4"><title>9 · 누적 점수 183점</title></circle><circle class="viz-dot s1" cx="570.0" cy="63.3" r="4"><title>10 · 누적 점수 202점</title></circle><text class="viz-val" x="578.0" y="67.3">202</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">연속 스트라이크 구간인 4~6프레임에서 점수가 가파르게 올라가요.</p><div class="viz-cap">단위: 점 · 자료: 위 예시 게임을 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>누적 점수</th></tr></thead><tbody><tr><td>1</td><td>20</td></tr><tr><td>2</td><td>39</td></tr><tr><td>3</td><td>48</td></tr><tr><td>4</td><td>78</td></tr><tr><td>5</td><td>106</td></tr><tr><td>6</td><td>125</td></tr><tr><td>7</td><td>134</td></tr><tr><td>8</td><td>154</td></tr><tr><td>9</td><td>183</td></tr><tr><td>10</td><td>202</td></tr></tbody></table></div></details></figure>
<p>그래프에서 4~6프레임이 가장 가파른 이유는 스트라이크 세 번이 이어졌기 때문이에요. 스트라이크 하나하나가 뒤의 보너스를 키워 주는 구조예요.</p>
<h2 id="s5">10프레임만 다른 이유</h2>
<p>10프레임에서는 스트라이크나 스페어를 하면 <strong>보너스 투구</strong>를 받아서 투구가 최대 3번이 돼요. 이 규칙이 필요한 이유는, 10프레임 뒤에는 보너스를 계산해 줄 다음 프레임이 없기 때문이에요. 그래서 10프레임에서 스트라이크를 했다면 그 보너스가 될 투구를 두 번 더 던지고, 스페어를 했다면 한 번 더 던져요.</p>
<p>중요한 건 10프레임의 점수 계산 방식이에요. 10프레임은 투구한 핀 수를 <strong>그대로 합산</strong>해요. 예를 들어 X, X, 9라면 10 + 10 + 9 = 29점이에요. 마지막 보너스 투구에 또 보너스가 붙지 않아요. 스트라이크도 스페어도 없이 두 투구로 끝났다면 투구는 2번이고, 두 투구의 합이 점수예요.</p>
<aside class="callout tip"><b class="callout-h">팁</b><p>10프레임에서 스트라이크나 스페어를 하면 핀이 새로 10개 세워져요. 스트라이크 뒤 첫 보너스 투구에서 또 스트라이크를 하면 마지막 투구도 핀이 새로 세워지지만, 7개만 쓰러뜨렸다면 마지막 투구는 남은 3개만 놓여 있고 그걸 모두 쓰러뜨리면 스페어(/)로 표기해요.</p></aside>
<h2 id="s6">퍼펙트 300점이 되는 이유</h2>
<p>모든 투구가 스트라이크이면 1~9프레임은 각각 10 + 10 + 10 = 30점이고 9프레임이 270점이에요. 10프레임에서는 스트라이크 3번을 던져 30점이 더해지니 총 300점이에요. 즉 <strong>12번 연속 스트라이크</strong>가 퍼펙트 게임이에요.</p>
<p>한 번이라도 스트라이크를 놓치면 300점은 불가능해요. 그렇다면 놓친 뒤 나머지를 완벽하게 해도 몇 점이 최대일까요? 앞 프레임을 모두 스트라이크로 치다가 한 프레임에서 9개만 쓰러뜨리고 0개로 끝낸 뒤, 남은 투구를 모두 쓰러뜨린다고 가정해서 계산해 보면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>오픈 프레임(9-0)이 나온 위치</th><th>이후 전부 쓰러뜨릴 때 최대 점수</th></tr></thead><tbody><tr><td>1프레임</td><td>279점</td></tr><tr><td>3프레임</td><td>267점</td></tr><tr><td>5프레임</td><td>267점</td></tr><tr><td>7프레임</td><td>267점</td></tr><tr><td>9프레임</td><td>267점</td></tr><tr><td>10프레임</td><td>267점</td></tr></tbody></table></div>
<p>1프레임에서 놓치면 279점이고, 그 외 위치에서는 모두 267점이에요. 이 값은 남은 투구의 모든 경우를 전부 따져 보는 방식으로도 확인했어요. 계산기의 "최대 가능 점수"가 같은 계산을 해서, 지금 상황에서 목표 점수(예: 200점)가 아직 가능한지 알려 줘요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s7">스페어가 평균 점수를 가르는 이유</h2>
<p>스트라이크가 화려하지만, 일반적인 게임에서는 스페어 처리가 점수 차를 만들어요. 매 프레임 같은 패턴으로 쳤다고 가정하고 직접 계산해 보면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>패턴 (매 프레임)</th><th>최종 점수</th></tr></thead><tbody><tr><td>9개 + 0개 (오픈 프레임만)</td><td>90점</td></tr><tr><td>5개 + 5개(스페어)</td><td>150점</td></tr><tr><td>7개 + 3개(스페어)</td><td>170점</td></tr><tr><td>8개 + 2개(스페어)</td><td>180점</td></tr><tr><td>9개 + 1개(스페어)</td><td>190점</td></tr></tbody></table></div>
<figure class="viz viz-hbar"><figcaption class="viz-title">매 프레임 같은 패턴으로 쳤을 때 최종 점수</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="매 프레임 같은 패턴으로 쳤을 때 최종 점수" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="117" y="29" text-anchor="end">9 + 0 오픈</text><rect class="s1" x="127" y="15" width="199.3" height="20" rx="3"><title>9 + 0 오픈 · 90점</title></rect><text class="viz-val" x="332.4" y="29">90점</text><text class="viz-ax lg" x="117" y="63" text-anchor="end">5 + 5 스페어</text><rect class="s1" x="127" y="49" width="332.2" height="20" rx="3"><title>5 + 5 스페어 · 150점</title></rect><text class="viz-val" x="465.2" y="63">150점</text><text class="viz-ax lg" x="117" y="97" text-anchor="end">7 + 3 스페어</text><rect class="s1" x="127" y="83" width="376.6" height="20" rx="3"><title>7 + 3 스페어 · 170점</title></rect><text class="viz-val" x="509.6" y="97">170점</text><text class="viz-ax lg" x="117" y="131" text-anchor="end">8 + 2 스페어</text><rect class="s1" x="127" y="117" width="398.7" height="20" rx="3"><title>8 + 2 스페어 · 180점</title></rect><text class="viz-val" x="531.7" y="131">180점</text><text class="viz-ax lg" x="117" y="165" text-anchor="end">9 + 1 스페어</text><rect class="s1" x="127" y="151" width="420.9" height="20" rx="3"><title>9 + 1 스페어 · 190점</title></rect><text class="viz-val" x="553.9" y="165">190점</text></svg><p class="viz-note">같은 9개를 쓰러뜨려도 나머지 1개를 스페어로 처리하느냐에 따라 90점과 190점이 돼요.</p><div class="viz-cap">단위: 점 · 자료: 10프레임 마지막 보너스 투구는 첫 투구와 같다고 가정하고 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>최종 점수</th></tr></thead><tbody><tr><td>9 + 0 오픈</td><td>90</td></tr><tr><td>5 + 5 스페어</td><td>150</td></tr><tr><td>7 + 3 스페어</td><td>170</td></tr><tr><td>8 + 2 스페어</td><td>180</td></tr><tr><td>9 + 1 스페어</td><td>190</td></tr></tbody></table></div></details></figure>
<p>같은 9개를 쓰러뜨려도 나머지 1개를 처리했느냐에 따라 90점과 190점으로 갈려요. 스페어 하나가 그 프레임의 10점뿐 아니라 <strong>다음 투구의 핀 수를 한 번 더</strong> 더해 주기 때문이에요. 점수를 올리고 싶다면 스트라이크를 늘리는 것보다 먼저 오픈 프레임을 줄이는 것이 확실해요.</p>
<h2 id="s8">평균(에버리지)과 핸디캡</h2>
<p>여러 게임의 점수를 더해 게임 수로 나눈 값이 평균, 볼링에서는 에버리지라고 불러요. 예를 들어 150, 172, 168, 190점을 쳤다면 합계 680점을 4로 나눈 170점이에요. 리그에 따라 소수점을 버리거나 반올림해서 쓰기도 해요.</p>
<p>핸디캡은 실력 차이가 있는 사람들이 같이 겨룰 수 있도록 점수가 낮은 쪽에 더해 주는 점수예요. 흔히 (기준 점수 − 평균) × 반영 비율로 계산하는데, <strong>기준 점수·비율·소수점 처리는 리그마다 달라요.</strong> 예를 들어 기준이 200이고 비율이 80%이며 평균이 170이면 (200 − 170) × 0.8 = 24점이에요. 이 숫자는 계산을 보여 주기 위한 예시이고 어느 리그의 규칙도 아니에요. 계산기 아래쪽의 평균·핸디캡 칸에 값을 직접 넣어 확인해 보세요.</p>
<h2 id="s9">점수판이 헷갈릴 때 확인할 것</h2>
<ul><li><strong>점수가 비어 있다</strong>: 스트라이크나 스페어의 보너스가 아직 정해지지 않았다는 뜻이에요. 다음 투구가 끝나면 채워져요.</li><li><strong>내 계산과 전자 점수판이 다르다</strong>: 스트라이크 뒤의 보너스를 "같은 프레임"의 투구로 계산하지 않았는지 확인해 보세요. 보너스는 항상 그 프레임 이후의 투구예요.</li><li><strong>10프레임에서 투구가 3번이다</strong>: 스트라이크나 스페어로 보너스 투구를 받은 것이라 정상이에요.</li><li><strong>0개 뒤에 10개가 스페어로 표시된다</strong>: 첫 투구 0개 뒤에 두 번째 투구로 10개를 쓰러뜨리면 스트라이크가 아니라 스페어예요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s10">읽을 때 주의할 점</h2>
<ul><li>이 글은 일반적인 10핀 볼링의 점수 규칙을 설명해요. 어린이용 점수 규정이나 특정 대회의 변형 규칙은 다를 수 있어요.</li><li>글의 점수 예시는 계산을 보여 주기 위한 임의의 투구 기록이에요. 실제 선수나 경기의 기록이 아니에요.</li><li>볼링은 무거운 공을 던지는 운동이에요. 손에 맞는 무게의 공을 쓰고, 던지기 전 가볍게 손목과 어깨를 풀어 주세요. 신고 있는 신발이 미끄러운 레인에서 넘어지지 않게 주의하는 것도 잊지 마세요.</li><li>운동 중 부상 예방과 안전 수칙은 <a href="https://www.sportsafety.or.kr" target="_blank" rel="noopener">스포츠안전재단</a>의 안내를 참고하세요. 대회·리그 점수 규정은 소속 단체의 공식 규칙이 우선해요.</li></ul>
<h2 id="s11">자주 묻는 질문</h2>
<h3>볼링에서 스트라이크를 치면 몇 점인가요?</h3>
<p>스트라이크 프레임은 10점에 다음 두 번 투구의 핀 수를 더해요. 뒤에 스트라이크가 두 번 이어지면 30점이고, 뒤 투구를 모두 놓치면 10점이에요. 그래서 스트라이크의 점수는 다음 투구가 끝나야 확정돼요.</p>
<h3>스페어는 몇 점인가요?</h3>
<p>스페어는 10점에 다음 한 번 투구의 핀 수를 더해요. 스페어 뒤 첫 투구에서 9개를 쓰러뜨리면 그 프레임은 19점이에요. 그 투구는 다음 프레임의 점수에도 그대로 쓰여요.</p>
<h3>볼링 최고 점수는 몇 점인가요?</h3>
<p>한 게임의 최고 점수는 12번 연속 스트라이크로 만드는 300점이에요. 1~9프레임이 각각 30점으로 270점이고, 10프레임에서 스트라이크 3번으로 30점이 더해져 300점이 돼요.</p>
<h3>10프레임은 왜 투구가 세 번까지인가요?</h3>
<p>10프레임에서 스트라이크나 스페어를 하면 그 보너스를 계산해 줄 다음 프레임이 없어요. 그래서 보너스에 해당하는 투구를 직접 던지게 해요. 스트라이크는 보너스 2투구, 스페어는 1투구를 더 던져요.</p>
<h3>핸디캡은 어떻게 계산하나요?</h3>
<p>일반적으로 (기준 점수 − 평균) × 반영 비율로 계산하지만, 기준 점수와 비율, 소수점 처리는 리그마다 달라요. 소속 리그나 볼링장의 규정을 확인하고, 계산기의 평균·핸디캡 칸에 기준과 비율을 직접 입력해 확인해 보세요.</p>
<h2 id="s12">정리</h2>
<p>볼링 점수의 핵심은 "스트라이크는 다음 2투구, 스페어는 다음 1투구를 보너스로 더한다"는 한 줄이에요. 점수판이 비어 있는 건 오류가 아니라 보너스를 기다리는 중이라는 뜻이고, 10프레임만 투구를 그대로 합산해요. 같은 9개를 쓰러뜨려도 스페어를 하느냐에 따라 90점과 190점이 갈리니, 점수를 올리려면 스트라이크보다 오픈 프레임을 줄이는 것부터 시작하세요. 투구를 입력하며 점수판이 채워지는 과정을 직접 보려면 <a href="https://tool.smithlab.co.kr/life/bowling-score-calculator/">볼링 점수 계산기</a>를, 여러 게임의 평균을 구하려면 <a href="https://tool.smithlab.co.kr/calculator/average-calculator/">평균 계산기</a>를, 연속 성공 확률이 궁금하면 <a href="https://tool.smithlab.co.kr/calculator/win-rate-streak-calculator/">승률 연승 계산기</a>를 써 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>스키장 시즌권 vs 일일권, 몇 번 가야 본전일까 (할인율·횟수별 계산)</title>
      <link>https://tool.smithlab.co.kr/posts/life/ski-season-pass-vs-day-ticket-breakeven-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/ski-season-pass-vs-day-ticket-breakeven-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>겨울이 오기 전에 스키장은 시즌권을 먼저 팔기 시작해요. 한 번에 수십만 원이 나가는 데다 겨울이 끝나기 전에는 이득인지 손해인지 알 수 없어서 "살까 말까"가 가장 오래 고민되는 겨울 소비 중 하나예요. 먼저 답부터 말하면, <strong>시즌권 총 부담을 일일권 실제 가격으로 나눠 올림한 횟수가 본전</strong>이에요. 시즌권이 60만원이고 일일권이 8만원이면 7.5번이라 8번 가는 순간부터 시즌권이 이득이에요.</p>
<p>이 글에서 쓰는 가격은 모두 <strong>계산을 보여 주기 위한 임의의 예시</strong>예요. 어느 스키장의 실제 요금도 아니고, 요금은 스키장·시기·판매 방식마다 달라요. 그래서 가격 대신 "어떻게 계산하고 무엇을 확인해야 하는지"를 중심으로 정리했어요. 내 숫자를 바로 넣어 보려면 <a href="https://tool.smithlab.co.kr/life/ski-season-pass-breakeven-calculator/">스키장 시즌권 본전 계산기</a>를 쓰세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>본전 횟수 = ⌈시즌권 총 부담 ÷ 일일권 실제 가격⌉ 이에요. 시즌권 60만원, 일일권 8만원이면 8회예요.</li><li>일일권을 20% 싸게 사면 같은 시즌권의 본전이 8회에서 10회로 늘어요. 할인을 빼고 계산하면 시즌권이 실제보다 이득으로 보여요.</li><li>시즌권 총 부담에는 사진 등록비·보증금 같은 추가 비용도 넣어야 하고, 렌탈·교통·식비는 양쪽에 똑같이 들어서 빼요.</li><li>갈 "예정"이 아니라 갈 "수 있는 날"을 기준으로 정하세요. 최대 일수를 다 가도 본전이 안 되면 일일권이 나아요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">본전 계산은 이렇게 해요</h2>
<p>계산은 세 줄이에요. 먼저 일일권 실제 가격은 <strong>일일권 가격 × (1 − 할인율)</strong> 이에요. 다음으로 시즌권 총 부담은 <strong>시즌권 가격 + 추가 부담</strong>이에요. 마지막으로 <strong>본전 횟수 = 시즌권 총 부담 ÷ 일일권 실제 가격</strong>을 올림해요. 7.5번이면 7번째에는 아직 손해이고 8번째에 처음으로 이득이 되기 때문에 올림이에요.</p>
<p>예를 들어 시즌권 600,000원, 일일권 80,000원, 할인 없음이면 600,000 ÷ 80,000 = 7.5 → <strong>8회</strong>예요. 7번 가면 일일권 합계가 560,000원이라 시즌권이 40,000원 손해고, 8번 가면 640,000원이라 시즌권이 40,000원 이득이에요. 이 값은 1회씩 더해 가며 처음 시즌권을 넘는 횟수를 세는 방식과 자바스크립트 계산으로도 맞춰 보았어요.</p>
<h2 id="s2">시즌권·일일권 가격별 본전 횟수</h2>
<p>시즌권이 쌀수록, 일일권이 비쌀수록 본전 횟수는 줄어요. 아래는 할인이 없을 때 시즌권 총 부담과 일일권 가격 조합별 본전 횟수예요.</p>
<div class="table-wrap"><table><thead><tr><th>시즌권 총 부담</th><th>일일권 6만원</th><th>일일권 8만원</th><th>일일권 10만원</th></tr></thead><tbody><tr><td>40만원</td><td>7회</td><td>5회</td><td>4회</td></tr><tr><td>60만원</td><td>10회</td><td>8회</td><td>6회</td></tr><tr><td>80만원</td><td>14회</td><td>10회</td><td>8회</td></tr><tr><td>100만원</td><td>17회</td><td>13회</td><td>10회</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">시즌권 60만원 기준, 일일권 가격별 본전 횟수 (할인 없음)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="시즌권 60만원 기준, 일일권 가격별 본전 횟수 (할인 없음)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">7.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">10</text><rect class="s1" x="80.8" y="18.0" width="134.4" height="236.0" rx="3"><title>일일권 6만원 · 본전 횟수 10회</title></rect><text class="viz-val" x="148.0" y="13.0" text-anchor="middle">10</text><text class="viz-ax" x="148.0" y="272" text-anchor="middle">일일권 6만원</text><rect class="s1" x="272.8" y="65.2" width="134.4" height="188.8" rx="3"><title>일일권 8만원 · 본전 횟수 8회</title></rect><text class="viz-val" x="340.0" y="60.2" text-anchor="middle">8</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">일일권 8만원</text><rect class="s1" x="464.8" y="112.4" width="134.4" height="141.6" rx="3"><title>일일권 10만원 · 본전 횟수 6회</title></rect><text class="viz-val" x="532.0" y="107.4" text-anchor="middle">6</text><text class="viz-ax" x="532.0" y="272" text-anchor="middle">일일권 10만원</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">같은 시즌권이라도 비교하는 일일권 가격에 따라 본전이 6회에서 10회까지 달라져요.</p><div class="viz-cap">단위: 회 · 자료: 600,000 ÷ 일일권 가격을 올림해 직접 계산 (예시 가격)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>본전 횟수</th></tr></thead><tbody><tr><td>일일권 6만원</td><td>10</td></tr><tr><td>일일권 8만원</td><td>8</td></tr><tr><td>일일권 10만원</td><td>6</td></tr></tbody></table></div></details></figure>
<p>이 표에서 눈여겨볼 점은 같은 60만원 시즌권이라도 일일권 가격이 6만원이냐 10만원이냐에 따라 본전이 10회에서 6회까지 달라진다는 거예요. 그래서 "시즌권은 몇 번 가면 이득이래"라는 남의 말은 어떤 일일권과 비교한 숫자인지 모르면 쓸 수 없어요.</p>
<h2 id="s3">일일권 할인을 꼭 반영하세요</h2>
<p>시즌권을 알아볼 때 가장 흔한 실수는 정가 일일권과만 비교하는 거예요. 얼리버드 예매, 제휴 카드 할인, 야간권, 평일권처럼 일일권을 싸게 사는 길이 있다면 그만큼 시즌권의 매력은 줄어요. 일일권 8만원을 기준으로 할인율별 본전 횟수를 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>평균 할인율</th><th>일일권 실제 가격</th><th>시즌권 40만원</th><th>시즌권 60만원</th><th>시즌권 80만원</th></tr></thead><tbody><tr><td>0%</td><td>80,000원</td><td>5회</td><td>8회</td><td>10회</td></tr><tr><td>10%</td><td>72,000원</td><td>6회</td><td>9회</td><td>12회</td></tr><tr><td>20%</td><td>64,000원</td><td>7회</td><td>10회</td><td>13회</td></tr><tr><td>30%</td><td>56,000원</td><td>8회</td><td>11회</td><td>15회</td></tr></tbody></table></div>
<figure class="viz viz-line"><figcaption class="viz-title">시즌권 60만원, 일일권 할인율별 본전 횟수</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="시즌권 60만원, 일일권 할인율별 본전 횟수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="570" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">6</text><line class="viz-grid" x1="52" x2="570" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">8</text><line class="viz-grid" x1="52" x2="570" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">12</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">0%</text><text class="viz-ax" x="224.7" y="272" text-anchor="middle">10%</text><text class="viz-ax" x="397.3" y="272" text-anchor="middle">20%</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">30%</text><polyline class="viz-line s1" points="52.0,96.7 224.7,77.0 397.3,57.3 570.0,37.7"/><circle class="viz-dot s1" cx="52.0" cy="96.7" r="4"><title>0% · 본전 횟수 8회</title></circle><circle class="viz-dot s1" cx="224.7" cy="77.0" r="4"><title>10% · 본전 횟수 9회</title></circle><circle class="viz-dot s1" cx="397.3" cy="57.3" r="4"><title>20% · 본전 횟수 10회</title></circle><circle class="viz-dot s1" cx="570.0" cy="37.7" r="4"><title>30% · 본전 횟수 11회</title></circle><text class="viz-val" x="578.0" y="41.7">11</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">할인을 30% 받으면 같은 시즌권의 본전이 3번 늘어요.</p><div class="viz-cap">단위: 회 · 자료: 일일권 8만원 × (1 − 할인율) 로 직접 계산 (예시 가격)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>본전 횟수</th></tr></thead><tbody><tr><td>0%</td><td>8</td></tr><tr><td>10%</td><td>9</td></tr><tr><td>20%</td><td>10</td></tr><tr><td>30%</td><td>11</td></tr></tbody></table></div></details></figure>
<p>할인율은 "받을 수 있는 최대 할인"이 아니라 "실제로 내가 평균적으로 받는 할인"으로 넣어야 해요. 얼리버드로 20% 싸게 사고 평일 야간으로 30% 싸게 사는 날이 반반이면 평균은 25%예요.</p>
<h2 id="s4">방문 횟수별로 얼마나 이득일까</h2>
<p>본전은 시작점일 뿐이고 실제 관심사는 "나는 몇 번 갈 것 같은데 그때 얼마나 이득이냐"예요. 시즌권 60만원, 일일권 8만원(할인 없음)에서 방문 횟수별 차액을 계산하면 아래와 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>방문 횟수</th><th>일일권 합계</th><th>시즌권</th><th>시즌권 쪽 차액</th><th>시즌권 1회당</th></tr></thead><tbody><tr><td>4회</td><td>320,000원</td><td>600,000원</td><td>−280,000원</td><td>150,000원</td></tr><tr><td>6회</td><td>480,000원</td><td>600,000원</td><td>−120,000원</td><td>100,000원</td></tr><tr><td>8회</td><td>640,000원</td><td>600,000원</td><td>+40,000원</td><td>75,000원</td></tr><tr><td>10회</td><td>800,000원</td><td>600,000원</td><td>+200,000원</td><td>60,000원</td></tr><tr><td>12회</td><td>960,000원</td><td>600,000원</td><td>+360,000원</td><td>50,000원</td></tr><tr><td>15회</td><td>1,200,000원</td><td>600,000원</td><td>+600,000원</td><td>40,000원</td></tr><tr><td>20회</td><td>1,600,000원</td><td>600,000원</td><td>+1,000,000원</td><td>30,000원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">방문 횟수별 시즌권 쪽 차액 (시즌권 60만원, 일일권 8만원)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="방문 횟수별 시즌권 쪽 차액 (시즌권 60만원, 일일권 8만원)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">-30</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">35</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">67.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="64.3" y="199.5" width="57.6" height="50.8" rx="3"><title>4회 · 차액 -28만원</title></rect><text class="viz-val" x="93.1" y="194.5" text-anchor="middle">-28</text><text class="viz-ax" x="93.1" y="272" text-anchor="middle">4회</text><rect class="s1" x="146.6" y="199.5" width="57.6" height="21.8" rx="3"><title>6회 · 차액 -12만원</title></rect><text class="viz-val" x="175.4" y="194.5" text-anchor="middle">-12</text><text class="viz-ax" x="175.4" y="272" text-anchor="middle">6회</text><rect class="s1" x="228.9" y="192.3" width="57.6" height="7.3" rx="3"><title>8회 · 차액 4만원</title></rect><text class="viz-val" x="257.7" y="187.3" text-anchor="middle">4</text><text class="viz-ax" x="257.7" y="272" text-anchor="middle">8회</text><rect class="s1" x="311.2" y="163.2" width="57.6" height="36.3" rx="3"><title>10회 · 차액 20만원</title></rect><text class="viz-val" x="340.0" y="158.2" text-anchor="middle">20</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">10회</text><rect class="s1" x="393.5" y="134.2" width="57.6" height="65.4" rx="3"><title>12회 · 차액 36만원</title></rect><text class="viz-val" x="422.3" y="129.2" text-anchor="middle">36</text><text class="viz-ax" x="422.3" y="272" text-anchor="middle">12회</text><rect class="s1" x="475.8" y="90.6" width="57.6" height="108.9" rx="3"><title>15회 · 차액 60만원</title></rect><text class="viz-val" x="504.6" y="85.6" text-anchor="middle">60</text><text class="viz-ax" x="504.6" y="272" text-anchor="middle">15회</text><rect class="s1" x="558.1" y="18.0" width="57.6" height="181.5" rx="3"><title>20회 · 차액 100만원</title></rect><text class="viz-val" x="586.9" y="13.0" text-anchor="middle">100</text><text class="viz-ax" x="586.9" y="272" text-anchor="middle">20회</text><line class="viz-base" x1="52" x2="628" y1="199.5" y2="199.5"/></svg><p class="viz-note">본전 이후에는 한 번 더 갈 때마다 일일권 가격만큼 차이가 벌어져요.</p><div class="viz-cap">단위: 만원 · 자료: 횟수 × 80,000 − 600,000 으로 직접 계산 (예시 가격)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>차액</th></tr></thead><tbody><tr><td>4회</td><td>-28</td></tr><tr><td>6회</td><td>-12</td></tr><tr><td>8회</td><td>4</td></tr><tr><td>10회</td><td>20</td></tr><tr><td>12회</td><td>36</td></tr><tr><td>15회</td><td>60</td></tr><tr><td>20회</td><td>100</td></tr></tbody></table></div></details></figure>
<p>손해 구간과 이득 구간이 비대칭이에요. 4번만 가면 28만원 손해로 시즌권 가격의 절반 가까이를 잃지만, 20번 가면 100만원 이득이에요. 그래서 시즌권은 "자주 가는 사람에게 크게 이득이고, 가끔 가는 사람에게는 꽤 큰 손해"인 상품이에요. 가끔 갈 것 같다면 시즌권은 사지 않는 쪽이 안전해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/ski-season-pass-vs-day-ticket-breakeven-guide-1.svg" alt="시즌권과 일일권 누적 비용이 만나는 본전 지점을 보여 주는 선 그래프" width="800" height="450" loading="lazy" decoding="async"><figcaption>일일권 누적 비용(파랑)이 시즌권 가격(주황)을 넘는 8회가 본전이에요</figcaption></figure>
<h2 id="s5">시즌권 총 부담에 넣을 것, 뺄 것</h2>
<p>본전 계산에서 헷갈리는 부분이 어디까지를 비용에 넣느냐예요. 원칙은 단순해요. <strong>시즌권을 사서만 생기는 비용은 시즌권 쪽에 넣고, 사든 안 사든 똑같이 드는 비용은 뺍니다.</strong></p>
<ul><li><strong>넣는 것</strong>: 사진 등록비, 보증금, 시즌권 전용 보관함·장비 보관료처럼 시즌권을 사야만 내는 돈이에요.</li><li><strong>빼는 것</strong>: 교통비, 장비 렌탈, 식비, 숙박은 시즌권을 사든 일일권을 사든 가는 횟수만큼 똑같이 들어요. 양쪽에 같은 금액을 더하면 차이는 그대로라서 본전 횟수가 달라지지 않아요.</li><li><strong>반영할 것</strong>: 시즌권에 렌탈 할인이나 주차 무료 같은 혜택이 붙어 있다면, 그 혜택으로 아끼는 돈을 시즌권 가격에서 빼서 계산해 보세요.</li></ul>
<p>겨울 레저 전체 예산이 궁금하다면 리프트권만이 아니라 교통·식비까지 합산해야 해요. 이럴 때는 <a href="https://tool.smithlab.co.kr/date/winter-break-planner-calculator/">겨울방학 계획 계산기</a>로 일정을 먼저 그려 보고 <a href="https://tool.smithlab.co.kr/money/split-bill-calculator/">더치페이 계산기</a>로 인원수대로 나누는 방법도 있어요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">사기 전에 확인할 조건 체크리스트</h2>
<p>가격만 보고 사면 나중에 곤란해지는 조건들이 있어요. 판매처 안내문에서 아래를 확인하세요.</p>
<ol><li><strong>사용 불가일(블랙아웃)</strong>: 성수기·공휴일에 못 쓰는 시즌권이 있어요. 내가 가려던 날이 사용 불가일이면 그날은 본전 횟수에 셀 수 없어요.</li><li><strong>주중 전용인지 주말 포함인지</strong>: 주말 포함 여부에 따라 가격이 달라지는 경우가 많아요. 직장인이 주말에만 간다면 주중 전용 시즌권은 의미가 없어요.</li><li><strong>야간 포함 여부</strong>: 야간 이용 가능 여부에 따라 같은 가격이어도 쓸 수 있는 횟수가 달라져요.</li><li><strong>양도·환불 규정</strong>: 못 갈 상황이 생겼을 때 양도하거나 환불받을 수 있는지에 따라 위험이 크게 달라져요. 사진 등록 후에는 환불이 안 되는 경우도 있어요.</li><li><strong>구매 마감 시점의 가격 변동</strong>: 판매 기간에 따라 가격이 오르는 경우가 있어요. 일찍 사면 싸지만, 일찍 살수록 날씨와 일정이 불확실한 상태에서 결정해야 한다는 점도 같이 보세요.</li><li><strong>여러 스키장 이용 여부</strong>: 한 스키장 시즌권이 제휴 스키장에서도 쓰이는지 확인하면, 실제로 갈 수 있는 곳이 넓어질 수 있어요.</li></ol>
<h2 id="s7">"갈 예정"이 아니라 "갈 수 있는 날"로 계산하세요</h2>
<p>시즌권을 사는 사람의 대부분은 시즌 초에 가장 의욕이 커요. "한 달에 두 번은 가겠지" 하고 10번을 생각하지만 실제로는 날씨, 컨디션, 다른 약속, 부상 때문에 절반이 되기도 해요. 그래서 달력에서 <strong>갈 수 있는 주말을 먼저 세고, 거기에 실제로 갈 확률을 곱해 보는 것</strong>이 현실적이에요. 겨울에 남은 주말은 <a href="https://tool.smithlab.co.kr/date/year-remaining-calculator/">올해 남은 날 계산기</a>로 확인할 수 있어요.</p>
<p>예를 들어 12월부터 2월까지 석 달 동안 주말이 약 13번이라면, 격주로만 가도 6~7번이에요. 시즌권 60만원·일일권 8만원 조합의 본전이 8번이니 격주 방문으로는 본전이 안 되고, 거의 매주 가야 이득이 돼요. 이 계산을 하고 나면 "갈 때마다 일일권을 사는 쪽이 자유롭다"는 결론이 나오는 사람이 많아요.</p>
<p>물건 값을 일한 시간으로 바꿔서 결정하는 방법도 있어요. 시즌권 60만원이 내 노동 몇 시간인지 <a href="https://tool.smithlab.co.kr/money/price-in-work-hours-calculator/">물건값 노동시간 계산기</a>로 바꿔 보면, 같은 돈으로 할 수 있는 다른 선택과 비교하기 쉬워져요. 자주 쓰는 정도를 한 번 쓸 때의 비용으로 환산하는 방식은 <a href="https://tool.smithlab.co.kr/money/cost-per-wear-calculator/">1회당 비용 계산기</a>와 같은 원리예요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">읽을 때 주의할 점</h2>
<ul><li>이 글의 모든 가격은 계산을 위한 임의의 예시이고, 특정 스키장의 요금이 아니에요. 내 숫자로 다시 계산하세요.</li><li>중도 환불, 부상으로 못 간 날의 보상, 시즌 중 가격 변동은 반영하지 않았어요. 판매처 약관을 따르세요.</li><li>스키·보드는 부상 위험이 있는 운동이에요. 이 글은 비용만 다루고, 안전 수칙은 <a href="https://www.sportsafety.or.kr" target="_blank" rel="noopener">스포츠안전재단</a>의 안내를 참고해 장비 점검과 컨디션 관리를 따로 챙기세요.</li><li>본전 계산이라는 개념의 일반적인 설명은 <a href="https://ko.wikipedia.org/wiki/%EC%86%90%EC%9D%B5%EB%B6%84%EA%B8%B0%EC%A0%90" target="_blank" rel="noopener">손익분기점 – 위키백과</a>에서 볼 수 있어요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>시즌권은 몇 번 가면 본전인가요?</h3>
<p>시즌권 총 부담을 일일권 실제 가격으로 나눠 올림한 횟수예요. 시즌권 60만원, 일일권 8만원이면 7.5번이라 8번부터 본전이고, 일일권을 20% 싸게 사면 10번으로 늘어요. 내 가격을 <a href="https://tool.smithlab.co.kr/life/ski-season-pass-breakeven-calculator/">본전 계산기</a>에 넣으면 바로 나와요.</p>
<h3>시즌권을 사면 렌탈이나 교통비도 아낄 수 있나요?</h3>
<p>기본적으로는 아니에요. 렌탈·교통·식비는 시즌권을 사든 안 사든 가는 횟수만큼 드는 비용이라 본전 계산에서는 빼요. 다만 시즌권에 렌탈 할인 같은 혜택이 따로 붙어 있다면 그만큼을 시즌권 가격에서 빼서 계산할 수 있어요.</p>
<h3>일일권 할인율은 얼마로 넣어야 하나요?</h3>
<p>받을 수 있는 최대 할인이 아니라 내가 평균적으로 받는 할인으로 넣으세요. 정가로만 간다면 0%, 얼리버드 20%와 야간 30%를 반반 쓰면 25%예요. 할인을 과대로 잡으면 본전 횟수가 실제보다 늘어나 시즌권을 불리하게 판단해요.</p>
<h3>가끔 가는 사람은 시즌권이 손해인가요?</h3>
<p>본전 횟수보다 적게 간다면 손해예요. 예시 가격에서 4번만 가면 28만원 손해이고 6번이면 12만원 손해예요. 8번을 넘어야 이득이 되므로, 가끔 갈 사람은 일일권을 쓰는 편이 안전해요.</p>
<h3>시즌권을 샀는데 못 가게 되면 어떻게 하나요?</h3>
<p>환불·양도 규정은 판매처마다 달라요. 시즌 시작 전과 후, 사진 등록 전과 후에 규정이 다를 수 있으니 구매 전에 반드시 확인하세요. 규정이 엄격하다면 그만큼 위험이 커지므로 본전 횟수에 여유를 두고 판단하는 편이 좋아요.</p>
<h2 id="s10">정리</h2>
<ul><li>시즌권은 "총 부담 ÷ 일일권 실제 가격"을 올림한 횟수부터 이득이에요.</li><li>일일권 할인과 시즌권 추가 비용을 반영해야 정확하고, 렌탈·교통·식비는 양쪽 똑같아서 빼요.</li><li>본전 아래에서는 손해가 크고, 본전을 넘으면 한 번 갈 때마다 일일권 가격만큼 이득이 쌓여요.</li><li>갈 예정이 아니라 갈 수 있는 날과 사용 불가일, 환불 규정으로 현실적인 횟수를 정하세요.</li><li>내 가격으로 확인하려면 <a href="https://tool.smithlab.co.kr/life/ski-season-pass-breakeven-calculator/">스키장 시즌권 본전 계산기</a>를 쓰고, 일정은 <a href="https://tool.smithlab.co.kr/date/winter-break-planner-calculator/">겨울방학 계획 계산기</a>와 함께 보세요.</li></ul>
    ]]></description>
    </item>
    <item>
      <title>적금 풍차돌리기 월급 대비 몇 개까지? 12개월째 납입액 역산하기</title>
      <link>https://tool.smithlab.co.kr/posts/money/savings-pinwheel-monthly-payment-salary-ratio-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/savings-pinwheel-monthly-payment-salary-ratio-guide/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>적금 풍차돌리기를 시작하기 전에 꼭 계산해야 할 것이 하나 있어요. <strong>12개월째에는 계좌 12개를 동시에 납입한다</strong>는 점이에요. 월 10만 원짜리를 12개 만들면 1개월째에는 10만 원이지만 12개월째에는 한 달에 120만 원이 빠져나가요. 먼저 답부터 말하면, 풍차 개수는 "하고 싶은 만큼"이 아니라 <strong>월급의 일정 비율에서 거꾸로 계산</strong>해서 정하는 것이 안전해요.</p>
<p>이 글에서 쓰는 금리(연 3~6%)와 금액은 모두 <strong>계산을 보여 주기 위한 임의 가정</strong>이에요. 어느 은행 상품의 실제 금리가 아니니, 가입할 상품의 금리를 확인해서 <a href="https://tool.smithlab.co.kr/money/savings-pinwheel-calculator/">적금 풍차돌리기 계산기</a>에 직접 넣어 보세요. 예금(목돈)으로 하는 풍차는 <a href="https://tool.smithlab.co.kr/posts/money/deposit-ladder-pinwheel/">예금 풍차돌리기 글</a>에서 따로 다뤘고, 이 글은 <strong>매달 조금씩 넣는 적금 풍차</strong>에 집중해요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>적금 풍차돌리기는 매달 적금 1개씩, 보통 12개월짜리 12개를 만드는 방식이에요. 12개월째부터 24개월째 직전까지는 계좌 12개를 동시에 납입해요.</li><li>월 10만 원, 연 4%(단리), 12개월 적금 1개의 세후 이자는 21,996원, 만기금은 1,221,996원이에요(일반과세 15.4%).</li><li>이자는 납입액에 비례해서 풍차로 나눠 넣어도 합계는 같은 돈을 한 적금에 넣을 때와 같아요. 풍차는 이자를 늘려 주지 않고 만기 흐름을 만들어 줘요.</li><li>개수는 월급의 10~20% 안쪽에서 "12개월째 월 납입액 ÷ 계좌당 월 납입액"으로 정하면 돼요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">적금 풍차돌리기, 12개월 동안 무슨 일이 벌어지나</h2>
<p>방식은 단순해요. 1월에 12개월짜리 적금 A를 들고, 2월에 B를 들고, 3월에 C를 드는 식으로 12월까지 12개를 만들어요. 각 적금은 가입한 달부터 12번 납입하고, 12개월 뒤에 만기가 돼요.</p>
<ul><li>1개월째: A만 납입 중 → 10만 원</li><li>2개월째: A, B 납입 중 → 20만 원</li><li>12개월째: A~L 12개 납입 중 → 120만 원, 그리고 A 만기</li><li>13개월째: B~L(11개)과 새 적금 M → 계속 12개를 유지하면 120만 원, 그리고 B 만기</li></ul>
<p>여기서 두 가지를 구분하세요. 첫째, 12개를 만들고 <strong>멈추면</strong> 13개월째부터 납입 계좌가 하나씩 줄고 매달 만기금이 들어와요(총 23개월 동안 이어져요). 둘째, 만기금을 받을 때마다 <strong>새 적금을 계속 들면</strong> 매달 납입액은 120만 원으로 유지되고, 만기금은 쓰거나 다시 굴리게 돼요. 어느 쪽이든 12개월째가 납입액 최고점이라는 사실은 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>시점 (월 10만 원 12개 만들 때)</th><th>그 달 납입액</th><th>납입 중인 계좌</th></tr></thead><tbody><tr><td>1개월째</td><td>100,000원</td><td>1개</td></tr><tr><td>2개월째</td><td>200,000원</td><td>2개</td></tr><tr><td>3개월째</td><td>300,000원</td><td>3개</td></tr><tr><td>6개월째</td><td>600,000원</td><td>6개</td></tr><tr><td>9개월째</td><td>900,000원</td><td>9개</td></tr><tr><td>12개월째</td><td>1,200,000원</td><td>12개</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">풍차 12개를 만들 때 월별 납입액 (월 10만 원 적금)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="풍차 12개를 만들 때 월별 납입액 (월 10만 원 적금)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">80</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">120</text><rect class="s1" x="59.2" y="234.3" width="33.6" height="19.7" rx="3"><title>1월 · 그 달 납입액 10만원</title></rect><text class="viz-val" x="76.0" y="229.3" text-anchor="middle">10</text><text class="viz-ax" x="76.0" y="272" text-anchor="middle">1월</text><rect class="s1" x="107.2" y="214.7" width="33.6" height="39.3" rx="3"><title>2월 · 그 달 납입액 20만원</title></rect><text class="viz-val" x="124.0" y="209.7" text-anchor="middle">20</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">2월</text><rect class="s1" x="155.2" y="195.0" width="33.6" height="59.0" rx="3"><title>3월 · 그 달 납입액 30만원</title></rect><text class="viz-val" x="172.0" y="190.0" text-anchor="middle">30</text><text class="viz-ax" x="172.0" y="272" text-anchor="middle">3월</text><rect class="s1" x="203.2" y="175.3" width="33.6" height="78.7" rx="3"><title>4월 · 그 달 납입액 40만원</title></rect><text class="viz-val" x="220.0" y="170.3" text-anchor="middle">40</text><text class="viz-ax" x="220.0" y="272" text-anchor="middle">4월</text><rect class="s1" x="251.2" y="155.7" width="33.6" height="98.3" rx="3"><title>5월 · 그 달 납입액 50만원</title></rect><text class="viz-val" x="268.0" y="150.7" text-anchor="middle">50</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">5월</text><rect class="s1" x="299.2" y="136.0" width="33.6" height="118.0" rx="3"><title>6월 · 그 달 납입액 60만원</title></rect><text class="viz-val" x="316.0" y="131.0" text-anchor="middle">60</text><text class="viz-ax" x="316.0" y="272" text-anchor="middle">6월</text><rect class="s1" x="347.2" y="116.3" width="33.6" height="137.7" rx="3"><title>7월 · 그 달 납입액 70만원</title></rect><text class="viz-val" x="364.0" y="111.3" text-anchor="middle">70</text><text class="viz-ax" x="364.0" y="272" text-anchor="middle">7월</text><rect class="s1" x="395.2" y="96.7" width="33.6" height="157.3" rx="3"><title>8월 · 그 달 납입액 80만원</title></rect><text class="viz-val" x="412.0" y="91.7" text-anchor="middle">80</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">8월</text><rect class="s1" x="443.2" y="77.0" width="33.6" height="177.0" rx="3"><title>9월 · 그 달 납입액 90만원</title></rect><text class="viz-val" x="460.0" y="72.0" text-anchor="middle">90</text><text class="viz-ax" x="460.0" y="272" text-anchor="middle">9월</text><rect class="s1" x="491.2" y="57.3" width="33.6" height="196.7" rx="3"><title>10월 · 그 달 납입액 100만원</title></rect><text class="viz-val" x="508.0" y="52.3" text-anchor="middle">100</text><text class="viz-ax" x="508.0" y="272" text-anchor="middle">10월</text><rect class="s1" x="539.2" y="37.7" width="33.6" height="216.3" rx="3"><title>11월 · 그 달 납입액 110만원</title></rect><text class="viz-val" x="556.0" y="32.7" text-anchor="middle">110</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">11월</text><rect class="s1" x="587.2" y="18.0" width="33.6" height="236.0" rx="3"><title>12월 · 그 달 납입액 120만원</title></rect><text class="viz-val" x="604.0" y="13.0" text-anchor="middle">120</text><text class="viz-ax" x="604.0" y="272" text-anchor="middle">12월</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">12개월째 한 달 납입액은 첫 달의 12배예요.</p><div class="viz-cap">단위: 만원 · 자료: 월 10만 원씩 매달 1개 가입, 12개월 적금 가정(직접 계산)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>그 달 납입액</th></tr></thead><tbody><tr><td>1월</td><td>10</td></tr><tr><td>2월</td><td>20</td></tr><tr><td>3월</td><td>30</td></tr><tr><td>4월</td><td>40</td></tr><tr><td>5월</td><td>50</td></tr><tr><td>6월</td><td>60</td></tr><tr><td>7월</td><td>70</td></tr><tr><td>8월</td><td>80</td></tr><tr><td>9월</td><td>90</td></tr><tr><td>10월</td><td>100</td></tr><tr><td>11월</td><td>110</td></tr><tr><td>12월</td><td>120</td></tr></tbody></table></div></details></figure>
<h2 id="s2">적금 1개 이자부터 정확히 계산하기</h2>
<p>풍차 전체를 보기 전에 계좌 하나의 이자를 계산해야 해요. 단리 정액적립식 적금은 첫 납입이 12개월치 이자, 마지막 납입은 1개월치 이자를 받아서 <strong>이자 = 월 납입액 × (연 금리 ÷ 12) × 12 × 13 ÷ 2</strong> 예요. 12×13÷2 = 78이라서, 월 10만 원·연 4%면 100,000 × (0.04 ÷ 12) × 78 = <strong>26,000원</strong>(세전)이에요. 일반과세 15.4%를 빼면 21,996원이고, 원금 1,200,000원과 더한 만기금이 1,221,996원이에요.</p>
<p>금리가 달라지면 이렇게 바뀌어요(월 10만 원, 12개월, 일반과세).</p>
<div class="table-wrap"><table><thead><tr><th>금리(연)</th><th>세전 이자</th><th>세후 이자</th><th>만기금</th></tr></thead><tbody><tr><td>3%</td><td>19,500원</td><td>16,497원</td><td>1,216,497원</td></tr><tr><td>4%</td><td>26,000원</td><td>21,996원</td><td>1,221,996원</td></tr><tr><td>5%</td><td>32,500원</td><td>27,495원</td><td>1,227,495원</td></tr><tr><td>6%</td><td>39,000원</td><td>32,994원</td><td>1,232,994원</td></tr></tbody></table></div>
<p>금리 1%p는 적금 1개당 세전 6,500원이에요. 풍차 12개면 78,000원이고요. "풍차를 하면 이자가 많아질 것 같다"는 기대와 달리, 금리가 낮은 은행에서 풍차를 돌리는 것보다 <strong>금리가 1~2%p 높은 상품을 고르는 쪽</strong>이 이득이 훨씬 커요.</p>
<h2 id="s3">풍차돌리기는 이자를 늘려 줄까 (아니요)</h2>
<p>가장 흔한 오해가 이거예요. 적금 이자는 납입액에 정비례해서, 월 10만 원짜리 12개의 이자 합계는 월 120만 원짜리 한 개를 12개월 넣을 때의 이자와 같아요. 계산해 보면 263,952원(세후)로 똑같아요. 그래서 풍차 자체가 이자를 더 주지는 않아요.</p>
<p>그러면 풍차를 하는 이유는 뭘까요? 세 가지가 실제로 의미 있어요.</p>
<ol><li><strong>만기가 매달 와요.</strong> 목돈이 한꺼번에 생기는 대신 매달 1,221,996원가 돌아와서, 그 돈을 쓰거나 다시 굴리기 편해요.</li><li><strong>한 계좌만 해지할 수 있어요.</strong> 급하게 돈이 필요할 때 적금 하나만 깨면 되고, 나머지는 유지돼요. 한 적금으로 12개월 넣다 중도 해지하면 전체 이자가 크게 줄어요.</li><li><strong>금리를 갈아탈 수 있어요.</strong> 매달 새 적금을 들 때 그 시점의 높은 금리 상품을 고를 수 있어요.</li></ol>
<p>반대로 단점도 분명해요. 가입과 관리가 번거롭고, 12개월째 납입액이 크며, 우대금리 조건(급여 이체, 카드 실적)을 계좌마다 채우기 어려울 수 있어요.</p>
<h2 id="s4">월급 대비 몇 개까지 가능한지 역산하기</h2>
<p>핵심은 이거예요. <strong>풍차 개수 = 감당할 수 있는 월 저축액 ÷ 계좌당 월 납입액</strong> (최대 가입 기간 개수까지). 월급의 몇 %를 저축으로 쓸지 먼저 정하고, 그 금액을 계좌당 납입액으로 나눠요. 계좌당 월 10만 원 기준으로 계산해 보면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>월급</th><th>저축 비율</th><th>월 저축 한도</th><th>풍차 개수(최대)</th><th>12개 만기 시 세후 이자(4%)</th></tr></thead><tbody><tr><td>월급 250만 원</td><td>10%</td><td>25만 원</td><td>2개</td><td>43,992원</td></tr><tr><td>월급 250만 원</td><td>20%</td><td>50만 원</td><td>5개</td><td>109,980원</td></tr><tr><td>월급 300만 원</td><td>10%</td><td>30만 원</td><td>3개</td><td>65,988원</td></tr><tr><td>월급 300만 원</td><td>20%</td><td>60만 원</td><td>6개</td><td>131,976원</td></tr><tr><td>월급 400만 원</td><td>10%</td><td>40만 원</td><td>4개</td><td>87,984원</td></tr><tr><td>월급 400만 원</td><td>20%</td><td>80만 원</td><td>8개</td><td>175,968원</td></tr></tbody></table></div>
<p>월급이 250만 원이고 저축 비율을 10%로 잡으면 월 25만 원이라서 계좌당 10만 원이면 <strong>2개</strong>까지만 가능해요. 12개를 만들려면 계좌당 납입액을 2만 원쯤으로 줄여야 해요. 반대로 월급 400만 원에 20%면 80만 원이라 10만 원짜리 8개까지 돼요. 이 계산을 하지 않고 "풍차 12개"를 먼저 정하면 중간에 부담이 커져서 해지하게 되는 일이 생겨요.</p>
<p>개수에 따라 숫자가 어떻게 달라지는지도 볼게요(월 10만 원, 연 4%, 12개월, 일반과세).</p>
<div class="table-wrap"><table><thead><tr><th>풍차 개수</th><th>월 최대 납입</th><th>총 납입 원금</th><th>총 이자(세후)</th></tr></thead><tbody><tr><td>3개</td><td>300,000원</td><td>3,600,000원</td><td>65,988원</td></tr><tr><td>6개</td><td>600,000원</td><td>7,200,000원</td><td>131,976원</td></tr><tr><td>9개</td><td>900,000원</td><td>10,800,000원</td><td>197,964원</td></tr><tr><td>12개</td><td>1,200,000원</td><td>14,400,000원</td><td>263,952원</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">풍차 개수별 총 이자(세후, 월 10만 원·연 4%·12개월)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="풍차 개수별 총 이자(세후, 월 10만 원·연 4%·12개월)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">50,000</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">100,000</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">150,000</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">200,000</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">250,000</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">300,000</text><rect class="s1" x="73.6" y="202.1" width="100.8" height="51.9" rx="3"><title>3개 · 세후 이자 합계 65,988원</title></rect><text class="viz-val" x="124.0" y="197.1" text-anchor="middle">65,988</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">3개</text><rect class="s1" x="217.6" y="150.2" width="100.8" height="103.8" rx="3"><title>6개 · 세후 이자 합계 131,976원</title></rect><text class="viz-val" x="268.0" y="145.2" text-anchor="middle">131,976</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">6개</text><rect class="s1" x="361.6" y="98.3" width="100.8" height="155.7" rx="3"><title>9개 · 세후 이자 합계 197,964원</title></rect><text class="viz-val" x="412.0" y="93.3" text-anchor="middle">197,964</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">9개</text><rect class="s1" x="505.6" y="46.4" width="100.8" height="207.6" rx="3"><title>12개 · 세후 이자 합계 263,952원</title></rect><text class="viz-val" x="556.0" y="41.4" text-anchor="middle">263,952</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">12개</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">이자는 계좌 수에 정비례해요.</p><div class="viz-cap">단위: 원 · 자료: 단리 정액적립식 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>세후 이자 합계</th></tr></thead><tbody><tr><td>3개</td><td>65,988</td></tr><tr><td>6개</td><td>131,976</td></tr><tr><td>9개</td><td>197,964</td></tr><tr><td>12개</td><td>263,952</td></tr></tbody></table></div></details></figure>
<h2 id="s5">12개월짜리와 6개월짜리, 어느 쪽이 나을까</h2>
<p>같은 월 10만 원, 연 4%라도 6개월짜리 적금은 이자 계산이 달라요. 6개월 적금 1개의 세전 이자는 100,000 × (0.04 ÷ 12) × 21 = 7,000원, 세후 5,922원로 12개월짜리의 약 4분의 1 수준이에요. 가입 기간이 절반이라도 이자는 절반보다 작아요. 납입 횟수도 절반이지만 평균 예치 기간도 짧아지기 때문이에요.</p>
<ul><li><strong>6개월 풍차</strong>: 6개월째부터 만기가 오니 현금 회전이 빠르지만, 금리가 높은 6개월 상품이 별로 없을 수 있고 가입을 자주 해야 해요.</li><li><strong>12개월 풍차</strong>: 가장 흔한 구성이에요. 상품 종류가 많고 이자도 상대적으로 커요.</li><li><strong>24개월 이상</strong>: 금리가 더 높은 상품이 있을 수 있지만 중도에 돈이 필요할 때 부담이 커요.</li></ul>
<p>선택 기준은 단순해요. <strong>같은 조건에서 금리가 높은 상품</strong>을 먼저 보고, 그다음 현금 회전을 얼마나 원하는지로 기간을 정해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">가상의 직장인으로 두 가지 설계 비교하기</h2>
<p>아래는 이해를 돕기 위한 <strong>가상의 인물</strong> 예시예요. 월 실수령 280만 원인 직장인이 저축에 15%, 월 42만 원까지 쓰기로 했어요. 금리는 모든 계좌 연 4% 단리, 12개월, 일반과세 15.4%로 같다고 가정했어요.</p>
<ol><li>월 저축 한도: 2,800,000 × 15% = 420,000원</li><li>설계 A — 계좌당 10만 원: 420,000 ÷ 100,000 = 4.2 → <strong>4개</strong>(4개월째부터 월 40만 원)</li><li>설계 B — 12개를 채우고 싶다면 계좌당 420,000 ÷ 12 = <strong>35,000원</strong>(12개월째 월 42만 원)</li></ol>
<div class="table-wrap"><table><thead><tr><th>비교</th><th>설계 A (10만 원 × 4개)</th><th>설계 B (3만 5천 원 × 12개)</th></tr></thead><tbody><tr><td>월 납입 최고점</td><td>400,000원(4개월째부터)</td><td>420,000원(12개월째)</td></tr><tr><td>총 납입 원금</td><td>4,800,000원</td><td>5,040,000원</td></tr><tr><td>계좌당 세후 이자</td><td>21,996원</td><td>약 7,699원</td></tr><tr><td>세후 이자 합계</td><td>87,984원</td><td>약 92,383원</td></tr><tr><td>만기가 오는 달</td><td>12~15개월째, 4번</td><td>12~23개월째, 매달 12번</td></tr><tr><td>만기 1번 금액</td><td>1,221,996원</td><td>약 427,699원</td></tr></tbody></table></div>
<p>이자는 원금에 정비례해서, 원금이 24만 원 많은 설계 B가 약 4,400원 많을 뿐이에요. 고르는 기준은 이자가 아니라 <strong>목돈 크기와 현금 흐름</strong>이에요. 120만 원대 목돈이 몇 번 필요하면(예: 보험료 연납, 자동차세 연납) 설계 A, 매달 40만 원대 여유 자금이 필요하면 설계 B가 맞아요. 설계 A는 4개월째에 최고점에 닿아서, 시작 후 석 달 안에 부담이 맞는지 확인할 수 있다는 장점도 있어요.</p>
<h2 id="s7">자주 틀리는 지점: 다른 계산과 결과가 달라지는 이유</h2>
<ul><li><strong>이자 공식을 '원금 × 금리'로 쓰기</strong>: 월 10만 원 × 12회 = 120만 원에 4%를 곱하면 48,000원이 나오는데, 적금은 납입 시점마다 예치 기간이 달라 실제 세전 이자는 26,000원이에요. 평균 예치 기간이 6.5개월이라 대략 '원금 × 금리 × 6.5 ÷ 12'예요.</li><li><strong>우대금리를 모든 계좌에 넣기</strong>: 급여 이체·카드 실적 우대는 한 사람이 한 상품에 한 번만 채울 수 있는 경우가 많아요. 풍차 12개를 같은 상품으로 열면 우대 조건이 첫 계좌에만 붙을 수 있으니 기본금리로 계산하는 게 안전해요.</li><li><strong>세금 끝자리</strong>: 이 글은 이자 × 15.4%로 계산했어요. 실제 원천징수는 세금의 10원 미만을 버려서 통장에는 몇 원 다르게 찍힐 수 있어요(예: 21,996원 → 22,000원).</li><li><strong>6개월 적금 이자를 12개월의 절반으로 보기</strong>: 6개월 적금 1개는 세전 7,000원으로 12개월(26,000원)의 약 27%예요. 기간이 절반이면 납입 횟수와 평균 예치 기간이 둘 다 줄어서 이자는 4분의 1 남짓이에요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">시작 전에 점검할 체크리스트</h2>
<ul><li>[ ] 12개월째(또는 개수가 달라지는 시점)의 월 납입액이 월급의 20% 안인가?</li><li>[ ] 연말·명절·휴가철처럼 지출이 몰리는 달에 납입액이 최고점이 되지는 않는가?</li><li>[ ] 금리는 우대 조건을 뺀 기본금리와 우대금리를 모두 확인했는가?</li><li>[ ] 예금자 보호 한도(한 금융회사당 원금과 이자 합계 1억 원, 2025년 9월부터)를 넘지 않는가? 보호 대상은 <a href="https://www.kdic.or.kr" target="_blank" rel="noopener">예금보험공사</a>에서 확인할 수 있어요.</li><li>[ ] 비상금(생활비 3개월분)을 풍차와 별도로 확보했는가? 비상금이 없으면 적금을 깨는 일이 생겨요.</li><li>[ ] 만기금을 어떻게 쓸지(재예치·소비·목표 자금) 정했는가?</li></ul>
<p>이 중에서 가장 자주 놓치는 것은 비상금이에요. 풍차는 "중도 해지 부담이 적다"는 장점이 있지만, 해지하면 약정 이자의 대부분을 잃어요. 비상금이 따로 있으면 적금을 깨지 않아도 돼요. 목표 금액을 정해 두려면 <a href="https://tool.smithlab.co.kr/money/savings-goal-calculator/">목표 저축 계산기</a>를, 적금 한 개의 이자는 <a href="https://tool.smithlab.co.kr/money/savings-calculator/">적금 이자 계산기</a>를 쓰세요.</p>
<h2 id="s9">자주 묻는 질문</h2>
<h3>풍차돌리기를 하면 이자가 더 많이 붙나요?</h3>
<p>아니에요. 적금 이자는 납입액에 정비례해서 풍차 12개의 합계가 한 적금에 같은 돈을 넣을 때와 같아요. 풍차의 장점은 이자가 아니라 만기 흐름과 유연함이에요.</p>
<h3>월 최대 납입액은 어떻게 계산하나요?</h3>
<p>계좌당 월 납입액에 동시에 납입 중인 계좌 수를 곱해요. 월 10만 원짜리 12개를 12개월 간격으로 만들면 12개월째에 120만 원이에요.</p>
<h3>월급이 적어도 풍차돌리기를 할 수 있나요?</h3>
<p>할 수 있어요. 계좌당 납입액을 줄이면 돼요. 월 5만 원짜리 12개면 12개월째 월 납입액은 60만 원이에요. 월급의 10~20% 안에서 계좌당 금액을 역산해 정하세요.</p>
<h3>풍차돌리기는 몇 개까지 만들어야 하나요?</h3>
<p>12개월 적금이면 12개가 한 사이클이지만, 꼭 12개일 필요는 없어요. 감당할 수 있는 월 저축액을 계좌당 납입액으로 나눈 만큼만 만들면 돼요.</p>
<h3>월급 300만 원이면 풍차 몇 개가 적당한가요?</h3>
<p>저축 비율을 먼저 정하면 바로 나와요. 15%면 월 45만 원이라 계좌당 10만 원이면 4개, 12개를 채우려면 계좌당 37,500원이에요. 20%면 월 60만 원이라 10만 원짜리 6개, 또는 5만 원짜리 12개예요.</p>
<h3>이자소득세는 얼마나 떼나요?</h3>
<p>일반과세는 이자의 15.4%(소득세 14% + 지방소득세 1.4%)예요. 세금우대·비과세 상품은 가입 조건이 있으니 가입 전에 확인하세요.</p>
<h2 id="s10">정리</h2>
<p>적금 풍차돌리기는 "이자를 더 받는 방법"이 아니라 "만기가 매달 돌아오는 구조를 만드는 방법"이에요. 시작 전에 12개월째 월 납입액을 계산하고(월 저축 한도 ÷ 계좌당 납입액으로 개수 역산), 금리가 높은 상품을 고르는 일을 먼저 하세요. 내 숫자로 바로 확인하려면 <a href="https://tool.smithlab.co.kr/money/savings-pinwheel-calculator/">적금 풍차돌리기 계산기</a>에 월 납입액·금리·개수를 넣어 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>작심삼일 확률로 보면, 하루 80%여도 30일 연속은 왜 어려울까</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/habit-streak-probability-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/habit-streak-probability-guide/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>"올해는 매일 운동한다", "한 달 동안 하루도 빠짐없이 단어를 외운다." 새해가 다가오면 이런 계획을 세우고, 며칠 못 가 끊기면 "역시 나는 의지가 약해"라고 결론 내리기 쉬워요. 그런데 숫자로 보면 이야기가 달라져요. 하루에 해낼 확률이 꽤 높은 80%인 사람도 30일을 한 번도 빠지지 않고 해낼 확률은 <strong>0.1%</strong>에 불과해요. 끊기는 것이 비정상이 아니라 확률상 거의 당연한 일이라는 뜻이에요.</p>
<p>이 글은 하루 성공 확률 p로 일정 기간 동안 연속 기록이 어떻게 나오는지를 직접 계산해서 보여 줘요. 곱셈으로 끝나는 "전부 성공할 확률", 표 계산으로 구하는 "k일 연속이 한 번이라도 나올 확률", 평균적인 "기대 최장 연속"을 차례로 설명하고, 계산 결과로 목표를 어떻게 바꾸면 현실적인지까지 정리했어요. 숫자를 직접 바꿔 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/habit-streak-probability-calculator/">습관 연속 달성 확률 계산기</a>를 쓰면 돼요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>하루 확률 p로 N일 전부 성공할 확률은 p의 N제곱이라 빠르게 작아져요. 80%로 30일이면 0.1%예요.</li><li>연속이 아니라 "한 번이라도 k일 연속이 나올 확률"은 훨씬 높아요. 80%, 30일, 7일 연속이면 84.2%예요.</li><li>하루 확률을 10%포인트 올리는 게 연속 기록에는 크게 작용해요. 21일 연속은 70%에서 0.2%, 90%에서 20.8%예요.</li><li>연속 일수 대신 "기간 중 몇 번"으로 목표를 세우면 끊김이 실패가 되지 않아요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">하루 80%는 5일에 하루를 빠진다는 뜻이에요</h2>
<p>확률부터 일상 언어로 바꿔 볼게요. 하루 성공 확률 80%는 열 번 중 여덟 번 했다는 뜻이에요. 달력으로는 30일 중 24일을 한 거예요. 이 정도면 꽤 꾸준한 사람이에요. 대부분의 헬스장 회원이나 학습 앱 사용자는 이 정도도 안 되는 경우가 많아요.</p>
<p>그런데 이 "꽤 꾸준한 사람"이 30일 연속 기록을 세울 확률은 0.8을 30번 곱한 값이에요. 0.8을 두 번 곱하면 0.64, 열 번 곱하면 0.107, 서른 번 곱하면 0.0012로 줄어요. 퍼센트로는 0.12%예요. 천 번 도전하면 한두 번 성공하는 숫자예요. 그러니 연속 기록 앱의 숫자가 0으로 돌아갔다고 해서 습관이 실패한 게 아니라, 이 정도 확률에서는 평균적으로 그렇게 되는 것이 정상이에요.</p>
<h3>작심삼일 통과 확률도 의외로 낮아요</h3>
<p>처음 3일을 모두 성공할 확률은 p의 세제곱이에요. 하루 80%라면 0.8×0.8×0.8 = 51.2%예요. 시작한 사람의 절반 가까이가 3일 안에 한 번은 빠진다는 뜻이에요. 하루 60%이면 21.6%, 하루 90%이면 72.9%예요. 작심삼일이 사람 탓만이 아니라는 걸 이 숫자가 보여 줘요.</p>
<div class="table-wrap"><table><thead><tr><th>하루 확률</th><th>3일 전부</th><th>7일 전부</th><th>14일 전부</th><th>30일 전부</th></tr></thead><tbody><tr><td>60%</td><td>21.6%</td><td>2.8%</td><td>0.1%</td><td>0.00%</td></tr><tr><td>80%</td><td>51.2%</td><td>21.0%</td><td>4.4%</td><td>0.12%</td></tr><tr><td>90%</td><td>72.9%</td><td>47.8%</td><td>22.9%</td><td>4.24%</td></tr><tr><td>95%</td><td>85.7%</td><td>69.8%</td><td>48.8%</td><td>21.46%</td></tr></tbody></table></div>
<p>표를 보면 95%의 철인형도 30일 전부 성공할 확률이 21.5%로 절반이 안 돼요. "하루도 빠지지 않는다"는 목표는 거의 모든 사람에게 확률적으로 불리한 목표예요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">하루 성공 확률별 30일 전부 성공할 확률</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="하루 성공 확률별 30일 전부 성공할 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">25</text><rect class="s1" x="69.3" y="254.0" width="80.6" height="0.5" rx="3"><title>60% · 30일 전부 성공 0%</title></rect><text class="viz-val" x="109.6" y="249.0" text-anchor="middle">0</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">60%</text><rect class="s1" x="184.5" y="254.0" width="80.6" height="0.5" rx="3"><title>70% · 30일 전부 성공 0%</title></rect><text class="viz-val" x="224.8" y="249.0" text-anchor="middle">0</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">70%</text><rect class="s1" x="299.7" y="252.9" width="80.6" height="1.1" rx="3"><title>80% · 30일 전부 성공 0.12%</title></rect><text class="viz-val" x="340.0" y="247.9" text-anchor="middle">0.12</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">80%</text><rect class="s1" x="414.9" y="214.0" width="80.6" height="40.0" rx="3"><title>90% · 30일 전부 성공 4.2%</title></rect><text class="viz-val" x="455.2" y="209.0" text-anchor="middle">4.2</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">90%</text><rect class="s1" x="530.1" y="51.4" width="80.6" height="202.6" rx="3"><title>95% · 30일 전부 성공 21.5%</title></rect><text class="viz-val" x="570.4" y="46.4" text-anchor="middle">21.5</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">95%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">하루 확률이 90%여도 30일 전부 성공은 4.2%예요.</p><div class="viz-cap">단위: % · 자료: p의 30제곱을 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>30일 전부 성공</th></tr></thead><tbody><tr><td>60%</td><td>0</td></tr><tr><td>70%</td><td>0</td></tr><tr><td>80%</td><td>0.12</td></tr><tr><td>90%</td><td>4.2</td></tr><tr><td>95%</td><td>21.5</td></tr></tbody></table></div></details></figure>
<h2 id="s2">연속이 "한 번이라도" 나올 확률은 훨씬 높아요</h2>
<p>"30일 전부"와 "7일 연속이 한 번이라도"는 다른 질문이에요. 후자는 중간에 빠져도 다시 이어서 7일을 채우면 성공이에요. 이 확률은 단순한 곱셈으로는 안 구해져요. 지금 이어진 연속 일수(0일부터 k−1일까지)를 상태로 두고 하루씩 넘기는 방법으로 계산해요. 오늘 성공하면 상태가 하나 올라가고, 실패하면 0으로 돌아가요. 이렇게 N일을 넘긴 뒤 아직 k일 연속에 도달하지 못한 확률을 모두 더하고 1에서 빼요.</p>
<p>이 계산을 하루 80%, 30일, 7일 연속으로 해 보면 <strong>84.2%</strong>가 나와요. 그러니까 하루 80%를 지키는 사람은 한 달 안에 7일 연속이 거의 한 번은 나오는 거예요. 같은 사람이 21일 연속을 노리면 2.6%로 뚝 떨어져요. 짧은 연속은 흔하고, 긴 연속은 확률이 급격히 낮아져요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">하루 확률별로 30일 안에 한 번이라도 나올 연속 달성 확률</figcaption><div class="viz-legend"><span><i class="s1"></i>7일 연속</span><span><i class="s2"></i>21일 연속</span></div><svg viewBox="0 0 640 300" role="img" aria-label="하루 확률별로 30일 안에 한 번이라도 나올 연속 달성 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="69.3" y="191.2" width="39.3" height="62.8" rx="3"><title>60% · 7일 연속 26.6%</title></rect><rect class="s2" x="110.6" y="254.0" width="39.3" height="0.5" rx="3"><title>60% · 21일 연속 0%</title></rect><text class="viz-ax" x="109.6" y="272" text-anchor="middle">60%</text><rect class="s1" x="184.5" y="124.7" width="39.3" height="129.3" rx="3"><title>70% · 7일 연속 54.8%</title></rect><rect class="s2" x="225.8" y="253.5" width="39.3" height="0.5" rx="3"><title>70% · 21일 연속 0.2%</title></rect><text class="viz-ax" x="224.8" y="272" text-anchor="middle">70%</text><rect class="s1" x="299.7" y="55.3" width="39.3" height="198.7" rx="3"><title>80% · 7일 연속 84.2%</title></rect><rect class="s2" x="341.0" y="247.9" width="39.3" height="6.1" rx="3"><title>80% · 21일 연속 2.6%</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">80%</text><rect class="s1" x="414.9" y="21.3" width="39.3" height="232.7" rx="3"><title>90% · 7일 연속 98.6%</title></rect><rect class="s2" x="456.2" y="204.9" width="39.3" height="49.1" rx="3"><title>90% · 21일 연속 20.8%</title></rect><text class="viz-ax" x="455.2" y="272" text-anchor="middle">90%</text><rect class="s1" x="530.1" y="18.2" width="39.3" height="235.8" rx="3"><title>95% · 7일 연속 99.9%</title></rect><rect class="s2" x="571.4" y="137.4" width="39.3" height="116.6" rx="3"><title>95% · 21일 연속 49.4%</title></rect><text class="viz-ax" x="570.4" y="272" text-anchor="middle">95%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">21일 연속은 하루 80% 이하에서 절반 이하예요.</p><div class="viz-cap">단위: % · 자료: 상태 표 계산으로 직접 계산 (14일 이하는 전수 열거로 검산)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>7일 연속</th><th>21일 연속</th></tr></thead><tbody><tr><td>60%</td><td>26.6</td><td>0</td></tr><tr><td>70%</td><td>54.8</td><td>0.2</td></tr><tr><td>80%</td><td>84.2</td><td>2.6</td></tr><tr><td>90%</td><td>98.6</td><td>20.8</td></tr><tr><td>95%</td><td>99.9</td><td>49.4</td></tr></tbody></table></div></details></figure>
<p>하루 확률이 60%에서 70%로 10%포인트 오르면 7일 연속 확률이 27%에서 55%로, 80%에서 90%로 오르면 84%에서 99%로 올라요. 하루 확률의 작은 개선이 연속 기록에서는 큰 차이를 만든다는 걸 알 수 있어요.</p>
<h2 id="s3">기대 최장 연속은 생각보다 짧아요</h2>
<p>"한 달 동안 가장 길게 이어진 연속은 평균 며칠일까"도 계산할 수 있어요. 최장 연속이 m일 이상일 확률을 m = 1부터 N까지 더하면 평균 최장 연속이 나와요. 30일 기준으로 하루 확률별 값은 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>하루 확률</th><th>기대 최장 연속</th><th>7일 연속</th><th>14일 연속</th></tr></thead><tbody><tr><td>60%</td><td>5.6일</td><td>26.6%</td><td>0.6%</td></tr><tr><td>70%</td><td>7.4일</td><td>54.8%</td><td>3.9%</td></tr><tr><td>80%</td><td>10.3일</td><td>84.2%</td><td>18.4%</td></tr><tr><td>90%</td><td>15.8일</td><td>98.6%</td><td>58.4%</td></tr><tr><td>95%</td><td>21.0일</td><td>99.9%</td><td>85.3%</td></tr></tbody></table></div>
<p>하루 80%인 사람의 한 달 평균 최장 연속은 10.3일이에요. 30일 중 24일을 했는데도 가장 길게 이어진 구간은 열흘 안팎이라는 뜻이에요. 빠진 날이 6일 정도 흩어져 있으면 연속 구간이 6~7개로 나뉘기 때문이에요. 이걸 모르면 "24일이나 했는데 연속은 열흘뿐이네" 하고 실망하기 쉬워요. 사실은 정상적인 결과예요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">기간별로 하루 80%에서 7일 연속이 한 번이라도 나올 확률</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="기간별로 하루 80%에서 7일 연속이 한 번이라도 나올 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="570" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">7일</text><text class="viz-ax" x="181.5" y="272" text-anchor="middle">14일</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">30일</text><text class="viz-ax" x="440.5" y="272" text-anchor="middle">60일</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">100일</text><polyline class="viz-line s1" points="52.0,204.4 181.5,135.3 311.0,55.3 440.5,22.2 570.0,18.2"/><circle class="viz-dot s1" cx="52.0" cy="204.4" r="4"><title>7일 · 7일 연속이 한 번이라도 21%</title></circle><circle class="viz-dot s1" cx="181.5" cy="135.3" r="4"><title>14일 · 7일 연속이 한 번이라도 50.3%</title></circle><circle class="viz-dot s1" cx="311.0" cy="55.3" r="4"><title>30일 · 7일 연속이 한 번이라도 84.2%</title></circle><circle class="viz-dot s1" cx="440.5" cy="22.2" r="4"><title>60일 · 7일 연속이 한 번이라도 98.2%</title></circle><circle class="viz-dot s1" cx="570.0" cy="18.2" r="4"><title>100일 · 7일 연속이 한 번이라도 99.9%</title></circle><text class="viz-val" x="578.0" y="22.2">99.9</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">기간이 길수록 짧은 연속은 거의 확실히 나오지만 한 번도 안 빠지는 건 더 어려워져요.</p><div class="viz-cap">단위: % · 자료: 상태 표 계산으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>7일 연속이 한 번이라도</th></tr></thead><tbody><tr><td>7일</td><td>21</td></tr><tr><td>14일</td><td>50.3</td></tr><tr><td>30일</td><td>84.2</td></tr><tr><td>60일</td><td>98.2</td></tr><tr><td>100일</td><td>99.9</td></tr></tbody></table></div></details></figure>
<h2 id="s4">달력으로 보면 이런 모양이에요</h2>
<p>숫자만 보면 감이 안 오니까 하루 80%로 30일을 보낸 가상의 달력을 그려 볼게요. 초록은 성공, 회색은 빠진 날이에요. 이런 달력은 누를 때마다 달라지는데, 거의 모든 달력에 빠진 날이 여섯 번 안팎 섞여 있어요. 빠진 날이 하나도 없는 달력은 천 번 뽑아야 한 번 나올 정도예요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/habit-streak-probability-guide-1.svg" alt="하루 성공 확률 80%로 뽑은 30일 가상 달력. 성공은 초록, 빠진 날은 회색이고 가장 긴 연속 구간이 표시돼 있다" width="800" height="450" loading="lazy" decoding="async"><figcaption>하루 80%로 30일을 보냈을 때 빠진 날이 흩어진 모양 (예시)</figcaption></figure>
<p>계산기의 "달력 굴려 보기" 버튼을 누르면 입력한 확률로 같은 그림을 직접 만들어 볼 수 있어요. 여러 번 눌러 보면 같은 확률인데도 최장 연속이 5일일 때도 있고 15일일 때도 있다는 걸 볼 수 있어요. 이런 들쑥날쑥함이 연속 기록 앱의 숫자가 사람을 지치게 하는 이유예요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">이 숫자로 목표를 다시 세우는 법</h2>
<h3>1. "연속"을 "횟수"로 바꾸기</h3>
<p>"30일 연속 운동"은 0.1%짜리 목표지만 "30일 중 24일 운동"은 하루 80%를 그대로 쓰는 목표라서 평균적으로 달성돼요. 같은 노력을 들여도 성공 경험이 쌓여요. 횟수 목표는 빠진 날이 있어도 계속 목표 안에 있으니까 한 번 빠졌다고 포기하는 일이 줄어요.</p>
<h3>2. 쉬는 날을 계획에 넣기</h3>
<p>일주일에 하루를 쉬는 날로 정하면 7일 중 6일 하기가 목표예요. 쉬는 날은 빠진 게 아니라 계획이므로 심리적 부담이 줄어요. 달력에 미리 쉬는 날을 표시해 두면 더 효과적이에요.</p>
<h3>3. 최소 버전을 정해 하루 확률 올리기</h3>
<p>운동 30분이 어려운 날에는 스쿼트 열 개, 단어 50개가 어려운 날에는 다섯 개만 해도 성공으로 치기로 미리 정해 두세요. 이렇게 하면 하루 성공 확률이 70%에서 90%로 오를 수 있고, 계산에 따르면 30일 안에 14일 연속이 나올 확률이 4%에서 58%로 달라져요. 의지를 키우는 것보다 기준을 낮춰 확률을 올리는 쪽이 효과가 커요.</p>
<h3>4. 두 번 연속 빠지지 않기</h3>
<p>한 번 빠진 것은 확률상 흔한 일이지만, 두 번 연달아 빠지면 다시 돌아오기가 눈에 띄게 어려워져요. 그래서 "빠져도 다음 날은 반드시 한다"는 한 가지 규칙만 세워 두면 연속 기록이 끊겨도 습관 자체는 이어져요. 이 규칙은 계산에도 반영돼요. 빠진 날 다음 날의 성공 확률이 올라가면 연속 구간이 길어지고 기대 최장 연속도 늘어나요.</p>
<h3>5. 시작 기간을 현실적으로 잡기</h3>
<p>66일이나 100일 같은 긴 기간을 한 번에 목표로 잡으면 중간에 반드시 빠지는 날이 생겨요. 처음에는 14일이나 21일 단위로 쪼개서 "이번 구간에서 몇 번"을 보고, 다음 구간을 이어 가면 좋아요. 연말 목표를 쪼개서 계산하는 법은 <a href="https://tool.smithlab.co.kr/date/year-end-goal-catchup-calculator/">연말 목표 따라잡기 계산기</a>에서 볼 수 있어요.</p>
<h2 id="s6">계산이 맞지 않을 수 있는 부분</h2>
<p>이 글의 계산은 매일의 성공이 서로 영향을 주지 않는다고 가정한 수학 모델이에요. 실제 습관은 다르게 움직여요.</p>
<ul><li><strong>관성이 있어요.</strong> 어제 했으면 오늘도 하기 쉬워서 성공과 실패가 뭉쳐서 나타나는 경우가 많아요. 그러면 실제 연속 구간은 계산보다 길어지는 대신 한 번 빠지면 여러 날 이어서 빠지기도 해요.</li><li><strong>요일 효과가 있어요.</strong> 주말에 빠지기 쉽다면 하루 확률이 요일마다 달라서, 평균 확률로 계산한 값과 차이가 나요.</li><li><strong>하루 확률은 변해요.</strong> 시작 첫 주에는 동기가 높아 확률이 높고 시간이 가면서 떨어지는 경우가 많아요. 계산기에 넣는 확률은 최근 2~4주의 실제 비율로 정하는 게 좋아요.</li><li><strong>습관 형성 기간은 사람마다 달라요.</strong> "66일이면 습관이 된다" 같은 숫자는 평균 연구 결과를 요약한 것이고 개인별로 크게 차이가 나요. 이 글은 의학·심리 상담을 대신하지 않고, 확률 계산을 이해하기 위한 자료예요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">자주 묻는 질문</h2>
<h3>작심삼일은 정말 확률로 설명되나요?</h3>
<p>일부는 그래요. 하루 80%라면 처음 3일을 모두 성공할 확률이 51.2%라서, 시작한 사람의 약 절반은 3일 안에 한 번은 빠져요. 의지 문제만이 아니라 확률상 흔한 일이라는 뜻이에요. 다만 실제 습관은 관성과 요일 효과가 있어 계산과 다를 수 있어요.</p>
<h3>하루 성공 확률은 어떻게 정하면 돼요?</h3>
<p>지난 2~4주 동안 실제로 한 날이 며칠인지 세어서 전체 일수로 나누면 돼요. 30일 중 24일 했다면 80%예요. 이제 막 시작하는 습관이라면 60~70%로 보수적으로 넣어 보는 게 안전해요.</p>
<h3>연속 기록이 끊기면 처음부터 다시 해야 하나요?</h3>
<p>앱의 연속 숫자는 0으로 돌아가지만 습관 자체는 이어져 있어요. 이 글의 계산처럼 한 번도 안 빠질 확률은 원래 낮으니까, 끊긴 다음 날 바로 이어 가는 것이 더 중요해요. 연속 숫자 대신 "기간 중 몇 번"을 같이 보면 마음이 덜 흔들려요.</p>
<h3>기대 최장 연속은 한 달에 꼭 그만큼 나온다는 뜻인가요?</h3>
<p>아니에요. 같은 확률로 같은 기간을 아주 많이 반복했을 때의 평균이에요. 하루 80%, 30일이면 평균 10.3일이지만 어떤 달은 5일, 어떤 달은 20일일 수 있어요. 달력 굴려 보기 버튼으로 그 흩어진 정도를 볼 수 있어요.</p>
<h3>연속 일수와 횟수 중 무엇을 목표로 삼는 게 좋나요?</h3>
<p>대부분의 사람에게는 횟수가 더 유리해요. 빠진 날이 있어도 목표가 이어지고, 계획한 쉬는 날을 자연스럽게 넣을 수 있기 때문이에요. 연속 기록은 동기를 주는 보조 지표로만 쓰고, 성적표는 "기간 중 몇 번"으로 삼는 것을 추천해요.</p>
<h2 id="s8">참고 자료</h2>
<ul><li>하루의 성공·실패를 독립으로 보는 이 모델은 <a href="https://ko.wikipedia.org/wiki/베르누이_시행" target="_blank" rel="noopener">베르누이 시행 (위키백과)</a>에서 더 읽어 볼 수 있어요.</li><li>확률과 통계 기본 개념의 교육과정 문서는 <a href="https://ncic.re.kr" target="_blank" rel="noopener">국가교육과정정보센터</a>에서 볼 수 있어요.</li></ul>
<h2 id="s9">정리</h2>
<ul><li>N일 전부 성공할 확률은 p의 N제곱이라 하루 80%로 30일이면 0.1%, 하루 90%로도 4.2%예요.</li><li>k일 연속이 "한 번이라도" 나올 확률은 훨씬 높아요. 하루 80%, 30일, 7일 연속은 84.2%예요.</li><li>한 달 평균 최장 연속은 하루 80%에서 10.3일 정도라서 24일을 해도 연속은 열흘 안팎이에요.</li><li>연속 기록은 쉽게 끊기는 것이 정상이니, "연속"보다 "횟수" 목표, 쉬는 날 계획, 최소 버전, 두 번 연속 빠지지 않기로 설계하세요.</li><li>직접 숫자를 넣어 보려면 <a href="https://tool.smithlab.co.kr/calculator/habit-streak-probability-calculator/">습관 연속 달성 확률 계산기</a>, 매일 쌓이는 변화는 <a href="https://tool.smithlab.co.kr/calculator/habit-compound-calculator/">습관 복리 계산기</a>에서 볼 수 있어요.</li></ul>
    ]]></description>
    </item>
    <item>
      <title>LLM 서빙 엔진별 샘플링 차이 — vLLM·llama.cpp 기본값과…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/llm-serving-engine-sampling-order-defaults-migration-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/llm-serving-engine-sampling-order-defaults-migration-guide/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>transformers 로 평가하던 오픈 모델을 vLLM 이나 llama.cpp 로 옮기면 같은 프롬프트, 같은 <code>temperature</code>·<code>top_p</code> 인데도 답의 결이 달라지는 경우가 많아요. 결론부터 말하면 <strong>엔진마다 필터를 거는 순서와, 요청에 값을 안 넣었을 때 쓰는 기본값이 달라서예요.</strong> llama.cpp 는 temperature 를 맨 마지막에 적용하고, vLLM 은 min-p 를 top-p 보다 먼저 걸어요. 그래서 엔진을 바꿀 때는 숫자를 그대로 옮기지 말고 <strong>값을 전부 명시하고, 남는 후보 수로 확인해야</strong> 해요.</p>
<p>이 글은 샘플링이 어떻게 동작하는지보다, 실제로 서빙 엔진을 고르고 옮길 때 필요한 숫자를 다뤄요. 기본값과 순서는 2026년 11월 6일에 각 프로젝트의 main 브랜치 소스를 직접 열어 확인했어요. 버전이 바뀌면 달라질 수 있으니 쓰는 버전의 소스로 한 번 더 보세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>적용 순서는 transformers 가 temperature → top-k → top-p → min-p, vLLM 이 temperature → min-p → top-k → top-p, llama.cpp 가 top-k → top-p → min-p → temperature 예요.</li><li>같은 설정을 12개 후보 예시에 넣어 보면 남는 후보 수가 엔진마다 최대 3개까지 달라져요. temperature 1.5 에 min_p 0.1 이면 transformers·vLLM 은 10개, llama.cpp 는 7개였어요.</li><li>요청에 값을 안 넣으면 vLLM 은 모델의 generation_config.json 을, llama.cpp 는 자체 기본값(temperature 0.8·top_k 40·top_p 0.95·min_p 0.05)을 써요. 같은 모델이 엔진마다 다른 설정으로 돌 수 있어요.</li><li>필터 연산 비용은 top-p 가 가장 커요. 어휘 15만 개 기준 CPU 측정에서 정렬이 필요한 top-p 는 top-k 의 약 9배였어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">엔진별 샘플링 기본값 비교표</h2>
<p>요청에 아무 값도 넣지 않았을 때 쓰이는 값부터 확인해요. 여기가 다르면 같은 클라이언트 코드라도 엔진만 바꿔서 출력이 달라져요.</p>
<div class="table-wrap"><table><thead><tr><th>엔진</th><th>temperature</th><th>top_p</th><th>top_k (끄는 값)</th><th>min_p</th><th>0 을 넣으면</th></tr></thead><tbody><tr><td>transformers <code>GenerationConfig</code></td><td>1.0</td><td>1.0</td><td><strong>50</strong> (0)</td><td>없음(끔)</td><td><code>do_sample=False</code> 가 그리디</td></tr><tr><td>vLLM <code>SamplingParams</code></td><td>1.0</td><td>1.0</td><td>0 (0 또는 -1)</td><td>0.0</td><td>temperature 0 이면 그리디, top_p·top_k·min_p 초기화</td></tr><tr><td>SGLang <code>SamplingParams</code></td><td>1.0</td><td>1.0</td><td>전체 어휘 (-1)</td><td>0.0</td><td>temperature 0 이면 top_k=1 로 바꿔 그리디</td></tr><tr><td>llama.cpp (CLI·서버)</td><td><strong>0.8</strong></td><td><strong>0.95</strong></td><td><strong>40</strong> (0)</td><td><strong>0.05</strong></td><td>temperature 0 이하면 그리디</td></tr></tbody></table></div>
<p>출처는 <a href="https://huggingface.co/docs/transformers/main_classes/text_generation" target="_blank" rel="noopener">transformers 생성 설정 문서</a>, <a href="https://github.com/vllm-project/vllm/blob/main/vllm/sampling_params.py" target="_blank" rel="noopener">vLLM SamplingParams 소스</a>, <a href="https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/sampling/sampling_params.py" target="_blank" rel="noopener">SGLang SamplingParams 소스</a>, <a href="https://github.com/ggml-org/llama.cpp/blob/master/common/common.h" target="_blank" rel="noopener">llama.cpp common.h</a> 예요.</p>
<p>표에서 볼 점은 두 가지예요.</p>
<ul><li><strong>llama.cpp 는 기본값부터 샘플링을 걸어요.</strong> 다른 엔진은 기본이 "자르지 않음"인데, llama.cpp 는 아무것도 안 넣어도 top-k 40·top-p 0.95·min-p 0.05 세 개가 동시에 걸리고 temperature 도 0.8 이에요.</li><li><strong>'끔'을 뜻하는 값이 달라요.</strong> vLLM 은 top_k 0 을 권장하고 -1 도 받아요. SGLang 은 -1 이 전체 어휘예요. transformers 는 0 이 끔이고, 아무 값도 안 주면 50 이 걸려요. 클라이언트에서 top_k 를 -1 로 고정해 두면 엔진에 따라 해석이 다를 수 있어요.</li></ul>
<h2 id="s2">모델 generation_config.json 이 기본값을 덮어요</h2>
<p>엔진 기본값 위에 한 겹이 더 있어요. 모델 폴더의 <code>generation_config.json</code> 이에요. <a href="https://github.com/vllm-project/vllm/blob/main/vllm/config/model.py" target="_blank" rel="noopener">vLLM 모델 설정 소스</a>에서 <code>generation_config</code> 옵션의 기본값은 <code>"auto"</code> 이고, 이때 모델 경로의 설정을 읽어 기본 샘플링 값으로 써요. <code>"vllm"</code> 으로 주면 이 파일을 읽지 않고 엔진 기본값만 쓰고, <code>override_generation_config</code> 로 일부만 덮을 수도 있어요.</p>
<p>주요 공개 모델의 파일을 열어 보면 값이 제각각이에요(2026-11-06 확인).</p>
<div class="table-wrap"><table><thead><tr><th>모델</th><th>do_sample</th><th>temperature</th><th>top_p</th><th>top_k</th></tr></thead><tbody><tr><td><a href="https://huggingface.co/Qwen/Qwen3-8B/blob/main/generation_config.json" target="_blank" rel="noopener">Qwen3-8B</a></td><td>true</td><td>0.6</td><td>0.95</td><td>20</td></tr><tr><td><a href="https://huggingface.co/deepseek-ai/DeepSeek-R1/blob/main/generation_config.json" target="_blank" rel="noopener">DeepSeek-R1</a></td><td>true</td><td>0.6</td><td>0.95</td><td>없음</td></tr><tr><td><a href="https://huggingface.co/mistralai/Mistral-Small-3.2-24B-Instruct-2506/blob/main/generation_config.json" target="_blank" rel="noopener">Mistral-Small-3.2-24B-Instruct-2506</a></td><td>true</td><td><strong>0.15</strong></td><td>없음</td><td>없음</td></tr><tr><td><a href="https://huggingface.co/openai/gpt-oss-20b/blob/main/generation_config.json" target="_blank" rel="noopener">gpt-oss-20b</a></td><td>true</td><td>없음</td><td>없음</td><td>없음</td></tr></tbody></table></div>
<p>같은 Mistral Small 3.2 를 띄워도 vLLM(<code>auto</code>)에서는 temperature 0.15 로, llama.cpp 에서는 값을 안 넣으면 0.8 로 돌아요. 다섯 배가 넘는 차이예요. 반대로 gpt-oss-20b 처럼 파일에 샘플링 값이 없으면 vLLM 도 엔진 기본값 1.0 을 써요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p>transformers 에서 "기본값"이라고 하면 이 파일까지 합친 값이에요. 평가를 transformers 로 하고 서빙을 다른 엔진으로 한다면, <code>model.generation_config</code> 를 출력해 실제로 걸린 값을 적어 두고 서빙 요청에 그 값을 전부 명시해요.</p></aside>
<p>API 로 쓰는 상용 모델은 이 손잡이 자체를 막아 둔 경우가 많아요. 어느 API 가 temperature 를 받는지, 다수결로 여러 번 뽑을 때 비용이 얼마인지는 <a href="https://tool.smithlab.co.kr/posts/developer/llm-sampling-parameter-selection-guide/">LLM temperature 설정 고르기</a>에 따로 정리했어요. 이 글은 직접 서빙하는 경우만 다뤄요.</p>
<h2 id="s3">엔진별 샘플링 적용 순서</h2>
<p>기본값을 모두 명시해도 차이가 남는 이유가 순서예요. 각 소스에서 확인한 순서는 이래요.</p>
<ul><li><strong>transformers</strong>: <code>_get_logits_processor</code> 의 샘플링 블록이 <code>TemperatureLogitsWarper</code> → <code>TopKLogitsWarper</code> → <code>TopPLogitsWarper</code> → <code>MinPLogitsWarper</code> 순으로 붙어요(<a href="https://github.com/huggingface/transformers/blob/main/src/transformers/generation/utils.py" target="_blank" rel="noopener">generation/utils.py</a>).</li><li><strong>vLLM v1</strong>: <code>Sampler</code> 문서 주석이 패널티 → temperature → argmax 를 바꾸지 않는 로짓 프로세서(기본 min-p) → top-k·top-p 순서를 적어 두었어요(<a href="https://github.com/vllm-project/vllm/blob/main/vllm/v1/sample/sampler.py" target="_blank" rel="noopener">v1 sampler.py</a>).</li><li><strong>llama.cpp</strong>: 기본 <code>samplers</code> 목록이 penalties → dry → top_n_sigma → top_k → typ_p → top_p → min_p → xtc → <strong>temperature</strong> 예요. 서버 README 도 <code>--samplers</code> 의 기본값을 같은 순서로 적어요(<a href="https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md" target="_blank" rel="noopener">llama.cpp 서버 README</a>).</li></ul>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/llm-serving-engine-sampling-order-defaults-migration-guide-1.svg" alt="transformers·vLLM·llama.cpp 세 엔진의 샘플링 단계를 가로 띠로 나란히 놓고, temperature 와 min-p 의 위치가 엔진마다 다른 것을 색으로 표시한 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 네 단계라도 temperature(주황)와 min-p(청록) 자리가 달라요</figcaption></figure>
<p>순서 차이가 결과를 바꾸는 경우는 두 갈래예요.</p>
<ol><li><strong>temperature 가 자르기보다 앞이냐 뒤냐.</strong> transformers·vLLM 은 temperature 를 먼저 걸고 그 분포로 top-p 누적합과 min-p 문턱을 계산해요. 그래서 temperature 를 올리면 같은 top_p 로도 후보가 늘어요. llama.cpp 는 원래 분포(temperature 1)로 먼저 자르고, 살아남은 후보 안에서만 temperature 를 적용해요. 후보 수는 temperature 와 무관하고, temperature 는 남은 후보 사이의 확률 차이만 바꿔요.</li><li><strong>min-p 와 top-p 를 같이 켰을 때 어느 쪽이 먼저냐.</strong> 먼저 자른 쪽이 남은 확률을 재정규화하므로, 뒤에 오는 필터가 보는 분포가 달라져요. vLLM 은 min-p 를 먼저, transformers·llama.cpp 는 top-p 를 먼저 걸어요.</li></ol>
<h2 id="s4">같은 설정, 엔진별로 남는 후보 수 계산</h2>
<p>말로만 보면 차이가 작아 보여서 직접 계산했어요. "오늘 점심은" 다음 토큰 후보 12개에 아래 로짓을 줬어요. 실제 모델 출력이 아니라 계산용 예시예요.</p>
<div class="table-wrap"><table><thead><tr><th>후보</th><th>김치찌개</th><th>된장찌개</th><th>비빔밥</th><th>라면</th><th>짜장면</th><th>돈까스</th><th>국수</th><th>샐러드</th><th>피자</th><th>햄버거</th><th>회의</th><th>자동차</th></tr></thead><tbody><tr><td>로짓</td><td>4.0</td><td>3.6</td><td>3.2</td><td>3.0</td><td>2.6</td><td>2.3</td><td>2.0</td><td>1.6</td><td>1.2</td><td>0.9</td><td>-0.5</td><td>-1.5</td></tr><tr><td>확률(T=1)</td><td>0.306</td><td>0.205</td><td>0.138</td><td>0.113</td><td>0.076</td><td>0.056</td><td>0.041</td><td>0.028</td><td>0.019</td><td>0.014</td><td>0.003</td><td>0.001</td></tr></tbody></table></div>
<p>필터는 세 엔진의 순서를 그대로 흉내 낸 numpy 코드로 돌렸어요. top-p 는 누적 확률이 p 이상이 되는 최소 집합, min-p 는 1등 확률 × min_p 이상, top-k 는 상위 k 개를 남기는 정의 그대로예요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">같은 설정에서 엔진별로 남는 후보 수 (12개 후보 예시)</figcaption><div class="viz-legend"><span><i class="s1"></i>transformers</span><span><i class="s2"></i>vLLM</span><span><i class="s3"></i>llama.cpp</span></div><svg viewBox="0 0 640 300" role="img" aria-label="같은 설정에서 엔진별로 남는 후보 수 (12개 후보 예시)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">7.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">10</text><rect class="s1" x="66.4" y="112.4" width="21.1" height="141.6" rx="3"><title>T0.6 k20 p0.95 · transformers 6개</title></rect><rect class="s2" x="89.5" y="112.4" width="21.1" height="141.6" rx="3"><title>T0.6 k20 p0.95 · vLLM 6개</title></rect><rect class="s3" x="112.5" y="65.2" width="21.1" height="188.8" rx="3"><title>T0.6 k20 p0.95 · llama.cpp 8개</title></rect><text class="viz-ax" x="100.0" y="272" text-anchor="middle">T0.6 k20 p0.95</text><rect class="s1" x="162.4" y="65.2" width="21.1" height="188.8" rx="3"><title>T1.5 p0.9 · transformers 8개</title></rect><rect class="s2" x="185.5" y="65.2" width="21.1" height="188.8" rx="3"><title>T1.5 p0.9 · vLLM 8개</title></rect><rect class="s3" x="208.5" y="88.8" width="21.1" height="165.2" rx="3"><title>T1.5 p0.9 · llama.cpp 7개</title></rect><text class="viz-ax" x="196.0" y="272" text-anchor="middle">T1.5 p0.9</text><rect class="s1" x="258.4" y="18.0" width="21.1" height="236.0" rx="3"><title>T1.5 minp0.1 · transformers 10개</title></rect><rect class="s2" x="281.5" y="18.0" width="21.1" height="236.0" rx="3"><title>T1.5 minp0.1 · vLLM 10개</title></rect><rect class="s3" x="304.5" y="88.8" width="21.1" height="165.2" rx="3"><title>T1.5 minp0.1 · llama.cpp 7개</title></rect><text class="viz-ax" x="292.0" y="272" text-anchor="middle">T1.5 minp0.1</text><rect class="s1" x="354.4" y="136.0" width="21.1" height="118.0" rx="3"><title>T1.0 p0.8 minp0.2 · transformers 5개</title></rect><rect class="s2" x="377.5" y="159.6" width="21.1" height="94.4" rx="3"><title>T1.0 p0.8 minp0.2 · vLLM 4개</title></rect><rect class="s3" x="400.5" y="136.0" width="21.1" height="118.0" rx="3"><title>T1.0 p0.8 minp0.2 · llama.cpp 5개</title></rect><text class="viz-ax" x="388.0" y="272" text-anchor="middle">T1.0 p0.8 minp0.2</text><rect class="s1" x="450.4" y="88.8" width="21.1" height="165.2" rx="3"><title>T0.8 k40 p0.95 minp0.05 · transformers 7개</title></rect><rect class="s2" x="473.5" y="112.4" width="21.1" height="141.6" rx="3"><title>T0.8 k40 p0.95 minp0.05 · vLLM 6개</title></rect><rect class="s3" x="496.5" y="65.2" width="21.1" height="188.8" rx="3"><title>T0.8 k40 p0.95 minp0.05 · llama.cpp 8개</title></rect><text class="viz-ax" x="484.0" y="272" text-anchor="middle">T0.8 k40 p0.95 minp0.05</text><rect class="s1" x="546.4" y="183.2" width="21.1" height="70.8" rx="3"><title>T0.6 p0.8 minp0.15 · transformers 3개</title></rect><rect class="s2" x="569.5" y="183.2" width="21.1" height="70.8" rx="3"><title>T0.6 p0.8 minp0.15 · vLLM 3개</title></rect><rect class="s3" x="592.5" y="136.0" width="21.1" height="118.0" rx="3"><title>T0.6 p0.8 minp0.15 · llama.cpp 5개</title></rect><text class="viz-ax" x="580.0" y="272" text-anchor="middle">T0.6 p0.8 minp0.15</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">첫 설정은 Qwen3-8B 권장값, 다섯째는 llama.cpp 기본값이에요. 여섯 설정 모두에서 적어도 한 엔진의 후보 수가 달랐어요.</p><div class="viz-cap">단위: 개 · 자료: 위 로짓 12개에 각 엔진 소스의 적용 순서를 numpy 로 재현해 직접 계산 (2026-11-06)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>transformers</th><th>vLLM</th><th>llama.cpp</th></tr></thead><tbody><tr><td>T0.6 k20 p0.95</td><td>6</td><td>6</td><td>8</td></tr><tr><td>T1.5 p0.9</td><td>8</td><td>8</td><td>7</td></tr><tr><td>T1.5 minp0.1</td><td>10</td><td>10</td><td>7</td></tr><tr><td>T1.0 p0.8 minp0.2</td><td>5</td><td>4</td><td>5</td></tr><tr><td>T0.8 k40 p0.95 minp0.05</td><td>7</td><td>6</td><td>8</td></tr><tr><td>T0.6 p0.8 minp0.15</td><td>3</td><td>3</td><td>5</td></tr></tbody></table></div></details></figure>
<p>몇 개를 손으로 따라가 보면 이유가 보여요.</p>
<ul><li><strong>Qwen3 권장값(T 0.6·top_k 20·top_p 0.95)</strong>: transformers·vLLM 은 T=0.6 으로 뾰족해진 분포에서 누적합이 0.968 이 되는 돈까스까지 6개를 남겨요. llama.cpp 는 원래 분포에서 0.963 이 되는 샐러드까지 8개를 남긴 뒤 T=0.6 을 걸어요. 모델 제작사가 transformers 기준으로 정한 값이라면, llama.cpp 에서는 후보가 더 넓게 열려요.</li><li><strong>T 1.5·min_p 0.1</strong>: transformers·vLLM 은 T=1.5 로 평평해진 분포에서 1등 0.227 의 10%(0.0227)를 문턱으로 써서 햄버거(0.029)까지 10개가 남아요. llama.cpp 는 원래 분포의 1등 0.306 의 10%(0.0306)로 잘라 국수까지 7개만 남아요. <strong>llama.cpp 에서 temperature 를 올려도 후보는 늘지 않는다</strong>는 게 숫자로 보여요.</li><li><strong>T 1.0·top_p 0.8·min_p 0.2</strong>: 순서만 다른 경우예요. transformers 는 top-p 로 짜장면까지 5개(합 0.838)를 남기고, 그 안에서 min-p 문턱(재정규화된 1등 0.366 × 0.2 = 0.073)을 짜장면(0.091)이 넘어 5개 그대로예요. vLLM 은 min-p 를 먼저 걸어 원래 분포의 문턱 0.061 에서 돈까스(0.056)를 자르고, 남은 5개를 재정규화한 분포로 top-p 0.8 을 계산해 라면까지 4개가 남아요.</li></ul>
<p>후보 1개 차이가 작아 보여도 생성은 수백 스텝 이어져요. 스텝마다 꼬리 후보가 하나씩 더 열리거나 닫히면 긴 답에서는 문체·길이·탈선 빈도가 눈에 띄게 달라질 수 있어요. 그래서 엔진을 옮길 때는 숫자를 똑같이 맞췄다고 끝내지 말고, 아래 점검표처럼 실제 출력을 비교해야 해요.</p>
<h2 id="s5">샘플링 필터 연산 지연 — 어휘 크기별 측정</h2>
<p>샘플링 파라미터는 모델 순전파 연산량을 바꾸지 않지만, 필터 자체에도 비용이 있어요. 특히 top-p 는 누적합을 내려고 어휘 전체를 정렬해야 해요. 어휘 크기는 모델 config.json 기준으로 Qwen3 151,936개, DeepSeek-R1 129,280개, gpt-oss-20b 201,088개, Mistral Small 3.2 131,072개예요.</p>
<p>Apple M1 CPU 에서 numpy 2.4.0 으로 한 스텝(배치 1) 필터 시간을 200번 평균 내고, 이를 세 번 돌린 가운데 값을 적었어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">어휘 크기별 샘플링 필터 1스텝 시간 (M1 CPU·numpy·배치 1)</figcaption><div class="viz-legend"><span><i class="s1"></i>top-p(전체 정렬)</span><span><i class="s2"></i>top-k(부분 정렬)</span><span><i class="s3"></i>min-p</span><span><i class="s4"></i>argmax(그리디)</span></div><svg viewBox="0 0 640 300" role="img" aria-label="어휘 크기별 샘플링 필터 1스텝 시간 (M1 CPU·numpy·배치 1)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">40</text><rect class="s1" x="73.6" y="221.6" width="23.7" height="32.4" rx="3"><title>3만2768개 · top-p(전체 정렬) 5.5ms</title></rect><rect class="s2" x="99.3" y="251.2" width="23.7" height="2.8" rx="3"><title>3만2768개 · top-k(부분 정렬) 0.47ms</title></rect><rect class="s3" x="125.0" y="250.6" width="23.7" height="3.4" rx="3"><title>3만2768개 · min-p 0.57ms</title></rect><rect class="s4" x="150.7" y="253.7" width="23.7" height="0.5" rx="3"><title>3만2768개 · argmax(그리디) 0.05ms</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">3만2768개</text><rect class="s1" x="217.6" y="114.8" width="23.7" height="139.2" rx="3"><title>12만9280개 · top-p(전체 정렬) 23.6ms</title></rect><rect class="s2" x="243.3" y="237.3" width="23.7" height="16.7" rx="3"><title>12만9280개 · top-k(부분 정렬) 2.8ms</title></rect><rect class="s3" x="269.0" y="241.7" width="23.7" height="12.3" rx="3"><title>12만9280개 · min-p 2.1ms</title></rect><rect class="s4" x="294.7" y="252.6" width="23.7" height="1.4" rx="3"><title>12만9280개 · argmax(그리디) 0.24ms</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">12만9280개</text><rect class="s1" x="361.6" y="87.6" width="23.7" height="166.4" rx="3"><title>15만1936개 · top-p(전체 정렬) 28.2ms</title></rect><rect class="s2" x="387.3" y="235.8" width="23.7" height="18.2" rx="3"><title>15만1936개 · top-k(부분 정렬) 3.1ms</title></rect><rect class="s3" x="413.0" y="240.7" width="23.7" height="13.3" rx="3"><title>15만1936개 · min-p 2.2ms</title></rect><rect class="s4" x="438.7" y="252.3" width="23.7" height="1.7" rx="3"><title>15만1936개 · argmax(그리디) 0.28ms</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">15만1936개</text><rect class="s1" x="505.6" y="27.4" width="23.7" height="226.6" rx="3"><title>20만1088개 · top-p(전체 정렬) 38.4ms</title></rect><rect class="s2" x="531.3" y="240.4" width="23.7" height="13.6" rx="3"><title>20만1088개 · top-k(부분 정렬) 2.3ms</title></rect><rect class="s3" x="557.0" y="235.9" width="23.7" height="18.1" rx="3"><title>20만1088개 · min-p 3.1ms</title></rect><rect class="s4" x="582.7" y="252.1" width="23.7" height="1.9" rx="3"><title>20만1088개 · argmax(그리디) 0.32ms</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">20만1088개</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">15만 개에서 top-p 는 top-k 의 약 9배, argmax 의 약 100배였어요. 어휘가 커질수록 정렬 비용이 가장 빨리 늘어요.</p><div class="viz-cap">단위: ms · 자료: 정규분포 무작위 로짓으로 직접 측정 (2026-11-06). GPU 서빙 시간과는 다르고, 방식 사이 상대 비교용이에요.</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>top-p(전체 정렬)</th><th>top-k(부분 정렬)</th><th>min-p</th><th>argmax(그리디)</th></tr></thead><tbody><tr><td>3만2768개</td><td>5.5</td><td>0.47</td><td>0.57</td><td>0.05</td></tr><tr><td>12만9280개</td><td>23.6</td><td>2.8</td><td>2.1</td><td>0.24</td></tr><tr><td>15만1936개</td><td>28.2</td><td>3.1</td><td>2.2</td><td>0.28</td></tr><tr><td>20만1088개</td><td>38.4</td><td>2.3</td><td>3.1</td><td>0.32</td></tr></tbody></table></div></details></figure>
<p>이 숫자를 GPU 서빙에 그대로 옮기면 안 돼요. GPU 에서는 이 연산이 훨씬 빠르고, 엔진도 정렬을 피하는 커널을 써요. vLLM 은 CUDA 에서 하드웨어가 지원하면 FlashInfer 샘플러로 top-k·top-p 를 처리하고, 이 동작은 <code>VLLM_USE_FLASHINFER_SAMPLER</code> 환경 변수로 끌 수 있어요(기본값 켬, <a href="https://github.com/vllm-project/vllm/blob/main/vllm/v1/sample/ops/topk_topp_sampler.py" target="_blank" rel="noopener">vLLM top-k·top-p 샘플러 소스</a>).</p>
<p>실무에서 볼 점은 이래요.</p>
<ul><li><strong>CPU 로 돌리는 llama.cpp·소형 서버</strong>에서는 필터 비용이 토큰당 시간에 직접 더해져요. llama.cpp 기본 체인은 top-k 40 이 top-p 보다 먼저 걸려 정렬할 후보가 40개로 줄어 있어요. top_k 를 0 으로 끄고 top_p 만 쓰면 정렬 대상이 어휘 전체가 돼요.</li><li><strong>GPU 서빙</strong>에서는 필터 비용보다 배치 크기·KV 캐시가 처리량을 좌우해요. 동시 요청 수 계산은 <a href="https://tool.smithlab.co.kr/posts/developer/vllm-kv-cache-memory-capacity-cost-guide/">vLLM KV 캐시 용량 계산</a>을 참고하세요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">상황별 엔진·설정 추천</h2>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>추천</th><th>이유</th></tr></thead><tbody><tr><td>transformers 로 평가 → vLLM 으로 서빙</td><td>같은 값을 명시, min-p 와 top-p 중 하나만</td><td>둘 다 temperature 먼저라 비슷하지만, min-p·top-p 를 같이 켜면 순서가 달라요</td></tr><tr><td>transformers 로 평가 → llama.cpp 로 배포</td><td><code>--samplers</code> 로 temperature 를 앞에 두거나, 출력 비교 후 값 재조정</td><td>기본 체인은 temperature 가 마지막이라 같은 값이라도 후보 수가 달라요</td></tr><tr><td>llama.cpp 를 기본값으로 바로 띄움</td><td>temperature·top_k·top_p·min_p 를 모델 권장값으로 모두 명시</td><td>기본값이 세 필터를 동시에 걸어 모델 제작사 설정과 달라요</td></tr><tr><td>모델 제작사 권장값이 있음</td><td>vLLM <code>generation_config auto</code> 유지, 다른 엔진은 그 값을 복사</td><td>파일 값이 곧 제작사가 검증한 설정이에요</td></tr><tr><td>엔진 기본값으로만 돌리고 싶음</td><td>vLLM <code>--generation-config vllm</code></td><td>모델 파일 값이 몰래 걸리지 않아요</td></tr><tr><td>평가·회귀 테스트 재현이 필요</td><td>그리디 + 응답 저장, 필요하면 vLLM 배치 불변 모드</td><td>샘플링을 켜면 엔진마다 결과가 달라져 비교가 안 돼요</td></tr></tbody></table></div>
<p>재현성 쪽은 <a href="https://docs.vllm.ai/en/latest/features/batch_invariance.html" target="_blank" rel="noopener">vLLM 배치 불변성 문서</a>에 따르면 <code>VLLM_BATCH_INVARIANT=1</code> 로 켜고, 연산 능력 8.0 이상 NVIDIA GPU·일부 AMD Instinct GPU·Triton 을 지원하는 Intel XPU 에서 돌아가요. 2026년 11월 기준 문서에 베타로 표시돼 있어요.</p>
<p>양자화 모델(GGUF 등)로 llama.cpp 를 쓸 계획이라면 형식별 메모리와 속도 차이는 <a href="https://tool.smithlab.co.kr/posts/developer/llm-quantization-format-selection-gpu-cost-guide/">LLM 양자화 형식 고르기</a>에 정리했어요.</p>
<h2 id="s7">서빙 엔진 바꿀 때 샘플링 점검표</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/llm-serving-engine-sampling-order-defaults-migration-guide-2.svg" alt="엔진 전환 전·전환 중·전환 후 세 칸에 나눠, 기본값 기록, 값 전부 명시, 순서 확인, 출력 비교, 운영 지표 감시 항목을 체크 상자로 늘어놓은 점검표" width="800" height="450" loading="lazy" decoding="async"><figcaption>왼쪽 칸부터 순서대로 확인해요</figcaption></figure>
<ol><li><strong>지금 실제로 걸린 값을 적어요.</strong> transformers 는 <code>model.generation_config</code>, vLLM 은 서버 시작 로그와 <code>--generation-config</code> 설정, llama.cpp 는 <code>--samplers</code> 와 네 값을 기록해요. 두 설정을 JSON 으로 뽑아 <a href="https://tool.smithlab.co.kr/developer/json-diff/">JSON 비교 도구</a>로 나란히 보면 빠진 값이 바로 보여요.</li><li><strong>새 엔진 요청에 네 값을 전부 넣어요.</strong> temperature·top_p·top_k·min_p 를 모두 명시하고, "끔"은 엔진이 권장하는 값(vLLM top_k 0, SGLang -1, llama.cpp 0)으로 넣어요.</li><li><strong>자르기 필터는 하나만 켜요.</strong> top-p 와 min-p 중 하나만 쓰면 순서 차이가 사라져요. top-k 를 같이 쓴다면 큰 값(예: 40 이상)으로 두어 안전장치 역할만 하게 해요.</li><li><strong>temperature 위치를 확인해요.</strong> llama.cpp 에서 transformers 와 같은 동작을 원하면 <code>--samplers</code> 순서에서 temperature 를 자르기 단계 앞으로 옮기고, 그 뒤에 출력을 비교해요.</li><li><strong>출력을 숫자로 비교해요.</strong> 같은 프롬프트 수십 개를 두 엔진에서 여러 번씩 생성해 평균 응답 길이, 길이 한도에 걸린 비율, 형식 실패율, 서로 다른 답의 개수를 봐요. 감으로 "비슷하다"고 넘기지 않아요.</li><li><strong>logprobs 로 후보를 직접 봐요.</strong> 두 엔진 모두 상위 토큰 확률을 돌려주면 한 스텝에서 몇 개가 남는지 확인할 수 있어요. vLLM 은 <code>logprobs</code> 를 처리 전·후 어느 값으로 줄지 옵션이 있으니 어떤 모드인지 같이 적어요.</li><li><strong>엔진 버전을 고정해요.</strong> 기본값과 순서는 소스에서 바뀔 수 있어요. 버전을 올릴 때마다 1번 기록을 다시 떠서 비교해요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>vLLM 에서 temperature 를 안 넣었는데 1.0 이 아닌 것 같아요.</h3>
<p><code>--generation-config</code> 의 기본값이 <code>auto</code> 라서 모델 폴더의 generation_config.json 값이 기본 샘플링 값으로 걸려요. Qwen3-8B 라면 temperature 0.6·top_p 0.95·top_k 20 이 걸려요. 엔진 기본값을 쓰려면 <code>--generation-config vllm</code> 으로 띄우거나 요청에 값을 직접 넣으세요.</p>
<h3>llama.cpp 에서 temperature 를 올려도 답이 별로 다양해지지 않아요.</h3>
<p>기본 sampler 순서에서 temperature 가 맨 마지막이라, top_k 40·top_p 0.95·min_p 0.05 가 원래 분포로 먼저 후보를 정해요. temperature 는 남은 후보 사이 확률만 고르게 만들 뿐 후보를 더하지 않아요. 더 열고 싶다면 min_p·top_p 를 완화하거나 <code>--samplers</code> 에서 temperature 를 앞으로 옮겨요.</p>
<h3>min_p 와 top_p 를 같이 쓰면 안 되나요?</h3>
<p>쓸 수는 있지만 엔진마다 적용 순서가 달라 결과가 달라져요. 위 예시에서 top_p 0.8·min_p 0.2 는 transformers 에서 5개, vLLM 에서 4개가 남았어요. 여러 엔진을 오갈 계획이면 하나만 켜는 쪽이 비교와 재현이 쉬워요.</p>
<h3>temperature 0 이면 엔진을 바꿔도 같은 답이 나오나요?</h3>
<p>세 엔진 모두 temperature 0 을 그리디로 처리하지만, 모델 연산 커널과 배치 구성이 달라 로짓이 미세하게 달라지고 1·2등이 가까운 자리에서 답이 갈릴 수 있어요. 같은 엔진 안에서도 배치 크기에 따라 달라질 수 있어 vLLM 은 별도의 배치 불변 모드를 두고 있어요. 회귀 테스트는 응답을 저장해 비교하는 편이 확실해요.</p>
<h3>필터 연산 때문에 top-p 대신 top-k 를 써야 하나요?</h3>
<p>GPU 서빙에서는 엔진이 정렬을 피하는 커널을 쓰므로 품질 기준으로 고르면 돼요. CPU 에서 큰 어휘 모델을 돌린다면 top-k 를 큰 값으로 먼저 걸어 정렬 대상을 줄이는 llama.cpp 기본 구성이 비용 면에서 유리해요. 측정에서 15만 개 어휘의 전체 정렬은 부분 정렬보다 약 9배 느렸어요.</p>
<h2 id="s9">정리</h2>
<ul><li>적용 순서가 엔진마다 달라요. transformers·vLLM 은 temperature 를 먼저, llama.cpp 는 맨 나중에 걸고, min-p 와 top-p 의 앞뒤는 vLLM 만 반대예요.</li><li>기본값도 달라요. llama.cpp 는 아무것도 안 넣어도 temperature 0.8·top_k 40·top_p 0.95·min_p 0.05 이고, vLLM 은 모델의 generation_config.json(Mistral Small 3.2 는 temperature 0.15)을 먼저 읽어요.</li><li>12개 후보 예시에서 여섯 설정 모두 엔진에 따라 후보 수가 달라졌어요. 엔진을 바꿀 때는 값을 전부 명시하고, 자르기 필터는 하나만 켜고, 출력 지표로 비교하세요.</li><li>필터 비용은 top-p 정렬이 가장 커요(CPU 측정에서 top-k 의 약 9배). GPU 서빙은 전용 커널이 처리하니, CPU 배포에서만 비용을 따져 보면 돼요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/llm-sampling-temperature-top-p-top-k" target="_blank" rel="noopener">LLM 샘플링 원리 — temperature·top-p·top-k</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>스프링 배치 retry·skip 고르는 법, 예외 분류표와 재시도 비용</title>
      <link>https://tool.smithlab.co.kr/posts/developer/spring-batch-retry-skip-policy-exception-selection-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/spring-batch-retry-skip-policy-exception-selection-guide/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>배치에 <code>faultTolerant()</code> 를 붙이면 어떤 예외를 <code>retry</code>·<code>skip</code> 에 넣을지, 몇 번·몇 초 간격으로 다시 할지, <code>skipLimit</code> 은 몇으로 둘지 정해야 해요. 결론부터 말하면 <strong>retry 는 외부 API·네트워크처럼 트랜잭션 밖의 일시 장애에만 쓰고, skip 은 "이 한 건을 빼도 결과가 맞는" 데이터 오류에만 쓰며, 둘 다 아니면 빨리 실패시키는 게 기본이에요.</strong> Spring Batch 6 에서는 재시도가 롤백 없이 같은 트랜잭션 안에서 일어나서, 5.x 시절 감각으로 DB 오류를 retry 에 넣으면 기대와 다르게 움직여요.</p>
<p>이 글은 <strong>값을 고르는 기준과 운영에서 볼 순서</strong>를 다뤄요. 기준은 Spring Batch 6.0.6, Spring Framework 7.0.10 소스와 <a href="https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/retry-logic.html" target="_blank" rel="noopener">Spring Batch 레퍼런스의 retry 설정 장</a>이고, 시간·호출 수는 조건을 밝힌 직접 계산이에요(2026-11-06). 청크 크기와 재시작 위치는 <a href="https://tool.smithlab.co.kr/posts/developer/spring-batch-chunk-size-restart-skip-settings-guide/">스프링 배치 청크 크기 정하는 법</a>에서 따로 다뤘어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>6.0 의 <code>retryLimit(3)</code> 은 첫 시도 포함 4번 호출하고, 사이마다 기본 1초를 기다려요. 그동안 청크 트랜잭션과 행 락을 계속 쥐고 있어요.</li><li>재시도는 같은 트랜잭션에서 라이터를 다시 부르는 방식이라, 한 번 오류가 나면 트랜잭션을 막아 버리는 DB(PostgreSQL 등) 오류에는 효과가 거의 없어요. 외부 API 호출에 맞아요.</li><li>skipLimit 은 "예상 불량 수 + 3×√예상 불량 수" 정도로 잡으면 정상 변동으로 멈출 확률이 0.2% 아래예요.</li><li><code>onSkipInWrite</code> 는 곧 롤백될 트랜잭션 안에서 불려서, 같은 DB 에 쓴 skip 기록은 사라져요. REQUIRES_NEW 나 별도 저장소로 남겨요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">예외 분류표, retry·skip·즉시 실패 중 어디로 보낼까</h2>
<p><a href="https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/configuring-skip.html" target="_blank" rel="noopener">skip 설정 문서</a>도 skip 여부는 데이터의 의미를 아는 사람이 정할 일이라고 적어요. 같은 예외라도 업무에 따라 갈래가 달라져요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/spring-batch-retry-skip-policy-exception-selection-guide-1.svg" alt="같은 입력을 다시 하면 성공할 수 있으면 retry, 한 건만 빼고 커밋해도 결과가 맞으면 skip, 둘 다 아니면 즉시 실패로 보내는 판단 순서도" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 물어 처음 &#x27;예&#x27;가 나온 칸으로 보내요</figcaption></figure>
<p>DB 예외는 스프링이 번역한 <code>DataAccessException</code> 계열 이름이에요.</p>
<div class="table-wrap"><table><thead><tr><th>예외</th><th>갈래</th><th>이유</th></tr></thead><tbody><tr><td>외부 API 의 503·429·연결 끊김</td><td>retry</td><td>잠시 뒤 성공 가능, DB 트랜잭션과 무관</td></tr><tr><td><code>QueryTimeoutException</code>, <code>TransientDataAccessResourceException</code></td><td>retry 후보</td><td>일시적이지만 오류 뒤 트랜잭션 상태가 DB 마다 다름(아래 절)</td></tr><tr><td><code>PessimisticLockingFailureException</code>(교착·락 획득 실패)</td><td>조건부 retry</td><td>같은 트랜잭션 재시도가 막히는 DB 가 있음</td></tr><tr><td><code>OptimisticLockingFailureException</code></td><td>retry 말고 재읽기</td><td>같은 낡은 버전으로 다시 써도 또 충돌</td></tr><tr><td><code>FlatFileParseException</code>, 검증 실패</td><td>skip</td><td>몇 번 해도 같은 결과</td></tr><tr><td><code>DataIntegrityViolationException</code>(중복 키 등)</td><td>skip 또는 실패</td><td>중복이 정상 상황인지 업무로 판단</td></tr><tr><td>외부 API 400·401·403·404</td><td>실패(400·404 는 skip 가능)</td><td>인증·설정 오류는 다음 건도 똑같이 실패</td></tr><tr><td><code>OutOfMemoryError</code> 등 <code>Error</code></td><td>실패</td><td>빌더 기본 정책도 <code>Exception</code> 만 대상</td></tr></tbody></table></div>
<p><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/TransientDataAccessException.html" target="_blank" rel="noopener">TransientDataAccessException 문서</a>에 일시적 DB 예외의 하위 클래스 목록이 있어요. <code>DeadlockLoserDataAccessException</code> 은 스프링 6.0.3 부터 deprecated 라 새 코드에는 부모인 <code>PessimisticLockingFailureException</code> 을 적어요. 외부 API 의 상태 코드가 어느 쪽인지 헷갈리면 <a href="https://tool.smithlab.co.kr/developer/http-status-code-lookup/">HTTP 상태 코드 조회</a>에서 뜻과 흔한 원인을 확인하고 갈래를 정해요.</p>
<h2 id="s2">retry 설정값 고르는 법, retryLimit 대신 RetryPolicy</h2>
<p>6.0 빌더의 <code>retry()</code>·<code>retryLimit()</code> 은 결국 Spring Framework 7 의 <code>RetryPolicy</code> 하나를 만드는 편의 메서드예요. 조합별 동작은 <a href="https://github.com/spring-projects/spring-batch/blob/v6.0.6/spring-batch-core/src/main/java/org/springframework/batch/core/step/builder/ChunkOrientedStepBuilder.java" target="_blank" rel="noopener">ChunkOrientedStepBuilder 소스</a>의 <code>build()</code> 에서 확인할 수 있어요.</p>
<div class="table-wrap"><table><thead><tr><th>설정</th><th>실제 호출 횟수</th><th>대기</th><th>대상 예외</th></tr></thead><tbody><tr><td>아무것도 안 줌</td><td>1번</td><td>없음</td><td>없음(첫 실패가 곧 skip 판정)</td></tr><tr><td><code>retry(X.class)</code> 만</td><td>1번</td><td>없음</td><td>X 지만 재시도 0회</td></tr><tr><td><code>retryLimit(3)</code> 만</td><td>4번</td><td>1초 고정</td><td><code>Exception</code> 전부</td></tr><tr><td><code>retry(X.class).retryLimit(3)</code></td><td>4번</td><td>1초 고정</td><td>X 와 원인 체인</td></tr><tr><td><code>RetryPolicy.builder().includes(X).build()</code></td><td>4번</td><td>1초 고정</td><td>X (maxRetries 기본 3)</td></tr><tr><td>빌더에 <code>maxRetries</code>·<code>delay</code>·<code>multiplier</code> 지정</td><td>maxRetries+1</td><td>직접 지정</td><td>직접 지정</td></tr></tbody></table></div>
<p>두 번째 줄이 가장 흔한 실수예요. 예외만 적고 횟수를 빼면 재시도 0회예요. <a href="https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java" target="_blank" rel="noopener">RetryPolicy 소스</a>는 "총 시도 = 첫 시도 1 + maxRetries" 라고 주석에 적고 있고, <code>includes</code> 는 예외의 원인(cause)까지 따라가며 맞춰 봐요.</p>
<p>출발점으로는 재시도 2회, 100ms 에서 두 배씩, 대기 최대 500ms, 전체 3초 상한을 권해요.</p>
<pre class="code-block" data-lang="java"><code>RetryPolicy retryPolicy = RetryPolicy.builder()
        .includes(TransientApiException.class, QueryTimeoutException.class)
        .excludes(OptimisticLockingFailureException.class)
        .maxRetries(2)                          // 총 3번 호출
        .delay(Duration.ofMillis(100))
        .multiplier(2)
        .maxDelay(Duration.ofMillis(500))
        .timeout(Duration.ofSeconds(3))         // 대기 포함 상한
        .build();

Step step = new StepBuilder(&quot;importStep&quot;, jobRepository)
        .&lt;Row, Order&gt;chunk(200)
        .transactionManager(transactionManager)
        .reader(reader).processor(processor).writer(writer)
        .faultTolerant()
        .retryPolicy(retryPolicy)
        .skipPolicy(new DataErrorSkipPolicy(150))
        .skipListener(skipRecorder)
        .build();</code></pre>
<p><code>timeout</code> 은 첫 호출부터 흐른 시간 기준이고, <code>RetryTemplate</code> 이 대기 전에 "기다리면 넘는지" 미리 계산해 포기해요. 느린 호출이 섞여도 트랜잭션 길이에 상한이 생겨요. <code>retryPolicy()</code> 를 주면 <code>retry()</code>·<code>retryLimit()</code> 은 무시돼요.</p>
<h2 id="s3">재시도 비용 직접 계산, 트랜잭션 길이와 중복 호출</h2>
<h3>재시도가 트랜잭션을 쥐는 시간</h3>
<p>5.x 는 쓰기 실패 때 롤백하고 다음 트랜잭션에서 다시 시도했지만, 6.0 은 같은 트랜잭션 안에서 잠들었다가 다시 불러요. 실패가 <strong>트랜잭션 하나의 길이</strong>로 그대로 더해져요.</p>
<p>계산식은 <code>트랜잭션 길이 = (maxRetries + 1) × 쓰기 1회 시간 + 대기 시간 합</code> 이에요. 대기 합은 고정 간격이면 <code>maxRetries × delay</code>, 배수면 <code>delay × (1 + m + m² + …)</code> 이고 <code>maxDelay</code> 에서 잘려요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">계속 실패하는 청크 하나가 트랜잭션을 쥐는 시간 (쓰기 1회 0.3초 가정)</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="계속 실패하는 청크 하나가 트랜잭션을 쥐는 시간 (쓰기 1회 0.3초 가정)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">6.0 maxRetries 2 + 100ms</text><rect class="s1" x="220" y="15" width="48.1" height="20" rx="3"><title>6.0 maxRetries 2 + 100ms · 1.1초</title></rect><text class="viz-val" x="274.1" y="29">1.1초</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">6.0 maxRetries 3 + 100ms x2</text><rect class="s1" x="220" y="49" width="83.1" height="20" rx="3"><title>6.0 maxRetries 3 + 100ms x2 · 1.9초</title></rect><text class="viz-val" x="309.1" y="63">1.9초</text><text class="viz-ax lg" x="210" y="97" text-anchor="end">6.0 maxRetries 5 + 200ms x2 + 최대 500ms</text><rect class="s1" x="220" y="83" width="170.6" height="20" rx="3"><title>6.0 maxRetries 5 + 200ms x2 + 최대 500ms · 3.9초</title></rect><text class="viz-val" x="396.6" y="97">3.9초</text><text class="viz-ax lg" x="210" y="131" text-anchor="end">6.0 retryLimit(3) 기본</text><rect class="s1" x="220" y="117" width="183.8" height="20" rx="3"><title>6.0 retryLimit(3) 기본 · 4.2초</title></rect><text class="viz-val" x="409.8" y="131">4.2초</text><text class="viz-ax lg" x="210" y="165" text-anchor="end">6.0 maxRetries 5 + 200ms x2</text><rect class="s1" x="220" y="151" width="350.0" height="20" rx="3"><title>6.0 maxRetries 5 + 200ms x2 · 8초</title></rect><text class="viz-val" x="576.0" y="165">8초</text></svg><p class="viz-note">같은 5회 재시도라도 maxDelay 500ms 를 걸면 8.0초가 3.9초로 줄어요</p><div class="viz-cap">단위: 초 · 자료: (maxRetries+1)×0.3초 + 대기 합으로 직접 계산. 5.x retryLimit(3)·대기 없음이면 시도마다 롤백돼 트랜잭션 하나는 0.3초</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>트랜잭션 길이</th></tr></thead><tbody><tr><td>6.0 maxRetries 2 + 100ms</td><td>1.1</td></tr><tr><td>6.0 maxRetries 3 + 100ms x2</td><td>1.9</td></tr><tr><td>6.0 maxRetries 5 + 200ms x2 + 최대 500ms</td><td>3.9</td></tr><tr><td>6.0 retryLimit(3) 기본</td><td>4.2</td></tr><tr><td>6.0 maxRetries 5 + 200ms x2</td><td>8</td></tr></tbody></table></div></details></figure>
<p>그동안 청크가 쓴 행의 락과 커넥션이 묶여요. 풀이 10개인데 파티션 10개가 같은 장애로 재시도에 들어가면 풀 전체가 최대 4.2초씩 잠겨요. 락 오류를 재시도하면 락 보유 시간이 늘어나는 셈이라, 교착이 잦은 테이블은 재시도보다 <strong>쓰는 순서를 키 순으로 맞추고 청크를 줄이는 쪽</strong>이 먼저예요. 격리 수준과 재시도의 관계는 <a href="https://tool.smithlab.co.kr/posts/developer/transaction-isolation-level-selection-retry-checklist/">트랜잭션 격리 수준 고르는 법</a>에 정리했어요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p>PostgreSQL 은 트랜잭션 안에서 오류가 한 번 나면 그 트랜잭션을 "aborted" 상태로 두고, 롤백하거나 세이브포인트로 돌아가기 전까지 이후 명령을 거부해요(<a href="https://www.postgresql.org/docs/current/tutorial-transactions.html" target="_blank" rel="noopener">PostgreSQL 트랜잭션 문서</a>). 그래서 6.0 의 제자리 쓰기 재시도는 PostgreSQL 쓰기 오류를 되살리지 못하고 다른 예외로 다시 실패해요. 쓰는 DB 가 오류 뒤 트랜잭션을 어떻게 다루는지 먼저 확인하고 DB 예외를 retry 목록에 넣어요.</p></aside>
<p>JPA 도 flush 가 실패하면 트랜잭션이 rollback-only 가 되고, 재시도한 <code>write()</code> 가 성공해도 6.0.6 은 청크를 실패로 처리해요(<a href="https://github.com/spring-projects/spring-batch/blob/v6.0.6/spring-batch-core/src/main/java/org/springframework/batch/core/step/item/ChunkOrientedStep.java" target="_blank" rel="noopener">ChunkOrientedStep 소스</a>의 <code>isRollbackOnly</code> 검사). flush 동작은 <a href="https://tool.smithlab.co.kr/posts/developer/jpa-dirty-checking-flush-mode-persistence-context-sizing-guide/">JPA 더티 체킹과 플러시 모드 글</a>에 정리했어요.</p>
<h3>라이터 재시도의 중복 호출</h3>
<p>외부 API 를 한 건씩 부르는 라이터는 retry 가 맞는 곳이지만, 재시도가 <strong>청크 전체</strong>로 <code>write()</code> 를 다시 부르니 실패한 건 앞의 성공한 호출이 매번 다시 나가요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">청크 마지막 건이 계속 실패할 때 이미 성공한 건에 다시 나가는 호출 수</figcaption><div class="viz-legend"><span><i class="s1"></i>maxRetries 1</span><span><i class="s2"></i>maxRetries 2</span><span><i class="s3"></i>maxRetries 3</span></div><svg viewBox="0 0 640 300" role="img" aria-label="청크 마지막 건이 계속 실패할 때 이미 성공한 건에 다시 나가는 호출 수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">250</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">500</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">750</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">1,250</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">1,500</text><rect class="s1" x="73.6" y="252.6" width="32.3" height="1.4" rx="3"><title>청크 10 · maxRetries 1 9회</title></rect><rect class="s2" x="107.9" y="251.2" width="32.3" height="2.8" rx="3"><title>청크 10 · maxRetries 2 18회</title></rect><rect class="s3" x="142.1" y="249.8" width="32.3" height="4.2" rx="3"><title>청크 10 · maxRetries 3 27회</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">청크 10</text><rect class="s1" x="217.6" y="246.3" width="32.3" height="7.7" rx="3"><title>청크 50 · maxRetries 1 49회</title></rect><rect class="s2" x="251.9" y="238.6" width="32.3" height="15.4" rx="3"><title>청크 50 · maxRetries 2 98회</title></rect><rect class="s3" x="286.1" y="230.9" width="32.3" height="23.1" rx="3"><title>청크 50 · maxRetries 3 147회</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">청크 50</text><rect class="s1" x="361.6" y="238.4" width="32.3" height="15.6" rx="3"><title>청크 100 · maxRetries 1 99회</title></rect><rect class="s2" x="395.9" y="222.8" width="32.3" height="31.2" rx="3"><title>청크 100 · maxRetries 2 198회</title></rect><rect class="s3" x="430.1" y="207.3" width="32.3" height="46.7" rx="3"><title>청크 100 · maxRetries 3 297회</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">청크 100</text><rect class="s1" x="505.6" y="175.5" width="32.3" height="78.5" rx="3"><title>청크 500 · maxRetries 1 499회</title></rect><rect class="s2" x="539.9" y="97.0" width="32.3" height="157.0" rx="3"><title>청크 500 · maxRetries 2 998회</title></rect><rect class="s3" x="574.1" y="18.5" width="32.3" height="235.5" rx="3"><title>청크 500 · maxRetries 3 1,497회</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">청크 500</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">실패 위치가 고르게 흩어지면 평균은 이 값의 절반이에요</p><div class="viz-cap">단위: 회 · 자료: maxRetries × (청크 크기 − 1) 로 직접 계산. 재시도를 다 쓴 뒤 skip 으로 스캔에 들어가면 각 건이 한 번씩 더 호출됨</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>maxRetries 1</th><th>maxRetries 2</th><th>maxRetries 3</th></tr></thead><tbody><tr><td>청크 10</td><td>9</td><td>18</td><td>27</td></tr><tr><td>청크 50</td><td>49</td><td>98</td><td>147</td></tr><tr><td>청크 100</td><td>99</td><td>198</td><td>297</td></tr><tr><td>청크 500</td><td>499</td><td>998</td><td>1,497</td></tr></tbody></table></div></details></figure>
<p>청크 100, 재시도 3회면 이미 끝난 99건이 297번 더 호출돼요. 두 번 나가면 안 되는 호출이면 셋 중 하나를 골라요.</p>
<ol><li><strong>멱등 키</strong>: (잡 인스턴스, 아이템 키)로 만든 키를 보내 API 가 중복을 무시하게 해요.</li><li><strong>재시도를 라이터 안으로</strong>: 스텝 retry 는 끄고, 라이터에서 호출 한 건만 <code>RetryTemplate</code> 으로 감싸요. 앞 건이 다시 나가지 않아요.</li><li><strong>청크를 작게</strong>: 중복 범위가 청크 크기에 비례하니 외부 호출 라이터는 10~50 이 무난해요.</li></ol>
<h2 id="s4">skipLimit 정하는 법, 예상 불량 수로 계산</h2>
<p><code>skipLimit</code> 기본값은 10이고 읽기·가공·쓰기 skip 을 합쳐 세요. 너무 크면 데이터가 통째로 깨져도 COMPLETED 로 끝나요. 지난 실행들의 평균 skip 수를 λ 로 잡고 불량이 서로 독립이라고 보면, skip 수는 대략 푸아송 분포를 따라요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">예상 불량 100건인 배치가 skipLimit 을 넘겨 멈출 확률</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="예상 불량 100건인 배치가 skipLimit 을 넘겨 멈출 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="570" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="570" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="570" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">50</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">100</text><text class="viz-ax" x="181.5" y="272" text-anchor="middle">110</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">120</text><text class="viz-ax" x="440.5" y="272" text-anchor="middle">130</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">140</text><polyline class="viz-line s1" points="52.0,30.7 181.5,184.6 311.0,243.3 440.5,253.2 570.0,254.0"/><circle class="viz-dot s1" cx="52.0" cy="30.7" r="4"><title>100 · 정상 변동으로 멈출 확률 47.3%</title></circle><circle class="viz-dot s1" cx="181.5" cy="184.6" r="4"><title>110 · 정상 변동으로 멈출 확률 14.7%</title></circle><circle class="viz-dot s1" cx="311.0" cy="243.3" r="4"><title>120 · 정상 변동으로 멈출 확률 2.3%</title></circle><circle class="viz-dot s1" cx="440.5" cy="253.2" r="4"><title>130 · 정상 변동으로 멈출 확률 0.17%</title></circle><circle class="viz-dot s1" cx="570.0" cy="254.0" r="4"><title>140 · 정상 변동으로 멈출 확률 0.006%</title></circle><text class="viz-val" x="578.0" y="258.0">0.006</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">한도를 예상값과 같게 두면 두 번에 한 번꼴로 멈춰요. 130 이면 0.2% 아래예요</p><div class="viz-cap">단위: % · 자료: 평균 100 인 푸아송 분포의 P(skip 수 &gt; 한도) 를 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>정상 변동으로 멈출 확률</th></tr></thead><tbody><tr><td>100</td><td>47.3</td></tr><tr><td>110</td><td>14.7</td></tr><tr><td>120</td><td>2.3</td></tr><tr><td>130</td><td>0.17</td></tr><tr><td>140</td><td>0.006</td></tr></tbody></table></div></details></figure>
<p>그래서 <strong>한도 ≈ λ + 3√λ</strong> 가 출발점이에요. λ=100 이면 130, λ=10 이면 약 20 이에요. 이걸 넘으면 원본이 깨졌을 가능성이 커서 멈추는 게 맞아요.</p>
<p>6.0 빌더에는 5.x 의 <code>noSkip()</code> 이 없어서, 일시 장애를 skip 에서 빼려면 <code>SkipPolicy</code> 를 직접 만들어요.</p>
<pre class="code-block" data-lang="java"><code>class DataErrorSkipPolicy implements SkipPolicy {
    private final long limit;
    DataErrorSkipPolicy(long limit) { this.limit = limit; }

    @Override
    public boolean shouldSkip(Throwable t, long skipCount) {
        if (has(t, TransientDataAccessException.class)) return false; // 일시 장애는 skip 금지
        if (!has(t, FlatFileParseException.class) &amp;&amp; !has(t, ValidationException.class)) return false;
        if (skipCount &lt; 0) return true;          // 청크 쓰기 단계: 종류만 판단
        if (skipCount &gt;= limit) throw new SkipLimitExceededException(limit, t);
        return true;
    }

    private static boolean has(Throwable t, Class&lt;? extends Throwable&gt; type) {
        for (Throwable c = t; c != null; c = c.getCause()) if (type.isInstance(c)) return true;
        return false;
    }
}</code></pre>
<p><code>skipCount &lt; 0</code> 줄이 중요해요. 6.0.6 은 청크 쓰기 실패 때 스캔 여부를 정하려고 정책을 <code>-1</code> 로 한 번 부르고, 스캔에서 아이템마다 실제 skip 수로 다시 불러요. 정책 안에서 호출을 직접 세면 쓰기 skip 이 두 번 세어져요.</p>
<p>비율로 보려면 <code>afterStep</code> 에서 <code>getSkipCount() / getReadCount()</code> 를 계산해 <a href="https://docs.spring.io/spring-batch/reference/step/controlling-flow.html" target="_blank" rel="noopener">스텝 흐름 제어 문서</a>의 <code>SkipCheckingListener</code> 예제처럼 "COMPLETED WITH SKIPS" 를 돌려주고, 알림 스텝으로 분기해요.</p>
<h2 id="s5">skip 기록 남기는 법, SkipListener 트랜잭션 함정</h2>
<p>skip 된 건을 테이블에 남겨 고치는 운영이 흔한데, 콜백이 도는 트랜잭션 때문에 기록이 빠지기 쉬워요. <a href="https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/intercepting-execution.html" target="_blank" rel="noopener">스텝 실행 가로채기 문서</a>가 직접 설명하는 부분이에요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/spring-batch-retry-skip-policy-exception-selection-guide-2.svg" alt="onSkipInRead·onSkipInProcess 는 청크 트랜잭션 안에서 불려 청크가 롤백되면 기록이 사라지고, onSkipInWrite 는 곧 롤백되는 스캔 트랜잭션 안에서 불려 항상 사라지므로 REQUIRES_NEW 나 별도 저장소로 기록하라는 정리 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 DataSource 로 쓰면 두 줄 다 기록이 사라질 수 있어요</figcaption></figure>
<ul><li><code>onSkipInWrite</code>: 스캔의 한 건짜리 트랜잭션 안에서 불리고, 콜백이 끝나면 그 트랜잭션은 <strong>항상 롤백</strong>돼요. 같은 DataSource 로 INSERT 하면 반드시 사라져요.</li><li><code>onSkipInRead</code>·<code>onSkipInProcess</code>: 청크 트랜잭션 안에서 불려요. 그 청크가 쓰기 실패로 롤백되면 기록도 같이 사라지고, 스캔 때 이 건은 다시 가공되지 않아 콜백도 다시 불리지 않아요.</li></ul>
<p>그래서 기록 메서드를 <code>REQUIRES_NEW</code> 트랜잭션으로 분리하거나 로그 파일·큐에 써요. 콜백은 아이템당 한 번이 보장되지만, 실패 후 재시작으로 같은 청크를 다시 하면 또 불릴 수 있어 (잡 인스턴스 ID, 아이템 키) 유일 제약으로 중복을 막아요.</p>
<p>REQUIRES_NEW 는 청크 트랜잭션의 커넥션을 쥔 채 하나를 더 빌려요. 풀이 스레드 수만큼만 있으면 서로 기다리며 멈추니, 풀은 최소 "동시 스텝 스레드 × 2" 로 잡아요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">5.x 에서 6.0 으로 옮길 때 retry·skip 체크리스트</h2>
<p>옛 <code>chunk(n, transactionManager).faultTolerant()</code> 는 6.0 에서 제거 예정이에요. <a href="https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-6.0-Migration-Guide" target="_blank" rel="noopener">6.0 마이그레이션 가이드</a>와 소스를 대조했어요.</p>
<div class="table-wrap"><table><thead><tr><th>확인할 것</th><th>5.x</th><th>6.0</th><th>할 일</th></tr></thead><tbody><tr><td><code>retryLimit(3)</code></td><td>총 3번</td><td>총 4번</td><td>같은 횟수를 원하면 2로</td></tr><tr><td>기본 대기</td><td>없음</td><td>1초</td><td><code>delay</code>·<code>maxDelay</code> 직접 지정</td></tr><tr><td>쓰기 재시도 위치</td><td>롤백 후 다음 트랜잭션</td><td>같은 트랜잭션</td><td>DB 예외를 retry 목록에서 다시 검토</td></tr><tr><td><code>noSkip()</code>·<code>noRetry()</code></td><td>있음</td><td>빌더에 없음</td><td><code>excludes()</code>, 직접 만든 <code>SkipPolicy</code></td></tr><tr><td>리스너 예외</td><td>아이템 실패로 retry·skip</td><td>항상 스텝 실패</td><td>리스너 안에서 잡아 로그로</td></tr><tr><td><code>backOffPolicy()</code></td><td>Spring Retry</td><td>없음</td><td><code>RetryPolicy</code> 의 <code>delay</code>·<code>multiplier</code></td></tr></tbody></table></div>
<p>리스너 줄이 사고로 이어지기 쉬워요. 5.x 에서 리스너 예외가 skip 으로 넘어가던 잡이 6.0 에서는 바로 실패하니, 옮기기 전에 리스너마다 try-catch 가 있는지 훑어요.</p>
<h2 id="s7">운영 장애 확인 순서, 증상별 체크리스트</h2>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>흔한 원인</th><th>확인·조치</th></tr></thead><tbody><tr><td>업그레이드 뒤 배치가 눈에 띄게 느려짐</td><td>6.0 기본 1초 대기</td><td>재시도 로그 수 × 1초가 늘어난 시간과 맞는지</td></tr><tr><td>외부 API 호출 수가 건수보다 많음</td><td>청크 단위 제자리 재시도</td><td>위 중복 호출 계산, 멱등 키</td></tr><tr><td><code>SkipLimitExceededException</code></td><td>한도가 예상 변동보다 작음, 원본 이상</td><td>지난 실행 skip 수와 비교 후 한도 또는 원본</td></tr><tr><td>스캔 중 <code>NonSkippableWriteException</code></td><td>스캔은 재시도 없이 한 번만 씀</td><td>일시 장애가 스캔과 겹쳤는지, 재시작</td></tr><tr><td>"rollback-only" 로그와 함께 청크 실패</td><td>JPA flush 실패 뒤 재시도 성공</td><td>해당 예외를 retry 에서 빼고 skip 또는 실패로</td></tr><tr><td>skip 수는 있는데 기록 테이블이 빔</td><td>롤백되는 트랜잭션 안에서 기록</td><td>REQUIRES_NEW 또는 트랜잭션 밖 저장소</td></tr><tr><td>커넥션 획득 타임아웃</td><td>재시도 대기 중 커넥션 보유, REQUIRES_NEW 추가</td><td>대기 상한·풀 크기 재계산</td></tr></tbody></table></div>
<p><code>BATCH_STEP_EXECUTION</code> 의 단계별 skip 수와 <code>ROLLBACK_COUNT</code> 를 먼저 보면 어느 단계 문제인지 좁혀져요. 6.0.6 은 스캔에 들어가는 청크마다, 쓰기 skip 마다 롤백을 하나씩 세서 둘의 차이가 대략 스캔한 청크 수예요.</p>
<h2 id="s8">도입 전에 따질 것, faultTolerant 를 안 켜는 게 나은 경우</h2>
<ul><li><strong>한 건도 빠지면 안 되는 데이터</strong>: 정산·송금은 skip 없이, 실패하면 고친 뒤 재시작해요.</li><li><strong>불량을 미리 걸러낼 수 있을 때</strong>: 프로세서에서 <code>null</code> 을 돌려주는 필터는 롤백·스캔이 없어요.</li><li><strong>DB 쓰기만 하는 스텝</strong>: 제자리 재시도는 DB 오류에 약하니 실패 후 재시작에 맡기는 편이 단순해요.</li><li><strong>장애가 몇 분씩 가는 의존 시스템</strong>: 초 단위 재시도로는 못 버텨요. 실패시키고 스케줄러가 몇 분 뒤 재시작하게 해요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 묻는 질문</h2>
<h3>retryLimit(3) 이면 몇 번 호출되나요?</h3>
<p>6.0 에서는 4번이에요. 이 값은 <code>RetryPolicy</code> 의 <code>maxRetries</code>, 즉 첫 시도를 뺀 재시도 횟수예요. 5.x 처럼 총 3번이면 2로 바꿔요.</p>
<h3>retry 를 설정했는데 한 번도 재시도하지 않아요.</h3>
<p><code>retry(예외)</code> 만 적고 <code>retryLimit</code> 을 빠뜨렸는지 보세요. 기본값 0이라 재시도 0회 정책이 돼요. <code>faultTolerant()</code> 를 빠뜨려도 retry·skip 이 모두 무시돼요.</p>
<h3>skip 과 retry 에 같은 예외를 넣으면 어떻게 되나요?</h3>
<p>먼저 재시도하고, 다 써도 실패하면 skip 판정을 해요. 일시 장애를 두 목록에 다 넣으면 장애가 길 때 정상 데이터가 skip 으로 빠지니 skip 목록에서는 빼요.</p>
<h3>SkipListener 에서 DB 에 기록했는데 남지 않아요.</h3>
<p><code>onSkipInWrite</code> 는 곧 롤백될 트랜잭션 안에서 불려요. 기록을 REQUIRES_NEW 로 분리하거나 파일·큐에 남기고, 재시작 중복은 유일 키로 막아요.</p>
<h3>skipLimit 은 몇으로 잡아야 하나요?</h3>
<p>지난 실행 평균 skip 수 λ 에 3√λ 를 더한 값에서 시작해요. 평균 100이면 130 이에요. 처음 돌리는 잡은 작게 두고 몇 번 실행한 뒤 맞춰요.</p>
<h2 id="s10">정리</h2>
<ul><li>예외는 "다시 하면 나을까 → 빼도 결과가 맞을까" 순서로 retry·skip·즉시 실패에 나눠요.</li><li>6.0 은 <code>retryLimit(n)</code> 이 총 n+1번, 기본 대기 1초, 같은 트랜잭션 재시도예요. <code>RetryPolicy</code> 로 <code>delay</code>·<code>maxDelay</code>·<code>timeout</code> 을 직접 주고, DB 오류보다 외부 API 에 써요.</li><li>라이터 재시도는 청크 전체를 다시 부르니 <code>maxRetries × (청크 − 1)</code> 만큼 중복 호출을 각오하거나 멱등 키·라이터 안 재시도로 막아요.</li><li>skipLimit 은 λ + 3√λ, skip 기록은 REQUIRES_NEW 와 유일 키로 남겨요.</li></ul>
<h2 id="s11">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/spring-batch-skip-retry-fault-tolerant-scan" target="_blank" rel="noopener">스프링 배치 Skip·Retry 원리 — 제자리 재시도와 스캔 모드</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>스프링 시큐리티 CSRF 403 확인 순서, SPA 쿠키 설정 판단표</title>
      <link>https://tool.smithlab.co.kr/posts/developer/spring-security-csrf-403-spa-cookie-settings-checklist/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/spring-security-csrf-403-spa-cookie-settings-checklist/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>스프링 시큐리티를 붙인 뒤 GET 은 되는데 POST·PUT·DELETE 만 403 이 나면, 대부분 인가 규칙이 아니라 CSRF 검사에서 막힌 거예요. 결론부터 말하면 <strong>서버 로그로 CSRF 인지 먼저 가리고, 보낸 토큰 길이가 36자(쿠키 원본)인지 96자(마스킹 값)인지 보고, 그 값이 설정과 짝이 맞는지 확인하면</strong> 원인 대부분이 좁혀져요. 설정은 "화면을 누가 그리나"와 "SPA 와 API 가 같은 출처인가" 두 질문으로 거의 정해져요.</p>
<p>이 글은 필터 내부 대신 <strong>어떤 설정을 언제 고르고, 403 이 나면 무엇부터 보는지</strong>를 다뤄요. 기준은 Spring Security 7.1.2 이고, 숫자는 Apple M1·OpenJDK 24.0.2 에서 7.1.2 jar 의 <code>CsrfFilter</code> 를 목 요청과 쿠키를 이어 싣는 가짜 브라우저로 직접 돌려 얻었어요(2026-11-06).</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>서버가 화면을 그리면 기본값(세션 저장소 + XOR 핸들러)을 그대로 두고, 같은 출처 SPA 는 7.0 부터 생긴 csrf.spa() 한 줄을 써요.</li><li>spa() 는 헤더에 36자 쿠키 원본을 보내야 통과해요. /csrf 엔드포인트에서 받은 96자 마스킹 값을 헤더에 넣으면 InvalidCsrfTokenException 으로 403 이었어요.</li><li>GET 100번을 보내는 동안 기본값과 "쿠키 저장소 + XOR" 조합은 XSRF-TOKEN 쿠키를 한 번도 내려보내지 않았고, spa() 는 첫 응답에서 1번 내려보냈어요.</li><li>처리 비용은 마스킹 생성 약 2.4~2.7µs, 복원 약 0.9~2.6µs 라 설정을 고를 때 기준이 되지 않아요. 기준은 쿠키를 누가 읽고 쓸 수 있느냐예요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">CSRF 설정 판단표, 끌지 켤지부터</h2>
<p>첫 질문은 "브라우저가 인증 정보를 알아서 붙이느냐"예요. <a href="https://docs.spring.io/spring-security/reference/features/exploits/csrf.html" target="_blank" rel="noopener">Spring Security 레퍼런스의 CSRF 기능 설명</a>은 일반 사용자가 브라우저로 보낼 수 있는 요청이면 보호를 켜고, 브라우저가 아닌 클라이언트만 쓰는 서비스라면 끄는 것을 고려하라고 해요. JWT 냐 세션이냐는 기준이 아니에요.</p>
<div class="table-wrap"><table><thead><tr><th>인증 정보가 오는 자리</th><th>다른 사이트 페이지가 실어 보낼 수 있나</th><th>권장</th></tr></thead><tbody><tr><td>세션 쿠키(폼 로그인)</td><td>예, 브라우저가 자동 첨부</td><td>켜기</td></tr><tr><td>쿠키에 담은 JWT(<code>HttpOnly</code>)</td><td>예, 세션 쿠키와 같음</td><td>켜기</td></tr><tr><td>HTTP Basic</td><td>예, 브라우저가 자격 증명을 기억</td><td>켜기</td></tr><tr><td>프런트 코드가 넣는 <code>Authorization: Bearer</code></td><td>아니오</td><td>끄는 것 고려</td></tr><tr><td>서버 간 호출, 모바일 앱 전용 API</td><td>해당 없음</td><td>끄기</td></tr><tr><td>위 둘이 한 앱에 섞임</td><td>경로마다 다름</td><td>체인을 나누거나 경로별 제외</td></tr></tbody></table></div>
<p>마지막 줄이 실무에서 흔해요. 관리자 화면(세션)과 외부 연동 API(Bearer)가 한 앱에 있으면 전부 끄지 말고 <code>SecurityFilterChain</code> 을 둘로 나누거나 <code>ignoringRequestMatchers("/api/partner/**")</code> 로 그 경로만 빼요. 서명으로 검증하는 웹훅도 같은 방식으로 빼요.</p>
<p><code>SameSite</code> 쿠키로 대신할 수 있냐는 질문도 많아요. 같은 문서는 SameSite 를 <strong>보조 방어(defense in depth)</strong> 로 권해요. 스프링 부트에서는 <code>server.servlet.session.cookie.same-site</code> 속성으로 세션 쿠키에 붙일 수 있으니(<a href="https://docs.spring.io/spring-boot/appendix/application-properties/index.html" target="_blank" rel="noopener">스프링 부트 속성 목록</a>), 토큰 검사와 같이 쓰는 쪽을 권해요.</p>
<h2 id="s2">저장소·핸들러 조합 고르기, 서버 렌더링과 SPA</h2>
<p>켜기로 했다면 정답 토큰을 어디 두고(저장소) 보낸 값을 어떻게 읽을지(핸들러)를 화면 구조로 골라요.</p>
<div class="table-wrap"><table><thead><tr><th>화면 구조</th><th>저장소</th><th>핸들러</th><th>클라이언트가 보내는 값</th><th>이유</th></tr></thead><tbody><tr><td>타임리프·JSP 서버 렌더링</td><td>세션(기본)</td><td>XOR(기본)</td><td>폼 hidden <code>_csrf</code> 96자</td><td>손댈 것 없음</td></tr><tr><td>서버 렌더링 + 일부 fetch</td><td>세션(기본)</td><td>XOR(기본)</td><td><code>&lt;meta&gt;</code> 에 그린 96자를 <code>X-CSRF-TOKEN</code> 헤더로</td><td>레퍼런스의 멀티 페이지 예시</td></tr><tr><td>같은 출처 SPA(7.0 이상)</td><td><code>csrf.spa()</code></td><td><code>csrf.spa()</code></td><td><code>XSRF-TOKEN</code> 쿠키 36자를 <code>X-XSRF-TOKEN</code> 헤더로</td><td>Angular·axios 기본 이름과 같음</td></tr><tr><td>같은 출처 SPA(6.x)</td><td>쿠키(<code>withHttpOnlyFalse</code>)</td><td>헤더 유무로 위임하는 직접 만든 핸들러</td><td>위와 같음</td><td>6.x 에는 <code>spa()</code> 가 없음</td></tr><tr><td>다른 서브도메인 SPA</td><td>세션(기본)</td><td>XOR(기본)</td><td><code>GET /csrf</code> 응답의 96자를 <code>X-CSRF-TOKEN</code> 헤더로</td><td>쿠키 도메인을 넓히지 않아도 됨</td></tr></tbody></table></div>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/spring-security-csrf-403-spa-cookie-settings-checklist-1.svg" alt="인증 정보 자동 첨부, 서버 렌더링, 같은 출처, 서브도메인 신뢰를 차례로 물어 CSRF 끄기·기본값·csrf.spa()·쿠키 Domain 공유·/csrf 엔드포인트 중 하나로 가는 순서도" width="800" height="560" loading="lazy" decoding="async"><figcaption>위에서부터 물어 처음 닿는 칸이 출발점이에요</figcaption></figure>
<p><code>spa()</code> 가 무엇을 바꾸는지는 <a href="https://github.com/spring-projects/spring-security/blob/7.1.2/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java" target="_blank" rel="noopener">7.1.2 CsrfConfigurer 소스</a>에 그대로 있어요. 저장소를 <code>CookieCsrfTokenRepository.withHttpOnlyFalse()</code> 로 바꾸고, 헤더에 값이 있으면 마스킹 없이 비교하고 없으면 XOR 로 풀어 비교하는 핸들러를 넣어요. 그래서 SPA 의 fetch(헤더)와 서버가 그린 폼(파라미터)이 한 앱에서 같이 동작해요.</p>
<p>다른 서브도메인 SPA 에 세션 저장소를 권하는 이유는 쿠키 범위예요. <code>app.example.com</code> 의 자바스크립트는 <code>api.example.com</code> 호스트 쿠키를 읽지 못해서, 쿠키 방식이면 <code>Domain</code> 을 <code>example.com</code> 으로 넓혀야 하고 그 순간 형제 서브도메인 전부가 그 쿠키를 읽고 덮어쓸 수 있어요(아래 "도입 전에 따질 것").</p>
<h2 id="s3">설정 조합별로 직접 돌려 본 결과</h2>
<p>"쿠키 + XOR" 은 5.x 에서 쿠키 저장소만 쓰던 SPA 를 6.x 이상으로 올린 상태이고, <code>spa()</code> 열은 위 소스의 핸들러를 그대로 옮겨 만든 조합이에요.</p>
<div class="table-wrap"><table><thead><tr><th>시나리오</th><th>기본(세션 + XOR)</th><th>쿠키 + XOR</th><th><code>spa()</code></th></tr></thead><tbody><tr><td>GET 100번 동안 만들어진 세션</td><td>0개</td><td>0개</td><td>0개</td></tr><tr><td>GET 100번 동안 받은 <code>Set-Cookie</code></td><td>0번</td><td>0번</td><td>1번(첫 응답)</td></tr><tr><td>쿠키 원본 36자를 헤더로 POST</td><td>—</td><td>403 Invalid</td><td>200</td></tr><tr><td>마스킹 값 96자를 <code>_csrf</code> 파라미터로 POST</td><td>200</td><td>200</td><td>200</td></tr><tr><td>마스킹 값 96자를 헤더로 POST</td><td>200</td><td>200</td><td><strong>403 Invalid</strong></td></tr><tr><td>쿠키 없이 헤더만 POST</td><td>—</td><td>—</td><td>403 Missing</td></tr><tr><td>헤더 이름을 <code>X-CSRF-TOKEN</code> 으로 잘못 보냄</td><td>—</td><td>—</td><td>403 Invalid</td></tr><tr><td>토큰 받은 뒤 세션 만료, 같은 값으로 POST</td><td>403 Missing</td><td>—</td><td>—</td></tr><tr><td>로그인 직후 옛 쿠키 값으로 POST</td><td>—</td><td>—</td><td>403 Missing</td></tr></tbody></table></div>
<p>가장 쓸모 있는 줄은 "마스킹 값을 헤더로"예요. <code>spa()</code> 는 헤더가 있으면 원본끼리 비교해서, <code>/csrf</code> 엔드포인트에서 받은 96자 값을 헤더에 넣으면 거절돼요. 기본 설정에서는 그 값이 정답이고요. <strong>같은 96자 값이 설정에 따라 정답도 오답도 되니</strong> 설정을 바꿀 때 프런트가 보내는 값부터 맞춰요.</p>
<p>두 번째는 "GET 만으로는 쿠키가 안 내려온다"예요. 토큰 지연 로딩 때문에 첫 화면이 GET 만 하면 <code>XSRF-TOKEN</code> 쿠키가 없고, 첫 POST 가 403 Missing 을 받은 뒤에야 그 응답으로 쿠키가 심어져요. "다시 누르면 되는데 첫 저장만 실패"하는 증상이에요. <code>spa()</code> 는 토큰을 매 요청 미리 읽게 해 첫 응답에서 쿠키를 내보내요.</p>
<p>세션 저장소는 토큰을 꺼내 쓰는 순간 세션을 만들어요. 실험에서도 GET 만 할 때는 0개였고 <code>_csrf</code> 를 읽자 세션이 생겼어요. 모든 페이지에 POST 폼이 있으면 비로그인 방문자도 세션을 하나씩 가져요. 부트 기본 세션 시간 30분(<code>server.servlet.session.timeout</code>)에 하루 24만 명이 고르게 오면 살아 있는 세션은 약 5,000개예요(24만 ÷ 48, 예시 계산). 레디스에 세션을 두면 이 수가 곧 키 수예요.</p>
<h2 id="s4">요청당 비용 계산, 바이트와 처리 시간</h2>
<p>비용은 요청마다 실리는 바이트와 마스킹·복원 CPU 시간이에요. 원본은 UUID 문자열 36자, 마스킹 값은 난수 36바이트 + XOR 결과 36바이트 = 72바이트를 Base64URL 로 바꾼 96자예요(72 ÷ 3 × 4). 길이 관계는 <a href="https://tool.smithlab.co.kr/developer/base64/">Base64 인코더</a>로 바로 확인돼요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">CSRF 토큰 때문에 요청 하나에 더 실리는 바이트</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="CSRF 토큰 때문에 요청 하나에 더 실리는 바이트" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">spa() 쿠키(모든 요청)</text><rect class="s1" x="220" y="15" width="137.1" height="20" rx="3"><title>spa() 쿠키(모든 요청) · 47바이트</title></rect><text class="viz-val" x="363.1" y="29">47바이트</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">spa() 헤더(POST만)</text><rect class="s1" x="220" y="49" width="145.8" height="20" rx="3"><title>spa() 헤더(POST만) · 50바이트</title></rect><text class="viz-val" x="371.8" y="63">50바이트</text><text class="viz-ax lg" x="210" y="97" text-anchor="end">__Host- 이름 쿠키(모든 요청)</text><rect class="s1" x="220" y="83" width="157.5" height="20" rx="3"><title>__Host- 이름 쿠키(모든 요청) · 54바이트</title></rect><text class="viz-val" x="383.5" y="97">54바이트</text><text class="viz-ax lg" x="210" y="131" text-anchor="end">기본 폼 파라미터(POST만)</text><rect class="s1" x="220" y="117" width="297.5" height="20" rx="3"><title>기본 폼 파라미터(POST만) · 102바이트</title></rect><text class="viz-val" x="523.5" y="131">102바이트</text><text class="viz-ax lg" x="210" y="165" text-anchor="end">기본 헤더(POST만)</text><rect class="s1" x="220" y="151" width="320.8" height="20" rx="3"><title>기본 헤더(POST만) · 110바이트</title></rect><text class="viz-val" x="546.8" y="165">110바이트</text></svg><p class="viz-note">쿠키 방식은 GET 에도 실리고, 세션 방식은 상태를 바꾸는 요청에만 실려요</p><div class="viz-cap">단위: 바이트 · 자료: 이름 + 구분자 + 값 길이로 직접 계산 (원본 36자, 마스킹 값 96자, HTTP/1.1 비압축 기준)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>바이트</th></tr></thead><tbody><tr><td>spa() 쿠키(모든 요청)</td><td>47</td></tr><tr><td>spa() 헤더(POST만)</td><td>50</td></tr><tr><td>__Host- 이름 쿠키(모든 요청)</td><td>54</td></tr><tr><td>기본 폼 파라미터(POST만)</td><td>102</td></tr><tr><td>기본 헤더(POST만)</td><td>110</td></tr></tbody></table></div></details></figure>
<p>요청 100만 건 중 10%가 POST 라면 <code>spa()</code> 는 47MB + 5MB = 약 52MB, 기본 헤더 방식은 110바이트 × 10만 = 11MB 예요. HTTP/2 이상은 <a href="https://www.rfc-editor.org/rfc/rfc7541" target="_blank" rel="noopener">HPACK(RFC 7541)</a> 동적 테이블로 반복 헤더가 몇 바이트로 줄 수 있어 실제 차이는 더 작아요.</p>
<p>CPU 는 마스킹 핸들러만 따로 200만 번씩 돌려 쟀어요. 두 번 돌린 값이 꽤 흔들려서 범위로 적어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">토큰 처리 한 번에 드는 시간 (Spring Security 7.1.2, Apple M1)</figcaption><div class="viz-legend"><span><i class="s1"></i>1차 측정</span><span><i class="s2"></i>2차 측정</span></div><svg viewBox="0 0 640 300" role="img" aria-label="토큰 처리 한 번에 드는 시간 (Spring Security 7.1.2, Apple M1)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">500</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1,500</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">2,000</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">2,500</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">3,000</text><rect class="s1" x="80.8" y="68.0" width="66.2" height="186.0" rx="3"><title>마스킹 값 만들기 · 1차 측정 2,364나노초</title></rect><rect class="s2" x="149.0" y="43.1" width="66.2" height="210.9" rx="3"><title>마스킹 값 만들기 · 2차 측정 2,681나노초</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">마스킹 값 만들기</text><rect class="s1" x="272.8" y="181.1" width="66.2" height="72.9" rx="3"><title>마스킹 값 풀기 · 1차 측정 927나노초</title></rect><rect class="s2" x="341.0" y="47.1" width="66.2" height="206.9" rx="3"><title>마스킹 값 풀기 · 2차 측정 2,630나노초</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">마스킹 값 풀기</text><rect class="s1" x="464.8" y="241.6" width="66.2" height="12.4" rx="3"><title>원본 그대로 읽기 · 1차 측정 158나노초</title></rect><rect class="s2" x="533.0" y="232.5" width="66.2" height="21.5" rx="3"><title>원본 그대로 읽기 · 2차 측정 273나노초</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">원본 그대로 읽기</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">마스킹 값 만들기는 SecureRandom 난수 36바이트가 대부분이에요</p><div class="viz-cap">단위: 나노초 · 자료: XorCsrfTokenRequestAttributeHandler·CsrfTokenRequestAttributeHandler 를 각 200만 번 호출해 직접 측정 (2026-11-06)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>1차 측정</th><th>2차 측정</th></tr></thead><tbody><tr><td>마스킹 값 만들기</td><td>2,364</td><td>2,681</td></tr><tr><td>마스킹 값 풀기</td><td>927</td><td>2,630</td></tr><tr><td>원본 그대로 읽기</td><td>158</td><td>273</td></tr></tbody></table></div></details></figure>
<p>나쁜 쪽 값으로 잡아도 마스킹 생성과 복원을 합쳐 요청당 약 5.3µs 예요. 초당 2,000건이면 2,000 × 5.3µs = 10.6ms, 코어 하나의 약 1% 예요. 필터 전체 비용은 목 요청 객체를 만드는 시간(약 6.6µs)과 구분되지 않았어요. <strong>성능을 이유로 XOR(BREACH 방어)을 끄는 건 권하지 않아요.</strong></p>
<h2 id="s5">POST 만 403 이 날 때 확인 순서, 체크리스트</h2>
<p>인가 실패와 CSRF 실패는 같은 403 이에요(<a href="https://tool.smithlab.co.kr/developer/http-status-code-lookup/">HTTP 상태 코드 조회</a>). 둘을 가르는 건 서버 로그예요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/spring-security-csrf-403-spa-cookie-settings-checklist-2.svg" alt="POST 만 403 일 때 로그, 예외 이름, 헤더 이름, 값 길이 36자·96자, 쿠키 전송, 직전 로그인·로그아웃을 차례로 보는 6단계 체크리스트" width="800" height="600" loading="lazy" decoding="async"><figcaption>1~2는 서버 로그, 3~5는 브라우저 네트워크 탭, 6은 화면 흐름에서 확인해요</figcaption></figure>
<ol><li><strong>CSRF 인지 확인.</strong> <code>logging.level.org.springframework.security.web.csrf=DEBUG</code> 를 켜고 다시 보내요. 7.1.2 <code>CsrfFilter</code> 는 실패할 때 <code>Invalid CSRF token found for &lt;주소&gt;</code> 를 DEBUG 로 찍어요. 없으면 인가 규칙 문제예요.</li><li><strong>예외 이름 보기.</strong> 커스텀 <code>AccessDeniedHandler</code> 에서 예외 클래스를 찍어요. <code>MissingCsrfTokenException</code> 은 서버에 정답이 없음(세션 만료·쿠키 안 옴), <code>InvalidCsrfTokenException</code> 은 값이 다름이에요.</li><li><strong>헤더 이름 맞추기.</strong> 세션 저장소는 <code>X-CSRF-TOKEN</code>, 쿠키 저장소는 <code>X-XSRF-TOKEN</code> 을 읽어요. 폼 파라미터는 둘 다 <code>_csrf</code> 예요.</li><li><strong>값 길이 보기.</strong> 36자면 쿠키 원본, 96자면 마스킹 값이에요. 원본은 <code>spa()</code> 헤더에서만, 마스킹 값은 XOR 핸들러와 폼 파라미터에서만 통과해요. XOR 핸들러가 원본을 받으면 TRACE 로그에 <code>Base64-decoded length (27) is not equal to (72)</code> 가 남아요(36자 ÷ 4 × 3 = 27바이트).</li><li><strong>쿠키가 실렸는지.</strong> <code>Cookie</code> 헤더에 <code>XSRF-TOKEN</code> 이 없으면 Missing 이에요. 쿠키 <code>Path</code> 가 컨텍스트 경로로 잡혔거나, <code>Secure</code> 쿠키를 <code>http</code> 로 보내거나, 다른 출처 호출에 <code>credentials: 'include'</code> 를 빠뜨린 경우가 흔해요.</li><li><strong>직전 화면 흐름 보기.</strong> 로그인·로그아웃 성공 때 토큰이 지워져요. 실험에서 로그인 응답은 <code>XSRF-TOKEN=; Max-Age=0</code> 이었고, 옛 값으로 보낸 POST 는 403 Missing 이었어요.</li></ol>
<p>파일 업로드만 실패하면 별도 갈래예요. <a href="https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html" target="_blank" rel="noopener">레퍼런스의 서블릿 CSRF 장</a>은 본문의 <code>_csrf</code> 를 읽으려면 <code>MultipartFilter</code> 를 시큐리티 앞에 둬야 하고, 그러면 누구나 임시 파일을 올릴 수 있다고 적어요. fetch 로 바꿔 헤더로 보내는 편이 깔끔해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">운영 설정 예시, 쿠키 이름과 SameSite</h2>
<p><code>spa()</code> 의 쿠키를 더 조이려면 저장소를 직접 만들어 넘겨요. 7.x 의 <a href="https://github.com/spring-projects/spring-security/blob/7.1.2/web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java" target="_blank" rel="noopener">CookieCsrfTokenRepository</a>에는 도메인·Secure·만료를 따로 바꾸는 setter 가 없고, 6.1 부터 있는 <code>setCookieCustomizer</code> 로 한 번에 바꿔요.</p>
<pre class="code-block" data-lang="java"><code>@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    CookieCsrfTokenRepository repo = CookieCsrfTokenRepository.withHttpOnlyFalse();
    repo.setCookieName(&quot;__Host-XSRF-TOKEN&quot;);          // 프런트의 쿠키 이름도 같이 바꿔요
    repo.setCookieCustomizer(c -&gt; c.secure(true).path(&quot;/&quot;).sameSite(&quot;Strict&quot;));

    http.csrf(csrf -&gt; csrf
            .spa()                                     // 저장소·핸들러 기본 세트
            .csrfTokenRepository(repo)                 // 저장소만 위 설정으로 교체
            .ignoringRequestMatchers(&quot;/webhooks/**&quot;)); // 서명으로 검증하는 경로만 제외
    return http.build();
}</code></pre>
<p><code>spa()</code> 뒤에 <code>csrfTokenRepository(...)</code> 를 부르면 저장소만 바뀌고 SPA 핸들러는 남아요. 실험에서 이 쿠키는 <code>__Host-XSRF-TOKEN=…; Path=/; Secure</code> 에 <code>SameSite=Strict</code> 가 붙어 나왔어요. <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie" target="_blank" rel="noopener">MDN Set-Cookie 문서</a>대로 <code>__Host-</code> 쿠키는 <code>Secure</code>·<code>Path=/</code>·<code>Domain</code> 없음이어야 받아들여져서, 서브도메인이 덮어쓸 수 없어요. 다만 <code>http</code> 개발 서버에서는 Secure 때문에 저장이 안 될 수 있어 프로필별로 나눠요.</p>
<p>Angular·axios 모두 기본은 <code>XSRF-TOKEN</code> 쿠키 → <code>X-XSRF-TOKEN</code> 헤더예요. <a href="https://angular.dev/best-practices/security" target="_blank" rel="noopener">Angular 보안 문서</a>는 이 헤더를 상대 주소·같은 출처로 가는 변경 요청에만 붙인다고 적어요. 다른 도메인 절대 주소로 부르면 헤더가 안 붙어서, 서버만 봐서는 찾기 어려워요.</p>
<h2 id="s7">도입 전에 따질 것, 쿠키 방식의 한계</h2>
<p>쿠키 저장소는 OWASP 가 말하는 "단순 이중 제출 쿠키(naive double-submit cookie)" 예요. 쿠키와 헤더 값이 같은지만 보므로, 공격자가 <strong>쿠키를 심을 수 있으면</strong> 아는 값을 양쪽에 넣어 통과해요. <a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html" target="_blank" rel="noopener">OWASP CSRF 방어 치트시트</a>는 취약한 형제 서브도메인, DNS 탈취, <code>__Host-</code> 가 아닌 쿠키에 대한 평문 HTTP 주입을 그 경로로 들고, 새 코드에는 세션에 묶은 서명 토큰 방식을 권해요.</p>
<p>그래서 도입 전에 이 네 가지를 확인해요.</p>
<ul><li><strong>형제 서브도메인 목록.</strong> 외부 서비스 CNAME, 방치된 호스트, 사용자 콘텐츠 호스트가 있으면 쿠키 도메인을 넓히지 말고 세션 저장소 + <code>/csrf</code> 로 가요.</li><li><strong>HTTPS 전용 여부.</strong> 전부 HTTPS(HSTS 포함)면 <code>__Host-</code> 이름을 써요. 평문 구간이 있으면 쿠키 주입 여지가 남아요.</li><li><strong>즉시 무효화 필요성.</strong> 쿠키 방식은 서버 상태가 없어 새어 나간 토큰을 강제로 무효화할 수 없어요. 세션 저장소는 세션만 지우면 돼요.</li><li><strong>XSS 대비.</strong> XSS 가 나면 어떤 CSRF 토큰도 읽혀요. <code>withHttpOnlyFalse()</code> 를 쓴다면 CSP 같은 대책을 같이 점검해요. 이스케이프 기본은 <a href="https://tool.smithlab.co.kr/posts/developer/html-entity-escape-xss/">HTML 엔티티 이스케이프와 XSS 방어</a>에 정리했어요.</li></ul>
<p>토큰을 Bearer 헤더로 옮겨 CSRF 를 끄는 설계도 있지만, 그러면 토큰 저장 위치가 새 문제가 돼요. 위험은 <a href="https://tool.smithlab.co.kr/posts/developer/jwt-security-mistakes/">JWT 보안 체크리스트</a>를, 리소스 서버 쪽 401·403 순서는 <a href="https://tool.smithlab.co.kr/posts/developer/spring-security-jwt-resource-server-settings-troubleshooting-guide/">스프링 시큐리티 JWT 리소스 서버 설정 판단표</a>를 보세요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p><a href="https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/csrf.html" target="_blank" rel="noopener">MockMvc 의 <code>with(csrf())</code></a> 는 유효 토큰을 알아서 넣어 주므로, 프런트가 실제로 보내는 값(36자·96자)과 헤더 이름은 검증하지 못해요. 업그레이드 뒤에는 실제 브라우저나 curl 로 쿠키 값을 헤더에 넣는 테스트를 한 번은 돌려요.</p></aside>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>스프링 시큐리티 6 으로 올린 뒤 SPA 의 POST 가 전부 403 이에요.</h3>
<p>기본 핸들러가 XOR 마스킹으로 바뀌어서, 쿠키 원본 36자를 헤더로 보내면 거절돼요. 7.0 이상이면 <code>csrf(csrf -&gt; csrf.spa())</code> 로 바꾸고, 6.x 에 머물러야 하면 헤더가 있을 때만 원본으로 비교하는 핸들러를 직접 넣어요. 핸들러를 <code>CsrfTokenRequestAttributeHandler</code> 로만 바꾸는 방법은 BREACH 방어까지 꺼요.</p>
<h3>XSRF-TOKEN 쿠키가 첫 화면에서 안 내려와요.</h3>
<p>토큰 지연 로딩 때문이에요. GET 만 오가면 아무도 토큰을 꺼내지 않아 쿠키를 심지 않아요. <code>spa()</code> 는 매 요청 토큰을 미리 읽어 첫 응답에서 쿠키를 내려보내고, 직접 설정한다면 필터에서 <code>CsrfToken</code> 요청 속성의 <code>getToken()</code> 을 한 번 부르면 돼요.</p>
<h3>JWT 를 쓰면 csrf.disable() 해도 되나요?</h3>
<p>프런트 코드가 <code>Authorization</code> 헤더에 넣는다면 다른 사이트가 그 헤더를 만들 수 없어 꺼도 돼요. <code>HttpOnly</code> 쿠키에 담았다면 자동으로 실리므로 켜 둬요.</p>
<h3>SameSite=Lax 를 켰는데 CSRF 토큰이 꼭 필요한가요?</h3>
<p>스프링 시큐리티 레퍼런스는 SameSite 를 단독 방어가 아닌 보조 방어로 권해요. Lax 는 최상위 이동의 GET 에는 쿠키를 보내고, 같은 사이트로 묶이는 형제 서브도메인에서 오는 요청은 막지 못해요. 토큰 검사를 기본으로 두고 SameSite 를 더하는 구성이 안전해요.</p>
<h3>로그인 직후 첫 POST 만 403 이 나요.</h3>
<p>로그인 성공 때 기존 토큰이 지워지는데, SPA 가 로그인 전에 받아 둔 값을 계속 보내서예요. 실험에서는 403 Missing 이었어요. 로그인·로그아웃 응답 뒤에 쿠키를 다시 읽거나 <code>/csrf</code> 를 다시 불러 새 값을 쓰게 바꿔요.</p>
<h2 id="s9">정리</h2>
<ul><li>끌지 켤지는 "브라우저가 인증 정보를 자동으로 붙이나"로 정해요. 세션·쿠키 JWT·Basic 이면 켜고, 섞인 앱은 체인을 나누거나 경로별로 빼요.</li><li>서버 렌더링은 기본값, 같은 출처 SPA 는 <code>spa()</code>, 다른 서브도메인 SPA 는 세션 저장소 + <code>/csrf</code> 엔드포인트가 출발점이에요. 형제 서브도메인을 다 믿을 수 없으면 쿠키 도메인을 넓히지 않아요.</li><li>403 은 로그 → 예외 이름 → 헤더 이름 → 값 길이(36·96) → 쿠키 → 직전 로그인·로그아웃 순서로 좁혀요. <code>spa()</code> 에서 96자를 헤더로 보내면 거절돼요.</li><li>비용은 요청당 수십~110바이트, 처리 수 µs 라 설정 선택의 기준이 아니에요. 기준은 쿠키를 누가 읽고 덮어쓸 수 있느냐예요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/spring-security-csrf-filter-xor-token-spa" target="_blank" rel="noopener">스프링 시큐리티 CSRF 원리 — CsrfFilter·XOR 토큰·SPA</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>알람 몇 시에 맞출까, 도착 시각에서 거꾸로 출발·기상 시각 정하는 법</title>
      <link>https://tool.smithlab.co.kr/posts/life/alarm-time-backward-departure-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/alarm-time-backward-departure-guide/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>"내일 9시까지 가야 하는데 알람을 몇 시에 맞추지?" 하고 머릿속으로 거꾸로 계산하다 보면 어느 순간 숫자가 꼬여요. 이동 40분, 준비 한 시간, 밥도 먹어야 하고, 길이 막힐지도 모르고… 그래서 대충 "7시쯤?" 하고 맞췄다가 아침에 허둥대는 일이 반복돼요. 이 글은 그 계산을 <strong>빼기 순서대로</strong> 정리한 글이에요.</p>
<p>결론부터 말하면, 09:00에 도착해야 하고 이동 40분, 지연 여유 20%, 일찍 도착 10분, 준비 50분, 식사·짐 15분, 알람 미루기 10분이라면 <strong>집에서는 08:02에 나서고 알람은 06:47</strong>에 맞추면 돼요. 알람부터 약속까지 총 2시간 13분이 필요한 셈이에요. 이 글에서는 이 계산의 각 단계가 왜 필요한지, 숫자를 어떻게 정하는지, 수능이나 면접처럼 늦으면 안 되는 날은 무엇이 달라지는지 설명해요. 직접 숫자를 넣어 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/departure-time-backward-calculator/">출발 시각 역산 계산기</a>를 쓰면 돼요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>알람 시각은 약속 시각에서 일찍 도착 여유, 이동 시간, 지연 여유, 준비·식사 시간, 알람 미루기를 차례로 뺀 값이에요.</li><li>지연 여유는 고정 분이 아니라 이동 시간의 비율(10~20%)로 잡으면 먼 길에서도 현실적이에요.</li><li>09:00 약속, 이동 40분, 지연 20% 기준으로 집 출발은 08:02, 알람은 06:47이에요.</li><li>알람을 앞당기는 만큼 전날 잠드는 시각도 앞당겨야 수면이 줄지 않아요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">알람 시각은 "빼기" 다섯 번이에요</h2>
<p>아침에 필요한 시간은 사실 다섯 덩어리로 나뉘어요. 약속 시각에서 위에서부터 차례로 빼면 알람 시각이 나와요.</p>
<ol><li><strong>일찍 도착할 여유</strong>: 약속 시각 정각에 문을 여는 게 아니라 몇 분 먼저 도착하겠다는 목표예요.</li><li><strong>이동 시간과 지연 여유</strong>: 문 앞에서 도착지까지 걸리는 시간과, 막힘에 대비해 더하는 시간이에요.</li><li><strong>준비 시간</strong>: 씻고 옷 입고 머리 만지는 시간이에요.</li><li><strong>식사·짐 챙기기</strong>: 밥을 먹거나 가방을 싸는 시간이에요.</li><li><strong>알람 미루기</strong>: 알람이 울린 뒤 실제로 몸을 일으키기까지 미루는 시간이에요.</li></ol>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/alarm-time-backward-departure-guide-1.svg" alt="약속 09:00에서 일찍 도착 10분, 이동 40분과 지연 여유 8분, 준비와 식사 65분, 알람 미루기 10분을 차례로 빼 알람 06:47을 구하는 순서도" width="800" height="450" loading="lazy" decoding="async"><figcaption>09:00 약속을 기준으로 거꾸로 빼면 알람은 06:47</figcaption></figure>
<p>식으로 쓰면 이래요. 시각은 자정부터 흐른 분으로 바꿔서 계산하면 편해요. 09:00은 540분이에요.</p>
<ul><li>집 출발 = 약속 − 일찍 도착 − 이동 − 지연 여유 = 540 − 10 − 40 − 8 = 482분 = <strong>08:02</strong></li><li>준비 시작 = 출발 − 준비 − 식사·짐 = 482 − 50 − 15 = 417분 = <strong>06:57</strong></li><li>알람 = 준비 시작 − 알람 미루기 = 417 − 10 = 407분 = <strong>06:47</strong></li></ul>
<p>이 숫자들은 파이썬과 자바스크립트로 각각 계산해 같은 결과가 나오는 것을 확인했어요.</p>
<h2 id="s2">이동 시간은 "문 앞에서 문 앞까지"로 재요</h2>
<p>가장 흔한 실수는 지도 앱이 알려 준 시간을 그대로 이동 시간으로 쓰는 거예요. 지도 앱의 예상 시간은 보통 출발지의 정류장이나 역에서 도착지의 정류장이나 역까지, 혹은 도로 위 이동 시간에 가까워요. 실제 아침에는 여기에 다음 시간이 더 붙어요.</p>
<ul><li>집 현관에서 엘리베이터를 타고 정류장·역까지 걷는 시간</li><li>승강장에서 기다리는 시간(배차 간격의 절반 정도)</li><li>환승 통로를 걷는 시간</li><li>내린 뒤 건물 입구와 사무실·교실까지 걷는 시간</li><li>자가용이라면 주차장 진입과 주차, 건물까지 걷는 시간</li></ul>
<p>한 번만 평소 출근길을 휴대폰 타이머로 문 앞에서 문 앞까지 재 보세요. 지도 앱 시간에 5~15분이 더 붙는 경우가 많아요. 이 값을 이동 시간 칸에 넣으면 이후 계산이 훨씬 정확해져요.</p>
<h2 id="s3">지연 여유는 비율로 잡아요</h2>
<p>"막힐지도 모르니 10분 더"처럼 고정 시간으로 잡는 방법도 있지만, 이동 시간이 20분인 날과 90분인 날에 같은 10분을 쓰면 먼 길에서는 모자라고 가까운 길에서는 남아요. 막힘으로 늘어나는 시간은 대체로 이동 시간에 비례하기 때문에 <strong>이동 시간의 몇 %</strong>로 잡는 편이 합리적이에요. 계산기는 올림해서 쓰므로 40분의 20%는 8분, 90분의 20%는 18분이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">이동 시간과 지연 비율별 알람부터 약속까지 필요한 시간 (분)</figcaption><div class="viz-legend"><span><i class="s1"></i>지연 0%</span><span><i class="s2"></i>지연 20%</span><span><i class="s3"></i>지연 50%</span></div><svg viewBox="0 0 640 300" role="img" aria-label="이동 시간과 지연 비율별 알람부터 약속까지 필요한 시간 (분)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">250</text><rect class="s1" x="73.6" y="154.9" width="32.3" height="99.1" rx="3"><title>이동 20분 · 지연 0% 105분</title></rect><rect class="s2" x="107.9" y="151.1" width="32.3" height="102.9" rx="3"><title>이동 20분 · 지연 20% 109분</title></rect><rect class="s3" x="142.1" y="145.4" width="32.3" height="108.6" rx="3"><title>이동 20분 · 지연 50% 115분</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">이동 20분</text><rect class="s1" x="217.6" y="136.0" width="32.3" height="118.0" rx="3"><title>이동 40분 · 지연 0% 125분</title></rect><rect class="s2" x="251.9" y="128.4" width="32.3" height="125.6" rx="3"><title>이동 40분 · 지연 20% 133분</title></rect><rect class="s3" x="286.1" y="117.1" width="32.3" height="136.9" rx="3"><title>이동 40분 · 지연 50% 145분</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">이동 40분</text><rect class="s1" x="361.6" y="117.1" width="32.3" height="136.9" rx="3"><title>이동 60분 · 지연 0% 145분</title></rect><rect class="s2" x="395.9" y="105.8" width="32.3" height="148.2" rx="3"><title>이동 60분 · 지연 20% 157분</title></rect><rect class="s3" x="430.1" y="88.8" width="32.3" height="165.2" rx="3"><title>이동 60분 · 지연 50% 175분</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">이동 60분</text><rect class="s1" x="505.6" y="88.8" width="32.3" height="165.2" rx="3"><title>이동 90분 · 지연 0% 175분</title></rect><rect class="s2" x="539.9" y="71.8" width="32.3" height="182.2" rx="3"><title>이동 90분 · 지연 20% 193분</title></rect><rect class="s3" x="574.1" y="46.3" width="32.3" height="207.7" rx="3"><title>이동 90분 · 지연 50% 220분</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">이동 90분</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">이동이 길수록 같은 비율이라도 더해지는 시간이 커져요. 90분 길은 0%와 50%의 차이가 45분이에요.</p><div class="viz-cap">단위: 분 · 자료: 일찍 도착 10분, 준비 50분, 식사·짐 15분, 알람 미루기 10분 기준으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>지연 0%</th><th>지연 20%</th><th>지연 50%</th></tr></thead><tbody><tr><td>이동 20분</td><td>105</td><td>109</td><td>115</td></tr><tr><td>이동 40분</td><td>125</td><td>133</td><td>145</td></tr><tr><td>이동 60분</td><td>145</td><td>157</td><td>175</td></tr><tr><td>이동 90분</td><td>175</td><td>193</td><td>220</td></tr></tbody></table></div></details></figure>
<p>차트를 보면 이동 20분 길에서는 지연 비율을 0%에서 50%로 올려도 필요한 시간이 10분(105분 → 115분) 늘어나는 데 그치지만, 90분 길에서는 45분(175분 → 220분)이나 늘어나요. 가까운 길은 비율을 낮게, 먼 길과 불안한 날은 높게 잡는 이유가 여기에 있어요.</p>
<h3>비율은 어떻게 정할까</h3>
<p>정답은 없지만 기준을 세우는 요령은 있어요.</p>
<ul><li><strong>평소 잘 안 막히는 길</strong>: 10~20%</li><li><strong>출퇴근 정체가 있거나 환승이 있는 길</strong>: 20~30%</li><li><strong>처음 가는 길, 비·눈이 오는 날, 큰 행사가 있는 날</strong>: 30~50% 이상</li><li><strong>놓치면 안 되는 일정(시험, 면접, 비행기)</strong>: 위 값에 더해 일찍 도착 여유도 15~25분으로 늘리기</li></ul>
<p>매일 50%를 잡으면 알람이 계속 이르고 피곤해져서 오래 못 가요. 평소에는 낮게, 중요한 날만 올리는 식으로 쓰면 지속하기 쉬워요.</p>
<h2 id="s4">지연 비율에 따라 알람이 얼마나 달라질까</h2>
<p>09:00 약속, 이동 40분, 일찍 도착 10분, 준비 50분, 식사·짐 15분, 알람 미루기 10분일 때 비율별 결과예요.</p>
<div class="table-wrap"><table><thead><tr><th>지연 비율</th><th>더한 시간</th><th>집 출발</th><th>알람</th></tr></thead><tbody><tr><td>0%</td><td>0분</td><td>08:10</td><td>06:55</td></tr><tr><td>10%</td><td>4분</td><td>08:06</td><td>06:51</td></tr><tr><td>20%</td><td>8분</td><td>08:02</td><td>06:47</td></tr><tr><td>30%</td><td>12분</td><td>07:58</td><td>06:43</td></tr><tr><td>50%</td><td>20분</td><td>07:50</td><td>06:35</td></tr></tbody></table></div>
<p>0%와 50%의 차이는 알람 기준으로 20분이에요. 20분이면 큰 차이 같지만, 지연이 실제로 생겼을 때 지각하느냐 아니냐를 가르는 크기이기도 해요. 비율을 올릴 때는 "그만큼 잠을 줄여도 괜찮은가"를 같이 따져 보세요.</p>
<h2 id="s5">준비 시간은 평소가 아니라 "느린 날" 기준으로</h2>
<p>준비 시간은 사람마다 큰 차이가 나요. 샤워 후 머리를 말리고 옷을 고르면 한 시간이 넘게 걸리는 사람도 있고, 20분이면 끝나는 사람도 있어요. 중요한 건 평균이 아니라 <strong>느린 날의 값</strong>을 쓰는 거예요. 머리를 감는 날, 다리미질이 필요한 날, 추워서 껴입을 옷이 많은 날, 짐이 많은 날은 평소보다 10~15분은 더 걸려요.</p>
<p>한 번 재 보는 방법은 간단해요. 내일 아침 알람이 울린 시각, 현관문을 닫은 시각을 적어 두고 그 사이에서 식사 시간을 빼면 돼요. 사흘 정도 적어 보면 평균과 최대값이 나와요. 평소엔 평균을, 중요한 날은 최대값을 쓰세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">알람 미루기도 계산에 넣어요</h2>
<p>"알람 끄고 바로 일어난다"는 가정은 나 자신에게 너무 후한 계산일 때가 많아요. 평소 알람을 두세 번 끄고 일어난다면 그 미루는 시간을 계산에 포함하는 게 정직해요. 예를 들어 5분 간격 알람을 두 번 미루면 10분이에요. 이 10분을 계산에 넣고 알람을 맞추면 미루는 습관을 고치지 않아도 지각하지 않고, 마음도 덜 불편해요.</p>
<p>반대로 미루는 습관을 줄이고 싶다면 미루기 시간을 0으로 놓고 알람을 맞추되 알람을 침대에서 먼 곳에 두는 방식을 쓸 수 있어요. 어느 쪽이든 자신에게 맞는 값을 정직하게 넣는 게 중요해요.</p>
<h2 id="s7">상황별로 어떻게 달라지나</h2>
<p>같은 방식이라도 상황마다 숫자가 달라요. 아래는 예시 숫자로 계산한 값이에요. 실제 이동·준비 시간은 사람마다 다르니 본인 숫자를 넣어 보세요.</p>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>약속 시각</th><th>일찍 도착</th><th>이동</th><th>지연</th><th>집 출발</th><th>알람</th></tr></thead><tbody><tr><td>출근</td><td>09:00</td><td>10분</td><td>40분</td><td>20%</td><td>08:02</td><td>06:47</td></tr><tr><td>수능 입실</td><td>08:10</td><td>25분</td><td>30분</td><td>25%</td><td>07:07</td><td>06:12</td></tr><tr><td>면접</td><td>14:00</td><td>15분</td><td>35분</td><td>20%</td><td>13:03</td><td>11:43</td></tr><tr><td>저녁 모임</td><td>19:00</td><td>5분</td><td>25분</td><td>15%</td><td>18:26</td><td>17:46</td></tr></tbody></table></div>
<p>수능 입실처럼 마감이 정해진 날은 일찍 도착 여유가 훨씬 커요. 시험장 입실 마감인 08:10을 기준으로 잡으면 입실 마감 25분 전인 07:45 도착을 목표로 하게 되고, 이동 30분과 지연 8분을 거꾸로 빼면 집 출발이 07:07이에요. 준비 40분과 식사 10분, 알람 미루기 5분을 더 빼면 알람은 06:12가 돼요. 2027학년도 수능 일정과 입실 시각은 <a href="https://tool.smithlab.co.kr/date/suneung-dday/">2027 수능 D-day · 시간표</a>에서 확인할 수 있어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">상황별 알람부터 약속까지 필요한 시간 (분)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="상황별 알람부터 약속까지 필요한 시간 (분)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="65" y="29" text-anchor="end">출근</text><rect class="s1" x="75" y="15" width="438.9" height="20" rx="3"><title>출근 · 133분</title></rect><text class="viz-val" x="519.9" y="29">133분</text><text class="viz-ax lg" x="65" y="63" text-anchor="end">수능 입실</text><rect class="s1" x="75" y="49" width="389.4" height="20" rx="3"><title>수능 입실 · 118분</title></rect><text class="viz-val" x="470.4" y="63">118분</text><text class="viz-ax lg" x="65" y="97" text-anchor="end">면접</text><rect class="s1" x="75" y="83" width="452.1" height="20" rx="3"><title>면접 · 137분</title></rect><text class="viz-val" x="533.1" y="97">137분</text><text class="viz-ax lg" x="65" y="131" text-anchor="end">저녁 모임</text><rect class="s1" x="75" y="117" width="244.2" height="20" rx="3"><title>저녁 모임 · 74분</title></rect><text class="viz-val" x="325.2" y="131">74분</text></svg><p class="viz-note">면접은 준비를 꼼꼼히 하는 만큼 필요 시간이 가장 길어요. 저녁 모임은 식사와 알람 미루기를 0으로 두면 74분이에요.</p><div class="viz-cap">단위: 분 · 자료: 위 표의 예시 숫자로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>필요 시간</th></tr></thead><tbody><tr><td>출근</td><td>133</td></tr><tr><td>수능 입실</td><td>118</td></tr><tr><td>면접</td><td>137</td></tr><tr><td>저녁 모임</td><td>74</td></tr></tbody></table></div></details></figure>
<h2 id="s8">전날 밤까지 이어서 계산하세요</h2>
<p>알람 시각이 정해졌다면 한 걸음 더 가야 해요. <strong>전날 몇 시에 잠들어야 하는가</strong>예요. 미국 질병통제예방센터(CDC)는 성인에게 <a href="https://www2c.cdc.gov/podcasts/media/pdf/mmwr5_021816-transcript.pdf" target="_blank" rel="noopener">하룻밤 7시간 이상의 수면</a>을 권해요. 알람이 06:47이고 7시간을 자고 싶다면 전날 23:47에는 잠들어 있어야 해요. 잠드는 데 걸리는 시간(보통 10~20분)까지 더하면 침대에 눕는 시각은 23:30 전후가 돼요. 알람은 앞당기면서 잠드는 시각은 그대로 두면 수면이 줄어서, 며칠 지나면 알람을 미루는 시간이 늘어나고 계산이 다시 틀어져요.</p>
<p>그래서 알람과 취침 시각은 한 쌍이에요. 수면 주기를 기준으로 몇 시에 자야 하는지는 <a href="https://tool.smithlab.co.kr/date/sleep-calculator/">수면 주기 계산기</a>로 따로 계산해 볼 수 있어요. 이 도구와 같이 쓰면 "내일 6시 47분에 일어나려면 오늘 몇 시에 눕나"까지 한 번에 정리돼요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자정을 넘기거나 아주 이른 약속일 때</h2>
<p>새벽 비행기나 이른 아침 열차처럼 약속이 05:00 같은 이른 시각이면 계산 결과가 자정 이전으로 내려가요. 예를 들어 05:00 약속에 이동 40분, 지연 20%, 일찍 도착 10분, 준비 50분, 식사·짐 15분, 알람 미루기 10분이라면 집 출발은 04:02, 준비 시작은 02:57, 알람은 02:47이에요. 이런 경우는 날짜가 바뀌기 전에 짧게 눈을 붙였다가 일어나는 것보다, 일정을 앞뒤로 조정하거나 전날 일찍 자는 게 나을 수 있어요. 계산기는 자정을 넘기면 "전날"이라고 붙여서 알려 주니 결과 문구를 꼭 확인하세요.</p>
<p>비행기나 기차는 운송사가 안내하는 탑승 마감이나 도착 권장 시각을 약속 시각 자리에 넣으세요. 이 글과 계산기는 그런 규정을 대신 알려 주지 않아요.</p>
<h2 id="s10">자주 묻는 질문</h2>
<h3>알람 시간은 어떻게 계산하나요?</h3>
<p>약속 시각에서 일찍 도착 여유, 이동 시간, 지연 여유, 준비 시간, 식사·짐 시간, 알람 미루기를 차례로 빼요. 09:00 약속에 일찍 도착 10분, 이동 40분, 지연 8분, 준비 50분, 식사 15분, 미루기 10분이면 알람은 06:47이에요.</p>
<h3>교통 지연 여유는 몇 분을 잡아야 하나요?</h3>
<p>고정 시간보다 이동 시간의 비율로 잡는 게 좋아요. 평소 길은 10~20%, 환승이 있거나 막히는 길은 20~30%, 비·눈이 오는 날이나 놓치면 안 되는 일정은 50% 이상을 써 보세요. 이동이 길수록 같은 비율에서도 더해지는 시간이 커져요.</p>
<h3>준비 시간은 평균으로 넣나요, 느린 날로 넣나요?</h3>
<p>평소엔 평균으로 넣고, 시험·면접·중요한 약속에는 느린 날의 값을 쓰세요. 머리를 감는 날이나 추운 날은 준비가 10~15분 더 걸리는 경우가 많아서, 중요한 날은 여유 있는 값이 안전해요.</p>
<h3>알람을 여러 개 맞추는 게 나을까요, 한 번에 맞추는 게 나을까요?</h3>
<p>계산기가 알려 주는 시각은 "일어나야 하는 시각"이에요. 여러 번 미루는 습관이 있다면 알람 미루기 시간을 계산에 넣고 그 시각 직전에 한 번 더 맞추는 방식으로 쓸 수 있어요. 어느 쪽이든 마지막 알람이 계산된 기상 시각과 맞는지 확인하세요.</p>
<h3>지도 앱 시간만 넣으면 안 되나요?</h3>
<p>넣을 수는 있지만 문 앞에서 문 앞까지의 시간보다 5~15분 짧게 나오는 경우가 많아요. 걷는 시간, 환승 대기, 주차, 건물 안 이동이 빠져 있기 때문이에요. 한 번 직접 재서 넣으면 훨씬 정확해요.</p>
<h2 id="s11">정리</h2>
<ul><li>알람 시각은 약속 시각에서 <strong>일찍 도착 → 이동 → 지연 여유 → 준비·식사 → 알람 미루기</strong> 순으로 빼면 나와요.</li><li>지연 여유는 이동 시간의 비율로, 평소엔 낮게 중요한 날엔 높게 잡으세요.</li><li>이동 시간은 문 앞에서 문 앞까지, 준비 시간은 느린 날 기준으로 재 보세요.</li><li>알람을 정했다면 전날 잠드는 시각까지 한 쌍으로 계산해야 수면이 줄지 않아요.</li><li>직접 계산해 보려면 <a href="https://tool.smithlab.co.kr/life/departure-time-backward-calculator/">출발 시각 역산 계산기</a>, 취침 시각은 <a href="https://tool.smithlab.co.kr/date/sleep-calculator/">수면 주기 계산기</a>, 시각 차이 계산은 <a href="https://tool.smithlab.co.kr/date/time-calculator/">시간 계산기</a>를 써 보세요.</li></ul>
    ]]></description>
    </item>
    <item>
      <title>릴스·쇼츠 컷 편집 박자 맞추는 법 — BPM으로 컷 길이와 타임코드 계산</title>
      <link>https://tool.smithlab.co.kr/posts/sns/reels-shorts-bpm-cut-timing-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/sns/reels-shorts-bpm-cut-timing-guide/</guid>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>릴스나 쇼츠를 만들다 보면 "같은 사진, 같은 음악인데 왜 내 영상은 밋밋하지?" 하는 순간이 와요. 이유 중 하나가 <strong>컷이 바뀌는 타이밍이 박자와 어긋나 있어서</strong>예요. 컷이 박자에 딱 붙으면 시청자는 이유를 모르고도 "리듬감 있다"고 느껴요. 박자에 맞추는 계산은 어렵지 않아요. <strong>컷 길이(초) = 박 수 × 60 ÷ BPM</strong> 이 한 줄이 전부예요. 120BPM이면 한 박이 0.5초라서 4박(한 마디)마다 컷을 바꾸면 컷 하나가 2초이고, 30fps 편집 앱에서는 정확히 60프레임이에요.</p>
<p>이 글에서는 BPM이 무엇인지, 컷 길이를 어떻게 계산하는지, 왜 어떤 BPM에서는 컷이 프레임에 딱 떨어지지 않는지, 그리고 실제 편집에서 컷 길이를 섞어 리듬을 만드는 방법을 숫자로 정리해요. 계산은 <a href="https://tool.smithlab.co.kr/sns/bpm-cut-edit-calculator/">BPM 컷 편집 계산기</a>로 바로 해 볼 수 있어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>한 박 길이는 60 ÷ BPM초이고, 컷 길이는 박 수를 곱한 값이에요.</li><li>120BPM에서 1박은 0.5초, 2박은 1초, 4박은 2초예요.</li><li>128BPM처럼 한 박이 정수 프레임이 아니면 컷을 이어 붙일수록 반올림 오차가 쌓여요.</li><li>컷 길이를 섞을 때는 합이 한 마디(4박)의 배수가 되게 짜면 박자가 어긋나지 않아요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">BPM이 뭐고 왜 컷 편집에 쓰이나</h2>
<p>BPM은 Beats Per Minute, 즉 <strong>1분에 박자가 몇 번 나오는지</strong>를 뜻해요. 120BPM이면 1분에 박자가 120번, 1초에 2번 나와요. 음악 앱이나 음원 정보에 BPM이 표시되는 경우가 많고, 모를 때는 노래를 들으며 박자에 맞춰 손가락으로 톡톡 치면서 간격을 재서 알아낼 수 있어요. 계산기의 "탭해서 재기" 버튼이 바로 그 방식이에요. 마지막 9번의 평균 간격을 60초로 나눈 값을 보여 줘요.</p>
<p>컷 편집에서 BPM이 중요한 이유는 <strong>컷 전환이 박자 위에 놓일 때</strong> 영상과 음악이 하나처럼 느껴지기 때문이에요. 박자가 울리는 바로 그 순간에 화면이 바뀌면 시각과 청각이 맞물려요. 반대로 박자에서 0.1초만 어긋나도 "뭔가 어색하다"는 느낌이 들어요. 사람이 박자의 어긋남에 꽤 민감하기 때문에 편집자들은 마커를 찍어 두고 컷을 맞춰요.</p>
<h2 id="s2">컷 길이 계산 공식</h2>
<p>공식은 두 단계예요.</p>
<ol><li><strong>한 박의 길이(초)</strong> = 60 ÷ BPM</li><li><strong>컷 길이(초)</strong> = 박 수 × 한 박의 길이</li></ol>
<p>예를 들어 100BPM이면 한 박은 0.6초, 2박은 1.2초, 4박은 2.4초예요. 140BPM이면 한 박은 약 0.429초, 4박은 약 1.714초예요. BPM이 높을수록 같은 박 수여도 컷이 짧아져요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">BPM별 컷 길이 (1박·2박·4박)</figcaption><div class="viz-legend"><span><i class="s1"></i>1박</span><span><i class="s2"></i>2박</span><span><i class="s3"></i>4박</span></div><svg viewBox="0 0 640 300" role="img" aria-label="BPM별 컷 길이 (1박·2박·4박)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">0.5</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1.5</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">3</text><rect class="s1" x="66.4" y="195.0" width="21.1" height="59.0" rx="3"><title>80BPM · 1박 0.75초</title></rect><rect class="s2" x="89.5" y="136.0" width="21.1" height="118.0" rx="3"><title>80BPM · 2박 1.5초</title></rect><rect class="s3" x="112.5" y="18.0" width="21.1" height="236.0" rx="3"><title>80BPM · 4박 3초</title></rect><text class="viz-ax" x="100.0" y="272" text-anchor="middle">80BPM</text><rect class="s1" x="162.4" y="206.8" width="21.1" height="47.2" rx="3"><title>100BPM · 1박 0.6초</title></rect><rect class="s2" x="185.5" y="159.6" width="21.1" height="94.4" rx="3"><title>100BPM · 2박 1.2초</title></rect><rect class="s3" x="208.5" y="65.2" width="21.1" height="188.8" rx="3"><title>100BPM · 4박 2.4초</title></rect><text class="viz-ax" x="196.0" y="272" text-anchor="middle">100BPM</text><rect class="s1" x="258.4" y="214.7" width="21.1" height="39.3" rx="3"><title>120BPM · 1박 0.5초</title></rect><rect class="s2" x="281.5" y="175.3" width="21.1" height="78.7" rx="3"><title>120BPM · 2박 1초</title></rect><rect class="s3" x="304.5" y="96.7" width="21.1" height="157.3" rx="3"><title>120BPM · 4박 2초</title></rect><text class="viz-ax" x="292.0" y="272" text-anchor="middle">120BPM</text><rect class="s1" x="354.4" y="217.1" width="21.1" height="36.9" rx="3"><title>128BPM · 1박 0.469초</title></rect><rect class="s2" x="377.5" y="180.2" width="21.1" height="73.8" rx="3"><title>128BPM · 2박 0.938초</title></rect><rect class="s3" x="400.5" y="106.5" width="21.1" height="147.5" rx="3"><title>128BPM · 4박 1.9초</title></rect><text class="viz-ax" x="388.0" y="272" text-anchor="middle">128BPM</text><rect class="s1" x="450.4" y="220.3" width="21.1" height="33.7" rx="3"><title>140BPM · 1박 0.429초</title></rect><rect class="s2" x="473.5" y="186.6" width="21.1" height="67.4" rx="3"><title>140BPM · 2박 0.857초</title></rect><rect class="s3" x="496.5" y="119.2" width="21.1" height="134.8" rx="3"><title>140BPM · 4박 1.7초</title></rect><text class="viz-ax" x="484.0" y="272" text-anchor="middle">140BPM</text><rect class="s1" x="546.4" y="222.5" width="21.1" height="31.5" rx="3"><title>150BPM · 1박 0.4초</title></rect><rect class="s2" x="569.5" y="191.1" width="21.1" height="62.9" rx="3"><title>150BPM · 2박 0.8초</title></rect><rect class="s3" x="592.5" y="128.1" width="21.1" height="125.9" rx="3"><title>150BPM · 4박 1.6초</title></rect><text class="viz-ax" x="580.0" y="272" text-anchor="middle">150BPM</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">같은 4박 컷이어도 80BPM은 3초, 150BPM은 1.6초로 거의 두 배 차이가 나요.</p><div class="viz-cap">단위: 초 · 자료: 컷 길이 = 박 수 × 60 ÷ BPM 으로 직접 계산 (소수 셋째 자리 반올림)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>1박</th><th>2박</th><th>4박</th></tr></thead><tbody><tr><td>80BPM</td><td>0.75</td><td>1.5</td><td>3</td></tr><tr><td>100BPM</td><td>0.6</td><td>1.2</td><td>2.4</td></tr><tr><td>120BPM</td><td>0.5</td><td>1</td><td>2</td></tr><tr><td>128BPM</td><td>0.469</td><td>0.938</td><td>1.9</td></tr><tr><td>140BPM</td><td>0.429</td><td>0.857</td><td>1.7</td></tr><tr><td>150BPM</td><td>0.4</td><td>0.8</td><td>1.6</td></tr></tbody></table></div></details></figure>
<h2 id="s3">영상 길이에 몇 컷이 들어갈까</h2>
<p>영상 길이를 컷 길이로 나누면 컷 수가 나와요. 120BPM, 30초 영상이면 1박마다 바꿀 때 60컷, 2박마다 30컷, 4박마다 15컷, 8박마다 7컷(남는 길이 2초)이에요. 계산기는 컷 수와 마지막에 남는 길이를 함께 보여 줘요.</p>
<p>컷을 정할 때는 사용할 사진·영상 클립 수를 먼저 세고 거기에 맞춰 박 수를 고르면 쉬워요. 클립이 15개라면 30초 영상에서는 4박 컷이 맞고, 클립이 30개면 2박 컷이 맞아요. 클립이 7~8개뿐이라면 8박 컷으로 길게 보여 주거나, 같은 클립을 두 번 쓰는 방법이 있어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/sns/reels-bpm-cut-timing-guide-1.svg" alt="120BPM에서 4박 컷과 2박 컷이 박자 위에 놓이는 모습과 타임코드를 보여 주는 도식" width="800" height="450" loading="lazy" decoding="async"><figcaption>컷 사이 경계가 박자(●) 위에 놓이는 것이 핵심이에요. 2박 컷은 4박 컷을 반으로 쪼갠 모양이에요</figcaption></figure>
<h2 id="s4">타임코드와 프레임 — 편집 앱에 옮기는 법</h2>
<p>편집 앱은 시간을 "분:초:프레임"(타임코드)으로 보여 주는 경우가 많아요. 프레임은 0부터 fps − 1까지고, 30fps라면 0~29예요. 계산기가 만드는 타임코드는 <strong>초 × fps를 반올림한 프레임</strong>을 분:초:프레임으로 바꾼 거예요.</p>
<p>120BPM·4박·30fps라면 컷 시작은 00:00:00, 00:02:00, 00:04:00처럼 초 단위로 깔끔하게 떨어져요. 같은 BPM이라도 24fps면 2초가 48프레임이라 타임코드는 같은 모양이지만 프레임 숫자의 단위가 달라요. 편집 앱 프로젝트의 fps와 계산기의 fps를 같게 맞추지 않으면 마커 위치가 어긋나니 가장 먼저 확인하세요.</p>
<p>실제 작업 순서는 이래요.</p>
<ol><li>편집 앱에 음악을 올리고, 처음 박자가 나오는 위치를 찾아요(앞에 인트로나 무음이 있으면 그 시간을 "첫 컷 시작 위치"로 넣어요).</li><li>계산기 결과의 타임코드를 보면서 편집 앱의 타임라인에 마커를 찍어요.</li><li>클립의 시작·끝을 마커에 붙이고, 화면 전환 효과를 쓴다면 마커 바로 앞에서 시작하게 해요.</li><li>처음부터 끝까지 한 번 재생해 보고 어긋난 컷만 한두 프레임 조정해요.</li></ol>
<h2 id="s5">128BPM에서 컷이 안 맞는 이유 — 프레임 오차</h2>
<p>한 박이 정수 프레임이 되는 조합은 박자에 정확히 붙지만, 그렇지 않은 조합은 반올림 오차가 생겨요. 30fps 기준으로 한 박이 몇 프레임인지 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>BPM</th><th>한 박(초)</th><th>30fps에서 프레임 수</th></tr></thead><tbody><tr><td>100</td><td>0.6</td><td>18</td></tr><tr><td>120</td><td>0.5</td><td>15</td></tr><tr><td>128</td><td>0.46875</td><td>14.0625</td></tr><tr><td>140</td><td>0.4286</td><td>12.857</td></tr></tbody></table></div>
<p>100BPM과 120BPM은 정수라 오차가 없어요. 128BPM은 14.0625프레임이라 1박 컷 기준으로 컷을 하나 넘길 때마다 0.0625프레임씩 어긋나고, 16컷이 지나면 1프레임이 쌓여요. 140BPM은 12.857프레임이라 컷마다 최대 반 프레임까지 반올림돼요. 반 프레임은 30fps에서 약 0.017초라서 눈으로는 거의 구분이 안 되지만, 음악과 컷을 겹쳐 놓고 보는 편집자에게는 어긋남이 보일 때가 있어요.</p>
<p>해결 방법은 세 가지예요. 첫째, 컷마다 타임코드를 계산해서 <strong>누적 시각으로</strong> 마커를 찍어요. 계산기는 컷 길이를 반복해서 더하는 방식이 아니라 "컷 번호 × 컷 길이"를 반올림하므로 오차가 쌓이지 않아요. 둘째, 편집 앱에서 음악의 파형을 보고 박자 위치에 마커를 직접 찍어요. 셋째, 프레임레이트를 BPM에 맞게 골라요. 예를 들어 60fps는 한 박이 120BPM에서 30프레임, 128BPM에서 28.125프레임이라 24·30fps보다 반올림 오차의 크기가 상대적으로 작아져요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">30fps에서 BPM별 한 박의 프레임 수</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="30fps에서 BPM별 한 박의 프레임 수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="570" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="570" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="570" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">25</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">80BPM</text><text class="viz-ax" x="155.6" y="272" text-anchor="middle">100BPM</text><text class="viz-ax" x="259.2" y="272" text-anchor="middle">120BPM</text><text class="viz-ax" x="362.8" y="272" text-anchor="middle">128BPM</text><text class="viz-ax" x="466.4" y="272" text-anchor="middle">140BPM</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">150BPM</text><polyline class="viz-line s1" points="52.0,41.6 155.6,84.1 259.2,112.4 362.8,121.3 466.4,132.6 570.0,140.7"/><circle class="viz-dot s1" cx="52.0" cy="41.6" r="4"><title>80BPM · 한 박 프레임 22.5프레임</title></circle><circle class="viz-dot s1" cx="155.6" cy="84.1" r="4"><title>100BPM · 한 박 프레임 18프레임</title></circle><circle class="viz-dot s1" cx="259.2" cy="112.4" r="4"><title>120BPM · 한 박 프레임 15프레임</title></circle><circle class="viz-dot s1" cx="362.8" cy="121.3" r="4"><title>128BPM · 한 박 프레임 14.1프레임</title></circle><circle class="viz-dot s1" cx="466.4" cy="132.6" r="4"><title>140BPM · 한 박 프레임 12.9프레임</title></circle><circle class="viz-dot s1" cx="570.0" cy="140.7" r="4"><title>150BPM · 한 박 프레임 12프레임</title></circle><text class="viz-val" x="578.0" y="144.7">12</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">값이 정수(18, 15, 12)인 BPM은 컷을 이어 붙여도 어긋남이 쌓이지 않아요.</p><div class="viz-cap">단위: 프레임 · 자료: 한 박 프레임 = 60 ÷ BPM × 30 으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>한 박 프레임</th></tr></thead><tbody><tr><td>80BPM</td><td>22.5</td></tr><tr><td>100BPM</td><td>18</td></tr><tr><td>120BPM</td><td>15</td></tr><tr><td>128BPM</td><td>14.1</td></tr><tr><td>140BPM</td><td>12.9</td></tr><tr><td>150BPM</td><td>12</td></tr></tbody></table></div></details></figure>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">컷 길이를 섞어서 리듬 만들기</h2>
<p>모든 컷을 같은 길이로 하면 규칙적이지만 단조로워요. 편집에서는 <strong>컷 길이를 섞되 합이 한 마디(4박)의 배수가 되게</strong> 짜는 방법을 많이 써요. 예를 들어 이런 패턴이에요.</p>
<ul><li><strong>4박 + 4박</strong>: 잔잔한 구간. 한 컷을 길게 보여 줘서 안정감을 줘요.</li><li><strong>2박 + 2박 + 4박</strong>: 두 번 빠르게 바뀌었다가 한 번 길게 머무르는 리듬이에요.</li><li><strong>1박 × 4</strong>: 박자마다 컷이 바뀌는 빠른 구간. 후렴이나 클라이맥스에 어울려요.</li><li><strong>8박</strong>: 한 컷이 두 마디 동안 이어지는 아주 느린 구간이에요.</li></ul>
<p>120BPM이라면 4박+4박은 4초, 2박+2박+4박도 4초, 1박×4+4박도 4초예요. 어떤 패턴이든 합이 같으면 다음 마디의 시작이 박자와 계속 맞아요. 합이 맞지 않으면(예: 3박 컷이 끼어든 경우) 다음 컷부터 박자가 한 박씩 밀려요. 의도적으로 박자를 엇나가게 하는 연출도 있지만, 처음에는 합을 맞춰서 시작하는 편이 안전해요.</p>
<p>음악의 구조도 같이 보세요. 많은 곡은 4마디나 8마디 단위로 구간이 바뀌어요. 구간이 바뀌는 지점에서 컷 길이도 바꾸면 영상이 음악의 흐름을 따라가는 느낌을 줘요. 인트로는 4~8박, 벌스는 2~4박, 후렴은 1~2박처럼 정해 두고 시작하면 편집이 빨라져요. 이건 정해진 규칙이 아니라 편집자들이 자주 쓰는 방식이니 자기 영상에 맞게 바꿔도 돼요.</p>
<h2 id="s7">박자가 불규칙한 음원은 어떻게 하나</h2>
<p>모든 곡이 일정한 BPM으로 흐르는 건 아니에요. 곡 중간에 템포가 바뀌거나, 연주 곡처럼 박자가 사람 손으로 미세하게 흔들리는 경우에는 한 번 잰 BPM이 뒤로 갈수록 맞지 않아요. 이럴 때는 구간별로 따로 BPM을 재서 계산해요. 계산기에서 "첫 컷 시작 위치"에 구간 시작 초를 넣고, 영상 길이는 그 구간의 길이로 넣으면 구간별 타임코드를 얻을 수 있어요.</p>
<p>또 3/4박자(왈츠)나 6/8박자 곡은 한 마디가 4박이 아니에요. 이때는 계산기의 박 수 선택지 대신 한 박의 길이(60 ÷ BPM)에 원하는 박 수를 직접 곱해서 컷 길이를 구하세요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">이 방법의 한계</h2>
<ul><li>BPM이 같아도 곡의 "느낌"은 달라요. 박자에 맞춘 컷이 항상 가장 좋은 건 아니고, 일부러 박자를 약간 비켜 가는 것도 연출이에요.</li><li>음원 속 박자와 실제로 들리는 강한 박(강박)은 다를 수 있어요. 컷을 첫 박(강박)에 붙일지 뒷박에 붙일지는 직접 들어 보고 정하세요.</li><li>계산기는 4/4박자를 기본으로 해요. 영상 길이는 편집 앱이 보여 주는 실제 길이를 넣어야 해요.</li><li>음원의 사용 가능 여부는 플랫폼마다 달라요. 어떤 음원을 써도 되는지는 각 앱의 음악 라이브러리와 이용 정책을 확인하세요.</li><li>영상 비율·길이 같은 규격은 <a href="https://tool.smithlab.co.kr/posts/sns/youtube-shorts-size-length-spec/">쇼츠 크기·길이 규격</a> 글을 참고하세요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>BPM을 모르는 노래의 BPM은 어떻게 알아내요?</h3>
<p>노래를 들으면서 박자에 맞춰 탭 버튼을 4번 이상 눌러 보세요. 마지막 9번의 평균 간격으로 BPM을 계산해요. 사람 손의 오차가 있으니 박자가 일정한 구간에서 여러 번 눌러 평균을 내는 것이 좋아요. 음원 정보에 BPM이 적혀 있다면 그 값을 쓰는 것이 더 정확해요.</p>
<h3>4박과 한 마디는 항상 같은가요?</h3>
<p>4/4박자 곡에서는 같아요. 대중음악의 많은 곡이 4/4박자지만, 3/4박자 곡은 한 마디가 3박이에요. 곡의 박자가 다르면 박 수를 직접 곱해서 컷 길이를 구하세요.</p>
<h3>프레임레이트는 뭘로 해야 하나요?</h3>
<p>편집 앱 프로젝트 설정의 fps를 그대로 쓰면 돼요. 스마트폰으로 찍은 영상은 30fps가 많고, 24fps는 영화 같은 느낌을 낼 때 써요. 계산기의 fps와 편집 앱의 fps가 다르면 타임코드의 프레임 숫자가 맞지 않아요.</p>
<h3>컷이 박자에 안 맞을 때 가장 빠른 해결 방법은요?</h3>
<p>마커를 박자 위치에 직접 찍고 컷을 마커에 붙이세요. 계산기의 타임코드 목록을 참고하면 마커를 어디에 찍을지 알 수 있어요. 한 박이 정수 프레임인 BPM·fps 조합(120BPM·30fps 등)은 마커 없이도 어긋나지 않아요.</p>
<h3>음악보다 영상이 짧아도 되나요?</h3>
<p>네. 영상 길이만큼만 컷을 계산해요. 음악의 중간 구간을 쓰고 싶다면 그 구간의 시작 초를 "첫 컷 시작 위치"에 넣어서 계산하면 돼요.</p>
<h2 id="s10">정리</h2>
<p>컷 편집을 박자에 맞추는 계산은 <strong>한 박 = 60 ÷ BPM초, 컷 = 박 수 × 한 박</strong>이 전부예요. 120BPM·30fps처럼 한 박이 정수 프레임이면 마커 없이도 깔끔하게 맞고, 128BPM처럼 정수가 아니면 컷 번호 × 컷 길이로 누적 시각을 계산해 오차가 쌓이지 않게 하세요. 컷 길이를 섞을 때는 합이 한 마디의 배수가 되게 짜면 박자가 밀리지 않아요. 지금 쓰려는 음악의 BPM과 영상 길이로 타임코드를 뽑아 보려면 <a href="https://tool.smithlab.co.kr/sns/bpm-cut-edit-calculator/">BPM 컷 편집 계산기</a>를 써 보세요. 챕터 시간 표시가 필요하면 <a href="https://tool.smithlab.co.kr/sns/youtube-chapter-generator/">유튜브 챕터 생성기</a>, 영상 위 설명 글의 줄바꿈은 <a href="https://tool.smithlab.co.kr/sns/instagram-line-break/">인스타 줄바꿈 도구</a>가 도와줘요.</p>
    ]]></description>
    </item>
    <item>
      <title>매직넘버 계산법, 144경기 85승 50패면 왜 5일까? 트래직넘버까지</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/magic-number-formula-clinch-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/magic-number-formula-clinch-guide/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>시즌 막바지가 되면 "우승 매직넘버 5"라는 말이 중계와 기사에 계속 나와요. 처음 들으면 어디서 나온 숫자인지 감이 안 오지만, 계산은 덧셈과 뺄셈이 전부예요. 결론부터 말하면 <strong>매직넘버 = 총 경기 수 + 1 − 내 팀 승 − 상대 팀 패</strong>예요. 144경기 시즌에 85승 50패인 팀과 80승 55패인 팀이라면 144 + 1 − 85 − 55 = 5, 내 팀이 앞으로 5승을 하면(또는 상대가 지는 경기를 합쳐 5번이 쌓이면) 순위가 확정돼요.</p>
<p>이 글은 그 식이 왜 나오는지, 숫자 하나가 하루에 얼마나 줄어드는지, 뒤처진 팀이 쓰는 트래직넘버와 자력 확정 조건은 무엇인지, 무승부와 동률 규칙 때문에 실제 중계 숫자와 어긋나는 경우는 언제인지를 직접 계산한 숫자로 정리했어요. 값을 직접 넣어 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/magic-number-calculator/">매직넘버 계산기</a>를 같이 열어 두세요. 아래 표의 값은 파이썬으로 남은 경기 결과를 전부 열거해 확인한 값이에요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>매직넘버 M = G + 1 − 내 승 − 상대 패 (G는 시즌 총 경기 수)예요. 내 팀이 이기거나 상대 팀이 지는 경기 하나마다 1씩 줄어요.</li><li>M이 내 남은 경기 수 이하이면 상대가 어떻게 해도 내 힘으로 확정할 수 있어요(자력 확정). 크면 상대가 져 주어야 해요.</li><li>두 팀이 같은 경기 수를 치렀다면 M = 남은 경기 + 1 − 게임차예요. 게임차가 클수록, 남은 경기가 적을수록 작아져요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">매직넘버가 뜻하는 것</h2>
<p>매직넘버는 "이 숫자만큼의 사건이 더 일어나면 상대가 어떤 결과를 내도 순위가 뒤집히지 않는다"는 값이에요. 사건은 두 종류예요. 내 팀의 승리 한 번, 그리고 비교 대상 팀의 패배 한 번이에요. 둘 중 어느 쪽이든 1씩 줄어요. 중계에서 "오늘 이기고 2위가 지면서 매직넘버가 2 줄었다"고 말하는 것이 이 때문이에요.</p>
<p>매직넘버는 <strong>한 팀과의 비교</strong>예요. 보통은 1위를 바짝 따라오는 2위 팀과 비교하지만, 플레이오프 막차 경쟁이라면 커트라인 바로 아래 팀이 비교 대상이에요. 비교 팀이 여럿이면 팀마다 매직넘버를 구해서 가장 큰 값이 진짜 확정 조건이에요.</p>
<h2 id="s2">공식이 나오는 과정</h2>
<p>시즌 총 경기를 G, 내 팀의 현재 승을 a, 패를 b, 상대 팀의 승을 c, 패를 d라고 할게요.</p>
<ol><li>상대 팀이 남은 경기를 전부 이기면 최종 승수는 G − d승이에요. 이게 상대가 낼 수 있는 최대 승수예요.</li><li>내 팀 최종 승수가 이 값보다 하나라도 많으면 상대가 아무리 잘해도 앞서요. 즉 G − d + 1승이 필요해요.</li><li>지금 a승이니 더 필요한 승수는 (G − d + 1) − a, 정리하면 <strong>G + 1 − a − d</strong>예요.</li></ol>
<p>이 식에 c(상대의 현재 승)와 b(나의 패)는 안 들어간다는 점이 중요해요. 상대의 현재 승은 이미 남은 경기 수에 반영되어 있고, 내 패는 내 남은 경기 수를 줄여서 자력 확정 가능 여부에만 쓰여요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/magic-number-formula-clinch-guide-1.svg" alt="매직넘버 공식을 144경기 예시로 풀어 쓴 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>144경기, 85승 50패 대 80승 55패의 매직넘버 계산</figcaption></figure>
<h2 id="s3">직접 계산: 144경기 85승 50패 대 80승 55패</h2>
<ul><li>상대 팀 최대 승수: 144 − 55 = 89승</li><li>내 팀이 필요한 최종 승수: 89 + 1 = 90승</li><li>지금 85승이므로 더 필요한 승수: 90 − 85 = <strong>5</strong></li><li>내 팀 남은 경기: 144 − 85 − 50 = 9경기. 5 ≤ 9이므로 자력 확정이 가능해요.</li></ul>
<p>반대쪽 입장의 숫자도 계산할 수 있어요. 상대 팀이 내 팀을 따라잡을 수 없게 되는 <strong>트래직넘버</strong>는 144 + 1 − 80 − 50 = 15예요. 내 팀의 패가 늘거나 상대 팀의 승이 쌓여 15가 되면 상대의 이번 시즌 앞서기는 불가능해요. 매직넘버와 트래직넘버는 "누구 입장에서 보느냐"만 다르고 식은 같아요.</p>
<h2 id="s4">하루에 숫자가 줄어드는 방식</h2>
<p>위 예시에서 하루하루 결과가 이어진다고 해 볼게요. 내 팀이 이기고 상대 팀이 지는 날은 5에서 3으로, 다시 한 번 더 같은 결과가 나오면 1로 줄어요. 한 번 더 같은 결과가 나오면 0 이하가 되어 확정이에요.</p>
<div class="table-wrap"><table><thead><tr><th>이 기간의 결과</th><th>내 팀</th><th>상대 팀</th><th>매직넘버</th></tr></thead><tbody><tr><td>시작</td><td>85승 50패</td><td>80승 55패</td><td>5</td></tr><tr><td>내 승 1 · 상대 패 1</td><td>86승 50패</td><td>80승 56패</td><td>3</td></tr><tr><td>내 승 2 · 상대 패 2</td><td>87승 50패</td><td>80승 57패</td><td>1</td></tr><tr><td>내 승 3 · 상대 패 3</td><td>88승 50패</td><td>80승 58패</td><td>0 이하(확정)</td></tr></tbody></table></div>
<p>반대로 내 팀이 지고 상대 팀이 이기는 날은 숫자가 줄지 않고 그대로예요. 오히려 내 팀의 남은 경기가 줄어서 자력 확정이 어려워질 수 있어요. 매직넘버가 같아 보여도 남은 경기가 얼마인지에 따라 안정감이 달라요.</p>
<h2 id="s5">게임차와 남은 경기로 보는 매직넘버</h2>
<p>게임차는 ((내 승 − 상대 승) + (상대 패 − 내 패)) ÷ 2예요. 두 팀이 같은 경기 수를 치렀다면 매직넘버는 <strong>남은 경기 + 1 − 게임차</strong>로 쓸 수 있어요. 144경기 시즌에서 120경기를 치른 시점의 예시예요.</p>
<div class="table-wrap"><table><thead><tr><th>내 팀</th><th>상대 팀</th><th>게임차</th><th>남은 경기</th><th>매직넘버</th><th>자력 확정</th></tr></thead><tbody><tr><td>70승 50패</td><td>65승 55패</td><td>5.0</td><td>24</td><td>20</td><td>가능</td></tr><tr><td>68승 52패</td><td>65승 55패</td><td>3.0</td><td>24</td><td>22</td><td>가능</td></tr><tr><td>66승 54패</td><td>65승 55패</td><td>1.0</td><td>24</td><td>24</td><td>가능(남은 전부 승리 필요)</td></tr><tr><td>65승 55패</td><td>65승 55패</td><td>0.0</td><td>24</td><td>25</td><td>불가</td></tr></tbody></table></div>
<p>게임차가 0이면 매직넘버가 남은 경기보다 하나 커서 내 힘만으로는 확정할 수 없어요. 게임차가 1이면 남은 경기를 전승해야 겨우 확정이에요. 이 구간에서는 "자력 우승 가능"이라는 말이 있어도 사실상 여유가 전혀 없다는 뜻이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">게임차 3.0이 유지될 때 경기 수별 매직넘버</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="게임차 3.0이 유지될 때 경기 수별 매직넘버" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">50</text><rect class="s1" x="73.6" y="55.8" width="100.8" height="198.2" rx="3"><title>100경기 · 매직넘버 42매직넘버</title></rect><text class="viz-val" x="124.0" y="50.8" text-anchor="middle">42</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">100경기</text><rect class="s1" x="217.6" y="150.2" width="100.8" height="103.8" rx="3"><title>120경기 · 매직넘버 22매직넘버</title></rect><text class="viz-val" x="268.0" y="145.2" text-anchor="middle">22</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">120경기</text><rect class="s1" x="361.6" y="206.8" width="100.8" height="47.2" rx="3"><title>132경기 · 매직넘버 10매직넘버</title></rect><text class="viz-val" x="412.0" y="201.8" text-anchor="middle">10</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">132경기</text><rect class="s1" x="505.6" y="244.6" width="100.8" height="9.4" rx="3"><title>140경기 · 매직넘버 2매직넘버</title></rect><text class="viz-val" x="556.0" y="239.6" text-anchor="middle">2</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">140경기</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">게임차가 같아도 시즌이 끝나 갈수록 매직넘버는 빠르게 줄어요.</p><div class="viz-cap">단위: 매직넘버 · 자료: 144경기 시즌, 게임차 3.0 유지 가정, 식 G+1−a−d로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>매직넘버</th></tr></thead><tbody><tr><td>100경기</td><td>42</td></tr><tr><td>120경기</td><td>22</td></tr><tr><td>132경기</td><td>10</td></tr><tr><td>140경기</td><td>2</td></tr></tbody></table></div></details></figure>
<p>게임차 3.0이 유지된다면 100경기 시점에서는 매직넘버가 42, 120경기에서는 22, 132경기에서는 10, 140경기에서는 2예요. 같은 3게임 차이도 시즌 초반에는 거의 의미가 없고 막판에는 사실상 결정적인 격차라는 것을 숫자가 보여 줘요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">트래직넘버와 자력 확정</h2>
<p><strong>트래직넘버</strong>는 뒤처진 팀의 입장에서 계산해요. 위 예시에서 상대 팀(80승 55패)의 트래직넘버는 15예요. 이 숫자가 0 이하가 되면 이 순위는 수학적으로 불가능해요. 포기해야 한다는 뜻이 아니라, 아직 가능성이 남아 있는 상태인지 판단하는 기준이에요.</p>
<p><strong>자력 확정</strong>은 매직넘버가 내 남은 경기 수 이하라는 뜻이에요. 앞 표의 마지막 행처럼 매직넘버(25)가 남은 경기(24)보다 크면 남은 경기를 전승해도 부족하고, 상대가 어느 정도 져 주어야 해요. 이 경우 계산기는 "상대가 몇 번 이상 져야 하는지"를 같이 보여 줘요. 25 − 24 = 1이므로 상대 팀이 1번 이상 져야 해요.</p>
<h2 id="s7">농구·배구 같은 다른 리그에도 쓸 수 있을까</h2>
<p>경기 수가 모든 팀에 같고 무승부가 없는 리그라면 같은 식을 그대로 써요. 예를 들어 총 82경기 시즌에서 50승 20패 팀과 48승 24패 팀이면 82 + 1 − 50 − 24 = 9, 남은 경기는 12라서 자력 확정이 가능해요. 승점제 리그는 다른 이야기예요. 승점 3점 승리·1점 무승부 방식은 위 식을 그대로 쓸 수 없고, 남은 경기 최대 승점으로 따로 계산해야 해요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">중계 숫자와 다르게 나오는 이유</h2>
<ul><li><strong>무승부</strong>: 무승부가 있는 리그에서는 승률로 순위를 정하는 경우가 많아서, 승수만으로 계산한 매직넘버와 한두 개 차이 날 수 있어요.</li><li><strong>동률 규칙</strong>: 승수가 같을 때 상대 전적 같은 규칙으로 순위를 정하는 리그에서는 동률이 확정으로 인정될 수 있어서 실제 매직넘버가 1 작을 수 있어요. 이 글과 계산기는 동률이 아닌 "더 많이 이기는" 경우만 확정으로 봐요.</li><li><strong>경기 수가 다른 팀</strong>: 팀마다 치른 경기가 달라도 시즌 총 경기는 같아요. 식은 같지만 승률로 순위를 정하는 리그에서는 취소·연기 경기가 영향을 줄 수 있어요.</li><li><strong>여러 팀이 얽힐 때</strong>: 팀 세 곳이 경쟁하면 각 팀과의 매직넘버를 따로 구하고 가장 큰 값을 써야 해요. 예를 들어 144경기에서 90승 50패 팀이 88승 52패 팀과 비교하면 3, 86승 54패 팀과 비교하면 1이라서 실제 기준은 3이에요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>매직넘버는 어떻게 계산하나요?</h3>
<p>총 경기 수 + 1 − 내 팀 승 − 상대 팀 패예요. 144경기 시즌에 85승 50패와 80승 55패라면 144 + 1 − 85 − 55 = 5예요.</p>
<h3>매직넘버와 게임차는 어떤 관계인가요?</h3>
<p>두 팀이 같은 경기 수를 치렀다면 매직넘버는 남은 경기 + 1 − 게임차예요. 120경기를 치른 144경기 시즌에서 게임차 3.0이면 24 + 1 − 3 = 22예요.</p>
<h3>매직넘버가 남은 경기보다 크면 어떻게 되나요?</h3>
<p>내 팀이 남은 경기를 전승해도 상대가 어떤 결과를 내느냐에 따라 확정이 안 될 수 있어요. 필요한 최소 상대 패는 매직넘버에서 내 남은 경기를 뺀 값이에요.</p>
<h3>트래직넘버는 무엇인가요?</h3>
<p>뒤처진 팀이 비교 팀을 따라잡을 수 없게 되는 숫자예요. 총 경기 수 + 1 − 앞선 팀 승 − 내 팀 패로 계산하고 0 이하가 되면 불가능해요.</p>
<h3>무승부가 있는 리그에서도 같은 식을 쓸 수 있나요?</h3>
<p>승수 기준 근사값이에요. 승률로 순위를 정하는 리그에서는 공식 순위표와 차이가 날 수 있으니, 아슬아슬한 구간에서는 리그가 발표한 숫자를 확인하세요.</p>
<h3>동률이면 확정이 아닌가요?</h3>
<p>리그 규정에 따라 달라요. 계산기는 동률이 되지 않는 경우만 확정으로 보므로, 동률에서도 내 팀이 앞서는 규정이라면 실제 매직넘버는 1 작을 수 있어요.</p>
<h2 id="s10">정리</h2>
<p>매직넘버는 어려운 확률이 아니라 "상대의 최대 승수보다 하나 더 이기면 끝"이라는 단순한 산술에서 나와요. 식은 G + 1 − 내 승 − 상대 패 하나이고, 경기 하루하루 1~2씩 줄어요. 남은 경기와 비교하면 자력 확정인지 아닌지가 바로 보여요. 직접 넣어 보려면 <a href="https://tool.smithlab.co.kr/calculator/magic-number-calculator/">매직넘버 계산기</a>를, 승률이 목표까지 얼마나 필요한지는 <a href="https://tool.smithlab.co.kr/calculator/win-rate-streak-calculator/">승률 올리기 계산기</a>를, 시리즈 우승 확률은 <a href="https://tool.smithlab.co.kr/calculator/series-win-probability-calculator/">선승제 시리즈 우승 확률 계산기</a>를 함께 쓰세요. 경우의 수와 조합 같은 바탕 개념은 고등학교 수학 교육과정에 나오고, 문서는 <a href="https://ncic.re.kr" target="_blank" rel="noopener">국가교육과정정보센터</a>에서 볼 수 있어요. 실제 순위와 경기 수는 각 리그가 발표하는 공식 순위표를 기준으로 하세요. 개념이 궁금하면 <a href="https://en.wikipedia.org/wiki/Magic_number_(sports" target="_blank" rel="noopener">Magic number (sports) (위키백과)</a>)를 참고하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>송년회 날짜가 안 잡히는 이유 — 6명 각자 80%여도 전원 확률 26%</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/meetup-date-all-free-probability-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/meetup-date-all-free-probability-guide/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>연말이 다가오면 단체 대화방마다 같은 장면이 반복돼요. "12월에 한번 모이자"까지는 빠르게 합의가 되는데, 날짜를 정하는 순간부터 대화가 멈춰요. 한 사람은 둘째 주가 안 되고, 다른 사람은 셋째 주 금요일이 안 되고, 결국 "다음에 보자"로 끝나기도 해요. 결론부터 말하면 <strong>이건 모임이 소홀해서가 아니라 확률 때문이에요.</strong> 6명이 각자 80% 확률로 그 날짜에 가능하다고 해도, 한 날짜에 6명이 모두 가능할 확률은 0.8을 여섯 번 곱한 <strong>26.2%</strong> 밖에 안 돼요.</p>
<p>이 글은 "전원이 가능한 날이 나올 확률"을 직접 계산해서 보여 주고, 후보 날짜를 몇 개 던져야 하는지, 전원이 아니라 몇 명 이상을 기준으로 삼으면 얼마나 쉬워지는지, 연말처럼 모두가 바쁜 시기에는 어떻게 달라지는지를 숫자로 정리해요. 모든 값은 사람마다 일정이 서로 독립이라는 단순한 가정으로 계산했고, 파이썬과 node로 따로 계산해 일치하는 것과 무작위 시뮬레이션으로도 확인했어요. 직접 숫자를 바꿔 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/meetup-attendance-probability-calculator/">송년회 전원 모일 확률 계산기</a>를 같이 열어 두세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>한 날짜에 전원이 가능할 확률은 각자 가능할 확률 p를 인원 N번 곱한 p^N 이에요. 각자 80%여도 6명이면 26.2%, 10명이면 10.7%, 20명이면 1.2%예요.</li><li>후보 날짜 K개 중 하루라도 전원 가능할 확률은 1 − (1 − p^N)^K 예요. 6명·80%에서 후보 3개면 59.8%, 90%를 넘기려면 8개가 필요해요.</li><li>전원이 아니라 "6명 중 5명 이상"을 기준으로 하면 한 날짜 성공 확률이 26.2%에서 65.5%로 뛰어요.</li><li>연말 피크 주간처럼 각자 확률이 50%로 떨어지면 6명 전원은 한 날짜에 1.6%예요. 이때는 일정을 먼저 정하고 사람을 맞추는 쪽이 현실적이에요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">왜 "다 된다"고 하는데 날짜는 안 잡힐까</h2>
<p>사람들은 보통 "나는 거의 될 것 같은데"라고 말하고, 그 말은 대략 70~90% 정도의 확률이에요. 한 사람 기준으로는 꽤 높은 확률이죠. 문제는 모임이 한 사람이 아니라 여러 사람이 동시에 가능해야 성립한다는 점이에요. 독립적인 사건이 모두 일어날 확률은 각각의 확률을 곱해서 구해요. 곱셈은 1보다 작은 수에서 값을 빠르게 줄이는 연산이에요.</p>
<p>0.8을 한 번 곱하면 0.8이지만, 두 번 곱하면 0.64, 세 번이면 0.512, 여섯 번이면 0.262예요. 사람이 늘 때마다 약 20%씩 깎여 나간다고 생각하면 돼요. 이 구조 때문에 "한 사람 한 사람은 괜찮은데 전체로는 안 되는" 일이 생겨요. 친구 3명 모임은 한 날짜에 51.2%로 반반이지만, 직장 부서 15명은 3.5%라서 한 날짜만 정해 놓고 "다 돼요?"라고 묻는 방식은 거의 반드시 실패해요.</p>
<p>여기에 후보 날짜를 하나만 제시하는지, 여러 개를 동시에 제시하는지가 더해져요. 날짜를 한 개씩 순서대로 제안하는 방식은 한 번 실패할 때마다 대화 한 바퀴가 지나가고, 사람들은 그 사이 다른 약속을 잡아 버려요. 확률은 같은데 시간만 흘러서 오히려 가능 확률이 낮아지는 악순환이 생기죠.</p>
<h2 id="s2">한 날짜에 전원이 가능할 확률 계산</h2>
<p>공식은 단순해요. 각자 가능할 확률이 p이고 인원이 N명이면 한 날짜에 모두 가능할 확률은 p^N 이에요. 예를 들어 6명이 모두 80%라면 0.8^6 = 0.262144 로 약 26.2%예요. 이 값을 파이썬·node·20만 번의 무작위 시뮬레이션(0.262±0.006)으로 모두 확인했어요.</p>
<div class="table-wrap"><table><thead><tr><th>인원</th><th>각자 60%</th><th>70%</th><th>80%</th><th>90%</th><th>95%</th></tr></thead><tbody><tr><td>3명</td><td>21.6%</td><td>34.3%</td><td>51.2%</td><td>72.9%</td><td>85.7%</td></tr><tr><td>4명</td><td>13.0%</td><td>24.0%</td><td>41.0%</td><td>65.6%</td><td>81.5%</td></tr><tr><td>6명</td><td>4.7%</td><td>11.8%</td><td>26.2%</td><td>53.1%</td><td>73.5%</td></tr><tr><td>8명</td><td>1.7%</td><td>5.8%</td><td>16.8%</td><td>43.0%</td><td>66.3%</td></tr><tr><td>10명</td><td>0.6%</td><td>2.8%</td><td>10.7%</td><td>34.9%</td><td>59.9%</td></tr><tr><td>15명</td><td>0.0%</td><td>0.5%</td><td>3.5%</td><td>20.6%</td><td>46.3%</td></tr><tr><td>20명</td><td>0.0%</td><td>0.1%</td><td>1.2%</td><td>12.2%</td><td>35.8%</td></tr></tbody></table></div>
<p>이 표에서 눈여겨볼 곳이 몇 군데 있어요. 첫째, 각자 95%로 거의 확실히 올 수 있는 사람들만 모여도 20명이면 35.8%예요. 둘째, 각자 90%로 높은 편이어도 10명이면 34.9%, 15명이면 20.6%예요. 셋째, 각자 60%처럼 현실적인 평일 확률에서는 6명만 돼도 4.7%라서, 한 날짜를 정해서 전원이 되기를 기대하는 건 사실상 불가능해요. 이 표는 "일정이 안 맞는 건 정상"이라는 걸 보여 주는 표예요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">각자 80% 가능할 때 인원별 한 날짜 전원 가능 확률</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="각자 80% 가능할 때 인원별 한 날짜 전원 가능 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">80</text><rect class="s1" x="66.4" y="65.2" width="67.2" height="188.8" rx="3"><title>2명 · 전원 가능 64%</title></rect><text class="viz-val" x="100.0" y="60.2" text-anchor="middle">64</text><text class="viz-ax" x="100.0" y="272" text-anchor="middle">2명</text><rect class="s1" x="162.4" y="133.1" width="67.2" height="120.9" rx="3"><title>4명 · 전원 가능 41%</title></rect><text class="viz-val" x="196.0" y="128.1" text-anchor="middle">41</text><text class="viz-ax" x="196.0" y="272" text-anchor="middle">4명</text><rect class="s1" x="258.4" y="176.7" width="67.2" height="77.3" rx="3"><title>6명 · 전원 가능 26.2%</title></rect><text class="viz-val" x="292.0" y="171.7" text-anchor="middle">26.2</text><text class="viz-ax" x="292.0" y="272" text-anchor="middle">6명</text><rect class="s1" x="354.4" y="204.4" width="67.2" height="49.6" rx="3"><title>8명 · 전원 가능 16.8%</title></rect><text class="viz-val" x="388.0" y="199.4" text-anchor="middle">16.8</text><text class="viz-ax" x="388.0" y="272" text-anchor="middle">8명</text><rect class="s1" x="450.4" y="222.4" width="67.2" height="31.6" rx="3"><title>10명 · 전원 가능 10.7%</title></rect><text class="viz-val" x="484.0" y="217.4" text-anchor="middle">10.7</text><text class="viz-ax" x="484.0" y="272" text-anchor="middle">10명</text><rect class="s1" x="546.4" y="233.6" width="67.2" height="20.4" rx="3"><title>12명 · 전원 가능 6.9%</title></rect><text class="viz-val" x="580.0" y="228.6" text-anchor="middle">6.9</text><text class="viz-ax" x="580.0" y="272" text-anchor="middle">12명</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">인원이 2명씩 늘 때마다 확률이 0.64배로 줄어요.</p><div class="viz-cap">단위: % · 자료: p^N 공식으로 직접 계산 (사람마다 일정이 독립이라는 가정)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>전원 가능</th></tr></thead><tbody><tr><td>2명</td><td>64</td></tr><tr><td>4명</td><td>41</td></tr><tr><td>6명</td><td>26.2</td></tr><tr><td>8명</td><td>16.8</td></tr><tr><td>10명</td><td>10.7</td></tr><tr><td>12명</td><td>6.9</td></tr></tbody></table></div></details></figure>
<h2 id="s3">후보 날짜를 늘리면 얼마나 좋아질까</h2>
<p>한 날짜의 전원 가능 확률이 q = p^N 이면, 후보 K개를 모두 실패할 확률은 (1 − q)^K 이고, 하나라도 성공할 확률은 1 − (1 − q)^K 예요. 후보를 늘리면 반드시 좋아지지만, q가 작을수록 늘어나는 속도가 느려요.</p>
<div class="table-wrap"><table><thead><tr><th>후보 날짜</th><th>4명</th><th>6명</th><th>8명</th><th>10명</th></tr></thead><tbody><tr><td>1일</td><td>41.0%</td><td>26.2%</td><td>16.8%</td><td>10.7%</td></tr><tr><td>2일</td><td>65.1%</td><td>45.6%</td><td>30.7%</td><td>20.3%</td></tr><tr><td>3일</td><td>79.4%</td><td>59.8%</td><td>42.4%</td><td>28.9%</td></tr><tr><td>5일</td><td>92.8%</td><td>78.1%</td><td>60.1%</td><td>43.3%</td></tr><tr><td>8일</td><td>98.5%</td><td>91.2%</td><td>77.0%</td><td>59.7%</td></tr></tbody></table></div>
<p>6명·80%에서 후보 3개면 59.8%예요. 직접 계산하면 q = 0.262144 이고 (1 − 0.262144)^3 = 0.7379^3 ≈ 0.4017 이라서 하나라도 성공할 확률은 1 − 0.4017 = 59.8%예요. 5개면 78.1%, 8개면 91.2%라서 성공률 90%를 넘기려면 후보가 8개 필요해요. 10명이면 상황이 훨씬 나빠져요. 후보 5개로도 43.3%예요. 이때 필요한 후보 날짜는 21개가 넘어요. 사실상 달력의 한 달을 전부 던져야 하는 셈이에요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">각자 80% 가능할 때 후보 날짜 수별 하루라도 성공할 확률</figcaption><div class="viz-legend"><span><i class="s1"></i>10명 전원</span><span><i class="s2"></i>10명 중 8명 이상</span></div><svg viewBox="0 0 640 300" role="img" aria-label="각자 80% 가능할 때 후보 날짜 수별 하루라도 성공할 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="570" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">1일</text><text class="viz-ax" x="181.5" y="272" text-anchor="middle">3일</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">5일</text><text class="viz-ax" x="440.5" y="272" text-anchor="middle">8일</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">12일</text><polyline class="viz-line s1" points="52.0,228.7 181.5,185.8 311.0,151.8 440.5,113.1 570.0,78.4"/><circle class="viz-dot s1" cx="52.0" cy="228.7" r="4"><title>1일 · 10명 전원 10.7%</title></circle><circle class="viz-dot s1" cx="181.5" cy="185.8" r="4"><title>3일 · 10명 전원 28.9%</title></circle><circle class="viz-dot s1" cx="311.0" cy="151.8" r="4"><title>5일 · 10명 전원 43.3%</title></circle><circle class="viz-dot s1" cx="440.5" cy="113.1" r="4"><title>8일 · 10명 전원 59.7%</title></circle><circle class="viz-dot s1" cx="570.0" cy="78.4" r="4"><title>12일 · 10명 전원 74.4%</title></circle><text class="viz-val" x="578.0" y="82.4">74.4</text><polyline class="viz-line s2" points="52.0,94.0 181.5,25.8 311.0,18.7 440.5,18.0 570.0,18.0"/><circle class="viz-dot s2" cx="52.0" cy="94.0" r="4"><title>1일 · 10명 중 8명 이상 67.8%</title></circle><circle class="viz-dot s2" cx="181.5" cy="25.8" r="4"><title>3일 · 10명 중 8명 이상 96.7%</title></circle><circle class="viz-dot s2" cx="311.0" cy="18.7" r="4"><title>5일 · 10명 중 8명 이상 99.7%</title></circle><circle class="viz-dot s2" cx="440.5" cy="18.0" r="4"><title>8일 · 10명 중 8명 이상 100%</title></circle><circle class="viz-dot s2" cx="570.0" cy="18.0" r="4"><title>12일 · 10명 중 8명 이상 100%</title></circle><text class="viz-val" x="578.0" y="22.0">100</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">전원을 고집하면 후보 12개로도 부족하지만 8명 이상이면 3개로 96.7%예요.</p><div class="viz-cap">단위: % · 자료: 1 − (1 − q)^K 공식으로 직접 계산 (날짜끼리 독립이라는 가정)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>10명 전원</th><th>10명 중 8명 이상</th></tr></thead><tbody><tr><td>1일</td><td>10.7</td><td>67.8</td></tr><tr><td>3일</td><td>28.9</td><td>96.7</td></tr><tr><td>5일</td><td>43.3</td><td>99.7</td></tr><tr><td>8일</td><td>59.7</td><td>100</td></tr><tr><td>12일</td><td>74.4</td><td>100</td></tr></tbody></table></div></details></figure>
<h2 id="s4">90% 성공하려면 후보 날짜가 며칠 필요할까</h2>
<p>성공률 90%를 넘기는 데 필요한 최소 후보 날짜 수를 인원과 각자 확률별로 계산했어요.</p>
<div class="table-wrap"><table><thead><tr><th>인원</th><th>각자 50%</th><th>70%</th><th>80%</th><th>90%</th></tr></thead><tbody><tr><td>4명</td><td>36일</td><td>9일</td><td>5일</td><td>3일</td></tr><tr><td>6명</td><td>147일</td><td>19일</td><td>8일</td><td>4일</td></tr><tr><td>8명</td><td>589일</td><td>39일</td><td>13일</td><td>5일</td></tr><tr><td>10명</td><td>2357일</td><td>81일</td><td>21일</td><td>6일</td></tr></tbody></table></div>
<p>각자 확률이 50%로 낮아지는 연말 피크 주간에는 6명이어도 후보 147일이 필요해요. 사실상 불가능하다는 뜻이에요. 반대로 각자 90%인 경우에는 4명이 3일, 6명이 5일이면 충분해요. 이 표의 교훈은 "인원이 많거나 평소에 바쁜 사람들일수록 후보 날짜를 늘리는 건 한계가 있다"는 거예요. 그때는 다음 장에서 설명하는 기준 완화가 훨씬 효과적이에요.</p>
<h2 id="s5">전원이 아니라 "몇 명 이상"으로 바꾸면</h2>
<p>전원 참석 대신 "N명 중 M명 이상"을 기준으로 하면 이항분포로 계산해요. 정확히 i명이 올 확률은 C(N, i) × p^i × (1 − p)^(N − i) 이고, M명 이상은 i가 M부터 N까지 더한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>기준</th><th>각자 60%</th><th>70%</th><th>80%</th><th>90%</th></tr></thead><tbody><tr><td>6명 중 5명 이상</td><td>23.3%</td><td>42.0%</td><td>65.5%</td><td>88.6%</td></tr><tr><td>8명 중 6명 이상</td><td>31.5%</td><td>55.2%</td><td>79.7%</td><td>96.2%</td></tr><tr><td>10명 중 8명 이상</td><td>16.7%</td><td>38.3%</td><td>67.8%</td><td>93.0%</td></tr><tr><td>12명 중 9명 이상</td><td>22.5%</td><td>49.3%</td><td>79.5%</td><td>97.4%</td></tr></tbody></table></div>
<p>6명·80%에서 전원은 26.2%였지만 "5명 이상"이면 65.5%예요. 10명·80%에서 전원은 10.7%인데 "8명 이상"이면 67.8%이고, 후보 3개를 던지면 96.7%까지 올라가요. 같은 모임인데 기준만 바꿨을 뿐인데 성공 확률이 몇 배로 뛰는 거예요. 물론 모임의 성격에 따라 한 명이라도 빠지면 안 되는 자리가 있어요. 결혼식 전 가족 상견례나 팀 송년회에서 팀장이 꼭 와야 하는 경우가 그렇죠. 그런 모임은 반대로 <strong>빠질 수 없는 사람의 일정을 먼저 받고</strong> 후보 날짜를 정하세요. 나머지 사람들만 확률로 계산하면 N이 줄어들어 훨씬 쉬워져요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/meetup-date-all-free-probability-guide-1.svg" alt="전원 가능 확률이 곱셈으로 줄어드는 과정과 기준 완화 효과를 보여 주는 도식" width="800" height="450" loading="lazy" decoding="async"><figcaption>각자 80%일 때 사람이 늘 때마다 확률이 0.8배씩 줄고, 기준을 낮추면 다시 올라가요</figcaption></figure>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">연말에는 각자 확률 자체가 떨어져요</h2>
<p>12월 둘째 주부터 마지막 주까지는 회사 송년회, 동창 모임, 가족 모임이 한꺼번에 몰려요. 평소 평일 저녁에 80%였던 사람이 이 시기에는 50% 안팎으로 내려가는 일이 흔해요. 같은 6명 모임에서 확률이 80%에서 50%로 내려가면 한 날짜 전원 가능 확률이 26.2%에서 1.6%로 줄어요. 16분의 1로 줄어드는 거예요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">6명 모임에서 각자 확률별 후보 3일 중 하루라도 전원 가능할 확률</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="6명 모임에서 각자 확률별 후보 3일 중 하루라도 전원 가능할 확률" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">100</text><rect class="s1" x="73.6" y="222.4" width="100.8" height="31.6" rx="3"><title>각자 60% · 후보 3일 13.4%</title></rect><text class="viz-val" x="124.0" y="217.4" text-anchor="middle">13.4</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">각자 60%</text><rect class="s1" x="217.6" y="180.1" width="100.8" height="73.9" rx="3"><title>70% · 후보 3일 31.3%</title></rect><text class="viz-val" x="268.0" y="175.1" text-anchor="middle">31.3</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">70%</text><rect class="s1" x="361.6" y="112.9" width="100.8" height="141.1" rx="3"><title>80% · 후보 3일 59.8%</title></rect><text class="viz-val" x="412.0" y="107.9" text-anchor="middle">59.8</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">80%</text><rect class="s1" x="505.6" y="42.3" width="100.8" height="211.7" rx="3"><title>90% · 후보 3일 89.7%</title></rect><text class="viz-val" x="556.0" y="37.3" text-anchor="middle">89.7</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">90%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><div class="viz-cap">단위: % · 자료: 1 − (1 − p^6)^3 공식으로 직접 계산 (일정 독립 가정)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>후보 3일</th></tr></thead><tbody><tr><td>각자 60%</td><td>13.4</td></tr><tr><td>70%</td><td>31.3</td></tr><tr><td>80%</td><td>59.8</td></tr><tr><td>90%</td><td>89.7</td></tr></tbody></table></div></details></figure>
<p>그래서 연말 모임은 <strong>11월 말~12월 초에 미리 잡는 것</strong>이 유리해요. 아직 다른 약속이 덜 들어온 시기에는 각자 확률이 높아서 후보 날짜 몇 개만으로도 날짜가 잡혀요. 반대로 12월 중순에 "이제 날짜 잡자"고 하면 이미 대부분의 날이 다른 약속으로 채워져 있어서 확률 구조상 성사되기 어려워요. 한 달 전에 후보 5~6개를 올리면 6명 모임은 80%대 확률로 하루는 건질 수 있어요.</p>
<h2 id="s7">실제로 날짜를 잘 잡는 방법</h2>
<p>계산 결과를 실천 방법으로 바꾸면 이렇게 돼요.</p>
<ol><li><strong>날짜를 하나씩 묻지 말고 후보를 한 번에 올리세요.</strong> 투표 앱이나 공유 캘린더에 후보 4~6개를 동시에 올리면 한 번의 응답으로 가장 많이 맞는 날이 정해져요. 순서대로 묻는 방식은 시간이 몇 배로 걸려요.</li><li><strong>일정이 가장 빡빡한 사람을 먼저 확인하세요.</strong> 전체 확률은 가장 낮은 사람의 확률에 크게 끌려가요. 그 사람의 가능한 날을 먼저 받아 후보를 그 안에서 고르면 N이 사실상 줄어드는 효과가 있어요.</li><li><strong>후보 날짜를 서로 다른 주에 흩어 놓으세요.</strong> 같은 주 월·화·수는 회사 행사나 출장 때문에 같이 막히는 경우가 많아요. 날짜끼리 독립에 가깝게 만들어야 후보 하나하나가 따로 기회가 돼요.</li><li><strong>기준 인원을 미리 정하세요.</strong> "6명 중 5명 이상이면 연다"처럼 처음에 정해 두면 마지막 한 명 때문에 모임이 깨지는 일이 줄어요. 이 기준은 대화방 첫 메시지에 적어 두면 좋아요.</li><li><strong>빠지는 사람에게는 다른 방식을 제안하세요.</strong> 한 명이 안 되는 날이라면 점심이나 영상 통화처럼 다른 형식으로 참여할 수 있는지 물어보세요.</li><li><strong>너무 늦게 시작하지 마세요.</strong> 연말에는 각자 확률이 계속 떨어지므로 일찍 잡을수록 유리해요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">이 계산이 보여 주지 못하는 것</h2>
<ul><li><strong>일정은 서로 독립이 아닐 수 있어요.</strong> 같은 회사 동료는 같은 날 회식이나 행사로 같이 막혀요. 이 경우 평균 확률이 같아도 어떤 날은 거의 전원 가능하고 어떤 날은 거의 불가능한 식으로 쏠려서, 실제 성공률은 계산과 달라질 수 있어요.</li><li><strong>사람마다 확률은 달라요.</strong> 모두 같은 p를 쓰는 건 단순화예요. 사람마다 다른 확률은 각자의 확률을 모두 곱하면 되는데, 이 경우 가장 낮은 사람이 결과를 좌우해요.</li><li><strong>날짜끼리도 독립이 아니에요.</strong> 한 주에 바쁜 사람은 그 주 모든 날이 바빠요. 그래서 같은 주에 후보를 몰아 놓은 것보다 흩어 놓은 쪽이 낫다는 조언이 나와요.</li><li><strong>확률은 정해진 값이 아니라 추정이에요.</strong> 이 글의 80%·60%는 예시이고, 실제 모임 사람들의 확률은 아니에요. 숫자를 바꿔 가며 경향을 보는 용도로 쓰세요.</li><li><strong>이항분포와 조합</strong>은 고등학교 확률 단원에서 배우는 내용이에요. 교육과정 문서는 <a href="https://ncic.re.kr" target="_blank" rel="noopener">국가교육과정정보센터</a>에서 볼 수 있어요. 비슷한 원리는 <a href="https://tool.smithlab.co.kr/calculator/birthday-paradox-calculator/">생일 역설 계산기</a>에서도 볼 수 있어요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>6명 모임에서 한 날짜에 모두 가능할 확률은 얼마인가요?</h3>
<p>각자 80% 가능하면 0.8^6 = 26.2%, 90%이면 53.1%, 70%이면 11.8%, 60%이면 4.7%예요. 인원이 늘어날수록 확률이 곱셈으로 빠르게 줄어요.</p>
<h3>후보 날짜를 몇 개 올리면 날짜가 잡힐까요?</h3>
<p>6명이 각자 80% 가능하면 3개로 59.8%, 5개로 78.1%, 8개로 91.2%예요. 10명이면 21개가 넘어야 90%에 도달해요. 인원이 많으면 후보를 늘리기보다 "몇 명 이상" 기준으로 바꾸는 게 효과적이에요.</p>
<h3>각자 가능할 확률은 어떻게 정하나요?</h3>
<p>정답은 없어요. 평일 저녁은 50~80%, 주말은 70~90%, 연말 피크 주간은 40~60% 정도로 두고 몇 가지 값을 넣어 비교해 보세요. 투표 앱에서 몇 명이 "가능"을 눌렀는지를 보고 감을 잡을 수도 있어요.</p>
<h3>같은 회사 사람들이면 계산이 달라지나요?</h3>
<p>달라져요. 이 글의 계산은 사람마다 독립이라고 가정하는데, 같은 팀은 일정이 함께 움직이기 때문이에요. 팀 공통 일정(회식, 행사, 마감)을 먼저 피해서 후보를 정하면 실제 확률이 계산보다 좋아질 수 있어요.</p>
<h3>전원이 와야 하는 모임은 어떻게 하나요?</h3>
<p>꼭 와야 하는 사람(예: 팀장, 어르신)을 기준으로 먼저 날짜를 받고, 그 안에서 나머지 사람들이 맞는 날을 고르세요. 그러면 확률로 계산해야 하는 사람 수 N이 줄어들어요. 그래도 어려우면 후보를 두세 주에 걸쳐 많이 올리고 최소 한 달 전에 시작하세요.</p>
<h2 id="s10">정리</h2>
<p>여러 사람의 일정이 동시에 맞을 확률은 각자 확률을 인원만큼 곱한 값이라서 생각보다 훨씬 낮아요. 6명이 각자 80%여도 한 날짜는 26.2%, 후보 3개면 59.8%, 90%를 넘기려면 8개가 필요해요. 날짜가 안 잡히는 건 정상이고, 해결책은 후보를 한 번에 여러 개 올리고, 가장 바쁜 사람의 일정을 먼저 받고, 전원이 아니라 "몇 명 이상"을 기준으로 삼는 거예요. 직접 인원과 확률을 넣어 보려면 <a href="https://tool.smithlab.co.kr/calculator/meetup-attendance-probability-calculator/">송년회 전원 모일 확률 계산기</a>를, 선택지 조합이 궁금하다면 <a href="https://tool.smithlab.co.kr/calculator/combination-calculator/">조합 계산기</a>를 써 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>승률 50%에서 60%가 되려면 몇 연승? 승률이 안 오르는 이유</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/win-rate-streak-needed-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/win-rate-streak-needed-guide/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>"이번 시즌은 승률 60% 찍어야지"라고 마음먹고 한참을 해도 숫자가 거의 안 움직이는 느낌, 랭크 게임이나 동호회 리그를 해 본 사람이라면 한 번쯤 겪어요. 느낌이 아니라 실제로 그래요. 결론부터 말하면 <strong>100승 100패(50.0%)에서 승률 60%가 되려면 한 판도 지지 않고 50연승을 해야 해요.</strong> 그 뒤 전적은 150승 100패로 정확히 60.0%예요. 지금까지 쌓인 판이 두 배가 되면 필요한 연승도 두 배가 돼요.</p>
<p>이 글은 승률이 왜 판이 쌓일수록 느리게 움직이는지, 목표 승률까지 필요한 연승과 판 수를 어떻게 구하는지, 이미 올린 승률을 지키려면 몇 번까지 져도 되는지를 직접 계산한 숫자로 정리했어요. 모든 값은 파이썬 분수 계산으로 한 판씩 올려 가며 확인했고, 직접 넣어 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/win-rate-streak-calculator/">승률 올리기 계산기</a>를 같이 열어 두세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>승률은 승 ÷ (승 + 패)라서 판이 늘수록 한 판의 영향이 작아져요. 100승 100패에서 한 판 이기면 50.25%, 1,000승 1,000패에서는 50.03%예요.</li><li>50%에서 60%까지 필요한 연승은 현재 판 수의 4분의 1이에요. 50승 50패는 25연승, 100승 100패는 50연승, 500승 500패는 250연승이에요.</li><li>연승 대신 앞으로 승률 65%로 계속한다면 100승 100패에서 55%까지 100판, 60%까지 400판이 걸려요. 높은 목표일수록 판 수가 급격히 늘어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">승률이 판이 쌓일수록 안 오르는 이유</h2>
<p>승률은 이긴 판을 전체 판으로 나눈 값이에요. 전체 판이 분모에 들어가기 때문에, 한 판의 결과가 승률에 주는 영향은 전체 판 수가 커질수록 작아져요. 10승 10패에서 한 판 이기면 52.4%로 2.4%포인트가 오르지만, 100승 100패에서는 50.2%로 0.2%포인트, 1,000승 1,000패에서는 50.03%로 거의 변화가 없어요.</p>
<div class="table-wrap"><table><thead><tr><th>현재 전적</th><th>한 판 이긴 뒤 승률</th><th>오르는 폭</th></tr></thead><tbody><tr><td>10승 10패</td><td>52.4%</td><td>+2.4%p</td></tr><tr><td>50승 50패</td><td>50.5%</td><td>+0.5%p</td></tr><tr><td>100승 100패</td><td>50.2%</td><td>+0.2%p</td></tr><tr><td>500승 500패</td><td>50.05%</td><td>+0.05%p</td></tr><tr><td>1,000승 1,000패</td><td>50.03%</td><td>+0.03%p</td></tr></tbody></table></div>
<p>그래서 새 계정이나 새 시즌 초반에는 승률이 롤러코스터처럼 움직이고, 판이 쌓이면 숫자가 굳어요. 이건 실력이 정체돼서가 아니라 수학적 구조예요. 오래 한 계정일수록 승률을 바꾸는 데 필요한 연승이 길다는 걸 먼저 받아들이면, 기대를 현실에 맞출 수 있어요.</p>
<h2 id="s2">목표 승률까지 필요한 연승 구하는 식</h2>
<p>현재 w승 l패에서 n번 연속으로 이기면 승률은 (w+n) ÷ (w+l+n)이에요. 이 값이 목표 p% 이상이 되는 가장 작은 n을 구하면 <strong>n = (p × (w+l) − 100 × w) ÷ (100 − p)</strong> 를 올림한 값이에요.</p>
<p>100승 100패에서 60%를 구해 보면 (60 × 200 − 100 × 100) ÷ 40 = 2,000 ÷ 40 = 50이에요. 50연승 뒤 전적은 150승 100패, 150 ÷ 250 = 60.0%예요. 한 판 덜 이긴 49연승이면 149 ÷ 249 = 59.84%라서 목표에 닿지 못해요. 분수 계산으로 한 판씩 확인해도 같은 값이 나와요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/win-rate-streak-needed-guide-1.svg" alt="50승 50패, 100승 100패, 500승 500패에서 승률 60%까지 필요한 연승이 각각 25, 50, 250이라는 것을 비교한 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>50%에서 60%로 갈 때 필요한 연승은 현재 판 수의 4분의 1이에요.</figcaption></figure>
<h2 id="s3">전적별 필요한 연승 표</h2>
<p>같은 50% 승률이라도 판이 많은 사람일수록 필요한 연승이 커져요. 아래는 승과 패가 같은 상태에서 목표별 필요 연승이에요.</p>
<div class="table-wrap"><table><thead><tr><th>현재 전적</th><th>55%까지</th><th>60%까지</th><th>70%까지</th></tr></thead><tbody><tr><td>10승 10패</td><td>3연승</td><td>5연승</td><td>14연승</td></tr><tr><td>50승 50패</td><td>12연승</td><td>25연승</td><td>67연승</td></tr><tr><td>100승 100패</td><td>23연승</td><td>50연승</td><td>134연승</td></tr><tr><td>200승 200패</td><td>45연승</td><td>100연승</td><td>267연승</td></tr><tr><td>500승 500패</td><td>112연승</td><td>250연승</td><td>667연승</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">승률 60%까지 필요한 연승 (승패가 같은 전적에서 출발)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="승률 60%까지 필요한 연승 (승패가 같은 전적에서 출발)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">250</text><rect class="s1" x="69.3" y="249.3" width="80.6" height="4.7" rx="3"><title>10승10패 · 필요한 연승 5연승</title></rect><text class="viz-val" x="109.6" y="244.3" text-anchor="middle">5</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">10승10패</text><rect class="s1" x="184.5" y="230.4" width="80.6" height="23.6" rx="3"><title>50승50패 · 필요한 연승 25연승</title></rect><text class="viz-val" x="224.8" y="225.4" text-anchor="middle">25</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">50승50패</text><rect class="s1" x="299.7" y="206.8" width="80.6" height="47.2" rx="3"><title>100승100패 · 필요한 연승 50연승</title></rect><text class="viz-val" x="340.0" y="201.8" text-anchor="middle">50</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">100승100패</text><rect class="s1" x="414.9" y="159.6" width="80.6" height="94.4" rx="3"><title>200승200패 · 필요한 연승 100연승</title></rect><text class="viz-val" x="455.2" y="154.6" text-anchor="middle">100</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">200승200패</text><rect class="s1" x="530.1" y="18.0" width="80.6" height="236.0" rx="3"><title>500승500패 · 필요한 연승 250연승</title></rect><text class="viz-val" x="570.4" y="13.0" text-anchor="middle">250</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">500승500패</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">현재 판 수가 늘어나는 비율대로 필요한 연승도 늘어나요.</p><div class="viz-cap">단위: 연승 · 자료: 승률 공식으로 직접 계산 (파이썬 분수 계산으로 검증)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>필요한 연승</th></tr></thead><tbody><tr><td>10승10패</td><td>5</td></tr><tr><td>50승50패</td><td>25</td></tr><tr><td>100승100패</td><td>50</td></tr><tr><td>200승200패</td><td>100</td></tr><tr><td>500승500패</td><td>250</td></tr></tbody></table></div></details></figure>
<p>70%를 목표로 하면 필요한 연승이 훨씬 가팔라져요. 100승 100패에서 60%는 50연승이지만 70%는 134연승이에요. 목표가 10%포인트 올라가는데 연승은 2.7배가 돼요. 분모 (100 − p)가 작아질수록 필요한 연승이 빠르게 커지는 식의 구조예요. 100%는 한 번 진 순간부터 영원히 불가능해요.</p>
<h2 id="s4">50% 아래에서 올라갈 때</h2>
<p>처음 몇십 판을 망쳤다면 어떨까요? 30승 70패(30.0%)에서 50%가 되려면 (50 × 100 − 100 × 30) ÷ 50 = 40이라서 40연승이에요. 40승 60패에서는 20연승, 45승 55패에서는 10연승이에요. 승률이 낮을 때는 연승 하나가 주는 효과가 큰 편이지만, 이미 진 판은 지워지지 않아서 기본 판 수가 클수록 만회하는 데 오래 걸려요. 랭크 게임에서 승률이 낮은 계정을 새로 시작하는 사람이 많은 이유가 여기 있어요. 다만 새 계정은 처음 실력 평가에서 불리하거나 유리한 점이 있을 수 있어서, 이 계산기는 어디까지나 현재 전적을 그대로 이어 가는 경우만 계산해요.</p>
<h2 id="s5">이미 오른 승률을 지키려면 몇 번까지 져도 될까</h2>
<p>목표를 넘긴 뒤 얼마나 질 수 있는지는 반대로 계산해요. 지금 w승 l패이고 지키려는 승률이 p%라면 m번까지 연패해도 w ÷ (w+l+m) ≥ p ÷ 100이에요. 내림한 m = 100 × w ÷ p − w − l이에요.</p>
<div class="table-wrap"><table><thead><tr><th>현재 전적</th><th>승률</th><th>지키려는 승률</th><th>버티는 연패</th></tr></thead><tbody><tr><td>120승 80패</td><td>60.0%</td><td>55%</td><td>18연패</td></tr><tr><td>120승 80패</td><td>60.0%</td><td>60%</td><td>0연패 (한 번 지면 아래)</td></tr><tr><td>300승 200패</td><td>60.0%</td><td>55%</td><td>45연패</td></tr><tr><td>700승 300패</td><td>70.0%</td><td>65%</td><td>77연패</td></tr></tbody></table></div>
<p>120승 80패는 정확히 60.0%라서 60%를 지키려는 사람에게는 여유가 한 판도 없어요. 목표를 딱 맞춰 넘긴 직후는 가장 위험한 순간이라서, 한 판 더 이기고 나서야 한 번 질 여유가 생겨요. 연패 후 승률은 천천히 내려가요. 60승 40패(60.0%)에서 1연패하면 59.4%, 5연패하면 57.1%, 10연패하면 54.5%, 20연패하면 50.0%예요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">한 번도 안 지는 건 현실적이지 않아요: 앞으로 승률로 계산하기</h2>
<p>연승 계산은 "최소 몇 판"을 알려 주는 기준선이에요. 실제로는 지기도 하니까 앞으로 이길 확률이 q%라고 두고 몇 판 뒤에 목표에 닿는지 구하는 식이 더 쓸모 있어요. 앞으로 g판을 승률 q%로 치르면 기대 승수는 q × g ÷ 100이고, 이 값이 목표를 만족하는 최소 g는 <strong>g = (p × (w+l) − 100 × w) ÷ (q − p)</strong> 를 올림한 값이에요. q가 p 이하이면 목표에 닿지 않아요.</p>
<p>100승 100패에서 목표 55%·60%까지의 판 수는 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>앞으로 승률</th><th>55%까지</th><th>60%까지</th></tr></thead><tbody><tr><td>65%</td><td>100판</td><td>400판</td></tr><tr><td>70%</td><td>67판</td><td>200판</td></tr><tr><td>80%</td><td>40판</td><td>100판</td></tr><tr><td>90%</td><td>27판</td><td>67판</td></tr></tbody></table></div>
<p>같은 60%라도 앞으로 승률이 65%이면 400판, 80%이면 100판이에요. 지금 실력이 50%인 사람이 갑자기 80%를 내기는 어려우니, 현실적으로는 65~70% 정도를 가정하고 몇백 판의 긴 일정으로 본다고 생각하는 게 안전해요. 앞으로 승률을 정할 때는 최근 20~30판 평균을 쓰고, 5판 정도의 짧은 호조를 근거로 80%를 넣지 마세요.</p>
<h2 id="s7">승률 계산을 쓸 때 주의할 점</h2>
<ul><li><strong>무승부는 리그마다 달라요.</strong> 승·패만 나눠 승률로 쓰는 곳도 있고 무승부를 0.5승으로 치는 곳도 있어서, 순위표를 볼 때 그 리그의 규칙을 먼저 확인하세요. 이 계산기는 무승부를 빼고 계산해요.</li><li><strong>랭크 게임은 이길수록 상대가 강해져요.</strong> 승률 60%를 오래 유지하기 어려운 구조의 게임이 많아서, 위 표의 앞으로 승률 70% 이상은 비현실적일 수 있어요.</li><li><strong>짧은 구간의 승률은 흔들려요.</strong> 20판에서 15승이면 75%지만 이 숫자가 실력 자체는 아니에요. 판이 적을수록 운의 비중이 커요.</li><li><strong>승률만 보지 마세요.</strong> 게임에서는 포지션별 기여나 상대 수준, 스포츠에서는 득실점 같은 다른 지표가 실력을 더 잘 보여 줄 때가 있어요.</li><li>기댓값과 이항분포는 고등학교 확률과 통계 단원에서 다뤄요. 교육과정 문서는 <a href="https://ncic.re.kr" target="_blank" rel="noopener">국가교육과정정보센터</a>에서 볼 수 있어요.</li><li>확률 계산의 바탕은 <a href="https://ko.wikipedia.org/wiki/이항_분포" target="_blank" rel="noopener">이항 분포 (위키백과)</a>에서 더 읽어 볼 수 있고, 연속 승부의 우승 확률은 <a href="https://tool.smithlab.co.kr/calculator/series-win-probability-calculator/">선승제 시리즈 우승 확률 계산기</a>에서 볼 수 있어요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">목표를 현실적으로 세우는 순서</h2>
<ol><li><strong>지금 전적을 정확히 적어요.</strong> 승·패 판 수를 계산기에 넣어 현재 승률을 확인해요.</li><li><strong>목표를 5%포인트 단위로 쪼개요.</strong> 50%에서 60%를 한 번에 노리기보다 55%를 먼저 보면 23연승(100승 100패 기준)으로 훨씬 가까워 보여요.</li><li><strong>앞으로 승률은 최근 20~30판으로 잡아요.</strong> 호조 기간의 승률을 그대로 쓰면 계획이 너무 짧게 나와요.</li><li><strong>시즌 길이와 비교해요.</strong> 남은 판이 150판인데 필요한 판이 400판이라면 이번 시즌 목표를 낮추는 게 맞아요.</li><li><strong>달성하면 유지 한도를 확인해요.</strong> 목표를 넘긴 직후에는 연패 한도가 거의 없으니, 몇 판 더 이겨 여유를 만들고 나서 도전적인 플레이를 해요.</li></ol>
<h2 id="s9">자주 묻는 질문</h2>
<h3>승률 60%가 되려면 몇 연승을 해야 하나요?</h3>
<p>현재 전적에 따라 달라요. 50승 50패에서는 25연승, 100승 100패에서는 50연승, 200승 200패에서는 100연승이에요. 50%에서 60%로 가는 경우 연승은 현재 판 수의 4분의 1이에요.</p>
<h3>왜 오래 한 계정일수록 승률이 안 오르나요?</h3>
<p>승률은 이긴 판을 전체 판으로 나눈 값이라 전체 판이 늘수록 한 판의 영향이 작아져요. 100승 100패에서 한 판 이기면 50.25%이고 1,000승 1,000패에서는 50.03%예요.</p>
<h3>이미 승률 60%인데 몇 번 져도 되나요?</h3>
<p>딱 60.0%라면 한 번만 져도 아래로 내려가요. 지키려는 기준보다 여유가 있을 때만 연패를 버틸 수 있어요. 120승 80패에서 55%를 지키는 거라면 18연패까지 괜찮아요.</p>
<h3>앞으로 승률은 얼마로 잡아야 하나요?</h3>
<p>최근 20~30판의 실제 승률이 무난해요. 짧은 기간의 호조로 80~90%를 넣으면 계산이 비현실적으로 짧게 나와요.</p>
<h3>무승부가 있는 경기는 어떻게 계산하나요?</h3>
<p>리그마다 무승부를 빼고 계산하기도 하고 0.5승으로 쳐서 계산하기도 해요. 계산기에는 무승부를 뺀 승·패만 넣고, 0.5승으로 치는 경우에는 무승부 수의 절반을 승에 더하고 나머지 절반을 패에 더해 넣으세요.</p>
<h2 id="s10">정리</h2>
<p>승률은 판이 쌓일수록 느리게 움직여요. 100승 100패에서 60%는 50연승, 앞으로 승률 65%로 계속하면 400판이 걸려요. 목표를 5%포인트씩 쪼개고, 앞으로 승률은 최근 20~30판 평균으로 잡고, 목표를 넘긴 뒤에는 연패 한도를 확인하세요. 직접 전적을 넣어 보려면 <a href="https://tool.smithlab.co.kr/calculator/win-rate-streak-calculator/">승률 올리기 계산기</a>를, 야구 기록의 승률·게임차는 <a href="https://tool.smithlab.co.kr/calculator/baseball-stats-calculator/">야구 기록 계산기</a>를, 일반적인 확률 계산은 <a href="https://tool.smithlab.co.kr/calculator/probability-calculator/">확률 계산기</a>를 써 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>투기적 디코딩 초안 모델 고르기, 크기 비율·수락률로 γ 정하는 법</title>
      <link>https://tool.smithlab.co.kr/posts/developer/speculative-decoding-draft-model-gamma-selection-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/speculative-decoding-draft-model-gamma-selection-guide/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><code>generate()</code>에 <code>assistant_model</code> 하나만 넘기면 투기적 디코딩이 켜지는데, 막상 어떤 작은 모델을 붙여야 할지, 미리 쓸 토큰 수 γ는 몇으로 둘지에서 막히는 경우가 많아요. 결론부터 말하면 <strong>초안은 목표 모델과 토크나이저가 같고 파라미터가 1/10 이하인 같은 계열 모델에서 고르고, γ는 감으로 정하지 말고 실제 프롬프트로 잰 수락률 α와 비용 비율 c로 계산해서 정하면 돼요.</strong> 계산이 번거로우면 Transformers 4.45.0부터 기본값이 된 동적 γ를 그대로 두는 편이 대부분 고정값보다 나아요.</p>
<p>이 글은 가장 기본인 <strong>별도 초안 모델 방식 하나를 골라 쓰는 법</strong>에 집중해요. 원 논문 실측값과 공식 식으로 초안 크기·γ·메모리·GPU 비용을 직접 계산하고, 두 숫자를 내 환경에서 재는 코드까지 담았어요. EAGLE·MTP·n-gram 같은 방식 비교와 배치별 손익은 <a href="https://tool.smithlab.co.kr/posts/developer/speculative-decoding-method-cost-guide/">투기적 디코딩 방식 고르기, EAGLE·MTP·n-gram 속도와 GPU 비용 비교</a>에 따로 정리해 두었어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>속도는 수락률 α, 초안 1회 시간 ÷ 목표 1회 시간인 c, 초안 길이 γ 세 숫자로 정해져요. α &gt; c 여야 이득이 생겨요.</li><li>원 논문(T5-XXL 11B)에서 초안을 77M → 800M으로 키우자 α는 0.75 → 0.82로 올랐지만 c가 0.02 → 0.11로 커져 속도는 3.4배 → 1.7배로 반 토막 났어요.</li><li>같은 α 0.7에서도 c가 0.02면 최적 γ는 7~8, c가 0.2면 3이에요. c가 큰 초안에 γ를 크게 주면 오히려 느려져요.</li><li>Llama 3.1 8B + Llama 3.2 1B 조합은 동적 γ로 요약 1.52배, 고정 휴리스틱으로는 1.00배였어요. 초안 크기 비율이 큰 조합일수록 γ 설정이 승패를 갈라요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">초안 모델 속도를 정하는 세 숫자 α·c·γ</h2>
<p>투기적 디코딩 한 라운드는 초안 모델이 γ개 토큰을 차례로 쓰고, 목표 모델이 그 γ개를 순전파 한 번으로 채점하는 구조예요. 여기서 고르는 사람이 손댈 수 있는 숫자는 셋이에요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/speculative-decoding-draft-model-gamma-selection-guide-1.svg" alt="수락률 α, 비용 비율 c, 초안 길이 γ 세 칸이 아래의 속도 식으로 모이는 구조도. α는 클수록, c는 작을수록 좋고 γ는 둘을 보고 정한다" width="800" height="450" loading="lazy" decoding="async"><figcaption>α와 c는 모델 짝이 정하고, γ만 내가 정해요</figcaption></figure>
<ul><li><strong>α (수락률)</strong>: 초안 토큰 하나가 목표 모델 검증을 통과할 확률이에요. 모델 짝, 작업 종류, 샘플링 온도가 정해요.</li><li><strong>c (비용 비율)</strong>: 초안을 한 번 돌리는 시간 ÷ 목표를 한 번 돌리는 시간이에요. <a href="https://arxiv.org/abs/2211.17192" target="_blank" rel="noopener">Leviathan 외(2022) 원 논문</a>은 α가 모델과 작업의 성질인 것과 달리 <strong>c는 하드웨어와 구현에 따라 달라진다</strong>고 적었어요.</li><li><strong>γ (초안 길이)</strong>: 한 라운드에 초안이 미리 쓰는 토큰 수예요. 세 숫자 중 유일하게 설정으로 바꾸는 값이에요.</li></ul>
<p>논문의 정리 3.8이 셋을 하나로 묶어요. 속도 향상 = (1 − α^(γ+1)) ÷ ((1 − α)(γc + 1)) 예요. 분자는 검증 한 번에 평균 몇 토큰을 얻는지, 분모는 그 대가로 초안을 γ번 돌린 시간까지 더한 비용이에요. γ = 1일 때 식이 (1 + α) ÷ (1 + c)가 되므로 <strong>α가 c보다 커야 조금이라도 빨라져요.</strong> 논문 따름정리 3.9도 같은 조건을 밝혀요.</p>
<p>토큰마다 수락 확률이 같다고 가정한 근사지만, 논문 부록 표 4에서 예측과 실측이 대체로 맞았어요(T5-small 예측 3.2배, 실측 3.4배).</p>
<h2 id="s2">초안이 클수록 느려지는 이유 — 원 논문 실측</h2>
<p>초안 모델을 고를 때 가장 흔한 착각은 "목표 모델과 비슷하게 말할수록 좋다"예요. 원 논문 표 2와 부록 표 4는 이걸 정면으로 반박해요. 목표는 T5-XXL(11B), 영어→독일어 번역, TPU-v4 한 장, 배치 1, 온도 0이에요.</p>
<div class="table-wrap"><table><thead><tr><th>초안 모델</th><th>파라미터 비율</th><th>실측 c</th><th>α</th><th>γ</th><th>속도</th></tr></thead><tbody><tr><td>T5-small (77M)</td><td>0.70%</td><td>0.02</td><td>0.75</td><td>7</td><td>3.4배</td></tr><tr><td>T5-base (250M)</td><td>2.27%</td><td>0.04</td><td>0.80</td><td>7</td><td>2.8배</td></tr><tr><td>T5-large (800M)</td><td>7.27%</td><td>0.11</td><td>0.82</td><td>7</td><td>1.7배</td></tr></tbody></table></div>
<p>파라미터 비율은 초안 파라미터 ÷ 11B로 직접 계산했고, c는 논문이 프로파일러 추적으로 잰 값이에요. 초안을 10배 키워 α를 0.07 올렸지만 c가 5.5배 커져서 속도가 절반이 됐어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">T5-XXL 초안 크기별 파라미터 비율과 실측 비용 비율 c</figcaption><div class="viz-legend"><span><i class="s1"></i>파라미터 비율</span><span><i class="s2"></i>실측 c</span></div><svg viewBox="0 0 640 300" role="img" aria-label="T5-XXL 초안 크기별 파라미터 비율과 실측 비용 비율 c" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">6</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">8</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">12</text><rect class="s1" x="80.8" y="240.2" width="66.2" height="13.8" rx="3"><title>T5-small 77M · 파라미터 비율 0.7%</title></rect><rect class="s2" x="149.0" y="214.7" width="66.2" height="39.3" rx="3"><title>T5-small 77M · 실측 c 2%</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">T5-small 77M</text><rect class="s1" x="272.8" y="209.4" width="66.2" height="44.6" rx="3"><title>T5-base 250M · 파라미터 비율 2.3%</title></rect><rect class="s2" x="341.0" y="175.3" width="66.2" height="78.7" rx="3"><title>T5-base 250M · 실측 c 4%</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">T5-base 250M</text><rect class="s1" x="464.8" y="111.0" width="66.2" height="143.0" rx="3"><title>T5-large 800M · 파라미터 비율 7.3%</title></rect><rect class="s2" x="533.0" y="37.7" width="66.2" height="216.3" rx="3"><title>T5-large 800M · 실측 c 11%</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">T5-large 800M</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">세 경우 모두 c가 파라미터 비율보다 1.5~2.9배 커요. 작은 초안일수록 그 배수가 커요.</p><div class="viz-cap">단위: % · 자료: Leviathan 외 2022 표 4의 c 값, 파라미터 비율은 11B 대비 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>파라미터 비율</th><th>실측 c</th></tr></thead><tbody><tr><td>T5-small 77M</td><td>0.7</td><td>2</td></tr><tr><td>T5-base 250M</td><td>2.3</td><td>4</td></tr><tr><td>T5-large 800M</td><td>7.3</td><td>11</td></tr></tbody></table></div></details></figure>
<p>여기서 규칙이 하나 나와요. <strong>c는 파라미터 비율보다 크게 나와요.</strong> 작은 모델도 층마다 커널을 차례로 실행하는 고정 비용이 있어서 크기만큼 시간이 줄지 않기 때문이에요. 그래서 <strong>층 수</strong>도 봐야 해요. 예를 들어 SmolLM-135M은 30층으로, 목표인 SmolLM-1.7B(24층)보다 층이 많아요(Hugging Face 모델 설정 파일 기준). 이런 깊고 좁은 초안은 c가 기대보다 크게 나올 수 있어요.</p>
<p>논문의 결론도 이 균형을 가리켜요. 같은 구조의 기성 모델을 초안으로 썼을 때 <strong>목표보다 두 자릿수(약 100배) 작은 초안이 대체로 가장 좋았고</strong>, 그 정도 크기에서 c는 늘 0.05 미만이었다고 적었어요. 그런 초안의 α는 0.5~0.9 사이였어요. <a href="https://huggingface.co/blog/assisted-generation" target="_blank" rel="noopener">Hugging Face의 Assisted Generation 블로그 글</a>은 조금 느슨하게 "적어도 한 자릿수(10배) 작게, 차이가 클수록 좋다"고 권해요.</p>
<h2 id="s3">최적 γ 계산표 — 수락률과 비용 비율로 정하기</h2>
<p>γ를 늘리면 검증 한 번에 얻을 수 있는 토큰이 늘지만, 그 증가분은 α^γ로 빠르게 줄어요. 반면 초안 비용 γc는 γ에 비례해 계속 커져요. 그래서 α·c마다 속도가 가장 높은 γ가 하나 있어요. 위 식에 γ = 1~20을 넣어 가장 빠른 값을 찾았어요.</p>
<div class="table-wrap"><table><thead><tr><th>α \ c</th><th>c = 0.02</th><th>c = 0.05</th><th>c = 0.1</th><th>c = 0.2</th></tr></thead><tbody><tr><td>0.5</td><td>γ 4 → 1.79배</td><td>γ 3 → 1.63배</td><td>γ 2 → 1.46배</td><td>γ 1 → 1.25배</td></tr><tr><td>0.6</td><td>γ 6 → 2.17배</td><td>γ 4 → 1.92배</td><td>γ 3 → 1.67배</td><td>γ 2 → 1.40배</td></tr><tr><td>0.7</td><td>γ 8 → 2.76배</td><td>γ 6 → 2.35배</td><td>γ 4 → 1.98배</td><td>γ 3 → 1.58배</td></tr><tr><td>0.8</td><td>γ 11 → 3.82배</td><td>γ 8 → 3.09배</td><td>γ 6 → 2.47배</td><td>γ 4 → 1.87배</td></tr><tr><td>0.9</td><td>γ 19 → 6.37배</td><td>γ 13 → 4.67배</td><td>γ 10 → 3.43배</td><td>γ 7 → 2.37배</td></tr></tbody></table></div>
<p>초안이 무거울수록(오른쪽) 최적 γ가 줄고, 잘 맞을수록(아래) 늘어요. 꼭대기 근처는 평평해서 α 0.7·c 0.02에서 γ 5는 2.67배로 최적(2.76배)과 3% 차이예요. α 0.7·c 0.02에서 γ 5는 2.67배로 최적(2.76배)과 3% 차이예요.</p>
<p>위험한 쪽은 반대 방향이에요. c가 큰 초안에 γ를 크게 주면 속도가 1배 아래로 떨어질 수 있어요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">α 0.7일 때 초안 길이 γ에 따른 이론 속도 향상</figcaption><div class="viz-legend"><span><i class="s1"></i>c 0.02</span><span><i class="s2"></i>c 0.1</span><span><i class="s3"></i>c 0.2</span></div><svg viewBox="0 0 640 300" role="img" aria-label="α 0.7일 때 초안 길이 γ에 따른 이론 속도 향상" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">0.5</text><line class="viz-grid" x1="52" x2="570" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1.5</text><line class="viz-grid" x1="52" x2="570" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="570" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">3</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">1</text><text class="viz-ax" x="146.2" y="272" text-anchor="middle">3</text><text class="viz-ax" x="240.4" y="272" text-anchor="middle">5</text><text class="viz-ax" x="334.5" y="272" text-anchor="middle">7</text><text class="viz-ax" x="428.7" y="272" text-anchor="middle">9</text><text class="viz-ax" x="522.9" y="272" text-anchor="middle">11</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">12</text><polyline class="viz-line s1" points="52.0,122.6 99.1,88.0 146.2,66.0 193.3,51.8 240.4,44.0 287.5,39.2 334.5,36.9 381.6,36.9 428.7,37.7 475.8,40.0 522.9,42.4 570.0,44.7"/><circle class="viz-dot s1" cx="52.0" cy="122.6" r="4"><title>1 · c 0.02 1.7배</title></circle><circle class="viz-dot s1" cx="99.1" cy="88.0" r="4"><title>2 · c 0.02 2.1배</title></circle><circle class="viz-dot s1" cx="146.2" cy="66.0" r="4"><title>3 · c 0.02 2.4배</title></circle><circle class="viz-dot s1" cx="193.3" cy="51.8" r="4"><title>4 · c 0.02 2.6배</title></circle><circle class="viz-dot s1" cx="240.4" cy="44.0" r="4"><title>5 · c 0.02 2.7배</title></circle><circle class="viz-dot s1" cx="287.5" cy="39.2" r="4"><title>6 · c 0.02 2.7배</title></circle><circle class="viz-dot s1" cx="334.5" cy="36.9" r="4"><title>7 · c 0.02 2.8배</title></circle><circle class="viz-dot s1" cx="381.6" cy="36.9" r="4"><title>8 · c 0.02 2.8배</title></circle><circle class="viz-dot s1" cx="428.7" cy="37.7" r="4"><title>9 · c 0.02 2.8배</title></circle><circle class="viz-dot s1" cx="475.8" cy="40.0" r="4"><title>10 · c 0.02 2.7배</title></circle><circle class="viz-dot s1" cx="522.9" cy="42.4" r="4"><title>11 · c 0.02 2.7배</title></circle><circle class="viz-dot s1" cx="570.0" cy="44.7" r="4"><title>12 · c 0.02 2.7배</title></circle><text class="viz-val" x="578.0" y="48.7">2.7</text><polyline class="viz-line s2" points="52.0,132.1 99.1,110.8 146.2,100.6 193.3,98.2 240.4,99.8 287.5,103.7 334.5,108.5 381.6,114.0 428.7,120.3 475.8,125.8 522.9,130.5 570.0,136.0"/><circle class="viz-dot s2" cx="52.0" cy="132.1" r="4"><title>1 · c 0.1 1.6배</title></circle><circle class="viz-dot s2" cx="99.1" cy="110.8" r="4"><title>2 · c 0.1 1.8배</title></circle><circle class="viz-dot s2" cx="146.2" cy="100.6" r="4"><title>3 · c 0.1 1.9배</title></circle><circle class="viz-dot s2" cx="193.3" cy="98.2" r="4"><title>4 · c 0.1 2배</title></circle><circle class="viz-dot s2" cx="240.4" cy="99.8" r="4"><title>5 · c 0.1 2배</title></circle><circle class="viz-dot s2" cx="287.5" cy="103.7" r="4"><title>6 · c 0.1 1.9배</title></circle><circle class="viz-dot s2" cx="334.5" cy="108.5" r="4"><title>7 · c 0.1 1.9배</title></circle><circle class="viz-dot s2" cx="381.6" cy="114.0" r="4"><title>8 · c 0.1 1.8배</title></circle><circle class="viz-dot s2" cx="428.7" cy="120.3" r="4"><title>9 · c 0.1 1.7배</title></circle><circle class="viz-dot s2" cx="475.8" cy="125.8" r="4"><title>10 · c 0.1 1.6배</title></circle><circle class="viz-dot s2" cx="522.9" cy="130.5" r="4"><title>11 · c 0.1 1.6배</title></circle><circle class="viz-dot s2" cx="570.0" cy="136.0" r="4"><title>12 · c 0.1 1.5배</title></circle><text class="viz-val" x="578.0" y="140.0">1.5</text><polyline class="viz-line s3" points="52.0,142.3 99.1,131.3 146.2,129.7 193.3,132.9 240.4,138.4 287.5,144.7 334.5,150.9 381.6,157.2 428.7,162.7 475.8,168.3 522.9,173.0 570.0,177.7"/><circle class="viz-dot s3" cx="52.0" cy="142.3" r="4"><title>1 · c 0.2 1.4배</title></circle><circle class="viz-dot s3" cx="99.1" cy="131.3" r="4"><title>2 · c 0.2 1.6배</title></circle><circle class="viz-dot s3" cx="146.2" cy="129.7" r="4"><title>3 · c 0.2 1.6배</title></circle><circle class="viz-dot s3" cx="193.3" cy="132.9" r="4"><title>4 · c 0.2 1.5배</title></circle><circle class="viz-dot s3" cx="240.4" cy="138.4" r="4"><title>5 · c 0.2 1.5배</title></circle><circle class="viz-dot s3" cx="287.5" cy="144.7" r="4"><title>6 · c 0.2 1.4배</title></circle><circle class="viz-dot s3" cx="334.5" cy="150.9" r="4"><title>7 · c 0.2 1.3배</title></circle><circle class="viz-dot s3" cx="381.6" cy="157.2" r="4"><title>8 · c 0.2 1.2배</title></circle><circle class="viz-dot s3" cx="428.7" cy="162.7" r="4"><title>9 · c 0.2 1.2배</title></circle><circle class="viz-dot s3" cx="475.8" cy="168.3" r="4"><title>10 · c 0.2 1.1배</title></circle><circle class="viz-dot s3" cx="522.9" cy="173.0" r="4"><title>11 · c 0.2 1배</title></circle><circle class="viz-dot s3" cx="570.0" cy="177.7" r="4"><title>12 · c 0.2 0.97배</title></circle><text class="viz-val" x="578.0" y="181.7">0.97</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">c 0.2에서 γ 12는 0.97배로 끄는 편이 빨라요. 같은 γ라도 초안 무게에 따라 결과가 갈려요.</p><div class="viz-cap">단위: 배 · 자료: Leviathan 외 2022 정리 3.8 식으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>c 0.02</th><th>c 0.1</th><th>c 0.2</th></tr></thead><tbody><tr><td>1</td><td>1.7</td><td>1.6</td><td>1.4</td></tr><tr><td>2</td><td>2.1</td><td>1.8</td><td>1.6</td></tr><tr><td>3</td><td>2.4</td><td>1.9</td><td>1.6</td></tr><tr><td>4</td><td>2.6</td><td>2</td><td>1.5</td></tr><tr><td>5</td><td>2.7</td><td>2</td><td>1.5</td></tr><tr><td>6</td><td>2.7</td><td>1.9</td><td>1.4</td></tr><tr><td>7</td><td>2.8</td><td>1.9</td><td>1.3</td></tr><tr><td>8</td><td>2.8</td><td>1.8</td><td>1.2</td></tr><tr><td>9</td><td>2.8</td><td>1.7</td><td>1.2</td></tr><tr><td>10</td><td>2.7</td><td>1.6</td><td>1.1</td></tr><tr><td>11</td><td>2.7</td><td>1.6</td><td>1</td></tr><tr><td>12</td><td>2.7</td><td>1.5</td><td>0.97</td></tr></tbody></table></div></details></figure>
<p>목표 모델이 8B이고 초안이 1B급이면 파라미터 비율만 15%예요(Llama 3.1 8B 약 8.03B, Llama 3.2 1B 약 1.24B — 공개 설정 파일로 계산). 위 T5 사례처럼 c가 파라미터 비율보다 크게 나온다면 c는 0.15를 넘는다고 보는 편이 안전하고, 그러면 표의 오른쪽 끝 열에 해당해요. 1B급 초안이 8B 목표에서 큰 이득을 못 내는 경우가 많은 이유가 이 계산에 그대로 드러나요.</p>
<h2 id="s4">고정 γ 대신 동적 γ — Transformers 기본값</h2>
<p>고정 γ는 평균값일 뿐, 실제 문장에는 쉬운 구간과 어려운 구간이 섞여 있어요. 원 논문도 라운드마다 γ를 맞히는 오라클이 있다면 고정 γ보다 최대 약 60% 빨라질 수 있다고 계산했어요.</p>
<p>Transformers는 이 아이디어를 기본값으로 넣었어요. <a href="https://huggingface.co/blog/dynamic_speculation_lookahead" target="_blank" rel="noopener">Intel·Hugging Face의 동적 투기적 디코딩 글</a>에 따르면 4.45.0부터 초안이 토큰을 하나 쓸 때마다 자기 확신도(softmax 확률)를 보고, 그 값이 <code>assistant_confidence_threshold</code>(기본 0.4) 아래로 내려가면 <code>num_assistant_tokens</code>(기본 최대 20)에 닿기 전이라도 멈추고 바로 검증을 맡겨요. 같은 글에 실린 RTX 4090, 온도 0 측정값이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">초안 모델 짝별 속도 향상, 고정 휴리스틱 γ vs 동적 γ</figcaption><div class="viz-legend"><span><i class="s1"></i>휴리스틱</span><span><i class="s2"></i>동적</span></div><svg viewBox="0 0 640 300" role="img" aria-label="초안 모델 짝별 속도 향상, 고정 휴리스틱 γ vs 동적 γ" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">0.5</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1.5</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">3</text><rect class="s1" x="64.3" y="110.8" width="27.8" height="143.2" rx="3"><title>OPT 6.7B 요약 · 휴리스틱 1.8배</title></rect><rect class="s2" x="94.1" y="40.8" width="27.8" height="213.2" rx="3"><title>OPT 6.7B 요약 · 동적 2.7배</title></rect><text class="viz-ax" x="93.1" y="272" text-anchor="middle">OPT 6.7B 요약</text><rect class="s1" x="146.6" y="157.2" width="27.8" height="96.8" rx="3"><title>OPT 6.7B 자유생성 · 휴리스틱 1.2배</title></rect><rect class="s2" x="176.4" y="128.9" width="27.8" height="125.1" rx="3"><title>OPT 6.7B 자유생성 · 동적 1.6배</title></rect><text class="viz-ax" x="175.4" y="272" text-anchor="middle">OPT 6.7B 자유생성</text><rect class="s1" x="228.9" y="184.0" width="27.8" height="70.0" rx="3"><title>CodeGen 6B 코드 · 휴리스틱 0.89배</title></rect><rect class="s2" x="258.7" y="168.3" width="27.8" height="85.7" rx="3"><title>CodeGen 6B 코드 · 동적 1.1배</title></rect><text class="viz-ax" x="257.7" y="272" text-anchor="middle">CodeGen 6B 코드</text><rect class="s1" x="311.2" y="161.2" width="27.8" height="92.8" rx="3"><title>Flan-T5 XL 요약 · 휴리스틱 1.2배</title></rect><rect class="s2" x="341.0" y="150.9" width="27.8" height="103.1" rx="3"><title>Flan-T5 XL 요약 · 동적 1.3배</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">Flan-T5 XL 요약</text><rect class="s1" x="393.5" y="175.3" width="27.8" height="78.7" rx="3"><title>Llama 8B 요약 · 휴리스틱 1배</title></rect><rect class="s2" x="423.3" y="134.4" width="27.8" height="119.6" rx="3"><title>Llama 8B 요약 · 동적 1.5배</title></rect><text class="viz-ax" x="422.3" y="272" text-anchor="middle">Llama 8B 요약</text><rect class="s1" x="475.8" y="175.3" width="27.8" height="78.7" rx="3"><title>Llama 8B 자유생성 · 휴리스틱 1배</title></rect><rect class="s2" x="505.6" y="161.2" width="27.8" height="92.8" rx="3"><title>Llama 8B 자유생성 · 동적 1.2배</title></rect><text class="viz-ax" x="504.6" y="272" text-anchor="middle">Llama 8B 자유생성</text><rect class="s1" x="558.1" y="168.3" width="27.8" height="85.7" rx="3"><title>Llama 8B 코드 · 휴리스틱 1.1배</title></rect><rect class="s2" x="587.9" y="163.5" width="27.8" height="90.5" rx="3"><title>Llama 8B 코드 · 동적 1.1배</title></rect><text class="viz-ax" x="586.9" y="272" text-anchor="middle">Llama 8B 코드</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">초안은 차례로 OPT-125M, OPT-125M, CodeGen-350M, Flan-T5-small, Llama 3.2 1B ×3이에요.</p><div class="viz-cap">단위: 배 · 자료: Hugging Face 블로그 Faster Assisted Generation with Dynamic Speculation (2024-10-08), RTX 4090 · 온도 0</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>휴리스틱</th><th>동적</th></tr></thead><tbody><tr><td>OPT 6.7B 요약</td><td>1.8</td><td>2.7</td></tr><tr><td>OPT 6.7B 자유생성</td><td>1.2</td><td>1.6</td></tr><tr><td>CodeGen 6B 코드</td><td>0.89</td><td>1.1</td></tr><tr><td>Flan-T5 XL 요약</td><td>1.2</td><td>1.3</td></tr><tr><td>Llama 8B 요약</td><td>1</td><td>1.5</td></tr><tr><td>Llama 8B 자유생성</td><td>1</td><td>1.2</td></tr><tr><td>Llama 8B 코드</td><td>1.1</td><td>1.1</td></tr></tbody></table></div></details></figure>
<p>c가 작은 짝(OPT 6.7B + 125M, 파라미터 비율 약 2%)은 어느 방식이든 이득이 컸지만, Llama 8B + 1B처럼 무거운 짝은 휴리스틱으로 1.00배였다가 동적으로 바꿔야 1.15~1.52배가 나왔어요. <strong>초안이 무거울수록 γ를 똑똑하게 정하는 가치가 커져요.</strong></p>
<p>고정값으로 돌아가고 싶으면 블로그 글이 안내한 대로 초안 모델의 생성 설정을 바꿔요.</p>
<pre class="code-block" data-lang="python"><code># 동적 γ(기본): 확신도 0.4 아래면 멈춤, 최대 20토큰
assistant_model.generation_config.assistant_confidence_threshold = 0.4
assistant_model.generation_config.num_assistant_tokens = 20

# 계산표에서 고른 고정 γ로 비교할 때
assistant_model.generation_config.num_assistant_tokens_schedule = &quot;constant&quot;
assistant_model.generation_config.assistant_confidence_threshold = 0
assistant_model.generation_config.num_assistant_tokens = 4</code></pre>
<p><a href="https://huggingface.co/docs/transformers/main/en/main_classes/text_generation" target="_blank" rel="noopener">Transformers 생성 설정 문서</a>에 따르면 <code>heuristic</code>은 초안이 모두 맞으면 γ를 2 늘리고 틀리면 1 줄이며, 바뀐 값이 다음 <code>generate()</code> 호출로 이어져요. 요청마다 처음 값으로 되돌리려면 <code>heuristic_transient</code>를 써요.</p>
<h2 id="s5">내 환경에서 α와 c를 재는 코드</h2>
<p>표를 쓰려면 내 GPU와 내 프롬프트에서 α와 c를 알아야 해요. 아래 코드는 그리디(온도 0) 기준으로 두 숫자를 재고 최적 γ까지 계산해요. 그리디에서는 초안과 목표의 확률 분포가 각자 가장 높은 토큰 하나로 모이므로, α는 "목표가 실제로 고른 토큰을 초안도 1순위로 골랐는가"의 비율과 같아요.</p>
<pre class="code-block" data-lang="python"><code>import time, torch
from transformers import AutoModelForCausalLM, AutoTokenizer

TARGET, DRAFT = &quot;HuggingFaceTB/SmolLM-1.7B&quot;, &quot;HuggingFaceTB/SmolLM-135M&quot;
tok = AutoTokenizer.from_pretrained(TARGET)
assert tok.get_vocab() == AutoTokenizer.from_pretrained(DRAFT).get_vocab(), &quot;어휘가 달라요 → UAD 경로 필요&quot;
target = AutoModelForCausalLM.from_pretrained(TARGET, dtype=&quot;auto&quot;, device_map=&quot;auto&quot;)
draft = AutoModelForCausalLM.from_pretrained(DRAFT, dtype=&quot;auto&quot;, device_map=&quot;auto&quot;)

def sync(t):
    if t.is_cuda:
        torch.cuda.synchronize()

@torch.no_grad()
def step_time(model, ids, n=32):            # KV 캐시를 쓴 1토큰 순전파 평균 시간
    out = model(ids, use_cache=True)
    past, nxt = out.past_key_values, out.logits[:, -1:].argmax(-1)
    sync(ids); t = time.perf_counter()
    for _ in range(n):
        out = model(nxt, past_key_values=past, use_cache=True)
        past, nxt = out.past_key_values, out.logits[:, -1:].argmax(-1)
    sync(ids)
    return (time.perf_counter() - t) / n

@torch.no_grad()
def greedy_alpha(prompt, max_new=128):      # 목표가 쓴 답을 초안도 1순위로 맞힌 비율
    ids = tok(prompt, return_tensors=&quot;pt&quot;).input_ids.to(target.device)
    seq = target.generate(ids, max_new_tokens=max_new, do_sample=False)
    n = ids.shape[1]
    t_pred = target(seq).logits[0, n - 1:-1].argmax(-1)
    d_pred = draft(seq.to(draft.device)).logits[0, n - 1:-1].argmax(-1).to(t_pred.device)
    return (t_pred == d_pred).float().mean().item()

prompts = [&quot;def parse_config(path):&quot;, &quot;다음 회의록을 세 줄로 요약해 줘: ...&quot;]   # 실제 트래픽에서 뽑기
alpha = sum(greedy_alpha(p) for p in prompts) / len(prompts)
ids = tok(prompts[0], return_tensors=&quot;pt&quot;).input_ids.to(target.device)
c = step_time(draft, ids.to(draft.device)) / step_time(target, ids)

speed = lambda g: (1 - alpha ** (g + 1)) / ((1 - alpha) * (g * c + 1))
best = max(range(1, 21), key=speed)
print(f&quot;alpha={alpha:.2f}  c={c:.3f}  best gamma={best}  예상 {speed(best):.2f}배&quot;)</code></pre>
<p>잴 때 지킬 점은 세 가지예요.</p>
<ul><li><strong>프롬프트는 실제 트래픽에서 뽑아요.</strong> 원 논문에서도 같은 T5-small·온도 0 조건으로 번역은 α 0.75, 요약은 0.65였어요.</li><li><strong>c는 1토큰 디코딩 시간으로 재요.</strong> 프롬프트 전체를 넣는 첫 순전파(프리필)는 빼야 해서 위 코드도 첫 호출을 시간에서 제외했어요.</li><li><strong>서비스 온도로 다시 확인해요.</strong> T5-small의 α는 온도 0에서 0.75, 온도 1에서 0.62였어요. 위 코드는 그리디 전용이라, 샘플링이면 <code>do_sample=True</code>로 켠 것과 끈 것의 속도를 직접 비교하세요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">초안 모델의 메모리와 GPU 비용 계산</h2>
<p>초안 모델은 가중치와 KV 캐시를 한 벌 더 올려요. bf16 기준(파라미터 1개에 2바이트)으로 공개 설정 파일의 층 수·KV 헤드 수·헤드 차원을 넣어 계산했어요. KV 캐시는 토큰 하나에 2(K·V) × 층 수 × KV 헤드 수 × 헤드 차원 × 2바이트예요.</p>
<div class="table-wrap"><table><thead><tr><th>모델</th><th>파라미터</th><th>bf16 가중치</th><th>토큰당 KV</th><th>4,096토큰 KV</th></tr></thead><tbody><tr><td>Llama 3.1 8B (목표)</td><td>약 8.03B</td><td>약 16.06GB</td><td>128KB</td><td>512MiB</td></tr><tr><td>Llama 3.2 1B (초안)</td><td>약 1.24B</td><td>약 2.47GB</td><td>32KB</td><td>128MiB</td></tr><tr><td>SmolLM-1.7B (목표)</td><td>약 1.71B</td><td>약 3.42GB</td><td>192KB</td><td>768MiB</td></tr><tr><td>SmolLM-135M (초안)</td><td>약 0.135B</td><td>약 0.27GB</td><td>22.5KB</td><td>90MiB</td></tr></tbody></table></div>
<p>Llama 짝은 초안이 가중치로 15%, KV 캐시로 25%를 더 써요. 24GB GPU에 8B 목표와 1B 초안을 함께 올리면 약 18.5GB라, KV 캐시와 실행 공간에 남는 몫이 그만큼 줄어 동시 요청 수가 줄어요. KV 캐시 용량과 동시 요청 수의 관계는 <a href="https://tool.smithlab.co.kr/posts/developer/vllm-kv-cache-memory-capacity-cost-guide/">vLLM KV 캐시 용량 계산</a>에서 자세히 다뤘어요.</p>
<p>비용은 속도 향상의 역수로 바뀌어요. 배치 1에 가까운 지연 중심 서비스라면, 같은 트래픽을 처리하는 데 필요한 GPU 시간이 1 ÷ 속도로 줄어요. <a href="https://lambda.ai/pricing" target="_blank" rel="noopener">Lambda GPU 클라우드 가격표</a>의 1장 인스턴스 요금(2026-11-05 확인, 세금 별도)으로 한 달(720시간) 비용을 계산했어요. A10 24GB는 시간당 1.29달러로 월 928.8달러, H100 SXM 80GB는 시간당 4.29달러로 월 3,088.8달러예요.</p>
<div class="table-wrap"><table><thead><tr><th>속도 향상 (위 동적 γ 실측)</th><th>GPU 시간 절감</th><th>A10 1장 월 절감</th><th>H100 1장 월 절감</th></tr></thead><tbody><tr><td>1.52배 (Llama 8B 요약)</td><td>34.2%</td><td>약 317.7달러</td><td>약 1,056.7달러</td></tr><tr><td>1.18배 (Llama 8B 자유생성)</td><td>15.3%</td><td>약 141.7달러</td><td>약 471.2달러</td></tr><tr><td>1.15배 (Llama 8B 코드)</td><td>13.0%</td><td>약 121.1달러</td><td>약 402.9달러</td></tr></tbody></table></div>
<p>이 표는 RTX 4090에서 잰 배수를 다른 GPU 요금에 곱한 <strong>예시 계산</strong>이에요. GPU가 바뀌면 c와 배수도 바뀌고, 배치가 커질 때 이득이 줄어드는 점도 빠져 있어요. 절감액은 앞 절의 코드로 내 GPU에서 잰 배수로 다시 계산하세요.</p>
<h2 id="s7">토크나이저가 다를 때와 초안이 마땅치 않을 때</h2>
<p>기본 방식은 두 모델의 어휘가 같아야 해요. 같은 토큰 번호 x에 대해 목표 확률 p(x)와 초안 확률 q(x)를 비교하기 때문이에요. 위 코드 첫머리의 <code>assert</code>처럼 <code>get_vocab()</code>을 비교하면 바로 확인돼요. 이름이 같은 계열이어도 세대가 다르면 어휘가 바뀌는 경우가 있으니 꼭 확인하세요.</p>
<p>어휘가 다르면 선택지는 셋이에요.</p>
<ul><li><strong>UAD(Universal assisted decoding)</strong>: <a href="https://huggingface.co/docs/transformers/main/en/assisted_decoding" target="_blank" rel="noopener">Transformers의 Assisted decoding 문서</a>가 소개하는 경로예요. <code>tokenizer</code>와 <code>assistant_tokenizer</code>를 함께 넘기면 초안 토큰을 텍스트로 풀어 목표 토크나이저로 다시 인코딩하고, 최장 공통 부분 수열로 위치를 맞춰요. 재인코딩 비용만큼 c가 늘어나요(정렬 보정은 <code>assistant_lookbehind</code>·<code>target_lookbehind</code>).</li><li><strong>프롬프트 룩업</strong>: 초안 모델 없이 프롬프트 안에서 겹치는 n-gram을 찾아 후보로 써요. <code>prompt_lookup_num_tokens</code>로 복사할 토큰 수를, <code>max_matching_ngram_size</code>(기본 2)로 찾을 구절 길이를 정해요. c가 사실상 0이라 요약·번역·코드 수정처럼 입력을 베끼는 작업에서 효율이 좋아요.</li><li><strong>초안을 직접 만들기</strong>: 맞는 작은 모델이 없으면 목표 모델 출력으로 작은 모델을 증류해 α를 올릴 수 있어요. 학습 비용은 <a href="https://tool.smithlab.co.kr/posts/developer/knowledge-distillation-method-cost-selection-guide/">지식 증류 방식 고르기</a>로 따져 보세요.</li></ul>
<aside class="callout warn"><b class="callout-h">주의</b><p>Transformers 문서에 따르면 초안 모델 방식은 그리디와 샘플링은 지원하지만 <strong>배치 입력은 지원하지 않아요.</strong> 여러 요청을 묶어 처리해야 하면 서빙 엔진(vLLM 등)의 초안 모델 설정을 써야 해요. 또 <code>assistant_ensemble_weight</code>(정적 앙상블 검증)를 켜면 출력이 목표 모델 분포가 아니라 혼합 분포가 되니, 무손실이 필요하면 켜지 마세요.</p></aside>
<h2 id="s8">초안 모델 상황별 추천과 고르는 순서</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/speculative-decoding-draft-model-gamma-selection-guide-2.svg" alt="토크나이저가 같은지, 파라미터 1/10 이하이고 층이 적은지, 측정한 α가 c보다 충분히 큰지, 메모리에 들어가는지를 차례로 묻고, 아니요면 UAD·더 작은 후보·증류 초안·n-gram 같은 대안으로 빠지는 순서도" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 확인하고 &#x27;아니요&#x27;가 나오면 오른쪽 대안을 검토해요</figcaption></figure>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>초안 선택</th><th>γ 설정</th></tr></thead><tbody><tr><td>같은 계열에 1/50~1/100 크기 모델이 있음</td><td>그 모델</td><td>계산표 기준 6~10, 또는 동적 기본값</td></tr><tr><td>같은 계열 최소 모델이 1/5~1/10 크기</td><td>써 보되 c를 꼭 측정</td><td>3 이하 고정 또는 동적 기본값</td></tr><tr><td>요약·번역·코드 수정 위주</td><td>프롬프트 룩업부터</td><td><code>prompt_lookup_num_tokens</code> 5 안팎</td></tr><tr><td>높은 온도의 창작·대화</td><td>초안 효과가 작음, 측정 후 결정</td><td>작게(2~3)</td></tr><tr><td>어휘가 맞는 작은 모델이 없음</td><td>UAD 또는 증류 초안</td><td>측정한 α·c로 계산</td></tr><tr><td>24GB급 GPU에 목표가 꽉 참</td><td>더 작은 초안 또는 n-gram</td><td>—</td></tr></tbody></table></div>
<p>순서도의 네 질문을 위에서부터 확인한 뒤, 계산표로 고른 고정 γ와 동적 기본값을 실제 프롬프트로 나란히 재서 빠른 쪽을 쓰면 돼요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 묻는 질문</h2>
<h3>초안 모델은 목표 모델보다 몇 배 작아야 하나요?</h3>
<p>원 논문은 두 자릿수(약 100배), Hugging Face 블로그는 적어도 한 자릿수(10배) 작게 고르라고 해요. 층 수가 비슷하면 10배 작아도 c가 크게 나올 수 있으니 직접 재서 정하세요.</p>
<h3>수락률이 높은 큰 초안이 더 빠르지 않나요?</h3>
<p>아니에요. T5-XXL 실험에서 초안을 77M에서 800M으로 키우자 α는 0.75에서 0.82로 올랐지만 속도는 3.4배에서 1.7배로 떨어졌어요. 초안을 γ번 돌리는 비용이 더 크게 늘었기 때문이에요.</p>
<h3>num_assistant_tokens를 몇으로 두면 되나요?</h3>
<p>Transformers 4.45.0 이후 기본값(동적, 확신도 0.4, 최대 20)은 대부분 고정값보다 나았어요. 고정하고 싶다면 α와 c를 재서 이 글의 계산표로 정하세요. 예를 들어 α 0.7이면 c 0.02에서 7~8, c 0.1에서 4, c 0.2에서 3이 가장 빨라요.</p>
<h3>투기적 디코딩을 켜면 결과가 달라지나요?</h3>
<p>표준 검증 규칙을 쓰면 출력 분포는 목표 모델 혼자 만든 것과 같아요. 다만 여러 토큰을 한 번에 처리하는 계산은 부동소수점 누적 순서가 달라 그리디에서도 드물게 다른 토큰이 나올 수 있어요. <code>assistant_ensemble_weight</code> 같은 느슨한 검증 옵션은 분포 자체가 바뀌어요.</p>
<h3>토크나이저가 다른 작은 모델도 초안으로 쓸 수 있나요?</h3>
<p>Transformers의 UAD(<code>tokenizer</code>·<code>assistant_tokenizer</code>를 함께 넘김)로 쓸 수 있지만 재인코딩만큼 c가 늘어요. 같은 어휘의 작은 모델이 있다면 그쪽이 먼저예요.</p>
<h2 id="s10">정리</h2>
<ul><li>초안 모델의 이득은 α(수락률)·c(비용 비율)·γ(초안 길이)가 정하고, α &gt; c여야 빨라져요. c는 파라미터 비율보다 크게 나오니 층 수까지 보고 직접 재요.</li><li>초안은 같은 어휘의 같은 계열 모델 중 목표의 1/10 이하, 가능하면 1/100 근처에서 골라요. 더 크고 정확한 초안이 더 빠르지는 않아요.</li><li>γ는 α·c로 계산하거나 Transformers의 동적 기본값을 써요. 초안이 무거운 짝(8B + 1B)일수록 동적 γ의 효과가 컸어요(요약 1.00배 → 1.52배).</li><li>초안은 가중치와 KV 캐시를 더 써요(Llama 8B + 1B 기준 가중치 15%, KV 25%). 절감액은 내 GPU에서 잰 속도 배수로 계산하세요.</li><li>다른 방식과 배치별 손익은 <a href="https://tool.smithlab.co.kr/posts/developer/speculative-decoding-method-cost-guide/">투기적 디코딩 방식 고르기</a>에서 이어서 볼 수 있어요.</li></ul>
<h2 id="s11">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/speculative-decoding-draft-model-acceptance" target="_blank" rel="noopener">투기적 디코딩 원리 — 초안 모델·수락 규칙·EAGLE</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>3SUM·4SUM 풀이 고르는 법 — n 한계 계산과 중복·쌍 개수…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/three-sum-four-sum-pair-count-two-pointer-selection-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/three-sum-four-sum-pair-count-two-pointer-selection-guide/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>3SUM 을 제출했더니 예제는 맞는데 시간 초과가 나거나, 같은 조합이 여러 번 나오거나, 쌍 개수를 세는 문제에서 답이 조금씩 모자란 적 있으시죠? 결론부터 말하면 <strong>정렬 뒤 투 포인터 변형은 "몇 개를 고르고 무엇을 돌려주는지"와 n 으로 정하고, 버그는 거의 다 같은 값이 여러 번 나올 때 생겨요.</strong> 3개는 하나를 고정하고 투 포인터, 4개는 n 이 1,000 근처면 쌍 합 해시로 바꾸고, 개수를 셀 때는 같은 값 묶음을 곱해서 더해요.</p>
<p>이 글은 원리 증명보다 <strong>고르는 기준과 직접 잰 숫자</strong>를 다뤄요. 시간·메모리는 Apple M1, 파이썬 3.15.2 로 쟀고(2026-11-05), 문제 제약은 CSES 문제 페이지에서 확인했어요. 절대 시간은 기기마다 다르니 방식 사이의 배수로 읽어 주세요. 구간(부분 배열) 문제에서 투 포인터·슬라이딩 윈도우·누적합을 고르는 기준은 <a href="https://tool.smithlab.co.kr/posts/developer/two-pointer-sliding-window-selection-bug-checklist/">투 포인터 언제 쓰나, 음수·0 입력 판단표</a>에 따로 정리해 두었고, 여기서는 <strong>정렬한 배열에서 원소 몇 개를 골라 합을 맞추는 문제</strong>만 봐요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>3SUM 중복 제거는 결과를 집합에 넣는 것보다 고정값·두 번째 값을 건너뛰는 쪽이 빨라요. 값 범위가 좁은 n=3,000 에서 36ms 대 1,259ms, 전부 0 이면 1.2ms 대 1,321ms 였어요.</li><li>파이썬에서 3SUM O(n²) 은 n=5,000 에 약 3.9초라 시간 제한 1초를 넘어요. 4SUM 은 둘을 고정하는 O(n³) 이 n=1,000 에 약 87초(추산), 쌍 합 해시는 0.48초에 메모리 약 66MiB 였어요.</li><li>합이 x 인 쌍 개수에서 맞을 때 양쪽을 한 칸씩 좁히면 같은 값이 있을 때 덜 세요. 0~5 값 무작위 배열 5,000개 중 1,765개(35%)에서 틀렸어요.</li><li>쌍 개수는 n=20만에서 100억을 넘어요(직접 센 값 10,019,050,186). 32비트 정수로는 담을 수 없어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">정렬 뒤 투 포인터 변형 판단표, 무엇을 돌려주나부터</h2>
<p>정렬하고 양 끝에서 좁혀 오는 틀은 같아도, 돌려줄 값이 바뀌면 포인터를 움직인 뒤 하는 일이 달라져요. 문제를 읽으면 먼저 "고를 원소 수"와 "돌려줄 것"을 적어요.</p>
<div class="table-wrap"><table><thead><tr><th>고를 수</th><th>돌려줄 것</th><th>고를 방법</th><th>시간</th><th>놓치기 쉬운 것</th></tr></thead><tbody><tr><td>2</td><td>있나, 원래 위치</td><td>해시 한 번 훑기 또는 (값, 위치) 정렬 + 투 포인터</td><td>O(n) 또는 O(n log n)</td><td>같은 위치 두 번 쓰기</td></tr><tr><td>2</td><td>합 &amp;lt; k 인 쌍 개수</td><td>투 포인터, 조건을 만족하면 hi − lo 를 한 번에 더함</td><td>정렬 + O(n)</td><td>개수 64비트</td></tr><tr><td>2</td><td>합 = x 인 쌍 개수</td><td>투 포인터 + 같은 값 묶음 곱하기, 또는 Counter</td><td>정렬 + O(n) / 평균 O(n)</td><td>중복 값에서 덜 세기</td></tr><tr><td>2</td><td>합이 x 에 가장 가까운 쌍</td><td>투 포인터, 매 단계 차이 갱신</td><td>정렬 + O(n)</td><td>동점 규칙, 차이 오버플로</td></tr><tr><td>3</td><td>있나, 위치</td><td>하나 고정 + 투 포인터</td><td>O(n²)</td><td>고정한 원소를 안쪽에서 다시 쓰기</td></tr><tr><td>3</td><td>서로 다른 값 조합 목록</td><td>하나 고정 + 투 포인터 + 건너뛰기</td><td>O(n²)</td><td>중복 조합, 건너뛰는 위치</td></tr><tr><td>4</td><td>있나, 위치 (n 수백)</td><td>둘 고정 + 투 포인터</td><td>O(n³)</td><td>n=1,000 이면 시간 초과</td></tr><tr><td>4</td><td>있나, 위치 (n 약 1,000)</td><td>쌍 합 해시(앞 쌍 저장, 뒤 쌍 조회)</td><td>O(n²) · 메모리 O(n²)</td><td>같은 위치를 두 쌍에서 쓰기</td></tr></tbody></table></div>
<p>표의 "합 &amp;lt; k 쌍 개수" 줄이 투 포인터가 해시보다 확실히 나은 자리예요. 해시는 "정확히 같은 값"만 찾을 수 있어서 부등식 개수에는 못 써요. 반대로 "합 = x 쌍이 있나"만 묻는다면 정렬이 필요 없는 해시가 보통 더 빨라요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/three-sum-four-sum-pair-count-two-pointer-selection-guide-1.svg" alt="고를 원소가 2개면 돌려줄 것(있나·위치, 합 미만 개수, 합 같은 개수, 가장 가까운 합)에 따라, 3개면 하나 고정 후 투 포인터와 건너뛰기, 4개면 n 크기에 따라 O(n³) 또는 쌍 합 해시로 가는 판단 순서도" width="800" height="560" loading="lazy" decoding="async"><figcaption>왼쪽에서 고를 수를 정하고 오른쪽 칸의 방법을 써요</figcaption></figure>
<h2 id="s2">3SUM 중복 제거 방식별 비용, 건너뛰기 vs 집합</h2>
<p>"합이 0 인 서로 다른 세 값 조합을 중복 없이" 같은 문제에서 중복을 없애는 방법은 크게 셋이에요.</p>
<ul><li><strong>건너뛰기</strong>: 고정값 a[i] 가 바로 앞과 같으면 그 i 를 통째로 건너뛰고, 답을 찾은 뒤에는 같은 a[lo] 를 건너뛰어요.</li><li><strong>집합</strong>: 건너뛰지 않고 찾은 조합을 전부 <code>set</code> 에 넣어 겹치는 것을 지워요.</li><li><strong>고정 + 해시</strong>: 하나를 고정하고 나머지에서 "필요한 값이 이미 나왔나"를 집합으로 찾아요. 정렬은 중복 정리용으로만 써요.</li></ul>
<p>세 방법 모두 같은 조합 목록을 냈어요. 시간은 크게 갈렸어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">3SUM 조합 목록 n=3000, 중복 제거 방식별 실행 시간</figcaption><div class="viz-legend"><span><i class="s1"></i>값 범위 ±50</span><span><i class="s2"></i>값 범위 ±100만</span></div><svg viewBox="0 0 640 300" role="img" aria-label="3SUM 조합 목록 n=3000, 중복 제거 방식별 실행 시간" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">500</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">1,500</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">2,000</text><rect class="s1" x="80.8" y="249.8" width="66.2" height="4.2" rx="3"><title>건너뛰기 · 값 범위 ±50 36ms</title></rect><rect class="s2" x="149.0" y="95.2" width="66.2" height="158.8" rx="3"><title>건너뛰기 · 값 범위 ±100만 1,346ms</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">건너뛰기</text><rect class="s1" x="272.8" y="105.4" width="66.2" height="148.6" rx="3"><title>결과를 집합에 · 값 범위 ±50 1,259ms</title></rect><rect class="s2" x="341.0" y="97.1" width="66.2" height="156.9" rx="3"><title>결과를 집합에 · 값 범위 ±100만 1,330ms</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">결과를 집합에</text><rect class="s1" x="464.8" y="246.9" width="66.2" height="7.1" rx="3"><title>고정+해시 · 값 범위 ±50 60ms</title></rect><rect class="s2" x="533.0" y="30.4" width="66.2" height="223.6" rx="3"><title>고정+해시 · 값 범위 ±100만 1,895ms</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">고정+해시</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">전부 0 인 배열 3,000개는 건너뛰기 1.2ms, 집합 1,321ms였어요. 결과는 모두 (0, 0, 0) 하나예요.</p><div class="viz-cap">단위: ms · 자료: 파이썬 3.15.2, Apple M1에서 직접 측정 (세 번 중 최솟값, 고정+해시는 한 번)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>값 범위 ±50</th><th>값 범위 ±100만</th></tr></thead><tbody><tr><td>건너뛰기</td><td>36</td><td>1,346</td></tr><tr><td>결과를 집합에</td><td>1,259</td><td>1,330</td></tr><tr><td>고정+해시</td><td>60</td><td>1,895</td></tr></tbody></table></div></details></figure>
<p>값이 넓게 퍼져 중복이 거의 없으면 건너뛰기와 집합은 비슷해요. 차이는 <strong>같은 값이 많을 때</strong> 나요. 값 범위 ±50 에 원소 3,000개면 같은 값이 평균 30개씩이에요. 건너뛰기는 서로 다른 고정값 101개에 대해서만 안쪽 투 포인터를 돌리지만, 집합 방식은 3,000번을 다 돌고 같은 조합을 수없이 만든 뒤 지워요. 그래서 약 35배 느렸어요.</p>
<p>운영 데이터는 대개 중복이 많아요. 금액·수량·점수처럼 단위가 정해진 값은 범위가 좁고, 같은 값이 수십~수천 번 나와요.</p>
<p>건너뛰는 위치는 두 곳이고 순서가 중요해요.</p>
<pre class="code-block" data-lang="python"><code>def three_sum_zero(nums):
    a = sorted(nums)
    out = []
    for i in range(len(a) - 2):
        if i &gt; 0 and a[i] == a[i - 1]:
            continue                      # ① 같은 고정값은 한 번만
        if a[i] &gt; 0:
            break                         # 정렬돼 있으니 뒤도 전부 양수
        lo, hi = i + 1, len(a) - 1
        while lo &lt; hi:
            s = a[i] + a[lo] + a[hi]
            if s &lt; 0:
                lo += 1
            elif s &gt; 0:
                hi -= 1
            else:
                out.append((a[i], a[lo], a[hi]))
                lo += 1
                hi -= 1
                while lo &lt; hi and a[lo] == a[lo - 1]:
                    lo += 1               # ② 답을 찾은 &quot;뒤에만&quot; 건너뛰기
    return out

print(three_sum_zero([-1, 0, 1, 2, -1, -4]))   # [(-1, -1, 2), (-1, 0, 1)]
print(three_sum_zero([0, 0, 0, 0]))            # [(0, 0, 0)]</code></pre>
<p>①을 <code>a[i] == a[i + 1]</code> 로 쓰면 첫 번째 −1 을 건너뛰고 두 번째 −1 을 고정해서 <code>(-1, -1, 2)</code> 를 놓쳐요. ②를 답을 찾기 전에 하면 마찬가지로 두 번째 자리에 같은 값을 쓰는 조합을 놓쳐요. <code>a[i] &gt; 0</code> 에서 멈추는 줄은 목표가 0 일 때만 맞아요. 목표가 다른 값이면 <code>a[i] * 3 &gt; target</code> 처럼 바꿔야 해요.</p>
<h2 id="s3">n 한계 직접 계산, 3SUM·4SUM 은 파이썬으로 어디까지 되나</h2>
<p>같은 O(n²) 이라도 언어에 따라 통과하는 n 이 달라요. 답이 없는 최악의 입력(1~10억 무작위, 목표 1)으로 "세 수 합이 있나"를 쟀어요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">파이썬 3SUM(하나 고정 + 투 포인터) 최악 실행 시간</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="파이썬 3SUM(하나 고정 + 투 포인터) 최악 실행 시간" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">2,000</text><line class="viz-grid" x1="52" x2="570" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">3,000</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">4,000</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">n=1000</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">n=2000</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">n=5000</text><polyline class="viz-line s1" points="52.0,245.1 311.0,219.0 570.0,23.4"/><circle class="viz-dot s1" cx="52.0" cy="245.1" r="4"><title>n=1000 · 실행 시간 151ms</title></circle><circle class="viz-dot s1" cx="311.0" cy="219.0" r="4"><title>n=2000 · 실행 시간 594ms</title></circle><circle class="viz-dot s1" cx="570.0" cy="23.4" r="4"><title>n=5000 · 실행 시간 3,908ms</title></circle><text class="viz-val" x="578.0" y="27.4">3,908</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">n이 2.5배 늘면 시간은 약 6.6배 늘어요. O(n²)에서 기대하는 6.25배와 비슷해요.</p><div class="viz-cap">단위: ms · 자료: 파이썬 3.15.2, Apple M1에서 직접 측정, 답이 없는 입력</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>실행 시간</th></tr></thead><tbody><tr><td>n=1000</td><td>151</td></tr><tr><td>n=2000</td><td>594</td></tr><tr><td>n=5000</td><td>3,908</td></tr></tbody></table></div></details></figure>
<p><a href="https://cses.fi/problemset/task/1641" target="_blank" rel="noopener">CSES 1641 Sum of Three Values</a> 는 n ≤ 5,000, 시간 제한 1초예요. C++ 로는 안쪽 단계가 약 1,250만 번이라 넉넉하지만, 위 측정대로 순수 파이썬은 약 3.9초라 통과하기 어려워요. 이때 고를 수 있는 길은 둘이에요.</p>
<ol><li>같은 알고리즘을 C++·자바·Go 로 옮겨요. 가장 확실해요.</li><li>가지치기를 넣어요. 정렬돼 있으니 <code>a[i] + a[i+1] + a[i+2] &gt; x</code> 면 바깥 루프를 끝내고, <code>a[i] + a[n-2] + a[n-1] &lt; x</code> 면 그 i 를 건너뛰어요. 최악 입력에서는 효과가 없으니 보험으로만 봐요.</li></ol>
<p>4SUM 은 차이가 더 커요. <a href="https://cses.fi/problemset/task/1642" target="_blank" rel="noopener">CSES 1642 Sum of Four Values</a> 는 n ≤ 1,000, 시간 제한 1초, 메모리 512MB 예요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">4SUM n=1000 위치 찾기, 방식별 실행 시간 (파이썬)</figcaption><svg viewBox="0 0 640 84" role="img" aria-label="4SUM n=1000 위치 찾기, 방식별 실행 시간 (파이썬)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">둘 고정 + 투 포인터 O(n³)</text><rect class="s1" x="220" y="15" width="303.1" height="20" rx="3"><title>둘 고정 + 투 포인터 O(n³) · 86.6초</title></rect><text class="viz-val" x="529.1" y="29">86.6초</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">쌍 합 해시 O(n²)</text><rect class="s1" x="220" y="49" width="1.7" height="20" rx="3"><title>쌍 합 해시 O(n²) · 0.48초</title></rect><text class="viz-val" x="227.7" y="63">0.48초</text></svg><p class="viz-note">쌍 합 해시의 최대 추가 메모리는 tracemalloc으로 약 65.6MiB였어요.</p><div class="viz-cap">단위: 초 · 자료: 파이썬 3.15.2, Apple M1에서 직접 측정. O(n³)은 n=300 실측 2.34초에 (1000/300)³을 곱한 추산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>실행 시간</th></tr></thead><tbody><tr><td>둘 고정 + 투 포인터 O(n³)</td><td>86.6</td></tr><tr><td>쌍 합 해시 O(n²)</td><td>0.48</td></tr></tbody></table></div></details></figure>
<p>둘을 고정하는 O(n³) 은 n=1,000 에서 안쪽 단계가 약 1.7억 번(n³/6)이에요. 단계가 단순해 C++ 로는 1초 안에 들 수 있지만 파이썬으로는 안 돼요. 쌍 합 해시는 "앞쪽 쌍의 합"을 딕셔너리에 쌓으면서 "뒤쪽 쌍"으로 조회해요.</p>
<pre class="code-block" data-lang="python"><code>def four_sum_positions(a, x):
    d = {}                                  # 쌍의 합 -&gt; (i, j), 모두 j 보다 앞 위치
    for j in range(len(a)):
        for k in range(j + 1, len(a)):      # 뒤쪽 쌍 (j, k) 로 조회
            p = d.get(x - a[j] - a[k])
            if p:
                return p + (j, k)
        for i in range(j):                  # (i, j) 를 앞쪽 쌍으로 등록
            d[a[i] + a[j]] = (i, j)
    return None</code></pre>
<p>j 를 기준으로 "조회 먼저, 등록 나중"이라 저장된 쌍은 늘 j 보다 앞 위치만 써요. 그래서 네 위치가 겹치지 않아요. 무작위 작은 배열 2,000개로 전수 조합과 대조해 모두 맞았어요. 대가는 메모리예요. 쌍이 n(n−1)/2 = 499,500 개라 파이썬에서 약 66MiB 를 썼어요. 같은 방식을 n=1만에 쓰면 쌍이 약 5,000만 개라 파이썬 딕셔너리로는 수 GB 가 들어요. 쌍 합 해시는 <strong>n 이 1,000~수천인 4SUM 전용</strong>으로 생각해 두면 돼요.</p>
<h2 id="s4">쌍 개수 세기, 같은 값에서 덜 세는 버그</h2>
<p>"합이 정확히 x 인 쌍이 몇 개인가"에서 가장 흔한 실수는 찾기 문제의 코드를 그대로 가져와 <code>cnt += 1; lo += 1; hi -= 1</code> 로 쓰는 거예요. 값이 모두 다르면 맞지만, 같은 값이 있으면 덜 세요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/three-sum-four-sum-pair-count-two-pointer-selection-guide-2.svg" alt="1, 1, 3, 3 배열에서 x=4 이면 1 묶음 2개와 3 묶음 2개를 곱해 4쌍, 2, 2, 2 배열에서 x=4 이면 남은 3개가 모두 같은 값이라 3×2÷2 = 3쌍인데 한 칸씩 좁히면 각각 2쌍과 1쌍만 센다는 비교 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 값은 묶음 크기로 한 번에 세고, 양 끝이 같은 값이면 그 자리에서 끝내요</figcaption></figure>
<p>고치는 법은 경우 두 개로 나눠요. a[lo] 와 a[hi] 값이 다르면 양쪽 묶음 크기를 세어 곱하고 두 묶음을 한꺼번에 넘겨요. 값이 같으면 lo~hi 사이가 전부 그 값이니 남은 개수 m 에서 m(m−1)/2 를 더하고 끝내요.</p>
<pre class="code-block" data-lang="python"><code>def count_pairs_equal(a, x):
    a = sorted(a)
    lo, hi, cnt = 0, len(a) - 1, 0
    while lo &lt; hi:
        s = a[lo] + a[hi]
        if s &lt; x:
            lo += 1
        elif s &gt; x:
            hi -= 1
        elif a[lo] == a[hi]:
            m = hi - lo + 1
            cnt += m * (m - 1) // 2
            break
        else:
            l2, h2 = lo, hi
            while a[l2] == a[lo]:
                l2 += 1
            while a[h2] == a[hi]:
                h2 -= 1
            cnt += (l2 - lo) * (hi - h2)
            lo, hi = l2, h2
    return cnt

print(count_pairs_equal([1, 1, 3, 3], 4), count_pairs_equal([2, 2, 2], 4))   # 4 3</code></pre>
<p>0~5 범위 값으로 길이 2~10 인 무작위 배열 5,000개를 전수 탐색과 대조했더니, 한 칸씩 좁히는 코드는 <strong>1,765개(35%)에서 틀렸고</strong> 위 코드는 모두 맞았어요. <code>collections.Counter</code> 로 "지금까지 본 값의 개수"를 세며 한 번 훑는 방법도 모두 맞았어요. n=20만, 값 1~1,000 에서 투 포인터 81ms, Counter 107ms 로 비슷했어요. 정렬이 이미 돼 있지 않으면 Counter 쪽이 코드가 짧고 실수할 곳이 적어요(<a href="https://docs.python.org/3/library/collections.html" target="_blank" rel="noopener">파이썬 collections 문서</a>).</p>
<h3>합이 k 미만인 쌍 개수, 투 포인터 vs 이분 탐색</h3>
<p>부등식 개수는 해시로 못 세요. 정렬 뒤 고를 수 있는 방법은 투 포인터와 원소마다 이분 탐색이에요. n=20만, 값 1~10억, k=10억으로 쟀어요.</p>
<ul><li>투 포인터: 149ms (조건을 만족하면 <code>cnt += hi - lo</code>)</li><li>원소마다 <code>bisect_left(a, k - x, i + 1)</code>: 406ms</li><li>둘 다 정렬 103ms 포함, 결과는 같은 10,019,050,186</li></ul>
<p><a href="https://docs.python.org/3/library/bisect.html" target="_blank" rel="noopener">파이썬 bisect 문서</a> 대로 <code>bisect_left</code> 는 lo 인자를 받아서, <code>i + 1</code> 을 넘기면 자기 자신과 앞쪽 원소를 빼고 셀 수 있어요. 이분 탐색은 원소마다 독립이라 "각 원소와 짝이 되는 개수"까지 필요할 때 편하고, 전체 개수 하나만 필요하면 투 포인터가 약 2.7배 빨랐어요. 경계(<code>bisect_left</code> 냐 <code>bisect_right</code> 냐, 미만이냐 이하냐)를 헷갈리기 쉬우니 <a href="https://tool.smithlab.co.kr/posts/developer/binary-search-bisect-selection-bug-checklist/">이분 탐색 판단표와 경계 버그 체크리스트</a> 도 같이 보면 좋아요.</p>
<p>결과 10,019,050,186 은 32비트 정수 최대 2,147,483,647 의 약 4.7배예요. n=20만이면 쌍이 최대 약 200억 개라, C++·자바에서 개수를 <code>int</code> 로 두면 조용히 틀린 값이 나와요. 개수 변수는 처음부터 <code>long long</code>·<code>long</code>·<code>int64</code> 로 둬요.</p>
<h2 id="s5">가장 가까운 합 문제에서 놓치는 것</h2>
<p>"합이 x 에 가장 가까운 세 수"처럼 정확히 맞지 않아도 되는 문제는 매 단계 차이를 비교하며 최선을 갱신해요. 틀은 3SUM 과 같지만 확인할 곳이 셋 있어요.</p>
<ul><li><strong>동점 규칙</strong>: x−1 과 x+1 이 똑같이 가까우면 무엇을 내야 하는지 문제에 적혀 있는지 봐요. <code>abs(s - x) &lt; abs(best - x)</code> 로 갱신하면 먼저 찾은 쪽이 남고, "작은 쪽" 규칙이 있으면 <code>(abs(s - x), s)</code> 튜플로 비교해요.</li><li><strong>초깃값과 오버플로</strong>: 초깃값을 <code>INT_MAX</code> 같은 큰 수로 두면 <code>best - x</code> 계산이 넘칠 수 있어요. 첫 후보 <code>a[0] + a[1] + a[2]</code> 로 시작하거나 차이를 64비트로 계산해요.</li><li><strong>일찍 끝내기</strong>: 차이가 0 이면 더 볼 필요가 없으니 바로 돌려줘요. 정확히 맞는 답이 많은 입력에서 시간이 크게 줄어요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">운영에서 자주 나는 문제와 확인 순서, 체크리스트</h2>
<p>정렬 뒤 투 포인터 변형의 버그는 예외 없이 <strong>조용히 틀린 값</strong>을 내요. 아래 순서로 보면 대부분 잡혀요.</p>
<ol><li><strong>돌려줄 것 다시 읽기.</strong> "있나"와 "몇 개"와 "목록"은 맞았을 때 하는 일이 달라요. 찾기 코드를 개수 문제에 복사하지 않았는지 봐요.</li><li><strong>같은 값 처리.</strong> 개수 문제는 묶음 곱하기와 m(m−1)/2, 목록 문제는 건너뛰는 두 위치(①은 앞과 비교, ②는 답 뒤에서만)를 확인해요.</li><li><strong>같은 위치 재사용.</strong> 2개를 고르면 <code>lo &lt; hi</code>, 3개면 안쪽이 <code>i + 1</code> 부터, 4개 쌍 합 해시면 "조회 먼저, 등록 나중"인지 봐요.</li><li><strong>정수 폭.</strong> 합은 원소 최대 × 고를 수, 개수는 n(n−1)/2 까지 가요. 원소 10⁹ 를 네 개 더하면 4×10⁹ 라 32비트를 넘어요.</li><li><strong>n 과 언어.</strong> 파이썬이면 3SUM 은 n 2,000 안팎, 4SUM O(n³) 은 n 200~300 안팎이 1~3초 선이에요. 그보다 크면 알고리즘이나 언어를 바꿔요.</li><li><strong>중복이 많은 입력으로 시간 재기.</strong> 값 범위가 넓은 무작위 입력과, 전부 같은 값·좁은 범위 입력을 둘 다 넣어요. 집합 중복 제거는 뒤쪽에서만 느려져요.</li></ol>
<p>고친 뒤엔 값 범위를 0~5 처럼 좁힌 무작위 작은 배열로 전수 탐색과 대조해요.</p>
<pre class="code-block" data-lang="python"><code>import random
from itertools import combinations

def brute_pairs(a, x):
    return sum(p + q == x for p, q in combinations(a, 2))

for _ in range(5000):
    a = [random.randint(0, 5) for _ in range(random.randint(2, 10))]
    x = random.randint(0, 10)
    assert count_pairs_equal(a, x) == brute_pairs(a, x), (a, x)</code></pre>
<aside class="callout warn"><b class="callout-h">주의</b><p>코딩 테스트 예제와 블로그 예시는 값이 대부분 서로 달라요. 같은 값이 여러 번 나오는 대조 테스트가 없으면 개수 버그와 집합 중복 제거의 느린 경우가 둘 다 운영 데이터에서 처음 드러나요.</p></aside>
<h2 id="s7">도입 전에 따질 것</h2>
<p>서비스 코드에 이런 변형을 직접 넣기 전에 확인해요.</p>
<ul><li><strong>정렬 비용과 출처</strong>: 이미 정렬된 목록(DB <code>ORDER BY</code>, 정렬된 가격표)이면 투 포인터가 가장 싸요. 새로 정렬해야 하면 n=20만 파이썬에서 정렬만 약 0.1초가 들고, 정확히 같은 값만 찾는 문제라면 해시가 더 단순해요. 정렬 비용은 <a href="https://tool.smithlab.co.kr/posts/developer/python-java-sort-performance-comparator-guide/">파이썬·자바 정렬 성능 판단표</a> 에서 key 함수별로 볼 수 있어요.</li><li><strong>원래 위치가 필요한가</strong>: 위치를 출력하거나 저장해야 하면 (값, 위치) 쌍으로 정렬해야 하고, 메모리와 시간이 늘어요.</li><li><strong>조합 수의 상한</strong>: 3SUM 목록은 최악에 결과가 O(n²) 개일 수 있어요. 결과를 전부 메모리에 담을지, 하나씩 내보낼지 먼저 정해요.</li><li><strong>4개 이상</strong>: 고를 수가 4개를 넘으면 쌍 합 해시도 메모리가 빠르게 늘어요. 이때는 문제를 "합이 x 이하인 부분집합"처럼 다른 꼴로 바꿀 수 있는지, 값 범위가 작아 DP 가 되는지부터 봐요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>3SUM 중복 제거는 set 에 넣으면 안 되나요?</h3>
<p>결과는 맞아요. 다만 같은 값이 많은 입력에서 크게 느려져요. n=3,000, 값 범위 ±50 에서 건너뛰기 36ms, 집합 1,259ms 였고 전부 0 이면 1.2ms 대 1,321ms 였어요. 고정값과 두 번째 값을 건너뛰는 방식으로 쓰는 게 안전해요.</p>
<h3>파이썬으로 n=5,000 인 3SUM 이 시간 초과가 나요.</h3>
<p>순수 파이썬 O(n²) 은 답이 없는 최악 입력에서 n=5,000 에 약 3.9초였어요(Apple M1). 1초 제한이면 C++·자바로 옮기는 게 가장 확실해요. 가지치기는 최악 입력에서 효과가 없어서 보험 정도예요.</p>
<h3>4SUM 은 O(n³) 투 포인터로 충분한가요?</h3>
<p>n 이 수백이면 충분해요. n=1,000 이면 안쪽 단계가 약 1.7억 번이라 파이썬으로 약 87초(추산)였어요. 이때는 쌍 합을 딕셔너리에 저장하는 O(n²) 방식으로 바꿔요. n=1,000 에서 0.48초, 메모리 약 66MiB 였어요.</p>
<h3>합이 x 인 쌍 개수가 조금씩 모자라요.</h3>
<p>맞는 쌍을 찾은 뒤 양쪽을 한 칸씩만 좁히고 있을 가능성이 커요. 같은 값 묶음 크기를 곱해서 더하고, 양 끝이 같은 값이면 남은 개수 m 으로 m(m−1)/2 를 더한 뒤 끝내야 해요. 정렬이 필요 없다면 Counter 로 한 번 훑는 방법이 더 단순해요.</p>
<h3>쌍 개수 결과가 음수로 나와요.</h3>
<p>32비트 정수 오버플로예요. n=20만이면 쌍 개수가 최대 약 200억이라 int 최대값(약 21억)을 넘어요. 개수 변수를 64비트로 바꾸고, 원소 여러 개를 더하는 합도 64비트로 계산해요.</p>
<h3>합이 k 미만인 쌍은 이분 탐색과 투 포인터 중 무엇이 낫나요?</h3>
<p>전체 개수 하나만 필요하면 투 포인터가 빨랐어요(n=20만에서 149ms 대 406ms, 정렬 포함). 원소마다 짝이 되는 개수가 따로 필요하거나, 질의마다 k 가 바뀌어 정렬 배열을 여러 번 쓰는 경우엔 이분 탐색이 편해요.</p>
<h2 id="s9">정리</h2>
<ul><li>정렬 뒤 투 포인터 변형은 "고를 수"와 "돌려줄 것"으로 골라요. 부등식 개수는 투 포인터, 정확히 같은 값 찾기는 해시가 보통 단순해요.</li><li>3SUM 중복 제거는 건너뛰기로 해요. 같은 값이 많을수록 집합 방식과의 차이가 커졌어요(최대 약 1,000배).</li><li>파이썬 한계는 3SUM n 2,000 안팎, 4SUM O(n³) n 200~300 안팎이에요. 4SUM n=1,000 은 쌍 합 해시로 0.48초·약 66MiB 였어요.</li><li>개수 문제는 같은 값 묶음을 곱하고, 개수와 합은 64비트로 담고, 좁은 값 범위의 무작위 대조로 검증해요.</li><li>구간 합·슬라이딩 윈도우 쪽 판단은 <a href="https://tool.smithlab.co.kr/posts/developer/two-pointer-sliding-window-selection-bug-checklist/">투 포인터 언제 쓰나, 음수·0 입력 판단표</a> 에서 이어서 볼 수 있어요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/two-pointers-two-sum-proof-sliding-window-monotonicity" target="_blank" rel="noopener">투 포인터 원리 — 정렬 2SUM 증명과 슬라이딩 윈도우 단조성</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>위상 정렬 Kahn vs DFS 고르는 법 — 순환 의존 탐지 비용과…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/topological-sort-kahn-dfs-cycle-detection-selection-checklist/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/topological-sort-kahn-dfs-cycle-detection-selection-checklist/</guid>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>빌드 단계나 마이그레이션, 작업 스케줄러에 의존 관계 정렬을 넣으려는데 Kahn 으로 갈지 DFS 로 갈지, 아니면 표준 라이브러리를 쓸지 고민되시죠? 결론부터 말하면 <strong>순서와 병렬 단계가 필요하면 Kahn(진입 차수 + 큐), 사이클이 났을 때 "어디가 고리인지" 보여 줘야 하면 3색 DFS를 고르고, 서비스에서는 둘을 같이 쓰는 경우가 많아요.</strong> 시간 복잡도는 둘 다 O(V+E) 라서 고르는 기준은 속도보다 "무엇을 돌려줘야 하나"예요.</p>
<p>이 글은 원리 증명 대신 <strong>고르는 기준과 직접 잰 숫자</strong>를 다뤄요. 시간은 Apple M1, 파이썬 3.15.2 로 쟀어요(2026-11-05). 절대 시간은 기기마다 다르니 방식 사이의 배수로 읽어 주세요. 최단 거리냐 경로 모양이냐로 BFS·DFS 를 고르는 큰 기준은 <a href="https://tool.smithlab.co.kr/posts/developer/bfs-dfs-selection-memory-recursion-checklist/">BFS DFS 언제 쓰나, 문제 유형별 판단표</a>에 있고, 여기서는 <strong>방향 그래프의 순서 정하기와 고리 찾기</strong>만 봐요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>정점 10만·간선 20만 무작위 DAG 에서 재귀 3색 DFS 134ms, Kahn 205ms, 반복형 3색 DFS 209ms, 사전순 최소 Kahn(힙) 354ms, graphlib 1,699ms 였어요.</li><li>"방문한 정점을 또 만나면 사이클" 코드는 사이클 없는 정점 20·간선 10 무작위 그래프 1만 개 중 9,999개를 사이클이라고 답했어요.</li><li>간선을 하나씩 넣으며 매번 전체를 다시 검사하면 간선 8,000개에 30초가 걸렸고, 추가 전에 "새 간선의 끝에서 시작점으로 갈 수 있나"만 보면 0.014초였어요.</li><li>작업 200개(합 1,106초)를 의존 순서대로 돌리면 작업자 32명에서 61초로 줄고 더는 안 줄어요. 가장 긴 의존 사슬이 바닥이에요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">위상 정렬·사이클 탐지 판단표, 무엇을 돌려줘야 하나</h2>
<p>같은 그래프를 다루더라도 호출하는 쪽이 원하는 결과가 다르면 고를 방법이 달라져요. 먼저 "정상일 때 무엇을, 사이클일 때 무엇을 돌려줄지"를 한 줄씩 적어요.</p>
<div class="table-wrap"><table><thead><tr><th>필요한 결과</th><th>고를 방법</th><th>시간 · 추가 메모리</th><th>놓치기 쉬운 것</th></tr></thead><tbody><tr><td>순서 하나</td><td>Kahn 또는 3색 DFS</td><td>O(V+E) · O(V)</td><td>간선 방향(의존 대상 → 의존자)</td></tr><tr><td>같은 단계끼리 병렬 실행</td><td>Kahn, 단계별로 묶어 꺼내기</td><td>O(V+E) · O(V)</td><td>단계 수는 가장 긴 사슬 길이</td></tr><tr><td>사전순 가장 작은 순서</td><td>Kahn + 최소 힙</td><td>O(V log V + E)</td><td>큐보다 약 1.7배 느림</td></tr><tr><td>실행할 때마다 같은 순서</td><td>Kahn + 정렬된 준비 목록</td><td>O(V log V + E)</td><td>해시·삽입 순서에 기대지 않기</td></tr><tr><td>사이클이면 고리 경로</td><td>3색 DFS + parent</td><td>O(V+E) · O(V)</td><td>경로는 고리 하나만</td></tr><tr><td>고리에 엮인 정점 전부</td><td>강연결 성분(Tarjan·Kosaraju)</td><td>O(V+E)</td><td>크기 1 성분도 자기 간선이면 고리</td></tr><tr><td>간선을 하나씩 추가하며 막기</td><td>추가 전 도달 검사</td><td>간선마다 최악 O(V+E)</td><td>매번 전체 정렬 다시 하기</td></tr></tbody></table></div>
<p>표에서 Kahn 과 DFS 가 갈리는 지점은 두 줄이에요. 병렬 단계는 Kahn 이 거저 주고, 고리 경로는 DFS 가 거저 줘요. Kahn 은 사이클이 있으면 "끝까지 못 꺼낸 정점이 남는다"는 것만 알려 주고, DFS 는 회색(지금 재귀 경로 위) 정점을 다시 만나는 순간 그 경로를 그대로 돌려줄 수 있어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/topological-sort-kahn-dfs-cycle-detection-selection-checklist-1.svg" alt="돌려줘야 하는 것(순서·병렬, 사전순, 사이클 경로, 간선 추가 시 차단, 고리 묶음, 파이썬에서 빨리)별로 Kahn, Kahn+최소 힙, 3색 DFS, 도달 검사, 강연결 성분, graphlib 을 고르는 판단표" width="800" height="520" loading="lazy" decoding="async"><figcaption>왼쪽에서 필요한 결과를 찾고 오른쪽 방법을 써요</figcaption></figure>
<h2 id="s2">파이썬 실행 시간 직접 재기, Kahn·DFS·graphlib</h2>
<p><a href="https://cses.fi/problemset/task/1679" target="_blank" rel="noopener">CSES 1679 Course Schedule</a> 의 제약(정점 n ≤ 10⁵, 간선 m ≤ 2·10⁵, 1초)과 같은 크기의 무작위 DAG 를 만들고, 다섯 구현을 각각 5번 돌려 가장 빠른 값을 적었어요. 모든 결과가 "모든 간선 u→v 에서 u 가 v 보다 앞"인지 따로 검증했어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">정점 10만 간선 20만 DAG 위상 정렬 시간 (Python 3.15.2)</figcaption><svg viewBox="0 0 640 186" role="img" aria-label="정점 10만 간선 20만 DAG 위상 정렬 시간 (Python 3.15.2)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="130" y="29" text-anchor="end">재귀 3색 DFS</text><rect class="s1" x="140" y="15" width="28.8" height="20" rx="3"><title>재귀 3색 DFS · 134ms</title></rect><text class="viz-val" x="174.8" y="29">134ms</text><text class="viz-ax lg" x="130" y="63" text-anchor="end">Kahn 큐</text><rect class="s1" x="140" y="49" width="44.1" height="20" rx="3"><title>Kahn 큐 · 205ms</title></rect><text class="viz-val" x="190.1" y="63">205ms</text><text class="viz-ax lg" x="130" y="97" text-anchor="end">반복형 3색 DFS</text><rect class="s1" x="140" y="83" width="44.9" height="20" rx="3"><title>반복형 3색 DFS · 209ms</title></rect><text class="viz-val" x="190.9" y="97">209ms</text><text class="viz-ax lg" x="130" y="131" text-anchor="end">Kahn 최소 힙</text><rect class="s1" x="140" y="117" width="76.1" height="20" rx="3"><title>Kahn 최소 힙 · 354ms</title></rect><text class="viz-val" x="222.1" y="131">354ms</text><text class="viz-ax lg" x="130" y="165" text-anchor="end">graphlib</text><rect class="s1" x="140" y="151" width="365.3" height="20" rx="3"><title>graphlib · 1,699ms</title></rect><text class="viz-val" x="511.3" y="165">1,699ms</text></svg><p class="viz-note">graphlib 은 딕셔너리로 바꾸는 시간 포함. 나머지는 인접 리스트 입력</p><div class="viz-cap">단위: ms · 자료: Apple M1, 5회 중 최솟값, 2026-11-05 직접 측정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>실행 시간</th></tr></thead><tbody><tr><td>재귀 3색 DFS</td><td>134</td></tr><tr><td>Kahn 큐</td><td>205</td></tr><tr><td>반복형 3색 DFS</td><td>209</td></tr><tr><td>Kahn 최소 힙</td><td>354</td></tr><tr><td>graphlib</td><td>1,699</td></tr></tbody></table></div></details></figure>
<p>읽을 점이 셋이에요.</p>
<ul><li><strong>Kahn 과 반복형 DFS 는 사실상 같아요.</strong> 205ms 대 209ms 라 속도로 고를 이유가 없어요. 재귀 DFS 가 134ms 로 가장 빨랐지만, 정점이 일렬로 길게 이어지면 재귀 깊이 문제가 생겨요(아래).</li><li><strong>사전순 최소 순서는 힙 비용이 들어요.</strong> 같은 그래프에서 354ms, 큐의 약 1.7배였어요. "실행할 때마다 같은 순서"만 필요하면 사전순까지 갈 필요 없이 준비 목록을 정렬하는 것으로 충분한 경우가 많아요.</li><li><strong>graphlib 은 약 8배 느렸어요.</strong> 중앙값으로 나눠 보면 딕셔너리 만들기 234ms, <code>TopologicalSorter</code> 생성 640ms, <code>prepare()</code> 460ms, <code>get_ready()</code>/<code>done()</code> 반복 336ms 였어요. CPython 소스를 보면 <code>prepare()</code> 가 순서를 내기 전에 <code>_find_cycle()</code> 로 사이클 검사를 한 번 따로 돌려요. 정점 수천 개짜리 설정 파일이나 빌드 단계라면 몇 ms 라 문제없고, 요청마다 정점 수만 개를 정렬하는 경로라면 직접 짜는 쪽이 나아요.</li></ul>
<p>메모리는 <code>tracemalloc</code> 최고치로 Kahn 2.3MiB, 반복형 DFS 2.7MiB, 재귀 DFS 3.4MiB, graphlib 42.8MiB 였어요. graphlib 은 정점마다 정보 객체를 하나씩 만들어서 커요.</p>
<h3>재귀 DFS 의 깊이 한계</h3>
<p>기본 재귀 한도는 1000 이라서, 정점이 일렬로 이어진 체인에서는 길이 약 1,000 근처에서 <code>RecursionError</code> 가 났어요(호출한 쪽 프레임도 한도에 포함돼요). 파이썬 3.12 <a href="https://docs.python.org/3/whatsnew/3.12.html" target="_blank" rel="noopener">변경 사항 문서</a>대로 재귀 한도는 이제 파이썬 코드에만 적용돼서, 한도를 10⁷ 로 올리면 메인 스레드에서도 체인 10만은 110ms, 100만은 1.24초에 끝났어요. 다만 <a href="https://docs.python.org/3/library/sys.html#sys.setrecursionlimit" target="_blank" rel="noopener">sys.setrecursionlimit 문서</a>는 한도가 너무 높으면 크래시가 날 수 있다고 적어요. 운영 코드라면 반복형으로 짜는 편이 안전해요.</p>
<h2 id="s3">visited 하나로 짠 사이클 검사, 실제로 얼마나 틀리나</h2>
<p>방향 그래프에서 "이미 방문한 정점을 또 만나면 사이클"로 짠 코드는 사이클 없는 그래프에서도 사이클이라고 답해요. 다른 가지에서 이미 끝난 정점(검은색)으로 들어가는 간선 하나만 있어도 걸리기 때문이에요. 얼마나 자주인지 세 봤어요.</p>
<div class="table-wrap"><table><thead><tr><th>무작위 DAG (정점 20개)</th><th>1만 개 중 "사이클" 오답</th></tr></thead><tbody><tr><td>간선 5개</td><td>9,744개 (97.4%)</td></tr><tr><td>간선 10개</td><td>9,999개</td></tr><tr><td>간선 15개 이상</td><td>10,000개 전부</td></tr></tbody></table></div>
<p>간선이 정점 수의 4분의 1뿐인 성긴 그래프도 97% 가 틀렸어요. 모든 정점에서 DFS 를 시작하는 바깥 루프가 있으니, 앞에서 끝난 정점을 뒤의 시작점이 가리키기만 해도 오답이 나요. 공통 라이브러리 하나를 두 모듈이 같이 쓰는 정상 의존성이 바로 이 모양이에요.</p>
<p>고치는 법은 상태를 셋으로 나누는 거예요. 들어갈 때 회색, 이웃을 다 본 뒤 검은색으로 칠하고, <strong>회색으로 가는 간선만</strong> 사이클로 봐요. 나올 때 다시 흰색으로 되돌리면 답은 맞지만 같은 정점을 경로 수만큼 다시 돌아서 최악에는 지수 시간이 돼요.</p>
<h2 id="s4">순환 의존 오류가 났을 때 확인 순서, 체크리스트</h2>
<p>운영에서 사이클 오류는 대부분 "설정을 누가 바꿨는데 어디가 고리인지 모르겠다"는 형태로 와요. 이때 Kahn 만 쓰면 남은 정점 목록이 고리보다 훨씬 넓게 나와요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/topological-sort-kahn-dfs-cycle-detection-selection-checklist-2.svg" alt="auth 와 user 가 서로 기다리는 그래프에서 Kahn 은 고리 2개와 그 뒤의 api·web·report 까지 남은 정점 5개를 돌려주고, 3색 DFS·graphlib 은 auth, user, auth 경로만 돌려주는 비교 그림" width="800" height="470" loading="lazy" decoding="async"><figcaption>Kahn 은 영향 범위, DFS 는 원인 경로를 알려 줘요</figcaption></figure>
<p>파이썬 <code>graphlib</code> 은 두 쪽을 다 줘요. <a href="https://docs.python.org/3/library/graphlib.html" target="_blank" rel="noopener">graphlib 공식 문서</a>에 따르면 <code>prepare()</code> 가 <code>CycleError</code> 를 던져도 <code>get_ready()</code> 로 처리 가능한 정점은 계속 꺼낼 수 있고, 고리는 예외의 <code>args[1]</code> 에 처음과 끝이 같은 목록으로 들어 있어요.</p>
<pre class="code-block" data-lang="python"><code>from graphlib import TopologicalSorter, CycleError

deps = {&quot;web&quot;: {&quot;api&quot;}, &quot;api&quot;: {&quot;auth&quot;, &quot;log&quot;},
        &quot;auth&quot;: {&quot;user&quot;}, &quot;user&quot;: {&quot;auth&quot;}, &quot;report&quot;: {&quot;api&quot;}}
ts = TopologicalSorter(deps)
try:
    ts.prepare()
except CycleError as e:
    print(&quot;cycle:&quot;, e.args[1])

done = []
while ready := ts.get_ready():
    done += ready
    ts.done(*ready)
nodes = set(deps) | {d for ds in deps.values() for d in ds}
print(&quot;processed:&quot;, done)
print(&quot;stuck:&quot;, sorted(nodes - set(done)))</code></pre>
<pre class="code-block" data-lang="text"><code>cycle: [&#x27;auth&#x27;, &#x27;user&#x27;, &#x27;auth&#x27;]
processed: [&#x27;log&#x27;]
stuck: [&#x27;api&#x27;, &#x27;auth&#x27;, &#x27;report&#x27;, &#x27;user&#x27;, &#x27;web&#x27;]</code></pre>
<p>고리는 2개 정점인데 막힌 정점은 5개예요. 사용자에게 보여 줄 메시지는 고리 경로, 영향 범위 안내는 막힌 목록으로 나눠 쓰면 돼요. 문서는 고리가 여럿이면 그중 무엇이 보고될지 정해져 있지 않다고 적으니, 하나 고친 뒤 다시 돌려 남은 고리를 찾아요.</p>
<p>확인 순서는 이렇게 잡아요.</p>
<ol><li><strong>간선 방향부터.</strong> "A 가 B 에 의존"을 A→B 로 그렸는지 B→A 로 그렸는지 코드 한 줄에 주석으로 적어요. graphlib 사전은 "키가 값들에 의존"이라 일반 인접 리스트와 방향이 반대예요.</li><li><strong>자기 간선.</strong> A 가 자기 자신을 의존 목록에 넣은 경우도 고리예요. 설정 파일 복사·붙여넣기에서 흔해요.</li><li><strong>고리 경로 하나를 출력.</strong> Kahn 의 남은 목록만 보고 찾지 말고 DFS 나 graphlib 으로 경로를 받아요.</li><li><strong>선택적 의존인지.</strong> 실행 시점에만 필요한 의존(지연 로딩, 런타임 조회)을 시작 순서 그래프에 넣어서 생긴 고리가 많아요. 그래프에서 빼거나 둘로 나눠요.</li><li><strong>도구가 주는 그림.</strong> 테라폼은 <a href="https://developer.hashicorp.com/terraform/cli/commands/graph" target="_blank" rel="noopener">graph 명령 문서</a>대로 <code>-draw-cycles</code> 로 고리 간선을 색칠해 줘요. 사례는 <a href="https://tool.smithlab.co.kr/posts/developer/terraform-parallelism-depends-on-create-before-destroy-guide/">테라폼 parallelism·depends_on 판단표</a>에 정리해 뒀어요.</li><li><strong>언어 차원의 금지.</strong> Go 는 <a href="https://go.dev/ref/spec" target="_blank" rel="noopener">언어 명세</a>에 패키지가 자기 자신을 직접이든 간접이든 import 하면 안 된다고 적어서, 모듈 사이 고리는 빌드 단계에서 막혀요. 파이썬처럼 순환 import 를 허용하는 언어는 반쯤 초기화된 모듈 오류로 늦게 드러나요.</li></ol>
<aside class="callout warn"><b class="callout-h">주의</b><p>사이클 검사를 "사이클 없음"을 가정한 테스트 데이터로만 돌리면 오탐 버그(visited 하나)도, 고리 보고 메시지도 검증되지 않아요. 다이아몬드(공통 의존), 자기 간선, 길이 2 고리, 고리 뒤에 매달린 정점이 있는 그래프를 테스트에 꼭 넣어요.</p></aside>
<h2 id="s5">간선을 하나씩 추가할 때 사이클 검사 비용</h2>
<p>워크플로 편집기나 작업 의존 설정 화면처럼 사용자가 간선을 하나씩 추가하고, 고리를 만드는 간선은 그 자리에서 거절해야 하는 경우가 있어요. 가장 쉬운 구현은 간선을 넣을 때마다 전체 위상 정렬을 다시 돌리는 건데, 간선 E 개를 넣으면 O(E·(V+E)) 가 돼요.</p>
<p>대신 <strong>간선 u→v 를 넣기 전에 v 에서 u 로 갈 수 있는지만</strong> 보면 돼요. 갈 수 있으면 새 간선이 고리를 닫고, 못 가면 안전해요.</p>
<pre class="code-block" data-lang="python"><code>def would_create_cycle(adj, u, v):
    stack, seen = [v], {v}
    while stack:
        x = stack.pop()
        if x == u:
            return True
        for y in adj[x]:
            if y not in seen:
                seen.add(y); stack.append(y)
    return False</code></pre>
<p>무작위 DAG 의 간선을 섞은 순서로 하나씩 넣으며 두 방식을 비교했어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">간선을 하나씩 넣으며 사이클 검사, 누적 시간</figcaption><div class="viz-legend"><span><i class="s1"></i>매번 전체 Kahn</span><span><i class="s2"></i>추가 전 도달 검사</span></div><svg viewBox="0 0 640 300" role="img" aria-label="간선을 하나씩 넣으며 사이클 검사, 누적 시간" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">10,000</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">20,000</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">30,000</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">40,000</text><rect class="s1" x="80.8" y="243.9" width="66.2" height="10.1" rx="3"><title>정점1000 간선2000 · 매번 전체 Kahn 1,720ms</title></rect><rect class="s2" x="149.0" y="254.0" width="66.2" height="0.5" rx="3"><title>정점1000 간선2000 · 추가 전 도달 검사 6ms</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">정점1000 간선2000</text><rect class="s1" x="272.8" y="209.3" width="66.2" height="44.7" rx="3"><title>정점2000 간선4000 · 매번 전체 Kahn 7,570ms</title></rect><rect class="s2" x="341.0" y="253.9" width="66.2" height="0.5" rx="3"><title>정점2000 간선4000 · 추가 전 도달 검사 12ms</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">정점2000 간선4000</text><rect class="s1" x="464.8" y="74.3" width="66.2" height="179.7" rx="3"><title>정점4000 간선8000 · 매번 전체 Kahn 30,450ms</title></rect><rect class="s2" x="533.0" y="253.9" width="66.2" height="0.5" rx="3"><title>정점4000 간선8000 · 추가 전 도달 검사 14ms</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">정점4000 간선8000</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">규모가 2배가 될 때 전체 재검사는 약 4배씩 늘었어요</p><div class="viz-cap">단위: ms · 자료: Apple M1, Python 3.15.2, 2026-11-05 직접 측정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>매번 전체 Kahn</th><th>추가 전 도달 검사</th></tr></thead><tbody><tr><td>정점1000 간선2000</td><td>1,720</td><td>6</td></tr><tr><td>정점2000 간선4000</td><td>7,570</td><td>12</td></tr><tr><td>정점4000 간선8000</td><td>30,450</td><td>14</td></tr></tbody></table></div></details></figure>
<p>전체 재검사는 크기를 2배로 키울 때마다 약 4배씩 늘어 정점 4,000·간선 8,000 에서 30초였고, 도달 검사는 14ms 였어요. 다만 도달 검사도 최악은 간선마다 O(V+E) 예요. 무작위 그래프에서는 v 에서 닿는 범위가 좁아서 빨랐던 것이고, 긴 사슬 끝에 간선을 계속 붙이는 그래프라면 더 느려질 수 있어요. 간선이 수십만 개로 커지고 추가가 잦다면 증분 위상 순서를 유지하는 알고리즘을 따로 검토해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">병렬 실행 단계 수와 작업자 수 계산</h2>
<p>Kahn 으로 진입 차수 0인 정점을 한 번에 묶어 꺼내면 "동시에 돌릴 수 있는 작업 묶음"이 나와요. graphlib 의 <code>get_ready()</code>/<code>done()</code> 이 이 모양이에요. 정점 10만 DAG 는 이 방식으로 16단계였어요.</p>
<p>작업자를 늘리면 얼마나 빨라지는지는 두 값으로 정해져요. <strong>전체 작업 시간 합 ÷ 작업자 수</strong>와 <strong>가장 긴 의존 사슬(임계 경로)의 시간</strong> 중 큰 값이 하한이에요. 작업 200개, 간선 400개, 작업마다 1~10초인 무작위 그래프로 계산했어요. 합은 1,106초, 임계 경로는 61초, 단계는 11개, 가장 넓은 단계는 56개였어요. 준비된 작업을 번호 순으로 빈 작업자에게 넘기는 단순한 방식으로 돌렸어요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">작업자 수별 전체 완료 시간 (작업 200개 합 1106초)</figcaption><div class="viz-legend"><span><i class="s1"></i>실제 완료 시간</span><span><i class="s2"></i>하한</span></div><svg viewBox="0 0 640 300" role="img" aria-label="작업자 수별 전체 완료 시간 (작업 200개 합 1106초)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="570" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">400</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">600</text><line class="viz-grid" x1="52" x2="570" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">800</text><line class="viz-grid" x1="52" x2="570" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">1,200</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">1명</text><text class="viz-ax" x="155.6" y="272" text-anchor="middle">2명</text><text class="viz-ax" x="259.2" y="272" text-anchor="middle">4명</text><text class="viz-ax" x="362.8" y="272" text-anchor="middle">8명</text><text class="viz-ax" x="466.4" y="272" text-anchor="middle">16명</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">32명</text><polyline class="viz-line s1" points="52.0,36.5 155.6,145.2 259.2,198.5 362.8,225.3 466.4,238.3 570.0,242.0"/><circle class="viz-dot s1" cx="52.0" cy="36.5" r="4"><title>1명 · 실제 완료 시간 1,106초</title></circle><circle class="viz-dot s1" cx="155.6" cy="145.2" r="4"><title>2명 · 실제 완료 시간 553초</title></circle><circle class="viz-dot s1" cx="259.2" cy="198.5" r="4"><title>4명 · 실제 완료 시간 282초</title></circle><circle class="viz-dot s1" cx="362.8" cy="225.3" r="4"><title>8명 · 실제 완료 시간 146초</title></circle><circle class="viz-dot s1" cx="466.4" cy="238.3" r="4"><title>16명 · 실제 완료 시간 80초</title></circle><circle class="viz-dot s1" cx="570.0" cy="242.0" r="4"><title>32명 · 실제 완료 시간 61초</title></circle><text class="viz-val" x="578.0" y="246.0">61</text><polyline class="viz-line s2" points="52.0,36.5 155.6,145.2 259.2,199.6 362.8,226.8 466.4,240.4 570.0,242.0"/><circle class="viz-dot s2" cx="52.0" cy="36.5" r="4"><title>1명 · 하한 1,106초</title></circle><circle class="viz-dot s2" cx="155.6" cy="145.2" r="4"><title>2명 · 하한 553초</title></circle><circle class="viz-dot s2" cx="259.2" cy="199.6" r="4"><title>4명 · 하한 276초</title></circle><circle class="viz-dot s2" cx="362.8" cy="226.8" r="4"><title>8명 · 하한 138초</title></circle><circle class="viz-dot s2" cx="466.4" cy="240.4" r="4"><title>16명 · 하한 69.1초</title></circle><circle class="viz-dot s2" cx="570.0" cy="242.0" r="4"><title>32명 · 하한 61초</title></circle><text class="viz-val" x="578.0" y="246.0">61</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">하한 = max(합 ÷ 작업자 수, 임계 경로 61초)</p><div class="viz-cap">단위: 초 · 자료: 무작위 DAG 로 직접 계산 (2026-11-05)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>실제 완료 시간</th><th>하한</th></tr></thead><tbody><tr><td>1명</td><td>1,106</td><td>1,106</td></tr><tr><td>2명</td><td>553</td><td>553</td></tr><tr><td>4명</td><td>282</td><td>276</td></tr><tr><td>8명</td><td>146</td><td>138</td></tr><tr><td>16명</td><td>80</td><td>69.1</td></tr><tr><td>32명</td><td>61</td><td>61</td></tr></tbody></table></div></details></figure>
<p>16명까지는 하한에 가깝게 줄고, 32명에서 임계 경로 61초에 닿은 뒤로는 작업자를 더 넣어도 안 줄어요. CI 러너나 배치 워커 수를 정할 때 이 계산을 먼저 해 보면 "작업자를 늘려도 안 빨라지는" 지점을 미리 알 수 있어요. 그 지점을 넘기려면 작업자가 아니라 임계 경로 위의 작업을 쪼개거나 의존을 끊어야 해요.</p>
<h2 id="s7">모든 사이클을 나열하면 안 되는 이유</h2>
<p>"사이클을 전부 보여 주세요"라는 요구가 오면 개수부터 계산해 봐요. 정점 k 개가 서로 다 이어진 방향 그래프의 단순 사이클 수는 길이 j 마다 C(k, j)·(j−1)! 개라 합하면 아래처럼 늘어요.</p>
<div class="table-wrap"><table><thead><tr><th>정점 수</th><th>단순 사이클 수</th></tr></thead><tbody><tr><td>4</td><td>20</td></tr><tr><td>6</td><td>409</td></tr><tr><td>8</td><td>16,064</td></tr><tr><td>10</td><td>1,112,073</td></tr><tr><td>12</td><td>119,481,284</td></tr><tr><td>15</td><td>255,323,504,917</td></tr></tbody></table></div>
<p>정점 12개만 촘촘히 엮여도 1억 개가 넘어요. 고리 정보를 사용자에게 줄 때는 "고리 하나의 경로" 또는 "고리에 엮인 정점 묶음(강연결 성분)"으로 제한하는 게 맞아요. 강연결 성분은 O(V+E) 로 끝나고, 크기가 2 이상인 성분(또는 자기 간선 정점)이 곧 고칠 대상 목록이에요.</p>
<h2 id="s8">도입 전에 따질 것</h2>
<ul><li><strong>그래프 크기와 호출 빈도.</strong> 정점 수천 개를 하루 몇 번 정렬한다면 graphlib 처럼 검증된 라이브러리가 가장 싸요. 요청마다 수만 개라면 직접 짠 Kahn 이 8배쯤 빨랐어요.</li><li><strong>순서의 재현성.</strong> 같은 입력에 같은 순서가 나와야 로그 비교와 캐시가 쉬워요. 문서상 graphlib 은 같은 단계 안의 순서를 삽입 순서로 정해요. 입력을 만드는 쪽이 매번 같은 순서인지 확인하고, 아니면 준비 목록을 정렬해요.</li><li><strong>사이클일 때의 정책.</strong> 전부 멈출지, 고리와 상관없는 작업은 먼저 돌릴지 정해요. 후자라면 graphlib 처럼 고리 보고 뒤에도 꺼낼 수 있는 구조가 필요해요.</li><li><strong>변경 방식.</strong> 그래프가 한 번 만들어지고 끝나는지, 간선이 계속 추가되는지에 따라 위의 도달 검사 같은 증분 방식이 필요해요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 묻는 질문</h2>
<h3>위상 정렬은 Kahn 과 DFS 중 무엇이 빠른가요?</h3>
<p>둘 다 O(V+E) 이고 정점 10만·간선 20만에서 Kahn 205ms, 반복형 DFS 209ms 로 거의 같았어요. 속도보다 필요한 결과로 골라요. 병렬 단계가 필요하면 Kahn, 고리 경로를 보여 줘야 하면 DFS 예요.</p>
<h3>방문 배열 하나로 사이클을 검사하면 왜 틀리나요?</h3>
<p>이미 끝난 정점(다른 가지)으로 들어가는 간선도 사이클로 보기 때문이에요. 사이클 없는 정점 20·간선 10 무작위 그래프 1만 개 중 9,999개를 사이클이라고 답했어요. 흰·회·검 세 상태로 나누고 회색으로 가는 간선만 사이클로 봐요.</p>
<h3>graphlib TopologicalSorter 를 운영 코드에 써도 되나요?</h3>
<p>정점 수천 개 이하라면 충분히 빠르고, 고리 경로와 처리 가능한 정점을 함께 줘서 편해요. 정점 10만에서는 직접 짠 Kahn 보다 약 8배 느렸으니, 큰 그래프를 자주 정렬한다면 직접 짜요.</p>
<h3>Kahn 에서 남은 정점이 전부 사이클인가요?</h3>
<p>아니에요. 고리 뒤에서 고리를 기다리는 정점도 남아요. 예시에서 고리는 2개였는데 남은 정점은 5개였어요. 원인을 찾으려면 DFS 나 graphlib 의 <code>CycleError</code> 로 고리 경로를 따로 받아요.</p>
<h3>간선을 추가할 때마다 사이클을 검사하면 너무 느려요.</h3>
<p>매번 전체 정렬을 다시 돌리고 있을 가능성이 커요. 간선 u→v 를 넣기 전에 v 에서 u 로 갈 수 있는지만 보면 돼요. 정점 4,000·간선 8,000 에서 전체 재검사 30초가 14ms 로 줄었어요.</p>
<h3>작업자를 늘려도 빌드가 더 안 빨라져요.</h3>
<p>가장 긴 의존 사슬(임계 경로) 시간에 닿았을 가능성이 커요. 작업 200개 예시에서 32명부터 61초로 고정됐어요. 임계 경로 위 작업을 쪼개거나 불필요한 의존을 끊어야 줄어요.</p>
<h2 id="s10">정리</h2>
<ul><li>Kahn 과 3색 DFS 는 속도가 비슷해요. 병렬 단계·사전순이 필요하면 Kahn, 고리 경로가 필요하면 DFS 로 고르고, 운영에서는 둘을 같이 쓰는 경우가 많아요.</li><li>visited 하나짜리 사이클 검사는 정상 그래프 대부분을 사이클로 봐요. 회색으로 가는 간선만 사이클이에요.</li><li>사이클 오류는 방향 → 자기 간선 → 고리 경로 → 선택적 의존 순서로 확인하고, Kahn 의 남은 목록은 영향 범위로만 써요.</li><li>간선 추가 시 검사는 도달 검사로, 작업자 수는 합 ÷ 작업자와 임계 경로 중 큰 값으로 계산해요.</li><li>그래프 탐색에서 BFS·DFS 를 고르는 기준과 재귀 깊이 측정은 <a href="https://tool.smithlab.co.kr/posts/developer/bfs-dfs-selection-memory-recursion-checklist/">BFS DFS 언제 쓰나</a>, 가중치가 0과 1뿐인 최단 경로는 <a href="https://tool.smithlab.co.kr/posts/developer/zero-one-bfs-deque-dijkstra-selection-wrong-answer-checklist/">0-1 BFS 언제 쓰나</a>에서 이어서 볼 수 있어요.</li></ul>
<h2 id="s11">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/dfs-three-color-cycle-detection-topological-sort" target="_blank" rel="noopener">DFS 3색 사이클 탐지 원리 — 역방향 간선과 위상 정렬</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>겨울방학 며칠일까? — 주말 빼면 평일은 몇 일, 쉬는 날 반영한 방학…</title>
      <link>https://tool.smithlab.co.kr/posts/date/winter-break-days-weekdays-plan-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/date/winter-break-days-weekdays-plan-guide/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><strong>2026년 12월 24일에 방학을 시작해 2027년 3월 2일(화)에 개학하면 방학은 68일(약 9.7주), 평일 48일·주말 20일이에요. 그런데 이 기간에는 평일 공휴일이 5일(성탄절·신정·설 연휴 월요일·설 대체공휴일·삼일절) 끼어 있어서, 학원·공부를 평일에만 한다면 실제로 쓸 수 있는 평일은 43일이에요.</strong> 여기서 여행 등으로 평일 5일을 더 빼면 38일, 하루 3시간이면 114시간이에요.</p>
<p>이 글은 날짜를 하루씩 세어 구한 2026~2027 겨울방학 일수표(공휴일 반영), "매일 조금" 과 "평일만 많이" 중 무엇이 총량이 큰지 가르는 1.5배 기준, 중간 점검에서 하루 분량을 얼마나 늘려야 하는지 계산하는 식, 그리고 가상의 초등학생 한 명의 방학 계획을 처음부터 끝까지 계산한 예시를 담았어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>방학 일수는 개학일 − 시작일이에요. 시작일은 포함하고 개학일은 포함하지 않아요.</li><li>12/24 ~ 3/2 개학이면 68일(평일 48 · 주말 20), 12/28 ~ 2/8 개학이면 42일(평일 30 · 주말 12)이에요.</li><li>쓸 수 있는 날 = 방학 일수 − 쉬는 날이고, 총 시간 = 쓸 수 있는 날 × 하루 목표 시간이에요.</li><li>쉬는 날을 하나도 넣지 않은 계획이 가장 먼저 무너져요. 쉬는 날을 처음부터 일수로 정해 두세요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">방학 일수는 어떻게 세나요</h2>
<p>방학 일수를 셀 때 가장 헷갈리는 것이 시작일과 개학일을 넣느냐 마느냐예요. 이 글과 계산기에서는 <strong>시작일은 방학에 포함하고 개학일은 포함하지 않아요.</strong> 개학일은 등교하는 날이라 쉬는 날이 아니기 때문이에요. 그래서 일수는 두 날짜의 차이와 같아요.</p>
<p>12월 24일에 시작해 3월 2일에 개학하는 경우를 달 단위로 나누어 보면 이래요.</p>
<ul><li>12월: 24일부터 31일까지 8일</li><li>1월: 31일</li><li>2월: 28일 (2027년은 윤년이 아니에요)</li><li>3월: 1일 하루 (3월 2일 개학 전날까지)</li></ul>
<p>모두 더하면 8 + 31 + 28 + 1 = <strong>68일</strong>이에요. 7로 나누면 9.7주예요. 달력을 넘기며 손으로 세는 것보다 이렇게 달별로 더하면 실수가 줄어요. 날짜 두 개만 넣어 바로 알고 싶다면 <a href="https://tool.smithlab.co.kr/date/winter-break-planner-calculator/">겨울방학 계획 계산기</a>를 쓰면 돼요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/date/winter-break-days-weekdays-plan-guide-1.svg" alt="방학 68일을 평일 48일과 주말 20일로 나누고 쉬는 날 10일을 뺀 58일의 총 시간을 보여 주는 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>68일 중 평일 48일, 주말 20일, 쉬는 날 10일을 빼면 쓸 수 있는 날은 58일이에요</figcaption></figure>
<h2 id="s2">평일과 주말은 며칠씩일까</h2>
<p>같은 길이의 방학이라도 시작 요일에 따라 주말 수가 조금 달라져요. 아래는 날짜별로 하루씩 요일을 세어 구한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>방학 기간</th><th>일수</th><th>평일</th><th>주말</th><th>평일 공휴일</th><th>공휴일 뺀 평일</th></tr></thead><tbody><tr><td>2026/12/24 ~ 2027/3/1 (3/2 개학)</td><td>68일</td><td>48일</td><td>20일</td><td>5일</td><td>43일</td></tr><tr><td>2026/12/24 ~ 2027/2/25 (2/26 개학)</td><td>64일</td><td>46일</td><td>18일</td><td>4일</td><td>42일</td></tr><tr><td>2026/12/28 ~ 2027/2/7 (2/8 개학)</td><td>42일</td><td>30일</td><td>12일</td><td>1일</td><td>29일</td></tr><tr><td>2027/1/6 ~ 3/1 (3/2 개학)</td><td>55일</td><td>39일</td><td>16일</td><td>3일</td><td>36일</td></tr><tr><td>2026/7/24 ~ 8/23 (8/24 개학)</td><td>31일</td><td>21일</td><td>10일</td><td>1일</td><td>20일</td></tr></tbody></table></div>
<p>평일 공휴일은 2026/12/25(금) 성탄절, 2027/1/1(금) 신정, 2/8(월) 설 연휴, 2/9(화) 설 대체공휴일, 3/1(월) 삼일절이에요. 2027년 설 연휴는 2/6(토)~2/8(월)이고, 설날 당일 2/7이 일요일이라 2/9가 대체공휴일이 돼요(우리 사이트 <a href="https://tool.smithlab.co.kr/date/holiday-calendar/">공휴일 달력</a>과 같은 기준). 2/8 개학이면 설 연휴가 개학 뒤라 평일 공휴일은 1일뿐이에요. 여름 예시는 광복절 대체공휴일 8/17(월) 하루예요. 달력과 공휴일(특일) 정보는 <a href="https://astro.kasi.re.kr/life/page" target="_blank" rel="noopener">한국천문연구원 천문우주지식정보</a>에서 확인할 수 있어요.</p>
<p>대략 <strong>방학의 28~30%가 주말</strong>이고, 겨울방학은 여기에 공휴일이 더해져 쓸 수 있는 평일이 전체의 63% 안팎(68일 중 43일)으로 줄어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">방학 기간별 평일과 주말 일수</figcaption><div class="viz-legend"><span><i class="s1"></i>평일</span><span><i class="s2"></i>주말</span></div><svg viewBox="0 0 640 300" role="img" aria-label="방학 기간별 평일과 주말 일수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">50</text><rect class="s1" x="73.6" y="112.4" width="49.4" height="141.6" rx="3"><title>6주(42일) · 평일 30일</title></rect><rect class="s2" x="125.0" y="197.4" width="49.4" height="56.6" rx="3"><title>6주(42일) · 주말 12일</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">6주(42일)</text><rect class="s1" x="217.6" y="36.9" width="49.4" height="217.1" rx="3"><title>64일 · 평일 46일</title></rect><rect class="s2" x="269.0" y="169.0" width="49.4" height="85.0" rx="3"><title>64일 · 주말 18일</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">64일</text><rect class="s1" x="361.6" y="27.4" width="49.4" height="226.6" rx="3"><title>68일 · 평일 48일</title></rect><rect class="s2" x="413.0" y="159.6" width="49.4" height="94.4" rx="3"><title>68일 · 주말 20일</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">68일</text><rect class="s1" x="505.6" y="18.0" width="49.4" height="236.0" rx="3"><title>10주(70일) · 평일 50일</title></rect><rect class="s2" x="557.0" y="159.6" width="49.4" height="94.4" rx="3"><title>10주(70일) · 주말 20일</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">10주(70일)</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">방학이 길어져도 주말 비율은 대략 30% 안팎이에요</p><div class="viz-cap">단위: 일 · 자료: 날짜별 요일을 하나씩 세어 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>평일</th><th>주말</th></tr></thead><tbody><tr><td>6주(42일)</td><td>30</td><td>12</td></tr><tr><td>64일</td><td>46</td><td>18</td></tr><tr><td>68일</td><td>48</td><td>20</td></tr><tr><td>10주(70일)</td><td>50</td><td>20</td></tr></tbody></table></div></details></figure>
<h2 id="s3">쉬는 날을 빼야 현실적인 계획이 나와요</h2>
<p>방학 계획을 세울 때 흔한 실수는 방학 전체 일수에 하루 목표를 그대로 곱하는 거예요. 가족 여행, 친척 방문, 명절, 아파서 쉬는 날, 아무것도 하기 싫은 날은 반드시 생겨요. 처음부터 <strong>쉬는 날을 일수로 넣어 두면</strong> 목표가 현실적이 되고, 계획이 어긋났을 때도 덜 좌절해요.</p>
<p>쓸 수 있는 날은 간단해요.</p>
<p><strong>쓸 수 있는 날 = 방학 일수 − 쉬는 날</strong></p>
<p>68일 방학에서 쉬는 날이 늘어날 때 총 시간(하루 3시간)이 어떻게 달라지는지 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>쉬는 날</th><th>쓸 수 있는 날</th><th>하루 3시간 총 시간</th></tr></thead><tbody><tr><td>0일</td><td>68일</td><td>204시간</td></tr><tr><td>5일</td><td>63일</td><td>189시간</td></tr><tr><td>10일</td><td>58일</td><td>174시간</td></tr><tr><td>15일</td><td>53일</td><td>159시간</td></tr><tr><td>20일</td><td>48일</td><td>144시간</td></tr></tbody></table></div>
<p>쉬는 날이 5일 늘 때마다 총 시간이 15시간씩 줄어요. 쉬는 날 20일을 넣은 144시간은 쉬는 날이 하나도 없는 204시간보다 60시간, 약 29% 적어요. 이 차이가 "계획대로 안 됐다"는 느낌의 정체인 경우가 많아요.</p>
<h2 id="s4">매일 할까, 평일만 할까: 1.5배 기준</h2>
<p>주말에 쉬는 계획이 같은 총량을 내려면 하루에 얼마나 더 해야 할까요? 68일 방학, 쉬는 날 10일(모두 평일)로 놓고 계산하면 매일 하는 경우 쓸 수 있는 날은 58일, 평일만 하는 경우는 38일이에요.</p>
<div class="table-wrap"><table><thead><tr><th>매일 하루</th><th>매일 총량 (58일)</th><th>평일만 같은 총량을 내려면</th></tr></thead><tbody><tr><td>1시간</td><td>58시간</td><td>하루 1.53시간</td></tr><tr><td>2시간</td><td>116시간</td><td>하루 3.05시간</td></tr><tr><td>3시간</td><td>174시간</td><td>하루 4.58시간</td></tr></tbody></table></div>
<p>58 ÷ 38 = 1.53이라서, <strong>평일만 하려면 매일 하는 시간의 약 1.5배를 해야 같은 총량</strong>이 나와요. 판단 기준은 이렇게 잡을 수 있어요.</p>
<ul><li>하루 1.5배를 지킬 자신이 있으면 평일만, 아니면 주말에도 짧게(평일의 절반쯤) 하는 편이 총량이 커요.</li><li>예를 들어 "평일 3시간 + 주말 0시간"(114시간)보다 "매일 2시간"(116시간)이 총량이 조금 더 크고 하루 부담은 작아요.</li><li>공휴일까지 쉬면 평일 수가 더 줄어요. 위 68일 방학에서 공휴일 5일과 쉬는 날 5일을 빼면 평일은 38일로 같지만, 쉬는 날을 따로 10일 잡으면 33일까지 줄어 1.76배가 필요해요.</li></ul>
<h2 id="s5">중간 점검: 남은 하루 분량은 몇 배로 늘려야 할까</h2>
<p>방학의 절반이 지났을 때 목표의 몇 %를 했는지로 남은 기간의 하루 분량이 정해져요. 계산식은 <strong>필요 배수 = 남은 비율 ÷ 남은 기간 비율</strong>이에요.</p>
<div class="table-wrap"><table><thead><tr><th>절반 지점 진행률</th><th>남은 일</th><th>필요한 하루 분량 (원래 대비)</th></tr></thead><tbody><tr><td>50%</td><td>50%</td><td>1.0배 (그대로)</td></tr><tr><td>40%</td><td>60%</td><td>1.2배</td></tr><tr><td>30%</td><td>70%</td><td>1.4배</td></tr><tr><td>20%</td><td>80%</td><td>1.6배</td></tr></tbody></table></div>
<p>절반 지점에서 30% 이하라면 남은 기간에 1.4배 이상을 해야 해서 현실적으로 어려운 경우가 많아요. 이때는 하루 분량을 늘리기보다 <strong>총 목표를 줄이는 쪽</strong>(예: 문제집 2권 → 1.5권)으로 조정하는 게 계획을 끝까지 끌고 가는 방법이에요. 68일 방학의 절반은 34일째인 2027년 1월 26일이에요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">하루 목표를 총량으로 바꿔 보기</h2>
<p>"하루에 조금씩"이 막연하게 느껴질 때는 총량으로 바꿔서 눈에 보이게 하면 좋아요. 쉬는 날 10일을 뺀 58일 기준이에요.</p>
<div class="table-wrap"><table><thead><tr><th>하루 시간</th><th>총 시간</th></tr></thead><tbody><tr><td>1시간</td><td>58시간</td></tr><tr><td>2시간</td><td>116시간</td></tr><tr><td>3시간</td><td>174시간</td></tr><tr><td>4시간</td><td>232시간</td></tr></tbody></table></div>
<p>독서 계획도 같은 방식이에요. 58일 동안 하루에 읽는 쪽수가 쌓이면 책 몇 권이 되는지 계산해 볼게요.</p>
<div class="table-wrap"><table><thead><tr><th>하루 쪽수</th><th>총 쪽수</th><th>300쪽 책</th><th>450쪽 책</th></tr></thead><tbody><tr><td>10쪽</td><td>580쪽</td><td>1.9권</td><td>1.2권</td></tr><tr><td>20쪽</td><td>1,160쪽</td><td>3.8권</td><td>2.5권</td></tr><tr><td>30쪽</td><td>1,740쪽</td><td>5.8권</td><td>3.8권</td></tr><tr><td>50쪽</td><td>2,900쪽</td><td>9.6권</td><td>6.4권</td></tr></tbody></table></div>
<p>하루 30쪽이면 한 시간 안쪽으로 읽을 수 있는 분량인 경우가 많은데, 방학 동안 쌓으면 300쪽 책 다섯 권 남짓이에요. "방학에 책 5권"이라는 목표가 하루 30쪽이라는 작은 습관으로 바뀌는 셈이에요. 읽는 속도는 사람마다 달라서 하루 쪽수는 직접 한 번 재 보고 정하세요.</p>
<h2 id="s7">예시: 가상의 초등 5학년 지우의 방학 계획</h2>
<p>아래는 설명을 위해 만든 가상의 예시예요. 2026/12/24 시작, 2027/3/2 개학, 평일에만 공부하고 공휴일은 쉬며, 1/18(월)~1/21(목) 가족 여행이 있는 경우예요.</p>
<ol><li>방학 일수: 3/2 − 12/24 = 68일, 평일 48일</li><li>평일 공휴일 5일을 빼면 43일</li><li>여행 평일 4일을 빼면 쓸 수 있는 날은 <strong>39일</strong></li><li>목표: 수학 문제집 2권(합계 320쪽) → 320 ÷ 39 = 8.2쪽이라 하루 9쪽으로 잡으면 351쪽, 여유 31쪽(약 3.4일분)이 생겨요.</li><li>독서: 하루 20쪽 × 39일 = 780쪽, 300쪽 책 2.6권이에요. "방학에 책 3권"이 목표라면 하루 23쪽(23 × 39 = 897쪽)이 필요해요.</li><li>중간 점검: 1/26에 문제집 160쪽(50%) 이상이면 그대로, 128쪽(40%)이면 남은 기간 하루 11쪽(9쪽의 1.2배)으로 올리거나 목표를 1.8권으로 줄여요.</li></ol>
<p>같은 지우가 주말에도 하루 5쪽씩 한다면 주말 20일 중 설 연휴에 걸린 2/6·2/7 이틀을 빼고 18일 × 5쪽 = 90쪽이 더해져, 평일 분량을 하루 7쪽으로 낮춰도(7 × 39 = 273쪽) 합계 363쪽으로 목표를 넘겨요.</p>
<h2 id="s8">자주 틀리는 지점</h2>
<ul><li><strong>개학일을 포함해 세기</strong>: 3/2를 넣으면 69일이 돼요. 이 글과 계산기는 개학일을 빼요.</li><li><strong>공휴일을 평일로 세기</strong>: 2026~2027 겨울방학(12/24~3/1)은 평일 공휴일이 5일이라, 평일 48일을 그대로 쓰면 학원·공부 일수를 5일 많게 잡게 돼요.</li><li><strong>설 연휴 위치</strong>: 2027년 설 연휴(2/6~2/9 대체공휴일 포함)는 2월 초라서 2월 8일 이전에 개학하는 학교는 방학 중에 설이 없어요. 2/8 개학 예시의 평일 공휴일이 1일뿐인 이유예요.</li><li><strong>윤년</strong>: 2028년은 윤년이라 2027/12/24 ~ 2028/3/2 개학이면 같은 날짜인데도 69일이에요.</li><li><strong>주 수 반올림</strong>: 68일은 9.7주인데 "10주"로 잡고 주당 목표를 곱하면 3% 정도 많게 잡혀요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">방학 계획이 무너지지 않게 하는 요령</h2>
<ol><li><strong>하루 분량을 작게 시작해요.</strong> 처음부터 5시간으로 잡는 것보다 2시간을 확실히 지키는 편이 길게 가요. 잘 되면 늘리세요.</li><li><strong>쉬는 날을 먼저 달력에 표시해요.</strong> 여행과 행사 날짜를 정하고 그 앞뒤 하루는 가볍게 두세요.</li><li><strong>첫 주와 마지막 주는 비워 두세요.</strong> 첫 주는 쉬는 리듬에서 벗어나는 데, 마지막 주는 개학 준비에 시간이 들어요.</li><li><strong>중간 점검일을 정해요.</strong> 68일 방학이라면 34일째쯤(1월 말)이 절반이에요. 이날 계획 대비 진행을 보고 남은 분량을 조정하세요.</li><li><strong>주말 규칙을 미리 정해요.</strong> 주말에도 할지, 쉴지 정해 두면 매주 고민하지 않아도 돼요. 평일만 하기로 했다면 쉬는 날에 주말 20일을 더해 계산해 보세요. 위 예시라면 쓸 수 있는 날이 38일, 하루 3시간이면 114시간이에요.</li><li><strong>개학 전 주에는 생활 리듬을 돌려놓아요.</strong> 늦잠에 익숙해졌다면 개학 일주일 전부터 일어나는 시간을 조금씩 앞당기세요.</li></ol>
<p>같은 계산은 직장인 휴가에도 쓸 수 있어요. 연말 연차 계획은 <a href="https://tool.smithlab.co.kr/posts/date/yearend-leave-christmas-newyear-holiday-guide/">연말 휴가 가이드</a>, 특정 날짜까지 남은 일수만 궁금하다면 <a href="https://tool.smithlab.co.kr/date/dday-calculator/">디데이 계산기</a>가 더 간단해요.</p>
<h2 id="s10">자주 묻는 질문</h2>
<h3>방학 일수에 개학일도 포함하나요?</h3>
<p>포함하지 않아요. 시작일은 방학에 넣고 개학일은 등교하는 날이라 넣지 않는 방식이 일반적이에요. 그래서 일수는 개학일에서 시작일을 뺀 값과 같아요. 12월 24일에 시작해 3월 2일에 개학하면 68일이에요.</p>
<h3>공휴일은 어떻게 반영하나요?</h3>
<p>공휴일은 해마다 달라지고 대체공휴일 규정도 있어서 계산기가 자동으로 빼지 않아요. 방학 중에 공휴일이나 연휴가 있다면 쉬는 날 칸에 그 일수를 더해서 계산하세요. 올해와 내년 공휴일은 <a href="https://tool.smithlab.co.kr/date/holiday-calendar/">공휴일 달력</a>에서 볼 수 있어요.</p>
<h3>겨울방학은 보통 몇 주예요?</h3>
<p>학교와 학년, 지역에 따라 달라요. 이 글의 예시는 약 6주(42일)부터 약 10주(70일)까지 일수를 계산해 본 것이고, 실제 방학 기간은 학교에서 보내는 가정통신문의 날짜를 쓰세요.</p>
<h3>2027년 겨울방학 중 공휴일은 며칠인가요?</h3>
<p>12/24에 시작해 3/2에 개학하는 경우 방학 중 공휴일은 성탄절, 신정, 설 연휴 3일(2/6~2/8), 설 대체공휴일(2/9), 삼일절로 7일이고, 이 중 평일에 해당하는 날은 5일이에요. 설 연휴의 토·일 이틀은 이미 주말로 세어져 있어요.</p>
<h3>오늘 날짜가 방학 시작 전이면 어떻게 나오나요?</h3>
<p>계산기가 "방학까지 N일 남음"으로 알려 줘요. 방학 중이면 지난 날과 남은 날을 보여 주고, 개학일이 지났다면 "이미 개학했어요"라고 나와요.</p>
<h3>여름방학에도 같은 방법을 쓸 수 있나요?</h3>
<p>네, 시작일과 개학일만 바꾸면 돼요. 계산기의 "여름방학 예시" 버튼으로 7월 말 ~ 8월 말 예시를 넣어 볼 수 있어요. 예시 날짜는 실제 학교 일정이 아니니 직접 날짜를 바꿔 쓰세요.</p>
<h2 id="s11">정리</h2>
<p>12/24 ~ 3/2 개학이면 68일(평일 48일, 주말 20일)이고 평일 공휴일 5일을 빼면 43일이에요. 평일만 하려면 매일 하는 시간의 약 1.5배가 필요하고, 절반 지점에서 40%를 했다면 남은 기간은 1.2배로 늘리거나 목표를 줄이면 돼요. 내 방학 날짜로 바로 계산해 보려면 <a href="https://tool.smithlab.co.kr/date/winter-break-planner-calculator/">겨울방학 계획 계산기</a>를 쓰고, 연말 일정은 <a href="https://tool.smithlab.co.kr/date/year-remaining-calculator/">올해 남은 날 계산기</a>와 함께 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>LLM 접두사 캐시 라우팅 고르기, 복제본 수별 적중률과 GPU 비용</title>
      <link>https://tool.smithlab.co.kr/posts/developer/prefix-cache-routing-replica-session-affinity-cost-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/prefix-cache-routing-replica-session-affinity-cost-guide/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>모델 서버를 늘렸는데 첫 토큰 지연이 그대로라면 라우팅부터 의심해 보세요. 접두사 캐시(prefix cache)는 GPU 마다 따로 있어서, 다음 턴이 다른 서버로 가면 지난 대화를 처음부터 다시 읽어요. 결론부터 말하면 <strong>앞부분이 시스템 프롬프트 하나뿐이면 라운드로빈으로 충분하고, 대화가 쌓이는 서비스는 세션 고정, 문서·에이전트처럼 앞부분이 여러 갈래면 캐시 인지 라우팅에 부하 균형을 켜는 게 기본값이에요.</strong> API 를 쓸 때도 같은 원리가 적용돼서, 병렬 요청을 보내는 순서 하나로 입력비가 7배 차이 나요.</p>
<p>캐싱의 단가와 TTL 은 <a href="https://tool.smithlab.co.kr/posts/developer/prompt-caching-cost-ttl-self-hosting-selection-guide/">프롬프트 캐싱 비용 계산 글</a>에서 다뤘어요. 이 글은 그다음 단계, <strong>요청을 어느 서버(또는 어느 캐시)로 보낼지</strong>를 숫자로 비교해요. 직접 서빙 쪽은 파이썬으로 라우팅을 시뮬레이션해 재계산 토큰과 GPU 비용을 냈고, API 쪽은 2026-11-04 에 연 공식 문서와 가격표로 계산했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>동시 대화 200개를 복제본 8대가 받는 시뮬레이션에서 라운드로빈은 프롬프트 토큰의 51%를 다시 계산했어요. 세션 고정은 3.7%, 캐시 인지 + 부하 균형은 4.3%였어요.</li><li>같은 조건의 프리필 GPU 비용(H100, 요청 100만 건당)은 라운드로빈 127달러, 세션 고정 9.5달러예요.</li><li>인기 문서에 질문이 몰리는 문서 Q&amp;A 는 문서 해시로 나누면 재계산이 61%에서 17%로 줄지만 가장 바쁜 서버가 평균보다 68% 바빠요. 부하 균형을 켜면 재계산 19%에 쏠림 4%예요.</li><li>Claude API 에 2만 토큰 앞부분을 공유하는 요청 10개를 동시에 쏘면 캐시가 없을 때보다 25% 비싸요. 하나를 먼저 데운 뒤 보내면 83% 싸져요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">왜 서버를 늘리면 캐시 적중률이 떨어지나</h2>
<p>vLLM 의 <a href="https://github.com/vllm-project/vllm/blob/main/docs/design/prefix_caching.md" target="_blank" rel="noopener">자동 접두사 캐싱 설계 문서</a>에 따르면 KV 블록은 앞 블록 해시와 블록 안 토큰으로 식별되고, 다 찬 블록만 캐시돼요. 이 해시 테이블과 블록은 엔진 프로세스 하나의 GPU 메모리 안에 있어요. 서버 A 가 계산해 둔 대화 앞부분을 서버 B 는 몰라요.</p>
<p>서버가 N 대인데 요청을 무작위로 나누면 직전 턴과 같은 서버로 갈 확률은 1/N 이에요. 같은 대화가 여러 서버에 조각조각 중복 저장돼 메모리도 낭비돼요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/prefix-cache-routing-replica-session-affinity-cost-guide-1.svg" alt="복제본 4대에서 같은 대화 1~4턴이 라운드로빈이면 서버 1~4로 흩어져 모두 다시 계산되고, 세션 고정이면 서버 2에 모여 첫 턴만 계산되며, 캐시 인지 라우팅은 서버 2가 바쁠 때만 4턴을 서버 3으로 넘기는 비교" width="800" height="450" loading="lazy" decoding="async"><figcaption>같은 서버로 돌아와야 지난 대화의 KV 를 다시 쓸 수 있어요</figcaption></figure>
<p>반대로 공통 앞부분이 시스템 프롬프트 하나뿐이면 문제가 거의 없어요. 서버마다 처음 한 번씩만 계산해 두면 그다음부터는 어느 서버로 가도 적중해요. 라우팅이 비용을 가르는 건 <strong>앞부분이 대화·문서·사용자마다 다른 경우</strong>예요.</p>
<h2 id="s2">계산 조건 — 이렇게 가정했어요</h2>
<p>복제본 하나는 Qwen3-8B(bf16)를 올린 H100 80GB 한 장이에요.</p>
<ul><li><strong>KV 용량</strong>: <a href="https://huggingface.co/Qwen/Qwen3-8B/blob/main/config.json" target="_blank" rel="noopener">Qwen3-8B config.json</a>의 36층·KV 헤드 8개·헤드 차원 128로 토큰당 144KiB 예요. <a href="https://tool.smithlab.co.kr/posts/developer/vllm-kv-cache-memory-capacity-cost-guide/">vLLM KV 캐시 용량 계산 글</a>의 H100 한 장 KV 공간 약 39만 토큰 중 진행 중인 요청 몫을 빼고 <strong>유휴 캐시 30만 토큰</strong>, 넘치면 오래 안 쓴 것부터 지운다(LRU)고 가정했어요.</li><li><strong>프리필 계산량</strong>: 토큰당 2 × 파라미터 수(81.9억) FLOP 에 어텐션 몫(앞 토큰 수에 비례)을 더했어요. <a href="https://www.nvidia.com/en-us/data-center/h100/" target="_blank" rel="noopener">NVIDIA H100 제품 페이지</a>의 BF16 989 TFLOPS(희소성 제외) 중 <strong>50%를 낸다고 가정</strong>했어요.</li><li><strong>GPU 단가</strong>: <a href="https://lambda.ai/pricing" target="_blank" rel="noopener">Lambda 가격표</a>의 H100 SXM 1장 인스턴스 시간당 4.29달러(2026-11-04 확인).</li><li><strong>대화형 시나리오</strong>: 시스템 프롬프트 2,000토큰, 턴마다 질문 200 + 답 400토큰, 12턴이면 끝나고 새 대화가 시작돼요. 동시에 열린 대화 200개, 요청 4만 건.</li><li><strong>문서 Q&amp;A 시나리오</strong>: 문서 300개, 각 16,000토큰 + 질문 100토큰. 인기는 순위에 반비례(지프 분포, 지수 1)해요. 요청 6만 건.</li></ul>
<p>시드를 바꿔 세 번 돌렸을 때 재계산 비율 차이는 1%p 안이었어요.</p>
<h2 id="s3">대화형 서비스 — 복제본 수별 재계산 비율</h2>
<p>대화 200개가 끝날 때까지 쌓이는 토큰은 평균 약 120만 개로, 서버 한 대의 유휴 캐시(30만)를 넘어요. 그래서 서버가 한 대여도 48.6% 를 다시 계산해요. 대화마다 첫 턴은 어차피 계산해야 하니 이론상 바닥은 3.7% 예요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">대화형 서비스 프롬프트 토큰 재계산 비율 (동시 대화 200개 · 12턴)</figcaption><div class="viz-legend"><span><i class="s1"></i>라운드로빈</span><span><i class="s2"></i>세션 고정</span><span><i class="s3"></i>캐시 인지+부하 균형</span></div><svg viewBox="0 0 640 300" role="img" aria-label="대화형 서비스 프롬프트 토큰 재계산 비율 (동시 대화 200개 · 12턴)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">60</text><rect class="s1" x="73.6" y="62.8" width="32.3" height="191.2" rx="3"><title>1대 · 라운드로빈 48.6%</title></rect><rect class="s2" x="107.9" y="62.8" width="32.3" height="191.2" rx="3"><title>1대 · 세션 고정 48.6%</title></rect><rect class="s3" x="142.1" y="62.8" width="32.3" height="191.2" rx="3"><title>1대 · 캐시 인지+부하 균형 48.6%</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">1대</text><rect class="s1" x="217.6" y="61.3" width="32.3" height="192.7" rx="3"><title>2대 · 라운드로빈 49%</title></rect><rect class="s2" x="251.9" y="117.5" width="32.3" height="136.5" rx="3"><title>2대 · 세션 고정 34.7%</title></rect><rect class="s3" x="286.1" y="117.5" width="32.3" height="136.5" rx="3"><title>2대 · 캐시 인지+부하 균형 34.7%</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">2대</text><rect class="s1" x="361.6" y="58.5" width="32.3" height="195.5" rx="3"><title>4대 · 라운드로빈 49.7%</title></rect><rect class="s2" x="395.9" y="203.7" width="32.3" height="50.3" rx="3"><title>4대 · 세션 고정 12.8%</title></rect><rect class="s3" x="430.1" y="205.2" width="32.3" height="48.8" rx="3"><title>4대 · 캐시 인지+부하 균형 12.4%</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">4대</text><rect class="s1" x="505.6" y="53.4" width="32.3" height="200.6" rx="3"><title>8대 · 라운드로빈 51%</title></rect><rect class="s2" x="539.9" y="239.4" width="32.3" height="14.6" rx="3"><title>8대 · 세션 고정 3.7%</title></rect><rect class="s3" x="574.1" y="237.1" width="32.3" height="16.9" rx="3"><title>8대 · 캐시 인지+부하 균형 4.3%</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">8대</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">라운드로빈은 서버를 늘려도 재계산이 줄지 않고, 고정 계열은 서버 수만큼 캐시 총량이 늘어 바닥(3.7%)에 다가가요</p><div class="viz-cap">단위: % · 자료: Qwen3-8B · 서버당 유휴 캐시 30만 토큰 · LRU 가정으로 직접 시뮬레이션</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>라운드로빈</th><th>세션 고정</th><th>캐시 인지+부하 균형</th></tr></thead><tbody><tr><td>1대</td><td>48.6</td><td>48.6</td><td>48.6</td></tr><tr><td>2대</td><td>49</td><td>34.7</td><td>34.7</td></tr><tr><td>4대</td><td>49.7</td><td>12.8</td><td>12.4</td></tr><tr><td>8대</td><td>51</td><td>3.7</td><td>4.3</td></tr></tbody></table></div></details></figure>
<p><strong>라운드로빈은 서버를 늘려도 그대로예요.</strong> 캐시 용량을 무한대로 풀어 줘도 라운드로빈은 4대에서 25.8%, 8대에서 39.4% 를 다시 계산했어요. 용량이 문제가 아니라 다음 턴이 다른 서버로 가는 게 문제라서예요.</p>
<p><strong>세션 고정은 서버 수만큼 캐시를 넓혀요.</strong> 대화가 서버별로 나뉘니 서버 8대면 각 서버가 대화 25개만 들고 있으면 돼요. 캐시 총량이 8배가 된 효과라 바닥에 거의 붙어요.</p>
<p>첫 토큰 지연으로 보면 12번째 턴(8,800토큰)의 프리필이 캐시 없이 약 338ms, 적중하면 약 9ms 예요(빈 GPU, 위 효율 가정). 시스템 프롬프트만 맞으면 269ms 라 대화형에서는 거의 안 줄어요.</p>
<h2 id="s4">라우팅 방식별 GPU 비용 — 요청 100만 건당</h2>
<p>재계산한 토큰을 GPU 시간으로 바꿔 요청 100만 건당 프리필 비용을 냈어요. 디코드(답 생성) 비용은 라우팅과 상관없어서 뺐어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">복제본 8대의 프리필 GPU 비용 (요청 100만 건당)</figcaption><div class="viz-legend"><span><i class="s1"></i>라운드로빈·무작위</span><span><i class="s2"></i>고정(세션·문서 해시)</span><span><i class="s3"></i>캐시 인지+부하 균형</span></div><svg viewBox="0 0 640 300" role="img" aria-label="복제본 8대의 프리필 GPU 비용 (요청 100만 건당)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">300</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">400</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">500</text><rect class="s1" x="95.2" y="193.9" width="65.9" height="60.1" rx="3"><title>대화형 · 라운드로빈·무작위 127달러</title></rect><rect class="s2" x="163.1" y="249.5" width="65.9" height="4.5" rx="3"><title>대화형 · 고정(세션·문서 해시) 9.5달러</title></rect><rect class="s3" x="230.9" y="248.8" width="65.9" height="5.2" rx="3"><title>대화형 · 캐시 인지+부하 균형 11달러</title></rect><text class="viz-ax" x="196.0" y="272" text-anchor="middle">대화형</text><rect class="s1" x="383.2" y="18.3" width="65.9" height="235.7" rx="3"><title>문서 Q&amp;A · 라운드로빈·무작위 499달러</title></rect><rect class="s2" x="451.1" y="186.0" width="65.9" height="68.0" rx="3"><title>문서 Q&amp;A · 고정(세션·문서 해시) 144달러</title></rect><rect class="s3" x="518.9" y="179.7" width="65.9" height="74.3" rx="3"><title>문서 Q&amp;A · 캐시 인지+부하 균형 157달러</title></rect><text class="viz-ax" x="484.0" y="272" text-anchor="middle">문서 Q&amp;A</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">대화형은 13분의 1, 문서 Q&amp;A 는 3.5분의 1로 줄어요</p><div class="viz-cap">단위: 달러 · 자료: H100 SXM 시간당 4.29달러(Lambda 2026-11-04) · BF16 989 TFLOPS의 50% 가정 · 직접 시뮬레이션</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>라운드로빈·무작위</th><th>고정(세션·문서 해시)</th><th>캐시 인지+부하 균형</th></tr></thead><tbody><tr><td>대화형</td><td>127</td><td>9.5</td><td>11</td></tr><tr><td>문서 Q&amp;A</td><td>499</td><td>144</td><td>157</td></tr></tbody></table></div></details></figure>
<p>대화형에서 하루 요청 100만 건이면 라우팅 하나로 한 달 약 3,500달러 차이예요((127.4 − 9.5) × 30). H100 한 장 한 달(730시간 × 4.29 = 약 3,132달러)보다 커요.</p>
<p><a href="https://github.com/llm-d/llm-d" target="_blank" rel="noopener">llm-d 저장소</a>는 Llama 3.1 70B 를 MI300X 4장에서 돌린 사례로, 접두사 캐시 인지 라우팅이 라운드로빈보다 출력 처리량 3배·TTFT 2배 빨랐다고 소개해요.</p>
<h2 id="s5">문서 질의응답에서 생기는 서버 쏠림</h2>
<p>고정 라우팅의 약점은 쏠림이에요. 문서 해시로 서버를 정하면 인기 문서를 맡은 서버에 질문이 몰려요. 시뮬레이션에서 서버 8대 기준 가장 바쁜 서버는 평균보다 <strong>68%</strong> 많은 요청을 받았어요. 가장 바쁜 서버에 맞춰 용량을 잡아야 하니 아낀 GPU 를 쏠림에 다시 내주게 돼요.</p>
<div class="table-wrap"><table><thead><tr><th>방식 (8대)</th><th>재계산 비율</th><th>가장 바쁜 서버 ÷ 평균</th><th>100만 건당 프리필</th></tr></thead><tbody><tr><td>무작위</td><td>60.9%</td><td>1.01</td><td>499달러</td></tr><tr><td>문서 해시 고정</td><td>17.4%</td><td>1.68</td><td>144달러</td></tr><tr><td>캐시 인지 + 부하 균형</td><td>19.1%</td><td>1.04</td><td>157달러</td></tr></tbody></table></div>
<p>캐시 인지 + 부하 균형은 이 문서를 가진 서버를 먼저 고르되, 그 서버가 다른 후보보다 두 배 넘게(그리고 일정 개수 넘게) 밀려 있으면 덜 바쁜 서버로 넘기는 방식으로 시뮬레이션했어요. 재계산은 1.7%p 늘었지만 쏠림이 거의 사라졌어요. 대화형의 해시 고정 쏠림은 1.08 로 작았지만, 소수 고객이 트래픽 대부분을 내는 B2B 라면 꼭 재 보세요.</p>
<h2 id="s6">직접 서빙 라우터별 설정 비교</h2>
<p>2026-11-04 기준 각 저장소 문서에서 확인한 내용이에요.</p>
<div class="table-wrap"><table><thead><tr><th>라우터</th><th>고를 수 있는 정책</th><th>캐시와 관련된 설정</th></tr></thead><tbody><tr><td><a href="https://github.com/sgl-project/sglang/blob/main/sgl-router/README.md" target="_blank" rel="noopener">SGLang sgl-router</a></td><td><code>power_of_two</code>(기본), <code>cache_aware</code>, <code>session_aware</code></td><td>최소 일치 토큰 기본 1,024, <code>--affinity-mode prefer</code>·<code>balanced</code>, 부하 배수 기본 2</td></tr><tr><td><a href="https://github.com/vllm-project/production-stack/blob/main/src/vllm_router/README.md" target="_blank" rel="noopener">vLLM production-stack 라우터</a></td><td><code>roundrobin</code>, <code>session</code></td><td><code>--session-key</code> 로 세션 헤더 지정, 접두사 인지 라우팅은 개발 중</td></tr><tr><td>일반 L7 로드밸런서</td><td>라운드로빈, 최소 연결, 헤더 해시</td><td>헤더 해시를 세션 고정으로 쓸 수 있음</td></tr></tbody></table></div>
<p>SGLang 라우터의 <code>cache_aware</code> 는 프롬프트의 가장 긴 캐시 접두사를 가진 엔진을 고르고, 선호 엔진이 없거나 입장 제한에 걸리면 <code>power_of_two</code> 로 내려가요. <code>balanced</code> 모드는 선호 엔진의 부하가 대안의 2배와 정해진 차이를 모두 넘을 때만 옮겨요.</p>
<p>라우터를 여러 대 띄울 때 함정이 있어요. 같은 문서에 따르면 라우터는 상태를 공유하지 않고 세션 바인딩은 라우터마다 메모리에 있어요. <code>session_aware</code> 를 쓰면서 라우터 앞에 라운드로빈 로드밸런서를 두면 세션이 라우터별로 갈라져요. 라우터 앞단도 세션 헤더 기준으로 고정해야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/prefix-cache-routing-replica-session-affinity-cost-guide-2.svg" alt="API 를 쓰는지, 복제본이 2대 이상인지, 앞부분이 시스템 프롬프트 하나뿐인지, 대화 단위로만 갈라지는지 차례로 물어 미리 데우기·라우터 없음·라운드로빈·세션 고정·캐시 인지 + 부하 균형 중 하나를 고르는 순서도" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 답하면 기본 라우팅이 정해져요</figcaption></figure>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s7">API 를 쓸 때도 라우팅은 있어요 — 병렬 요청과 캐시 키</h2>
<p><strong>Claude API — 병렬 요청은 하나를 먼저 보내요.</strong> <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" target="_blank" rel="noopener">Claude 프롬프트 캐싱 문서</a>는 캐시 항목이 첫 응답이 시작된 뒤에야 쓸 수 있다고 적어요. 같은 앞부분으로 요청 10개를 한꺼번에 보내면 모두 캐시를 못 찾고 각자 쓰기를 해요. 문서는 <code>max_tokens: 0</code> 으로 출력 없이 캐시만 써 두는 미리 데우기 요청도 안내해요. 이때 thinking 과 effort 설정을 본 요청과 똑같이 맞춰야 같은 항목에 적중해요.</p>
<p><a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener">Claude 가격표</a>의 Sonnet 5.5 단가(100만 토큰당 입력 2달러, 5분 캐시 쓰기 2.50달러, 읽기 0.10달러)로 2만 토큰 앞부분 요청 10개를 계산했어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">2만 토큰 앞부분을 공유하는 요청 10개의 입력비 (Claude Sonnet 5.5)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="2만 토큰 앞부분을 공유하는 요청 10개의 입력비 (Claude Sonnet 5.5)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="156" y="29" text-anchor="end">캐시 없음</text><rect class="s1" x="166" y="15" width="323.2" height="20" rx="3"><title>캐시 없음 · 40센트</title></rect><text class="viz-val" x="495.2" y="29">40센트</text><text class="viz-ax lg" x="156" y="63" text-anchor="end">10개 동시 전송</text><rect class="s1" x="166" y="49" width="404.0" height="20" rx="3"><title>10개 동시 전송 · 50센트</title></rect><text class="viz-val" x="576.0" y="63">50센트</text><text class="viz-ax lg" x="156" y="97" text-anchor="end">1개 먼저 후 9개</text><rect class="s1" x="166" y="83" width="54.9" height="20" rx="3"><title>1개 먼저 후 9개 · 6.8센트</title></rect><text class="viz-val" x="226.9" y="97">6.8센트</text><text class="viz-ax lg" x="156" y="131" text-anchor="end">미리 데우기 후 10개</text><rect class="s1" x="166" y="117" width="56.6" height="20" rx="3"><title>미리 데우기 후 10개 · 7센트</title></rect><text class="viz-val" x="228.6" y="131">7센트</text></svg><p class="viz-note">동시에 보내면 10개 모두 쓰기 요금(1.25배)이라 캐시를 안 쓸 때보다 비싸요</p><div class="viz-cap">단위: 센트 · 자료: Claude 가격표 2026-11-04 (입력 2달러·5분 쓰기 2.50달러·읽기 0.10달러 / 100만 토큰)로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>입력비</th></tr></thead><tbody><tr><td>캐시 없음</td><td>40</td></tr><tr><td>10개 동시 전송</td><td>50</td></tr><tr><td>1개 먼저 후 9개</td><td>6.8</td></tr><tr><td>미리 데우기 후 10개</td><td>7</td></tr></tbody></table></div></details></figure>
<p>계산은 단순해요. 동시 전송은 2만 × 10 × 2.50달러 ÷ 100만 = 0.50달러, 먼저 하나를 보내면 쓰기 0.05달러 + 읽기 9번 0.018달러 = 0.068달러예요. 나머지 9개가 기다리는 게 싫으면 사용자가 몰리기 전에 미리 데워 두세요. Batches API 는 적중이 최선 노력(best-effort)이라, 문서는 1시간 캐시를 단 요청 하나짜리 배치를 먼저 끝낸 뒤 나머지를 넣으라고 권해요.</p>
<p>캐시는 워크스페이스 단위로 격리되니, 같은 트래픽을 여러 워크스페이스로 나누면 캐시도 갈라져요.</p>
<p><strong>OpenAI API — 키당 분당 15건 안팎.</strong> <a href="https://developers.openai.com/api/docs/guides/prompt-caching" target="_blank" rel="noopener">OpenAI 프롬프트 캐싱 문서</a>에 따르면 캐시는 개별 머신에 있고, 라우팅은 머신 부하·앞부분 토큰 해시(도구 정의 포함)·<code>prompt_cache_key</code> 로 정해져요. 분당 15건을 넘으면 다른 머신으로 넘칠 수 있어서, GPT-5.6 이전 모델은 고정 <code>prompt_cache_key</code> 를 주고 키당 분당 15건 안팎으로 맞추라고 해요. 분당 120건이면 키 8개로 나누는 식이에요. 나눌 때는 사용자 ID 해시를 8로 나눈 나머지처럼 <strong>같은 입력이면 늘 같은 키</strong>가 나오는 매핑을 써야 해요. <a href="https://tool.smithlab.co.kr/developer/hash-generator/">해시 생성기</a>로 같은 ID 가 매번 같은 값이 나오는지 확인해 볼 수 있어요. 키는 라우팅에 영향만 줄 뿐 적중을 보장하지 않고, GPT-5.6 이후 모델은 라우팅을 알아서 해서 키가 고객별 캐시 회계 용도로만 쓰여요.</p>
<h2 id="s8">상황별 추천</h2>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>추천</th><th>이유</th></tr></thead><tbody><tr><td>서버 1대, 또는 앞부분이 공통 시스템 프롬프트뿐</td><td>엔진 접두사 캐싱만 켜고 라운드로빈·최소 부하</td><td>서버마다 한 번씩만 쓰면 끝</td></tr><tr><td>챗봇·상담처럼 대화가 쌓임</td><td>세션 고정(대화 ID 헤더)</td><td>8대에서 재계산 51% → 3.7%</td></tr><tr><td>문서 Q&amp;A·RAG 에서 문서 수가 서버 캐시보다 많음</td><td>캐시 인지 + 부하 균형</td><td>서버별로 문서를 나눠 캐시 총량을 늘림</td></tr><tr><td>에이전트·퓨샷처럼 앞부분이 나무 모양으로 갈라짐</td><td>캐시 인지(가장 긴 접두사 일치)</td><td>대화 ID 하나로 표현이 안 됨</td></tr><tr><td>API 에서 같은 앞부분으로 병렬 처리</td><td>미리 데우기 후 병렬</td><td>동시 전송은 캐시 없음보다 비쌈</td></tr><tr><td>OpenAI GPT-5.6 이전 모델, 분당 수백 건</td><td>결정적 매핑으로 <code>prompt_cache_key</code> 분할</td><td>키당 분당 15건 안팎 권장</td></tr></tbody></table></div>
<p>어떤 방식이든 날짜·요청 ID 가 앞쪽에 있어 앞부분이 매번 바뀌면 소용없으니 그것부터 고쳐요.</p>
<h2 id="s9">도입 체크리스트</h2>
<aside class="callout warn"><b class="callout-h">주의</b><p>라우팅을 바꿔도 오류는 나지 않아요. 서버별 적중률과 가장 바쁜 서버의 대기열을 같이 보지 않으면 좋아졌는지 나빠졌는지 알 수 없어요.</p></aside>
<p><strong>바꾸기 전</strong></p>
<ul><li>요청 로그에서 앞부분이 무엇으로 갈라지는지(시스템 프롬프트만, 대화, 문서, 에이전트 분기) 분류했다.</li><li>대화·문서별 요청 수 분포를 뽑아 상위 1%가 트래픽의 몇 %인지 봤다. 쏠림이 크면 순수 해시 고정은 피한다.</li><li>서버 한 대의 유휴 캐시 토큰 수와 동시에 살아 있는 대화·문서의 총 토큰 수를 비교했다.</li></ul>
<p><strong>바꾼 뒤</strong></p>
<ul><li>서버별 적중률을 따로 본다. 평균만 보면 0%인 서버를 놓쳐요.</li><li>가장 바쁜 서버의 대기 요청 수와 TTFT p95 를 평균과 나란히 띄운다.</li><li>라우터가 여러 대면 세션 헤더 기준으로 라우터 앞단도 고정했다.</li><li>고객사끼리 캐시를 나눠야 하면 <a href="https://tool.smithlab.co.kr/posts/developer/llm-kv-cache-isolation-cross-request-leak-checklist/">KV 캐시 격리 점검표</a>로 분리 설정을 따로 확인했다.</li></ul>
<p><strong>API 라면</strong></p>
<ul><li>병렬 작업 전에 미리 데우기 요청을 두고, Claude 는 <code>cache_read_input_tokens</code>, OpenAI 는 <code>cached_tokens</code> 로 두 번째 요청부터 적중하는지 확인했다.</li><li>적중하지 않는 두 요청의 원문을 저장해 <a href="https://tool.smithlab.co.kr/text/text-compare/">텍스트 비교 도구</a>로 어느 줄에서 갈라지는지 봤다.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s10">자주 묻는 질문</h2>
<h3>서버가 두 대뿐인데도 라우팅을 신경 써야 하나요?</h3>
<p>대화가 쌓이는 서비스라면 그래요. 시뮬레이션에서 두 대만 돼도 세션 고정이 재계산을 49%에서 35%로 줄였어요. 앞부분이 시스템 프롬프트 하나뿐이면 두 대든 열 대든 라운드로빈으로 충분해요.</p>
<h3>세션 고정을 하면 서버 하나가 죽었을 때 어떻게 되나요?</h3>
<p>그 서버에 묶인 대화는 다른 서버로 옮겨지고 다음 한 턴만 처음부터 계산해요. 답이 틀려지지는 않아요. 서버 수가 바뀌면 해시가 재배치되니, 일관 해시(consistent hashing)를 쓰는 라우터면 옮겨지는 대화가 일부로 줄어요.</p>
<h3>캐시 인지 라우팅은 라우터가 무거워지지 않나요?</h3>
<p>라우터가 프롬프트를 토큰화하고 접두사 트리를 들고 있어야 해서 CPU 와 메모리를 더 써요. SGLang 라우터는 기본으로 1,024토큰 넘게 일치해야 그 엔진을 선호하고, 그보다 짧으면 부하 기준으로 골라요. 앞부분이 짧은 서비스는 캐시 인지의 이득이 작으니 세션 고정이 더 가벼워요.</p>
<h3>GPU 캐시가 모자라면 CPU 나 디스크로 늘리면 되지 않나요?</h3>
<p>KV 를 CPU 메모리나 디스크로 내려 GPU 밖에 더 오래 보관하는 계층형 캐시도 있어요. llm-d 는 이를 CPU·디스크 오프로딩과 KV 캐시 상태의 전역 인덱스로 소개해요. 다만 GPU 로 다시 올리는 시간이 들어서, 같은 GPU 에 이미 있는 캐시를 쓰는 것보다는 느려요. 라우팅으로 GPU 적중부터 올리는 게 먼저예요.</p>
<h3>프리필 비용이 이렇게 차이 나면 출력 지연도 줄어드나요?</h3>
<p>답을 한 토큰씩 만드는 시간은 그대로예요. 줄어드는 건 첫 토큰까지의 시간과 프리필에 쓰던 GPU 시간이에요. 다만 프리필이 덜 끼어들면 같은 배치에서 돌던 다른 요청의 토큰 간격도 덜 흔들려요. 이 관계는 <a href="https://tool.smithlab.co.kr/posts/developer/llm-serving-batching-chunked-prefill-selection-cost-guide/">LLM 서빙 배칭 방식 글</a>에서 계산했어요.</p>
<h2 id="s11">정리</h2>
<ul><li>접두사 캐시는 서버마다 따로 있어서, 라운드로빈은 서버를 늘려도 재계산이 줄지 않아요(8대에서 51%).</li><li>대화형은 세션 고정만으로 3.7%까지 내려가고 프리필 GPU 비용이 13분의 1이 됐어요.</li><li>앞부분이 여러 갈래이거나 쏠림이 크면 캐시 인지 + 부하 균형으로 적중률과 쏠림을 함께 잡아요.</li><li>API 는 같은 앞부분 요청을 동시에 쏘지 말고 미리 데운 뒤 보내요. OpenAI GPT-5.6 이전 모델은 키당 분당 15건 안팎으로 나눠요.</li></ul>
<h2 id="s12">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/prompt-caching-prefix-kv-cache-how-it-works" target="_blank" rel="noopener">프롬프트 캐싱 원리 — 접두사 KV 캐시·블록 해시·RadixAttention</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>트랜잭션 격리 수준 고르는 법 — MySQL·PostgreSQL…</title>
      <link>https://tool.smithlab.co.kr/posts/developer/transaction-isolation-level-selection-retry-checklist/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/transaction-isolation-level-selection-retry-checklist/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>격리 수준을 바꿀지 고민된다면 순서부터 바꿔 보세요. 결론부터 말하면 <strong>대부분의 서비스는 기본값을 그대로 두고, 동시성 버그는 쿼리 모양(원자적 UPDATE·잠금 읽기·유니크 제약)으로 먼저 막고, 여러 행에 걸친 규칙만 SERIALIZABLE 과 재시도로 지키는 게 실무 기본값이에요.</strong> 격리 수준을 전역으로 올리면 버그 하나를 막는 대가로 모든 트랜잭션이 락 대기나 재시도 비용을 내요.</p>
<p>함정은 두 DB 의 기본값과 동작이 다르다는 점이에요. PostgreSQL 은 READ COMMITTED(RC), MySQL InnoDB 는 REPEATABLE READ(RR)가 기본이에요. 같은 RR 이라도 PostgreSQL 은 동시 갱신에 오류를 내고, InnoDB 는 기다렸다가 최신 행에 덮어써요. 이 글은 내부 구조 설명 대신 <strong>언제 무엇을 고르나, 얼마나 드나, 장애가 나면 어디부터 보나</strong>를 다뤄요. 근거는 2026-11-04 에 연 <a href="https://www.postgresql.org/docs/current/transaction-iso.html" target="_blank" rel="noopener">PostgreSQL 트랜잭션 격리 문서</a>와 <a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html" target="_blank" rel="noopener">MySQL 8.4 InnoDB 격리 수준 문서</a>예요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>재고 차감·잔액 변경처럼 한 행을 고치는 문제는 격리 수준이 아니라 <code>SET v = v - 1</code> 같은 원자적 UPDATE 나 <code>FOR UPDATE</code> 로 풀어요.</li><li>"없으면 넣기" 중복은 유니크 제약이, 여러 행에 걸친 규칙(최소 인원·합계 한도)은 PostgreSQL SERIALIZABLE + 재시도가 맡아요.</li><li>직렬화 실패율 20% 에서도 기대 시도 수는 1.25회, 최대 4번 시도면 최종 실패는 0.16% 예요. 재시도 비용보다 재시도 코드가 없는 게 더 위험해요.</li><li>초당 500행을 고치는 테이블에서 1시간 열린 트랜잭션은 옛 버전 180만 개(약 360MB)를 붙잡아요. 격리 수준보다 트랜잭션 길이를 먼저 재세요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">먼저 지금 격리 수준부터 확인해요</h2>
<p>ORM·커넥션 풀이 접속할 때 세션 값을 바꾸기도 해서, 설정 파일보다 애플리케이션이 실제로 쓰는 커넥션에서 물어보는 게 정확해요.</p>
<pre class="code-block" data-lang="sql"><code>-- MySQL
SELECT @@GLOBAL.transaction_isolation, @@SESSION.transaction_isolation;

-- PostgreSQL
SHOW default_transaction_isolation;   -- 새 트랜잭션의 기본값
SHOW transaction_isolation;           -- 지금 트랜잭션의 값</code></pre>
<p><a href="https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html" target="_blank" rel="noopener">MySQL SET TRANSACTION 문서</a>에 따르면 <code>SESSION</code>·<code>GLOBAL</code> 없이 쓴 <code>SET TRANSACTION ISOLATION LEVEL ...</code> 은 <strong>다음 트랜잭션 하나</strong>에만 적용되고, 트랜잭션 안에서는 쓸 수 없어요(오류 1568). <code>SET SESSION</code> 은 그 커넥션의 이후 트랜잭션 전부에 남아요. 풀에서 빌린 커넥션에 <code>SET SESSION</code> 을 하고 돌려주면, 다음에 그 커넥션을 빌린 전혀 다른 요청이 높은 격리 수준으로 돌아요. 특정 작업만 올리려면 트랜잭션 단위(<code>BEGIN ISOLATION LEVEL ...</code>, 프레임워크의 트랜잭션 속성)로 지정하는 편이 안전해요.</p>
<h2 id="s2">상황별 격리 수준 판단표</h2>
<p>아래 표는 "격리 수준을 몇으로 하나"보다 "어떤 수단으로 막나"를 먼저 적었어요. 대부분의 칸이 기본값 유지인 이유는, 격리 수준을 올려도 그 문제가 안 풀리거나 더 싼 수단이 있기 때문이에요.</p>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>흔한 버그</th><th>먼저 쓸 수단</th><th>격리 수준</th></tr></thead><tbody><tr><td>목록·상세 화면 조회</td><td>없음</td><td>그대로</td><td>기본값(RC 또는 RR)</td></tr><tr><td>재고·포인트·잔액 차감</td><td>갱신 손실, 음수 재고</td><td><code>SET v = v - 1 WHERE v &gt;= 1</code> 원자적 UPDATE, 영향 행 수 확인</td><td>기본값</td></tr><tr><td>읽고 계산해서 저장(복잡한 로직)</td><td>갱신 손실</td><td><code>SELECT ... FOR UPDATE</code> 또는 버전 컬럼 낙관적 잠금</td><td>기본값</td></tr><tr><td>없으면 넣기(가입·쿠폰 발급)</td><td>중복 행</td><td>유니크 제약 + 중복 키 오류 처리</td><td>기본값</td></tr><tr><td>여러 행에 걸친 규칙(당직 최소 1명, 예약 겹침, 합계 한도)</td><td>쓰기 스큐</td><td>PG: SERIALIZABLE + 재시도 / MySQL: 규칙 대상 행을 잠금 읽기, 또는 제약·잠글 대표 행 하나</td><td>PG SERIALIZABLE</td></tr><tr><td>리포트·정산의 여러 쿼리가 같은 시점</td><td>쿼리마다 숫자가 다름</td><td>읽기 전용 트랜잭션 하나로 묶기</td><td>RR(읽기 전용, 짧게)</td></tr><tr><td>대량 배치 UPDATE</td><td>락 대기·데드락</td><td>작은 묶음으로 나눠 커밋, 같은 순서로 접근</td><td>MySQL 은 RC 검토</td></tr></tbody></table></div>
<p>예약 겹침처럼 "범위"가 문제인 규칙은 PostgreSQL 이라면 배제 제약(<code>EXCLUDE</code>)으로 DB 가 직접 막게 할 수도 있어요. 규칙을 제약으로 옮길 수 있으면 격리 수준도 재시도 코드도 필요 없어져서 가장 싸요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/transaction-isolation-level-selection-retry-checklist-1.svg" alt="읽기만 하나, 한 행을 읽고 고치나, 없으면 넣는 로직인가, 여러 행에 걸친 규칙이 있나, 여러 쿼리가 한 시점을 봐야 하나를 차례로 물어 기본값 유지·원자적 UPDATE·유니크 제약·SERIALIZABLE 재시도·읽기 전용 RR 중 하나를 고르는 순서도" width="800" height="600" loading="lazy" decoding="async"><figcaption>위에서부터 답하면 필요한 수단이 정해져요</figcaption></figure>
<p>애그리거트 단위로 버전 컬럼을 두는 낙관적 잠금은 <a href="https://tool.smithlab.co.kr/posts/developer/ddd-aggregate-boundary-decision-guide/">DDD 애그리거트 경계 판단표 글</a>과 <a href="https://tool.smithlab.co.kr/posts/developer/jpa-aggregate-root-version-force-increment-guide/">JPA 애그리거트 루트 버전 강제 증가 글</a>에서 충돌률까지 계산했어요. 여러 서버가 DB 밖의 자원을 두고 다툰다면 격리 수준이 닿지 않으니 <a href="https://tool.smithlab.co.kr/posts/developer/redis-distributed-lock-ttl-redlock-fencing-selection-guide/">레디스 분산 락 판단표</a>를 보세요.</p>
<h2 id="s3">같은 이름, 다른 동작 — 엔진별로 꼭 알아 둘 차이</h2>
<p>판단표를 엔진에 옮길 때 가장 많이 틀리는 곳 세 가지예요.</p>
<p><strong>1. 갱신 손실은 InnoDB RR 에서도 남아요.</strong> 값을 애플리케이션으로 읽어 와서 계산한 뒤 <code>SET v = 계산값</code> 으로 쓰면, PostgreSQL RR 은 뒤늦은 쪽에 직렬화 실패를 내지만 InnoDB RR 은 기다렸다가 최신 행에 그대로 덮어써요. "MySQL 은 RR 이 기본이니 안전하다"는 판단이 가장 위험해요. InnoDB 에서는 처음 읽을 때 <code>FOR UPDATE</code> 를 걸거나 계산을 SQL 안으로 옮겨야 해요.</p>
<p><strong>2. MySQL 을 RC 로 내리면 바이너리 로그 형식이 바뀌어요.</strong> 문서에 따르면 RC 에서는 행 기반 바이너리 로그만 지원되고, <code>binlog_format=MIXED</code> 이면 자동으로 행 기반으로 기록해요. 대신 RC 에서는 잠금 읽기·UPDATE·DELETE 가 간격(gap)을 잠그지 않아서(외래 키·중복 키 검사 제외) 경합과 데드락이 줄고, 팬텀은 다시 생겨요.</p>
<p><strong>3. PostgreSQL SERIALIZABLE 은 복제본에서 못 써요.</strong> <a href="https://www.postgresql.org/docs/current/hot-standby.html" target="_blank" rel="noopener">PostgreSQL 핫 스탠바이 문서</a>는 핫 스탠바이에서 SERIALIZABLE 을 지정하면 오류가 난다고 적어요. 읽기 부하를 복제본으로 돌리는 구조라면, 직렬 가능성이 필요한 읽기는 주 서버에서 해야 해요.</p>
<div class="table-wrap"><table><thead><tr><th>항목</th><th>PostgreSQL 18</th><th>MySQL 8.4 InnoDB</th></tr></thead><tbody><tr><td>기본값</td><td>READ COMMITTED</td><td>REPEATABLE READ</td></tr><tr><td>RR 에서 동시 갱신</td><td>직렬화 실패(40001), 트랜잭션 재시도</td><td>기다린 뒤 최신 커밋 버전에 적용</td></tr><tr><td>SERIALIZABLE 방식</td><td>읽기를 막지 않고 위험하면 사후 실패</td><td>일반 SELECT 를 <code>FOR SHARE</code> 로 바꿔 락 대기(autocommit 꺼졌을 때)</td></tr><tr><td>재시도가 필요한 오류</td><td>40001, 40P01(데드락)</td><td>1213(데드락, SQLSTATE 40001), 1205(락 대기 초과)</td></tr><tr><td>락 대기 기본 한도</td><td>무제한(<code>lock_timeout</code> 0)</td><td><code>innodb_lock_wait_timeout</code> 50초</td></tr></tbody></table></div>
<h2 id="s4">재시도 비용 — 실패율별 기대 시도 수</h2>
<p>SERIALIZABLE(PostgreSQL)이나 PostgreSQL RR 을 쓰면 재시도는 선택이 아니에요. 문서도 이 수준의 애플리케이션은 직렬화 실패에 대비해 재시도해야 하고, 실패하면 <strong>트랜잭션 전체를 처음부터</strong> 다시 돌리라고 적어요. 그러면 재시도가 얼마나 드는지 계산해 볼 수 있어요.</p>
<p>시도마다 실패할 확률을 p 라 하고 시도끼리 독립이라고 가정하면, 성공할 때까지 기대 시도 수는 1 ÷ (1 − p) 예요. 최대 4번(첫 시도 + 재시도 3번)까지 하면 끝내 실패할 확률은 p⁴ 이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">직렬화 실패율별 성공까지 기대 시도 수</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="직렬화 실패율별 성공까지 기대 시도 수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">0.25</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">0.5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">0.75</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">1.2</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">1.5</text><rect class="s1" x="69.3" y="95.1" width="80.6" height="158.9" rx="3"><title>1% · 기대 시도 수 1회</title></rect><text class="viz-val" x="109.6" y="90.1" text-anchor="middle">1</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">1%</text><rect class="s1" x="184.5" y="88.8" width="80.6" height="165.2" rx="3"><title>5% · 기대 시도 수 1.1회</title></rect><text class="viz-val" x="224.8" y="83.8" text-anchor="middle">1.1</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">5%</text><rect class="s1" x="299.7" y="79.4" width="80.6" height="174.6" rx="3"><title>10% · 기대 시도 수 1.1회</title></rect><text class="viz-val" x="340.0" y="74.4" text-anchor="middle">1.1</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">10%</text><rect class="s1" x="414.9" y="57.3" width="80.6" height="196.7" rx="3"><title>20% · 기대 시도 수 1.2회</title></rect><text class="viz-val" x="455.2" y="52.3" text-anchor="middle">1.2</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">20%</text><rect class="s1" x="530.1" y="29.0" width="80.6" height="225.0" rx="3"><title>30% · 기대 시도 수 1.4회</title></rect><text class="viz-val" x="570.4" y="24.0" text-anchor="middle">1.4</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">30%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">최대 4번 시도면 최종 실패 확률은 p⁴ — 10%면 0.01%, 20%면 0.16%, 30%면 0.81%</p><div class="viz-cap">단위: 회 · 자료: 시도마다 독립인 실패 확률 p 에서 1÷(1−p) 로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>기대 시도 수</th></tr></thead><tbody><tr><td>1%</td><td>1</td></tr><tr><td>5%</td><td>1.1</td></tr><tr><td>10%</td><td>1.1</td></tr><tr><td>20%</td><td>1.2</td></tr><tr><td>30%</td><td>1.4</td></tr></tbody></table></div></details></figure>
<p>숫자로 보면 실패율 20% 도 평균 1.25번에 끝나서 생각보다 싸요. 비싼 건 두 경우예요.</p>
<p><strong>같은 행에 몰리는 경합.</strong> 독립 가정이 깨져요. 동시에 c 개 트랜잭션이 같은 행을 읽고 고치면 한 번에 하나만 성공하고 나머지는 실패해 다시 겨룬다고 단순화해 볼게요. 모두 끝날 때까지 총 시도는 c(c+1)/2, 성공 하나당 평균 (c+1)/2 번이에요.</p>
<p>동시에 5개면 총 15번(성공당 3번), 10개면 55번(5.5번), 20개면 210번(10.5번)이에요.</p>
<p>조회수·좋아요 카운터처럼 한 행에 쓰기가 몰리는 곳을 높은 격리 수준의 "읽고 계산해서 쓰기"로 처리하면 재시도가 경합의 제곱으로 늘어요. 이런 곳은 격리 수준을 올리지 말고 <code>SET cnt = cnt + 1</code> 로 DB 안에서 더하세요. 그러면 행 락을 차례로 기다릴 뿐 버려지는 시도가 없어요.</p>
<p><strong>재시도 안에 외부 부작용이 있는 경우.</strong> 결제 API 호출, 메일 발송, 메시지 발행이 트랜잭션 안에 있으면 재시도마다 다시 나가요. 부작용은 커밋 뒤로 빼거나(아웃박스 테이블), 멱등 키를 붙여야 재시도가 안전해요.</p>
<aside class="callout tip"><b class="callout-h">팁</b><p>재시도 코드에는 횟수 한도(3~5회), 지수 백오프에 무작위 지연(지터), 그리고 재시도한 횟수를 지표로 남기는 것까지 넣으세요. 재시도 비율이 올라가는 게 보이면 그게 경합이 커졌다는 첫 신호예요.</p></aside>
<h2 id="s5">오래 열린 트랜잭션의 비용 — 붙잡힌 옛 버전 계산</h2>
<p>MVCC 에서는 열려 있는 가장 오래된 스냅샷이 필요로 하는 옛 버전을 지울 수 없어요. <a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-purge-configuration.html" target="_blank" rel="noopener">MySQL 퍼지 설정 문서</a>는 쓰기가 많거나 오래 도는 트랜잭션이 있으면 읽기 전용이어도 History list length 가 늘어난다고 적고, PostgreSQL <a href="https://www.postgresql.org/docs/current/runtime-config-client.html" target="_blank" rel="noopener">클라이언트 설정 문서</a>도 열린 트랜잭션이 최근 죽은 튜플의 VACUUM 을 막아 테이블이 부푼다고 적어요.</p>
<p>얼마나 쌓이는지 예시로 계산해 볼게요. 초당 500행을 UPDATE 하는 테이블이 있고, 옛 버전 하나가 헤더 포함 약 200바이트라고 가정해요. 그동안 지우지 못하는 버전 수는 단순히 <strong>초당 갱신 행 수 × 트랜잭션이 열려 있던 시간</strong>이에요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">트랜잭션이 열려 있던 시간별 붙잡힌 옛 버전 용량 (초당 500행 갱신)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="트랜잭션이 열려 있던 시간별 붙잡힌 옛 버전 용량 (초당 500행 갱신)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="50" y="29" text-anchor="end">1분</text><rect class="s1" x="60" y="15" width="1.0" height="20" rx="3"><title>1분 · 6MB</title></rect><text class="viz-val" x="67.0" y="29">6MB</text><text class="viz-ax lg" x="50" y="63" text-anchor="end">10분</text><rect class="s1" x="60" y="49" width="10.2" height="20" rx="3"><title>10분 · 60MB</title></rect><text class="viz-val" x="76.2" y="63">60MB</text><text class="viz-ax lg" x="50" y="97" text-anchor="end">1시간</text><rect class="s1" x="60" y="83" width="61.2" height="20" rx="3"><title>1시간 · 360MB</title></rect><text class="viz-val" x="127.2" y="97">360MB</text><text class="viz-ax lg" x="50" y="131" text-anchor="end">8시간</text><rect class="s1" x="60" y="117" width="489.6" height="20" rx="3"><title>8시간 · 2,880MB</title></rect><text class="viz-val" x="555.6" y="131">2,880MB</text></svg><p class="viz-note">8시간이면 옛 버전 1,440만 개 — 퇴근 전 BEGIN 만 하고 놔둔 세션 하나로 생기는 양이에요</p><div class="viz-cap">단위: MB · 자료: 500행/초 × 시간 × 버전당 200바이트 가정으로 직접 계산 (예시)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>붙잡힌 용량</th></tr></thead><tbody><tr><td>1분</td><td>6</td></tr><tr><td>10분</td><td>60</td></tr><tr><td>1시간</td><td>360</td></tr><tr><td>8시간</td><td>2,880</td></tr></tbody></table></div></details></figure>
<p>용량만이 아니라 스캔이 지우지 못한 버전을 건너뛰느라 느려져요. 흔한 원인은 세 가지예요.</p>
<ul><li>커넥션 풀에서 <code>BEGIN</code> 후 예외로 커밋·롤백 없이 반납된 세션(PostgreSQL 에서 <code>idle in transaction</code>)</li><li>autocommit 을 끈 채 <code>SELECT</code> 만 하고 끝내지 않은 콘솔·BI 도구 세션</li><li>트랜잭션 안에서 외부 API 를 부르거나 사용자 입력을 기다리는 코드</li></ul>
<p>PostgreSQL 은 <code>idle_in_transaction_session_timeout</code>, <code>transaction_timeout</code> 으로 이런 세션을 끊을 수 있어요. 둘 다 기본값은 0(끔)이에요. 서버 전체에 걸면 정상 배치까지 끊길 수 있으니, 애플리케이션 계정에 역할 단위로 거는 방식을 많이 써요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">락 대기·데드락·40001 이 늘 때 확인 순서</h2>
<p>알림이 울리면 아래 순서로 보세요. 대부분은 1·2단계에서 원인이 나와요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/transaction-isolation-level-selection-retry-checklist-2.svg" alt="MySQL InnoDB 와 PostgreSQL 각각에 대해 오래 열린 트랜잭션, 막는 세션, 데드락 기록, 옛 버전 쌓임, 갭 락이나 술어 락 승격을 차례로 확인하는 5단계 점검 카드" width="800" height="600" loading="lazy" decoding="async"><figcaption>1단계 오래 열린 트랜잭션에서 원인이 가장 자주 나와요</figcaption></figure>
<p><strong>1. 오래 열린 트랜잭션.</strong> MySQL 은 <code>information_schema.innodb_trx</code> 의 <code>trx_started</code>, PostgreSQL 은 <code>pg_stat_activity</code> 의 <code>xact_start</code> 를 오래된 순으로 봐요.</p>
<pre class="code-block" data-lang="sql"><code>-- PostgreSQL: 5분 넘게 열린 트랜잭션
SELECT pid, state, now() - xact_start AS 열린시간, left(query, 60)
FROM pg_stat_activity
WHERE xact_start &lt; now() - interval &#x27;5 minutes&#x27;
ORDER BY xact_start;</code></pre>
<p><strong>2. 누가 누구를 막고 있나.</strong> MySQL 은 <code>performance_schema.data_lock_waits</code> 와 <code>data_locks</code> 를, PostgreSQL 은 <code>pg_blocking_pids(pid)</code> 로 막는 쪽을 찾아요. 막는 쪽이 1단계의 오래된 트랜잭션인 경우가 많아요.</p>
<p><strong>3. 데드락 기록.</strong> <a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks-handling.html" target="_blank" rel="noopener">MySQL 데드락 대응 문서</a>대로 <code>SHOW ENGINE INNODB STATUS</code> 의 LATEST DETECTED DEADLOCK 절에서 가장 최근 데드락을 보고, 자주 나면 <code>innodb_print_all_deadlocks</code> 를 잠시 켜서 전부 에러 로그에 남겨요. 문서의 처방은 트랜잭션을 작고 짧게, 여러 테이블·행을 고칠 때 늘 같은 순서로, 잠금 읽기를 쓴다면 RC 같은 낮은 수준을 검토하라는 거예요. PostgreSQL 은 <code>pg_stat_database.deadlocks</code> 로 횟수를 보고 서버 로그에서 내용을 봐요. 데드락 검사는 <code>deadlock_timeout</code>(기본 1초)만큼 기다린 뒤에 돌아요.</p>
<p><strong>4. 옛 버전 쌓임.</strong> MySQL 은 INNODB STATUS 의 History list length(보통 수천 이하), PostgreSQL 은 <code>pg_stat_activity.backend_xmin</code> 이 오래된 세션과 <code>pg_stat_user_tables.n_dead_tup</code> 을 봐요.</p>
<p><strong>5. 락 범위가 넓어진 이유.</strong> InnoDB RR 에서 <code>WHERE</code> 조건 컬럼에 인덱스가 없으면 훑는 범위 전체에 next-key 락이 걸려 엉뚱한 INSERT 까지 막혀요. 실행 계획부터 확인하세요. PostgreSQL SERIALIZABLE 은 순차 스캔이 테이블 단위 술어 락을 잡고, 술어 락 테이블 메모리가 모자라면 락을 묶어 올리면서 직렬화 실패가 늘어날 수 있다고 문서가 적어요. 인덱스 스캔이 되게 하거나 <code>max_pred_locks_per_transaction</code> 등을 늘려요.</p>
<aside class="callout warn"><b class="callout-h">주의</b><p>InnoDB 의 락 대기 초과(1205)는 기본으로 <strong>그 문장만</strong> 롤백하고 트랜잭션은 열린 채 남아요. 데드락(1213)은 트랜잭션 전체를 롤백해요. 1205 를 받고 그대로 다음 문장을 이어 가면 앞부분만 반영된 채 커밋될 수 있어요. 전체를 롤백하려면 <code>innodb_rollback_on_timeout</code> 을 켜거나, 애플리케이션이 1205 에서 명시적으로 롤백하세요(<a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html" target="_blank" rel="noopener">InnoDB 오류 처리 문서</a>).</p></aside>
<h2 id="s7">격리 수준을 바꾸기 전에 따질 것</h2>
<p>전역 기본값을 바꾸는 건 스키마 변경만큼 영향이 커요. 바꾸기 전에 이 다섯 가지를 확인하세요.</p>
<ol><li><strong>재시도 코드가 모든 쓰기 경로에 있나.</strong> PostgreSQL 을 RR·SERIALIZABLE 로 올리면 지금까지 안 나던 40001 이 모든 트랜잭션에서 날 수 있어요. 재시도가 없는 경로는 오류로 사용자에게 그대로 보여요.</li><li><strong>트랜잭션 안에 외부 부작용이 있나.</strong> 있으면 재시도가 중복 결제·중복 발송이 돼요.</li><li><strong>복제·백업 도구와 맞나.</strong> MySQL RC 는 행 기반 바이너리 로그가 전제고, PostgreSQL SERIALIZABLE 은 핫 스탠바이에서 못 써요.</li><li><strong>읽기 전용 리포트가 길게 도나.</strong> 리포트 하나가 몇 시간짜리 스냅샷을 잡으면 위 계산처럼 옛 버전이 쌓여요. PostgreSQL 에서 직렬 가능한 리포트가 필요하면 <code>SERIALIZABLE READ ONLY DEFERRABLE</code> 이 시작 때만 잠깐 기다리고 이후에는 실패 걱정 없이 읽어요(문서가 SERIALIZABLE 이 RR 과 달리 막히는 유일한 경우라고 적어요).</li><li><strong>동시 부하로 재 봤나.</strong> 단일 요청 테스트로는 차이가 안 보여요. 재시도율·락 대기 시간을 바꾸기 전후로 비교하세요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>MySQL 기본값 RR 을 RC 로 내려도 되나요?</h3>
<p>많은 서비스가 그렇게 운영해요. 간격 락이 사라져 데드락과 락 대기가 줄어드는 대신 잠금 읽기에서 팬텀이 다시 생기고, 바이너리 로그가 행 기반이어야 해요. 갱신 손실·중복 방지를 원자적 UPDATE 와 유니크 제약으로 막고 있다면 RC 로 내려도 정합성 위험은 크게 늘지 않아요. 그 전에 "범위를 SELECT 로 확인하고 넣는" 코드가 있는지 찾아보세요.</p>
<h3>PostgreSQL 에서 SERIALIZABLE 을 전역으로 켜도 되나요?</h3>
<p>문서는 SERIALIZABLE 을 일관되게 쓰면 개별 트랜잭션만 맞으면 전체가 맞는다고 보장할 수 있어 개발이 단순해진다고 적어요. 다만 모든 쓰기 경로에 재시도가 있어야 하고, 순차 스캔이 많으면 실패율이 올라가요. 보통은 규칙이 걸린 트랜잭션에만 지정하고, 재시도율을 본 뒤 넓혀요.</p>
<h3>40001 과 데드락 오류를 같은 방법으로 재시도해도 되나요?</h3>
<p>네. 둘 다 트랜잭션 전체를 처음부터 다시 돌리면 돼요. PostgreSQL 은 40001(직렬화 실패)과 40P01(데드락), MySQL 은 1213(데드락)이 대상이에요. MySQL 1205(락 대기 초과)는 기본 설정에서 문장만 롤백되니, 롤백을 명시하고 전체를 재시도하는 쪽으로 맞추는 게 단순해요.</p>
<h3>낙관적 잠금(버전 컬럼)을 쓰면 격리 수준은 신경 안 써도 되나요?</h3>
<p>같은 행(애그리거트)을 동시에 고치는 갱신 손실은 버전 컬럼으로 막혀요. 하지만 서로 다른 행을 고치면서 생기는 쓰기 스큐(합계 한도·최소 인원)는 버전 컬럼이 못 막아요. 그 규칙에 걸린 행들을 한 애그리거트로 묶거나, 대표 행 하나를 잠그거나, SERIALIZABLE 을 써야 해요.</p>
<h3>읽기 전용 트랜잭션도 짧아야 하나요?</h3>
<p>네. 아무것도 쓰지 않아도 열려 있는 스냅샷이 옛 버전 정리를 막아요. 위 예시처럼 초당 500행을 고치는 테이블이면 1시간에 360MB 쯤 쌓여요. 리포트는 끝나는 즉시 커밋하고, 콘솔·BI 도구는 autocommit 을 켜 두세요.</p>
<h2 id="s9">정리</h2>
<ul><li>격리 수준보다 먼저 쿼리 모양을 봐요. 한 행 갱신은 원자적 UPDATE·<code>FOR UPDATE</code>, 중복 방지는 유니크 제약, 여러 행 규칙만 PostgreSQL SERIALIZABLE + 재시도.</li><li>PostgreSQL 기본은 RC, MySQL 기본은 RR 이고, InnoDB RR 은 읽고 계산해서 쓰는 갱신 손실을 막지 않아요.</li><li>재시도는 독립 실패면 싸지만(20% 에서 1.25회) 한 행에 몰리면 경합의 제곱으로 늘어요. 카운터는 DB 안에서 더하세요.</li><li>오래 열린 트랜잭션 하나가 옛 버전을 붙잡아요. 장애 땐 오래된 트랜잭션 → 막는 세션 → 데드락 기록 → 옛 버전 → 락 범위 순으로 보세요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/transaction-isolation-mvcc-phantom-read" target="_blank" rel="noopener">트랜잭션 격리 수준과 MVCC 원리 — 팬텀 리드의 정체</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>타입스크립트 구조적 타이핑 실무 판단표, 브랜드 타입과 초과 속성 구멍</title>
      <link>https://tool.smithlab.co.kr/posts/developer/typescript-structural-typing-brand-excess-property-inference-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/typescript-structural-typing-brand-excess-property-inference-guide/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>타입스크립트는 이름이 아니라 모양으로 타입을 맞춰 봐요. 그래서 <code>UserId</code> 와 <code>OrderId</code> 가 둘 다 문자열이면 서로 섞여도 아무 말이 없고, 설정 객체를 변수로 한 번 빼면 <code>colour</code> 같은 오타도 조용히 지나가요. 결론부터 말하면 <strong>섞이면 안 되는 값은 브랜드 타입으로, 오타를 잡아야 하는 객체는 선언 자리에 타입을 붙여서, 추론이 엉뚱한 제네릭은 <code>NoInfer</code>·<code>const</code> 타입 매개변수로</strong> 막는 게 실무에서 가장 싸요.</p>
<p>이 글은 원리 설명 대신 <strong>언제 무엇을 고르는지, 운영에서 무엇부터 의심할지</strong>를 다뤄요. 모든 예제는 <code>typescript@7.0.2</code>(npm <code>latest</code>)의 <code>tsc --strict</code> 로 돌려서 에러가 나는지 직접 확인했고, 런타임 비용은 Apple M1·Node.js 25.3 에서 쟀어요. 규칙의 근거는 <a href="https://www.typescriptlang.org/docs/handbook/type-compatibility.html" target="_blank" rel="noopener">TypeScript 핸드북 Type Compatibility</a>와 각 버전 릴리스 노트예요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>브랜드 타입(<code>string &amp; { __brand }</code>)은 런타임에 아무것도 남지 않아 ID 하나에 32바이트로 문자열과 같고, <code>{ value }</code> 래퍼나 <code>#private</code> 클래스는 64바이트로 두 배였어요.</li><li>초과 속성 검사는 19가지 쓰는 방식 중 10가지에서만 걸렸어요. 변수 경유·<code>as</code>·펼침·콜백 반환·제네릭 함수 경유에서는 조용히 통과해요.</li><li>제네릭에서 기본값 인수가 후보로 끼어드는 문제는 <code>NoInfer</code>(5.4~), 배열이 <code>string[]</code> 로 넓혀지는 문제는 <code>const</code> 타입 매개변수(5.0~)로 막아요.</li><li>같은 라이브러리가 두 벌 설치돼도 버전이 같으면 하나로 합쳐지고, 버전이 다르면 <code>private</code> 멤버 때문에 "separate declarations" 에러가 나요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">같은 모양, 다른 의미 — 타입 구분 방법 판단표</h2>
<p>구조적 타이핑에서 가장 자주 사고가 나는 곳은 <strong>모양은 같고 뜻이 다른 값</strong>이에요. 사용자 ID 와 주문 ID, 원과 달러, 정규화 전 문자열과 정규화 후 문자열처럼요. 고를 수 있는 수단은 다섯 가지예요.</p>
<div class="table-wrap"><table><thead><tr><th>수단</th><th>섞임 차단</th><th>런타임 흔적</th><th>JSON 으로 나갈 때</th><th>맞는 경우</th></tr></thead><tbody><tr><td><code>type UserId = string</code></td><td>안 됨</td><td>없음</td><td>문자열 그대로</td><td>읽기 쉽게 이름만 붙일 때</td></tr><tr><td>브랜드 <code>string &amp; { readonly __brand: "UserId" }</code></td><td>됨</td><td>없음</td><td>문자열 그대로</td><td>ID·단위·검증 끝난 값</td></tr><tr><td><code>unique symbol</code> 브랜드</td><td>됨</td><td>없음</td><td>문자열 그대로</td><td>라이브러리 공개 API (속성 이름 충돌 방지)</td></tr><tr><td><code>private</code>·<code>#private</code> 필드 클래스</td><td>됨</td><td>객체 하나</td><td><code>#private</code> 는 <code>{}</code> 로 사라짐</td><td>값에 메서드·불변식이 많을 때</td></tr><tr><td><code>{ value: string }</code> 래퍼 객체</td><td>안 됨 (모양이 같으면 섞임)</td><td>객체 하나</td><td><code>{"value":"..."}</code> 로 모양이 바뀜</td><td>거의 없음</td></tr></tbody></table></div>
<p>판단표에서 놓치기 쉬운 줄이 마지막이에요. 래퍼 객체를 만들어도 <strong>다른 래퍼와 모양이 같으면</strong> 다시 섞여요. 구분이 목적이라면 속성 이름이 아니라 브랜드 리터럴이나 <code>private</code> 선언 위치가 달라야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/typescript-structural-typing-brand-excess-property-inference-guide-1.svg" alt="섞이면 버그인 값인지, JSON·DB 를 오가는지, 동작이 많은지 세 질문으로 type 별칭·브랜드 타입·#private 클래스 중 하나를 고르는 순서도" width="800" height="520" loading="lazy" decoding="async"><figcaption>ID 처럼 저장·전송되는 값은 대부분 브랜드 타입으로 끝나요</figcaption></figure>
<p>클래스가 명목적으로 동작하는 이유는 <code>private</code> 멤버의 <strong>선언 위치</strong>를 비교하기 때문이에요. 모양이 똑같은 두 클래스에 <code>private id</code> 를 각각 두면 <code>tsc</code> 7.0.2 가 이렇게 막아요.</p>
<pre class="code-block" data-lang="text"><code>error TS2322: Type &#x27;A2&#x27; is not assignable to type &#x27;A1&#x27;.
  Types have separate declarations of a private property &#x27;id&#x27;.</code></pre>
<p>ES 표준 <code>#id</code> 필드도 똑같이 막히고, 메시지만 "refers to a different member" 로 달라요. 클래스 쪽 규칙은 <a href="https://www.typescriptlang.org/docs/handbook/2/classes.html" target="_blank" rel="noopener">핸드북 Classes 문서</a>에 정리돼 있어요.</p>
<h3>브랜드 타입 만드는 규칙</h3>
<p>브랜드는 "검증을 통과한 값" 이라는 표시라서, <code>as</code> 로 브랜드를 붙이는 곳을 <strong>생성 함수 한 곳</strong>으로 묶어야 의미가 있어요.</p>
<pre class="code-block" data-lang="typescript"><code>type UserId = string &amp; { readonly __brand: &quot;UserId&quot; };

export function toUserId(raw: string): UserId {
  if (!/^u-\d+$/.test(raw)) throw new Error(`bad user id: ${raw}`);
  return raw as UserId;          // as 는 여기서만
}</code></pre>
<ul><li>API 응답·DB 행·URL 파라미터에서 들어오는 값은 모두 이 함수를 거치게 해요. 파싱 계층(스키마 검증 라이브러리)에서 한 번에 붙이면 빠뜨릴 일이 줄어요.</li><li>코드 곳곳에 <code>"u-1" as UserId</code> 가 보이면 브랜드가 아무것도 보장하지 않는 상태예요. 린트로 <code>as UserId</code> 를 생성 함수 파일 밖에서 막는 팀도 많아요.</li><li>브랜드 타입은 <code>string</code> 의 하위 타입이라 <code>UserId</code> 를 <code>string</code> 자리에 넣는 건 자유로워요. 반대 방향만 막혀요.</li></ul>
<h2 id="s2">브랜드 타입과 래퍼 객체 비용 — ID 100만 개로 잰 숫자</h2>
<p>"타입 하나 추가하려고 객체를 만들면 비싸지 않나" 를 숫자로 확인했어요. <code>"u-0"</code> ~ <code>"u-999999"</code> 문자열 100만 개를 배열에 담되, ① 문자열 그대로(브랜드 타입은 컴파일 뒤 이것과 같아요) ② <code>{ value }</code> 객체 ③ <code>#private</code> 필드 클래스로 감싸서 GC 뒤 힙 증가량과 생성 시간을 쟀어요. 세 번 돌려 중앙값을 썼어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">ID 하나가 차지하는 힙 (배열 칸 포함)</figcaption><svg viewBox="0 0 640 118" role="img" aria-label="ID 하나가 차지하는 힙 (배열 칸 포함)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="156" y="29" text-anchor="end">브랜드(문자열 그대로)</text><rect class="s1" x="166" y="15" width="162.1" height="20" rx="3"><title>브랜드(문자열 그대로) · 32.1바이트</title></rect><text class="viz-val" x="334.1" y="29">32.1바이트</text><text class="viz-ax lg" x="156" y="63" text-anchor="end">{ value } 래퍼</text><rect class="s1" x="166" y="49" width="323.7" height="20" rx="3"><title>{ value } 래퍼 · 64.1바이트</title></rect><text class="viz-val" x="495.7" y="63">64.1바이트</text><text class="viz-ax lg" x="156" y="97" text-anchor="end">#private 클래스</text><rect class="s1" x="166" y="83" width="323.7" height="20" rx="3"><title>#private 클래스 · 64.1바이트</title></rect><text class="viz-val" x="495.7" y="97">64.1바이트</text></svg><p class="viz-note">래퍼는 객체 헤더와 필드 칸만큼 ID 하나에 32바이트씩 더 들어요</p><div class="viz-cap">단위: 바이트 · 자료: Node.js 25.3·Apple M1, ID 100만 개를 만들고 --expose-gc 로 GC 한 뒤 heapUsed 차이를 개수로 나눔 (3회 같은 값)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>ID 하나당 힙</th></tr></thead><tbody><tr><td>브랜드(문자열 그대로)</td><td>32.1</td></tr><tr><td>{ value } 래퍼</td><td>64.1</td></tr><tr><td>#private 클래스</td><td>64.1</td></tr></tbody></table></div></details></figure>
<figure class="viz viz-hbar"><figcaption class="viz-title">ID 100만 개 생성 시간</figcaption><svg viewBox="0 0 640 118" role="img" aria-label="ID 100만 개 생성 시간" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="156" y="29" text-anchor="end">브랜드(문자열 그대로)</text><rect class="s1" x="166" y="15" width="245.6" height="20" rx="3"><title>브랜드(문자열 그대로) · 304ms</title></rect><text class="viz-val" x="417.6" y="29">304ms</text><text class="viz-ax lg" x="156" y="63" text-anchor="end">{ value } 래퍼</text><rect class="s1" x="166" y="49" width="235.1" height="20" rx="3"><title>{ value } 래퍼 · 291ms</title></rect><text class="viz-val" x="407.1" y="63">291ms</text><text class="viz-ax lg" x="156" y="97" text-anchor="end">#private 클래스</text><rect class="s1" x="166" y="83" width="326.4" height="20" rx="3"><title>#private 클래스 · 404ms</title></rect><text class="viz-val" x="498.4" y="97">404ms</text></svg><p class="viz-note">문자열 만드는 비용이 대부분이라 브랜드와 래퍼는 오차 범위, 클래스는 약 1.3배</p><div class="viz-cap">단위: ms · 자료: Node.js 25.3·Apple M1, 3회 중앙값 (문자열 결합 포함)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>생성 시간</th></tr></thead><tbody><tr><td>브랜드(문자열 그대로)</td><td>304</td></tr><tr><td>{ value } 래퍼</td><td>291</td></tr><tr><td>#private 클래스</td><td>404</td></tr></tbody></table></div></details></figure>
<ul><li><strong>메모리</strong>: ID 1,000만 개를 메모리에 들고 있는 서비스라면 래퍼 때문에 약 320MB 가 더 들어요(32바이트 × 1,000만). 요청 하나에서 수백 개 쓰는 수준이면 무시해도 돼요.</li><li><strong>조회</strong>: 같은 ID 로 <code>Map.has</code> 를 100만 번 부르는 시간은 세 방식 모두 464~498ms 로 차이가 없었어요. 문자열 해시가 시간을 대부분 써요.</li><li><strong>직렬화</strong>: 이쪽 차이가 더 커요. <code>JSON.stringify</code> 결과가 브랜드는 <code>"u-1"</code>(5바이트), 래퍼는 <code>{"value":"u-1"}</code>(15바이트)이고, <code>#private</code> 클래스는 <code>{}</code> 로 <strong>값이 통째로 사라져요</strong>. 비공개 필드는 열거되지 않기 때문이에요. 클래스를 고르면 <code>toJSON()</code> 을 꼭 달아야 해요.</li></ul>
<p>컴파일 시간도 확인했어요. 판별 속성이 있는 객체 800종의 유니언을 모양이 같은 다른 800종 유니언에 대입해 봐도 <code>tsc --checkers 1</code> 의 Check time 이 빈 파일(0.84~1.4초)과 구분되지 않았어요. 구조 비교 자체를 성능 이유로 피할 일은 거의 없어요. 검사가 느릴 때 볼 곳은 <a href="https://tool.smithlab.co.kr/posts/developer/typescript-type-check-settings-performance-guide/">타입스크립트 strict 옵션과 tsc 검사 한계 글</a>에 따로 정리했어요.</p>
<h2 id="s3">초과 속성 검사가 꺼지는 자리 — 19가지 직접 확인</h2>
<p>초과 속성 검사는 구조 규칙이 아니라 "바로 넘긴 객체 리터럴에 없는 속성이 있으면 오타일 것" 이라는 보조 검사예요(<a href="https://www.typescriptlang.org/docs/handbook/2/objects.html" target="_blank" rel="noopener">핸드북 Object Types</a>). 그래서 <strong>리터럴과 타입 사이에 무언가 한 단계가 끼면</strong> 꺼져요. <code>interface Pet { name: string }</code> 에 <code>owner</code> 를 더 넣는 식으로 19가지를 돌린 결과예요.</p>
<div class="table-wrap"><table><thead><tr><th>쓰는 방식</th><th>7.0.2 결과</th></tr></thead><tbody><tr><td><code>cfg({ colour: "red" })</code> 처럼 인수에 바로</td><td>잡힘 (TS2561, "Did you mean 'color'?" 까지 알려 줌)</td></tr><tr><td><code>const p: Pet = {...}</code>, 배열 원소, 삼항의 양쪽</td><td>잡힘</td></tr><tr><td>반환 타입을 적은 함수의 <code>return</code>, <code>async</code> 함수의 <code>Promise&lt;Pet&gt;</code> 반환</td><td>잡힘</td></tr><tr><td><code>{...} satisfies Pet</code></td><td>잡힘</td></tr><tr><td>판별 속성(<code>kind</code>)이 있는 유니언</td><td>잡힘</td></tr><tr><td><code>{ ...base, age: 3 }</code> 에서 직접 적은 <code>age</code></td><td>잡힘 (<code>base</code> 안의 속성은 안 봄)</td></tr><tr><td>모든 속성이 선택적인 타입에 공통 속성 0개인 변수</td><td>잡힘 (TS2559, 약한 타입 검사)</td></tr><tr><td><code>map((n): Pet =&gt; ({...}))</code> 처럼 콜백 반환 타입을 적음</td><td>잡힘</td></tr><tr><td>변수에 담았다가 넘김 (공통 속성이 하나라도 있음)</td><td>통과</td></tr><tr><td><code>{...} as Pet</code></td><td>통과</td></tr><tr><td><code>{ ...base }</code> 펼침만</td><td>통과</td></tr><tr><td><code>const a: Pet[] = list.map(n =&gt; ({...}))</code></td><td>통과</td></tr><tr><td><code>setP(p =&gt; ({ ...p, owner: "x" }))</code> 처럼 문맥 타입이 있는 콜백 반환</td><td>통과</td></tr><tr><td>판별 속성 없는 유니언 `{a} \</td><td>{b}<code> 에 </code>{a, b}`</td><td>통과</td></tr><tr><td><code>Object.assign({ name }, { owner })</code></td><td>통과</td></tr><tr><td><code>id&lt;T&gt;(x: T): T</code> 같은 제네릭 함수를 거침</td><td>통과</td></tr><tr><td><code>Promise.resolve({...})</code> 를 <code>Promise&lt;Pet&gt;</code> 에</td><td>통과</td></tr></tbody></table></div>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/typescript-structural-typing-brand-excess-property-inference-guide-2.svg" alt="초과 속성 검사가 잡는 10가지 자리와 조용히 통과하는 9가지 자리를 두 카드로 나눠 비교한 그림" width="800" height="470" loading="lazy" decoding="async"><figcaption>타입이 자리에 바로 붙어 있으면 잡히고, 중간에 추론이 끼면 꺼져요</figcaption></figure>
<p>실무에서 사고가 잦은 건 <strong>콜백 반환</strong>이에요. 리액트 상태 갱신 <code>setUser(u =&gt; ({ ...u, nmae: "새 이름" }))</code> 같은 코드는 오타가 그대로 들어가고, 원래 <code>name</code> 은 바뀌지 않아요. 대처는 셋 중 하나예요.</p>
<ol><li>콜백에 반환 타입을 적어요: <code>setUser((u): User =&gt; ({ ...u, name }))</code>.</li><li>리터럴을 먼저 <code>satisfies</code> 로 검사받고 넘겨요.</li><li>설정 객체처럼 한 번 만들어 여러 곳에 넘기는 값은 <strong>선언하는 줄에</strong> 타입 주석이나 <code>satisfies</code> 를 붙여요. 주석·<code>satisfies</code>·<code>as const</code> 중 무엇을 고를지는 앞의 <a href="https://tool.smithlab.co.kr/posts/developer/typescript-type-check-settings-performance-guide/">strict 옵션 글</a> 판단표를 보면 돼요.</li></ol>
<p>판별 속성 없는 유니언이 통과하는 것도 기억해 두세요. <code>{ a } | { b }</code> 는 "둘 중 하나" 가 아니라 "적어도 하나" 처럼 동작해요. 정말 하나만 허용하려면 <code>{ a: number; b?: never } | { b: number; a?: never }</code> 처럼 반대쪽을 <code>never</code> 로 막거나 <code>kind</code> 판별 속성을 두세요.</p>
<h2 id="s4">제네릭 추론이 엉뚱할 때 고르는 수단</h2>
<p>제네릭 함수는 인수마다 <code>T</code> 후보를 모아 하나로 합쳐요(<a href="https://www.typescriptlang.org/docs/handbook/2/generics.html" target="_blank" rel="noopener">핸드북 Generics</a>). 그래서 "추론에 쓰이면 안 되는 인수" 가 후보로 끼거나, 원하는 것보다 넓게 합쳐지는 일이 생겨요. 증상별로 고를 수단이에요.</p>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>예</th><th>고를 수단</th><th>7.0.2 결과</th></tr></thead><tbody><tr><td>기본값·대조값 인수가 <code>T</code> 를 넓힘</td><td><code>withDefault(["sm","md"], "lg")</code> 가 통과</td><td>그 매개변수를 <code>NoInfer&lt;T&gt;</code> 로 (5.4~)</td><td><code>"lg"</code> 에 TS2345</td></tr><tr><td>배열·튜플 인수가 <code>string[]</code> 로 넓혀짐</td><td><code>routes(["/a","/b"])</code> → <code>string[]</code></td><td><code>&lt;const T extends readonly string[]&gt;</code> (5.0~)</td><td><code>readonly ["/a", "/b"]</code></td></tr><tr><td>서로 다른 타입 인수를 같이 받아야 함</td><td><code>pick("a", 1)</code> 이 <code>"a"</code> 로 굳어 에러</td><td>호출에 타입 인자 명시 `pick&lt;string \</td><td>number&gt;`</td><td>통과</td></tr><tr><td>결과를 <code>let</code> 에 담았더니 리터럴이 사라짐</td><td><code>let b = pick("a","b")</code> → <code>string</code></td><td>의도한 것. 리터럴이 필요하면 <code>const</code> 나 주석</td><td>—</td></tr><tr><td>콜백 매개변수가 <code>any</code>/<code>unknown</code></td><td>콜백이 다른 인수보다 앞에 있음</td><td>추론 재료가 되는 인수를 앞으로, 콜백은 뒤로</td><td>—</td></tr></tbody></table></div>
<p><code>NoInfer</code> 는 <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-4.html" target="_blank" rel="noopener">TypeScript 5.4 릴리스 노트</a>, <code>const</code> 타입 매개변수는 <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html" target="_blank" rel="noopener">TypeScript 5.0 릴리스 노트</a>에 나와 있어요. 둘 다 함수 <strong>선언</strong> 쪽에서 고치는 수단이라, 라이브러리 API 를 만드는 사람이 쓰기 좋아요. 호출하는 쪽에서는 타입 인자 명시가 가장 확실해요.</p>
<p>에러 메시지 읽는 요령도 하나 있어요. <code>Argument of type '1' is not assignable to parameter of type '"a"'</code> 처럼 <strong>매개변수 타입이 리터럴 하나로 찍혀 있으면</strong> 대개 앞 인수가 <code>T</code> 를 먼저 정해 버린 경우예요. 뒤 인수가 틀렸다고 보기보다 <code>T</code> 가 무엇으로 굳었는지부터 보세요.</p>
<h2 id="s5">콜백 타입은 속성 문법으로 — 메서드 문법의 구멍</h2>
<p><code>strictFunctionTypes</code> 를 켜도 <strong>메서드 문법</strong>으로 쓴 타입은 매개변수를 양방향으로 허용해요(<a href="https://www.typescriptlang.org/tsconfig/strictFunctionTypes.html" target="_blank" rel="noopener">TSConfig strictFunctionTypes</a>). 7.0.2 에서 확인한 결과예요.</p>
<pre class="code-block" data-lang="typescript"><code>type Handler  = { on: (e: string | number) =&gt; void };  // 속성 문법
type Handler2 = { on(e: string | number): void };       // 메서드 문법
const onlyText = (e: string) =&gt; e.trim();

const h1: Handler  = { on: onlyText };  // TS2322 로 막힘
const h2: Handler2 = { on: onlyText };  // 통과 → 숫자가 오면 런타임 TypeError</code></pre>
<p>이벤트 핸들러·플러그인 훅처럼 <strong>밖에서 함수를 받아 오는 자리</strong>는 속성 문법으로 쓰는 게 안전해요. 팀 전체에 강제하려면 typescript-eslint 의 <a href="https://typescript-eslint.io/rules/method-signature-style/" target="_blank" rel="noopener">method-signature-style 규칙</a>을 <code>property</code> 로 켜면 돼요. 이 규칙은 자동 수정도 돼요. 다만 클래스가 구현하는 인터페이스를 바꾸면 기존 구현이 새로 에러가 날 수 있으니, 에러 수를 먼저 세 보고 켜세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">운영에서 자주 나는 문제와 확인 순서</h2>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>먼저 볼 것</th><th>고치는 법</th></tr></thead><tbody><tr><td><code>Types have separate declarations of a private property</code></td><td><code>npm ls &lt;패키지&gt;</code> 로 같은 라이브러리가 <strong>다른 버전</strong>으로 두 벌인지</td><td>버전 맞추기, <code>overrides</code>/<code>resolutions</code> 로 하나로</td></tr><tr><td>설정 오타가 배포 뒤에야 발견됨</td><td>설정 객체 선언 줄에 타입이 붙었는지</td><td>선언 줄에 <code>satisfies Config</code></td></tr><tr><td>상태 갱신이 반영 안 됨</td><td><code>setX(p =&gt; ({...p, ...}))</code> 안의 속성 이름</td><td>콜백 반환 타입 명시</td></tr><tr><td>서로 다른 ID 가 함수 인수에서 섞임</td><td>그 ID 가 <code>type X = string</code> 별칭인지</td><td>브랜드 타입 + 생성 함수</td></tr><tr><td><code>Box&lt;string&gt;</code> 자리에 <code>Box&lt;number&gt;</code> 가 들어감</td><td><code>Box</code> 정의 안에서 <code>T</code> 를 실제로 쓰는지</td><td>쓰지 않는 타입 매개변수 제거, 또는 멤버에 사용</td></tr><tr><td>클래스 객체를 API 로 보냈더니 <code>{}</code></td><td><code>#private</code> 필드만 있는지</td><td><code>toJSON()</code> 추가, 또는 브랜드 타입으로</td></tr></tbody></table></div>
<p>첫 줄은 직접 재현해 봤어요. <code>node_modules/lib</code> 와 <code>node_modules/plugin/node_modules/lib</code> 에 <code>private</code> 필드가 있는 같은 <code>Client</code> 클래스 선언을 두고, <code>plugin</code> 이 돌려준 <code>Client</code> 를 최상위 <code>Client</code> 에 대입했어요. <strong>두 <code>package.json</code> 의 이름·버전이 같으면 <code>tsc</code> 가 하나로 합쳐 에러가 없었고</strong>, 안쪽 버전만 1.1.0 으로 바꾸자 위 에러가 났어요. 그러니 이 에러를 보면 코드보다 잠금 파일부터 보는 게 빨라요. <code>private</code> 멤버가 없는 클래스라면 버전이 달라도 모양만 맞으면 통과해서, 더 늦게 런타임에서 드러날 수도 있어요.</p>
<p>다섯째 줄은 핸드북의 <code>Empty&lt;T&gt;</code> 예와 같은 원리예요. 타입 매개변수는 구조에 나타날 때만 비교에 쓰여요. "단위를 타입 인자로 구분하자" 며 <code>Money&lt;"KRW"&gt;</code> 를 만들었다면 그 인자가 <code>currency: C</code> 같은 멤버에 실제로 들어가 있는지 확인하세요.</p>
<h2 id="s7">도입 전에 따질 것</h2>
<p>브랜드 타입이나 클래스 기반 값 객체를 기존 코드에 들일 때 확인할 것들이에요.</p>
<ul><li><strong>경계가 어디인가</strong> — 값이 들어오는 곳(HTTP 핸들러, DB 매퍼, 큐 소비자)을 목록으로 뽑아 생성 함수를 붙일 자리를 먼저 정해요. 경계가 많을수록 <code>as</code> 가 퍼지기 쉬워요.</li><li><strong>직렬화 모양을 바꿔도 되나</strong> — 클라이언트·다른 서비스가 <code>"u-1"</code> 을 기대하면 래퍼·클래스는 쓰면 안 돼요. 브랜드는 바깥 모양이 그대로예요. JSON 샘플에서 타입을 뽑을 때는 <a href="https://tool.smithlab.co.kr/developer/json-to-typescript/">JSON → TypeScript 변환기</a>로 먼저 <code>string</code> 인터페이스를 만든 뒤 ID 필드만 브랜드로 바꾸면 손이 덜 가요.</li><li><strong>라이브러리 공개 API 인가</strong> — 공개 타입에 <code>__brand</code> 같은 평범한 속성 이름을 쓰면 다른 라이브러리 브랜드와 이름이 겹칠 수 있어요. <code>declare const brand: unique symbol</code> 을 키로 쓰면 겹치지 않아요.</li><li><strong>테스트 픽스처</strong> — 테스트 코드에서 <code>"u-1" as UserId</code> 가 대량으로 생기기 쉬워요. 픽스처용 생성 함수(<code>userId(1)</code>)를 따로 두면 나중에 형식이 바뀌어도 한 곳만 고쳐요.</li><li><strong>점진 도입</strong> — 한 번에 바꾸지 말고 ID 종류 하나(예: 결제 ID)부터 바꿔 에러 수를 세 보세요. 에러가 많이 나는 곳이 실제로 ID 가 섞여 흐르던 곳이에요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>브랜드 타입을 쓰면 런타임 성능이 떨어지나요?</h3>
<p>아니요. 브랜드는 타입에만 있고 컴파일 뒤에는 그냥 문자열이에요. ID 100만 개로 재 보니 힙이 ID 하나에 32.1바이트로 문자열과 같았어요. 비용이 생기는 건 <code>{ value }</code> 래퍼나 클래스로 감쌀 때이고, 그때는 하나에 32바이트씩 더 들었어요.</p>
<h3><code>private</code> 키워드와 <code>#private</code> 중 무엇으로 명목적 타입을 만들어야 하나요?</h3>
<p>타입 구분만 보면 둘 다 막혀요. 차이는 런타임이에요. <code>#private</code> 는 진짜로 숨겨져 <code>JSON.stringify</code> 에서 사라지고, <code>private</code> 키워드는 타입에서만 숨겨져 JSON 에 그대로 나가요. 외부로 내보내는 값이면 어느 쪽이든 <code>toJSON()</code> 을 직접 정하는 게 안전해요.</p>
<h3>초과 속성 검사를 변수에도 항상 적용하는 옵션이 있나요?</h3>
<p><code>tsconfig</code> 에 그런 옵션은 없어요. 변수 선언 줄에 타입 주석이나 <code>satisfies</code> 를 붙이는 게 사실상 유일한 방법이에요. 콜백 반환처럼 자주 놓치는 자리는 반환 타입을 적는 습관으로 막아요.</p>
<h3><code>NoInfer</code> 를 쓸 수 없는 낮은 버전에서는 어떻게 하나요?</h3>
<p>5.4 전에는 <code>&lt;C extends string, D extends C&gt;</code> 처럼 기존 매개변수에 묶인 타입 매개변수를 하나 더 두는 우회를 썼어요. 5.4 릴리스 노트도 이 방식을 소개하면서, 서명에 한 번만 쓰이는 타입 매개변수라 어색하다고 적어요. 가능하면 5.4 이상으로 올리고 <code>NoInfer</code> 를 쓰는 게 나아요. 현재 npm <code>latest</code> 는 7.0.2 예요.</p>
<h3>판별 유니언에서 초과 속성이 잡히는데 일반 유니언에서는 안 잡히는 이유가 뭔가요?</h3>
<p>판별 속성(<code>kind: "c"</code>)이 있으면 검사기가 리터럴이 어느 멤버를 노린 것인지 하나로 고를 수 있어서 그 멤버 기준으로 초과 속성을 봐요. 판별 속성이 없으면 고를 기준이 없어 유니언 전체의 속성 이름을 다 허용해요. 하나만 허용해야 하는 옵션 객체라면 판별 속성을 두세요.</p>
<h2 id="s9">정리</h2>
<ul><li>섞이면 버그인 값은 브랜드 타입 + 생성 함수 한 곳이 기본이에요. 메모리·직렬화 비용이 0이고, 클래스는 동작이 많을 때만 골라요.</li><li>초과 속성 검사는 19가지 중 10가지에서만 걸려요. 변수·<code>as</code>·펼침·콜백 반환·제네릭 경유는 통과하니, 선언 줄 <code>satisfies</code> 와 콜백 반환 타입으로 막아요.</li><li>제네릭이 엉뚱하게 넓어지면 <code>NoInfer</code>·<code>const</code> 타입 매개변수·타입 인자 명시 순서로 봐요. 매개변수 타입이 리터럴 하나로 찍힌 에러는 앞 인수가 <code>T</code> 를 정해 버린 신호예요.</li><li>"separate declarations of a private property" 는 코드보다 잠금 파일의 중복 버전부터 확인해요.</li></ul>
<p>타입 좁히기 쪽 판단은 <a href="https://tool.smithlab.co.kr/posts/developer/typescript-type-narrowing-guard-selection-troubleshooting-guide/">타입스크립트 타입 좁히기 고르는 법</a>에서 이어서 볼 수 있어요.</p>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/typescript-structural-typing-type-inference" target="_blank" rel="noopener">타입스크립트 구조적 타이핑·타입 추론 원리 — 대입 판정과 넓히기</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>가습기 물통이 하루도 못 가는 이유 — 20㎡ 거실 습도 유지에 하루…</title>
      <link>https://tool.smithlab.co.kr/posts/life/humidifier-water-amount-ventilation-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/humidifier-water-amount-ventilation-guide/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>가습기 3리터 물통이 하루도 못 가는 건 대부분 정상이에요. <strong>20㎡(약 6평) 거실의 습도를 30%에서 50%로 올리는 데 공기가 새로 머금는 물은 약 159mL, 컵 하나 분량</strong>이지만, 바깥이 0℃이고 환기가 시간당 0.5회인 집이라면 그 습도를 <strong>유지하는 데는 하루 약 3.4L</strong>가 필요해요. 물을 많이 먹는 건 습도를 올리는 일이 아니라 환기로 빠져나가는 수증기를 계속 채우는 일이에요.</p>
<p>이 글에는 방 크기·바깥 날씨·환기·실내 온도별 필요량 표와 함께, <strong>내 가습기 분무량으로 도달할 수 있는 최고 습도표</strong>와 <strong>물통이 비는 시간으로 우리 집 환기 횟수를 거꾸로 구하는 법</strong>을 직접 계산해 넣었어요. 바로 계산해 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/humidifier-water-calculator/">가습기 물 얼마나 필요할까 계산기</a>에 방 넓이와 날씨만 넣으면 돼요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>공기 1㎥가 머금는 수증기는 20℃ 습도 50%에서 8.62g, 30%에서 5.17g이에요. 20㎡ 거실(46㎥)을 30→50%로 올리는 데는 약 159mL면 돼요.</li><li>바깥이 0℃·습도 50%면 바깥 공기는 2.42g/㎥뿐이에요. 환기 0.5회/시간이면 시간당 약 143mL, 하루 약 3.4L를 채워야 습도가 유지돼요.</li><li>바깥이 -10℃로 춥거나 환기가 2배가 되면 필요한 물도 비례해서 늘어요. 환기 횟수는 정확히 비례해요.</li><li>실내 20℃에서 습도 50%의 이슬점은 9.3℃예요. 이보다 찬 유리와 벽에는 물방울이 맺히니 결로가 심하면 40% 안팎으로 낮추세요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">공기가 머금는 물의 양은 온도로 정해져요</h2>
<p>공기 1㎥가 머금을 수 있는 최대 수증기는 0℃에서 약 4.8g, 20℃에서 약 17.2g이에요. 상대습도는 이 최대치에 대한 비율이라 같은 50%여도 온도에 따라 물의 양이 달라요.</p>
<p>계산에는 Magnus 근사식을 썼어요. 포화수증기압은 es = 6.112 × exp(17.62 × T ÷ (243.12 + T)) hPa이고, 공기 1㎥에 든 수증기(절대습도)는 216.7 × (습도 ÷ 100) × es ÷ (273.15 + T) g이에요. 20℃·100%에서 17.24g, 0℃·100%에서 4.85g이 나와서 흔히 알려진 값과 맞아요.</p>
<div class="table-wrap"><table><thead><tr><th>온도</th><th>습도 100%</th><th>습도 50%</th><th>습도 30%</th></tr></thead><tbody><tr><td>-10℃</td><td>2.36g</td><td>1.18g</td><td>0.71g</td></tr><tr><td>-5℃</td><td>3.41g</td><td>1.71g</td><td>1.02g</td></tr><tr><td>0℃</td><td>4.85g</td><td>2.42g</td><td>1.45g</td></tr><tr><td>5℃</td><td>6.79g</td><td>3.40g</td><td>2.04g</td></tr><tr><td>10℃</td><td>9.38g</td><td>4.69g</td><td>2.81g</td></tr><tr><td>20℃</td><td>17.24g</td><td>8.62g</td><td>5.17g</td></tr></tbody></table></div>
<p>표에서 눈에 띄는 건 바깥 공기예요. 0℃ 공기가 습도 100%여도 수증기는 4.85g/㎥이에요. 이 공기가 환기로 집에 들어와 20℃로 데워지면 같은 수증기가 17.2g까지 들어갈 수 있는 공기에 4.8g만 든 셈이라 습도가 약 28%로 떨어져요. 겨울 실내가 건조한 이유예요.</p>
<h2 id="s2">습도를 올리는 데는 물이 많이 들지 않아요</h2>
<p>방 부피는 면적 × 천장 높이예요. 천장이 2.3m인 20㎡ 거실은 46㎥예요. 실내 20℃에서 습도를 30%에서 50%로 올리면 공기 1㎥당 8.62 − 5.17 = 3.45g이 늘어나요. 46㎥를 곱하면 159g, 물로는 약 159mL예요.</p>
<ul><li>10㎡ 방: 약 79mL</li><li>20㎡ 거실: 약 159mL</li><li>33㎡ 거실: 약 262mL</li></ul>
<p>300mL/시간 분무량의 가습기라면 20㎡ 거실을 올리는 데 이론상 32분이면 돼요. 그런데 현실에서는 그보다 훨씬 오래 걸려요. 벽지, 이불, 가구, 옷이 수증기를 빨아들이기 때문이에요. 이 값은 "공기만 채울 때의 하한"이고, 실제로는 몇 배가 든다고 보세요.</p>
<h2 id="s3">진짜 큰 물 소비는 환기로 빠지는 수증기예요</h2>
<p>집은 완전히 밀폐되어 있지 않아요. 창틀 틈, 문틈, 환풍기, 문을 여닫는 일로 공기가 계속 바뀌어요. 실내 공기가 한 시간에 몇 번 바깥 공기로 바뀌는지를 환기 횟수라고 해요. 새 단열 집은 0.2회 안팎, 보통 집은 0.5회, 틈이 많은 집은 1회 이상으로 어림해요.</p>
<p>바깥 공기가 들어오면 실내 목표 습도의 수증기와 바깥 공기 수증기의 차이만큼 매번 채워야 해요. 20㎡ 거실 기준으로 계산하면 이렇게 돼요.</p>
<ul><li>실내 목표(20℃·50%) 8.62g/㎥ − 바깥(0℃·50%) 2.42g/㎥ = 6.20g/㎥</li><li>시간당 6.20 × 46㎥ × 0.5회 = 약 143g, 즉 143mL</li><li>하루 24시간이면 약 3.4L</li></ul>
<p>그래서 3L 물통은 21.0시간밖에 못 버텨요. 밤새 켜 두면 새벽에 물이 떨어지고, 그 순간부터 습도가 빠르게 내려가요. 처음 올리는 데 필요한 159mL와 비교하면 유지 비용이 22배 가까이 크다는 것을 알 수 있어요.</p>
<h2 id="s4">방이 크면 물도 비례해서 늘어요</h2>
<p>환경은 같고 방 크기만 바꾸면 필요한 물은 부피에 정비례해요. 실내 20℃ 30→50%, 바깥 0℃ 50%, 환기 0.5회 기준이에요.</p>
<div class="table-wrap"><table><thead><tr><th>방 넓이</th><th>방 부피(천장 2.3m)</th><th>처음 올릴 물</th><th>시간당 유지</th><th>하루 유지</th></tr></thead><tbody><tr><td>10㎡</td><td>23.0㎥</td><td>79mL</td><td>71mL</td><td>1.7L</td></tr><tr><td>15㎡</td><td>34.5㎥</td><td>119mL</td><td>107mL</td><td>2.6L</td></tr><tr><td>20㎡</td><td>46.0㎥</td><td>159mL</td><td>143mL</td><td>3.4L</td></tr><tr><td>30㎡</td><td>69.0㎥</td><td>238mL</td><td>214mL</td><td>5.1L</td></tr><tr><td>40㎡</td><td>92.0㎥</td><td>317mL</td><td>285mL</td><td>6.8L</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">방 넓이별 하루 가습 유지에 필요한 물</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="방 넓이별 하루 가습 유지에 필요한 물" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">6</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">8</text><rect class="s1" x="69.3" y="203.8" width="80.6" height="50.2" rx="3"><title>10㎡ · 하루 필요한 물 1.7L</title></rect><text class="viz-val" x="109.6" y="198.8" text-anchor="middle">1.7</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">10㎡</text><rect class="s1" x="184.5" y="177.3" width="80.6" height="76.7" rx="3"><title>15㎡ · 하루 필요한 물 2.6L</title></rect><text class="viz-val" x="224.8" y="172.3" text-anchor="middle">2.6</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">15㎡</text><rect class="s1" x="299.7" y="153.7" width="80.6" height="100.3" rx="3"><title>20㎡ · 하루 필요한 물 3.4L</title></rect><text class="viz-val" x="340.0" y="148.7" text-anchor="middle">3.4</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">20㎡</text><rect class="s1" x="414.9" y="103.6" width="80.6" height="150.4" rx="3"><title>30㎡ · 하루 필요한 물 5.1L</title></rect><text class="viz-val" x="455.2" y="98.6" text-anchor="middle">5.1</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">30㎡</text><rect class="s1" x="530.1" y="53.4" width="80.6" height="200.6" rx="3"><title>40㎡ · 하루 필요한 물 6.8L</title></rect><text class="viz-val" x="570.4" y="48.4" text-anchor="middle">6.8</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">40㎡</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">방 부피에 정비례해요. 여러 방을 열어 두면 부피를 합쳐서 보세요.</p><div class="viz-cap">단위: L · 자료: 실내 20℃ 30→50%, 바깥 0℃ 50%, 환기 0.5회로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>하루 필요한 물</th></tr></thead><tbody><tr><td>10㎡</td><td>1.7</td></tr><tr><td>15㎡</td><td>2.6</td></tr><tr><td>20㎡</td><td>3.4</td></tr><tr><td>30㎡</td><td>5.1</td></tr><tr><td>40㎡</td><td>6.8</td></tr></tbody></table></div></details></figure>
<p>방문을 닫은 침실 10㎡는 하루 약 1.7L라서 3L 물통 하나면 충분하지만, 거실과 방을 연결해 쓰면 부피가 늘어 하루 5.6L(33㎡)로 올라가요.</p>
<h2 id="s5">바깥이 추울수록 더 많이 필요해요</h2>
<p>바깥 공기의 수증기가 적을수록 환기로 들어오는 공기와의 차이가 커져요. 20㎡ 거실, 실내 20℃ 50%, 환기 0.5회 기준이에요.</p>
<div class="table-wrap"><table><thead><tr><th>바깥 기온·습도</th><th>바깥 공기 수증기</th><th>시간당 유지</th><th>하루 유지</th></tr></thead><tbody><tr><td>-10℃ · 40%</td><td>0.95g</td><td>177mL</td><td>4.2L</td></tr><tr><td>-5℃ · 50%</td><td>1.71g</td><td>159mL</td><td>3.8L</td></tr><tr><td>0℃ · 50%</td><td>2.42g</td><td>143mL</td><td>3.4L</td></tr><tr><td>5℃ · 60%</td><td>4.07g</td><td>105mL</td><td>2.5L</td></tr><tr><td>10℃ · 70%</td><td>6.57g</td><td>47mL</td><td>1.1L</td></tr></tbody></table></div>
<figure class="viz viz-line"><figcaption class="viz-title">바깥 날씨별 하루 가습에 필요한 물</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="바깥 날씨별 하루 가습에 필요한 물" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="570" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="570" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">3</text><line class="viz-grid" x1="52" x2="570" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">5</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">-10℃ 40%</text><text class="viz-ax" x="181.5" y="272" text-anchor="middle">-5℃ 50%</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">0℃ 50%</text><text class="viz-ax" x="440.5" y="272" text-anchor="middle">5℃ 60%</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">10℃ 70%</text><polyline class="viz-line s1" points="52.0,55.8 181.5,74.6 311.0,93.5 440.5,136.0 570.0,202.1"/><circle class="viz-dot s1" cx="52.0" cy="55.8" r="4"><title>-10℃ 40% · 하루 필요한 물 4.2L</title></circle><circle class="viz-dot s1" cx="181.5" cy="74.6" r="4"><title>-5℃ 50% · 하루 필요한 물 3.8L</title></circle><circle class="viz-dot s1" cx="311.0" cy="93.5" r="4"><title>0℃ 50% · 하루 필요한 물 3.4L</title></circle><circle class="viz-dot s1" cx="440.5" cy="136.0" r="4"><title>5℃ 60% · 하루 필요한 물 2.5L</title></circle><circle class="viz-dot s1" cx="570.0" cy="202.1" r="4"><title>10℃ 70% · 하루 필요한 물 1.1L</title></circle><text class="viz-val" x="578.0" y="206.1">1.1</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">한파일수록 가습기 물이 빨리 줄어요. 10℃ 안팎까지 오면 필요량이 크게 줄어요.</p><div class="viz-cap">단위: L · 자료: 20㎡ 거실, 실내 20℃ 30→50%, 환기 0.5회로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>하루 필요한 물</th></tr></thead><tbody><tr><td>-10℃ 40%</td><td>4.2</td></tr><tr><td>-5℃ 50%</td><td>3.8</td></tr><tr><td>0℃ 50%</td><td>3.4</td></tr><tr><td>5℃ 60%</td><td>2.5</td></tr><tr><td>10℃ 70%</td><td>1.1</td></tr></tbody></table></div></details></figure>
<p>겨울 중에서도 한파 때 가습기 물이 평소보다 빨리 닳는 이유를 이 표로 설명할 수 있어요. 반대로 초겨울이나 봄에는 바깥 공기가 촉촉해서 같은 집에서도 하루 1.1L까지 줄어요.</p>
<h2 id="s6">환기 횟수가 두 배가 되면 물도 두 배예요</h2>
<p>환기는 습도 유지에서 가장 큰 변수예요. 20㎡ 거실, 바깥 0℃ 50% 기준으로 비교해 볼게요.</p>
<div class="table-wrap"><table><thead><tr><th>환기 횟수(시간당)</th><th>시간당 유지</th><th>하루 유지</th></tr></thead><tbody><tr><td>0.2회</td><td>57mL</td><td>1.4L</td></tr><tr><td>0.5회</td><td>143mL</td><td>3.4L</td></tr><tr><td>1회</td><td>285mL</td><td>6.8L</td></tr><tr><td>2회</td><td>570mL</td><td>13.7L</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">환기 횟수별 하루 가습 유지에 필요한 물</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="환기 횟수별 하루 가습 유지에 필요한 물" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">2.5</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">7.5</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">12.5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">15</text><rect class="s1" x="73.6" y="232.0" width="100.8" height="22.0" rx="3"><title>0.2회 · 하루 필요한 물 1.4L</title></rect><text class="viz-val" x="124.0" y="227.0" text-anchor="middle">1.4</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">0.2회</text><rect class="s1" x="217.6" y="200.5" width="100.8" height="53.5" rx="3"><title>0.5회 · 하루 필요한 물 3.4L</title></rect><text class="viz-val" x="268.0" y="195.5" text-anchor="middle">3.4</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">0.5회</text><rect class="s1" x="361.6" y="147.0" width="100.8" height="107.0" rx="3"><title>1회 · 하루 필요한 물 6.8L</title></rect><text class="viz-val" x="412.0" y="142.0" text-anchor="middle">6.8</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">1회</text><rect class="s1" x="505.6" y="38.5" width="100.8" height="215.5" rx="3"><title>2회 · 하루 필요한 물 13.7L</title></rect><text class="viz-val" x="556.0" y="33.5" text-anchor="middle">13.7</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">2회</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">환기 횟수에 정확히 비례해요.</p><div class="viz-cap">단위: L · 자료: 20㎡ 거실, 실내 20℃ 30→50%, 바깥 0℃ 50%로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>하루 필요한 물</th></tr></thead><tbody><tr><td>0.2회</td><td>1.4</td></tr><tr><td>0.5회</td><td>3.4</td></tr><tr><td>1회</td><td>6.8</td></tr><tr><td>2회</td><td>13.7</td></tr></tbody></table></div></details></figure>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/humidifier-water-amount-ventilation-guide-1.svg" alt="환기로 빠지는 수증기와 가습기가 채우는 물의 균형을 보여 주는 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>환기로 빠지는 물을 가습기가 채워야 습도가 유지돼요</figcaption></figure>
<p>문틈과 창틀 틈새를 막으면 난방비도 줄고 가습에 드는 물도 줄어요. 단, 환기를 아예 끊으라는 뜻은 아니에요. 요리, 숨쉬기, 가전에서 나온 오염물질과 이산화탄소를 내보내려면 하루 몇 번 짧게 창문을 열어야 해요. 환기 직후 습도가 일시적으로 떨어졌다가 다시 오르는 건 자연스러워요.</p>
<h2 id="s7">실내 온도를 낮추면 물이 덜 들어요</h2>
<p>같은 습도 50%여도 실내가 따뜻할수록 공기 중 수증기량이 늘어서 더 많은 물이 필요해요. 20㎡ 거실, 바깥 0℃ 50%, 환기 0.5회에서 실내 온도만 바꿔 봤어요(실내 습도 50%).</p>
<div class="table-wrap"><table><thead><tr><th>실내 온도</th><th>습도 50%일 때 수증기</th><th>시간당 유지</th><th>하루 유지</th></tr></thead><tbody><tr><td>18℃</td><td>7.66g</td><td>120mL</td><td>2.9L</td></tr><tr><td>20℃</td><td>8.62g</td><td>143mL</td><td>3.4L</td></tr><tr><td>22℃</td><td>9.68g</td><td>167mL</td><td>4.0L</td></tr><tr><td>24℃</td><td>10.85g</td><td>194mL</td><td>4.7L</td></tr></tbody></table></div>
<p>실내 온도를 24℃에서 20℃로 낮추면 하루 필요한 물이 약 1.2L 줄어요. 난방비도 함께 줄어드니 일석이조예요. 난방비 계산은 <a href="https://tool.smithlab.co.kr/life/heating-cost-calculator/">난방비 계산기</a>로 해 볼 수 있어요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s8">습도를 높일수록 결로 위험도 같이 올라가요</h2>
<p>실내 20℃·50%의 이슬점은 9.3℃라서, 이보다 찬 창 유리와 외벽 모서리에는 물방울이 맺혀요. 목표 습도는 '가장 차가운 창 표면 온도'로 정하는 게 맞고, 표면 온도별 가습 상한표는 <a href="https://tool.smithlab.co.kr/posts/life/window-condensation-humidity-dew-point-guide/">창문 결로 습도 글</a>에 따로 계산해 두었어요. 오래 젖은 곳은 곰팡이로 이어지니(<a href="https://www.cdc.gov/mold-health/" target="_blank" rel="noopener">미국 CDC의 곰팡이 안내</a>), 결로가 보이면 목표를 40% 안팎으로 낮추세요. 우리 집 숫자는 <a href="https://tool.smithlab.co.kr/life/dew-point-condensation-calculator/">이슬점·결로 계산기</a>로 확인할 수 있고, 이슬점 개념은 <a href="https://en.wikipedia.org/wiki/Dew_point" target="_blank" rel="noopener">Dew point (Wikipedia)</a>에 정리돼 있어요.</p>
<h2 id="s9">판단 기준: 내 가습기로 도달할 수 있는 최고 습도</h2>
<p>가습기 분무량이 시간당 유지량보다 작으면 하루 종일 켜도 목표 습도에 못 가요. 이때 습도는 '바깥 공기 수증기 + 분무량 ÷ (방 부피 × 환기 횟수)'에서 멈춰요. 실내 20℃, 바깥 0℃·50%, 천장 2.3m 기준으로 직접 계산한 최고 습도예요.</p>
<div class="table-wrap"><table><thead><tr><th>분무량(시간당)</th><th>20㎡·환기 0.5회</th><th>20㎡·환기 1회</th><th>33㎡·환기 0.5회</th><th>33㎡·환기 1회</th></tr></thead><tbody><tr><td>100mL</td><td>39%</td><td>27%</td><td>29%</td><td>22%</td></tr><tr><td>150mL</td><td>52%</td><td>33%</td><td>37%</td><td>26%</td></tr><tr><td>200mL</td><td>64%</td><td>39%</td><td>45%</td><td>29%</td></tr><tr><td>300mL</td><td>90%</td><td>52%</td><td>60%</td><td>37%</td></tr><tr><td>400mL</td><td>100% 이상</td><td>64%</td><td>75%</td><td>45%</td></tr></tbody></table></div>
<ul><li><strong>표의 값이 목표(예: 50%)보다 높으면</strong> 한 대로 충분하고, 세기를 낮춰 써도 돼요.</li><li><strong>목표보다 낮으면</strong> 대수를 늘리거나, 문을 닫아 부피를 줄이거나, 틈을 막아 환기를 줄여야 해요. 33㎡·환기 1회 집은 300mL/시간 한 대로 37%가 한계예요.</li><li>바깥이 -10℃로 내려가면 바깥 공기 수증기가 줄어 표의 값이 몇 %p 더 낮아져요.</li></ul>
<h2 id="s10">우리 집 환기 횟수를 물통으로 역산하는 법</h2>
<p>환기 횟수는 집마다 달라서 어림값(0.2~1회)으로는 오차가 커요. 대신 가습기가 목표 습도를 유지하는 동안 물이 줄어드는 속도로 거꾸로 구할 수 있어요.</p>
<ul><li>환기 횟수 = 시간당 물 사용량 ÷ ((실내 수증기 − 바깥 수증기) × 방 부피)</li><li>예: 20㎡ 거실(46㎥), 실내 20℃·50%, 바깥 0℃·50%에서 3L 물통이 15시간에 빈다면 시간당 200mL ÷ (6.20 × 46) = 약 0.70회예요.</li><li>12시간에 빈다면 시간당 250mL라서 약 0.88회예요.</li></ul>
<p>이 값을 위 표나 계산기에 넣으면 우리 집에 맞는 필요량이 나와요. 단, 습도가 아직 오르는 중이거나 빨래·요리처럼 다른 수증기가 있을 때 재면 값이 틀어지니, 목표 습도에 도달한 뒤 조용한 시간대에 재세요.</p>
<h2 id="s11">가상 사례: 25㎡ 거실, 250mL 가습기, 4L 물통</h2>
<p>설명을 위해 만든 가상의 집이에요. 거실 25㎡(천장 2.4m, 60㎥), 실내 21℃, 목표 45%, 바깥 -5℃·50%, 물통 역산으로 구한 환기 0.6회, 가습기 분무량 250mL/시간·물통 4L예요.</p>
<ol><li><strong>처음 올리는 물</strong>: 30%→45%로 올리는 데 공기만 따지면 약 164mL예요.</li><li><strong>유지에 필요한 물</strong>: (21℃·45%의 6.52g/㎥ 차이) × 60㎥ × 0.6회 = 시간당 약 235mL, 하루 약 5.6L예요.</li><li><strong>도달 가능한 최고 습도</strong>: 250mL/시간이면 약 47%예요. 목표 45%는 닿지만 여유가 2%p라 최대 세기로 계속 돌려야 해요.</li><li><strong>물통</strong>: 4L ÷ 235mL = 약 17시간이라 밤에 채우면 다음 날 오후에 비어요.</li><li><strong>바꿀 수 있는 것</strong>: 실내를 20℃로 낮추면 시간당 약 218mL로 줄고 물통은 약 18.4시간을 버텨요. 같은 0℃ 날이면 시간당 약 209mL예요. 이 집은 가습기를 하나 더 사기보다 실내 온도 1℃와 거실 문 닫기가 먼저예요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s12">자주 틀리는 지점</h2>
<ul><li><strong>'올리는 물'만 계산해요.</strong> 방 부피 × 수증기 차이만 보여 주는 계산은 위 159mL 같은 하한이라 실제 물통 소비(하루 수 리터)와 맞지 않아요. 이 글과 계산기는 환기로 빠지는 양까지 넣어요.</li><li><strong>분무량 표기를 그대로 믿어요.</strong> 제품의 분무량이 어느 세기 기준인지는 제품마다 달라요. 설명서의 기준을 확인하고, 실제로는 물통이 줄어드는 속도를 재는 게 정확해요.</li><li><strong>습도계를 가습기 바로 옆에 둬요.</strong> 뿜어진 수증기를 바로 재서 높게 나오고, 창가에 두면 공기가 차가워 또 높게 나와요. 방 가운데, 가습기에서 2m쯤 떨어진 곳이 이 글의 가정이에요.</li><li><strong>온도가 다른 방의 습도를 그대로 비교해요.</strong> 같은 50%여도 18℃와 24℃의 수증기량은 7.66g과 10.85g으로 40% 넘게 달라요.</li></ul>
<h2 id="s13">가습기 몇 대, 물통 몇 리터가 필요할까</h2>
<p>필요한 물을 알았으니 가습기 분무량과 물통을 골라 볼게요. 분무량이 300mL/시간인 가습기를 하루 종일 켜면 7.2L를 뿌릴 수 있어요. 20㎡ 거실에서 필요한 시간당 143mL보다 분무량이 크면 한 대로 충분하고, 모자라면 대수를 늘려야 해요.</p>
<ul><li>20㎡ 거실(바깥 0℃·환기 0.5회): 시간당 약 143mL → 300mL/시간 가습기 0.5대분</li><li>33㎡ 거실, 실내 21℃ 35→55%, 바깥 2℃ 70%, 환기 1회: 시간당 약 468mL → 300mL/시간 1.6대분, 3L 물통은 6.4시간</li><li>하루 종일 켜는 것을 기준으로, 낮에 사람이 없거나 밤에만 켠다면 켜는 시간만큼 필요한 물도 줄어요.</li></ul>
<p>물통이 작아 자주 채워야 한다면 큰 물통 제품을 고르거나, 자기 전에 물통을 가득 채우는 습관이 필요해요. 단, 오래 둔 물은 세균이 번식할 수 있어서 매일 비우고 헹구는 것을 권해요. 종류별 장단점과 청소 방법은 <a href="https://tool.smithlab.co.kr/posts/life/humidifier-buying-guide-types-cost-cleaning/">가습기 고르는 법과 청소법</a> 글을 참고하세요.</p>
<h2 id="s14">자주 묻는 질문</h2>
<h3>습도를 올리는 물이 컵 하나라면 가습기를 안 써도 되나요?</h3>
<p>아니에요. 올리는 건 컵 하나이지만 환기로 계속 빠져나가기 때문에 유지하려면 하루 3.4L 정도를 채워야 해요. 가습기를 끄면 몇십 분~몇 시간 안에 원래 습도로 돌아가요.</p>
<h3>3L 물통이 하루도 못 가는데 정상인가요?</h3>
<p>20㎡ 거실, 바깥 0℃ 기준으로 시간당 약 143mL가 들기 때문에 3L는 약 21시간이면 비어요. 한파나 환기가 잦은 날은 더 빨리 닳아요. 고장이 아니라 계산상 정상 범위예요.</p>
<h3>목표 습도는 몇 %로 하는 게 좋아요?</h3>
<p>보통 40~60%를 많이 권해요. 창문 결로가 생기는 집이라면 40% 안팎으로 낮추는 게 안전해요. 위 이슬점 표를 보면 20℃에서 습도 40%의 이슬점은 6.0℃, 60%는 12.0℃예요.</p>
<h3>가습기를 켜도 습도가 40%에서 안 올라가요. 고장인가요?</h3>
<p>분무량이 환기로 빠지는 양을 못 따라가면 습도는 특정 값에서 멈춰요. 위 최고 습도표에서 33㎡·환기 1회 집은 300mL/시간 한 대로 37%가 한계예요. 문을 닫아 부피를 줄이거나 대수를 늘려 보세요.</p>
<h3>빨래를 널어 두면 가습기 대신이 되나요?</h3>
<p>젖은 빨래는 마르면서 수증기를 내놓아서 습도를 올리는 데 도움이 돼요. 이 글의 계산으로 보면 거실 하루 유지에 3L 안팎이 필요하니, 물을 머금은 빨래 한 번이 몇 백 mL에서 1L 남짓을 내놓는 것은 의미 있는 양이에요. 다만 환기가 막힌 방에서 오래 말리면 결로와 곰팡이를 부를 수 있어요.</p>
<h2 id="s15">정리</h2>
<p>습도를 올리는 데 필요한 물은 적지만 환기로 빠져나가는 수증기를 채우는 데 훨씬 많은 물이 들어요. 20㎡ 거실 기준 처음 올리는 물은 약 159mL, 유지에는 하루 약 3.4L(바깥 0℃·환기 0.5회)예요. 방이 클수록, 바깥이 추울수록, 환기가 많을수록, 실내가 따뜻할수록 더 필요하고 필요량은 모두 비례해서 늘어요. 우리 집 조건으로 직접 계산하려면 <a href="https://tool.smithlab.co.kr/life/humidifier-water-calculator/">가습기 물 얼마나 필요할까 계산기</a>를 써 보세요. 겨울 전기요금이 걱정이라면 <a href="https://tool.smithlab.co.kr/life/winter-heater-electricity-calculator/">겨울 난방기기 전기요금 계산기</a>와 함께 보면 좋아요.</p>
    ]]></description>
    </item>
    <item>
      <title>겨울 샤워 한 번 가스비 얼마? — 10분이면 약 330원, 수도 물…</title>
      <link>https://tool.smithlab.co.kr/posts/life/winter-shower-hot-water-gas-cost-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/winter-shower-hot-water-gas-cost-guide/</guid>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><strong>한겨울 수도 물이 8℃일 때 9L/분 물줄기로 10분 샤워하면 물은 90L를 쓰고, 도시가스 보일러(효율 0.85) 기준 온수 비용은 약 330원</strong>이에요. 같은 샤워를 여름 수도 물 25℃로 하면 약 143원이라 겨울이 2배 넘게 들어요.</p>
<p>가열 방식을 고를 때 쓸 숫자도 먼저 적어 둘게요. 같은 가스 단가에서 데운 열 1kWh는 약 105원이라, <strong>히트펌프(COP 3)는 전기 단가가 kWh당 약 315원보다 싸면 가스보다 유리</strong>하고, 저항식 전기온수기는 전기가 kWh당 약 105원 아래가 아니면 가스를 이기지 못해요. 아래 표와 가상의 4인 가구 예시는 모두 직접 계산한 값이에요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>온수 비용 = 물의 양(L) × 온도차(℃) × 0.0011628(kWh) ÷ 효율 × 단가예요.</li><li>10분·9L/분·8℃→38℃ 샤워 한 번은 가스 보일러 약 330원, 전기온수기 약 630원, 히트펌프(COP 3) 약 210원이에요.</li><li>샤워 1분을 줄이면 한 번에 약 33원, 하루 2번이면 한 달 약 2,000원을 아껴요.</li><li>수도 물이 5℃일 때는 25℃일 때보다 열이 약 2.5배 들어요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">샤워 한 번에 드는 온수 비용을 계산하는 방법</h2>
<p>물을 데우는 데 필요한 열은 물리 공식으로 정해져 있어요. 물 1L를 1℃ 올리는 데는 4.186kJ이 들고, 1kWh가 3,600kJ이니 <strong>물 1L를 1℃ 올리는 데 약 0.0011628kWh</strong>가 필요해요. 여기에 데울 물의 양과 온도차를 곱하면 열이 나와요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/winter-shower-hot-water-gas-cost-guide-1.svg" alt="샤워 1회 온수 비용이 물 양, 데우는 열, 효율, 단가 순서로 계산되는 과정을 보여 주는 4단계 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>90L를 30℃ 데우면 약 3.14kWh, 가스 보일러로는 약 13.3MJ, 약 329원이에요</figcaption></figure>
<ol><li><strong>물의 양</strong>: 샤워 시간 10분 × 분당 물줄기 9L = 90L</li><li><strong>데우는 열</strong>: 90L × (38℃ − 8℃) × 0.0011628 ≈ 3.14kWh</li><li><strong>실제로 쓰는 에너지</strong>: 보일러 효율 0.85면 3.14 ÷ 0.85 ≈ 3.69kWh. 가스는 MJ로 청구되니 3.69 × 3.6 ≈ 13.3MJ</li><li><strong>비용</strong>: 서울도시가스의 2026년 10월 1일 단가 22.5268원/MJ에 부가세 10%를 더한 24.78원/MJ를 곱하면 13.3 × 24.78 ≈ <strong>329원</strong></li></ol>
<p>단가는 <a href="https://www.seoulgas.co.kr/mobile/payment/gasPayTable" target="_blank" rel="noopener">서울도시가스 요금표</a>에서 확인한 값이고, 지역 도시가스 회사마다 달라서 내 고지서의 단가를 넣어야 정확해요. 이 계산을 그대로 해 주는 <a href="https://tool.smithlab.co.kr/life/shower-hot-water-cost-calculator/">샤워 온수 비용 계산기</a>를 쓰면 시간·수온·단가만 바꿔 바로 확인할 수 있어요.</p>
<h2 id="s2">샤워 시간별 온수 비용</h2>
<p>가장 쉽게 줄일 수 있는 건 샤워 시간이에요. 물의 양이 시간에 비례하고, 열도 물의 양에 비례하니 비용도 정확히 시간에 비례해요. 아래는 수도 8℃, 9L/분, 38℃, 가스 보일러(효율 0.85) 기준이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">샤워 시간별 1회 온수 비용 (도시가스 보일러, 8℃→38℃, 9L/분)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="샤워 시간별 1회 온수 비용 (도시가스 보일러, 8℃→38℃, 9L/분)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">400</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">600</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">800</text><rect class="s1" x="69.3" y="205.3" width="80.6" height="48.7" rx="3"><title>5분 · 가스 보일러 165원</title></rect><text class="viz-val" x="109.6" y="200.3" text-anchor="middle">165</text><text class="viz-ax" x="109.6" y="272" text-anchor="middle">5분</text><rect class="s1" x="184.5" y="185.9" width="80.6" height="68.1" rx="3"><title>7분 · 가스 보일러 231원</title></rect><text class="viz-val" x="224.8" y="180.9" text-anchor="middle">231</text><text class="viz-ax" x="224.8" y="272" text-anchor="middle">7분</text><rect class="s1" x="299.7" y="156.9" width="80.6" height="97.1" rx="3"><title>10분 · 가스 보일러 329원</title></rect><text class="viz-val" x="340.0" y="151.9" text-anchor="middle">329</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">10분</text><rect class="s1" x="414.9" y="108.3" width="80.6" height="145.7" rx="3"><title>15분 · 가스 보일러 494원</title></rect><text class="viz-val" x="455.2" y="103.3" text-anchor="middle">494</text><text class="viz-ax" x="455.2" y="272" text-anchor="middle">15분</text><rect class="s1" x="530.1" y="59.6" width="80.6" height="194.4" rx="3"><title>20분 · 가스 보일러 659원</title></rect><text class="viz-val" x="570.4" y="54.6" text-anchor="middle">659</text><text class="viz-ax" x="570.4" y="272" text-anchor="middle">20분</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">시간이 두 배가 되면 비용도 정확히 두 배예요.</p><div class="viz-cap">단위: 원 · 자료: 직접 계산 (서울도시가스 2026.10.1 단가 22.5268원/MJ + 부가세 10%, 효율 0.85 가정)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>가스 보일러</th></tr></thead><tbody><tr><td>5분</td><td>165</td></tr><tr><td>7분</td><td>231</td></tr><tr><td>10분</td><td>329</td></tr><tr><td>15분</td><td>494</td></tr><tr><td>20분</td><td>659</td></tr></tbody></table></div></details></figure>
<p>10분에서 9분으로 1분만 줄여도 한 번에 약 33원을 아껴요. 하루 2번(식구 전체 합) 샤워한다고 하면 한 달(60번)에 약 1,980원이에요. 한 달에 몇천 원이라 크게 느껴지지 않을 수 있지만, 식구가 많거나 하루에 샤워를 4번 하는 집이라면 숫자가 커져요.</p>
<div class="table-wrap"><table><thead><tr><th>샤워 시간</th><th>물 사용량</th><th>데우는 열</th><th>가스 보일러</th><th>전기온수기</th></tr></thead><tbody><tr><td>5분</td><td>45L</td><td>1.57kWh</td><td>165원</td><td>314원</td></tr><tr><td>7분</td><td>63L</td><td>2.20kWh</td><td>231원</td><td>440원</td></tr><tr><td>10분</td><td>90L</td><td>3.14kWh</td><td>329원</td><td>628원</td></tr><tr><td>15분</td><td>135L</td><td>4.71kWh</td><td>494원</td><td>942원</td></tr><tr><td>20분</td><td>180L</td><td>6.28kWh</td><td>659원</td><td>1,256원</td></tr></tbody></table></div>
<h2 id="s3">수도 물 온도에 따라 달라지는 비용</h2>
<p>같은 10분 샤워라도 계절에 따라 비용이 크게 달라요. 데워야 하는 온도차가 바뀌기 때문이에요. 수도 물 온도는 지역과 수도관 상태에 따라 달라서 아래 값은 어림값이고, 정확히는 찬물만 틀어서 온도계로 재 보세요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">수도 물 온도별 10분 샤워 1회 온수 비용 (가스 보일러, 9L/분, 38℃)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="수도 물 온도별 10분 샤워 1회 온수 비용 (가스 보일러, 9L/분, 38℃)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">300</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">400</text><rect class="s1" x="73.6" y="40.4" width="100.8" height="213.6" rx="3"><title>5℃ · 가스 보일러 362원</title></rect><text class="viz-val" x="124.0" y="35.4" text-anchor="middle">362</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">5℃</text><rect class="s1" x="217.6" y="72.3" width="100.8" height="181.7" rx="3"><title>10℃ · 가스 보일러 308원</title></rect><text class="viz-val" x="268.0" y="67.3" text-anchor="middle">308</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">10℃</text><rect class="s1" x="361.6" y="104.7" width="100.8" height="149.3" rx="3"><title>15℃ · 가스 보일러 253원</title></rect><text class="viz-val" x="412.0" y="99.7" text-anchor="middle">253</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">15℃</text><rect class="s1" x="505.6" y="169.6" width="100.8" height="84.4" rx="3"><title>25℃ · 가스 보일러 143원</title></rect><text class="viz-val" x="556.0" y="164.6" text-anchor="middle">143</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">25℃</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">수도 물이 5℃에서 25℃로 오르면 온도차가 33℃에서 13℃로 줄어 비용은 약 2.5분의 1이 돼요.</p><div class="viz-cap">단위: 원 · 자료: 직접 계산 (효율 0.85, 서울도시가스 2026.10.1 단가 + 부가세 10%)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>가스 보일러</th></tr></thead><tbody><tr><td>5℃</td><td>362</td></tr><tr><td>10℃</td><td>308</td></tr><tr><td>15℃</td><td>253</td></tr><tr><td>25℃</td><td>143</td></tr></tbody></table></div></details></figure>
<p>수도 물 5℃일 때는 25℃일 때보다 열이 33 ÷ 13 ≈ 2.5배 필요해요. 한겨울 아침 샤워가 유독 비싼 이유가 이거예요. 수도 물 온도는 사람이 바꿀 수 없으니 겨울에는 시간과 물줄기로 줄이는 수밖에 없어요.</p>
<h2 id="s4">물줄기 세기에 따른 차이</h2>
<p>샤워 헤드에서 나오는 물의 양도 비용에 영향을 줘요. 같은 10분이라도 물줄기가 센 집은 물을 더 많이 쓰고 데울 물도 많아져요.</p>
<div class="table-wrap"><table><thead><tr><th>물줄기</th><th>10분 물 사용량</th><th>가스 보일러 1회</th></tr></thead><tbody><tr><td>약한 물줄기·절수형(6L/분)</td><td>60L</td><td>약 220원</td></tr><tr><td>보통(9L/분)</td><td>90L</td><td>약 329원</td></tr><tr><td>센 물줄기(12L/분)</td><td>120L</td><td>약 439원</td></tr></tbody></table></div>
<p>6L/분 절수 샤워 헤드와 9L/분의 차이는 한 번에 약 110원이고, 하루 2번이면 한 달 약 6,600원이에요. 다만 수압이 너무 약하면 샤워 시간이 늘어나 절약 효과가 줄 수 있으니, 샤워 헤드를 바꿀 때는 샤워 시간이 얼마나 되는지도 같이 보세요.</p>
<p>우리 집 물줄기가 몇 L/분인지 모르겠다면 샤워기를 틀어 10L짜리 통에 물을 받으며 초시계로 재 보세요. 통이 채워지는 시간이 1분이면 10L/분, 2분이면 5L/분이에요.</p>
<h2 id="s5">가열 방식에 따른 차이: 가스 보일러·전기온수기·히트펌프</h2>
<p>온수를 만드는 방식에 따라 같은 열을 만드는 데 드는 에너지와 비용이 달라요. 10분·9L/분·8℃→38℃ 샤워 기준으로 비교해 볼게요.</p>
<div class="table-wrap"><table><thead><tr><th>방식</th><th>효율·COP(예시)</th><th>쓰는 에너지</th><th>1회 비용</th><th>한 달(60번)</th></tr></thead><tbody><tr><td>도시가스 보일러</td><td>0.85</td><td>3.69kWh (13.3MJ)</td><td>약 329원</td><td>약 19,700원</td></tr><tr><td>전기온수기(저항 가열)</td><td>1.0</td><td>3.14kWh</td><td>약 628원</td><td>약 37,700원</td></tr><tr><td>히트펌프 온수기</td><td>3.0</td><td>1.05kWh</td><td>약 209원</td><td>약 12,500원</td></tr></tbody></table></div>
<p>여기서 전기 단가는 kWh당 200원으로 둔 예시이고, 실제 전기 요금은 누진 구간에 따라 달라요. 효율과 COP도 제품과 조건에 따라 달라서 제품 설명서 값으로 바꿔 보는 게 좋아요. 그래도 흐름은 분명해요. 전기로 직접 물을 데우는 방식은 같은 열에 전기를 가장 많이 쓰고, 히트펌프는 공기 중의 열을 옮겨 오는 방식이라 쓰는 전기가 훨씬 적어요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">가열 방식과 절수 헤드, 숫자로 정하는 판단 기준</h2>
<p>위 표는 전기 단가를 200원으로 고정한 결과라, 우리 집 전기 단가가 다르면 순서가 바뀔 수 있어요. 그래서 <strong>가스 단가 24.78원/MJ, 보일러 효율 0.85</strong>를 기준으로 '전기가 얼마 이하여야 가스보다 싼지'를 거꾸로 풀었어요. 가스로 데운 열 1kWh의 값은 3.6MJ × 24.78원 ÷ 0.85 ≈ 105원이에요.</p>
<div class="table-wrap"><table><thead><tr><th>전기 쪽 방식</th><th>가스와 비용이 같아지는 전기 단가</th><th>판단</th></tr></thead><tbody><tr><td>저항식 전기온수기(효율 1.0)</td><td>약 105원/kWh</td><td>이보다 비싸면 가스가 유리(가정용 단가는 대부분 이보다 높아요)</td></tr><tr><td>히트펌프 COP 2.0</td><td>약 210원/kWh</td><td>한겨울처럼 COP가 떨어질 때 기준</td></tr><tr><td>히트펌프 COP 2.5</td><td>약 262원/kWh</td><td></td></tr><tr><td>히트펌프 COP 3.0</td><td>약 315원/kWh</td><td>이보다 싸면 히트펌프가 유리</td></tr></tbody></table></div>
<p>우리 지역 가스 단가가 다르면 기준선도 같은 비율로 움직여요. 예를 들어 가스 단가가 10% 비싸면 위 기준 단가도 모두 10% 올라가요.</p>
<p>절수형 샤워 헤드(9L/분 → 6L/분)는 10분 샤워 한 번에 약 110원을 아껴요. 헤드 값을 아낀 돈으로 나누면 본전까지 걸리는 날짜가 나와요.</p>
<div class="table-wrap"><table><thead><tr><th>헤드 가격(예시)</th><th>하루 2번 샤워</th><th>하루 4번 샤워</th></tr></thead><tbody><tr><td>15,000원</td><td>약 68일</td><td>약 34일</td></tr><tr><td>30,000원</td><td>약 137일</td><td>약 68일</td></tr></tbody></table></div>
<p>수압이 약해져 샤워 시간이 늘면 이 계산은 무너져요. 9L/분으로 10분 쓰던 사람이 6L/분으로 15분을 쓰면 물 양이 90L로 같아져서 아끼는 돈이 0이에요. 헤드를 바꾼 뒤에도 샤워 시간이 1.5배 미만으로 늘어야 이득이에요.</p>
<h2 id="s7">가상의 4인 가구로 처음부터 끝까지 계산해 보기</h2>
<p>아래는 이해를 돕기 위한 <strong>가상의 가구</strong> 예시예요. 4인 가구가 1인당 하루 1번, 12분씩, 10L/분 물줄기로 샤워하고, 한겨울 수도 물은 7℃, 샤워 물은 40℃로 써요. 도시가스 보일러(효율 0.85), 24.78원/MJ, 한 달 30일로 계산했어요.</p>
<ol><li>1회 물 양: 12분 × 10L = 120L</li><li>1회 열: 120L × 33℃ × 0.0011628 ≈ 4.60kWh</li><li>1회 가스: 4.60 ÷ 0.85 × 3.6 ≈ 19.5MJ → 약 483원</li><li>한 달: 483원 × 4명 × 30일 ≈ <strong>약 58,000원</strong></li></ol>
<p>이 가구가 바꿀 수 있는 것을 하나씩 적용하면 이렇게 줄어요.</p>
<div class="table-wrap"><table><thead><tr><th>바꾼 조건</th><th>1회 비용</th><th>한 달(120회)</th><th>처음보다</th></tr></thead><tbody><tr><td>처음(12분·10L/분·40℃)</td><td>약 483원</td><td>약 57,990원</td><td>—</td></tr><tr><td>샤워 10분으로</td><td>약 403원</td><td>약 48,330원</td><td>−9,660원</td></tr><tr><td>절수 헤드 7L/분만</td><td>약 338원</td><td>약 40,590원</td><td>−17,400원</td></tr><tr><td>10분 + 7L/분</td><td>약 282원</td><td>약 33,830원</td><td>−24,160원</td></tr><tr><td>10분 + 7L/분 + 39℃</td><td>약 273원</td><td>약 32,800원</td><td>−25,190원</td></tr></tbody></table></div>
<p>시간 2분과 물줄기 3L/분을 같이 줄이면 한 달 약 2만 4천 원이 줄고, 물 온도 1℃는 그 위에 1천 원 남짓이에요. 순서는 물줄기 → 시간 → 온도로 손대는 게 효과가 커요.</p>
<h2 id="s8">자주 틀리는 지점: 다른 계산과 결과가 달라지는 이유</h2>
<ul><li><strong>보일러 온수 설정 온도를 낮추면 크게 아낀다?</strong> 60℃ 온수에 찬물을 섞어 38℃로 쓰든, 38℃로 바로 데우든 샤워기에서 나오는 물의 양과 온도가 같으면 데우는 열은 같아요. 차이는 배관·탱크에서 식는 손실 정도예요. 비용을 정하는 건 '보일러 설정값'이 아니라 '실제로 쓰는 물의 온도와 양'이에요.</li><li><strong>부가세 포함 여부</strong>: 요금표의 MJ당 단가는 부가세 전 값인 경우가 많아요. 이 글은 10%를 더했어요. 부가세 없이 계산한 결과는 이 글보다 약 9% 낮게 나와요.</li><li><strong>MJ와 kWh 단위 섞기</strong>: 가스는 MJ, 전기는 kWh로 청구돼요. 1kWh = 3.6MJ를 빼먹으면 가스 비용이 3.6배 틀려요.</li><li><strong>기본요금</strong>: 가스 기본요금은 샤워를 안 해도 나와서 넣지 않았어요. 고지서 총액을 샤워 횟수로 나누면 이 글보다 크게 나와요.</li><li><strong>샤워 물 온도 1℃의 효과</strong>: 온도차 30℃ 중 1℃라 약 3%(10분 샤워 한 번에 약 11원)예요. 체감은 크지만 돈으로는 작아요. 아이나 어르신이 있는 집은 절약보다 화상 방지가 먼저예요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">이 글의 계산이 현실과 다를 수 있는 이유</h2>
<ul><li>이 계산은 수도 물 전체를 샤워 물 온도까지 데운다고 단순화했어요. 실제로는 배관에서 식는 열과 온수가 도착하기 전에 흘려보내는 물이 있어서 달라질 수 있어요. 기다리는 동안 흘린 찬물은 양동이에 받아 청소용으로 쓰면 물값이라도 아껴요.</li><li>가스 보일러의 점화 손실, 전기온수기의 저장 탱크에서 식는 열처럼 샤워와 직접 관계없는 에너지는 넣지 않았어요.</li><li>상하수도 요금은 넣지 않았어요. 물 사용량이 90L면 상하수도 요금도 별도로 붙어요.</li><li>단가와 효율은 예시이고 우리 집 값으로 바꿔야 해요.</li></ul>
<p>생활 속 에너지 절약 정보는 <a href="https://www.energy.or.kr/" target="_blank" rel="noopener">한국에너지공단</a> 사이트에서 더 찾아볼 수 있어요. 월 난방비까지 한꺼번에 어림하고 싶다면 <a href="https://tool.smithlab.co.kr/life/heating-cost-calculator/">도시가스 난방비 계산기</a>를, 전기 난방기를 쓴다면 <a href="https://tool.smithlab.co.kr/life/winter-heater-electricity-calculator/">난방기 전기요금 계산기</a>를 같이 확인해 보세요.</p>
<h2 id="s10">자주 묻는 질문</h2>
<h3>샤워 한 번에 가스비가 얼마나 드나요?</h3>
<p>수도 물 8℃, 9L/분, 10분, 38℃, 도시가스 보일러(효율 0.85), 24.78원/MJ 기준으로 약 330원이에요. 수도 물이 25℃인 여름에는 약 143원이에요. 지역 단가와 보일러 효율에 따라 달라지니 고지서의 단가를 넣어 보세요.</p>
<h3>샤워 시간을 줄이면 한 달에 얼마나 아끼나요?</h3>
<p>10분에서 9분으로 1분 줄이면 한 번에 약 33원이고, 하루 2번이면 한 달에 약 1,980원이에요. 5분 줄이면 약 9,900원 정도 되지만, 이 숫자는 같은 조건에서 계산한 어림값이에요.</p>
<h3>왜 겨울에 같은 샤워를 해도 더 비싼가요?</h3>
<p>수도에서 나오는 찬물을 38℃까지 데워야 하는데 겨울에는 처음 온도가 낮아서 데워야 하는 온도차가 커요. 물 5℃에서 38℃까지는 33℃, 25℃에서 38℃까지는 13℃라서 약 2.5배의 열이 필요해요.</p>
<h3>전기온수기와 가스 보일러 중 어느 쪽이 더 싼가요?</h3>
<p>예시 단가(전기 200원/kWh, 가스 24.78원/MJ)로는 가스 보일러가 약 329원, 전기온수기가 약 628원으로 가스가 더 싸게 나와요. 하지만 실제 단가는 지역과 전기 누진 구간에 따라 달라서 우리 집 단가를 넣어 직접 비교해야 해요.</p>
<h3>히트펌프 온수기는 정말 전기를 적게 쓰나요?</h3>
<p>히트펌프는 전기로 열을 직접 만드는 대신 공기 중의 열을 옮겨 오는 방식이라 같은 열을 만드는 데 전기를 적게 써요. 이 비율이 COP이고 계산에서는 3으로 두었어요. 실제 COP는 제품과 바깥 기온에 따라 달라지고 기온이 낮을수록 떨어지는 경향이 있어요.</p>
<h3>절수 샤워기로 바꾸면 몇 달 만에 본전을 뽑나요?</h3>
<p>9L/분에서 6L/분으로 바꾸고 10분 샤워를 유지하면 한 번에 약 110원을 아껴요. 15,000원짜리 헤드면 하루 2번 샤워하는 집은 약 68일, 4번이면 약 34일이에요. 샤워 시간이 1.5배 이상 늘면 아끼는 돈이 사라져요.</p>
<h3>보일러 온수 온도를 낮추면 가스비가 줄어드나요?</h3>
<p>찬물을 섞어 쓰는 양까지 생각하면 샤워 물의 온도와 양이 같을 때 데우는 열은 거의 같아요. 줄어드는 건 배관과 탱크에서 식는 손실 정도라, 큰 절약을 기대하기는 어려워요. 실제 비용은 샤워 물 온도와 시간, 물줄기가 정해요.</p>
<h2 id="s11">정리</h2>
<p>샤워 한 번의 온수 비용은 물의 양, 온도차, 효율, 단가만 알면 계산할 수 있어요. 한겨울 10분 샤워는 도시가스 기준 약 330원이고, 여기서 줄일 수 있는 것은 샤워 시간과 물줄기, 그리고 가열 방식이에요. 내 집 조건으로 계산해 보려면 <a href="https://tool.smithlab.co.kr/life/shower-hot-water-cost-calculator/">샤워 온수 비용 계산기</a>에 수도 물 온도와 단가를 넣어 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>JPA 애그리거트 루트 버전 안 오를 때, 강제 증가 판단표와 비용</title>
      <link>https://tool.smithlab.co.kr/posts/developer/jpa-aggregate-root-version-force-increment-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/jpa-aggregate-root-version-force-increment-guide/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>루트에 <code>@Version</code>을 붙였는데 두 사람이 같은 주문의 서로 다른 줄을 고치자 한도가 조용히 깨졌다면, 원인은 거의 항상 <strong>그 명령이 루트 행을 건드리지 않아서 루트 버전이 그대로였기 때문</strong>이에요. 결론부터 말하면 자식 엔티티 필드나 <code>mappedBy</code> 컬렉션만 바꾸는 명령은 루트를 <code>OPTIMISTIC_FORCE_INCREMENT</code>로 읽어야 하고, 이건 불변식에 닿는 <strong>모든</strong> 쓰기 경로에 빠짐없이 걸어야 효과가 있어요. 한 경로만 빠져도 실측에서 한도 100짜리 주문이 110이 됐어요.</p>
<p>이 글은 원리 설명보다 "우리 코드의 어느 명령에 무엇을 걸까"를 정하는 데 집중해요. Hibernate ORM 7.4.13.Final과 H2 2.4.240으로 명령 종류별 버전 변화와 SQL 문장 수를 직접 세었고, 그 숫자로 판단표·비용 계산·점검 순서를 만들었어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>루트 필드, 임베디드 값, 요소 컬렉션, 단방향 <code>@OneToMany</code> 줄 추가·삭제는 루트 버전을 저절로 올려요. 자식 엔티티 필드와 <code>mappedBy</code> 컬렉션 변경, JPQL 벌크 수정은 올리지 않아요.</li><li>강제 증가는 "자식만 바꾸는 쓰기 명령"에 걸어요. 모든 조회에 걸면 읽기만 해도 버전이 올라 거짓 충돌이 생기고, 루트 필드까지 바꾸는 명령은 버전이 2씩 올라요.</li><li>비용은 쓰기 명령 하나당 루트 UPDATE 한 문장이에요. 실측 비율로 계산하면 자식만 고치는 명령에만 걸 때 쓰기 문장이 1.5배, 모든 쓰기에 걸면 약 1.8배가 돼요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">명령별로 루트 버전이 오르는지 — Hibernate 7.4 실측표</h2>
<p>애그리거트 루트 <code>Ord</code>(버전 <code>long</code>)에 단방향 <code>@OneToMany</code> 줄, <code>@ElementCollection</code> 태그, <code>@Embedded</code> 주소, <code>@OptimisticLock(excluded = true)</code> 조회수를 두고, 비교용으로 양방향(<code>mappedBy</code>) 컬렉션을 가진 루트도 따로 만들었어요. 명령 하나를 트랜잭션 하나로 커밋하고, <code>StatementInspector</code>로 나간 SQL을 모두 모아 셌어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/jpa-aggregate-root-version-force-increment-guide-1.svg" alt="루트 버전이 저절로 오르는 변경, 안 오르는 변경, 버전을 우회하는 변경을 세 칸으로 나눈 그림" width="800" height="520" loading="lazy" decoding="async"><figcaption>가운데와 오른쪽 칸이 불변식에 닿으면 그 경로만 따로 처리해요</figcaption></figure>
<div class="table-wrap"><table><thead><tr><th>명령이 바꾸는 것</th><th>나간 쓰기 SQL</th><th>루트 버전</th><th>판단</th></tr></thead><tbody><tr><td>루트 필드(<code>memo</code>)</td><td>루트 UPDATE 1</td><td>0 → 1</td><td>그대로 둬요</td></tr><tr><td>임베디드 값(<code>addr.city</code>)</td><td>루트 UPDATE 1</td><td>0 → 1</td><td>그대로 둬요</td></tr><tr><td>요소 컬렉션에 태그 추가</td><td>루트 버전 UPDATE 1 + INSERT 1</td><td>0 → 1</td><td>그대로 둬요</td></tr><tr><td>단방향 <code>@OneToMany</code>에 줄 추가</td><td>INSERT 1 + 루트 버전 UPDATE 1 + 외래키 UPDATE 1</td><td>0 → 1</td><td>그대로 둬요</td></tr><tr><td>단방향 <code>@OneToMany</code>에서 줄 삭제</td><td>루트 버전 UPDATE 1 + 외래키 UPDATE 1 + DELETE 1</td><td>0 → 1</td><td>그대로 둬요</td></tr><tr><td>자식 엔티티 필드(<code>line.amount</code>)</td><td>자식 UPDATE 1</td><td><strong>0 → 0</strong></td><td>강제 증가 필요</td></tr><tr><td><code>mappedBy</code> 컬렉션에 줄 추가</td><td>자식 INSERT 1</td><td><strong>0 → 0</strong></td><td>강제 증가 필요</td></tr><tr><td><code>mappedBy</code> 컬렉션에서 줄 삭제</td><td>자식 DELETE 1</td><td><strong>0 → 0</strong></td><td>강제 증가 필요</td></tr><tr><td>제외 필드(<code>viewCount++</code>)</td><td>루트 UPDATE 1(버전 검사만)</td><td>0 → 0</td><td>의도대로예요</td></tr><tr><td>JPQL <code>update Ord set …</code></td><td>벌크 UPDATE 1</td><td><strong>0 → 0</strong></td><td><code>update versioned</code>로</td></tr></tbody></table></div>
<p><code>mappedBy</code> 결과는 버그가 아니라 명세대로예요. <a href="https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2.html" target="_blank" rel="noopener">Jakarta Persistence 3.2 명세</a> 3.5.1절은 버전 관리에서 엔티티의 상태를 "관계가 아닌 영속 필드와, 그 엔티티가 소유한 관계"로 정의하고, 소유하지 않은 관계는 상태에 넣지 않는다고 적어요. 양방향에서 외래키를 가진 쪽은 자식이니, 부모 컬렉션에 줄을 넣고 빼도 부모 상태는 바뀌지 않은 것이에요. 반대로 <code>@JoinColumn</code>을 단 단방향 <code>@OneToMany</code>는 부모가 관계를 소유하므로 버전이 올라요.</p>
<h2 id="s2">강제 증가를 거는 방법 고르기</h2>
<p>루트 버전을 올리는 방법은 네 가지가 있고, 버전이 오르는 시점과 잠금 방식이 달라요. 아래 시점도 같은 실험에서 SQL 순서로 확인했어요.</p>
<div class="table-wrap"><table><thead><tr><th>방법</th><th>루트 버전이 오르는 때</th><th>잠금</th><th>맞는 경우</th></tr></thead><tbody><tr><td><code>em.find(Ord.class, id, OPTIMISTIC_FORCE_INCREMENT)</code></td><td>플러시 뒤, 커밋 직전</td><td>없음</td><td>자식만 고치는 명령의 기본 선택</td></tr><tr><td>조회 뒤 <code>em.lock(root, OPTIMISTIC_FORCE_INCREMENT)</code></td><td>플러시 뒤, 커밋 직전</td><td>없음</td><td>루트를 다른 쿼리로 이미 읽었을 때, 쓰기가 확정된 뒤에만 걸고 싶을 때</td></tr><tr><td><code>PESSIMISTIC_FORCE_INCREMENT</code></td><td><strong>조회 직후</strong> 즉시</td><td>루트 행 쓰기 잠금</td><td>충돌이 잦아 실패·재시도보다 대기가 나은 루트</td></tr><tr><td>루트에 <code>lastChangedAt</code> 같은 필드를 두고 매번 갱신</td><td>플러시 때</td><td>없음</td><td>잠금 모드를 못 쓰는 경로(직렬화된 엔티티 병합 등)</td></tr></tbody></table></div>
<p><a href="https://docs.hibernate.org/orm/7.0/userguide/html_single/Hibernate_User_Guide.html" target="_blank" rel="noopener">Hibernate 사용자 가이드</a>는 <code>OPTIMISTIC_FORCE_INCREMENT</code>를 "엔티티가 바뀌지 않았어도 버전을 자동으로 올린다"고 설명하고, 같은 문서에서 <strong>애플리케이션이 Hibernate가 정한 버전 값을 직접 바꾸는 것은 금지</strong>라고 적어요. <code>root.version++</code> 같은 코드는 쓰지 말고 위 방법 중 하나를 써요. 마지막 줄의 "필드 하나 갱신"은 값이 실제로 바뀌어야 UPDATE가 나가니, 같은 밀리초 안의 두 명령처럼 값이 같아지는 경우를 조심해요.</p>
<p>스프링 데이터 JPA에서는 <a href="https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html" target="_blank" rel="noopener">JPA Locking 문서</a>대로 조회 메서드에 <code>@Lock</code>을 붙여요. 기본 <code>findById</code>를 다시 선언해 잠그면 읽기 전용 화면까지 버전이 오르니, <strong>쓰기용 조회 메서드를 따로</strong> 두는 편이 안전해요.</p>
<pre class="code-block" data-lang="java"><code>interface OrdRepository extends Repository&lt;Ord, Long&gt; {
    Optional&lt;Ord&gt; findById(Long id);                       // 조회 화면용, 잠금 없음

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query(&quot;select o from Ord o where o.id = :id&quot;)
    Optional&lt;Ord&gt; findForChildChange(@Param(&quot;id&quot;) Long id); // 자식만 고치는 명령용
}</code></pre>
<h2 id="s3">한 경로라도 빠지면 깨져요 — 섞인 경로 실험</h2>
<p>강제 증가는 양쪽이 모두 걸어야 의미가 있어요. 한도 100, 줄 40+40인 주문을 두 트랜잭션이 동시에 읽고 각자 다른 줄을 55로 올린 뒤, 앨리스가 먼저 커밋하는 실험을 잠금 조합별로 돌렸어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/jpa-aggregate-root-version-force-increment-guide-2.svg" alt="앨리스와 밥의 잠금 모드 조합 네 가지 중 둘 다 강제 증가를 건 칸만 밥이 롤백되고 합계 95를 지키며, 나머지는 모두 합계 110이 되는 표" width="800" height="520" loading="lazy" decoding="async"><figcaption>초록 칸 하나만 안전해요</figcaption></figure>
<p>결과는 네 칸 중 세 칸이 한도 초과였어요.</p>
<ul><li><strong>둘 다 기본 조회</strong>: 둘 다 커밋, 합계 110. 루트 버전은 0 그대로예요.</li><li><strong>앨리스만 강제 증가</strong>: 앨리스가 버전을 1로 올렸지만 밥은 루트 버전을 검사하지 않아 커밋돼요. 합계 110이에요.</li><li><strong>밥만 강제 증가</strong>: 앨리스가 버전을 올리지 않았으니 밥의 <code>where version = 0</code>이 그대로 맞아요. 합계 110이에요.</li><li><strong>둘 다 강제 증가</strong>: 밥의 루트 UPDATE가 0행을 갱신해 <code>OptimisticLockException</code>으로 롤백돼요. 합계 95예요.</li><li><strong>앨리스가 <code>PESSIMISTIC_WRITE</code>, 밥은 기본 조회</strong>: 앨리스가 루트 행을 잠가도 밥은 루트를 잠그지도, 버전을 보지도 않아서 합계 110이에요.</li></ul>
<p>명세의 비관적 잠금 범위 설명도 엔티티가 외래키를 갖지 않는 관계(단방향 일대다 등)는 기본으로 잠그지 않는다고 적어요. 비관적 잠금도 <strong>모든 경로가 같은 루트 잠금을 잡을 때만</strong> 지켜져요.</p>
<p>그래서 점검 단위는 "엔티티"가 아니라 "불변식에 닿는 명령 목록"이에요. 줄 금액 변경, 줄 추가, 줄 삭제, 관리자 화면 일괄 수정, 배치처럼 그 규칙에 영향을 주는 진입점을 모두 적고, 각각이 루트 버전을 올리고 검사하는지 표시해 보세요. 표에 빈칸이 하나라도 있으면 그 경로로 불변식이 새요.</p>
<h2 id="s4">숫자로 보는 비용 — 추가 UPDATE와 버전 소모</h2>
<p>강제 증가의 직접 비용은 루트 UPDATE 한 문장이에요. 실측한 쓰기 SQL 수를 명령 종류별로 놓으면 이래요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">명령 하나가 DB에 보내는 쓰기 SQL 수 (실측)</figcaption><div class="viz-legend"><span><i class="s1"></i>기본</span><span><i class="s2"></i>OPTIMISTIC_FORCE_INCREMENT</span></div><svg viewBox="0 0 640 300" role="img" aria-label="명령 하나가 DB에 보내는 쓰기 SQL 수 (실측)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">3</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">4</text><rect class="s1" x="73.6" y="195.0" width="49.4" height="59.0" rx="3"><title>루트 필드 변경 · 기본 1문장</title></rect><rect class="s2" x="125.0" y="136.0" width="49.4" height="118.0" rx="3"><title>루트 필드 변경 · OPTIMISTIC_FORCE_INCREMENT 2문장</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">루트 필드 변경</text><rect class="s1" x="217.6" y="195.0" width="49.4" height="59.0" rx="3"><title>자식 필드 변경 · 기본 1문장</title></rect><rect class="s2" x="269.0" y="136.0" width="49.4" height="118.0" rx="3"><title>자식 필드 변경 · OPTIMISTIC_FORCE_INCREMENT 2문장</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">자식 필드 변경</text><rect class="s1" x="361.6" y="77.0" width="49.4" height="177.0" rx="3"><title>단방향 줄 추가 · 기본 3문장</title></rect><rect class="s2" x="413.0" y="18.0" width="49.4" height="236.0" rx="3"><title>단방향 줄 추가 · OPTIMISTIC_FORCE_INCREMENT 4문장</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">단방향 줄 추가</text><rect class="s1" x="505.6" y="195.0" width="49.4" height="59.0" rx="3"><title>mappedBy 줄 추가 · 기본 1문장</title></rect><rect class="s2" x="557.0" y="136.0" width="49.4" height="118.0" rx="3"><title>mappedBy 줄 추가 · OPTIMISTIC_FORCE_INCREMENT 2문장</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">mappedBy 줄 추가</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">루트 필드나 단방향 줄을 바꾸는 명령에 강제 증가를 걸면 버전 UPDATE가 한 번 더 나가 버전이 2씩 올라요.</p><div class="viz-cap">단위: 문장 · 자료: Hibernate ORM 7.4.13.Final · H2 2.4.240, StatementInspector로 직접 집계</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>기본</th><th>OPTIMISTIC_FORCE_INCREMENT</th></tr></thead><tbody><tr><td>루트 필드 변경</td><td>1</td><td>2</td></tr><tr><td>자식 필드 변경</td><td>1</td><td>2</td></tr><tr><td>단방향 줄 추가</td><td>3</td><td>4</td></tr><tr><td>mappedBy 줄 추가</td><td>1</td><td>2</td></tr></tbody></table></div></details></figure>
<p>눈여겨볼 것은 왼쪽과 세 번째 막대예요. 원래 루트 버전이 오르는 명령에 강제 증가를 덧붙이면 보호는 그대로인데 UPDATE만 하나 늘고 버전이 0 → 2로 두 칸 올라요. 꼭 필요한 곳은 가운데 두 종류(자식 필드, <code>mappedBy</code> 컬렉션)뿐이에요.</p>
<p>예시로 초당 쓰기 명령 500건 가운데 60%가 자식 필드만 바꾸고(300건), 30%가 루트 필드만 바꾸고(150건), 10%가 단방향 컬렉션에 줄을 추가한다고(50건) 가정해요.</p>
<ul><li>기본: 300×1 + 150×1 + 50×3 = 초당 600문장</li><li>자식만 고치는 명령에만 강제 증가: 300×2 + 150×1 + 50×3 = 초당 900문장(1.5배)</li><li>모든 쓰기 명령에 강제 증가: 300×2 + 150×2 + 50×4 = 초당 1,100문장(약 1.83배)</li></ul>
<figure class="viz viz-hbar"><figcaption class="viz-title">초당 쓰기 명령 500건일 때 DB 쓰기 SQL 수 (예시 계산)</figcaption><svg viewBox="0 0 640 118" role="img" aria-label="초당 쓰기 명령 500건일 때 DB 쓰기 SQL 수 (예시 계산)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">기본(보호 안 됨)</text><rect class="s1" x="220" y="15" width="175.0" height="20" rx="3"><title>기본(보호 안 됨) · 600문장/초</title></rect><text class="viz-val" x="401.0" y="29">600문장/초</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">자식만 고치는 명령에 강제 증가</text><rect class="s1" x="220" y="49" width="262.5" height="20" rx="3"><title>자식만 고치는 명령에 강제 증가 · 900문장/초</title></rect><text class="viz-val" x="488.5" y="63">900문장/초</text><text class="viz-ax lg" x="210" y="97" text-anchor="end">모든 쓰기에 강제 증가</text><rect class="s1" x="220" y="83" width="320.8" height="20" rx="3"><title>모든 쓰기에 강제 증가 · 1,100문장/초</title></rect><text class="viz-val" x="546.8" y="97">1,100문장/초</text></svg><p class="viz-note">두 번째와 세 번째의 보호 수준은 같아요. 차이 200문장은 순수한 낭비예요.</p><div class="viz-cap">단위: 문장/초 · 자료: 위 실측 문장 수 × 가정한 명령 비율(자식 60% · 루트 30% · 줄 추가 10%)로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>쓰기 SQL</th></tr></thead><tbody><tr><td>기본(보호 안 됨)</td><td>600</td></tr><tr><td>자식만 고치는 명령에 강제 증가</td><td>900</td></tr><tr><td>모든 쓰기에 강제 증가</td><td>1,100</td></tr></tbody></table></div></details></figure>
<p>루트 필드 변경과 단방향 줄 추가는 원래 루트 버전을 올리고 검사하니까요. 그래서 기준은 "모든 쓰기에 일괄"이 아니라 <strong>"루트 행을 건드리지 않는 쓰기에만"</strong>이에요.</p>
<p>버전 소모 속도도 같이 봐요. 루트 필드 변경에 강제 증가까지 걸면 명령마다 2씩 올라 <code>int</code> 버전의 수명이 절반이 돼요. 버전 타입을 <code>long</code>으로 두면 이 걱정은 사실상 사라져요. 버전 타입 고르는 기준과 경계 크기별 충돌률 계산은 <a href="https://tool.smithlab.co.kr/posts/developer/ddd-aggregate-boundary-decision-guide/">DDD 애그리거트 경계 정하는 법</a>에 따로 정리해 뒀어요.</p>
<h2 id="s5">벌크 수정·배치·네이티브 SQL 경로</h2>
<p>엔티티를 거치지 않는 수정은 버전을 아예 모르고 지나가요. <a href="https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2.html" target="_blank" rel="noopener">Jakarta Persistence 3.2 명세</a>는 벌크 업데이트가 "낙관적 잠금 검사를 우회해 DB 업데이트로 바로 이어진다"며, 이식성 있는 애플리케이션은 버전 컬럼을 직접 올리거나 검증해야 한다고 적어요. 실측에서도 <code>update Ord o set o.memo = 'z'</code>는 버전을 0에 두었어요.</p>
<p>이게 문제가 되는 장면은 이래요. 사용자가 수정 화면을 연 뒤, 야간 배치가 같은 주문의 상태를 벌크로 바꿔요. 버전이 그대로이니 사용자의 저장은 배치 결과를 덮어쓰고도 성공해요.</p>
<p>해결은 둘 중 하나예요.</p>
<ul><li><strong>HQL <code>update versioned</code></strong>: <a href="https://docs.hibernate.org/orm/7.0/querylanguage/html_single/Hibernate_Query_Language.html" target="_blank" rel="noopener">Hibernate 쿼리 언어 가이드</a>는 update 문이 기본으로 <code>@Version</code> 컬럼을 건드리지 않고, <code>versioned</code>를 붙이면 버전을 올린다고 설명해요. 실측 SQL은 <code>set memo='z', version=(o1_0.version+1)</code>이었어요.</li><li><strong>네이티브 SQL</strong>: <code>SET version = version + 1</code>을 직접 넣어요. 자식 테이블만 바꾸는 배치라면 같은 트랜잭션에서 해당 루트들의 버전도 올려요.</li></ul>
<p>벌크 수정은 영속성 컨텍스트와 동기화되지 않는다는 점도 명세에 함께 적혀 있어요. 벌크 수정과 엔티티 수정을 한 트랜잭션에 섞을 때의 플러시 순서 문제는 <a href="https://tool.smithlab.co.kr/posts/developer/jpa-dirty-checking-flush-mode-persistence-context-sizing-guide/">JPA 더티 체킹 비용과 플러시 모드 고르는 법</a>에서 다뤘어요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">운영에서 자주 나는 문제와 확인 순서</h2>
<p><strong>1. 합계·개수 규칙이 가끔 깨져 있어요.</strong> SQL 로그(또는 <code>StatementInspector</code>)에서 그 명령이 루트 테이블에 <code>version</code>을 갱신하는 UPDATE를 보내는지 먼저 봐요. 없으면 위 실측표에서 해당 줄을 찾아요. 자식 필드나 <code>mappedBy</code> 컬렉션이면 쓰기용 조회를 강제 증가로 바꾸고, 같은 규칙에 닿는 다른 진입점도 모두 바꿔요.</p>
<p><strong>2. 조회만 했는데 다른 사람 저장이 실패해요.</strong> 읽기 경로가 강제 증가 조회를 쓰고 있을 가능성이 커요. 실측에서 아무것도 바꾸지 않고 <code>OPTIMISTIC_FORCE_INCREMENT</code>로 읽기만 해도 루트 UPDATE가 한 번 나가고 버전이 올랐어요. <code>@OptimisticLock(excluded = true)</code>로 뺀 조회수 필드도, 강제 증가로 읽은 뒤 바꾸면 버전이 올라요. 조회 메서드와 쓰기 메서드를 나눠요.</p>
<p><strong>3. 버전이 명령마다 2씩 올라요.</strong> 루트 필드를 바꾸는 명령에도 강제 증가를 걸었을 때예요. 보호는 같고 UPDATE만 늘었으니, 그 명령은 기본 조회로 되돌려요.</p>
<p><strong>4. 비관적 락을 걸었는데도 규칙이 깨져요.</strong> 잠금 없이 같은 루트를 읽어 자식을 고치는 다른 경로가 있는지 찾아요. 섞인 경로 실험처럼 한쪽만 <code>PESSIMISTIC_WRITE</code>면 다른 쪽은 기다리지 않아요.</p>
<p><strong>5. 배치가 돈 뒤 화면 저장이 배치 결과를 덮어써요.</strong> 배치의 수정 쿼리에 <code>versioned</code>나 <code>version = version + 1</code>이 있는지 봐요.</p>
<p><strong>6. 충돌 예외가 갑자기 늘었어요.</strong> 강제 증가를 새로 건 직후라면 정상일 수 있어요. 그동안 조용히 덮어쓰던 동시 수정이 이제 드러난 것이에요. 예외가 난 루트 ID를 기록해 같은 ID에 몰리는지 보고, 몰린다면 경계를 다시 나눌 신호예요. 실패한 요청을 다시 시도할 때는 새 트랜잭션에서 다시 읽어야 해요.</p>
<p>재발을 막으려면 테스트로 고정해 두는 게 가장 확실해요. 명령을 실행한 뒤 루트 버전 UPDATE가 나갔는지 확인하는 간단한 방법이에요.</p>
<pre class="code-block" data-lang="java"><code>// 테스트용 StatementInspector가 모은 SQL에서 루트 버전 갱신을 찾는다
long rootBumps = SqlCapture.statements().stream()
        .filter(sql -&gt; sql.startsWith(&quot;update ord set&quot;) &amp;&amp; sql.contains(&quot;version=?&quot;))
        .count();
assertThat(rootBumps).as(&quot;줄 금액 변경은 루트 버전을 올려야 함&quot;).isEqualTo(1);</code></pre>
<p>같은 테스트를 불변식에 닿는 명령마다 하나씩 두면, 누군가 조회 메서드를 <code>findById</code>로 바꿨을 때 바로 실패해요. 동시 실행 실패 여부는 두 <code>EntityManager</code>로 섞인 경로 실험처럼 따로 확인해요.</p>
<h2 id="s7">도입 전에 따질 것</h2>
<ul><li><strong>불변식이 실제로 여러 엔티티에 걸쳐 있는가</strong>: "줄 합계 ≤ 한도", "같은 시간대 예약 겹침 금지"처럼 여러 행을 함께 봐야 하는 규칙이 있을 때만 강제 증가가 필요해요. 규칙이 줄 하나 안에서 끝나면 자식 엔티티 자체에 <code>@Version</code>을 두는 편이 충돌 범위가 좁아요.</li><li><strong>같은 루트에 쓰기가 얼마나 몰리는가</strong>: 강제 증가는 자식끼리의 동시 수정을 충돌로 바꿔요. 그동안 다른 줄을 동시에 고치던 사용자들이 이제 서로를 실패시켜요. 한 루트에 동시 편집자가 많다면 재시도 정책과 화면 안내(다시 불러오기)를 함께 준비해요.</li><li><strong>매핑 방향을 바꿀 수 있는가</strong>: 단방향 <code>@OneToMany</code> + <code>@JoinColumn</code>이면 줄 추가·삭제에서 버전이 저절로 오르지만, 실측처럼 외래키 UPDATE가 따로 나가 문장 수가 늘어요.</li><li><strong>엔티티를 거치지 않는 수정이 있는가</strong>: 배치, 관리자 SQL, 다른 서비스의 직접 DB 접근이 있다면 그 경로도 버전 규칙을 지키게 바꿔야 해요. 바꿀 수 없다면 그 테이블은 낙관적 락만으로 보호된다고 보면 안 돼요.</li><li><strong>커밋 뒤 후속 처리와 엮이는가</strong>: 강제 증가로 롤백되는 트랜잭션에서 이벤트를 먼저 보내면 안 되니, 후속 처리는 커밋 뒤 단계로 넘겨요. 리스너 단계 고르는 법은 <a href="https://tool.smithlab.co.kr/posts/developer/spring-domain-event-listener-phase-pool-sizing-guide/">스프링 도메인 이벤트 설정 고르기</a>에 있어요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>자식 엔티티에 @Version을 붙이면 루트 강제 증가가 필요 없나요?</h3>
<p>규칙이 그 자식 하나 안에서 끝날 때만 그래요. 서로 다른 두 줄을 고치는 두 트랜잭션은 각자 다른 행의 버전을 검사하니 둘 다 통과해요. "줄 합계 ≤ 한도"처럼 여러 줄을 함께 보는 규칙은 공통 지점인 루트 버전이 있어야 잡혀요.</p>
<h3>mappedBy 컬렉션에 줄을 추가해도 부모 버전이 안 오르는 건 버그인가요?</h3>
<p>아니에요. Jakarta Persistence 명세는 버전 관리 대상 상태에 그 엔티티가 소유한 관계만 넣고, 소유하지 않은 관계(양방향의 <code>mappedBy</code> 쪽)는 뺀다고 정해 두었어요. 그래서 부모 버전에 의존하는 규칙이라면 줄 추가 명령도 강제 증가로 루트를 읽어야 해요.</p>
<h3>OPTIMISTIC_FORCE_INCREMENT와 PESSIMISTIC_FORCE_INCREMENT는 어떻게 고르나요?</h3>
<p>실측에서 낙관적 쪽은 커밋 직전에 루트 버전을 올렸고, 비관적 쪽은 조회 직후 바로 루트 버전 UPDATE를 보내 행을 잠갔어요. 충돌이 드물면 낙관적 쪽이 대기가 없어 유리해요. 같은 루트에 쓰기가 몰려 실패와 재시도가 반복된다면 비관적 쪽으로 바꿔 순서대로 기다리게 할 수 있지만, 잠금 대기 시간과 교착 가능성을 함께 봐야 해요.</p>
<h3>모든 조회 메서드에 강제 증가를 걸면 안전하지 않나요?</h3>
<p>보호는 되지만 비용과 부작용이 커요. 읽기만 해도 루트 UPDATE가 나가 버전이 오르니 조회 화면을 연 것만으로 다른 사람의 저장이 실패할 수 있어요. 예시 계산에서 모든 쓰기에만 걸어도 자식 명령에만 걸 때보다 쓰기 SQL이 초당 200문장 많았는데, 읽기까지 걸면 그 차이가 읽기 수만큼 더 커져요.</p>
<h3>강제 증가를 걸었는지 코드 리뷰에서 어떻게 확인하나요?</h3>
<p>쓰기 명령 목록과 각 명령이 쓰는 조회 메서드를 표로 맞춰 보는 게 가장 빨라요. 쓰기용 조회 메서드 이름을 <code>findForChildChange</code>처럼 용도로 지어 두면, 자식만 바꾸는 서비스 메서드가 일반 <code>findById</code>를 부를 때 바로 눈에 띄어요. 위의 SQL 확인 테스트를 같이 두면 리뷰에서 놓친 것도 잡혀요.</p>
<h2 id="s9">정리</h2>
<ul><li>루트 버전은 루트 행이 갱신될 때만 올라요. 자식 엔티티 필드, <code>mappedBy</code> 컬렉션 추가·삭제, JPQL 벌크 수정은 실측에서 버전을 0에 두었어요.</li><li>강제 증가는 루트 행을 건드리지 않는 쓰기 명령에만, 쓰기용 조회 메서드를 따로 만들어 걸어요. 루트 필드 변경에 덧붙이면 UPDATE가 하나 늘고 버전이 2씩 올라요.</li><li>불변식에 닿는 진입점은 하나도 빠짐없이 같은 규칙을 따라야 해요. 한쪽만 강제 증가나 비관적 락을 걸면 실측에서 한도 100이 110이 됐어요.</li><li>배치와 벌크 수정은 <code>update versioned</code>나 <code>version = version + 1</code>로 버전 규칙에 넣고, 명령마다 루트 버전 UPDATE를 확인하는 테스트로 고정해요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/ddd-aggregate-transaction-boundary-optimistic-lock" target="_blank" rel="noopener">DDD 애그리거트와 트랜잭션 경계 원리 — 낙관적 락과 루트 버전</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>MCP 무상태 스펙 전환, 접속 모드·ttlMs·MRTR 설정 고르는 법</title>
      <link>https://tool.smithlab.co.kr/posts/developer/mcp-stateless-spec-client-mode-cache-mrtr-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/mcp-stateless-spec-client-mode-cache-mrtr-guide/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>MCP 서버나 호스트를 2026-07-28 스펙으로 옮기려는데 "클라이언트는 auto 로 둘까 버전을 고정할까, <code>tools/list</code> 의 <code>ttlMs</code> 는 몇으로 줄까, 사용자에게 되묻는 도구는 어떻게 만들까"에서 막히는 경우가 많아요. 결론부터 말하면 <strong>상대 서버가 전부 새 스펙이면 버전 고정, 섞여 있으면 auto 로 한 번 판정해 결과를 저장하고, 도구 목록은 <code>ttlMs</code> 를 몇 분 단위로 주고 순서를 고정하고, 되묻기는 SDK 의 MRTR 경로에 맡기되 <code>requestState</code> 를 반드시 봉인</strong>하는 게 기본값이에요. 이 중 가장 비싼 실수는 도구 순서가 바뀌어 모델 쪽 프롬프트 캐시가 매번 깨지는 경우로, 아래 계산에서 캐싱을 아예 안 쓸 때보다 돈이 더 나갔어요.</p>
<p>이 글은 프로토콜이 어떻게 흐르는지 대신 <strong>설정값을 고를 때 필요한 숫자</strong>만 다뤄요. 공식 파이썬 SDK <code>mcp</code> 2.3.0 으로 Apple M1·파이썬 3.13 에서 직접 쟀고, 비용은 2026-11-03 에 연 <a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener">Anthropic 가격표</a>로 계산했어요. 절대 시간은 기기마다 달라서 비율과 자릿수로 읽어 주세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>로컬 HTTP 에서 첫 도구 결과까지 legacy 41.9ms, auto 27.6ms, 버전 고정 19.9ms 였어요. 차이는 순차 왕복 수(4·3·2번)에서 나와서, 원격이면 왕복 시간만큼 그대로 벌어져요.</li><li>auto 모드는 응답 없이 침묵하는 옛 stdio 서버를 만나면 탐색 제한 시간 10초를 다 기다린 뒤에야 옛 방식으로 내려가요. 판정 결과를 저장해 두지 않으면 서버를 띄울 때마다 10초를 내요.</li><li>SDK 서버의 기본 <code>tools/list</code> 힌트는 <code>ttlMs: 0</code>, <code>private</code> 이라 목록을 매번 다시 받아요. 5분으로 주자 목록 조회가 5.7ms 에서 0.35ms 가 됐어요.</li><li>도구 1만 토큰짜리 에이전트를 Sonnet 5.5 로 월 1만 작업(작업당 10회) 돌리면, 순서를 고정하고 캐싱하면 340달러, 순서가 매번 바뀌면 2,500달러, 캐싱을 끄면 2,000달러예요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">MCP 2026-07-28 전환에서 정할 것 다섯 가지</h2>
<p><a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener">2026-07-28 변경 기록</a>을 운영 관점으로 다시 묶으면 직접 값을 골라야 하는 칸은 다섯 개예요.</p>
<div class="table-wrap"><table><thead><tr><th>정할 것</th><th>선택지</th><th>기본 추천</th><th>이유</th></tr></thead><tbody><tr><td>클라이언트 접속 모드</td><td>legacy · auto · 버전 고정</td><td>상대가 확실하면 고정, 아니면 auto + 결과 저장</td><td>고정이 왕복이 가장 적고, auto 는 옛 서버도 붙음</td></tr><tr><td>서버가 받을 시대</td><td>새 스펙만 · 듀얼</td><td>전환 기간엔 듀얼</td><td>옛 클라이언트는 새 서버로 올라갈 방법이 없음</td></tr><tr><td>목록 캐시 힌트</td><td><code>ttlMs</code> 값 · <code>public</code>/<code>private</code></td><td>5~15분, 사용자별 목록이면 <code>private</code></td><td>SDK 기본 0이라 매번 다시 받음</td></tr><tr><td>HTTP 응답 형식</td><td>JSON 하나 · 요청별 SSE</td><td>짧은 도구는 JSON, 오래 걸리면 SSE</td><td>진행률 알림과 취소가 필요할 때만 SSE 의미</td></tr><tr><td>되묻기 상태</td><td>봉인 방식 · 만료</td><td>AES-GCM, 만료 5~10분</td><td>스펙이 조작 가능한 입력으로 다루라고 요구</td></tr></tbody></table></div>
<p>다섯 가지 모두 "서버가 연결별 상태를 기억하지 않는다"는 변화에서 나와요. 도입 여부 자체나 stdio·HTTP 중 무엇을 쓸지는 <a href="https://tool.smithlab.co.kr/posts/developer/mcp-adoption-transport-tool-token-cost-guide/">MCP 도입 판단표</a>에서 다뤘으니, 여기서는 이미 쓰기로 정한 뒤의 설정만 봐요.</p>
<h2 id="s2">클라이언트 접속 모드 — legacy·auto·버전 고정 지연 비교</h2>
<p>파이썬 SDK 의 <code>Client(..., mode=...)</code> 는 세 값을 받아요. <code>legacy</code> 는 예전처럼 <code>initialize</code> 로 시작하고, <code>auto</code>(기본값)는 <code>server/discover</code> 를 먼저 보내 보고 새 서버가 아니면 옛 방식으로 내려가요. <code>"2026-07-28"</code> 처럼 버전 문자열을 주면 탐색 없이 그 버전으로 바로 요청해요.</p>
<p>같은 서버를 로컬 <code>127.0.0.1</code> HTTP 로 띄우고 모드마다 15번 연결해 첫 <code>tools/call</code> 결과가 올 때까지의 중앙값을 쟀어요. 같은 측정을 두 번 했고 두 번째 값을 적었어요(첫 번째와 3ms 안쪽 차이).</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">MCP 클라이언트 모드별 첫 도구 결과까지 시간 (로컬 HTTP)</figcaption><svg viewBox="0 0 640 118" role="img" aria-label="MCP 클라이언트 모드별 첫 도구 결과까지 시간 (로컬 HTTP)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="78" y="29" text-anchor="end">legacy</text><rect class="s1" x="88" y="15" width="403.9" height="20" rx="3"><title>legacy · 41.9ms</title></rect><text class="viz-val" x="497.9" y="29">41.9ms</text><text class="viz-ax lg" x="78" y="63" text-anchor="end">auto</text><rect class="s1" x="88" y="49" width="266.1" height="20" rx="3"><title>auto · 27.6ms</title></rect><text class="viz-val" x="360.1" y="63">27.6ms</text><text class="viz-ax lg" x="78" y="97" text-anchor="end">버전 고정</text><rect class="s1" x="88" y="83" width="191.8" height="20" rx="3"><title>버전 고정 · 19.9ms</title></rect><text class="viz-val" x="285.8" y="97">19.9ms</text></svg><p class="viz-note">연결이 열린 뒤 호출 한 번은 세 모드 모두 6.1~6.4ms 로 거의 같았어요. 차이는 연결 직후에만 생겨요</p><div class="viz-cap">단위: ms · 자료: 직접 측정 (2026-11-03, mcp 2.3.0, Apple M1, 15회 중앙값)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>첫 결과</th></tr></thead><tbody><tr><td>legacy</td><td>41.9</td></tr><tr><td>auto</td><td>27.6</td></tr><tr><td>버전 고정</td><td>19.9</td></tr></tbody></table></div></details></figure>
<p>서버 접근 기록에서 HTTP 요청 수를 세 보면 이유가 보여요.</p>
<div class="table-wrap"><table><thead><tr><th>모드</th><th>첫 결과까지 보낸 요청</th><th>순차 왕복</th><th>왕복 50ms 망이라면(예시 계산)</th></tr></thead><tbody><tr><td>legacy</td><td>initialize → initialized 알림 → tools/list → tools/call (+ GET 스트림, 끝에 DELETE)</td><td>4번</td><td>약 200ms</td></tr><tr><td>auto</td><td>server/discover → tools/list → tools/call</td><td>3번</td><td>약 150ms</td></tr><tr><td>버전 고정</td><td>tools/list → tools/call</td><td>2번</td><td>약 100ms</td></tr><tr><td>버전 고정 + 목록 보유</td><td>tools/call</td><td>1번</td><td>약 50ms</td></tr></tbody></table></div>
<p><code>tools/list</code> 가 끼는 건 SDK 클라이언트가 처음 부르는 도구의 출력 스키마를 검증하려고 목록을 한 번 받아 두기 때문이에요(같은 연결에서 세 번 부르면 요청은 4번). 서버리스 함수처럼 요청마다 연결을 새로 여는 구조라면 이 왕복 차이가 매 요청에 붙으니, 상대 서버가 새 스펙인 걸 알면 버전 고정이 가장 싸요.</p>
<h3>auto 모드의 10초 함정</h3>
<p><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio" target="_blank" rel="noopener">stdio 전송 스펙</a>은 양쪽 시대를 다 지원하는 클라이언트가 <code>server/discover</code> 로 먼저 찔러 보고, 새 스펙 오류가 아닌 오류가 오거나 <strong>적당한 시간 안에 답이 없으면</strong> 옛 서버로 보라고 해요. 옛 서버 중에는 <code>initialize</code> 전에 온 모르는 요청에 아예 답하지 않는 구현이 있어요. 이 경우를 흉내 낸 서버와, 같은 상황에서 <code>-32601</code> 오류로 답하는 서버를 각각 만들어 SDK 로 붙여 봤어요.</p>
<div class="table-wrap"><table><thead><tr><th>옛 서버의 반응</th><th>legacy 모드 연결</th><th>auto 모드 연결</th></tr></thead><tbody><tr><td>모르는 요청에 <code>-32601</code> 오류</td><td>124ms</td><td>124ms</td></tr><tr><td>모르는 요청에 침묵</td><td>159ms</td><td>10,020ms</td></tr></tbody></table></div>
<p>SDK 코드의 <code>DISCOVER_TIMEOUT_SECONDS = 10.0</code> 이 그대로 드러난 결과예요. <a href="https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector/protocol-eras" target="_blank" rel="noopener">MCP Inspector 의 프로토콜 시대 문서</a>도 같은 이유로 Inspector 기본값을 auto 가 아닌 legacy 로 뒀다고 적고 있어요. 그래서 데스크톱 호스트처럼 서버를 여러 개 띄우는 앱이라면 <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" target="_blank" rel="noopener">버전·호환성 문서</a>가 권하는 대로 판정 결과를 서버 설정 단위로 저장하고(stdio 는 프로세스 수명 동안, HTTP 는 출처 단위), 저장된 판정이 틀렸을 때만 다시 탐색하는 게 맞아요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/mcp-stateless-spec-client-mode-cache-mrtr-guide-1.svg" alt="붙일 서버가 모두 새 스펙인지, 서버별 시대를 설정에 적을 수 있는지, 전송이 stdio 인지 차례로 물어 버전 고정·서버별 기록·auto 와 결과 캐시·HTTP auto 중 하나로 가는 순서도" width="800" height="520" loading="lazy" decoding="async"><figcaption>HTTP 는 400 응답 본문으로 바로 판정돼 10초 함정이 없어요</figcaption></figure>
<h2 id="s3">서버는 새 스펙만 받을까, 듀얼로 받을까</h2>
<p>서버 쪽 판단은 호환 행렬 하나로 끝나요. 버전·호환성 문서의 표를 서버 운영자 입장으로 줄이면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>내 서버</th><th>상대 클라이언트</th><th>결과</th></tr></thead><tbody><tr><td>새 스펙만</td><td>새 스펙 또는 듀얼</td><td>동작</td></tr><tr><td>새 스펙만</td><td>옛 스펙만</td><td>실패 — 옛 클라이언트는 위로 올라갈 방법이 없음</td></tr><tr><td>듀얼</td><td>누구든</td><td>동작 — 첫 요청 모양(<code>initialize</code> 냐 <code>_meta</code> 냐)으로 갈림</td></tr></tbody></table></div>
<p>판단 기준은 <strong>내가 클라이언트를 통제하느냐</strong>예요. 사내 에이전트처럼 클라이언트 버전을 같이 올릴 수 있으면 새 스펙만 받아도 돼요. 외부 사용자가 각자 쓰는 IDE·데스크톱 앱에서 붙는 공개 서버라면, 옛 클라이언트가 남아 있는 동안 듀얼로 두는 게 안전해요. 새 스펙만 받기로 했다면 <code>initialize</code> 요청에 돌려주는 오류 메시지에 지원 버전을 적으라고 스펙이 권해요. 옛 클라이언트가 사용자에게 보여 줄 수 있는 단서가 그것뿐이라서예요.</p>
<p>실측한 SDK 서버는 버전을 모르는 요청에 HTTP 400 과 <code>-32022</code> 오류, <code>data.supported: ["2026-07-28"]</code> 를 돌려줬어요. 필수 <code>_meta</code> 키가 빠지면 400 과 <code>-32602</code>, <code>Mcp-Name</code> 헤더가 본문의 도구 이름과 다르면 400 과 <code>-32020</code> 이었어요. 게이트웨이 로그에서 400 이 갑자기 늘면 이 세 코드를 먼저 나눠 보면 원인이 바로 갈려요. 오류 본문 JSON 이 길면 <a href="https://tool.smithlab.co.kr/developer/json-formatter/">JSON 정렬 도구</a>에 붙여 <code>error.code</code> 와 <code>data</code> 만 보면 돼요.</p>
<p>참고로 매 요청에 버전·클라이언트 정보·능력을 싣는 비용은 작아요. 인자 두 개짜리 <code>tools/call</code> 본문이 96바이트에서 <code>_meta</code> 를 넣고 304바이트가 됐어요. 요청당 약 200바이트라 압축이나 대역폭을 걱정할 수준은 아니에요.</p>
<h2 id="s4">tools/list 캐시 — ttlMs·cacheScope 값 고르기</h2>
<p>새 스펙에서는 <code>tools/list</code>·<code>resources/list</code>·<code>prompts/list</code>·<code>resources/read</code>·<code>server/discover</code> 결과에 <code>ttlMs</code> 와 <code>cacheScope</code> 가 꼭 붙어요. <a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching" target="_blank" rel="noopener">캐싱 스펙</a>의 규칙을 정리하면 이래요.</p>
<ul><li><code>ttlMs</code> 는 HTTP <code>max-age</code> 처럼 "이 시간 동안은 다시 안 물어도 된다"는 힌트예요. 보장이 아니라서 그 사이에 목록이 바뀔 수도 있어요.</li><li>클라이언트는 TTL 을 <strong>주기적 재조회 간격으로 쓰면 안 되고</strong>, 필요할 때 신선도를 보고 지났으면 다시 받아요. 굳이 폴링한다면 지터와 백오프를 넣어야 해요.</li><li><code>public</code> 은 모든 사용자에게 같은 목록이라 게이트웨이가 공유 캐시해도 되는 경우, <code>private</code> 은 토큰이 다르면 캐시를 나눠야 하는 경우예요.</li><li><code>list_changed</code> 알림을 받으면 TTL 이 남아 있어도 바로 버려요. 새 스펙에서 이 알림은 <code>subscriptions/listen</code> 스트림을 열어 둔 클라이언트만 받아요.</li></ul>
<p>SDK 의 <code>MCPServer</code> 를 그냥 띄우면 <code>tools/list</code> 응답에 <code>ttlMs: 0</code>, <code>cacheScope: "private"</code> 가 실려요. 클라이언트 캐시가 있어도 매번 다시 묻게 되는 값이에요. <code>cache_hints</code> 로 바꿔 보고 같은 연결에서 <code>list_tools()</code> 를 300번 불렀어요.</p>
<div class="table-wrap"><table><thead><tr><th>서버 설정</th><th>응답 힌트</th><th>목록 조회 중앙값</th></tr></thead><tbody><tr><td>기본값</td><td><code>ttlMs: 0</code>, <code>private</code></td><td>5.73ms (매번 서버 왕복)</td></tr><tr><td><code>CacheHint(ttl_ms=300000, scope="public")</code></td><td><code>ttlMs: 300000</code>, <code>public</code></td><td>0.35ms (클라이언트 캐시)</td></tr></tbody></table></div>
<pre class="code-block" data-lang="python"><code>from mcp.server import MCPServer
from mcp.server.caching import CacheHint

mcp = MCPServer(
    &quot;repo-tools&quot;,
    cache_hints={&quot;tools/list&quot;: CacheHint(ttl_ms=10 * 60 * 1000, scope=&quot;public&quot;)},
)</code></pre>
<p>값을 고르는 기준은 <strong>도구 목록이 실제로 얼마나 자주 바뀌느냐</strong>예요.</p>
<div class="table-wrap"><table><thead><tr><th>목록 성격</th><th>추천 <code>ttlMs</code></th><th><code>cacheScope</code></th></tr></thead><tbody><tr><td>배포 때만 바뀌는 고정 도구</td><td>10분~1시간</td><td>모두 같으면 <code>public</code></td></tr><tr><td>권한·요금제에 따라 사용자마다 다른 목록</td><td>5~15분</td><td><code>private</code> (공유하면 남의 도구가 보임)</td></tr><tr><td>기능 플래그로 수시로 켜고 끄는 도구</td><td>1분 안팎 + <code>list_changed</code> 알림</td><td>상황에 따라</td></tr></tbody></table></div>
<p>SDK 클라이언트는 받은 TTL 을 최대 24시간으로 잘라요. 그보다 길게 줘도 효과는 같아요. 그리고 <code>public</code> 은 "같은 인가 맥락이 아니어도 공유된다"는 뜻이라, 캐시 범위를 접근 통제 수단으로 쓰면 안 된다고 스펙이 따로 경고해요. 도구 호출 자체의 권한 검사는 매 호출에서 해야 해요.</p>
<h2 id="s5">도구 순서가 바뀌면 드는 돈 — 프롬프트 캐시 계산</h2>
<p>목록 캐시보다 돈이 더 크게 걸린 곳은 모델 API 쪽이에요. 호스트는 MCP 에서 받은 도구 정의를 모델 요청의 <code>tools</code> 배열로 넣는데, 모델 제공자의 프롬프트 캐시는 요청 앞부분이 한 글자라도 다르면 맞지 않아요. 그래서 변경 기록에 <strong>도구 목록을 결정적인 순서로 돌려주라</strong>는 권고가 들어갔고, <a href="https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices" target="_blank" rel="noopener">클라이언트 모범 사례</a> 문서도 대화 중간에 도구를 넣고 빼면 캐시가 깨진다고 짚어요.</p>
<p>순서가 흔들리는 흔한 원인은 서버가 집합(set)이나 해시 맵 순서로 목록을 만들 때, 그리고 호스트가 여러 서버 목록을 <strong>도착한 순서대로</strong> 합칠 때예요. 짧은 <code>ttlMs</code> 로 목록을 자주 다시 받을수록 순서가 흔들릴 기회도 많아져요.</p>
<p>계산 조건은 이래요. 시스템 프롬프트와 도구 정의를 합친 앞부분 1만 토큰, 작업 하나에 모델 호출 10번, 월 1만 작업. 캐싱은 5분 캐시를 쓰고, 순서가 고정이면 작업마다 첫 호출 한 번 쓰기·나머지 아홉 번 읽기, 순서가 매번 바뀌면 열 번 모두 쓰기로 봤어요. 단가는 가격표의 기본 입력·5분 캐시 쓰기·캐시 읽기 값(Sonnet 5.5 기준 100만 토큰당 2달러·2.50달러·0.10달러)이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">도구 순서와 캐싱에 따른 앞부분 1만 토큰의 월 비용</figcaption><div class="viz-legend"><span><i class="s1"></i>캐싱 안 씀</span><span><i class="s2"></i>순서 고정 + 캐싱</span><span><i class="s3"></i>순서 흔들림 + 캐싱</span></div><svg viewBox="0 0 640 300" role="img" aria-label="도구 순서와 캐싱에 따른 앞부분 1만 토큰의 월 비용" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">1,000</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">2,000</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">3,000</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">4,000</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">5,000</text><rect class="s1" x="80.8" y="249.3" width="43.5" height="4.7" rx="3"><title>Haiku 5.5 · 캐싱 안 씀 100달러</title></rect><rect class="s2" x="126.3" y="253.0" width="43.5" height="1.0" rx="3"><title>Haiku 5.5 · 순서 고정 + 캐싱 21.5달러</title></rect><rect class="s3" x="171.7" y="248.1" width="43.5" height="5.9" rx="3"><title>Haiku 5.5 · 순서 흔들림 + 캐싱 125달러</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">Haiku 5.5</text><rect class="s1" x="272.8" y="159.6" width="43.5" height="94.4" rx="3"><title>Sonnet 5.5 · 캐싱 안 씀 2,000달러</title></rect><rect class="s2" x="318.3" y="238.0" width="43.5" height="16.0" rx="3"><title>Sonnet 5.5 · 순서 고정 + 캐싱 340달러</title></rect><rect class="s3" x="363.7" y="136.0" width="43.5" height="118.0" rx="3"><title>Sonnet 5.5 · 순서 흔들림 + 캐싱 2,500달러</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">Sonnet 5.5</text><rect class="s1" x="464.8" y="65.2" width="43.5" height="188.8" rx="3"><title>Opus 5.5 · 캐싱 안 씀 4,000달러</title></rect><rect class="s2" x="510.3" y="221.9" width="43.5" height="32.1" rx="3"><title>Opus 5.5 · 순서 고정 + 캐싱 680달러</title></rect><rect class="s3" x="555.7" y="18.0" width="43.5" height="236.0" rx="3"><title>Opus 5.5 · 순서 흔들림 + 캐싱 5,000달러</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">Opus 5.5</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">출력 토큰과 대화 기록 토큰은 뺀 앞부분 몫만이에요</p><div class="viz-cap">단위: 달러 · 자료: Anthropic 공식 가격표(2026-11-03 확인) 기준 직접 계산, 월 1만 작업 × 작업당 10회 호출</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>캐싱 안 씀</th><th>순서 고정 + 캐싱</th><th>순서 흔들림 + 캐싱</th></tr></thead><tbody><tr><td>Haiku 5.5</td><td>100</td><td>21.5</td><td>125</td></tr><tr><td>Sonnet 5.5</td><td>2,000</td><td>340</td><td>2,500</td></tr><tr><td>Opus 5.5</td><td>4,000</td><td>680</td><td>5,000</td></tr></tbody></table></div></details></figure>
<p>Sonnet 5.5 로 풀면 순서 고정은 작업당 1만 × (2.50 + 9 × 0.10) ÷ 100만 = 0.034달러, 월 340달러예요. 순서가 흔들리면 1만 × 10 × 2.50 ÷ 100만 = 0.25달러, 월 2,500달러로 캐싱을 아예 끈 2,000달러보다 25% 비싸요. 캐시 쓰기가 기본 입력보다 1.25배 비싼데 읽기는 한 번도 못 하기 때문이에요. 캐시가 실제로 맞는지는 모델 응답의 <code>cache_read_input_tokens</code> 가 0보다 큰지로 확인해요. TTL 과 캐시 쓰기 단가의 손익은 <a href="https://tool.smithlab.co.kr/posts/developer/prompt-caching-cost-ttl-self-hosting-selection-guide/">프롬프트 캐싱 비용 계산</a>에서 따로 다뤘어요.</p>
<h2 id="s6">응답 방식 — JSON 하나 vs 요청별 SSE</h2>
<p><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" target="_blank" rel="noopener">Streamable HTTP 스펙</a>에서 서버는 요청마다 JSON 하나로 답할지, 그 요청 전용 SSE 스트림을 열지 고를 수 있고 클라이언트는 둘 다 받아야 해요. 파이썬 SDK 는 <code>mcp.run("streamable-http", json_response=True)</code> 로 JSON, 기본값(<code>False</code>)으로 SSE 를 써요. 연결을 연 뒤 덧셈 도구를 500번 불렀어요.</p>
<div class="table-wrap"><table><thead><tr><th>응답 형식</th><th>버전 고정 중앙값</th><th>99퍼센타일</th><th>legacy 모드 중앙값</th></tr></thead><tbody><tr><td>JSON 하나</td><td>6.10ms</td><td>8.94ms</td><td>6.40ms</td></tr><tr><td>요청별 SSE</td><td>6.44ms</td><td>8.83ms</td><td>8.53ms</td></tr></tbody></table></div>
<p>새 스펙에서는 차이가 5% 남짓이라 지연으로 고를 일은 아니에요. 고르는 기준은 기능이에요.</p>
<ul><li><strong>SSE 가 필요한 경우</strong>: 수십 초 걸리는 도구라 <code>notifications/progress</code> 로 진행률을 보내고 싶을 때, 그리고 사용자가 취소하면 바로 멈춰야 할 때예요. 새 스펙에서는 클라이언트가 그 요청의 SSE 스트림을 닫는 것 자체가 취소 신호예요.</li><li><strong>JSON 이 나은 경우</strong>: 조회처럼 금방 끝나는 도구가 대부분일 때예요. 프록시·API 게이트웨이·서버리스 플랫폼의 응답 버퍼링 문제를 신경 쓸 필요가 없어요.</li><li>SSE 를 쓴다면 nginx 같은 리버스 프록시가 이벤트를 모아 두지 않게 응답에 <code>X-Accel-Buffering: no</code> 를 넣으라고 스펙이 권해요.</li></ul>
<p>스트림이 중간에 끊기면 새 스펙에서는 <code>Last-Event-ID</code> 로 이어받을 수 없고 클라이언트가 새 id 로 다시 보내요. 결제·이슈 생성처럼 두 번 실행되면 안 되는 도구는 인자에 멱등 키를 받는 쪽으로 설계해야 해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s7">되묻는 도구 — MRTR 비용과 requestState 봉인</h2>
<p>도구가 실행 중에 사용자 확인을 받아야 할 때, 새 스펙은 서버가 클라이언트에게 요청을 거꾸로 보내는 대신 <code>resultType: "input_required"</code> 로 일단 끝내고 클라이언트가 답을 붙여 원래 요청을 다시 보내게 해요(<a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr" target="_blank" rel="noopener">MRTR 패턴 문서</a>).</p>
<p>처음에 예전 방식대로 도구 안에서 <code>await ctx.elicit(...)</code> 를 부르게 짰더니, 버전 고정 HTTP 연결에서 <code>Cannot send 'elicitation/create': this transport context has no back-channel</code> 오류가 났어요. 새 스펙 연결에는 서버가 거꾸로 말할 통로가 없어서예요. SDK 2.3.0 에서는 질문을 리졸버로 빼서 선언하면, 연결이 새 스펙이면 MRTR 로, 옛 스펙이면 예전 방식으로 알아서 바꿔 줘요.</p>
<pre class="code-block" data-lang="python"><code>from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Elicit
from mcp.server.request_state import RequestStateSecurity

mcp = MCPServer(&quot;repo-tools&quot;, request_state_security=RequestStateSecurity.ephemeral(ttl=300))

class Confirm(BaseModel):
    ok: bool

def ask(name: str):
    return Elicit(f&quot;{name} 저장소를 지울까요?&quot;, Confirm)

@mcp.tool()
async def delete_repo(name: str, confirm: Annotated[Confirm, Resolve(ask)]) -&gt; str:
    &quot;&quot;&quot;저장소를 지우기 전에 확인을 받아요.&quot;&quot;&quot;
    return &quot;deleted&quot; if confirm.ok else &quot;kept&quot;</code></pre>
<p>같은 연결에서 확인이 필요 없는 도구와 이 도구를 번갈아 300번 불렀어요. 클라이언트의 확인 콜백은 바로 승인하게 해서 사람 응답 시간은 뺐어요.</p>
<div class="table-wrap"><table><thead><tr><th>측정</th><th>값</th></tr></thead><tbody><tr><td>일반 도구 호출 중앙값</td><td>4.99ms</td></tr><tr><td>MRTR 도구 호출 중앙값 (요청 2번)</td><td>12.73ms</td></tr><tr><td>첫 응답의 <code>requestState</code> 길이</td><td>338자 (첫 응답 본문 762바이트)</td></tr><tr><td>봉인 값을 3글자 바꿔 다시 보냄</td><td>HTTP 400, <code>-32602</code> Invalid or expired requestState</td></tr><tr><td>AES-GCM 봉인+해제 / HMAC 서명+검증 1회</td><td>10µs / 16µs</td></tr></tbody></table></div>
<p>MRTR 은 왕복이 하나 더 생기지만 사람이 답하는 데 초 단위가 걸리니 이 7~8ms 는 의미가 없어요. 따져야 할 건 보안이에요. 스펙은 <code>requestState</code> 를 공격자가 조작할 수 있는 입력으로 보고, 권한이나 업무 로직에 영향을 주면 무결성 보호를 <strong>반드시</strong> 하라고 해요. 그리고 봉투 안에 요청한 사용자, 짧은 만료, 원래 요청 식별값을 넣어 받을 때 확인하라고 권해요. 위 실측에서 SDK 는 봉인 값을 한 글자만 바꿔도 거절했어요. HMAC 이 어떤 값인지 감을 잡고 싶다면 <a href="https://tool.smithlab.co.kr/developer/hash-generator/">해시 생성기</a>의 HMAC 칸에 키와 메시지를 넣어 한 글자 바꿀 때 결과가 통째로 바뀌는 걸 보면 돼요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/mcp-stateless-spec-client-mode-cache-mrtr-guide-2.svg" alt="requestState 봉투 안에 넣을 사용자·만료·원래 요청 다이제스트·받은 답, 가운데 AES-GCM 또는 HMAC 봉인, 오른쪽 재요청 때 거절할 네 경우를 나란히 놓은 그림" width="800" height="480" loading="lazy" decoding="async"><figcaption>봉인은 재사용 범위를 좁힐 뿐 한 번만 쓰게 하지는 못해요</figcaption></figure>
<aside class="callout warn"><b class="callout-h">주의</b><p>봉인과 만료만으로는 같은 <code>requestState</code> 를 만료 전에 두 번 쓰는 걸 막지 못해요. 쿠폰 사용이나 송금처럼 딱 한 번이어야 하는 처리는 스펙이 요구하는 대로 서버 저장소에 사용 기록을 남겨 직접 막아야 해요. SDK 의 <code>RequestStateSecurity</code> 만료 기본값은 600초라 민감한 도구는 위 코드처럼 줄여 두는 게 좋아요. 서버를 여러 대 띄운다면 <code>ephemeral()</code> 은 프로세스마다 키를 새로 만들어서 다른 인스턴스가 봉인을 못 풀어요. 키를 공유하는 생성자로 바꿔야 해요.</p></aside>
<h2 id="s8">상황별 추천</h2>
<ul><li><strong>사내 에이전트가 사내 MCP 서버 몇 개만 쓸 때</strong>: 서버와 클라이언트를 함께 올리고 클라이언트는 <code>"2026-07-28"</code> 고정, 서버는 새 스펙만 받아요. 왕복이 가장 적고 설정이 단순해요.</li><li><strong>여러 사람이 각자 서버를 붙이는 데스크톱·IDE 호스트</strong>: auto 로 처음 한 번 판정해 서버 설정 옆에 저장하고, 다음부터는 저장된 값으로 바로 붙어요. 침묵하는 옛 서버 때문에 앱 시작이 10초씩 늘어나는 걸 막는 방법이에요.</li><li><strong>외부에 공개하는 원격 서버</strong>: 듀얼로 받고, <code>initialize</code> 오류 메시지에 지원 버전을 적어요. <code>tools/list</code> 는 <code>ttlMs</code> 10분 이상, 사용자마다 같으면 <code>public</code>.</li><li><strong>도구가 많은 범용 에이전트</strong>: 목록 순서 고정이 먼저예요. 여러 서버를 합칠 때 서버 이름·도구 이름순으로 정렬해 두면 위 계산의 2,500달러가 340달러 쪽으로 돌아와요.</li><li><strong>오래 걸리는 빌드·배포 도구</strong>: SSE 로 진행률을 보내고 멱등 키를 받아요.</li><li><strong>삭제·결제 전 확인이 필요한 도구</strong>: 리졸버 + MRTR, <code>requestState</code> 만료 5분 안쪽, 1회용이면 서버에 기록.</li></ul>
<h2 id="s9">전환 체크리스트</h2>
<ol><li>클라이언트 모드를 설정 파일에 적었고, auto 판정 결과를 서버 설정 단위로 저장하나요?</li><li>새 스펙만 받는 서버라면 <code>initialize</code> 오류 메시지에 지원 버전을 넣었나요?</li><li>실제 <code>tools/list</code> 응답의 <code>ttlMs</code> 가 0이 아니고, 사용자별 목록에 <code>public</code> 을 주고 있지 않나요?</li><li>같은 목록 요청을 두 번 보내 <code>tools</code> 배열이 바이트 단위로 같은가요? 모델 응답의 캐시 읽기 토큰이 두 번째 호출부터 0보다 큰가요?</li><li>오래 걸리는 도구만 SSE 로 답하고 프록시 버퍼링을 껐나요? 부작용 있는 도구는 재요청돼도 한 번만 처리되나요?</li><li><code>ctx.elicit</code> 를 리졸버 방식으로 바꿨고, 여러 인스턴스가 같은 <code>requestState</code> 키를 쓰나요?</li><li>게이트웨이 400 오류를 <code>-32022</code>·<code>-32602</code>·<code>-32020</code> 별로 나눠 보고 있나요?</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s10">자주 묻는 질문</h2>
<h3>2026-07-28 이전 서버는 이제 못 쓰나요?</h3>
<p>쓸 수 있어요. 양쪽을 다 지원하는 클라이언트(파이썬 SDK 의 auto 모드 등)는 옛 서버를 만나면 <code>initialize</code> 로 내려가요. 못 붙는 조합은 새 스펙만 아는 클라이언트와 옛 서버, 그리고 옛 클라이언트와 새 스펙만 받는 서버예요.</p>
<h3>auto 모드로 두면 손해 보는 건 없나요?</h3>
<p>새 스펙 서버에는 탐색 왕복이 한 번 더 붙고(로컬 실측 19.9ms → 27.6ms), 침묵하는 옛 stdio 서버에는 10초를 기다려요. 판정 결과를 저장하면 둘 다 처음 한 번으로 끝나요.</p>
<h3>ttlMs 를 길게 주면 도구를 추가해도 안 보이지 않나요?</h3>
<p>TTL 이 끝날 때까지는 안 보일 수 있어요. 바로 반영돼야 하면 서버가 <code>listChanged</code> 능력을 알리고 목록이 바뀔 때 알림을 보내면, 구독 중인 클라이언트는 TTL 과 상관없이 캐시를 버려요. 구독하지 않는 클라이언트는 도구 호출이 "없는 도구" 오류로 실패할 때 다시 받아도 된다고 스펙에 적혀 있어요.</p>
<h3>requestState 는 서버 세션과 뭐가 다른가요?</h3>
<p>상태를 서버 메모리가 아니라 응답에 실어 클라이언트에게 맡긴다는 점이 달라요. 그래서 어느 인스턴스가 재요청을 받아도 처리할 수 있어요. 대신 클라이언트를 거쳐 오므로 봉인과 만료 확인이 필수이고, 인스턴스끼리 봉인 키를 공유해야 해요.</p>
<h3>JSON 응답으로 정하면 진행률은 못 보내나요?</h3>
<p>요청 하나에 JSON 하나로 답하면 중간 알림을 끼울 자리가 없어요. 서버는 요청마다 형식을 고를 수 있으니, 오래 걸리는 도구만 SSE 로 답하고 나머지는 JSON 으로 두는 식으로 섞어도 돼요.</p>
<h2 id="s11">정리</h2>
<ul><li>접속 모드는 상대를 알면 버전 고정, 모르면 auto 로 한 번 판정해 저장해요. 고정이 첫 결과까지 왕복 2번으로 가장 적고, 저장 안 한 auto 는 침묵하는 옛 stdio 서버에 10초를 써요.</li><li>SDK 서버의 <code>tools/list</code> 기본 힌트는 <code>ttlMs: 0</code> 이에요. 몇 분 단위로 주면 목록 조회가 클라이언트 캐시로 끝나요.</li><li>도구 순서를 고정하지 않으면 모델 쪽 캐시가 매번 깨져 캐싱을 안 쓸 때보다 비싸져요. Sonnet 5.5 예시에서 340달러가 2,500달러가 됐어요.</li><li>응답 형식은 지연이 아니라 진행률·취소 필요 여부로 고르고, 되묻는 도구는 리졸버 + MRTR 로 만들되 <code>requestState</code> 를 봉인하고 만료를 짧게 둬요.</li></ul>
<p>MCP 를 쓸지 말지, stdio 와 HTTP 중 무엇을 쓸지는 <a href="https://tool.smithlab.co.kr/posts/developer/mcp-adoption-transport-tool-token-cost-guide/">MCP 도입 판단표</a>에, SDK 코드와 예제는 <a href="https://github.com/modelcontextprotocol/python-sdk" target="_blank" rel="noopener">MCP 공식 파이썬 SDK 저장소</a>에 있어요.</p>
<h2 id="s12">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/mcp-model-context-protocol-how-it-works" target="_blank" rel="noopener">MCP 동작 원리 — tools/call 흐름과 무상태 스펙</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>NestJS durable 프로바이더 언제 쓰나, 테넌트 메모리와 점검표</title>
      <link>https://tool.smithlab.co.kr/posts/developer/nestjs-durable-provider-multi-tenant-memory-checklist/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/nestjs-durable-provider-multi-tenant-memory-checklist/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>멀티테넌트 NestJS 서비스에서 "테넌트마다 DB 연결을 따로 잡고 싶은데 요청 스코프는 느리다고 하니 durable 을 써 볼까" 하는 순간이 와요. 결론부터 말하면 <strong>durable 프로바이더는 테넌트가 수십 곳 안쪽으로 정해져 있고, 테넌트마다 진짜로 다른 객체(전용 연결·전용 클라이언트)가 필요할 때만 맞아요.</strong> 테넌트 ID 를 읽기만 한다면 싱글턴 + AsyncLocalStorage 가 더 싸요.</p>
<p>이 글은 <strong>설정을 고르고 운영할 때 필요한 숫자와 확인 순서</strong>를 다뤄요. <code>@nestjs/core</code> 12.3.2·Express 5.3 어댑터로 작은 서버를 띄워 Apple M1·Node.js 25.3 에서 직접 쟀고, 규칙은 <a href="https://docs.nestjs.com/fundamentals/injection-scopes" target="_blank" rel="noopener">NestJS 공식 문서 Injection scopes</a>의 Durable providers 절과 12.3.2 패키지 소스로 다시 확인했어요. 스코프 전반과 요청 스코프 처리량은 <a href="https://tool.smithlab.co.kr/posts/developer/nestjs-provider-scope-module-design-di-error-checklist/">NestJS 프로바이더 스코프 고르기 글</a>에 있어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>빈 클래스만으로 잰 테넌트 트리 크기는 인스턴스 5개일 때 약 3.4KB, 12개일 때 7.6KB, 32개일 때 19.5KB 였어요. 테넌트 10만 곳이면 인스턴스 12개 체인만으로 약 760MB 예요.</li><li>체인에 durable 이 아닌 요청 스코프 클래스, 또는 <code>@Inject(REQUEST)</code> 를 받는 평범한 클래스가 하나만 있어도 위쪽 전체가 다시 요청마다 만들어졌어요.</li><li>헤더 값을 그대로 키로 쓰지 말고 허용 목록으로 거르고, 트리를 내보낼 때는 <code>onModuleDestroy</code> 가 불리지 않으니 연결을 직접 닫아야 해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">durable 프로바이더 쓸까 말까 — 판단표</h2>
<p>공식 문서는 durable 을 "고객사 10곳이 각자 전용 데이터 소스를 쓰는" 경우로 설명하고, 테넌트가 많으면 맞지 않는다고 경고해요.</p>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>고를 것</th><th>이유</th></tr></thead><tbody><tr><td>테넌트 ID·사용자·로케일을 서비스에서 읽기만 함</td><td>싱글턴 + AsyncLocalStorage</td><td>트리를 만들 일이 없어요. 문서도 이 경우 이 방식을 먼저 권해요</td></tr><tr><td>테넌트 10~50곳 고정, 테넌트마다 전용 DB·스키마 연결</td><td><code>REQUEST</code> + <code>durable: true</code></td><td>테넌트당 트리 한 벌, 같은 테넌트 요청은 생성자 호출 없음</td></tr><tr><td>테넌트 수백 곳 이상이거나 가입으로 계속 늘어남</td><td>싱글턴 연결 관리자 + AsyncLocalStorage</td><td>연결 수·메모리를 관리자 한 곳에서 한도로 묶을 수 있어요</td></tr><tr><td>요청 UUID 기반 캐시처럼 요청마다 진짜로 다른 상태</td><td><code>REQUEST</code>(durable 아님)</td><td>묶을 수 있는 공통 속성이 없어요</td></tr><tr><td>테넌트별 설정값(요금제·기능 플래그) 몇 개만 다름</td><td>싱글턴 + 설정 조회</td><td>객체를 테넌트마다 만들 이유가 없어요</td></tr></tbody></table></div>
<p>핵심 질문은 "<strong>테넌트마다 다른 인스턴스가 꼭 있어야 하나</strong>"예요. 값만 다르면 값을 넘기면 돼요. AsyncLocalStorage 쓰는 법은 <a href="https://docs.nestjs.com/recipes/async-local-storage" target="_blank" rel="noopener">NestJS Async local storage 레시피</a>와 <a href="https://nodejs.org/api/async_context.html" target="_blank" rel="noopener">Node.js async_context 문서</a>에 있어요.</p>
<h2 id="s2">durable 이 안 먹는 세 가지 경우 — 생성자 횟수로 확인</h2>
<p>durable 은 위로 번져요. 문서 설명대로 A 가 durable 인 B 에 의존하면 A 도 durable 이 되는데, <strong>A 의 다른 요청 스코프 의존성이 모두 durable 일 때만</strong>이에요. 하나라도 아니면 A 는 평범한 요청 스코프로 남고, 그 위의 컨트롤러도 요청마다 새로 만들어져요.</p>
<p><code>Controller → OrderService → TenantConn(durable)</code> 체인에 테넌트 a·b·a·a·b·c 순서로 여섯 번 요청하고, <code>OrderService</code> 에 의존성 하나를 더 붙인 두 변형도 같은 순서로 돌렸어요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">요청 6번(테넌트 3곳) 동안 생성자가 불린 횟수</figcaption><div class="viz-legend"><span><i class="s1"></i>Controller</span><span><i class="s2"></i>OrderService</span><span><i class="s3"></i>TenantConn</span></div><svg viewBox="0 0 640 300" role="img" aria-label="요청 6번(테넌트 3곳) 동안 생성자가 불린 횟수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">1</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">2</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">3</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">4</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">6</text><rect class="s1" x="80.8" y="136.0" width="43.5" height="118.0" rx="3"><title>① durable 만 · Controller 3번</title></rect><rect class="s2" x="126.3" y="136.0" width="43.5" height="118.0" rx="3"><title>① durable 만 · OrderService 3번</title></rect><rect class="s3" x="171.7" y="136.0" width="43.5" height="118.0" rx="3"><title>① durable 만 · TenantConn 3번</title></rect><text class="viz-ax" x="148.0" y="272" text-anchor="middle">① durable 만</text><rect class="s1" x="272.8" y="18.0" width="43.5" height="236.0" rx="3"><title>② 요청 스코프 섞임 · Controller 6번</title></rect><rect class="s2" x="318.3" y="18.0" width="43.5" height="236.0" rx="3"><title>② 요청 스코프 섞임 · OrderService 6번</title></rect><rect class="s3" x="363.7" y="136.0" width="43.5" height="118.0" rx="3"><title>② 요청 스코프 섞임 · TenantConn 3번</title></rect><text class="viz-ax" x="340.0" y="272" text-anchor="middle">② 요청 스코프 섞임</text><rect class="s1" x="464.8" y="18.0" width="43.5" height="236.0" rx="3"><title>③ REQUEST 직접 주입 · Controller 6번</title></rect><rect class="s2" x="510.3" y="18.0" width="43.5" height="236.0" rx="3"><title>③ REQUEST 직접 주입 · OrderService 6번</title></rect><rect class="s3" x="555.7" y="136.0" width="43.5" height="118.0" rx="3"><title>③ REQUEST 직접 주입 · TenantConn 3번</title></rect><text class="viz-ax" x="532.0" y="272" text-anchor="middle">③ REQUEST 직접 주입</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">3번이면 테넌트마다 한 번, 6번이면 요청마다 한 번 만들어졌다는 뜻이에요</p><div class="viz-cap">단위: 번 · 자료: @nestjs/core 12.3.2·Express 5.3, 생성자 안에서 횟수를 세어 직접 확인</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>Controller</th><th>OrderService</th><th>TenantConn</th></tr></thead><tbody><tr><td>① durable 만</td><td>3</td><td>3</td><td>3</td></tr><tr><td>② 요청 스코프 섞임</td><td>6</td><td>6</td><td>3</td></tr><tr><td>③ REQUEST 직접 주입</td><td>6</td><td>6</td><td>3</td></tr></tbody></table></div></details></figure>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/nestjs-durable-provider-multi-tenant-memory-checklist-1.svg" alt="durable 만 있는 체인은 Controller 가 3번, durable 아닌 요청 스코프 클래스가 섞인 체인과 @Inject(REQUEST) 를 받는 평범한 클래스가 섞인 체인은 Controller 가 6번 만들어진다는 것을 세 열로 비교한 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>TenantConn 은 세 경우 모두 테넌트마다 한 번이지만 그 위가 갈려요</figcaption></figure>
<ul><li><strong>① durable 만</strong> — 테넌트마다 한 번씩, 의도대로 3번이에요.</li><li><strong>② <code>durable</code> 을 안 적은 요청 스코프 클래스가 섞임</strong> — 요청 로거 하나가 <code>OrderService</code> 와 컨트롤러를 요청마다 만들게 했어요.</li><li><strong>③ <code>@Injectable()</code> 만 붙은 클래스가 <code>@Inject(REQUEST)</code> 를 받음</strong> — 놓치기 쉬워요. 12.3.2 의 <code>request-providers.js</code> 에서 <code>REQUEST</code> 프로바이더는 <code>scope: Scope.REQUEST</code> 만 있고 <code>durable</code> 이 없어서, 그걸 받는 평범한 클래스는 durable 이 아닌 요청 스코프로 판정돼요.</li></ul>
<p><code>TenantConn</code> 도 <code>REQUEST</code> 를 받는데 멀쩡한 이유는 <code>durable: true</code> 를 <strong>직접 적었기</strong> 때문이에요. 명시한 클래스는 자기 플래그를 쓰고 의존성을 따지지 않아요. 요청 정보를 읽는 클래스에는 <code>{ scope: Scope.REQUEST, durable: true }</code> 를 적으세요.</p>
<h3>판정 결과를 로그로 보는 법</h3>
<p>환경 변수 <code>NEST_DEBUG=1</code> 을 주고 로거를 켠 채(<code>logger: false</code> 면 안 보여요) 앱을 띄우면 부팅 때 클래스마다 <code>introspected as request-scoped</code>, durable 이면 이어서 <code>introspected as durable</code> 이 찍혀요. ①에서는 <code>TenantConn</code>·<code>OrderService</code>·컨트롤러 셋 다 durable 줄이 나왔고, ②에서는 <code>TenantConn</code> 만 나왔어요. <strong>컨트롤러 이름 옆에 durable 줄이 없으면</strong> 체인 어딘가가 막고 있는 거예요. durable 줄이 없는 가장 아래 클래스가 원인이에요.</p>
<h2 id="s3">테넌트 수별 메모리 — 직접 잰 트리 크기</h2>
<p>durable 트리는 전략의 맵이 테넌트 <code>ContextId</code> 를 쥐는 동안 살아 있어서 메모리는 (테넌트 수 × 트리 크기)로 늘어요. 생성자 말고는 아무것도 없는 빈 서비스를 3·10·30개 이은 체인을 만들고 서로 다른 테넌트 1만 곳으로 한 번씩 요청한 뒤 GC 후 힙 증가량을 나눴어요. 인스턴스 수는 서비스에 <code>TenantConn</code> 과 컨트롤러를 더한 값이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">테넌트 트리 하나가 차지하는 힙 (빈 클래스 기준)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="테넌트 트리 하나가 차지하는 힙 (빈 클래스 기준)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">20</text><rect class="s1" x="80.8" y="213.9" width="134.4" height="40.1" rx="3"><title>인스턴스 5개 · 테넌트당 힙 3.4KB</title></rect><text class="viz-val" x="148.0" y="208.9" text-anchor="middle">3.4</text><text class="viz-ax" x="148.0" y="272" text-anchor="middle">인스턴스 5개</text><rect class="s1" x="272.8" y="164.3" width="134.4" height="89.7" rx="3"><title>인스턴스 12개 · 테넌트당 힙 7.6KB</title></rect><text class="viz-val" x="340.0" y="159.3" text-anchor="middle">7.6</text><text class="viz-ax" x="340.0" y="272" text-anchor="middle">인스턴스 12개</text><rect class="s1" x="464.8" y="23.9" width="134.4" height="230.1" rx="3"><title>인스턴스 32개 · 테넌트당 힙 19.5KB</title></rect><text class="viz-val" x="532.0" y="18.9" text-anchor="middle">19.5</text><text class="viz-ax" x="532.0" y="272" text-anchor="middle">인스턴스 32개</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">실제 서비스는 인스턴스가 연결·캐시·설정을 들고 있어서 이보다 커요. 하한으로 읽으세요</p><div class="viz-cap">단위: KB · 자료: @nestjs/core 12.3.2·Node.js 25.3, 테넌트 1만 곳 요청 후 --expose-gc 로 GC 하고 heapUsed 차이를 테넌트 수로 나눔(2회 재현)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>테넌트당 힙</th></tr></thead><tbody><tr><td>인스턴스 5개</td><td>3.4</td></tr><tr><td>인스턴스 12개</td><td>7.6</td></tr><tr><td>인스턴스 32개</td><td>19.5</td></tr></tbody></table></div></details></figure>
<p>인스턴스 하나가 늘 때마다 약 0.6KB 씩 커졌어요. 테넌트 수를 곱하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>테넌트 수</th><th>인스턴스 5개 체인</th><th>인스턴스 12개 체인</th><th>인스턴스 32개 체인</th></tr></thead><tbody><tr><td>50곳</td><td>0.17MB</td><td>0.38MB</td><td>0.98MB</td></tr><tr><td>1,000곳</td><td>3.4MB</td><td>7.6MB</td><td>19.5MB</td></tr><tr><td>10만 곳</td><td>344MB</td><td>760MB</td><td>1,949MB</td></tr></tbody></table></div>
<p>50곳이면 걱정할 크기가 아니에요. 문제는 <strong>키가 클라이언트 입력으로 무한히 늘 수 있을 때</strong>예요. 헤더 값을 그대로 키로 쓰면 임의 문자열 10만 개만으로 마지막 줄이 생겨요.</p>
<p>시간 비용도 같이 쟀어요. 새 테넌트의 첫 요청은 같은 테넌트 반복 요청보다 인스턴스 5개 체인에서 약 0.17ms, 12개에서 0.31ms, 32개에서 0.80ms 더 걸렸어요(로컬 HTTP 왕복 포함, 1만 회 평균). 실제로 무거운 건 생성자 안에서 여는 DB 연결이에요.</p>
<h2 id="s4">테넌트별 커넥션 풀 계산 — DB 연결 한도부터</h2>
<p>durable 을 쓰는 가장 흔한 이유가 테넌트별 DB 연결이에요. 이때 먼저 터지는 건 Node 메모리가 아니라 <strong>DB 쪽 연결 수</strong>예요. 계산식은 간단해요.</p>
<p>DB 서버 하나가 받는 연결 수 = (그 서버에 있는 테넌트 수) × (테넌트당 풀 크기) × (앱 파드 수)</p>
<p>PostgreSQL 은 <a href="https://www.postgresql.org/docs/current/runtime-config-connection.html" target="_blank" rel="noopener">공식 문서 max_connections 항목</a>에 기본값이 보통 100 이라고 적혀 있어요. 예를 들어 볼게요.</p>
<div class="table-wrap"><table><thead><tr><th>구성</th><th>계산</th><th>DB 서버당 연결</th></tr></thead><tbody><tr><td>테넌트마다 DB 서버 따로, 풀 10, 파드 4</td><td>1 × 10 × 4</td><td>40</td></tr><tr><td>DB 서버 하나에 스키마로 나눈 테넌트 10곳, 풀 10, 파드 4</td><td>10 × 10 × 4</td><td>400</td></tr><tr><td>같은 구성, 풀 2 로 줄임</td><td>10 × 2 × 4</td><td>80</td></tr><tr><td>같은 구성, 파드 10 으로 오토스케일</td><td>10 × 2 × 10</td><td>200</td></tr></tbody></table></div>
<p>두 번째 줄이 흔한 함정이에요. 스키마로 나눈 테넌트마다 풀을 만들면 기본 한도 100 의 네 배예요. 풀 크기는 (DB 한도 − 여유분) ÷ (테넌트 수 × 최대 파드 수)로 거꾸로 정하는 게 안전해요. 위 예에서 여유분 20 을 남기면 (100 − 20) ÷ (10 × 10) = 0.8 이라 테넌트별 풀은 성립하지 않고, 공유 풀 하나에서 요청마다 <code>SET search_path</code> 로 스키마를 바꾸는 방식이 맞아요. 풀 크기를 스레드·작업 수로 거꾸로 계산하는 방법은 <a href="https://tool.smithlab.co.kr/posts/developer/spring-batch-partitioning-gridsize-thread-pool-sizing-guide/">스프링 배치 파티셔닝의 커넥션 풀 계산</a>과 같은 방식이에요.</p>
<h2 id="s5">테넌트 키 설계 — 허용 목록과 퇴출</h2>
<p><code>ContextIdStrategy.attach()</code> 는 요청마다 불려 어느 트리를 쓸지 정해요. 문서 예제는 헤더 값을 맵 키로 바로 쓰는데, 운영에서는 두 가지를 더해야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/nestjs-durable-provider-multi-tenant-memory-checklist-2.svg" alt="요청이 오면 attach 가 허용 목록을 확인해 알려진 테넌트는 맵의 ContextId 를 쓰고 모르는 값은 요청 ContextId 를 그대로 돌려주며, 맵이 한도를 넘으면 가장 오래 안 쓴 테넌트를 resolve 해 close 한 뒤 맵에서 지우는 흐름" width="800" height="450" loading="lazy" decoding="async"><figcaption>모르는 테넌트는 트리를 남기지 않고, 내보낼 때는 연결부터 닫아요</figcaption></figure>
<h3>모르는 테넌트는 트리를 만들지 않기</h3>
<p>허용 목록에 없는 값이면 원래 요청 <code>ContextId</code> 를 돌려줘요. 그 요청은 평범한 요청 스코프로 돌고 끝나면 GC 되며, 거절은 가드에서 해요. 임의 테넌트 1만 개로 다시 재 보니 인스턴스 12개 체인에서 테넌트당 7.6KB 남던 것이 요청당 약 0.35KB 로 줄었어요(나머지는 측정 오차 수준). 키는 헤더보다 서명된 토큰의 테넌트 클레임에서 가져오는 게 안전해요. 토큰에 테넌트 클레임을 넣는 구조라면 <a href="https://tool.smithlab.co.kr/developer/jwt-decoder/">JWT 디코더</a>로 실제 토큰의 payload 에 그 값이 있는지 먼저 확인해 보세요.</p>
<pre class="code-block" data-lang="typescript"><code>attach(contextId: ContextId, request: Request) {
  const tenant = request.headers[&#x27;x-tenant-id&#x27;] as string | undefined;
  if (!tenant || !this.allowed.has(tenant)) {
    return { resolve: () =&gt; contextId, payload: { tenantId: null } };
  }
  const tree = this.lru.getOrCreate(tenant, () =&gt; ContextIdFactory.create());
  return {
    resolve: (info: HostComponentInfo) =&gt; (info.isTreeDurable ? tree : contextId),
    payload: { tenantId: tenant },
  };
}</code></pre>
<h3>오래 안 쓴 테넌트 내보내기</h3>
<p>맵에서 테넌트 항목을 지우면 그 트리는 다른 데서 참조하지 않는 한 GC 대상이 돼요. 직접 재 보니 1만 곳을 지운 뒤 늘었던 힙의 84~94% 가 돌아왔어요. 그래서 맵을 LRU 로 만들어 상한을 두면 돼요. 함정은 <a href="https://docs.nestjs.com/fundamentals/lifecycle-events" target="_blank" rel="noopener">라이프사이클 이벤트 문서</a>대로 요청 스코프 클래스에서는 <code>onModuleInit</code>·<code>onModuleDestroy</code> 같은 훅이 불리지 않고, 실제로 <code>app.close()</code> 때 durable 인스턴스의 <code>onModuleDestroy</code> 는 안 불렸어요. <strong>맵에서 지우기 전에 연결을 직접 닫아야</strong> 해요.</p>
<p>닫을 인스턴스는 <code>moduleRef.resolve(TenantConn, 테넌트ContextId, { strict: false })</code> 로 꺼내면 돼요. 테넌트 <code>ContextId</code> 를 넘기면 이미 만들어진 인스턴스가 나와요. <code>close()</code> 후 맵에서 지우면 다음 요청에서 새 트리가 생겨요. 처리 중이던 요청이 옛 인스턴스를 쥐고 있을 수 있으니 진행 중 쿼리를 기다리는 정상 종료를 쓰고, 앱 종료 때 맵 전체를 닫는 코드는 싱글턴의 <code>onApplicationShutdown</code> 에 두세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">HTTP 밖에서 쓰기 — 큐 작업·크론에서 테넌트 트리 꺼내기</h2>
<p>durable 트리는 HTTP 라우터가 요청을 받을 때 고르는데, 큐 작업이나 크론에는 요청이 없어요. 이때 <a href="https://docs.nestjs.com/fundamentals/module-ref" target="_blank" rel="noopener">Module reference 문서</a>의 <code>resolve()</code> 를 쓰는데, <code>ContextId</code> 를 안 넘기면 부를 때마다 새 트리가 만들어져요. 실제로 넘기지 않았더니 <code>TenantConn</code> 생성자가 한 번 더 불렸고 <code>REQUEST</code> 자리는 <code>undefined</code> 였어요. 테넌트 트리를 재사용하려면 전략을 통과한 <code>ContextId</code> 를 만들어 넘겨야 해요.</p>
<pre class="code-block" data-lang="typescript"><code>const contextId = ContextIdFactory.getByRequest({ headers: { &#x27;x-tenant-id&#x27;: job.tenantId } } as any);
this.moduleRef.registerRequestByContextId(contextId.payload, contextId);
const svc = await this.moduleRef.resolve(OrderService, contextId, { strict: false });</code></pre>
<p><code>getByRequest()</code> 는 전략이 등록돼 있으면 <code>attach()</code> 를 불러 주니, 가짜 요청 객체에 테넌트 값만 넣으면 HTTP 요청이 만든 테넌트 트리의 인스턴스가 그대로 나오고 생성자는 다시 불리지 않았어요. 이 코드가 작업마다 번거롭다면 AsyncLocalStorage 가 더 맞다는 신호예요.</p>
<h2 id="s7">운영에서 자주 나는 문제와 확인 순서</h2>
<div class="table-wrap"><table><thead><tr><th>증상</th><th>먼저 볼 것</th><th>흔한 원인</th></tr></thead><tbody><tr><td>durable 로 바꿨는데 처리량이 그대로</td><td><code>NEST_DEBUG=1</code> 로 컨트롤러가 durable 로 판정됐는지</td><td>체인에 durable 아닌 요청 스코프, <code>@Inject(REQUEST)</code> 받는 평범한 클래스</td></tr><tr><td>힙이 시간이 갈수록 계속 늘어남</td><td>전략 맵 크기를 메트릭으로</td><td>헤더 값을 검증 없이 키로 사용, 퇴출 없음</td></tr><tr><td>DB "too many connections"</td><td>(테넌트 × 풀 × 파드) 계산</td><td>테넌트별 풀 + 오토스케일</td></tr><tr><td><code>REQUEST</code> 로 받은 값에 헤더가 없음</td><td><code>attach()</code> 의 payload</td><td>durable 트리에는 요청 객체가 아니라 payload 만 들어가요</td></tr><tr><td>테넌트 A 요청에서 B 의 값이 보임</td><td>durable 클래스가 요청마다 바뀌는 값을 필드에 저장하는지</td><td>사용자 ID·요청 ID 를 durable 인스턴스 필드에 넣음</td></tr><tr><td>큐 작업마다 연결이 새로 열림</td><td><code>resolve()</code> 에 <code>ContextId</code> 를 넘겼는지</td><td>인자 없이 <code>resolve()</code> 호출</td></tr><tr><td>배포·종료 후 DB 에 연결이 남음</td><td>종료 훅에서 테넌트 연결을 닫는지</td><td>durable 인스턴스엔 <code>onModuleDestroy</code> 가 안 불림</td></tr></tbody></table></div>
<p>다섯 번째 줄은 보안 문제예요. durable 인스턴스는 <strong>같은 테넌트의 모든 요청이 공유</strong>해서, 필드에 로그인 사용자를 저장하면 같은 테넌트의 다른 사용자 요청이 그 값을 읽을 수 있어요. durable 클래스에는 테넌트 단위로 같은 값(연결·설정)만 두세요.</p>
<h2 id="s8">도입 전 체크리스트</h2>
<ul><li><strong>객체가 달라야 하나</strong>: 테넌트마다 다른 "값"이면 durable 이 아니에요.</li><li><strong>테넌트 수 상한</strong>: 1년 뒤 수백 곳을 넘을 것 같으면 연결 관리자 방식으로 시작해요.</li><li><strong>체인 판정</strong>: <code>NEST_DEBUG=1</code> 로그에서 컨트롤러가 <code>introspected as durable</code> 인지, <code>@Inject(REQUEST)</code> 를 받는 클래스가 모두 <code>durable: true</code> 를 적었는지.</li><li><strong>키 출처</strong>: 서명된 값에서 오는지, 허용 목록 밖이면 트리를 안 만드는지.</li><li><strong>연결 수</strong>: (테넌트 × 풀 × 최대 파드)가 DB 한도의 80% 안인지.</li><li><strong>퇴출과 종료</strong>: LRU 상한, 퇴출·종료 때 <code>close()</code> 코드가 있는지.</li><li><strong>비HTTP 경로</strong>: 큐·크론이 <code>getByRequest()</code> 로 같은 트리를 받는지, 아니면 ALS 로 처리하는지.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 묻는 질문</h2>
<h3>durable 프로바이더는 싱글턴과 성능이 같나요?</h3>
<p>같지 않아요. 생성자는 다시 안 불리지만 요청마다 전략을 거쳐 트리를 고르는 단계가 붙어요. 새 테넌트 첫 요청은 인스턴스 12개 기준 약 0.3ms 더 걸렸어요.</p>
<h3>테넌트가 몇 곳까지면 durable 을 써도 되나요?</h3>
<p>보통 메모리보다 연결 수가 먼저 한계예요. 트리는 1,000곳·인스턴스 12개여도 빈 클래스 기준 7.6MB 지만, 테넌트별 DB 풀은 (테넌트 × 풀 × 파드)가 DB 한도 안이어야 해요.</p>
<h3>durable 클래스에서 요청 헤더를 읽을 수 있나요?</h3>
<p>직접은 못 읽어요. <code>REQUEST</code> 자리에는 <code>attach()</code> 가 돌려준 payload 가 들어가요. 사용자·요청마다 다른 값은 메서드 인자나 AsyncLocalStorage 로 넘기세요.</p>
<h3>테넌트 트리를 지우면 정말 메모리가 돌아오나요?</h3>
<p>맵에서 <code>ContextId</code> 를 지우면 GC 대상이 되고, 1만 곳 기준 늘어난 힙의 84~94% 가 돌아왔어요. 연 DB 연결은 GC 로 닫히지 않으니 지우기 전에 직접 닫아야 해요.</p>
<h3>큐 작업에서도 같은 테넌트 인스턴스를 쓸 수 있나요?</h3>
<p><code>ContextIdFactory.getByRequest()</code> 에 테넌트 값을 담은 객체를 넘겨 얻은 ID 로 <code>moduleRef.resolve()</code> 를 부르면 같은 인스턴스가 나와요. ID 없이 부르면 매번 새 트리가 생겨요.</p>
<h2 id="s10">정리</h2>
<ul><li>durable 은 테넌트마다 다른 객체가 필요하고 테넌트가 적고 고정일 때만 써요. 값만 다르면 싱글턴 + AsyncLocalStorage 예요.</li><li>durable 아닌 요청 스코프나 <code>@Inject(REQUEST)</code> 를 받는 평범한 클래스 하나가 체인 위쪽을 다시 요청마다 만들어요. <code>NEST_DEBUG=1</code> 로 먼저 확인하세요.</li><li>테넌트 트리는 빈 클래스로도 3.4~19.5KB 씩 남아요. 허용 목록·LRU 상한·직접 <code>close()</code> 를 같이 두세요.</li><li>테넌트별 DB 풀은 (테넌트 × 풀 × 파드) ≤ DB 한도로 거꾸로 계산해요.</li><li>관련 글: <a href="https://tool.smithlab.co.kr/posts/developer/nestjs-provider-scope-module-design-di-error-checklist/">NestJS 프로바이더 스코프 고르기</a>, <a href="https://tool.smithlab.co.kr/posts/developer/spring-batch-partitioning-gridsize-thread-pool-sizing-guide/">스프링 배치 커넥션 풀 계산</a></li></ul>
<h2 id="s11">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/nestjs-injection-scope-request-transient-durable" target="_blank" rel="noopener">NestJS 주입 스코프 원리 — REQUEST·durable 버블링</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>케이크 똑같이 나누는 법 — 8명은 칼질 4번인데 7명은 왜 7번일까?…</title>
      <link>https://tool.smithlab.co.kr/posts/life/cake-equal-slices-odd-people-cuts-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/cake-equal-slices-odd-people-cuts-guide/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>생일 파티나 크리스마스 모임에서 케이크를 앞에 두고 "몇 번 잘라야 다 똑같이 나눠지지?" 하며 칼을 든 채 멈칫한 적이 있으시죠. 결론부터 말하면 <strong>원형 케이크는 인원이 짝수면 칼질이 인원의 절반이고, 홀수면 인원 수만큼 필요해요.</strong> 8명이면 4번, 7명이면 7번이고, 한 조각의 각도는 360도를 인원수로 나눈 값이에요.</p>
<p>이 규칙은 케이크 위에서 칼이 지나가는 선이 어떻게 생기는지만 이해하면 외울 필요가 없어요. 이 글은 인원별 각도와 칼질 횟수 표, 칼질을 몇 번 하면 최대 몇 조각까지 나오는지, 사각 시트 케이크는 어떻게 배열해야 반듯한지를 모두 직접 계산해서 정리했어요. 입력한 값 외에 외부 통계는 쓰지 않는 순수 도형 계산이라 숫자를 믿고 써도 돼요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>원형 케이크의 한 조각 중심각은 360도를 인원수로 나눈 값이에요. 8명이면 45도, 6명이면 60도예요.</li><li>짝수 인원은 칼질이 인원의 절반이고, 홀수 인원은 인원 수만큼 필요해요. 7명은 7번이에요.</li><li>조각 크기가 달라도 되면 칼질 k번으로 최대 k(k+1)÷2+1조각까지 나와요. 4번이면 11조각이에요.</li><li>사각 케이크는 인원의 약수 쌍으로 가로×세로 칸을 정하면 돼요. 12명이면 4×3이 가장 반듯해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">원형 케이크를 똑같이 나누면 각도는 얼마일까</h2>
<p>둥근 케이크를 윗면에서 보면 한가운데에서 바깥으로 뻗는 부채꼴이 한 조각이 돼요. 전체 각도가 360도이니 N명이 똑같이 나누면 한 조각의 중심각은 360 ÷ N도예요. 가장 많이 쓰는 인원별로 정리하면 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>인원</th><th>한 조각 각도</th><th>똑같이 나누는 칼질</th><th>크기 상관없이 최소 칼질</th></tr></thead><tbody><tr><td>2명</td><td>180도</td><td>1번</td><td>1번</td></tr><tr><td>3명</td><td>120도</td><td>3번</td><td>2번</td></tr><tr><td>4명</td><td>90도</td><td>2번</td><td>2번</td></tr><tr><td>5명</td><td>72도</td><td>5번</td><td>3번</td></tr><tr><td>6명</td><td>60도</td><td>3번</td><td>3번</td></tr><tr><td>7명</td><td>51.43도</td><td>7번</td><td>3번</td></tr><tr><td>8명</td><td>45도</td><td>4번</td><td>4번</td></tr><tr><td>10명</td><td>36도</td><td>5번</td><td>4번</td></tr><tr><td>12명</td><td>30도</td><td>6번</td><td>5번</td></tr></tbody></table></div>
<p>마지막 칸은 "조각 수만 맞으면 되고 크기는 달라도 되는" 경우예요. 3명을 똑같이 나누려면 120도씩 세 번 자르지만, 그냥 3조각만 나오면 되면 두 번이면 돼요. 이 차이는 뒤에서 설명할 칼질 횟수 공식 때문에 생겨요. 인원을 바꿔 가며 확인하고 싶으면 <a href="https://tool.smithlab.co.kr/life/cake-cutting-calculator/">케이크 똑같이 나누기 계산기</a>에 인원만 넣으면 각도와 칼질 횟수, 그림이 바로 나와요.</p>
<h2 id="s2">8명은 칼질 4번인데 7명은 왜 7번일까</h2>
<p>원형 케이크를 똑같은 부채꼴로 나누려면 한가운데에서 바깥으로 뻗는 선이 인원수만큼 필요해요. 8명이면 선 8개, 7명이면 선 7개예요. 그런데 칼을 케이크 한가운데에 대고 끝까지 밀어 자르면 한 번에 <strong>서로 반대 방향으로 뻗는 선 2개</strong>가 생겨요. 한가운데를 지나는 직선이기 때문이에요.</p>
<p>8명이면 필요한 선이 0도, 45도, 90도, 135도, 180도, 225도, 270도, 315도예요. 이 가운데 0도와 180도, 45도와 225도처럼 서로 반대쪽끼리 짝이 맞아서 칼질 4번이면 8개 선이 모두 만들어져요. 반면 7명이면 선이 0도, 51.43도, 102.86도, 154.29도, 205.71도, 257.14도, 308.57도예요. 어느 선의 반대쪽(180도 돌린 위치)도 다른 선과 겹치지 않아서 한 번에 선 하나씩, 즉 7번을 잘라야 해요.</p>
<p>그래서 공식은 이렇게 정리돼요.</p>
<ul><li>인원이 짝수: 칼질 = 인원 ÷ 2</li><li>인원이 홀수: 칼질 = 인원 (단, 1명이면 자를 필요 없음)</li></ul>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/cake-equal-slices-odd-people-cuts-guide-1.svg" alt="8명 케이크와 7명 케이크를 같은 방식으로 자를 때 한가운데를 지나는 선과 칼질 횟수를 비교한 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>8명은 45도씩 4번, 7명은 51.43도씩 7번이에요</figcaption></figure>
<p>현실에서는 홀수 인원일 때 한가운데에서 바깥으로 칼을 대고 끝까지 눌러 자르는 방식이 편해요. 이때 칼날이 한가운데에서 살짝 겹치지 않도록 주의하면 조각 한가운데의 뾰족한 부분이 일그러지지 않아요. 5명처럼 홀수 인원은 72도씩이어서 눈대중으로 맞추기 어려워요. 종이에 한가운데를 표시한 원을 그려 72도 간격 표시를 해 두고 케이크를 그 위에 올려놓으면 훨씬 쉬워요.</p>
<h2 id="s3">칼질 횟수별 최대 조각 수</h2>
<p>조각 크기가 같지 않아도 되면 칼질을 서로 엇갈리게 해서 조각을 훨씬 많이 만들 수 있어요. 직선 k번으로 나눌 수 있는 최대 조각 수는 k(k+1) ÷ 2 + 1이에요. 이유는 단순해요. 새로 칼질을 할 때마다 이전 칼질들과 모두 한 번씩 만나면, 그 칼질이 지나는 구간이 (이전 칼질 수 + 1)개로 쪼개지고, 쪼개진 구간마다 조각이 하나씩 더 생기기 때문이에요.</p>
<div class="table-wrap"><table><thead><tr><th>칼질</th><th>최대 조각(펼쳐 놓고 자를 때)</th><th>최대 조각(쌓아 자를 때)</th></tr></thead><tbody><tr><td>0번</td><td>1개</td><td>1개</td></tr><tr><td>1번</td><td>2개</td><td>2개</td></tr><tr><td>2번</td><td>4개</td><td>4개</td></tr><tr><td>3번</td><td>7개</td><td>8개</td></tr><tr><td>4번</td><td>11개</td><td>16개</td></tr><tr><td>5번</td><td>16개</td><td>32개</td></tr><tr><td>6번</td><td>22개</td><td>64개</td></tr></tbody></table></div>
<figure class="viz viz-line"><figcaption class="viz-title">칼질 횟수별 케이크 최대 조각 수</figcaption><div class="viz-legend"><span><i class="s1"></i>펼쳐 놓고 자르기</span><span><i class="s2"></i>쌓아서 자르기</span></div><svg viewBox="0 0 640 300" role="img" aria-label="칼질 횟수별 케이크 최대 조각 수" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="570" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">80</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">0번</text><text class="viz-ax" x="138.3" y="272" text-anchor="middle">1번</text><text class="viz-ax" x="224.7" y="272" text-anchor="middle">2번</text><text class="viz-ax" x="311.0" y="272" text-anchor="middle">3번</text><text class="viz-ax" x="397.3" y="272" text-anchor="middle">4번</text><text class="viz-ax" x="483.7" y="272" text-anchor="middle">5번</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">6번</text><polyline class="viz-line s1" points="52.0,251.1 138.3,248.1 224.7,242.2 311.0,233.3 397.3,221.6 483.7,206.8 570.0,189.1"/><circle class="viz-dot s1" cx="52.0" cy="251.1" r="4"><title>0번 · 펼쳐 놓고 자르기 1개</title></circle><circle class="viz-dot s1" cx="138.3" cy="248.1" r="4"><title>1번 · 펼쳐 놓고 자르기 2개</title></circle><circle class="viz-dot s1" cx="224.7" cy="242.2" r="4"><title>2번 · 펼쳐 놓고 자르기 4개</title></circle><circle class="viz-dot s1" cx="311.0" cy="233.3" r="4"><title>3번 · 펼쳐 놓고 자르기 7개</title></circle><circle class="viz-dot s1" cx="397.3" cy="221.6" r="4"><title>4번 · 펼쳐 놓고 자르기 11개</title></circle><circle class="viz-dot s1" cx="483.7" cy="206.8" r="4"><title>5번 · 펼쳐 놓고 자르기 16개</title></circle><circle class="viz-dot s1" cx="570.0" cy="189.1" r="4"><title>6번 · 펼쳐 놓고 자르기 22개</title></circle><text class="viz-val" x="578.0" y="193.1">22</text><polyline class="viz-line s2" points="52.0,251.1 138.3,248.1 224.7,242.2 311.0,230.4 397.3,206.8 483.7,159.6 570.0,65.2"/><circle class="viz-dot s2" cx="52.0" cy="251.1" r="4"><title>0번 · 쌓아서 자르기 1개</title></circle><circle class="viz-dot s2" cx="138.3" cy="248.1" r="4"><title>1번 · 쌓아서 자르기 2개</title></circle><circle class="viz-dot s2" cx="224.7" cy="242.2" r="4"><title>2번 · 쌓아서 자르기 4개</title></circle><circle class="viz-dot s2" cx="311.0" cy="230.4" r="4"><title>3번 · 쌓아서 자르기 8개</title></circle><circle class="viz-dot s2" cx="397.3" cy="206.8" r="4"><title>4번 · 쌓아서 자르기 16개</title></circle><circle class="viz-dot s2" cx="483.7" cy="159.6" r="4"><title>5번 · 쌓아서 자르기 32개</title></circle><circle class="viz-dot s2" cx="570.0" cy="65.2" r="4"><title>6번 · 쌓아서 자르기 64개</title></circle><text class="viz-val" x="578.0" y="69.2">64</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">3번까지는 펼쳐 자르면 7개, 쌓으면 8개로 차이가 작지만 6번이 되면 22개와 64개로 크게 벌어져요.</p><div class="viz-cap">단위: 개 · 자료: 최대 조각 수 공식으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>펼쳐 놓고 자르기</th><th>쌓아서 자르기</th></tr></thead><tbody><tr><td>0번</td><td>1</td><td>1</td></tr><tr><td>1번</td><td>2</td><td>2</td></tr><tr><td>2번</td><td>4</td><td>4</td></tr><tr><td>3번</td><td>7</td><td>8</td></tr><tr><td>4번</td><td>11</td><td>16</td></tr><tr><td>5번</td><td>16</td><td>32</td></tr><tr><td>6번</td><td>22</td><td>64</td></tr></tbody></table></div></details></figure>
<p>쌓아서 자른다는 건 한 번 자른 조각들을 겹쳐 올린 다음 다시 한 번에 자르는 방식이에요. 모든 조각이 한 번에 둘로 나뉘니까 칼질 k번이면 2의 k제곱 개가 돼요. 하지만 이 방식은 조각 크기를 똑같이 맞추기 어렵고, 크림과 스펀지가 뭉개져서 케이크에는 어울리지 않아요. 수학 퍼즐로 즐기는 정도로 알아 두면 좋아요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/cake-equal-slices-odd-people-cuts-guide-2.svg" alt="칼질 횟수 0번부터 6번까지 펼쳐 놓고 잘랐을 때 최대 조각 수를 막대로 보여 주는 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>칼질 4번이면 11조각, 6번이면 22조각이 최대예요</figcaption></figure>
<p>이 수열은 수학에서 '레이지 케이터러 수열'이라고 부르고, 수학 일반 자료는 <a href="https://www.kms.or.kr/" target="_blank" rel="noopener">대한수학회</a>에서 찾아볼 수 있어요. 이 수열을 이용하면 "10명이 나눠야 하는데 크기는 신경 안 써도 된다면 몇 번 잘라야 할까"에 답할 수 있어요. 3번이면 최대 7조각이라 모자라고 4번이면 11조각이니까 4번이에요. 다만 이 경우 11조각 가운데 1조각은 남고 조각마다 크기가 제각각이라, 손님에게 낼 때는 의미 없는 방식이에요. 실제로 모임에서 쓸 수 있는 건 똑같이 나누는 쪽이에요.</p>
<h2 id="s4">지름별로 한 조각이 얼마나 클까</h2>
<p>원형 케이크의 윗면 넓이는 π × 반지름²이라서 같은 인원이어도 지름이 조금만 달라지면 한 조각 크기가 크게 달라져요. 아래는 지름과 인원별 한 조각의 윗면 넓이예요. 모든 값은 π × 반지름² ÷ 인원으로 직접 계산했고 단위는 cm²예요.</p>
<div class="table-wrap"><table><thead><tr><th>지름</th><th>6명</th><th>8명</th><th>10명</th><th>12명</th></tr></thead><tbody><tr><td>15cm</td><td>29.5</td><td>22.1</td><td>17.7</td><td>14.7</td></tr><tr><td>18cm</td><td>42.4</td><td>31.8</td><td>25.4</td><td>21.2</td></tr><tr><td>21cm</td><td>57.7</td><td>43.3</td><td>34.6</td><td>28.9</td></tr><tr><td>24cm</td><td>75.4</td><td>56.5</td><td>45.2</td><td>37.7</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">지름별 한 조각 윗면 넓이 (8명·12명)</figcaption><div class="viz-legend"><span><i class="s1"></i>8명</span><span><i class="s2"></i>12명</span></div><svg viewBox="0 0 640 300" role="img" aria-label="지름별 한 조각 윗면 넓이 (8명·12명)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">30</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">60</text><rect class="s1" x="73.6" y="167.1" width="49.4" height="86.9" rx="3"><title>15cm · 8명 22.1cm²</title></rect><rect class="s2" x="125.0" y="196.2" width="49.4" height="57.8" rx="3"><title>15cm · 12명 14.7cm²</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">15cm</text><rect class="s1" x="217.6" y="128.9" width="49.4" height="125.1" rx="3"><title>18cm · 8명 31.8cm²</title></rect><rect class="s2" x="269.0" y="170.6" width="49.4" height="83.4" rx="3"><title>18cm · 12명 21.2cm²</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">18cm</text><rect class="s1" x="361.6" y="83.7" width="49.4" height="170.3" rx="3"><title>21cm · 8명 43.3cm²</title></rect><rect class="s2" x="413.0" y="140.3" width="49.4" height="113.7" rx="3"><title>21cm · 12명 28.9cm²</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">21cm</text><rect class="s1" x="505.6" y="31.8" width="49.4" height="222.2" rx="3"><title>24cm · 8명 56.5cm²</title></rect><rect class="s2" x="557.0" y="105.7" width="49.4" height="148.3" rx="3"><title>24cm · 12명 37.7cm²</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">24cm</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">지름이 15cm에서 24cm로 1.6배가 되면 한 조각 넓이는 약 2.6배가 돼요.</p><div class="viz-cap">단위: cm² · 자료: π × 반지름² ÷ 인원으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>8명</th><th>12명</th></tr></thead><tbody><tr><td>15cm</td><td>22.1</td><td>14.7</td></tr><tr><td>18cm</td><td>31.8</td><td>21.2</td></tr><tr><td>21cm</td><td>43.3</td><td>28.9</td></tr><tr><td>24cm</td><td>56.5</td><td>37.7</td></tr></tbody></table></div></details></figure>
<p>표에서 눈여겨볼 점은 지름이 1.6배 커질 때 조각 넓이가 2.6배가 된다는 거예요. 넓이가 지름의 제곱에 비례하기 때문이에요. 그래서 15cm 케이크 8조각이 21cm 케이크 12조각보다 작아요(22.1 대 28.9). 케이크를 주문할 때 "몇 인용"이라는 설명만 믿기보다 지름을 보고 조각 크기를 어림해 보면 후회가 적어요. 몇 명이 나눠 먹을지 정했다면 조각 크기를 먼저 정하고 필요한 지름을 거꾸로 정할 수도 있어요. 예를 들어 한 조각 윗면이 40cm² 정도면 좋겠고 인원이 10명이라면 전체 400cm²가 필요하니 반지름이 약 11.3cm, 지름은 약 22.6cm예요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">사각 시트 케이크는 어떻게 나눌까</h2>
<p>사각 케이크는 원형보다 훨씬 쉬워요. 인원수의 약수 쌍으로 가로 칸 × 세로 칸을 정하면 모든 조각이 똑같은 직사각형이 돼요. 12명이면 1×12, 2×6, 3×4처럼 곱해서 12가 되는 쌍이 후보예요. 가로 30cm, 세로 24cm인 시트 케이크를 12명이 나누는 경우를 비교하면 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>배열(가로×세로)</th><th>한 조각 크기</th><th>칼질 횟수</th></tr></thead><tbody><tr><td>4×3</td><td>7.5×8cm</td><td>5번</td></tr><tr><td>6×2</td><td>5×12cm</td><td>6번</td></tr><tr><td>3×4</td><td>10×6cm</td><td>5번</td></tr><tr><td>2×6</td><td>15×4cm</td><td>6번</td></tr><tr><td>12×1</td><td>2.5×24cm</td><td>11번</td></tr><tr><td>1×12</td><td>30×2cm</td><td>11번</td></tr></tbody></table></div>
<p>한 조각 넓이는 모두 720 ÷ 12 = 60cm²로 같아요. 하지만 4×3 배열은 7.5×8cm로 가장 네모반듯하고 칼질도 5번으로 적은 편이에요. 반대로 12×1처럼 한 줄로 늘어놓으면 조각이 막대처럼 길어지고 칼질도 11번이나 돼요. 칼질 횟수는 (가로 칸 − 1) + (세로 칸 − 1)로 구해요.</p>
<p>인원이 7이나 11처럼 소수라면 약수 쌍이 1×N 하나뿐이에요. 이럴 때는 가까운 수(8이나 12)로 먼저 나눈 다음 일부 조각을 반으로 나누거나, 소수 인원일 때만 조각이 길쭉해진다는 걸 받아들이는 방법이 있어요. 20명이면 5×4, 24명이면 6×4처럼 약수가 많은 숫자는 어떤 배열도 가능해서 편해요.</p>
<h2 id="s6">똑같이 나누기 전에 확인할 것</h2>
<ul><li><strong>장식 위치를 먼저 정해요.</strong> 딸기나 초콜릿 장식이 몇 조각에만 몰려 있으면 크기가 같아도 불공평하게 느껴져요. 자르는 선 위에 장식을 맞춰 올리면 모두에게 공평해요.</li><li><strong>칼을 따뜻하게 하세요.</strong> 뜨거운 물에 담갔다 물기를 닦은 칼이 크림이 덜 묻고 단면이 깔끔해요. 한 번 자를 때마다 닦아 주세요.</li><li><strong>2단·3단 케이크는 층을 곱해요.</strong> 윗면을 8등분한 2층 케이크는 16조각이 나와요. 같은 층끼리 한 조각을 먹을지, 층을 합쳐 한 조각으로 낼지 미리 정하세요.</li><li><strong>알레르기를 확인해요.</strong> 견과류나 유제품이 들어간 부분이 있으면 그 조각을 따로 표시해 두세요.</li><li><strong>칼날 두께를 생각해요.</strong> 얇은 칼을 쓰면 상관없지만, 두꺼운 칼이나 톱니 칼은 조각이 조금 작아질 수 있어요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">상황별로 보는 케이크 나누기</h2>
<ul><li><strong>가족 4명, 지름 18cm</strong>: 한가운데를 지나는 칼질 2번이면 90도씩 4조각이에요. 한 조각 윗면은 약 63.6cm²예요.</li><li><strong>팀 회식 12명, 원형</strong>: 칼질 6번에 30도씩이에요. 30도는 눈대중으로 맞추기 어려우니 먼저 4등분한 다음 각 조각을 3등분하는 방법이 편해요.</li><li><strong>팀 회식 12명, 사각 30×24cm</strong>: 4×3 배열 칼질 5번이 가장 반듯해요. 한 조각이 7.5×8cm예요.</li><li><strong>아이 생일 파티 10명</strong>: 원형이면 칼질 5번에 36도씩이에요. 먼저 2등분한 뒤 각 반쪽을 5등분하면 된다고 생각하면 쉬워요.</li><li><strong>친구 모임 7명</strong>: 홀수라 칼질 7번이에요. 51.43도는 눈대중이 어려우니 인원에 맞는 각도를 종이에 미리 그려 두고 자르는 게 안전해요.</li></ul>
<p>모임 비용도 같이 나누고 싶다면 <a href="https://tool.smithlab.co.kr/money/split-bill-calculator/">N빵 계산기</a>로 케이크 값을 인원수로 나눌 수 있고, 피자처럼 둥근 음식의 크기를 비교하려면 <a href="https://tool.smithlab.co.kr/life/pizza-size-calculator/">피자 크기 비교 계산기</a>가 같은 원리로 넓이를 계산해요.</p>
<h2 id="s8">자주 묻는 질문</h2>
<h3>원형 케이크를 5명이 똑같이 나누려면 몇 번 잘라야 하나요?</h3>
<p>5명은 홀수라 한가운데에서 바깥으로 5번 잘라야 해요. 한 조각 각도는 360 ÷ 5 = 72도예요. 한가운데를 지나는 칼질 한 번은 반대쪽으로 뻗는 선 2개를 만드는데, 5명은 반대쪽에 짝이 되는 선이 없어서 한 번에 선 하나씩만 만들 수 있어요.</p>
<h3>칼질 3번이면 최대 몇 조각까지 나오나요?</h3>
<p>3 × 4 ÷ 2 + 1 = 7조각이에요. 세 칼질이 모두 서로 엇갈리고 세 선이 한 점에서 만나지 않게 놓았을 때예요. 이때 조각 크기는 같지 않아요.</p>
<h3>쌓아서 자르면 정말 2의 k제곱 조각이 나오나요?</h3>
<p>이론상 그래요. 조각을 겹쳐 한 번에 자르면 모든 조각이 둘로 나뉘니까요. 다만 조각 크기가 같다는 뜻은 아니고, 현실에서는 크림이 뭉개져서 실용적이지 않아요.</p>
<h3>사각 케이크에서 인원이 소수면 어떻게 하나요?</h3>
<p>7이나 11처럼 소수면 한 줄로 길게 나뉘는 배열밖에 없어요. 가까운 약수 인원(8이나 12)으로 먼저 나눈 다음 일부 조각을 반으로 자르거나, 길쭉한 조각이 나와도 괜찮다고 모두에게 알리고 시작하는 게 편해요.</p>
<h3>층이 여러 개인 케이크는 어떻게 계산하나요?</h3>
<p>윗면을 인원수대로 나누고 층 수를 곱해서 생각하면 돼요. 8등분한 2층 케이크는 16조각이에요. 층마다 케이크 맛이 다르다면 미리 한 조각에 층을 모두 담을지 정하는 것도 좋아요.</p>
<h2 id="s9">정리</h2>
<p>원형 케이크의 한 조각은 360도를 인원수로 나눈 각도이고, 칼질은 짝수 인원이면 인원의 절반, 홀수 인원이면 인원 수만큼 필요해요. 크기가 달라도 되면 칼질 k번에 최대 k(k+1)÷2+1조각까지 나와요. 지름이 같으면 인원이 늘수록 조각이 작아지고, 같은 인원이어도 지름이 조금 커지면 조각 넓이는 제곱으로 커져요. 사각 케이크는 약수 쌍으로 가로×세로를 정하면 돼요.</p>
<p>내 케이크 치수와 인원으로 직접 확인하려면 <a href="https://tool.smithlab.co.kr/life/cake-cutting-calculator/">케이크 똑같이 나누기 계산기</a>를 써 보세요. 원형과 사각 모두 그림으로 보여 줘요. 같은 원리로 <a href="https://tool.smithlab.co.kr/life/pizza-size-calculator/">피자 크기 비교 계산기</a>와 <a href="https://tool.smithlab.co.kr/money/split-bill-calculator/">N빵 계산기</a>도 함께 쓰면 모임 준비가 한결 편해져요.</p>
    ]]></description>
    </item>
    <item>
      <title>크리스마스 트리 전구 몇 미터 필요할까? — 높이별 줄 길이 표와…</title>
      <link>https://tool.smithlab.co.kr/posts/life/christmas-tree-lights-length-meters-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/christmas-tree-lights-length-meters-guide/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>12월이 오기 전에 트리를 꺼내 놓고 전구 줄을 한 번 둘러 보다가 "이거 모자라겠는데?" 싶었던 적이 있으시죠. 반대로 미리 샀더니 한참 남아서 괜히 뭉쳐서 걸어 둔 경험도 흔해요. 결론부터 말하면 <strong>높이 180cm, 아래 지름 90cm인 트리에 꼭대기에서 아래까지 8바퀴를 감으면 전구 줄은 약 11.6m가 필요하고, 매듭과 꼬리 선 여유 10%까지 합치면 12.8m 줄을 사야 해요.</strong> 10m 줄 한 개로는 부족하고 15m나 두 줄이 맞아요.</p>
<p>이 길이는 어림이 아니라 트리를 원뿔로 보고 계산한 값이에요. 세로로 한 줄만 쭉 내리면 1.86m인데, 그 줄을 나선으로 8바퀴 돌리면 6배 넘게 길어져요. 이 글은 그 계산이 어떻게 나오는지, 트리 크기와 바퀴 수에 따라 얼마나 달라지는지 표로 정리했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>트리를 원뿔로 보면 줄 길이는 높이와 지름, 바퀴 수 세 가지로 정해져요.</li><li>180cm 트리 8바퀴는 11.6m, 6바퀴는 8.8m, 10바퀴는 14.4m예요.</li><li>바퀴 수를 두 배로 해도 줄이 두 배가 되지는 않아요. 4바퀴 6.1m, 8바퀴 11.6m예요.</li><li>사는 길이는 계산값에 10~30% 여유를 더하고, 제품 설명의 '전구 부분 길이'를 확인하세요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">전구 줄 길이는 왜 어림하기 어려울까</h2>
<p>전구 줄을 감는 모습을 떠올려 보세요. 줄은 트리를 한 바퀴 돌면서 동시에 조금씩 아래로 내려가요. 게다가 트리는 위로 갈수록 가늘어져서 같은 한 바퀴라도 위쪽은 짧고 아래쪽은 길어요. 그래서 "아래 둘레 × 바퀴 수"로 계산하면 지나치게 크게 나오고, "중간 둘레 × 바퀴 수"로 계산하면 세로로 내려오는 길이를 놓쳐서 조금 작게 나와요.</p>
<p>숫자로 보면 지름 90cm 트리의 아래 둘레는 약 2.83m예요. 여기에 8바퀴를 곱하면 22.6m인데, 실제 필요한 줄은 11.6m예요. 거의 두 배 차이라서 이렇게 계산하면 필요 이상으로 사게 돼요. 꼭대기에서 아래로 갈수록 둘레가 0에서 2.83m로 커지기 때문에 평균 둘레는 절반인 약 1.41m이고, 8바퀴를 곱하면 11.3m 정도가 나와요. 여기에 세로로 내려오는 효과가 더해져 정확한 값은 11.59m가 돼요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/christmas-tree-lights-length-meters-guide-1.svg" alt="트리 높이와 지름, 세로 한 줄과 8바퀴 감은 줄 길이를 비교한 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>세로 한 줄은 1.86m인데 8바퀴를 감으면 11.59m예요</figcaption></figure>
<h2 id="s2">계산 공식을 쉽게 풀면</h2>
<p>수학적으로는 원뿔 표면에서 꼭대기부터 밑면까지 n바퀴를 도는 나선의 길이예요. 높이를 h, 밑면 반지름을 r(지름의 절반)이라 하고 A² = r² + h², B = 2πnr 이라 하면 길이는 다음과 같아요.</p>
<ul><li>줄 길이 = ½ × √(A² + B²) + A² ÷ (2B) × asinh(B ÷ A)</li><li>바퀴 수가 0이면 B도 0이 되고 길이는 √(r² + h²), 즉 원뿔의 빗변 길이가 돼요.</li></ul>
<p>식이 어려워 보이지만 계산기가 대신해 줘요. <a href="https://tool.smithlab.co.kr/life/christmas-tree-lights-calculator/">트리 전구 줄 길이 계산기</a>에 높이와 지름, 바퀴 수를 넣으면 바로 나와요. 이 식은 컴퓨터로 구간을 잘게 나눠 더한 수치 적분 결과와 소수 넷째 자리까지 같다는 것을 확인했어요.</p>
<p>예를 들어 높이 180cm, 지름 90cm(반지름 45cm)이면 A² = 45² + 180² = 34,425이고, 8바퀴일 때 B = 2π × 8 × 45 ≈ 2,261.9예요. 식에 넣으면 1,159cm, 즉 11.59m가 나와요.</p>
<h2 id="s3">트리 높이별 필요한 줄 길이</h2>
<p>아래 표는 지름을 높이의 절반으로 가정하고, 높이가 클수록 바퀴를 조금 더 감는다고 정한 예시예요. 모든 값은 위 공식으로 직접 계산했어요.</p>
<div class="table-wrap"><table><thead><tr><th>트리 높이</th><th>아래 지름</th><th>바퀴 수</th><th>줄 길이</th><th>여유 10% 포함</th></tr></thead><tbody><tr><td>90cm</td><td>45cm</td><td>4바퀴</td><td>3.06m</td><td>3.36m</td></tr><tr><td>120cm</td><td>60cm</td><td>5바퀴</td><td>4.97m</td><td>5.47m</td></tr><tr><td>150cm</td><td>75cm</td><td>6바퀴</td><td>7.36m</td><td>8.09m</td></tr><tr><td>180cm</td><td>90cm</td><td>8바퀴</td><td>11.59m</td><td>12.75m</td></tr><tr><td>210cm</td><td>105cm</td><td>9바퀴</td><td>15.14m</td><td>16.66m</td></tr><tr><td>240cm</td><td>120cm</td><td>10바퀴</td><td>19.17m</td><td>21.08m</td></tr></tbody></table></div>
<figure class="viz viz-bar"><figcaption class="viz-title">트리 높이별 전구 줄 길이 (지름=높이의 절반, 여유 10% 포함)</figcaption><div class="viz-legend"><span><i class="s1"></i>줄 길이</span><span><i class="s2"></i>여유 10% 포함</span></div><svg viewBox="0 0 640 300" role="img" aria-label="트리 높이별 전구 줄 길이 (지름=높이의 절반, 여유 10% 포함)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="628" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="628" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="628" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">25</text><rect class="s1" x="66.4" y="225.1" width="32.6" height="28.9" rx="3"><title>90cm · 줄 길이 3.1m</title></rect><rect class="s2" x="101.0" y="222.3" width="32.6" height="31.7" rx="3"><title>90cm · 여유 10% 포함 3.4m</title></rect><text class="viz-ax" x="100.0" y="272" text-anchor="middle">90cm</text><rect class="s1" x="162.4" y="207.1" width="32.6" height="46.9" rx="3"><title>120cm · 줄 길이 5m</title></rect><rect class="s2" x="197.0" y="202.4" width="32.6" height="51.6" rx="3"><title>120cm · 여유 10% 포함 5.5m</title></rect><text class="viz-ax" x="196.0" y="272" text-anchor="middle">120cm</text><rect class="s1" x="258.4" y="184.5" width="32.6" height="69.5" rx="3"><title>150cm · 줄 길이 7.4m</title></rect><rect class="s2" x="293.0" y="177.6" width="32.6" height="76.4" rx="3"><title>150cm · 여유 10% 포함 8.1m</title></rect><text class="viz-ax" x="292.0" y="272" text-anchor="middle">150cm</text><rect class="s1" x="354.4" y="144.6" width="32.6" height="109.4" rx="3"><title>180cm · 줄 길이 11.6m</title></rect><rect class="s2" x="389.0" y="133.6" width="32.6" height="120.4" rx="3"><title>180cm · 여유 10% 포함 12.8m</title></rect><text class="viz-ax" x="388.0" y="272" text-anchor="middle">180cm</text><rect class="s1" x="450.4" y="111.1" width="32.6" height="142.9" rx="3"><title>210cm · 줄 길이 15.1m</title></rect><rect class="s2" x="485.0" y="96.7" width="32.6" height="157.3" rx="3"><title>210cm · 여유 10% 포함 16.7m</title></rect><text class="viz-ax" x="484.0" y="272" text-anchor="middle">210cm</text><rect class="s1" x="546.4" y="73.0" width="32.6" height="181.0" rx="3"><title>240cm · 줄 길이 19.2m</title></rect><rect class="s2" x="581.0" y="55.0" width="32.6" height="199.0" rx="3"><title>240cm · 여유 10% 포함 21.1m</title></rect><text class="viz-ax" x="580.0" y="272" text-anchor="middle">240cm</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">높이가 2배가 되면 지름도 2배라 길이는 바퀴 수에 따라 4배 안팎으로 늘어요.</p><div class="viz-cap">단위: m · 자료: 원뿔 나선 길이 공식으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>줄 길이</th><th>여유 10% 포함</th></tr></thead><tbody><tr><td>90cm</td><td>3.1</td><td>3.4</td></tr><tr><td>120cm</td><td>5</td><td>5.5</td></tr><tr><td>150cm</td><td>7.4</td><td>8.1</td></tr><tr><td>180cm</td><td>11.6</td><td>12.8</td></tr><tr><td>210cm</td><td>15.1</td><td>16.7</td></tr><tr><td>240cm</td><td>19.2</td><td>21.1</td></tr></tbody></table></div></details></figure>
<p>표에서 눈여겨볼 점은 크기가 커질수록 길이가 빠르게 늘어난다는 점이에요. 120cm 트리에서 240cm 트리로 가면 높이는 2배인데 줄 길이는 약 3.9배예요. 줄을 같은 간격으로 감으려면 트리가 커질수록 지름과 바퀴 수가 함께 늘기 때문이에요. 트리를 새로 사려고 하면 전구 줄도 같이 계산해 두는 게 좋아요.</p>
<h2 id="s4">바퀴 수에 따라 줄 길이는 얼마나 변할까</h2>
<p>같은 180cm 트리라도 몇 바퀴 감느냐에 따라 필요한 줄이 달라져요.</p>
<div class="table-wrap"><table><thead><tr><th>바퀴 수</th><th>줄 길이</th><th>바퀴 사이 세로 간격</th><th>전구 10cm 간격일 때 개수</th></tr></thead><tbody><tr><td>0바퀴(세로 한 줄)</td><td>1.86m</td><td>-</td><td>19개</td></tr><tr><td>4바퀴</td><td>6.11m</td><td>45cm</td><td>62개</td></tr><tr><td>6바퀴</td><td>8.83m</td><td>30cm</td><td>89개</td></tr><tr><td>8바퀴</td><td>11.59m</td><td>22.5cm</td><td>117개</td></tr><tr><td>10바퀴</td><td>14.38m</td><td>18cm</td><td>144개</td></tr><tr><td>12바퀴</td><td>17.17m</td><td>15cm</td><td>172개</td></tr></tbody></table></div>
<figure class="viz viz-line"><figcaption class="viz-title">180cm 트리 바퀴 수별 전구 줄 길이 (지름 90cm)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="180cm 트리 바퀴 수별 전구 줄 길이 (지름 90cm)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">5</text><line class="viz-grid" x1="52" x2="570" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">10</text><line class="viz-grid" x1="52" x2="570" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">15</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">20</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">0바퀴</text><text class="viz-ax" x="155.6" y="272" text-anchor="middle">4바퀴</text><text class="viz-ax" x="259.2" y="272" text-anchor="middle">6바퀴</text><text class="viz-ax" x="362.8" y="272" text-anchor="middle">8바퀴</text><text class="viz-ax" x="466.4" y="272" text-anchor="middle">10바퀴</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">12바퀴</text><polyline class="viz-line s1" points="52.0,232.1 155.6,181.9 259.2,149.8 362.8,117.2 466.4,84.3 570.0,51.4"/><circle class="viz-dot s1" cx="52.0" cy="232.1" r="4"><title>0바퀴 · 줄 길이 1.9m</title></circle><circle class="viz-dot s1" cx="155.6" cy="181.9" r="4"><title>4바퀴 · 줄 길이 6.1m</title></circle><circle class="viz-dot s1" cx="259.2" cy="149.8" r="4"><title>6바퀴 · 줄 길이 8.8m</title></circle><circle class="viz-dot s1" cx="362.8" cy="117.2" r="4"><title>8바퀴 · 줄 길이 11.6m</title></circle><circle class="viz-dot s1" cx="466.4" cy="84.3" r="4"><title>10바퀴 · 줄 길이 14.4m</title></circle><circle class="viz-dot s1" cx="570.0" cy="51.4" r="4"><title>12바퀴 · 줄 길이 17.2m</title></circle><text class="viz-val" x="578.0" y="55.4">17.2</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">4바퀴 이후로는 바퀴를 하나 더 감을 때마다 약 1.4m씩 늘어요.</p><div class="viz-cap">단위: m · 자료: 원뿔 나선 길이 공식으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>줄 길이</th></tr></thead><tbody><tr><td>0바퀴</td><td>1.9</td></tr><tr><td>4바퀴</td><td>6.1</td></tr><tr><td>6바퀴</td><td>8.8</td></tr><tr><td>8바퀴</td><td>11.6</td></tr><tr><td>10바퀴</td><td>14.4</td></tr><tr><td>12바퀴</td><td>17.2</td></tr></tbody></table></div></details></figure>
<p>바퀴 수를 4에서 8로 두 배 늘리면 줄은 6.11m에서 11.59m로 1.9배가 돼요. 세로 방향 길이가 처음부터 포함돼 있어 정확히 두 배는 아니에요. 한 바퀴를 추가할 때마다 늘어나는 길이는 4바퀴 근처에서 약 1.4m예요. 그러니 "한 바퀴 더 감을 때 줄 1.4m 추가"를 기억해 두면 현장에서 빠르게 가늠할 수 있어요.</p>
<p>바퀴 사이 세로 간격은 높이를 바퀴 수로 나눈 값이에요. 180cm에 8바퀴면 22.5cm마다 한 줄이 지나가요. 간격이 20~25cm쯤이면 은은하게 반짝이고, 15cm 아래로 내려가면 줄이 빽빽한 느낌이 나요. 취향에 따라 고르되, 바퀴 수를 정하면 줄 길이도 같이 정해진다는 점을 알면 돼요.</p>
<h2 id="s5">가진 줄로 몇 바퀴 감을 수 있을까</h2>
<p>반대로 이미 있는 줄을 쓸 수도 있어요. 180cm, 지름 90cm 트리에서 여유 10%를 남긴다고 보고 줄 길이별로 감을 수 있는 바퀴 수를 계산하면 다음과 같아요.</p>
<ul><li>5m 줄: 약 2.8바퀴</li><li>10m 줄: 약 6.2바퀴</li><li>15m 줄: 약 9.5바퀴</li><li>20m 줄: 약 12.7바퀴</li></ul>
<p>10m 줄 하나로는 6바퀴 남짓이라, 8바퀴를 원하면 줄을 하나 더 이어야 해요. 계산기에서 '가진 전구 줄 길이'에 값을 넣으면 같은 계산을 해 줘요. 줄을 이어 쓸 때는 제품마다 연결할 수 있는 개수에 한도가 있어서 설명서를 꼭 확인하세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">전구 개수와 간격 계산</h2>
<p>전구 줄을 살 때는 길이와 함께 전구 간격이 중요해요. 같은 10m 줄이라도 간격 5cm면 전구가 약 201개, 10cm면 101개, 20cm면 51개예요. 개수는 길이를 간격으로 나눠 내림하고 1을 더해서 구해요.</p>
<p>간격이 좁을수록 밝고 화려하지만 같은 길이에 전구가 많아 전력도 조금 더 들어요. 어느 쪽이든 LED 제품은 소비전력이 작아서 하루 몇 시간 켜는 정도로는 전기요금에 큰 영향이 없어요. 정확히 알고 싶으면 제품에 적힌 소비전력(W)을 <a href="https://tool.smithlab.co.kr/life/electricity-calculator/">전기요금 계산기</a>에 넣어 확인해 보세요.</p>
<h2 id="s7">줄을 사기 전에 확인할 것</h2>
<ul><li><strong>전구 부분 길이와 전체 길이를 구분해요.</strong> 제품 설명에 '총 길이 10m'라고 적혀 있어도 전구가 달린 부분은 8m이고 나머지 2m는 플러그까지 가는 전선인 경우가 많아요. 계산한 길이는 전구가 달린 부분 기준으로 맞춰야 해요.</li><li><strong>여유분은 10~30%로 잡아요.</strong> 매듭을 짓거나 가지에 얹히는 길이, 콘센트까지 가는 길이를 위한 것이에요. 안쪽 가지까지 입체로 감을 계획이면 30%까지 늘려 보세요.</li><li><strong>콘센트 위치를 정해요.</strong> 콘센트가 트리 아래에 있으면 아래에서 시작해 위로 올라간 뒤 다시 내려와야 해서 꼬리 선이 길어져요.</li><li><strong>연결 가능 개수와 전원 방식을 봐요.</strong> 건전지식, USB식, 콘센트식에 따라 줄을 이어 쓸 수 있는 방법이 달라요.</li><li><strong>안전하게 써요.</strong> 장시간 켜 둔 채 외출하거나 잠들지 않고, 줄이 손상된 제품은 쓰지 않는 게 안전해요. 멀티탭 하나에 여러 제품을 꽂을 땐 허용 용량을 넘기지 않도록 <a href="https://www.kesco.or.kr/" target="_blank" rel="noopener">한국전기안전공사</a>의 전기 안전 안내를 참고하세요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">직접 감는 순서</h2>
<ol><li>줄 전체를 펼쳐서 전구가 모두 켜지는지 먼저 확인해요.</li><li>콘센트에서 가까운 쪽을 정해 꼭대기 또는 바닥에서 시작해요.</li><li>바퀴 사이 세로 간격(계산기 결과)을 눈으로 기억해 두고 일정한 간격을 유지하며 돌려요.</li><li>한두 바퀴마다 가지 안쪽으로 줄을 살짝 밀어 넣으면 입체감이 생겨요.</li><li>끝에서 줄이 남으면 아래쪽 바퀴 간격을 조금 좁히고, 모자라면 위쪽 바퀴 수를 한 바퀴 줄여요.</li></ol>
<h2 id="s9">자주 묻는 질문</h2>
<h3>180cm 트리에는 전구 줄이 몇 미터 필요한가요?</h3>
<p>지름 90cm에 8바퀴를 감는다면 약 11.6m, 여유 10%를 합치면 12.8m예요. 6바퀴로 가볍게 하면 8.8m, 10바퀴로 촘촘히 하면 14.4m예요.</p>
<h3>바퀴 수는 몇 바퀴가 적당한가요?</h3>
<p>정답은 없고 취향이에요. 은은한 느낌은 높이 180cm에 5~6바퀴, 반짝이는 느낌은 8~10바퀴가 많이 쓰여요. 바퀴 사이 세로 간격이 20~25cm쯤이면 균형이 맞아 보이는 편이에요.</p>
<h3>아래 둘레 × 바퀴 수로 계산하면 안 되나요?</h3>
<p>그렇게 하면 180cm 트리 8바퀴가 22.6m로 나와서 두 배 가까이 많이 사게 돼요. 트리는 위로 갈수록 좁아져서 평균 둘레는 아래 둘레의 절반이기 때문이에요.</p>
<h3>트리가 슬림형인데 계산이 같은가요?</h3>
<p>같은 공식이지만 지름을 실제로 재서 넣어야 해요. 슬림 트리는 지름이 높이의 3분의 1 이하인 경우도 있어서, 같은 높이와 바퀴 수에서 줄이 짧게 필요해요. 예를 들어 180cm에 지름 60cm, 8바퀴는 약 7.9m예요.</p>
<h3>전구 개수는 어떻게 구하나요?</h3>
<p>줄 길이를 전구 간격으로 나눠서 내림하고 1을 더하면 돼요. 12m 줄에 10cm 간격이면 121개예요. 제품 포장에 적힌 전구 간격을 넣으세요.</p>
<h3>계산한 것보다 줄이 더 필요한 경우는 언제인가요?</h3>
<p>가지가 튀어나온 입체 트리, 안쪽 가지까지 줄을 넣는 경우, 콘센트가 멀리 있는 경우예요. 이럴 땐 여유분을 20~30%로 늘려서 계산하세요.</p>
<h2 id="s10">정리</h2>
<p>전구 줄 길이는 트리를 원뿔로 보고 높이, 지름, 바퀴 수로 계산해요. 180cm 트리 8바퀴는 11.6m, 여유까지 12.8m이고, 높이 90cm에서 240cm까지 3.1m에서 19.2m로 크게 달라져요. 바퀴 수를 정하면 줄 길이가 정해지고, 한 바퀴 더하면 4바퀴 근처에서 약 1.4m가 더 필요해요. 사기 전에 <a href="https://tool.smithlab.co.kr/life/christmas-tree-lights-calculator/">트리 전구 줄 길이 계산기</a>로 내 트리 치수를 넣어 보고, 장식은 <a href="https://tool.smithlab.co.kr/life/christmas-tree-decorator/">크리스마스 트리 꾸미기</a>에서 미리 구성해 보세요. 전기요금이 궁금하면 <a href="https://tool.smithlab.co.kr/life/electricity-calculator/">전기요금 계산기</a>도 함께 쓰면 좋아요.</p>
    ]]></description>
    </item>
    <item>
      <title>눈사람 만들 때 눈이 얼마나 필요할까? — 키 150cm면 약 240리터…</title>
      <link>https://tool.smithlab.co.kr/posts/life/snowman-snow-amount-weight-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/snowman-snow-amount-weight-guide/</guid>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>첫눈이 오면 아이도 어른도 한 번쯤 눈사람을 만들고 싶어져요. 그런데 막상 시작하면 "이거 눈이 얼마나 필요한 거지?" 하고 놀라게 돼요. 결론부터 말하면 <strong>키 150cm의 3단 눈사람 하나에는 눈이 약 240리터, 무게로는 약 73kg이 들어가요.</strong> 어른 한 명 몸무게와 비슷한 눈덩이를 쌓는 셈이에요.</p>
<p>이 글은 눈사람의 키만 알면 단마다 지름이 얼마여야 하는지, 눈이 몇 리터 필요한지, 바닥에 눈이 몇 cm 쌓여 있어야 만들 수 있는지를 직접 계산해서 정리했어요. 쓰는 공식은 구의 부피뿐이고, 눈의 밀도처럼 날씨에 따라 달라지는 값은 범위를 밝히고 어림값으로 썼어요. 계산을 대신 해 주는 <a href="https://tool.smithlab.co.kr/life/snowman-snow-calculator/">눈사람 눈 양 계산기</a>도 함께 소개해요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>눈덩이 하나의 부피는 π÷6에 지름의 세제곱을 곱한 값이에요. 지름 50cm면 약 65리터예요.</li><li>키 150cm 3단 눈사람(보통 뭉침, 300kg/m³)은 눈이 약 244리터, 무게가 약 73kg이에요.</li><li>키가 2배면 눈의 양은 8배가 돼요. 100cm는 22kg, 200cm는 173kg이에요.</li><li>촉촉한 눈이 10cm 쌓여 있다면 150cm 눈사람에 약 3.7제곱미터의 눈을 모아야 해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">눈사람 한 개의 부피는 어떻게 계산할까</h2>
<p>눈사람은 크고 작은 공을 쌓은 모양이라 각 단을 구로 보고 부피를 더하면 돼요. 구의 부피는 <strong>π ÷ 6 × 지름의 세제곱</strong>이에요. 지름이 50cm인 눈덩이라면 3.1416 ÷ 6 × 125,000 ≈ 65,450입방센티미터, 즉 약 65.4리터예요. 1리터는 1,000입방센티미터라서 나눠 주면 돼요.</p>
<p>단마다 지름은 눈사람의 키와 단 수로 정해져요. 이 글에서는 많이 쓰는 모양을 단순하게 정해 3단일 때 <strong>아래 단 : 가운데 단 : 머리 = 1 : 0.7 : 0.5</strong>, 2단일 때 <strong>1 : 0.65</strong>로 뒀어요. 키는 지름을 모두 더한 값이라 3단 눈사람의 아래 단 지름은 키 ÷ 2.2, 2단은 키 ÷ 1.65예요. 과학적으로 정해진 비율은 아니고 보기 좋은 모양을 기준으로 한 가정이에요. 눈덩이가 서로 겹치는 부분은 무시했어요.</p>
<h2 id="s2">눈의 무게는 밀도로 구해요</h2>
<p>부피를 알았으면 무게는 <strong>부피 × 밀도</strong>예요. 눈의 밀도는 눈의 상태에 따라 꽤 달라요. <a href="https://www.eoas.ubc.ca/courses/atsc113/snow/met_concepts/07-met_concepts/07b-newly-fallen-snow-density/" target="_blank" rel="noopener">UBC 대기과학 강의 자료</a>와 <a href="https://www.avalanche-center.org/Education/glossary/density.php" target="_blank" rel="noopener">미국 눈사태 센터 용어집</a> 등을 종합하면 방금 내린 푸석한 눈은 1세제곱미터당 50~175kg 정도가 흔하고, 쌓여서 다져진 눈은 200~300kg, 바람에 다져진 눈은 350~400kg 정도로 보고돼요. 자료마다 범위가 조금씩 다르고(기상 일반 자료는 <a href="https://www.komes.or.kr/" target="_blank" rel="noopener">한국기상학회</a>에서도 찾아볼 수 있어요) 물을 많이 머금은 젖은 눈은 더 무겁기도 해서, 정확한 값이라기보다 어림값이에요.</p>
<p>눈사람을 만들 때는 눈을 손으로 쥐어 뭉치면서 밀도가 높아져요. 그래서 이 글에서는 눈덩이의 밀도를 세 단계로 나눠 계산했어요.</p>
<ul><li><strong>느슨하게 뭉침</strong>: 1세제곱미터당 약 200kg</li><li><strong>보통으로 뭉침</strong>: 약 300kg</li><li><strong>꾹꾹 단단하게 뭉침</strong>: 약 400kg</li></ul>
<p>참고로 1리터에 300kg/m³라면 0.3kg이에요. 그래서 244리터 눈사람은 244 × 0.3 ≈ 73kg이 돼요.</p>
<h2 id="s3">키별 눈사람 크기와 눈의 양</h2>
<p>3단 눈사람을 보통으로 뭉쳤을 때(300kg/m³)의 크기와 무게를 계산하면 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>키</th><th>아래 / 가운데 / 머리 지름</th><th>눈 부피</th><th>무게</th><th>10L 양동이</th></tr></thead><tbody><tr><td>50cm</td><td>23 / 16 / 11cm</td><td>9L</td><td>3kg</td><td>1통</td></tr><tr><td>80cm</td><td>36 / 25 / 18cm</td><td>37L</td><td>11kg</td><td>4통</td></tr><tr><td>100cm</td><td>45 / 32 / 23cm</td><td>72L</td><td>22kg</td><td>8통</td></tr><tr><td>120cm</td><td>55 / 38 / 27cm</td><td>125L</td><td>37kg</td><td>13통</td></tr><tr><td>150cm</td><td>68 / 48 / 34cm</td><td>244L</td><td>73kg</td><td>25통</td></tr><tr><td>170cm</td><td>77 / 54 / 39cm</td><td>355L</td><td>106kg</td><td>36통</td></tr><tr><td>200cm</td><td>91 / 64 / 45cm</td><td>577L</td><td>173kg</td><td>58통</td></tr></tbody></table></div>
<p>이 표에서 가장 눈에 띄는 건 <strong>키가 커질수록 눈이 급격히 늘어난다</strong>는 점이에요. 100cm에서 200cm로 키가 2배가 되면 눈의 양은 72리터에서 577리터로 8배가 돼요. 부피가 지름의 세제곱에 비례하기 때문이에요. 아이 키만 한 눈사람은 금방 만드는데 어른 키만 한 눈사람은 하루 종일 걸리는 이유가 여기 있어요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/snowman-snow-amount-weight-guide-1.svg" alt="키별 3단 눈사람 무게를 막대로 보여 주는 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>키 100cm는 22kg, 150cm는 73kg, 200cm는 173kg이에요</figcaption></figure>
<h2 id="s4">바닥에 눈이 얼마나 쌓여 있어야 할까</h2>
<p>눈사람을 만들 눈은 바닥에서 긁어모아요. 뭉치면 부피가 줄어드니 눈사람에 들어가는 눈보다 더 많은 바닥 눈이 필요해요. 뭉친 눈사람의 무게가 같다면 <strong>바닥 눈 부피 = 눈사람 무게 ÷ 바닥 눈 밀도</strong>예요.</p>
<p>키 150cm, 3단, 보통 뭉침(300kg/m³)인 눈사람의 무게는 약 73kg이에요. 바닥 눈이 촉촉하고 무거운 눈(약 200kg/m³)이라면 73 ÷ 200 ≈ 0.365세제곱미터, 약 365리터의 눈이 있어야 해요. 푸석한 눈(약 100kg/m³)이라면 730리터로 두 배가 필요해요. 이걸 눈 깊이로 나누면 모아야 할 땅의 넓이가 나와요.</p>
<div class="table-wrap"><table><thead><tr><th>바닥 눈 깊이</th><th>푸석한 눈(100kg/m³)</th><th>촉촉한 눈(200kg/m³)</th></tr></thead><tbody><tr><td>3cm</td><td>24.4㎡</td><td>12.2㎡</td></tr><tr><td>5cm</td><td>14.6㎡</td><td>7.3㎡</td></tr><tr><td>10cm</td><td>7.3㎡</td><td>3.7㎡</td></tr><tr><td>20cm</td><td>3.7㎡</td><td>1.8㎡</td></tr></tbody></table></div>
<p>눈이 10cm 쌓인 날 촉촉한 눈이라면 3.7제곱미터, 가로세로 약 1.9미터 정도의 눈을 모으면 돼요. 반대로 눈이 3cm밖에 안 쌓였는데 푸석한 눈이라면 약 24제곱미터, 가로세로 5미터 가까운 땅을 긁어야 해서 150cm 눈사람은 현실적으로 어려워요. 이런 날은 50~80cm 작은 눈사람이 알맞아요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">뭉치는 정도와 단 수가 달라지면</h2>
<p>같은 키라도 어떻게 뭉치느냐에 따라 무게가 달라져요. 키 150cm 3단 눈사람은 느슨하게 뭉치면 약 49kg, 보통이면 73kg, 단단하게 뭉치면 약 97kg이에요. 단단할수록 무겁지만 쉽게 갈라지지 않고 오래 서 있어요.</p>
<p>단 수도 영향을 줘요. 같은 150cm에서 3단은 아래 단 지름이 68cm이고 눈이 244리터, 2단은 아래 단이 91cm로 커지고 눈이 501리터(약 150kg)가 들어가요. 두 배가 넘는 눈이 필요하니 키를 크게 하고 싶다면 단 수를 늘리는 쪽이 눈을 훨씬 아껴요. 아이들끼리 만든다면 3단 이상이 가볍고 현실적이에요.</p>
<h2 id="s6">눈이 잘 뭉치는 날씨는 따로 있어요</h2>
<p>모든 눈이 눈사람에 어울리는 건 아니에요. 기온이 너무 낮은 날의 눈은 얼음 알갱이처럼 푸석해서 뭉쳐지지 않아요. 눈이 알맞게 뭉치려면 눈 표면에 얇은 물막이 있어야 하는데, 이 조건은 대체로 기온이 영하 몇 도에서 0도 근처일 때 생겨요. 정확한 온도는 습도와 눈 종류에 따라 달라서 숫자로 딱 잘라 말하기는 어려워요. 간단한 확인 방법은 눈을 한 줌 쥐어 보는 거예요. 꽉 쥐었을 때 모양이 유지되면 눈사람에 알맞은 눈이에요.</p>
<p>나갈 때는 체감온도도 챙겨야 해요. 바람이 불면 같은 기온도 훨씬 춥게 느껴져서 오래 있다 보면 손발이 먼저 얼어요. <a href="https://tool.smithlab.co.kr/life/feels-like-temperature/">체감온도 계산기</a>로 기온과 풍속을 넣어 보고 나갈 시간을 정하면 좋아요. 집에 돌아온 뒤 몸을 데우느라 난방을 세게 틀게 된다면 <a href="https://tool.smithlab.co.kr/life/winter-heater-electricity-calculator/">난방기 전기요금 계산기</a>로 하루 사용 요금도 어림해 볼 수 있어요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">눈사람을 쌓는 순서와 요령</h2>
<ol><li><strong>작은 덩어리부터 굴려요.</strong> 주먹만 한 눈덩이를 눈 위로 굴리면 지름이 커질수록 닿는 면이 넓어져서 점점 빨리 커져요.</li><li><strong>아래 단은 서 있을 자리에서 굴려요.</strong> 80kg에 가까운 아래 단은 옮기기가 어려워서 마지막 자리에서 가까운 곳에서 만들어요.</li><li><strong>가운데 단과 머리는 가볍게 만들어 올려요.</strong> 150cm 눈사람의 머리는 지름이 34cm이고 무게가 약 6kg이라 혼자서도 올릴 수 있지만 가운데 단은 지름 48cm에 약 17kg이라 둘이서 드는 게 안전해요.</li><li><strong>단 사이는 눈으로 메워요.</strong> 얹은 뒤 틈에 눈을 채우고 눌러 이음새를 단단하게 해 주면 갈라지지 않아요.</li><li><strong>눈과 코, 팔은 마지막에.</strong> 가벼운 소품은 마지막에 꽂으면 모양이 잡혀요.</li></ol>
<h2 id="s8">자주 묻는 질문</h2>
<h3>눈사람 하나 만들려면 눈이 몇 통 필요한가요?</h3>
<p>키 100cm 3단 눈사람은 눈이 약 72리터라서 10리터 양동이로 8통 정도예요. 150cm는 25통, 200cm는 58통이에요. 뭉치기 전 바닥 눈으로 모으려면 이보다 더 많이 필요해요.</p>
<h3>눈이 3cm밖에 안 쌓였는데 눈사람을 만들 수 있나요?</h3>
<p>작게는 가능해요. 촉촉한 눈이라면 50cm 눈사람은 뭉치기 전 눈이 약 14리터라서 0.5제곱미터 정도의 눈으로 충분하지만, 150cm는 12제곱미터 넘게 필요해서 어려워요.</p>
<h3>눈사람 단 지름 비율은 정해져 있나요?</h3>
<p>정해진 규칙은 없어요. 이 글에서는 3단 1 : 0.7 : 0.5, 2단 1 : 0.65로 가정했어요. 머리를 더 크게 하고 싶으면 비율을 바꿔 구의 부피 공식으로 다시 계산하면 돼요.</p>
<h3>눈의 무게가 자료마다 다른 이유는 뭔가요?</h3>
<p>눈의 밀도가 눈의 종류와 날씨, 쌓인 시간에 따라 크게 달라서예요. 방금 내린 푸석한 눈과 다져진 눈, 젖은 눈은 몇 배씩 차이가 나요. 이 글의 숫자는 어림값이에요.</p>
<h3>눈이 오는 날 혼자 큰 눈사람을 만들면 위험한가요?</h3>
<p>100kg 가까운 눈덩이는 허리를 다칠 수 있어요. 아래 단은 굴려서 위치를 맞추고, 위 단은 둘이서 올리세요. 도로나 비탈 근처는 피하고, 장갑이 젖으면 바로 갈아 끼우세요.</p>
<h2 id="s9">정리</h2>
<p>눈사람의 눈 양은 키가 정해지면 구의 부피 공식으로 바로 나와요. 키 150cm 3단 눈사람은 약 244리터, 약 73kg이고, 키가 2배가 되면 눈은 8배가 돼요. 바닥에 눈이 얼마나 쌓였는지에 따라 모아야 할 면적이 달라지니, 눈이 적은 날은 작게 만드는 편이 좋아요. 키·단 수·눈 상태를 바꿔 가며 계산해 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/snowman-snow-calculator/">눈사람 눈 양 계산기</a>를 써 보세요. 겨울 모임 준비는 <a href="https://tool.smithlab.co.kr/life/cake-cutting-calculator/">케이크 똑같이 나누기 계산기</a>와 <a href="https://tool.smithlab.co.kr/life/christmas-tree-lights-calculator/">트리 전구 줄 길이 계산기</a>도 도움이 돼요.</p>
    ]]></description>
    </item>
    <item>
      <title>배구 승점 계산 방법, 3-0 승리와 3-2 승리는 승점이 얼마나 다를까</title>
      <link>https://tool.smithlab.co.kr/posts/calculator/volleyball-points-rule-set-score-win-same-rank-different/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/calculator/volleyball-points-rule-set-score-win-same-rank-different/</guid>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>프로배구 순위표를 보다 보면 "승수는 더 많은데 왜 순위가 아래지?" 싶은 순간이 있어요. 야구나 농구는 이기면 1승, 지면 1패로 끝나지만 배구는 <strong>세트 스코어에 따라 승점이 달라지는</strong> 방식이기 때문이에요. 결론부터 말하면 3-0 또는 3-1로 이기면 3점, 3-2로 이기면 2점, 3-2로 지면 1점, 그 외에 지면 0점이고, 한 경기에서 두 팀이 나눠 갖는 승점은 언제나 합쳐서 3점이에요.</p>
<p>이 글은 승점 규칙을 먼저 정리하고, 두 팀의 세트 승률이 같을 때와 다를 때 경기마다 승점이 어떻게 나오는지 직접 계산해서 보여 줘요. 이어서 같은 승패인데도 승점이 크게 벌어지는 이유, 시즌 승점을 어림하는 방법, 순위표를 읽을 때 놓치기 쉬운 부분까지 다뤄요. 지금까지의 전적을 넣어 바로 계산해 보고 싶다면 <a href="https://tool.smithlab.co.kr/calculator/volleyball-points-calculator/">배구 승점 계산기</a>를 함께 쓰세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>배구 승점은 3-0·3-1 승 3점, 3-2 승 2점, 3-2 패 1점, 그 외 패 0점이에요. 한 경기의 승점 합은 항상 3점이에요.</li><li>두 팀 실력이 비슷해 한 세트를 이길 확률이 50%라면 경기의 62.5%가 4세트 안에 끝나고 37.5%는 5세트까지 가요.</li><li>10승 10패인 팀의 승점은 최소 20점에서 최대 40점까지 벌어질 수 있어요.</li><li>승점 차이는 승수보다 '4세트 안에 끝낸 승리'와 '풀세트 패배'에서 나는 경우가 많아요.</li></ul></aside>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/calculator/volleyball-points-rule-set-score-1.svg" alt="배구 경기 결과별 승점을 보여 주는 카드: 3-0·3-1 승 3점 대 0점, 3-2 승 2점 대 1점" width="800" height="450" loading="lazy" decoding="async"><figcaption>한 경기에서 나눠 갖는 승점은 항상 3점이에요</figcaption></figure>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">배구 승점 규칙 한눈에 보기</h2>
<p>배구는 세트를 먼저 3개 가져가는 팀이 이기는 경기예요. 그래서 경기가 끝나는 세트 스코어는 3-0, 3-1, 3-2 세 가지가 있고, 승점은 이렇게 나뉘어요.</p>
<div class="table-wrap"><table><thead><tr><th>세트 스코어</th><th>이긴 팀 승점</th><th>진 팀 승점</th><th>승점 합</th></tr></thead><tbody><tr><td>3-0</td><td>3점</td><td>0점</td><td>3점</td></tr><tr><td>3-1</td><td>3점</td><td>0점</td><td>3점</td></tr><tr><td>3-2</td><td>2점</td><td>1점</td><td>3점</td></tr></tbody></table></div>
<p>기준일은 2026-11-02예요. 한국 V-리그는 4세트 이내에 이긴 팀에 승점 3점을 주고, 5세트까지 간 경기는 이긴 팀 2점, 진 팀 1점으로 나눠요. 규칙 설명은 <a href="https://en.wikipedia.org/wiki/2023%E2%80%9324_V-League_(South_Korea" target="_blank" rel="noopener">2023–24 V-League (위키백과)</a>)에서 볼 수 있고, 시즌별 세부 규정은 <a href="https://www.kovo.co.kr/" target="_blank" rel="noopener">한국배구연맹(KOVO)</a>에서 확인할 수 있어요.</p>
<p>이 방식이 만드는 가장 큰 차이는 <strong>풀세트 접전의 가치</strong>예요. 3-2로 끝나면 이긴 팀은 1점을 덜 가져가고, 진 팀은 빈손으로 돌아가지 않아요. 접전이 많은 팀은 승패 기록과 승점 기록이 어긋나기 쉬워요.</p>
<h2 id="s2">세트 승률별로 경기 결과는 어떻게 나뉠까</h2>
<p>두 팀이 맞붙을 때 한 팀이 한 세트를 이길 확률을 p라고 하고, 세트마다 서로 독립이라고 단순화해서 계산해 볼게요. 3-0이 나올 확률은 p³, 3-1은 3p³(1−p), 3-2는 6p³(1−p)², 반대로 지는 쪽은 2-3이 6(1−p)³p², 1-3이 3(1−p)³p, 0-3이 (1−p)³ 이에요. 아래 표는 이 식으로 직접 계산한 값이에요.</p>
<div class="table-wrap"><table><thead><tr><th>세트 승률 p</th><th>3-0 승</th><th>3-1 승</th><th>3-2 승</th><th>2-3 패</th><th>1-3 패</th><th>0-3 패</th></tr></thead><tbody><tr><td>40%</td><td>6.4%</td><td>11.5%</td><td>13.8%</td><td>20.7%</td><td>25.9%</td><td>21.6%</td></tr><tr><td>50%</td><td>12.5%</td><td>18.8%</td><td>18.8%</td><td>18.8%</td><td>18.8%</td><td>12.5%</td></tr><tr><td>60%</td><td>21.6%</td><td>25.9%</td><td>20.7%</td><td>13.8%</td><td>11.5%</td><td>6.4%</td></tr><tr><td>70%</td><td>34.3%</td><td>30.9%</td><td>18.5%</td><td>7.9%</td><td>5.7%</td><td>2.7%</td></tr></tbody></table></div>
<p>세트를 이길 확률이 똑같이 50%인 두 팀이면 경기의 37.5%(3-2 승 18.8% + 2-3 패 18.8%)가 5세트까지 가고, 나머지 62.5%는 4세트 안에 끝나요. 한 팀이 세트를 70%로 이기는 압도적인 팀이면 경기 승리 확률이 83.7%까지 올라가요. 세트 승률이 70%인 팀의 3-0 승리는 34.3%로 3경기 중 1경기꼴이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">세트 승률별 경기 결과 확률 (4세트 이내 승리 vs 3-2 승리 vs 패배)</figcaption><div class="viz-legend"><span><i class="s1"></i>4세트 이내 승리</span><span><i class="s2"></i>3-2 승리</span><span><i class="s3"></i>패배(전체)</span></div><svg viewBox="0 0 640 300" role="img" aria-label="세트 승률별 경기 결과 확률 (4세트 이내 승리 vs 3-2 승리 vs 패배)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="195.0" y2="195.0"/><text class="viz-ax" x="46" y="199.0" text-anchor="end">20</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">40</text><line class="viz-grid" x1="52" x2="628" y1="77.0" y2="77.0"/><text class="viz-ax" x="46" y="81.0" text-anchor="end">60</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">80</text><rect class="s1" x="73.6" y="201.2" width="32.3" height="52.8" rx="3"><title>40% · 4세트 이내 승리 17.9%</title></rect><rect class="s2" x="107.9" y="213.3" width="32.3" height="40.7" rx="3"><title>40% · 3-2 승리 13.8%</title></rect><rect class="s3" x="142.1" y="52.5" width="32.3" height="201.5" rx="3"><title>40% · 패배(전체) 68.3%</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">40%</text><rect class="s1" x="217.6" y="161.7" width="32.3" height="92.3" rx="3"><title>50% · 4세트 이내 승리 31.3%</title></rect><rect class="s2" x="251.9" y="198.5" width="32.3" height="55.5" rx="3"><title>50% · 3-2 승리 18.8%</title></rect><rect class="s3" x="286.1" y="106.5" width="32.3" height="147.5" rx="3"><title>50% · 패배(전체) 50%</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">50%</text><rect class="s1" x="361.6" y="113.9" width="32.3" height="140.1" rx="3"><title>60% · 4세트 이내 승리 47.5%</title></rect><rect class="s2" x="395.9" y="192.9" width="32.3" height="61.1" rx="3"><title>60% · 3-2 승리 20.7%</title></rect><rect class="s3" x="430.1" y="160.5" width="32.3" height="93.5" rx="3"><title>60% · 패배(전체) 31.7%</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">60%</text><rect class="s1" x="505.6" y="61.7" width="32.3" height="192.3" rx="3"><title>70% · 4세트 이내 승리 65.2%</title></rect><rect class="s2" x="539.9" y="199.4" width="32.3" height="54.6" rx="3"><title>70% · 3-2 승리 18.5%</title></rect><rect class="s3" x="574.1" y="205.9" width="32.3" height="48.1" rx="3"><title>70% · 패배(전체) 16.3%</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">70%</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">세트 승률이 오를수록 3점짜리 승리의 비중이 가장 빠르게 늘어요.</p><div class="viz-cap">단위: % · 자료: 세트 독립 가정, 이항 계산으로 직접 산출</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>4세트 이내 승리</th><th>3-2 승리</th><th>패배(전체)</th></tr></thead><tbody><tr><td>40%</td><td>17.9</td><td>13.8</td><td>68.3</td></tr><tr><td>50%</td><td>31.3</td><td>18.8</td><td>50</td></tr><tr><td>60%</td><td>47.5</td><td>20.7</td><td>31.7</td></tr><tr><td>70%</td><td>65.2</td><td>18.5</td><td>16.3</td></tr></tbody></table></div></details></figure>
<h2 id="s3">세트 승률이 곧 경기당 기대 승점이에요</h2>
<p>경기당 평균 승점도 계산해 볼게요. 승점 기대값은 3×(3-0 승 + 3-1 승) + 2×(3-2 승) + 1×(2-3 패)예요.</p>
<div class="table-wrap"><table><thead><tr><th>세트 승률 p</th><th>경기 승리 확률</th><th>경기당 기대 승점</th><th>36경기 환산</th></tr></thead><tbody><tr><td>40%</td><td>31.7%</td><td>1.02점</td><td>36.7점</td></tr><tr><td>45%</td><td>40.7%</td><td>1.26점</td><td>45.3점</td></tr><tr><td>50%</td><td>50.0%</td><td>1.50점</td><td>54.0점</td></tr><tr><td>55%</td><td>59.3%</td><td>1.74점</td><td>62.7점</td></tr><tr><td>60%</td><td>68.3%</td><td>1.98점</td><td>71.2점</td></tr><tr><td>65%</td><td>76.5%</td><td>2.20점</td><td>79.2점</td></tr><tr><td>70%</td><td>83.7%</td><td>2.41점</td><td>86.6점</td></tr></tbody></table></div>
<p>세트 승률 50%인 팀의 경기당 기대 승점은 정확히 1.5점이에요. 한 경기의 승점 합이 3점이고 두 팀이 대칭이니 당연한 값이에요. 승점 경쟁에서 두 팀의 차이는 세트를 5%포인트 더 이길 때마다 경기당 약 0.24점씩 벌어지는 모양이에요. 36경기로 환산하면 한 시즌에 약 8~9점 차이예요. 이 표의 36경기는 계산을 위해 가정한 값이고, 실제 경기 수는 시즌마다 달라요.</p>
<h2 id="s4">같은 승패인데 승점이 다른 이유</h2>
<p>승점제의 재미는 승패 기록과 승점이 어긋나는 데서 나와요. 10경기를 치른 두 팀을 비교해 볼게요.</p>
<ul><li>A팀: 8승 2패. 승리 8번 중 6번은 4세트 이내(3점), 2번은 3-2(2점). 패배 2번은 모두 0-3 또는 1-3(0점). 승점 = 3×6 + 2×2 = <strong>22점</strong>.</li><li>B팀: 8승 2패. 승리 8번이 모두 3-2(2점)이고 패배 2번도 3-2(1점). 승점 = 2×8 + 1×2 = <strong>18점</strong>.</li></ul>
<p>승패 기록은 같은데 A팀이 4점 앞서요. 세트 스코어를 보면 A팀은 압도하는 경기를 했고 B팀은 매번 접전이었으니, 승점이 실력을 더 정확히 반영한다고 볼 수도 있어요.</p>
<p>반대로 승수가 적은 팀이 승점이 더 많은 일도 생겨요. 6승 4패인 C팀이 승리 6번이 모두 4세트 이내(18점)이고 패배 4번이 모두 3-2(4점)라면 승점은 <strong>22점</strong>이에요. 8승 2패인 B팀(18점)보다 승수는 2승 적지만 승점은 4점 많아요.</p>
<h3>전적별 승점의 최소·최대 범위</h3>
<p>w승 l패인 팀의 승점 범위는 간단한 식으로 나와요. 최소는 모든 승리가 3-2이고 모든 패배가 0-3인 경우로 2w점, 최대는 모든 승리가 4세트 이내이고 모든 패배가 3-2인 경우로 3w + l점이에요.</p>
<div class="table-wrap"><table><thead><tr><th>전적</th><th>최소 승점</th><th>최대 승점</th><th>차이</th></tr></thead><tbody><tr><td>5승 5패</td><td>10점</td><td>20점</td><td>10점</td></tr><tr><td>8승 4패</td><td>16점</td><td>28점</td><td>12점</td></tr><tr><td>10승 10패</td><td>20점</td><td>40점</td><td>20점</td></tr><tr><td>12승 8패</td><td>24점</td><td>44점</td><td>20점</td></tr><tr><td>15승 9패</td><td>30점</td><td>54점</td><td>24점</td></tr></tbody></table></div>
<p>실제 팀의 승점은 이 범위의 가운데 어디쯤에 있어요. 하지만 범위가 20점이나 벌어질 수 있다는 건 승수만으로 순위를 판단하면 안 된다는 뜻이에요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s5">목표 승점까지 몇 번 이겨야 할까</h2>
<p>남은 경기에서 목표 승점을 채우려면 부족한 승점을 기준으로 거꾸로 계산해요. 부족한 승점이 13점이면 모두 4세트 이내로 이길 때 올림(13÷3) = 5번, 모두 3-2 승으로만 이긴다면 올림(13÷2) = 7번이 필요해요. 실제로는 두 종류가 섞이니까 5번과 7번 사이의 어딘가에서 목표에 닿아요.</p>
<div class="table-wrap"><table><thead><tr><th>부족한 승점</th><th>3점 승만</th><th>3-2 승만</th></tr></thead><tbody><tr><td>6점</td><td>2번</td><td>3번</td></tr><tr><td>9점</td><td>3번</td><td>5번</td></tr><tr><td>12점</td><td>4번</td><td>6번</td></tr><tr><td>15점</td><td>5번</td><td>8번</td></tr><tr><td>20점</td><td>7번</td><td>10번</td></tr></tbody></table></div>
<p>남은 경기가 10경기이고 목표가 20점 부족한 상황이라면, 3점 승이라면 7번만 이겨도 되지만 풀세트로만 이긴다면 10경기를 전부 이겨야 해요. 이럴 땐 단순히 이기는 것보다 <strong>4세트 이내로 끝내는 것</strong>이 목표 달성의 열쇠예요. 기록을 넣어 보면 <a href="https://tool.smithlab.co.kr/calculator/volleyball-points-calculator/">배구 승점 계산기</a>가 필요한 승리 횟수를 바로 계산해 줘요.</p>
<h2 id="s6">시즌 승점을 어림하는 방법</h2>
<p>시즌 중간에 최종 승점을 어림할 때는 지금까지의 평균 승점이 계속된다고 가정해요. 12경기를 치러 승점 21점이라면 평균은 1.75점이고, 남은 24경기를 같은 속도로 가면 21 + 24×1.75 = 63점으로 어림할 수 있어요. 앞 표에 따르면 평균 1.74점은 세트 승률 55% 정도의 팀이에요.</p>
<p>다만 이 방식에는 한계가 있어요. 남은 상대가 강팀 위주인지, 홈과 원정 일정이 어떻게 몰려 있는지, 부상 선수가 돌아오는지 등은 반영하지 못해요. 그래서 계산기는 예상 최종 승점과 함께 <strong>남은 경기를 모두 지는 경우와 모두 3점 승을 거두는 경우의 범위</strong>도 같이 보여 줘요. 이 범위 안에 목표가 들어 있는지부터 확인하면 현실적인지 판단하기 쉬워요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s7">순위표를 읽을 때 놓치기 쉬운 것</h2>
<ul><li><strong>승수보다 승점을 먼저 봐요.</strong> 배구 순위표는 승점이 기본이에요. 승점이 같을 때의 기준은 시즌 규정집에서 확인하세요.</li><li><strong>경기 수를 맞춰 비교해요.</strong> 경기 수가 다른 두 팀의 승점은 단순 비교가 어려워요. 경기당 평균 승점으로 나눠 보면 공정해요.</li><li><strong>풀세트 비율을 확인해요.</strong> 3-2 경기가 많은 팀은 승점이 승패에 비해 낮거나 높게 보일 수 있어요.</li><li><strong>세트득실률은 승점과 다른 지표예요.</strong> 순위표의 세트득실률(딴 세트 ÷ 내준 세트)은 경기 내용을 보여 주는 참고 지표예요.</li></ul>
<h2 id="s8">자주 묻는 질문</h2>
<h3>배구 3-2 승리는 왜 2점이에요?</h3>
<p>접전 끝에 이긴 경기는 이긴 팀에 2점, 진 팀에 1점을 줘서 승점 합을 3점으로 맞추는 방식이에요. 4세트 이내에 끝낸 승리를 더 값지게 보고, 끝까지 싸운 패배에도 1점을 주는 구조예요.</p>
<h3>승수가 같으면 승점도 같은가요?</h3>
<p>아니에요. 승수가 같아도 3-0·3-1 승과 3-2 승의 비중, 3-2 패배 횟수에 따라 승점이 달라져요. 10승 10패라면 20점에서 40점까지 벌어질 수 있어요.</p>
<h3>남은 경기에서 모두 이겨도 목표 승점에 못 닿을 수 있나요?</h3>
<p>네, 남은 경기 수 × 3점이 도달 가능한 최대 승점 증가분이에요. 부족한 승점이 이보다 크면 모두 이겨도 불가능해요. 계산기의 '최대 최종 승점'에서 바로 확인할 수 있어요.</p>
<h3>평균 승점으로 최종 승점을 어림하면 정확한가요?</h3>
<p>참고용이에요. 남은 상대와 일정, 선수 상태를 반영하지 않기 때문이에요. 범위(최소~최대)를 같이 보고 목표가 현실적인지만 판단하는 용도로 쓰세요.</p>
<h3>승점이 같으면 순위는 어떻게 정해져요?</h3>
<p>승점이 같을 때의 기준과 순서는 시즌 규정에 따라 달라요. 정확한 내용은 한국배구연맹(KOVO) 규정집과 공식 순위표를 확인하세요.</p>
<h2 id="s9">정리</h2>
<p>배구 승점은 3-0·3-1 승 3점, 3-2 승 2점·패 1점, 그 외 패 0점이고 한 경기의 승점 합은 항상 3점이에요. 세트 승률이 50%인 팀의 경기당 기대 승점은 1.5점이고, 5%포인트씩 오를 때마다 0.24점 안팎씩 늘어나요. 같은 전적이어도 승점이 최대 두 배까지 벌어질 수 있으니 순위는 승수가 아니라 승점으로 읽고, 목표를 세울 땐 4세트 이내 승리를 몇 번 거둬야 하는지부터 계산해 보세요. 전적을 넣어 바로 확인하려면 <a href="https://tool.smithlab.co.kr/calculator/volleyball-points-calculator/">배구 승점 계산기</a>, 시리즈 승리 확률은 <a href="https://tool.smithlab.co.kr/calculator/series-win-probability-calculator/">선승제 시리즈 확률 계산기</a>, 일반 확률 계산은 <a href="https://tool.smithlab.co.kr/calculator/probability-calculator/">확률 계산기</a>를 이용하세요.</p>
    ]]></description>
    </item>
    <item>
      <title>Mockito doReturn vs when 고르는 법</title>
      <link>https://tool.smithlab.co.kr/posts/developer/mockito-stubbing-doreturn-spy-matcher-strictness-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/developer/mockito-stubbing-doreturn-spy-matcher-strictness-guide/</guid>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p><code>when(spy.get(0))</code>에서 갑자기 <code>IndexOutOfBoundsException</code>이 나거나, JUnit 5로 옮기자마자 멀쩡하던 테스트가 <code>UnnecessaryStubbingException</code>으로 무더기 실패한 적 있으시죠. 결론부터 말하면 <strong>스텁은 기본적으로 <code>when(...).thenReturn(...)</code>으로 쓰고, void 메서드·spy·예외를 건 스텁을 덮어쓸 때만 <code>doReturn</code> 계열로 바꾸면</strong> 대부분의 혼란이 정리돼요. strict 스텁 오류는 테스트를 느슨하게 풀 신호가 아니라, 스텁과 실제 호출이 어긋났다는 신호로 읽는 게 맞아요.</p>
<p>이 글은 스텁을 어떤 문법으로 걸지 정하는 판단표, 예외 이름으로 원인을 찾는 확인 순서, 그리고 스텁 개수와 호출 횟수가 테스트 시간에 얼마나 영향을 주는지 직접 잰 숫자를 정리했어요. 목 메이커 선택과 <code>-javaagent</code> 설정은 <a href="https://tool.smithlab.co.kr/posts/developer/mockito-mock-maker-setup-guide/">Mockito 목 메이커 선택표와 javaagent 경고 해결 순서</a>에서 따로 다뤘어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>기본은 <code>when().thenReturn()</code>이에요. 반환 타입을 컴파일러가 검사해 줘서 실수가 적어요.</li><li>void 메서드, spy, 예외를 건 스텁을 다시 바꿀 때는 <code>doReturn</code>·<code>doThrow</code>·<code>doAnswer</code>를 써요. 공식 문서 12절이 이 세 경우를 꼭 필요한 경우로 꼽아요.</li><li>매처는 인자 전부에 쓰거나 아예 안 써요. 기본형 인자에는 <code>any()</code> 대신 <code>anyInt()</code> 같은 전용 매처를 써요.</li><li>목 호출 한 번은 직접 만든 가짜 구현보다 수천 배 느려요. 같은 메서드에 스텁을 수백 개 걸거나 반복문 안에서 수십만 번 부르는 테스트는 <code>thenAnswer</code>나 가짜 구현으로 바꿔요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">Mockito 스텁 문법 네 가지, 무엇이 다른가요</h2>
<p>Mockito에서 목의 반환값을 정하는 방법은 크게 네 가지예요. 공식 <a href="https://javadoc.io/static/org.mockito/mockito-core/5.23.0/org.mockito/org/mockito/Mockito.html" target="_blank" rel="noopener">Mockito Javadoc</a> 2·11·12·13절에 각각 나와 있어요. 차이는 "스텁을 거는 순간 대상 메서드가 실제로 불리느냐"와 "반환 타입을 컴파일 때 검사하느냐" 두 가지로 정리돼요.</p>
<div class="table-wrap"><table><thead><tr><th>문법</th><th>스텁 거는 순간 대상 메서드 호출</th><th>반환 타입 컴파일 검사</th><th>void 메서드</th><th>주로 쓰는 곳</th></tr></thead><tbody><tr><td><code>when(m.f(x)).thenReturn(v)</code></td><td>불림 (목이면 기본값, spy면 진짜 코드)</td><td>됨</td><td>불가</td><td>일반 목 대부분</td></tr><tr><td><code>doReturn(v).when(m).f(x)</code></td><td>안 불림</td><td>안 됨 (<code>Object</code>로 받음)</td><td>불가(<code>doNothing</code> 사용)</td><td>spy, 덮어쓰기</td></tr><tr><td><code>doThrow(e).when(m).f(x)</code></td><td>안 불림</td><td>—</td><td>가능</td><td>void 메서드 예외</td></tr><tr><td><code>when(...).thenAnswer(inv -&gt; ...)</code></td><td>불림</td><td>됨</td><td>불가(<code>doAnswer</code> 사용)</td><td>인자에 따라 값 계산</td></tr></tbody></table></div>
<p><code>when()</code> 문법은 괄호 안의 <code>m.f(x)</code>가 먼저 실행된 뒤에 <code>when()</code>이 불려요. 목이면 기본값을 돌려줄 뿐이지만 spy라면 진짜 코드가 그대로 실행돼요. 반대로 <code>doReturn</code>은 값을 <code>Object</code>로 받아서 컴파일러가 타입을 확인해 주지 않아요. 문자열을 돌려줘야 할 자리에 숫자를 넣어도 컴파일은 통과하고 실행할 때 <code>WrongTypeOfReturnValue</code>로 실패해요.</p>
<p>그래서 실무에서는 <strong>타입 안전한 <code>when</code>을 기본으로, 호출이 미리 일어나면 곤란한 경우만 <code>doReturn</code></strong>으로 나누는 게 가장 덜 헷갈려요.</p>
<h2 id="s2">상황별로 when·doReturn·spy 고르는 판단표</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/mockito-stubbing-doreturn-spy-matcher-strictness-guide-1.svg" alt="void 메서드인지, spy인지, 예외 스텁을 덮어쓰는지, 인자 종류가 많은지를 차례로 물어 doThrow·doReturn·thenAnswer·when 중 하나를 고르는 순서도" width="800" height="560" loading="lazy" decoding="async"><figcaption>위에서부터 질문하고 처음 &#x27;예&#x27;가 나오는 곳에서 멈춰요. 대부분은 맨 아래 when().thenReturn()에 도착해요</figcaption></figure>
<div class="table-wrap"><table><thead><tr><th>상황</th><th>고를 것</th><th>이유</th></tr></thead><tbody><tr><td>반환값이 있는 일반 목 메서드</td><td><code>when().thenReturn()</code></td><td>타입 검사가 되고 읽기 쉬워요</td></tr><tr><td>void 메서드가 예외를 던지게</td><td><code>doThrow(e).when(m).save(x)</code></td><td><code>when()</code> 괄호 안에 void 호출을 넣을 수 없어요</td></tr><tr><td>void 메서드에 부수 효과(인자 캡처, 카운트)</td><td><code>doAnswer(inv -&gt; ...).when(m).save(x)</code></td><td>인자를 꺼내 쓸 수 있어요</td></tr><tr><td>spy의 일부 메서드만 바꾸기</td><td><code>doReturn(v).when(spy).f(x)</code></td><td><code>when(spy.f(x))</code>는 진짜 <code>f(x)</code>를 먼저 실행해요</td></tr><tr><td>앞에서 <code>thenThrow</code>를 건 메서드를 다른 값으로</td><td><code>doReturn(v).when(m).f(x)</code></td><td><code>when(m.f(x))</code> 재호출이 앞의 예외를 터뜨려요</td></tr><tr><td>연속 호출마다 다른 값</td><td><code>thenReturn(a, b, c)</code></td><td>마지막 값이 이후 호출에 계속 쓰여요</td></tr><tr><td>인자 값에 따라 결과가 규칙적으로 바뀜</td><td><code>thenAnswer</code> 1개</td><td>스텁을 인자마다 따로 거는 것보다 빠르고 짧아요</td></tr><tr><td>목 대신 진짜 동작 일부가 필요</td><td>작은 가짜 구현 클래스</td><td>spy·부분 목은 공식 문서도 레거시 코드용으로 권해요</td></tr></tbody></table></div>
<p>spy와 "예외 스텁 덮어쓰기" 두 줄은 Mockito 5.23.0에서 직접 재현해 확인했어요. 빈 <code>ArrayList</code>의 spy에 <code>when(spy.get(0)).thenReturn("x")</code>를 걸면 진짜 <code>get(0)</code>이 먼저 실행돼 <code>IndexOutOfBoundsException: Index 0 out of bounds for length 0</code>이 나요. 같은 스텁을 <code>doReturn("x").when(spy).get(0)</code>으로 바꾸면 예외 없이 <code>"x"</code>가 나오고 <code>size()</code>는 여전히 0이에요. 또 <code>when(r.find(1)).thenThrow(...)</code> 뒤에 <code>when(r.find(1)).thenReturn("x")</code>를 쓰면 두 번째 줄의 <code>r.find(1)</code>이 첫 번째 스텁을 실행해 예외가 그 자리에서 터져요. <code>doReturn</code>으로 덮어쓰면 문제없어요.</p>
<p>spy에 대해서는 하나 더 알아 둘 게 있어요. 공식 문서 13절대로 <strong>spy는 원래 객체를 감싸지 않고 복사본을 만들어요.</strong> 카운터 객체를 spy로 만든 뒤 spy에서 두 번, 원래 객체에서 한 번 증가시키면 원래 객체는 1, spy는 2로 따로 놀아요. 원래 객체를 다른 곳에 넘겨 놓고 spy만 검증하면 "분명히 불렀는데 verify가 실패한다"는 상황이 생겨요. 테스트 대상에 넘기는 객체가 spy 자신인지 꼭 확인하세요.</p>
<h2 id="s3">인자 매처 쓰는 규칙 — any·eq·anyInt 판단표</h2>
<p>인자 매처(<code>any()</code>, <code>eq()</code>, <code>anyInt()</code> 등)는 <a href="https://javadoc.io/static/org.mockito/mockito-core/5.23.0/org.mockito/org/mockito/ArgumentMatchers.html" target="_blank" rel="noopener">ArgumentMatchers Javadoc</a>에 목록이 있어요. 실무에서 걸리는 규칙은 세 가지예요.</p>
<p><strong>1. 한 호출 안에서 매처를 하나라도 쓰면 전부 매처로 써요.</strong> <code>when(r.find2(anyInt(), "kim"))</code>은 실행할 때 <code>InvalidUseOfMatchersException</code>이 나고 메시지에 <code>2 matchers expected, 1 recorded</code>가 찍혀요. 날값 <code>"kim"</code>을 <code>eq("kim")</code>으로 감싸면 해결돼요. 공식 문서 3절대로 매처는 내부 스택에 기록만 하고 가짜 값을 돌려주므로, 괄호 밖에서 변수에 미리 담아 두는 것도 안 돼요.</p>
<p><strong>2. 기본형 인자에는 기본형 전용 매처를 써요.</strong> <code>int</code>를 받는 메서드에 <code>when(r.find(any()))</code>를 쓰면 <code>any()</code>가 돌려준 null을 <code>int</code>로 풀다가 <code>NullPointerException</code>이 나요. 직접 돌려 본 메시지는 <code>Cannot invoke "java.lang.Integer.intValue()" because the return value of "org.mockito.ArgumentMatchers.any()" is null</code>이에요. <code>anyInt()</code>·<code>anyLong()</code>·<code>anyBoolean()</code>은 0이나 false를 돌려줘서 이 문제가 없어요.</p>
<p><strong>3. 넓은 스텁을 먼저, 좁은 스텁을 나중에 걸어요.</strong> 같은 메서드에 스텁이 여럿이면 <strong>나중에 건 것부터</strong> 맞춰 봐요. 직접 확인한 결과는 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>스텁 거는 순서</th><th><code>find(1)</code> 결과</th><th><code>find(2)</code> 결과</th></tr></thead><tbody><tr><td><code>anyInt()</code> → <code>"any"</code>, 그다음 <code>1</code> → <code>"one"</code></td><td>one</td><td>any</td></tr><tr><td><code>1</code> → <code>"one"</code>, 그다음 <code>anyInt()</code> → <code>"any"</code></td><td>any</td><td>any</td></tr></tbody></table></div>
<p>두 번째 순서는 특정 값 스텁이 예외 없이 조용히 가려져요. <code>@BeforeEach</code>에서 넓은 스텁을, 각 테스트에서 좁은 스텁을 거는 구조가 안전해요.</p>
<div class="table-wrap"><table><thead><tr><th>인자 상황</th><th>쓸 매처</th><th>피할 것</th></tr></thead><tbody><tr><td>정확한 값, equals()가 잘 정의됨</td><td>날값 그대로 (매처 없이)</td><td>불필요한 <code>eq()</code> 남발</td></tr><tr><td>값은 상관없고 타입만</td><td><code>any(Foo.class)</code>, <code>anyString()</code></td><td>기본형 자리의 <code>any()</code></td></tr><tr><td>객체의 필드 하나만 확인</td><td><code>argThat(u -&gt; u.id() == 1)</code></td><td>복잡한 람다 여러 단계</td></tr><tr><td>넘긴 객체를 꺼내 자세히 검사</td><td><code>ArgumentCaptor</code> (verify 쪽)</td><td>스텁에 캡처 사용</td></tr><tr><td>여러 인자 중 하나만 고정</td><td>나머지도 <code>any..()</code> + 고정값은 <code>eq()</code></td><td>날값과 매처 섞기</td></tr></tbody></table></div>
<h2 id="s4">strict 스텁 설정 고르기 — STRICT_STUBS·WARN·lenient</h2>
<p>Mockito는 원래 느슨한(loose) 프레임워크예요. <code>Mockito.mock()</code>으로 그냥 만든 목은 쓰지 않은 스텁이 있어도, 다른 인자로 불려도 아무 말을 하지 않아요. 반면 JUnit 5의 <code>MockitoExtension</code>은 <a href="https://javadoc.io/static/org.mockito/mockito-junit-jupiter/5.23.0/org.mockito.junit.jupiter/org/mockito/junit/jupiter/MockitoSettings.html" target="_blank" rel="noopener">MockitoSettings Javadoc</a>에 적힌 대로 기본 엄격도가 <code>STRICT_STUBS</code>예요. 같은 테스트 코드가 확장을 붙이는 순간 실패하기 시작하는 이유가 이거예요.</p>
<p><a href="https://javadoc.io/static/org.mockito/mockito-core/5.23.0/org.mockito/org/mockito/quality/Strictness.html" target="_blank" rel="noopener">Strictness Javadoc</a>에 정리된 세 단계를 실무 기준으로 옮기면 다음과 같아요.</p>
<div class="table-wrap"><table><thead><tr><th>단계</th><th>쓰지 않은 스텁</th><th>다른 인자로 호출</th><th>언제 고르나</th></tr></thead><tbody><tr><td><code>STRICT_STUBS</code></td><td>테스트 실패 (<code>UnnecessaryStubbingException</code>)</td><td>즉시 실패 (<code>PotentialStubbingProblem</code>)</td><td>새로 쓰는 테스트, 기본값으로 권장</td></tr><tr><td><code>WARN</code></td><td>콘솔 경고만</td><td>콘솔 경고만</td><td>공식 문서도 "콘솔은 대개 안 본다"며 비권장</td></tr><tr><td><code>LENIENT</code></td><td>아무 일 없음</td><td>기본값 반환</td><td>옮겨 오는 중인 오래된 테스트, 일부 목만</td></tr></tbody></table></div>
<p><code>PotentialStubbingProblem</code>에는 잘 알려지지 않은 조건이 하나 있어요. Mockito 소스의 <a href="https://github.com/mockito/mockito/blob/main/mockito-core/src/main/java/org/mockito/internal/junit/DefaultStubbingLookupListener.java" target="_blank" rel="noopener">DefaultStubbingLookupListener</a>는 <strong>스텁을 건 위치와 호출 위치가 같은 소스 파일이면 인자 불일치로 보지 않아요.</strong> 테스트 코드가 직접 다른 인자로 부른 건 의도로 간주한다는 뜻이에요. 실제로 테스트 파일 안에서 <code>find(1)</code>을 스텁하고 같은 파일에서 <code>find(2)</code>를 부르면 null이 나오고 끝에 <code>UnnecessaryStubbingException</code>만 나요. 반면 다른 파일의 서비스 클래스가 <code>find(2)</code>를 부르면 그 자리에서 <code>Strict stubbing argument mismatch</code> 메시지와 함께 <code>PotentialStubbingProblem</code>이 나요. 즉 이 예외는 "운영 코드가 테스트가 예상한 인자와 다르게 부른다"는 뜻이라, 인자를 만드는 운영 코드나 <code>equals()</code> 구현부터 의심하는 게 순서예요.</p>
<p>엄격도를 풀 때는 범위를 가장 좁게 잡아요.</p>
<ol><li><strong>스텁 하나만</strong>: <code>lenient().when(m.f(x)).thenReturn(v)</code> — <code>@BeforeEach</code> 공통 스텁 중 일부 테스트만 안 쓰는 경우.</li><li><strong>목 하나만</strong>: <code>@Mock(strictness = Strictness.LENIENT)</code> 또는 <code>withSettings().strictness(...)</code> — 공식 문서 52절, 4.6.0부터.</li><li><strong>테스트 클래스 전체</strong>: <code>@MockitoSettings(strictness = Strictness.LENIENT)</code> — 이건 문제를 숨기는 경우가 많아 마이그레이션 기간에만 써요.</li></ol>
<h2 id="s5">스텁 테스트가 깨질 때 확인 순서</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/developer/mockito-stubbing-doreturn-spy-matcher-strictness-guide-2.svg" alt="InvalidUseOfMatchers, NullPointerException, PotentialStubbingProblem, UnnecessaryStubbing, WrongTypeOfReturnValue, UnfinishedStubbing 여섯 가지 예외별로 먼저 확인할 것과 고치는 법을 정리한 표" width="800" height="600" loading="lazy" decoding="async"><figcaption>예외 이름으로 줄을 찾아요. 여섯 가지 모두 Mockito 5.23.0에서 직접 재현한 메시지예요</figcaption></figure>
<p>그림에 다 담지 못한 판단 포인트만 덧붙이면 이래요.</p>
<ol><li><strong><code>InvalidUseOfMatchersException</code></strong> — 메시지의 "N matchers expected, M recorded"에서 M이 작으면 날값이 섞인 거고, M이 크면 앞 줄에서 괄호 밖에 쓴 매처가 남은 거예요.</li><li><strong>스텁 줄의 <code>NullPointerException</code></strong> — 스택이 <code>ArgumentMatchers.any()</code>가 아니라면 <code>when(a.b().c())</code>처럼 중간 목이 null을 돌려준 경우가 많아요.</li><li><strong><code>PotentialStubbingProblem</code></strong> — 메시지의 "this invocation"과 "stubbing(s) with different arguments" 두 줄의 인자를 비교하고, 값 객체면 <code>equals()</code> 구현을 보세요.</li><li><strong><code>UnnecessaryStubbingException</code></strong> — 공통 스텁을 쓰는 테스트로 옮기는 게 먼저고, <code>lenient()</code>는 그다음이에요.</li><li><strong><code>UnfinishedStubbingException</code></strong> — <code>thenReturn(helper())</code> 안에서 다른 목을 부르면 스텁이 중간에 끊겨요. 값을 변수로 먼저 만드세요.</li></ol>
<p>예외는 나지 않는데 값이 이상하다면 순서가 달라져요. 넓은 스텁이 좁은 스텁을 가리지 않았는지(앞 절의 표), 테스트 대상에 넘긴 객체가 spy인지 원본인지, <code>reset()</code>을 테스트 중간에 부르지 않았는지 보세요. 공식 문서 17절은 테스트 중간의 <code>reset()</code>을 "너무 많이 테스트하고 있다는 신호"라고 적어요.</p>
<p>null 때문에 원인을 찾기 어렵다면 <code>mock(Foo.class, RETURNS_SMART_NULLS)</code>를 임시로 써 볼 만해요. 스텁하지 않은 메서드가 문자열이면 빈 문자열을, 객체면 "어디서 스텁 안 된 호출이 있었는지"를 알려 주는 대리 객체를 돌려줘요. 원인을 찾은 뒤에는 기본값으로 되돌리세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">숫자로 보는 비용 — 스텁 개수와 목 호출 횟수</h2>
<p>목은 편하지만 공짜가 아니에요. Apple M1, OpenJDK 24.0.2, Mockito 5.23.0, Byte Buddy 1.18.15에서 <code>-javaagent</code>를 넣고 직접 재 봤어요. 절대값은 기계마다 다르니 자릿수와 비율을 보세요.</p>
<p>먼저 <strong>호출 한 번의 비용</strong>이에요. 인자 하나를 받는 인터페이스를 람다로 만든 가짜 구현은 호출당 약 6ns였고, 같은 메서드에 스텁 하나를 건 목은 약 12.9µs였어요(20만 번 호출, 7회 중앙값). 대략 2,000배예요. 호출 기록을 끈 <code>stubOnly()</code> 목도 약 11.4µs로 거의 같았어요. 비용 대부분이 기록보다 호출을 가로채고 스텁을 찾는 과정에서 나온다는 뜻이에요.</p>
<p>다음은 <strong>같은 메서드에 스텁을 몇 개 걸었느냐</strong>에 따른 호출 비용이에요. 인자마다 <code>when(r.find(i)).thenReturn(...)</code>을 걸고, 가장 먼저 건 스텁(나중 것부터 훑으니 가장 늦게 찾아지는 스텁)을 반복 호출했어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">같은 메서드의 스텁 개수별 목 호출 1번 시간 (3회 중앙값)</figcaption><svg viewBox="0 0 640 152" role="img" aria-label="같은 메서드의 스텁 개수별 목 호출 1번 시간 (3회 중앙값)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="104" y="29" text-anchor="end">스텁 1개</text><rect class="s1" x="114" y="15" width="23.7" height="20" rx="3"><title>스텁 1개 · 13µs</title></rect><text class="viz-val" x="143.7" y="29">13µs</text><text class="viz-ax lg" x="104" y="63" text-anchor="end">스텁 10개</text><rect class="s1" x="114" y="49" width="30.1" height="20" rx="3"><title>스텁 10개 · 16.5µs</title></rect><text class="viz-val" x="150.1" y="63">16.5µs</text><text class="viz-ax lg" x="104" y="97" text-anchor="end">스텁 100개</text><rect class="s1" x="114" y="83" width="62.0" height="20" rx="3"><title>스텁 100개 · 34µs</title></rect><text class="viz-val" x="182.0" y="97">34µs</text><text class="viz-ax lg" x="104" y="131" text-anchor="end">스텁 1000개</text><rect class="s1" x="114" y="117" width="419.5" height="20" rx="3"><title>스텁 1000개 · 230µs</title></rect><text class="viz-val" x="539.5" y="131">230µs</text></svg><p class="viz-note">스텁을 나중 것부터 하나씩 맞춰 보기 때문에 스텁이 많을수록 호출이 길어져요.</p><div class="viz-cap">단위: µs · 자료: Apple M1 · OpenJDK 24.0.2 · Mockito 5.23.0으로 직접 측정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>호출 1번</th></tr></thead><tbody><tr><td>스텁 1개</td><td>13</td></tr><tr><td>스텁 10개</td><td>16.5</td></tr><tr><td>스텁 100개</td><td>34</td></tr><tr><td>스텁 1000개</td><td>230</td></tr></tbody></table></div></details></figure>
<p>스텁 1,000개를 걸고 1,000개 인자를 한 번씩 부르는 테스트를 통째로 재면 차이가 더 분명해요. 1,000개 스텁은 거는 데만 약 183ms가 들었어요. 스텁을 하나 걸 때마다 <code>when()</code> 괄호 안의 호출이 이미 걸린 스텁을 훑기 때문이에요. 같은 결과를 <code>when(r.find(anyInt())).thenAnswer(inv -&gt; "v" + inv.getArgument(0))</code> 하나로 만들면 준비와 호출을 합쳐 약 29ms였어요.</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">인자 1000종류를 다루는 방법별 시간 (5회 중앙값)</figcaption><svg viewBox="0 0 640 118" role="img" aria-label="인자 1000종류를 다루는 방법별 시간 (5회 중앙값)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">개별 스텁 1000개 걸기</text><rect class="s1" x="220" y="15" width="320.2" height="20" rx="3"><title>개별 스텁 1000개 걸기 · 183ms</title></rect><text class="viz-val" x="546.2" y="29">183ms</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">개별 스텁으로 1000번 호출</text><rect class="s1" x="220" y="49" width="273.0" height="20" rx="3"><title>개별 스텁으로 1000번 호출 · 156ms</title></rect><text class="viz-val" x="499.0" y="63">156ms</text><text class="viz-ax lg" x="210" y="97" text-anchor="end">thenAnswer 1개로 준비+1000번 호출</text><rect class="s1" x="220" y="83" width="50.8" height="20" rx="3"><title>thenAnswer 1개로 준비+1000번 호출 · 29ms</title></rect><text class="viz-val" x="276.8" y="97">29ms</text></svg><p class="viz-note">개별 스텁은 걸기와 호출을 합쳐 약 339ms예요. 규칙이 있는 값은 thenAnswer 하나가 10배 이상 빨라요.</p><div class="viz-cap">단위: ms · 자료: Apple M1 · OpenJDK 24.0.2 · Mockito 5.23.0으로 직접 측정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>걸린 시간</th></tr></thead><tbody><tr><td>개별 스텁 1000개 걸기</td><td>183</td></tr><tr><td>개별 스텁으로 1000번 호출</td><td>156</td></tr><tr><td>thenAnswer 1개로 준비+1000번 호출</td><td>29</td></tr></tbody></table></div></details></figure>
<p>이 숫자로 판단 기준을 계산해 보면 이래요.</p>
<ul><li><strong>반복문 안의 목 호출</strong>: 배치 로직 테스트가 목 저장소를 10만 번 부르면 12.9µs × 100,000 = 약 1.3초예요. 같은 일을 가짜 구현으로 하면 1ms도 안 걸려요. 테스트 하나가 1초 넘게 걸리고 목 호출 횟수가 수만 번 이상이면 가짜 구현으로 바꿀 후보예요.</li><li><strong>스텁이 많은 테스트</strong>: 테스트 하나에 스텁 10개 정도는 호출 비용이 13µs에서 16.5µs로 늘 뿐이라 신경 쓸 필요가 없어요. 같은 메서드에 스텁이 100개를 넘으면 그 메서드는 사실상 "규칙이 있는 함수"이니 <code>thenAnswer</code>나 가짜 구현으로 표현하는 게 읽기에도 나아요.</li><li><strong>deep stub</strong>: <code>RETURNS_DEEP_STUBS</code>로 <code>a.b().c()</code> 세 단계를 한 줄에 스텁하는 것과 목 세 개를 손으로 엮는 것은 각각 약 98µs로 비슷했어요. 속도로는 고를 이유가 없고, 공식 문서 32절이 "목이 목을 돌려주는 건 대부분 잘못"이라고 적은 설계 문제를 먼저 보세요.</li></ul>
<h2 id="s7">도입 전에 따질 것 — 목 대신 진짜·가짜 객체가 나은 경우</h2>
<p>스텁 문법을 고르기 전에 "이걸 목으로 만들어야 하나"를 먼저 따지면 위의 문제 상당수가 처음부터 생기지 않아요.</p>
<ul><li><strong>값 객체·컬렉션·record</strong>: 진짜 인스턴스를 쓰세요. 목으로 만들면 <code>equals()</code>가 기본값으로 바뀌어 매처 문제와 <code>PotentialStubbingProblem</code>이 함께 따라와요.</li><li><strong>메서드가 적고 규칙이 단순한 저장소</strong>: <code>Map</code>을 감싼 가짜 구현이 목보다 빠르고, 저장한 값을 다시 읽는 흐름까지 진짜처럼 확인돼요.</li><li><strong>외부 경계(HTTP 클라이언트, 메시지 발행, 시계)</strong>: 목이 잘 맞는 자리예요. 호출 여부와 인자를 <code>verify</code>나 <code>ArgumentCaptor</code>로 확인하는 게 테스트의 목적이기 때문이에요.</li><li><strong>spy가 필요해 보이는 클래스</strong>: 한 클래스의 메서드 일부만 바꾸고 싶다면, 바꾸고 싶은 부분을 별도 협력 객체로 분리할 신호예요. 공식 문서 16절도 부분 목을 레거시 코드와 외부 인터페이스용으로 한정해요.</li><li><strong>DB 쿼리 자체가 검증 대상</strong>: 저장소를 목으로 만들면 쿼리는 한 번도 실행되지 않아요. 이 경우는 실제 DB를 띄우는 통합 테스트가 맞고, 수명 설정과 CI 비용은 <a href="https://tool.smithlab.co.kr/posts/developer/testcontainers-lifecycle-ci-troubleshooting-cost-guide/">Testcontainers 수명 고르는 법과 CI 시간 계산</a>에 정리했어요.</li></ul>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 묻는 질문</h2>
<h3>doReturn을 항상 쓰면 더 안전하지 않나요?</h3>
<p>공식 문서 12절도 "모든 스텁에 doReturn을 써도 된다"고 적어요. 다만 <code>doReturn</code>은 반환 타입을 컴파일 때 검사하지 않아서 타입 실수가 실행할 때 <code>WrongTypeOfReturnValue</code>로 드러나요. 일반 목은 <code>when().thenReturn()</code>, spy·void·덮어쓰기에만 <code>doReturn</code> 계열로 나누는 쪽이 실수가 적어요.</p>
<h3>UnnecessaryStubbingException을 끄려면 lenient를 클래스 전체에 걸면 되나요?</h3>
<p>끌 수는 있지만 권하지 않아요. 클래스 전체를 느슨하게 하면 잘못된 인자로 불리는 진짜 버그도 함께 숨어요. 공통 스텁 중 일부 테스트만 쓰는 스텁이 원인이면 그 스텁을 해당 테스트로 옮기고, 꼭 필요하면 <code>lenient().when(...)</code>으로 그 한 줄만 풀어요.</p>
<h3>spy에 when을 썼더니 진짜 메서드가 실행돼요. 버그인가요?</h3>
<p>버그가 아니라 자바 문법 순서 때문이에요. <code>when(spy.get(0))</code>은 괄호 안의 <code>spy.get(0)</code>을 먼저 실행하고, spy는 스텁 전이라 진짜 코드를 돌려요. <code>doReturn(v).when(spy).get(0)</code>으로 바꾸면 진짜 메서드에 닿지 않아요.</p>
<h3>PotentialStubbingProblem은 왜 어떤 테스트에서는 안 나나요?</h3>
<p>스텁을 건 곳과 호출한 곳이 같은 소스 파일이면 Mockito가 테스트의 의도로 보고 불일치로 처리하지 않아요. 운영 코드(다른 파일)가 다른 인자로 부를 때만 나요. 그래서 테스트 안에서 직접 목을 불러 확인할 때는 이 예외 대신 끝에 <code>UnnecessaryStubbingException</code>이 날 수 있어요.</p>
<h3>목 호출이 느리다는데 테스트에서 신경 써야 하나요?</h3>
<p>대부분은 아니에요. 측정한 호출 한 번이 약 13µs라 테스트 하나에서 수백 번 불러도 수 밀리초예요. 반복문으로 수만~수십만 번 부르는 배치·집계 로직 테스트, 같은 메서드에 스텁을 수백 개 거는 테스트만 가짜 구현이나 <code>thenAnswer</code>로 바꾸면 돼요.</p>
<h2 id="s9">정리</h2>
<ul><li>스텁은 <code>when().thenReturn()</code>을 기본으로 쓰고, void 메서드는 <code>doThrow</code>·<code>doNothing</code>·<code>doAnswer</code>, spy와 예외 스텁 덮어쓰기는 <code>doReturn</code>으로 바꿔요.</li><li>매처는 전부 아니면 전무, 기본형에는 <code>anyInt()</code> 같은 전용 매처, 같은 메서드의 스텁은 넓은 것을 먼저 걸어요.</li><li><code>MockitoExtension</code>의 기본값은 <code>STRICT_STUBS</code>예요. <code>PotentialStubbingProblem</code>은 운영 코드가 다른 인자로 부른다는 신호이고, 엄격도는 스텁 한 줄 → 목 하나 → 클래스 순으로 좁게 풀어요.</li><li>목 호출 한 번은 약 13µs로 가짜 구현보다 수천 배 느려요. 수만 번 넘게 부르거나 스텁이 수백 개면 <code>thenAnswer</code>나 가짜 구현이 빠르고 읽기도 쉬워요.</li><li>목 메이커와 <code>-javaagent</code> 설정은 <a href="https://tool.smithlab.co.kr/posts/developer/mockito-mock-maker-setup-guide/">Mockito 목 메이커 선택표</a>, DB까지 확인해야 하는 테스트는 <a href="https://tool.smithlab.co.kr/posts/developer/testcontainers-lifecycle-ci-troubleshooting-cost-guide/">Testcontainers 수명 고르는 법</a>을 이어서 보세요.</li></ul>
<h2 id="s10">더 깊이 읽기</h2>
<p>이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 <a href="https://devboi.tistory.com/entry/mockito-mock-bytecode-proxy-mockmaker" target="_blank" rel="noopener">Mockito 목 객체 생성 원리 — 바이트코드 프록시와 인라인 MockMaker</a>에서 도식과 코드로 단계별로 풀었어요.</p>
    ]]></description>
    </item>
    <item>
      <title>한국 생활 문화 가이드 — 외국인이 처음 헷갈리는 쓰레기·교통·식당·존댓…</title>
      <link>https://tool.smithlab.co.kr/posts/life/korea-life-culture-guide-for-foreigners/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/korea-life-culture-guide-for-foreigners/</guid>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>한국에 막 도착하면 생각보다 작은 일에서 자주 멈칫해요. 쓰레기를 아무 봉투에 담아 내놓았는데 그대로 남아 있거나, 버스에서 내릴 때 카드를 안 찍었더니 다음 버스 요금이 따로 나가거나, 식당에서 직원을 부르려고 손을 들었는데 테이블 위에 벨이 있었던 식이에요. 결론부터 말하면 <strong>한국 생활에서 처음 한 달에 꼭 익힐 것은 종량제 봉투(volume-based garbage bag)와 음식물 쓰레기 분리, 교통카드 하차 태그와 환승 시간, 입국 후 90일 안의 외국인등록(alien registration), 이사 후 15일 안의 체류지 변경 신고 네 가지예요.</strong> 나머지 식당 예절이나 존댓말은 틀려도 크게 문제 되지 않고, 알면 훨씬 편해지는 정도예요.</p>
<p>이 글은 외국인 친구에게 설명하듯 한국의 생활 문화를 항목별로 정리했어요. 항목마다 무엇인지, 왜 그런지, 실제로 어떻게 하는지, 흔한 실수를 순서대로 적었고 핵심 한국어 단어에는 영어 표기를 붙였어요. 기한과 규칙은 정부·지자체 공식 안내로 확인한 것만 숫자로 적고, 지역마다 다른 부분은 "사는 곳 기준을 확인"하라고 따로 표시했어요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>쓰레기는 일반 쓰레기는 동네 종량제 봉투, 음식물은 따로, 재활용은 비우고 헹궈서 종류별로 내놓아요.</li><li>수도권 버스·지하철은 내릴 때도 카드를 찍어야 하고, 내린 뒤 30분(21시~다음 날 7시는 60분) 안에 갈아타면 환승 할인이 돼요.</li><li>90일 넘게 머물 외국인은 입국일부터 90일 안에 외국인등록을, 등록 뒤 이사하면 15일 안에 체류지 변경 신고를 해요.</li><li>식당의 벨·셀프 물·반찬 리필, 신발 벗기, 존댓말과 나이 묻기는 규칙이라기보다 생활 습관이라 천천히 익혀도 돼요.</li></ul></aside>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/056-korea-life-culture-guide-for-foreigners-1.svg" alt="한국 생활 첫 달에 교통카드, 쓰레기 규칙, 외국인등록, 은행과 본인인증 순서로 정리하는 흐름도" width="800" height="450" loading="lazy" decoding="async"><figcaption>도착 직후 바로 필요한 교통카드부터, 등록증이 있어야 쉬워지는 은행 업무까지 순서대로 정리했어요</figcaption></figure>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">한국 쓰레기 분리배출: 종량제 봉투와 음식물 쓰레기</h2>
<p><strong>무엇인가요.</strong> 한국에서는 일반 쓰레기를 아무 봉투에나 담아 버릴 수 없어요. 구청·시청이 정한 <strong>종량제 봉투(jongnyangje bongtu, volume-based garbage bag)</strong> 를 사서 그 안에 담아야 수거돼요. 봉투 값에 처리 비용이 들어 있어서, 쓰레기를 많이 버릴수록 돈을 더 내는 구조예요. <strong>음식물 쓰레기(food waste)</strong> 는 일반 쓰레기 봉투에 넣으면 안 되고, 아파트는 단지의 음식물 수거함에, 주택은 음식물 전용 봉투나 용기에 따로 내놓아요. <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148799406" target="_blank" rel="noopener">정책브리핑의 쓰레기 종량제 소개 기사</a>에도 음식물 쓰레기를 일반 종량제 봉투에 버리는 것이 금지돼 있고, 단독주택은 음식물 종량제 봉투를 쓴다고 정리돼 있어요.</p>
<p><strong>왜 그런가요.</strong> 버리는 양에 따라 비용을 내게 해서 쓰레기 자체를 줄이고, 음식물과 재활용품을 따로 모아 다시 쓰기 위해서예요. 그래서 봉투 없이 내놓은 쓰레기나 섞여 있는 쓰레기는 수거하지 않고 남겨 두는 경우가 많아요.</p>
<p><strong>실제로 어떻게 하나요.</strong></p>
<ul><li>봉투는 동네 편의점·마트에서 "종량제 봉투 주세요(Garbage bags, please)"라고 하면 살 수 있어요. 봉투는 <strong>구·시마다 디자인이 달라서 이사한 동네 것</strong>을 써야 해요.</li><li>재활용(recycling)은 <strong>비우고, 헹구고, 분리하고, 섞지 않는다</strong>가 기본이에요. 플라스틱·캔·유리병·종이·비닐을 종류별로 나눠요. <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148877214" target="_blank" rel="noopener">정책브리핑의 배달 용기 분리배출 요령</a>도 같은 네 원칙을 강조해요.</li><li>국물·기름이 지워지지 않는 배달 용기, 컵라면 용기, 영수증, 코팅된 종이는 재활용이 안 돼 종량제 봉투로 가요. <a href="https://www.korea.kr/multi/visualNewsView.do?newsId=148878300" target="_blank" rel="noopener">정책브리핑의 '종량제 봉투로 가야 할 쓰레기' 카드뉴스</a>에 이런 품목이 정리돼 있어요.</li><li>닭 뼈·돼지 뼈 같은 큰 뼈, 조개껍데기처럼 딱딱한 것은 음식물이 아니라 일반 쓰레기로 분류하는 곳이 많아요. 세부 기준은 지자체마다 조금씩 달라서 사는 구청 안내를 한 번 확인하세요.</li><li>배출 요일과 시간은 동네마다 정해져 있어요. 원룸·빌라는 건물 앞에 안내문이 붙어 있는 경우가 많고, 아파트는 분리수거 요일이 따로 있어요.</li></ul>
<p><strong>흔한 실수.</strong> 마트 비닐봉지에 쓰레기를 담아 내놓기, 음식물을 일반 봉투에 섞기, 다른 구에서 산 봉투 쓰기, 재활용 페트병에 음료가 남아 있는 채로 내놓기가 가장 흔해요. 가구·가전 같은 큰 물건은 <strong>대형 폐기물(large waste)</strong> 이라 구청에 신고하고 스티커를 붙여서 내놓아야 해요.</p>
<h2 id="s2">한국 대중교통: 교통카드, 하차 태그, 환승 30분 규칙</h2>
<p><strong>무엇인가요.</strong> 수도권(서울·경기·인천)의 버스와 지하철은 <strong>교통카드(transit card)</strong> 로 타는 게 기본이에요. 편의점에서 티머니 같은 선불 교통카드를 사서 현금으로 충전하거나, 후불 기능이 있는 카드를 써요. 한 번 탈 때 카드를 단말기에 대는 걸 "태그(tag)"라고 불러요.</p>
<p><strong>왜 그런가요.</strong> 수도권은 버스와 지하철 요금을 하나로 묶은 <strong>통합환승할인(integrated transfer discount)</strong> 을 써요. 여러 번 갈아타도 탄 거리를 합쳐서 요금을 계산하는데, 그러려면 언제 어디서 내렸는지 기록이 필요해요. 그래서 <strong>내릴 때도 카드를 찍는 것</strong>이 중요해요.</p>
<p><strong>실제 규칙(서울시 교통 안내 기준, 2026-11-02 확인).</strong></p>
<ul><li>내린 뒤 <strong>30분 이내</strong>에 다음 수단을 타면 환승으로 인정돼요. <strong>21시부터 다음 날 7시까지는 60분 이내</strong>예요. 이 시간대는 다음 수단에 타는 시각으로 판단해요.</li><li>환승은 <strong>최대 4회, 즉 5번 탑승</strong>까지 할인돼요.</li><li>버스에서 내릴 때 카드를 찍지 않으면 환승이 인정되지 않아 다음 승차 때 기본요금이 다시 나가요.</li><li>같은 노선 버스끼리 갈아타거나, 지하철 개찰구를 나왔다가 다시 지하철을 타는 경우는 환승 할인이 안 돼요.</li></ul>
<figure class="viz viz-hbar"><figcaption class="viz-title">수도권 통합환승 인정 시간 (내린 뒤 다음 탑승까지)</figcaption><svg viewBox="0 0 640 84" role="img" aria-label="수도권 통합환승 인정 시간 (내린 뒤 다음 탑승까지)" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="143" y="29" text-anchor="end">07~21시</text><rect class="s1" x="153" y="15" width="208.5" height="20" rx="3"><title>07~21시 · 30분</title></rect><text class="viz-val" x="367.5" y="29">30분</text><text class="viz-ax lg" x="143" y="63" text-anchor="end">21~다음 날 07시</text><rect class="s1" x="153" y="49" width="417.0" height="20" rx="3"><title>21~다음 날 07시 · 60분</title></rect><text class="viz-val" x="576.0" y="63">60분</text></svg><p class="viz-note">시간대는 다음 수단에 타는 시각 기준이고, 환승은 최대 4회(5번 탑승)까지예요.</p><div class="viz-cap">단위: 분 · 자료: 서울시 교통 누리집 통합환승할인 안내 (2026-11-02 확인)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>환승 인정 시간</th></tr></thead><tbody><tr><td>07~21시</td><td>30</td></tr><tr><td>21~다음 날 07시</td><td>60</td></tr></tbody></table></div></details></figure>
<p><strong>흔한 실수.</strong> 버스 뒷문으로 내리면서 카드를 안 찍는 것, 카드 두 장을 지갑째 대서 오류가 나는 것, 같은 번호 버스를 다시 타는 것이 흔해요. 부산·대구 등 다른 지역은 환승 규칙이 수도권과 다르니 그 지역 안내를 따로 보세요. 지하철역 이름은 영어·로마자로도 표시돼 있어서 <a href="https://tool.smithlab.co.kr/text/korean-romanization/">한글 로마자 변환기</a>로 역 이름 표기를 미리 확인해 두면 길 찾기 앱 검색이 쉬워요.</p>
<h2 id="s3">외국인등록과 체류지 변경 신고: 90일·15일</h2>
<p><strong>무엇인가요.</strong> 한국에 <strong>90일을 넘게 머물 외국인</strong>은 입국한 날부터 90일 안에 관할 출입국·외국인관서에서 <strong>외국인등록(oegugin deungnok, alien registration)</strong> 을 해야 해요. 등록하면 <strong>외국인등록증(residence card)</strong> 이 나와요. 등록한 뒤 이사를 하면 새 주소로 옮긴 날부터 <strong>15일 안에 체류지 변경 신고(change of address report)</strong> 를 해야 해요. 이 두 기한은 출입국관리법 제31조와 제36조에 근거하고, 서울 구로구·영등포구 등 구청 외국인 민원 안내에도 같은 기한이 적혀 있어요(2026-11-02 확인).</p>
<figure class="viz viz-hbar"><figcaption class="viz-title">외국인이 꼭 지켜야 할 신고 기한</figcaption><svg viewBox="0 0 640 84" role="img" aria-label="외국인이 꼭 지켜야 할 신고 기한" preserveAspectRatio="xMidYMid meet"><text class="viz-ax lg" x="210" y="29" text-anchor="end">외국인등록(입국일부터)</text><rect class="s1" x="220" y="15" width="315.0" height="20" rx="3"><title>외국인등록(입국일부터) · 90일</title></rect><text class="viz-val" x="541.0" y="29">90일</text><text class="viz-ax lg" x="210" y="63" text-anchor="end">체류지 변경 신고(이사한 날부터)</text><rect class="s1" x="220" y="49" width="52.5" height="20" rx="3"><title>체류지 변경 신고(이사한 날부터) · 15일</title></rect><text class="viz-val" x="278.5" y="63">15일</text></svg><p class="viz-note">체류 자격에 따라 예외가 있으니 본인 비자 기준은 출입국·외국인관서에서 확인하세요.</p><div class="viz-cap">단위: 일 · 자료: 출입국관리법 제31조·제36조, 구청 외국인 민원 안내 (2026-11-02 확인)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>기한</th></tr></thead><tbody><tr><td>외국인등록(입국일부터)</td><td>90</td></tr><tr><td>체류지 변경 신고(이사한 날부터)</td><td>15</td></tr></tbody></table></div></details></figure>
<p><strong>왜 그런가요.</strong> 한국은 주민과 장기 체류 외국인의 주소를 공적으로 관리해요. 등록증과 주소가 있어야 휴대폰 개통, 은행 계좌, 건강보험, 각종 우편물이 제대로 이어져요.</p>
<p><strong>실제로 어떻게 하나요.</strong></p>
<ul><li>외국인등록은 관할 출입국·외국인관서 방문 예약을 잡고, 여권·사진·체류 목적에 맞는 서류를 챙겨 가요. 필요한 서류는 비자 종류마다 달라요.</li><li>체류지 변경 신고는 새 주소지의 <strong>주민센터(community service center)</strong>, 출입국·외국인관서, 또는 정부 온라인 민원 사이트 하이코리아(HiKorea) 중 한 곳에서 할 수 있어요. 기한이 지나면 온라인 신고가 막히고 직접 방문해야 한다고 안내하는 구청이 많아요.</li><li>체류 관련 질문은 국번 없이 <strong>1345(외국인종합안내센터, Immigration Contact Center)</strong> 로 여러 언어 상담을 받을 수 있어요.</li></ul>
<p><strong>흔한 실수.</strong> 이사하고 신고를 잊는 경우가 가장 흔해요. 기한을 넘기면 과태료 등 제재 대상이 될 수 있고, 이후 체류 연장 심사에도 불리할 수 있어요. 집 계약서와 이사 날짜를 정리해 두면 신고할 때 편해요.</p>
<h2 id="s4">은행 계좌와 본인인증: 왜 휴대폰 명의가 중요할까</h2>
<p><strong>무엇인가요.</strong> 한국에서는 온라인 쇼핑, 배달 앱, 은행 앱, 정부 사이트에 가입할 때 <strong>본인인증(identity verification)</strong> 을 자주 요구해요. 가장 흔한 방법은 <strong>본인 명의 휴대폰(phone in your own name)</strong> 으로 문자 인증을 받는 것이에요.</p>
<p><strong>왜 그런가요.</strong> 한국의 많은 온라인 서비스가 실명 확인을 기본으로 해요. 그래서 휴대폰이 본인 명의가 아니거나, 이름 표기가 여권과 등록증에서 다르면 인증이 자꾸 실패해요.</p>
<p><strong>실제로 어떻게 하나요.</strong> 외국인등록증을 받은 뒤에 휴대폰을 본인 명의로 개통하고, 은행 계좌를 여는 순서가 대체로 편해요. 은행마다 외국인 계좌 개설 서류(재직·재학 증명 등)가 다르니 방문 전에 해당 은행에 확인하세요. 영문 이름은 여권·등록증·휴대폰·은행에서 <strong>띄어쓰기와 순서까지 똑같이</strong> 맞춰 두면 인증 오류가 줄어요.</p>
<p><strong>흔한 실수.</strong> 친구 명의 휴대폰으로 가입했다가 나중에 본인 계정으로 옮기지 못하는 경우, 영문 이름 순서가 서류마다 달라 인증이 실패하는 경우가 많아요.</p>
<h2 id="s5">한국 식당 문화: 벨, 셀프 물, 반찬 리필, 계산</h2>
<p><strong>무엇인가요.</strong> 한국 식당에는 테이블에 <strong>호출 벨(call bell)</strong> 이 있는 곳이 많아요. 물과 컵은 <strong>셀프(self-service)</strong> 로 정수기에서 가져오는 곳이 흔하고, 김치 같은 <strong>반찬(banchan, side dishes)</strong> 은 무료로 더 달라고 할 수 있는 곳이 많아요. 계산은 보통 식사가 끝난 뒤 <strong>입구 카운터(counter)</strong> 에서 해요.</p>
<p><strong>왜 그런가요.</strong> 빠른 회전과 적은 인원으로 운영하는 식당이 많아서, 손님이 필요한 걸 직접 가져가거나 벨로 부르는 방식이 자리 잡았어요. 팁(tip)을 주는 문화는 일반적이지 않아요.</p>
<p><strong>실제로 어떻게 하나요.</strong></p>
<ul><li>벨이 있으면 벨을, 없으면 "저기요(Excuse me)" 또는 "사장님"이라고 부르면 돼요.</li><li>"물은 셀프예요"라는 안내문이 있으면 정수기나 냉장고에서 직접 가져와요.</li><li>반찬 리필은 "반찬 더 주세요(More side dishes, please)". 셀프 반찬 코너가 있는 곳도 있어요. 다만 고기·해산물 반찬은 추가 요금이 붙는 곳도 있으니 메뉴판을 확인해요.</li><li>고기집은 1인분 단위로 주문하고, 최소 2인분부터 받는 곳도 있어요.</li><li>키오스크(kiosk)나 테이블 태블릿으로 주문하는 곳이 늘었어요. 언어 버튼이 있는 경우가 많아요.</li></ul>
<p><strong>흔한 실수.</strong> 직원이 물을 가져다줄 때까지 기다리기, 1인 식사가 안 되는 메뉴를 혼자 주문하기, 계산서를 테이블에서 기다리기가 흔해요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">배달 문화와 택배, 경비실</h2>
<p><strong>무엇인가요.</strong> 한국은 <strong>배달(delivery)</strong> 이 생활 깊숙이 들어와 있어요. 배달 앱으로 늦은 밤에도 음식을 시킬 수 있고, 온라인 주문은 다음 날 도착하는 경우도 많아요. 아파트에는 <strong>경비실(security office)</strong> 이나 <strong>무인 택배함(parcel locker)</strong> 이 있어서 택배를 대신 받아 두기도 해요.</p>
<p><strong>실제로 어떻게 하나요.</strong> 배달 앱 가입에는 보통 본인인증이 필요해서, 앞에서 말한 본인 명의 휴대폰이 있으면 훨씬 편해요. 주소는 <strong>도로명 주소(road-name address)</strong> 로 쓰고, 동·호수를 정확히 적어야 해요. "문 앞에 두고 가 주세요(Leave it at the door)"를 요청 사항에 적으면 비대면으로 받을 수 있어요. 해외 직구 물품은 통관 과정에서 <strong>개인통관고유부호(customs ID number)</strong> 를 요구받을 수 있어요.</p>
<p><strong>흔한 실수.</strong> 동·호수 없이 건물 이름만 적기, 공동현관 비밀번호를 안 적어 배달원이 못 들어오기, 경비실에 맡겨진 택배를 오래 두기가 흔해요. 배달 용기는 앞에서 말한 대로 씻어서 재활용으로 내놓아요.</p>
<h2 id="s7">존댓말과 나이 묻기, 만 나이</h2>
<p><strong>무엇인가요.</strong> 한국어에는 <strong>존댓말(jondaetmal, honorific speech)</strong> 과 <strong>반말(banmal, casual speech)</strong> 이 있어요. 처음 만난 사람, 나이가 많은 사람, 직장 상사에게는 존댓말을 써요. 그래서 처음 만난 자리에서 <strong>나이를 묻는 경우</strong>가 있는데, 사생활을 캐려는 뜻이라기보다 어떤 말투를 쓸지 정하려는 경우가 많아요.</p>
<p><strong>나이 세는 법.</strong> 예전에는 태어나자마자 1살로 치는 "세는 나이"를 일상에서 많이 썼지만, 2023년 6월 28일부터 행정·민사상 나이는 <strong>만 나이(international age)</strong> 로 통일됐어요. <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148916790" target="_blank" rel="noopener">정책브리핑의 만 나이 통일 안내</a>에 따르면 법령·계약서의 나이는 특별한 규정이 없으면 만 나이로 읽어요. 다만 <a href="https://www.korea.kr/news/policyNewsView.do?newsId=148916955" target="_blank" rel="noopener">만 나이 적용 예외 사례 기사</a>처럼 술·담배 구매, 병역, 초등학교 입학 시기 등은 지금도 출생 연도 기준을 써요. 내 만 나이는 <a href="https://tool.smithlab.co.kr/date/age-calculator/">만 나이 계산기</a>로 바로 확인할 수 있어요.</p>
<p><strong>실제로 어떻게 하나요.</strong> 확신이 없으면 존댓말(~요, ~습니다)을 쓰는 게 안전해요. 상대가 "말 편하게 하세요"라고 하면 그때 반말로 바꿔요. 나이를 묻는 질문이 불편하면 "몇 년생이에요"처럼 연도로 답하거나 웃으며 넘겨도 괜찮아요.</p>
<p><strong>흔한 실수.</strong> 연장자에게 이름만 부르기, 직장에서 직함 없이 부르기가 흔해요. 한국 직장에서는 "김 과장님"처럼 성+직함+님으로 부르는 경우가 많아요.</p>
<h2 id="s8">신발 벗기, 빨리빨리, 회식</h2>
<p><strong>신발 벗기(taking off shoes).</strong> 한국 집에 들어갈 때는 현관에서 신발을 벗어요. 바닥 난방(ondol, floor heating)이 있어 바닥에 앉거나 눕는 생활이 많기 때문이에요. 일부 식당·병원·학교 공간도 신발을 벗는 곳이 있으니 입구에 신발장이 있는지 보세요. 신발 사이즈는 한국에서 mm 단위로 표기해서, 쇼핑 전에 <a href="https://tool.smithlab.co.kr/convert/shoe-size-converter/">신발 사이즈 변환기</a>로 맞춰 보면 편해요.</p>
<p><strong>빨리빨리(ppalli-ppalli, "hurry, hurry").</strong> 배달, 택배, 행정 처리, 인터넷 속도까지 "빠른 것"을 중요하게 여기는 생활 습관을 흔히 이렇게 불러요. 엘리베이터 닫힘 버튼을 바로 누르거나, 식당 회전이 빠른 것도 같은 맥락이에요. 무례함이라기보다 일 처리 방식의 차이로 이해하면 마음이 편해요.</p>
<p><strong>회식(hoesik, company dinner).</strong> 직장 동료끼리 퇴근 후 함께 식사하는 자리예요. 예전보다 자유로워졌고, 술을 마시지 않아도 괜찮다고 말하는 곳이 늘었어요. 술을 못 마시면 처음부터 "술은 못 마셔요"라고 정중하게 말하면 돼요. 윗사람이 술을 따라 줄 때 두 손으로 잔을 받는 예절이 있어요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">한눈에 보는 한국 생활 문화 요약표</h2>
<div class="table-wrap"><table><thead><tr><th>항목 (English)</th><th>핵심</th><th>흔한 실수</th></tr></thead><tbody><tr><td>종량제 봉투 (volume-based garbage bag)</td><td>사는 구의 봉투에 일반 쓰레기</td><td>마트 비닐봉지 사용</td></tr><tr><td>음식물 쓰레기 (food waste)</td><td>일반 쓰레기와 분리</td><td>큰 뼈·껍데기를 음식물로 버림</td></tr><tr><td>교통카드 (transit card)</td><td>내릴 때도 태그</td><td>하차 태그 누락</td></tr><tr><td>환승 (transfer)</td><td>내린 뒤 30분, 21~07시 60분, 최대 4회</td><td>같은 노선 재탑승</td></tr><tr><td>외국인등록 (alien registration)</td><td>입국일부터 90일 이내</td><td>예약을 늦게 잡음</td></tr><tr><td>체류지 변경 (address change)</td><td>이사한 날부터 15일 이내</td><td>이사 후 신고 잊음</td></tr><tr><td>본인인증 (ID verification)</td><td>본인 명의 휴대폰</td><td>영문 이름 표기 불일치</td></tr><tr><td>식당 (restaurant)</td><td>벨·셀프 물·카운터 계산</td><td>테이블에서 계산 기다림</td></tr><tr><td>존댓말 (honorifics)</td><td>모르면 존댓말</td><td>연장자에게 반말</td></tr><tr><td>신발 (shoes)</td><td>집 현관에서 벗기</td><td>신발 신고 거실 진입</td></tr></tbody></table></div>
<p>생활에 쓰는 단위도 미리 알아 두면 좋아요. 한국은 미터·킬로그램·섭씨를 쓰고, 집 크기는 아직 <strong>평(pyeong)</strong> 으로 말하는 경우가 많아요. 1평은 약 3.3㎡예요. 단위가 헷갈리면 <a href="https://tool.smithlab.co.kr/convert/unit-converter/">단위 변환기</a>를, 가격표의 "3만 5천원" 같은 표기가 헷갈리면 <a href="https://tool.smithlab.co.kr/text/number-to-korean/">숫자 한글 변환기</a>를 써 보세요.</p>
<h2 id="s10">자주 묻는 질문</h2>
<h3>한국에서 쓰레기 종량제 봉투는 어디서 사나요?</h3>
<p>동네 편의점·마트·슈퍼에서 살 수 있어요. 봉투는 구·시마다 달라서 사는 동네에서 사야 하고, 다른 구로 이사하면 새 동네 봉투를 써야 해요. 크기(리터)별로 여러 종류가 있어요.</p>
<h3>버스에서 내릴 때 카드를 안 찍으면 어떻게 되나요?</h3>
<p>수도권에서는 하차 기록이 없으면 환승이 인정되지 않아, 다음 버스나 지하철을 탈 때 기본요금이 다시 나가요. 환승을 하지 않더라도 거리 계산을 위해 내릴 때 찍는 습관을 들이는 게 좋아요.</p>
<h3>외국인등록은 언제까지 해야 하나요?</h3>
<p>90일을 넘게 머물 외국인은 입국한 날부터 90일 안에 관할 출입국·외국인관서에서 해야 해요. 체류 자격에 따라 서류가 다르니 방문 예약 전에 1345 외국인종합안내센터나 관서 안내로 필요한 서류를 확인하세요.</p>
<h3>이사하면 꼭 신고해야 하나요?</h3>
<p>네. 외국인등록을 한 사람은 새 주소로 옮긴 날부터 15일 안에 체류지 변경 신고를 해야 해요. 새 주소지 주민센터, 출입국·외국인관서, 하이코리아 온라인 중 한 곳에서 할 수 있어요.</p>
<h3>한국 식당에서 반찬을 더 달라고 해도 되나요?</h3>
<p>김치 같은 기본 반찬은 무료로 더 주는 곳이 많아요. "반찬 더 주세요"라고 하면 돼요. 다만 고기·해산물 반찬이나 일부 메뉴는 추가 요금이 붙을 수 있으니 메뉴판이나 안내문을 확인하세요.</p>
<h3>한국 사람들은 왜 처음 만나면 나이를 묻나요?</h3>
<p>존댓말과 반말 중 어떤 말투를 쓸지, 호칭을 어떻게 할지 정하려는 경우가 많아요. 불편하면 대답하지 않아도 되고, 처음에는 서로 존댓말을 쓰는 게 일반적이에요.</p>
<h3>한국 생활 문화를 재미로 점검해 볼 수 있나요?</h3>
<p>한국 생활 상식을 문제로 풀어 보는 <a href="https://tool.smithlab.co.kr/survey/korea-life-test/">Korea Life Test</a>와 사진을 보고 한국 생활 장면을 맞히는 <a href="https://tool.smithlab.co.kr/survey/korean-citizen-test/">대한민국 국민 테스트</a>가 있어요. 이 글을 읽은 뒤 풀어 보면 어떤 부분이 아직 헷갈리는지 알 수 있어요.</p>
<h2 id="s11">정리</h2>
<p>한국 생활에서 가장 먼저 익힐 것은 쓰레기(종량제 봉투·음식물 분리), 교통(하차 태그·환승 30분), 체류 신고(외국인등록 90일·체류지 변경 15일), 본인 명의 휴대폰 네 가지예요. 이 네 가지만 정리돼도 생활의 불편이 크게 줄어요. 식당의 벨과 셀프 물, 신발 벗기, 존댓말, 회식 같은 문화는 틀려도 웃고 넘어가는 경우가 대부분이니 천천히 익히면 돼요.</p>
<p>규칙은 지역과 체류 자격에 따라 달라질 수 있어요. 쓰레기 배출 요일과 품목 기준은 사는 구청 안내를, 체류 관련 질문은 1345를, 여행·통역이 필요할 때는 <a href="https://english.visitkorea.or.kr/" target="_blank" rel="noopener">한국관광공사 VisitKorea</a>의 1330 관광통역안내전화 안내를 참고하세요. 내 한국식 나이는 <a href="https://tool.smithlab.co.kr/date/age-calculator/">만 나이 계산기</a>로, 한국 생활 감각은 <a href="https://tool.smithlab.co.kr/survey/korea-life-test/">Korea Life Test</a>와 <a href="https://tool.smithlab.co.kr/survey/korean-citizen-test/">대한민국 국민 테스트</a>로 가볍게 확인해 보세요.</p>
    ]]></description>
    </item>
    <item>
      <title>눈길·빙판길 제동거리, 속도를 얼마나 줄여야 할까?</title>
      <link>https://tool.smithlab.co.kr/posts/life/snow-ice-braking-distance-speed-guide/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/life/snow-ice-braking-distance-speed-guide/</guid>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>결론부터 말하면 <strong>80km/h로 달리다 위험을 보고 멈출 때 필요한 거리는 마른 노면 58.2m, 젖은 노면 85.2m, 눈길 148.2m, 빙판 274.2m예요.</strong> 같은 속도인데 빙판에서는 마른 노면보다 4.7배 멀리 가요. 그리고 흔히 말하는 '눈길엔 앞차 간격 4초'는 앞에 멈춘 차가 갑자기 드러나는 최악의 경우라면 <strong>약 42km/h까지만</strong> 버텨요. 이 글은 반응시간 1초와 대표 마찰계수를 쓴 단순 모델로 직접 계산한 숫자이고, 법적 기준이나 실제 시험값이 아니에요.</p>
<p>정지거리는 크게 두 조각이에요. 위험을 보고 브레이크를 밟기까지 그냥 달리는 <strong>공주거리</strong>, 브레이크를 밟은 뒤 서기까지 미끄러지는 <strong>제동거리</strong>예요. 눈길에서 늘어나는 건 제동거리 쪽이고, 줄일 수 있는 건 속도와 반응시간이에요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>정지거리 = 공주거리(속도×반응시간) + 제동거리(속도²÷2μg)로 계산해요.</li><li>80km/h 기준 정지거리는 마른 노면 58.2m, 눈길 148.2m, 빙판 274.2m예요.</li><li>제동거리는 속도 제곱에 비례해서 속도를 20% 줄이면 36% 짧아져요.</li><li>눈길에서는 평소 앞차와 간격의 두 배 이상, 빙판에서는 그보다 더 길게 잡아야 해요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">정지거리는 어떻게 계산할까</h2>
<p>공식은 중학교 물리 수준이에요. 속도를 초속(m/s)으로 바꿔서 쓰면 돼요. km/h를 3.6으로 나누면 m/s가 되니까 80km/h는 초속 22.2m예요.</p>
<ul><li>공주거리(m) = 초속 × 반응시간(초)</li><li>제동거리(m) = 초속² ÷ (2 × μ × 9.8)</li><li>정지거리 = 공주거리 + 제동거리</li></ul>
<p>μ는 타이어와 노면 사이의 마찰계수예요. 교재와 교통안전 기사에서는 마른 아스팔트 0.7 안팎, 젖은 아스팔트 0.4 안팎, 눈길 0.2, 빙판 0.1 이하로 소개하는 경우가 많아요. 이 글은 0.7·0.4·0.2·0.1을 대표값으로 썼어요. 실제로는 타이어 마모, 노면 온도, 눈이 다져진 정도에 따라 달라지니 <strong>범위로 이해하는 게 맞아요.</strong> 한국도로교통공단이 측정한 젖은 낙엽 노면 마찰계수가 0.2~0.3으로 빙판과 비슷한 수준이라는 보도도 있어서(<a href="https://www.imaeil.com/page/view/2026101422311361388" target="_blank" rel="noopener">매일신문 기사</a>) 가을 낙엽길도 조심해야 해요.</p>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/snow-ice-braking-distance-speed-guide-1.svg" alt="정지거리를 공주거리와 제동거리로 나눠 보여 주는 도식" width="800" height="450" loading="lazy" decoding="async"><figcaption>정지거리는 공주거리와 제동거리의 합이에요</figcaption></figure>
<h2 id="s2">속도별·노면별 정지거리 표 (반응시간 1초)</h2>
<div class="table-wrap"><table><thead><tr><th>속도</th><th>공주거리</th><th>마른 노면</th><th>젖은 노면</th><th>눈길</th><th>빙판</th></tr></thead><tbody><tr><td>30km/h</td><td>8.3m</td><td>13.4m</td><td>17.2m</td><td>26.0m</td><td>43.8m</td></tr><tr><td>50km/h</td><td>13.9m</td><td>27.9m</td><td>38.5m</td><td>63.1m</td><td>112.3m</td></tr><tr><td>60km/h</td><td>16.7m</td><td>36.9m</td><td>52.1m</td><td>87.5m</td><td>158.4m</td></tr><tr><td>80km/h</td><td>22.2m</td><td>58.2m</td><td>85.2m</td><td>148.2m</td><td>274.2m</td></tr><tr><td>100km/h</td><td>27.8m</td><td>84.0m</td><td>126.2m</td><td>224.6m</td><td>421.5m</td></tr><tr><td>110km/h</td><td>30.6m</td><td>98.6m</td><td>149.6m</td><td>268.7m</td><td>506.9m</td></tr></tbody></table></div>
<p>표를 보면 30km/h 빙판 정지거리(43.8m)가 60km/h 젖은 노면(52.1m)보다 짧거나 비슷해요. 반대로 80km/h 눈길 정지거리(148.2m)는 마른 노면 110km/h(98.6m)와 비슷해요. 즉 <strong>눈길에서 80km/h로 달리는 건, 마른 길에서 110km/h로 달리다 멈추는 것과 비슷한 거리가 필요하다</strong>는 뜻이에요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">80km/h 노면별 정지거리 (반응시간 1초)</figcaption><div class="viz-legend"><span><i class="s1"></i>공주거리</span><span><i class="s2"></i>제동거리</span></div><svg viewBox="0 0 640 300" role="img" aria-label="80km/h 노면별 정지거리 (반응시간 1초)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">250</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">300</text><rect class="s1" x="73.6" y="236.5" width="49.4" height="17.5" rx="3"><title>마른 노면 · 공주거리 22.2m</title></rect><rect class="s2" x="125.0" y="225.7" width="49.4" height="28.3" rx="3"><title>마른 노면 · 제동거리 36m</title></rect><text class="viz-ax" x="124.0" y="272" text-anchor="middle">마른 노면</text><rect class="s1" x="217.6" y="236.5" width="49.4" height="17.5" rx="3"><title>젖은 노면 · 공주거리 22.2m</title></rect><rect class="s2" x="269.0" y="204.4" width="49.4" height="49.6" rx="3"><title>젖은 노면 · 제동거리 63m</title></rect><text class="viz-ax" x="268.0" y="272" text-anchor="middle">젖은 노면</text><rect class="s1" x="361.6" y="236.5" width="49.4" height="17.5" rx="3"><title>눈길 · 공주거리 22.2m</title></rect><rect class="s2" x="413.0" y="154.9" width="49.4" height="99.1" rx="3"><title>눈길 · 제동거리 126m</title></rect><text class="viz-ax" x="412.0" y="272" text-anchor="middle">눈길</text><rect class="s1" x="505.6" y="236.5" width="49.4" height="17.5" rx="3"><title>빙판 · 공주거리 22.2m</title></rect><rect class="s2" x="557.0" y="55.8" width="49.4" height="198.2" rx="3"><title>빙판 · 제동거리 252m</title></rect><text class="viz-ax" x="556.0" y="272" text-anchor="middle">빙판</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">공주거리는 어느 노면에서나 같고 제동거리가 노면에 따라 달라져요.</p><div class="viz-cap">단위: m · 자료: 제동거리 공식 v²/(2μg)로 직접 계산, 마찰계수는 대표값 가정</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>공주거리</th><th>제동거리</th></tr></thead><tbody><tr><td>마른 노면</td><td>22.2</td><td>36</td></tr><tr><td>젖은 노면</td><td>22.2</td><td>63</td></tr><tr><td>눈길</td><td>22.2</td><td>126</td></tr><tr><td>빙판</td><td>22.2</td><td>252</td></tr></tbody></table></div></details></figure>
<h2 id="s3">속도가 2배면 제동거리는 4배</h2>
<p>제동거리 식에 속도가 제곱으로 들어가기 때문이에요. 빙판(μ 0.1)에서 제동거리를 계산하면 40km/h는 63.0m, 80km/h는 252.0m, 120km/h는 566.9m예요. 40에서 80으로 두 배 올리면 4배(×4), 40에서 120으로 세 배 올리면 9배가 돼요.</p>
<p>이 때문에 <strong>조금만 줄여도 효과가 커요.</strong> 눈길 80km/h를 20% 줄여 64km/h로 가면 제동거리가 36% 짧아져요(0.8×0.8=0.64). 아래는 눈길(μ 0.2)에서 80km/h를 기준으로 감속률별 정지거리를 계산한 거예요.</p>
<div class="table-wrap"><table><thead><tr><th>속도</th><th>눈길 정지거리</th><th>80km/h 대비 줄어드는 거리</th></tr></thead><tbody><tr><td>80km/h (그대로)</td><td>148.2m</td><td>0.0m</td></tr><tr><td>64km/h (20% 감속)</td><td>98.4m</td><td>49.8m</td></tr><tr><td>56km/h (30% 감속)</td><td>77.3m</td><td>70.9m</td></tr><tr><td>40km/h (50% 감속)</td><td>42.6m</td><td>105.6m</td></tr></tbody></table></div>
<p>눈길에서 50% 감속(40km/h)하면 정지거리가 42.6m로 80km/h일 때(148.2m)의 3분의 1 아래로 줄어요. 속도는 절반인데 거리는 3분의 1이 되는 게 제곱의 효과예요.</p>
<figure class="viz viz-line"><figcaption class="viz-title">눈길(μ 0.2) 속도별 정지거리</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="눈길(μ 0.2) 속도별 정지거리" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="570" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="570" y1="206.8" y2="206.8"/><text class="viz-ax" x="46" y="210.8" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="570" y1="159.6" y2="159.6"/><text class="viz-ax" x="46" y="163.6" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="570" y1="112.4" y2="112.4"/><text class="viz-ax" x="46" y="116.4" text-anchor="end">150</text><line class="viz-grid" x1="52" x2="570" y1="65.2" y2="65.2"/><text class="viz-ax" x="46" y="69.2" text-anchor="end">200</text><line class="viz-grid" x1="52" x2="570" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">250</text><text class="viz-ax" x="52.0" y="272" text-anchor="middle">30km/h</text><text class="viz-ax" x="126.0" y="272" text-anchor="middle">40km/h</text><text class="viz-ax" x="200.0" y="272" text-anchor="middle">50km/h</text><text class="viz-ax" x="274.0" y="272" text-anchor="middle">60km/h</text><text class="viz-ax" x="348.0" y="272" text-anchor="middle">70km/h</text><text class="viz-ax" x="422.0" y="272" text-anchor="middle">80km/h</text><text class="viz-ax" x="496.0" y="272" text-anchor="middle">90km/h</text><text class="viz-ax" x="570.0" y="272" text-anchor="middle">100km/h</text><polyline class="viz-line s1" points="52.0,229.5 126.0,213.8 200.0,194.4 274.0,171.4 348.0,144.6 422.0,114.1 496.0,79.9 570.0,42.0"/><circle class="viz-dot s1" cx="52.0" cy="229.5" r="4"><title>30km/h · 정지거리 26m</title></circle><circle class="viz-dot s1" cx="126.0" cy="213.8" r="4"><title>40km/h · 정지거리 42.6m</title></circle><circle class="viz-dot s1" cx="200.0" cy="194.4" r="4"><title>50km/h · 정지거리 63.1m</title></circle><circle class="viz-dot s1" cx="274.0" cy="171.4" r="4"><title>60km/h · 정지거리 87.5m</title></circle><circle class="viz-dot s1" cx="348.0" cy="144.6" r="4"><title>70km/h · 정지거리 116m</title></circle><circle class="viz-dot s1" cx="422.0" cy="114.1" r="4"><title>80km/h · 정지거리 148m</title></circle><circle class="viz-dot s1" cx="496.0" cy="79.9" r="4"><title>90km/h · 정지거리 184m</title></circle><circle class="viz-dot s1" cx="570.0" cy="42.0" r="4"><title>100km/h · 정지거리 225m</title></circle><text class="viz-val" x="578.0" y="46.0">225</text><line class="viz-base" x1="52" x2="570" y1="254.0" y2="254.0"/></svg><p class="viz-note">곡선이 위로 가팔라지는 구간이 속도를 줄일 때 효과가 큰 구간이에요.</p><div class="viz-cap">단위: m · 자료: 반응시간 1초, 마찰계수 0.2 가정으로 직접 계산</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>정지거리</th></tr></thead><tbody><tr><td>30km/h</td><td>26</td></tr><tr><td>40km/h</td><td>42.6</td></tr><tr><td>50km/h</td><td>63.1</td></tr><tr><td>60km/h</td><td>87.5</td></tr><tr><td>70km/h</td><td>116</td></tr><tr><td>80km/h</td><td>148</td></tr><tr><td>90km/h</td><td>184</td></tr><tr><td>100km/h</td><td>225</td></tr></tbody></table></div></details></figure>
<h2 id="s4">시야 안에 멈추려면 최대 몇 km/h일까</h2>
<p>운전 중에는 "내가 볼 수 있는 거리 안에서 멈출 수 있는가"가 핵심이에요. 정지거리 공식을 속도에 대해 풀면, 앞이 D미터만큼 보일 때 낼 수 있는 최대 속도를 구할 수 있어요. 반응시간 1초로 계산하면 이래요.</p>
<div class="table-wrap"><table><thead><tr><th>멈출 수 있는 거리</th><th>마른 노면</th><th>젖은 노면</th><th>눈길</th><th>빙판</th></tr></thead><tbody><tr><td>20m</td><td>40km/h</td><td>33km/h</td><td>26km/h</td><td>19km/h</td></tr><tr><td>40m</td><td>63km/h</td><td>51km/h</td><td>39km/h</td><td>29km/h</td></tr><tr><td>60m</td><td>82km/h</td><td>65km/h</td><td>49km/h</td><td>36km/h</td></tr><tr><td>100m</td><td>111km/h</td><td>88km/h</td><td>65km/h</td><td>47km/h</td></tr></tbody></table></div>
<p>눈이 내리거나 안개가 껴서 시야가 40m밖에 안 보일 때, 눈길이라면 최대 39km/h, 빙판이라면 29km/h예요. 이건 앞의 정지 물체 바로 앞에서 겨우 서는 속도라서 <strong>실제로는 이보다 낮게</strong> 가야 여유가 생겨요. 커브길이나 내리막은 보이는 거리도 짧아요.</p>
<h2 id="s5">판단 기준: 앞차 간격은 몇 초면 충분할까</h2>
<p>'앞차 간격 몇 초'가 얼마나 안전한지는 앞차가 어떻게 서느냐에 따라 달라요. 두 경계를 나눠 계산하면 판단이 쉬워져요.</p>
<ul><li><strong>가장 좋은 경우</strong>: 앞차도 같은 노면에서 브레이크를 밟아 서서히 서면, 두 차의 제동거리가 비슷해서 필요한 간격은 내 반응시간(약 1초)에 가까워요.</li><li><strong>가장 나쁜 경우</strong>: 앞차가 사고로 그 자리에 멈추거나, 앞차가 차로를 바꾸면서 멈춘 차가 갑자기 드러나면 내 정지거리 전체가 필요해요. 이때 필요한 간격(초)은 '정지거리 ÷ 초속'이고, 식으로 쓰면 반응시간 + 초속 ÷ (2μg)예요.</li></ul>
<p>아래는 가장 나쁜 경우에 필요한 앞차 간격을 초로 계산한 표예요(반응시간 1초).</p>
<div class="table-wrap"><table><thead><tr><th>속도</th><th>마른 노면</th><th>젖은 노면</th><th>눈길</th><th>빙판</th></tr></thead><tbody><tr><td>40km/h</td><td>1.8초</td><td>2.4초</td><td>3.8초</td><td>6.7초</td></tr><tr><td>60km/h</td><td>2.2초</td><td>3.1초</td><td>5.3초</td><td>9.5초</td></tr><tr><td>80km/h</td><td>2.6초</td><td>3.8초</td><td>6.7초</td><td>12.3초</td></tr><tr><td>100km/h</td><td>3.0초</td><td>4.5초</td><td>8.1초</td><td>15.2초</td></tr></tbody></table></div>
<p>같은 식을 거꾸로 풀면 간격별로 '최악의 경우까지 버티는 속도'가 나와요. 눈길(μ 0.2)에서 4초 간격은 약 42km/h, 빙판(μ 0.1)에서는 약 21km/h까지예요. 그래서 판단 규칙은 이렇게 정리돼요.</p>
<ul><li>눈길에서 <strong>4초 간격 + 40km/h 안팎</strong>이면 앞에 멈춘 차가 갑자기 나타나도 계산상 설 수 있어요.</li><li>눈길에서 60km/h를 내야 하는 도로라면 간격을 <strong>5초 이상</strong>으로 늘려야 같은 수준이 돼요.</li><li>빙판이 의심되면(그늘진 다리·터널 입구) 간격만으로는 해결이 안 되니 속도를 20km/h대까지 낮추는 게 맞아요.</li></ul>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">가상 사례: 눈 덮인 4차로, 70km/h에 앞차 간격 2초</h2>
<p>설명을 위해 만든 가상의 운전 상황이에요. 눈이 다져진 왕복 4차로를 70km/h로 달리고, 앞차와 간격은 평소 습관대로 2초(38.9m)예요. 앞차가 갑자기 옆 차로로 빠지자 사고로 멈춘 차가 39m 앞에 보여요.</p>
<ol><li><strong>필요한 정지거리</strong>: 70km/h·눈길·반응 1초면 115.9m, 순간 당황해서 반응이 1.5초면 125.6m예요. 39m 안에서는 서지 못해요.</li><li><strong>39m 안에서 설 수 있는 속도</strong>: 반응 1초면 약 38km/h, 1.5초면 약 35km/h예요. 지금 속도의 절반 정도예요.</li><li><strong>다른 선택</strong>: 50km/h로 낮추고 간격을 5초(69.4m)로 두면 정지거리는 63.1m(반응 1초)라 여유가 6m쯤 남아요. 반응이 1.5초로 늘면 70.0m가 필요해서 0.6m 모자라요. 45km/h·5초(62.5m)면 반응 1.5초에서도 58.6m로 설 수 있어요.</li></ol>
<p>이 사례에서 보듯 <strong>속도만 줄이거나 간격만 늘리는 것보다 둘을 같이 바꿀 때</strong> 숫자가 맞아떨어져요.</p>
<h2 id="s7">반응시간이 길어지면 어떻게 될까</h2>
<p>공주거리는 속도×반응시간이라 반응시간이 길어질수록 직선으로 늘어나요. 80km/h에서 반응시간이 0.7초면 15.6m, 1초면 22.2m, 1.5초면 33.3m, 2초면 44.4m예요. 2초와 0.7초 차이는 28.9m로, 차 대여섯 대 길이와 맞먹어요. 휴대전화를 보거나 졸릴 때가 여기 해당해요.</p>
<p>눈길에서는 제동거리가 이미 길기 때문에 공주거리가 늘면 정지거리가 더 위험해져요. 반응시간 2초일 때 눈길 80km/h 정지거리는 170.4m예요.</p>
<h2 id="s8">눈길·빙판길 안전 운전 체크리스트</h2>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/life/snow-ice-braking-distance-speed-guide-2.svg" alt="눈길 운전 전 체크리스트 카드" width="800" height="450" loading="lazy" decoding="async"><figcaption>출발 전과 주행 중에 확인할 항목</figcaption></figure>
<ol><li><strong>출발 전</strong>: 타이어 마모와 공기압을 확인하고 필요하면 겨울용 타이어나 스노체인을 준비해요. 마찰계수 자체를 올리는 유일한 방법이에요.</li><li><strong>속도와 간격은 같이</strong>: 위 판단 기준처럼 눈길이면 40km/h 안팎에 4초 간격을 하나의 묶음으로 기억해요.</li><li><strong>브레이크는 나눠서</strong>: 한 번에 꾹 밟지 말고 부드럽게, ABS 차량은 페달이 떨려도 유지해요.</li><li><strong>그늘진 다리·터널 입구 전에 미리 감속</strong>: 먼저 얼고, 젖어 보이는 곳이 블랙아이스일 수 있어요. 감속은 커브 진입 전 직선에서 해요.</li></ol>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s9">자주 틀리는 오해</h2>
<ul><li><strong>"4륜 구동이니까 잘 선다"</strong>: 4륜은 출발과 가속에 유리하지만 멈추는 힘은 타이어와 노면 마찰이 정해요. 제동거리는 거의 비슷해요.</li><li><strong>"ABS가 있으니 괜찮다"</strong>: ABS는 바퀴가 잠기지 않게 해서 방향을 조작할 수 있게 해 줄 뿐, 빙판에서 제동거리를 크게 줄여 주지는 않아요.</li><li><strong>"눈이 녹아 길이 젖었으니 안전하다"</strong>: 기온이 영하 근처면 녹은 물이 다시 얼 수 있어요.</li><li><strong>"큰 차가 안전하다"</strong>: 질량이 커도 마찰계수가 같으면 제동거리 공식에서 질량은 약분돼요.</li><li><strong>"20% 감속"을 "속도의 20%로"로 읽어요.</strong> 80km/h에서 20% 감속은 64km/h예요. 계산기마다 '감속률'과 '남은 속도 비율'을 섞어 쓰는 경우가 있으니 결과를 비교할 땐 실제 km/h로 맞춰 보세요.</li><li><strong>다른 계산기와 숫자가 달라요.</strong> 반응시간(0.7초·1초·1.5초)과 마찰계수(눈길 0.15~0.3) 가정이 제각각이라서예요. 이 글은 반응 1초, μ 0.7·0.4·0.2·0.1로 통일했어요.</li></ul>
<h2 id="s10">자주 묻는 질문</h2>
<h3>빙판에서 제동거리가 마른 노면보다 몇 배 길어요?</h3>
<p>제동거리만 보면 마찰계수 비율인 7배(0.7÷0.1)로 계산돼요. 공주거리까지 합친 정지거리는 80km/h 반응시간 1초 기준으로 4.7배예요. 기관 실험에서는 빙판길 제동거리가 약 5~7배 길어졌다고 소개된 사례도 있어요(<a href="https://www.seoul.co.kr/news/society/2022/10/19/20221019009004" target="_blank" rel="noopener">서울신문 교통안전 기사</a>).</p>
<h3>눈길에서는 몇 %를 감속해야 하나요?</h3>
<p>법으로 정해진 비율은 도로 상황에 따라 달라요. 계산상으로는 20% 감속하면 제동거리가 36%, 50% 감속하면 75% 줄어요. 시야와 노면 상태에 맞춰 더 줄이는 게 안전해요.</p>
<h3>반응시간은 몇 초로 계산하나요?</h3>
<p>흔히 1초 안팎을 쓰고, 피곤하거나 한눈팔면 2초 이상으로 늘 수 있어요. 안전하게 보려면 1.5초 이상으로 넣어 보세요.</p>
<h3>계산기 숫자와 실제 제동거리가 다르면 왜 그런가요?</h3>
<p>계산은 평지, 일정한 마찰계수, 최대 제동을 가정한 단순 모델이에요. 실제 차는 타이어, 브레이크 성능, 노면 온도에 따라 달라서 같은 속도라도 몇 m씩 차이가 날 수 있어요.</p>
<h3>앞차와의 안전거리는 어떻게 재나요?</h3>
<p>앞차가 표지판 같은 한 지점을 지난 뒤 내 차가 같은 지점을 지나기까지 '하나, 둘…' 하고 세요. 몇 초가 필요한지는 위 판단 기준 표처럼 속도와 노면에 따라 달라요.</p>
<h3>겨울용 타이어를 끼우면 제동거리가 얼마나 줄어요?</h3>
<p>제품과 노면에 따라 달라서 이 글에서 단정할 수 있는 숫자는 없어요. 계산기에서 마찰계수를 0.2에서 0.25로 바꿔 보면, 같은 속도에서 제동거리는 마찰계수에 반비례해서 20% 짧아진다는 걸 확인할 수 있어요.</p>
<h2 id="s11">정리</h2>
<p>눈길 제동거리는 마찰이 줄어서 늘어나고, 속도가 제곱으로 작용하기 때문에 <strong>속도를 낮추는 게 가장 큰 효과</strong>를 내요. 80km/h 눈길 정지거리 148.2m는 마른 길 110km/h와 비슷하다는 숫자만 기억해도 속도를 줄이는 데 도움이 돼요. 직접 속도와 반응시간을 바꿔 보고 싶다면 <a href="https://tool.smithlab.co.kr/life/braking-distance-calculator/">겨울철 제동거리 계산기</a>에서 네 대의 차가 서로 다른 지점에서 멈추는 모습을 확인해 보세요. 속도 단위가 헷갈린다면 <a href="https://tool.smithlab.co.kr/convert/speed-converter/">속도 변환기</a>도 함께 써 보세요. 겨울철 집 안 습기가 궁금하다면 <a href="https://tool.smithlab.co.kr/posts/life/window-condensation-humidity-dew-point-guide/">창문 결로 습도 글</a>도 이어서 읽어 볼 만해요. 도로 위험 구간과 교통안전 정보는 <a href="https://www.koroad.or.kr/" target="_blank" rel="noopener">도로교통공단</a>에서 확인할 수 있어요. 제동거리의 수학적 배경은 <a href="https://en.wikipedia.org/wiki/Braking_distance" target="_blank" rel="noopener">Braking distance 문서(Wikipedia)</a>에 정리돼 있어요.</p>
    ]]></description>
    </item>
    <item>
      <title>52주 적금 챌린지 총액 137만 8천 원, 거꾸로 넣으면 뭐가 달라질까</title>
      <link>https://tool.smithlab.co.kr/posts/money/52-week-savings-challenge-total-reverse-order/</link>
      <guid isPermaLink="true">https://tool.smithlab.co.kr/posts/money/52-week-savings-challenge-total-reverse-order/</guid>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0900</pubDate>
      <category>스미스 연구소</category>
      <description><![CDATA[
<p>첫 주에 1,000원, 둘째 주에 2,000원, 이렇게 매주 1,000원씩 늘려 마지막 52주 차에 52,000원을 넣으면 <strong>1년 총액은 1,378,000원</strong>이에요. 문제는 마지막 4주에만 202,000원이 몰린다는 점이라, 시작 전에 정할 숫자는 총액이 아니라 <strong>증가액</strong>이에요. 마지막 4주에 쓸 수 있는 돈을 B원이라 하면 첫 주 1,000원 기준 증가액은 (B − 4,000) ÷ 198원 이하로 잡으면 돼요. 마지막 4주 예산 10만 원이면 증가액 400원(총액 582,400원), 15만 원이면 700원(총액 980,200원)이에요. 거꾸로 넣기는 총액이 같고, 연 3% 단리 예시로 이자가 세후 약 1만 1천 원 더 붙어요. 내 금액으로 바로 보고 싶다면 <a href="https://tool.smithlab.co.kr/money/52-week-savings-challenge/">52주 적금 챌린지 계산기</a>에 첫 주 금액과 증가액을 넣어 보세요.</p>
<aside class="callout key"><b class="callout-h">핵심 요약</b><ul><li>첫 주 a원, 매주 d원씩 늘리면 n주 총액은 n×a + d×n×(n-1)÷2 예요. 1,000원·1,000원이면 52주에 1,378,000원이에요.</li><li>거꾸로 넣어도 총액은 같지만 큰 돈을 먼저 넣어서 같은 금리에서 이자가 더 붙고, 대신 초반 부담이 커요.</li><li>챌린지의 가장 큰 위험은 총액이 아니라 중간에 끊기는 것이에요. 부담이 큰 구간을 미리 표로 보고 증가액을 줄이는 쪽이 완주 확률을 높여요.</li></ul></aside>
<!-- kakao-ad --><div class="ad-kakao"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-QiLsqZLE7gbSwimU" data-ad-width="320" data-ad-height="480" data-ad-onfail="smithAdFail"></ins></div><script>function smithAdFail(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}</script><!-- /kakao-ad -->
<h2 id="s1">52주 적금 총액은 어떻게 계산할까</h2>
<p>52주 적금 챌린지는 매주 넣는 돈이 일정하게 늘어나는 <strong>등차수열</strong>이에요. 첫 주를 a원, 매주 늘리는 금액을 d원, 기간을 n주라고 하면 n주 차에 넣는 돈은 a + d×(n-1)원이고, 총액은 아래 식으로 구해요.</p>
<p>총액 = n × a + d × n × (n − 1) ÷ 2</p>
<p>가장 흔한 1,000원 시작·1,000원 증가 조건에 52주를 넣어 볼게요.</p>
<ul><li>n × a = 52 × 1,000 = 52,000원</li><li>d × n × (n−1) ÷ 2 = 1,000 × 52 × 51 ÷ 2 = 1,000 × 1,326 = 1,326,000원</li><li>합계 = 52,000 + 1,326,000 = <strong>1,378,000원</strong></li></ul>
<p>마지막 52주 차에 넣는 돈은 1,000 + 1,000 × 51 = 52,000원이에요. 52주 평균으로는 주당 26,500원, 월로 환산하면 약 114,833원(1,378,000 ÷ 12)이에요. 같은 137만 원을 목표로 한다면 처음부터 매주 26,500원씩 넣는 방식과 총액이 같다는 뜻이에요. 챌린지는 이 평균을 "초반엔 아주 적게, 후반엔 크게"로 나눠 놓은 형태예요.</p>
<h2 id="s2">시작 금액과 증가액별 총액표</h2>
<p>1,000원·1,000원이 가장 유명하지만 월급과 생활비에 맞춰 바꿔도 돼요. 아래는 위 식으로 직접 계산한 52주 총액이에요.</p>
<div class="table-wrap"><table><thead><tr><th>첫 주 금액</th><th>매주 증가액</th><th>52주 차 납입액</th><th>52주 총액</th></tr></thead><tbody><tr><td>100원</td><td>100원</td><td>5,200원</td><td>137,800원</td></tr><tr><td>500원</td><td>500원</td><td>26,000원</td><td>689,000원</td></tr><tr><td>1,000원</td><td>100원</td><td>6,100원</td><td>184,600원</td></tr><tr><td>1,000원</td><td>1,000원</td><td>52,000원</td><td>1,378,000원</td></tr><tr><td>2,000원</td><td>0원 (고정)</td><td>2,000원</td><td>104,000원</td></tr></tbody></table></div>
<p>같은 "52주"라도 증가액이 총액을 거의 결정해요. 첫 주 금액을 1,000원에서 2,000원으로 올리는 것보다 증가액을 100원에서 1,000원으로 올리는 쪽이 총액을 훨씬 크게 바꿔요. 증가액이 크면 마지막 달 부담도 같이 커지니, 총액을 정한 뒤 마지막 주 납입액이 내 월급에서 감당 가능한지 먼저 확인하는 게 좋아요.</p>
<figure class="viz viz-bar"><figcaption class="viz-title">증가액별 52주 총액 (첫 주 1,000원 기준)</figcaption><svg viewBox="0 0 640 300" role="img" aria-label="증가액별 52주 총액 (첫 주 1,000원 기준)" preserveAspectRatio="xMidYMid meet"><line class="viz-grid" x1="52" x2="628" y1="254.0" y2="254.0"/><text class="viz-ax" x="46" y="258.0" text-anchor="end">0</text><line class="viz-grid" x1="52" x2="628" y1="214.7" y2="214.7"/><text class="viz-ax" x="46" y="218.7" text-anchor="end">25</text><line class="viz-grid" x1="52" x2="628" y1="175.3" y2="175.3"/><text class="viz-ax" x="46" y="179.3" text-anchor="end">50</text><line class="viz-grid" x1="52" x2="628" y1="136.0" y2="136.0"/><text class="viz-ax" x="46" y="140.0" text-anchor="end">75</text><line class="viz-grid" x1="52" x2="628" y1="96.7" y2="96.7"/><text class="viz-ax" x="46" y="100.7" text-anchor="end">100</text><line class="viz-grid" x1="52" x2="628" y1="57.3" y2="57.3"/><text class="viz-ax" x="46" y="61.3" text-anchor="end">125</text><line class="viz-grid" x1="52" x2="628" y1="18.0" y2="18.0"/><text class="viz-ax" x="46" y="22.0" text-anchor="end">150</text><rect class="s1" x="73.6" y="245.8" width="100.8" height="8.2" rx="3"><title>증가 0원 · 52주 총액 5.2만원</title></rect><text class="viz-val" x="124.0" y="240.8" text-anchor="middle">5.2</text><text class="viz-ax" x="124.0" y="272" text-anchor="middle">증가 0원</text><rect class="s1" x="217.6" y="224.9" width="100.8" height="29.1" rx="3"><title>증가 100원 · 52주 총액 18.5만원</title></rect><text class="viz-val" x="268.0" y="219.9" text-anchor="middle">18.5</text><text class="viz-ax" x="268.0" y="272" text-anchor="middle">증가 100원</text><rect class="s1" x="361.6" y="141.5" width="100.8" height="112.5" rx="3"><title>증가 500원 · 52주 총액 71.5만원</title></rect><text class="viz-val" x="412.0" y="136.5" text-anchor="middle">71.5</text><text class="viz-ax" x="412.0" y="272" text-anchor="middle">증가 500원</text><rect class="s1" x="505.6" y="37.2" width="100.8" height="216.8" rx="3"><title>증가 1000원 · 52주 총액 138만원</title></rect><text class="viz-val" x="556.0" y="32.2" text-anchor="middle">138</text><text class="viz-ax" x="556.0" y="272" text-anchor="middle">증가 1000원</text><line class="viz-base" x1="52" x2="628" y1="254.0" y2="254.0"/></svg><p class="viz-note">증가 500원은 52,000 + 500×1,326 = 715,000원이에요.</p><div class="viz-cap">단위: 만원 · 자료: 총액 = n×a + d×n×(n-1)÷2 로 직접 계산 (52주, 첫 주 1,000원)</div><details class="viz-table"><summary>표로 보기</summary><div class="table-wrap"><table><thead><tr><th>구분</th><th>52주 총액</th></tr></thead><tbody><tr><td>증가 0원</td><td>5.2</td></tr><tr><td>증가 100원</td><td>18.5</td></tr><tr><td>증가 500원</td><td>71.5</td></tr><tr><td>증가 1000원</td><td>138</td></tr></tbody></table></div></details></figure>
<figure class="post-fig"><img src="https://tool.smithlab.co.kr/assets/img/posts/money/52-week-savings-challenge-total-reverse-order-1.svg" alt="정방향과 거꾸로 52주 적금의 주차별 납입액 비교 그림" width="800" height="450" loading="lazy" decoding="async"><figcaption>정방향은 1,000원에서 52,000원으로, 거꾸로는 52,000원에서 1,000원으로 — 총액은 같아요</figcaption></figure>
<h2 id="s3">증가액 정하는 판단 기준: 마지막 4주 예산에서 거꾸로 풀기</h2>
<p>정방향 챌린지에서 가장 비싼 달은 마지막 4주(49~52주 차)예요. 첫 주 a원, 증가액 d원이면 그 4주 합계는 4a + 198d원이에요(49주 차부터 48d, 49d, 50d, 51d가 더해지니까). 그래서 <strong>마지막 4주에 쓸 수 있는 돈 B를 먼저 정하고 d = (B − 4a) ÷ 198</strong> 로 거꾸로 풀면 끝까지 감당 가능한 증가액이 나와요. 첫 주 1,000원 기준으로 100원 단위로 내려 계산했어요.</p>
<div class="table-wrap"><table><thead><tr><th>마지막 4주 예산</th><th>증가액(100원 단위)</th><th>마지막 4주 실제 합계</th><th>52주 총액</th></tr></thead><tbody><tr><td>5만 원</td><td>200원</td><td>43,600원</td><td>317,200원</td></tr><tr><td>10만 원</td><td>400원</td><td>83,200원</td><td>582,400원</td></tr><tr><td>15만 원</td><td>700원</td><td>142,600원</td><td>980,200원</td></tr><tr><td>20만 원</td><td>900원</td><td>182,200원</td><td>1,245,400원</td></tr><tr><td>30만 원</td><td>1,400원</td><td>281,200원</td><td>1,908,400원</td></tr></tbody></table></div>
<p>유명한 '1,000원 증가'는 마지막 4주에 202,000원을 낼 수 있어야 맞는 설계예요. 그 4주가 연말 지출(경조사·연말 모임)과 겹친다면, 위 표에서 한 단계 낮은 증가액을 고르는 게 완주에 유리해요.</p>
<h2 id="s4">26주 챌린지는 어떨까</h2>
<p>52주가 길게 느껴지면 26주(반년)로 줄이는 방법이 있어요. 1,000원·1,000원 조건으로 26주를 넣으면 총액은 26 × 1,000 + 1,000 × 26 × 25 ÷ 2 = 26,000 + 325,000 = <strong>351,000원</strong>이고, 마지막 주 납입액은 26,000원이에요. 52주 총액의 4분의 1 수준(351,000 ÷ 1,378,000 ≈ 25.5%)이지만 마지막 주 부담은 절반이에요. 처음 해 보는 사람이라면 26주를 한 번 완주하고 이어서 다음 26주를 다시 하는 방식이 중간에 끊길 확률을 낮춰요.</p>
<h2 id="s5">거꾸로 52주 적금은 무엇이 다를까</h2>
<p>52주 차에 52,000원을 먼저 넣고 매주 1,000원씩 줄여 마지막 주에 1,000원을 넣는 <strong>거꾸로 방식</strong>도 많이 해요. 총액은 똑같이 1,378,000원이에요. 달라지는 것은 두 가지예요.</p>
<ol><li><strong>부담이 앞쪽에 몰려요.</strong> 첫 4주 합계만 봐도 정방향은 1,000 + 2,000 + 3,000 + 4,000 = 10,000원이고, 거꾸로는 52,000 + 51,000 + 50,000 + 49,000 = 202,000원이에요. 거꾸로 첫 달이 정방향 첫 달의 20배가 넘어요. 새해 의욕이 가장 높을 때 큰돈을 넣고, 힘이 빠지는 시기에 가볍게 마무리하는 설계예요.</li><li><strong>같은 금리라면 이자가 더 붙어요.</strong> 큰돈이 더 오래 묶이기 때문이에요. 아래 예시로 확인해 볼게요.</li></ol>
<h3>이자 차이 예시 계산 (연 3% 단리 가정)</h3>
<p>실제 금리는 상품마다 달라서 <strong>예시 계산</strong>으로만 봐 주세요. 매주 초 납입, 52주 차까지 매주 같은 방식으로 이자가 붙는 단리라고 가정하면, i번째 납입금은 (52 − i + 1)주 동안 묶여요. 이자 = Σ 납입금 × 연 3% × 보관 주수 ÷ 52 로 계산해요.</p>
<div class="table-wrap"><table><thead><tr><th>방식</th><th>총 납입</th><th>예시 이자(연 3% 단리)</th><th>비고</th></tr></thead><tbody><tr><td>정방향 (1,000원부터)</td><td>1,378,000원</td><td>약 14,310원(세후 약 12,106원)</td><td>큰돈이 늦게 들어가 이자가 적음</td></tr><tr><td>매주 고정 26,500원</td><td>1,378,000원</td><td>약 21,068원(세후 약 17,823원)</td><td>정방향과 거꾸로의 중간</td></tr><tr><td>거꾸로 (52,000원부터)</td><td>1,378,000원</td><td>약 27,825원(세후 약 23,540원)</td><td>큰돈이 먼저 들어가 이자가 큼</td></tr></tbody></table></div>
<p>차이는 세전 약 13,500원, 이자소득세 15.4%를 떼면 약 11,400원이에요. 크다고 보기는 어렵고, 실제 상품은 세금(이자소득세 15.4%)과 중도 해지 이율, 납입 방식(자유적립식/정기적립식)에 따라 달라요. 그래서 <strong>이자 때문에 거꾸로를 고를 이유는 크지 않고</strong>, 내가 끝까지 할 수 있는 모양을 고르는 게 먼저예요. 이자까지 정확히 보고 싶다면 <a href="https://tool.smithlab.co.kr/money/savings-calculator/">적금 이자 계산기</a>에 매주 납입이 아닌 월 단위로 환산해서 넣어 보세요.</p>
<!-- kakao-midpair --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-ZYyczKMzADFPJ1j8" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-2"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-VLKZK0d9HWvhImkp" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail7"></ins></div></div><script>function smithAdFail7(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-pair-2');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-midpair -->
<h2 id="s6">부담이 가장 큰 구간을 미리 보기</h2>
<p>정방향 챌린지는 누적액이 후반에 급하게 늘어요. 아래는 주차별 누적 납입액이에요(직접 계산).</p>
<div class="table-wrap"><table><thead><tr><th>주차</th><th>그 주 납입액</th><th>누적 납입액</th></tr></thead><tbody><tr><td>12주</td><td>12,000원</td><td>78,000원</td></tr><tr><td>26주</td><td>26,000원</td><td>351,000원</td></tr><tr><td>39주</td><td>39,000원</td><td>780,000원</td></tr><tr><td>52주</td><td>52,000원</td><td>1,378,000원</td></tr></tbody></table></div>
<p>26주(절반)까지 누적은 351,000원으로 전체의 약 25%뿐이에요. 즉 절반을 지나도 전체의 4분의 1만 모은 셈이고, <strong>나머지 75%가 후반 26주에 몰려 있어요.</strong> 40주 차부터는 매주 4만 원 이상을 넣어야 하니, 이 구간의 월 납입액이 월급의 몇 %인지 미리 계산해 두는 게 중요해요. 마지막 4주 합계는 49,000 + 50,000 + 51,000 + 52,000 = 202,000원이에요. 월 실수령액이 250만 원이라면 약 8%, 150만 원이라면 약 13.5%예요.</p>
<h2 id="s7">가상의 사회초년생으로 처음부터 설계해 보기</h2>
<p>아래는 이해를 돕기 위한 <strong>가상의 인물</strong> 예시예요. 월 실수령 230만 원인 직장인이 1월 첫 주에 시작해요. 연말 4주에는 모임이 많아 챌린지에 15만 원까지만 쓰기로 했어요.</p>
<ol><li>증가액: (150,000 − 4,000) ÷ 198 ≈ 737원 → 700원으로 내림</li><li>52주 차 납입액: 1,000 + 700 × 51 = 36,700원</li><li>52주 총액: 52 × 1,000 + 700 × 1,326 = <strong>980,200원</strong></li><li>마지막 4주 합계: 4,000 + 198 × 700 = 142,600원(실수령의 약 6.2%)</li><li>연 3% 단리 예시 이자: 약 10,256원, 세후 약 8,676원</li></ol>
<p>매주 이체가 번거로우면 4주 단위로 묶어 넣어도 돼요. 이 인물의 4주 묶음 납입액은 아래와 같아요(직접 계산).</p>
<div class="table-wrap"><table><thead><tr><th>4주 묶음</th><th>1</th><th>4</th><th>7</th><th>10</th><th>13 (마지막)</th></tr></thead><tbody><tr><td>1,000원 증가(기본)</td><td>10,000원</td><td>58,000원</td><td>106,000원</td><td>154,000원</td><td>202,000원</td></tr><tr><td>700원 증가(이 예시)</td><td>8,200원</td><td>41,800원</td><td>75,400원</td><td>109,000원</td><td>142,600원</td></tr></tbody></table></div>
<p>증가액을 1,000원에서 700원으로 줄이면 총액은 약 29% 줄고 마지막 4주 부담도 약 29% 줄어요. 빠진 주는 건너뛰지 말고 다음 주에 합쳐 넣으면 총액이 유지돼요.</p>
<!-- kakao-late --><div class="ad-pair"><div class="ad-kakao ad-kakao-mid"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-xZrUvRQuVp26FKbP" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div><div class="ad-kakao ad-kakao-mid ad-pair-side ad-side-late"><span class="ad-label">광고</span><ins class="kakao_ad_area" style="display:none;" data-ad-unit="DAN-IFtZlmr9M8XrXxzT" data-ad-width="300" data-ad-height="250" data-ad-onfail="smithAdFail6"></ins></div></div><script>function smithAdFail6(el){var w=el&&el.closest&&el.closest('.ad-kakao');if(w)w.remove()}(function(){var b=document.querySelector('.ad-side-late');if(b&&!(window.matchMedia&&matchMedia('(min-width:700px)').matches))b.remove()})()</script><!-- /kakao-late -->
<h2 id="s8">자주 틀리는 지점</h2>
<ul><li><strong>52주를 12개월로 나누기</strong>: 52주는 364일이라 4주 묶음으로 13번이에요. '월 평균 114,833원'은 총액 ÷ 12로 본 값이고, 4주 묶음 평균은 106,000원이에요. 월급날 기준으로 계획하면 한 달에 주가 5번 끼는 달이 생겨 그 달 부담이 커져요.</li><li><strong>총액에 이자가 들어 있다고 보기</strong>: 1,378,000원은 넣은 원금 합계예요. 이자는 상품 금리와 납입 방식에 따라 따로 붙고, 일반 과세면 15.4%를 떼요.</li><li><strong>거꾸로 넣기 이자를 크게 보기</strong>: 위 예시에서 세후 차이는 1만 1천 원 남짓이에요. 이자보다 첫 4주 202,000원을 낼 수 있는지가 먼저예요.</li><li><strong>비상금과 섞기</strong>: 급한 돈이 필요해 중도 해지하면 이율이 낮아져요. 비상금이 따로 없다면 챌린지보다 비상금이 먼저고, 고금리 대출이 있다면 그 상환이 먼저예요.</li></ul>
<h2 id="s9">자주 묻는 질문</h2>
<h3>52주 적금 챌린지 총액은 얼마인가요?</h3>
<p>첫 주 1,000원, 매주 1,000원 증가 기준으로 52주 총액은 1,378,000원이에요. 식은 52×1,000 + 1,000×52×51÷2 예요. 시작 금액과 증가액이 다르면 위 표 또는 <a href="https://tool.smithlab.co.kr/money/52-week-savings-challenge/">계산기</a>로 계산하세요.</p>
<h3>거꾸로 하면 총액이 달라지나요?</h3>
<p>납입 총액은 같아요. 다만 큰돈을 먼저 넣기 때문에 같은 금리의 적금이라면 이자가 조금 더 붙고, 초반 부담이 커요. 연 3% 단리 예시에서는 약 13,500원 차이가 났어요.</p>
<h3>매주 적금 통장을 새로 만들어야 하나요?</h3>
<p>그렇지 않아요. 자유적립식 적금이나 입출금 통장 하나에 매주 입금하는 방식이 가장 많아요. 은행·상품마다 가입 조건과 납입 한도가 달라서 가입 전에 약관을 확인하세요.</p>
<h3>한 주 빼먹으면 챌린지가 실패인가요?</h3>
<p>아니에요. 빠진 주의 금액을 다음 주에 합쳐 넣으면 총액이 유지돼요. 중요한 건 완벽한 주 단위 납입이 아니라 목표 총액을 채우는 거예요.</p>
<h3>이자소득세가 붙나요?</h3>
<p>일반 과세 적금이면 이자에 15.4%(소득세 14% + 지방소득세 1.4%)가 원천징수되는 것이 일반적이에요. 비과세·세금우대 조건은 상품과 가입자 자격에 따라 달라서 가입 시 확인하세요.</p>
<h3>52주 적금 증가액은 얼마로 하는 게 좋나요?</h3>
<p>마지막 4주에 낼 수 있는 돈을 먼저 정하고 (그 금액 − 4,000원) ÷ 198로 나눈 값 이하로 잡으면 돼요. 마지막 4주 예산 10만 원이면 400원, 15만 원이면 700원, 20만 원이면 900원이에요.</p>
<h3>26주만 하면 얼마인가요?</h3>
<p>1,000원·1,000원 조건으로 26주에 351,000원, 마지막 주 납입액은 26,000원이에요.</p>
<h2 id="s10">정리</h2>
<p>52주 적금 챌린지 총액은 n×a + d×n×(n−1)÷2 로 계산하고, 1,000원·1,000원이면 1,378,000원이에요. 거꾸로 넣어도 총액은 같고 이자 차이는 크지 않으니, 내가 끝까지 할 수 있는 모양과 후반 납입 부담을 먼저 따져 보세요. 내 금액을 바로 넣어 보려면 <a href="https://tool.smithlab.co.kr/money/52-week-savings-challenge/">52주 적금 챌린지 계산기</a>를, 이자까지 확인하려면 <a href="https://tool.smithlab.co.kr/money/savings-calculator/">적금 이자 계산기</a>와 <a href="https://tool.smithlab.co.kr/money/compound-interest-calculator/">복리 계산기</a>를 함께 써 보세요.</p>
<p>예금·적금 상품의 금리와 가입 조건은 <a href="https://fine.fss.or.kr" target="_blank" rel="noopener">금융감독원 금융소비자 정보 포털 파인</a>에서 비교할 수 있어요.</p>
    ]]></description>
    </item>
  </channel>
</rss>
