<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>김 양의 멋따라 개발따기</title>
    <link>https://new-crystal.tistory.com/</link>
    <description>GIT : https://github.com/new-crystal
e-mail : newcrystal670@gmail.com</description>
    <language>ko</language>
    <pubDate>Thu, 16 Apr 2026 06:17:52 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>개발따라김양</managingEditor>
    <image>
      <title>김 양의 멋따라 개발따기</title>
      <url>https://tistory1.daumcdn.net/tistory/5446924/attach/dede5e214ad64eec91fd31237cdc2c66</url>
      <link>https://new-crystal.tistory.com</link>
    </image>
    <item>
      <title>JavaScript !! (느낌표 두 개) 완전 정리: 왜 쓰고, 언제 위험할까?</title>
      <link>https://new-crystal.tistory.com/163</link>
      <description>&lt;h2 data-end=&quot;297&quot; data-start=&quot;278&quot; data-ke-size=&quot;size26&quot;&gt;1) !와 !!의 의미&lt;/h2&gt;
&lt;h3 data-end=&quot;324&quot; data-start=&quot;299&quot; data-ke-size=&quot;size23&quot;&gt;!value : 부정(논리 NOT)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;392&quot; data-start=&quot;325&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;365&quot; data-start=&quot;325&quot;&gt;value를 truthy/falsy로 평가한 뒤 결과를 뒤집는다.&lt;/li&gt;
&lt;li data-end=&quot;392&quot; data-start=&quot;366&quot;&gt;결과 타입은 &lt;b&gt;항상 boolean&lt;/b&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;478&quot; data-start=&quot;442&quot; data-ke-size=&quot;size23&quot;&gt;!!value : boolean으로 강제 변환(정규화)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;552&quot; data-start=&quot;479&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;523&quot; data-start=&quot;479&quot;&gt;!를 두 번 적용해서 뒤집고 다시 뒤집음 &amp;rarr; &amp;ldquo;참/거짓 여부만&amp;rdquo; 남긴다.&lt;/li&gt;
&lt;li data-end=&quot;552&quot; data-start=&quot;524&quot;&gt;결과는 무조건 true 또는 false.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;h2 data-end=&quot;722&quot; data-start=&quot;694&quot; data-ke-size=&quot;size26&quot;&gt;2) truthy / falsy 한 번에 정리&lt;/h2&gt;
&lt;p data-end=&quot;771&quot; data-start=&quot;724&quot; data-ke-size=&quot;size16&quot;&gt;JS에서 조건문은 boolean이 아니어도 동작하고, 값은 크게 두 종류로 평가된다.&lt;/p&gt;
&lt;h3 data-end=&quot;799&quot; data-start=&quot;773&quot; data-ke-size=&quot;size23&quot;&gt;falsy (조건문에서 false 취급)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;883&quot; data-start=&quot;800&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;809&quot; data-start=&quot;800&quot;&gt;false&lt;/li&gt;
&lt;li data-end=&quot;821&quot; data-start=&quot;810&quot;&gt;0, -0&lt;/li&gt;
&lt;li data-end=&quot;837&quot; data-start=&quot;822&quot;&gt;0n (BigInt)&lt;/li&gt;
&lt;li data-end=&quot;852&quot; data-start=&quot;838&quot;&gt;&quot;&quot; (빈 문자열)&lt;/li&gt;
&lt;li data-end=&quot;861&quot; data-start=&quot;853&quot;&gt;null&lt;/li&gt;
&lt;li data-end=&quot;875&quot; data-start=&quot;862&quot;&gt;undefined&lt;/li&gt;
&lt;li data-end=&quot;883&quot; data-start=&quot;876&quot;&gt;NaN&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;912&quot; data-start=&quot;885&quot; data-ke-size=&quot;size23&quot;&gt;truthy (그 외 전부 true 취급)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;993&quot; data-start=&quot;913&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;926&quot; data-start=&quot;913&quot;&gt;&quot;0&quot; (문자열)&lt;/li&gt;
&lt;li data-end=&quot;944&quot; data-start=&quot;927&quot;&gt;&quot;false&quot; (문자열)&lt;/li&gt;
&lt;li data-end=&quot;958&quot; data-start=&quot;945&quot;&gt;[] (빈 배열)&lt;/li&gt;
&lt;li data-end=&quot;972&quot; data-start=&quot;959&quot;&gt;{} (빈 객체)&lt;/li&gt;
&lt;li data-end=&quot;993&quot; data-start=&quot;973&quot;&gt;function() {} 등&amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;1025&quot; data-start=&quot;995&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1025&quot; data-start=&quot;997&quot; data-ke-size=&quot;size16&quot;&gt;포인트: &lt;b&gt;빈 배열/빈 객체도 truthy&lt;/b&gt;다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-end=&quot;1054&quot; data-start=&quot;1032&quot; data-ke-size=&quot;size26&quot;&gt;3) !!를 쓰는 이유 (장점)&lt;/h2&gt;
&lt;h3 data-end=&quot;1081&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size23&quot;&gt;1) 타입을 boolean으로 &amp;ldquo;고정&amp;rdquo;&lt;/h3&gt;
&lt;p data-end=&quot;1163&quot; data-start=&quot;1082&quot; data-ke-size=&quot;size16&quot;&gt;설정값이 1, &quot;Y&quot;, 객체 같은 형태로 들어올 수 있을 때도&lt;br /&gt;한 번 !!로 &lt;b&gt;플래그 형태(booleans)로 정규화&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;h3 data-end=&quot;1263&quot; data-start=&quot;1241&quot; data-ke-size=&quot;size23&quot;&gt;2) 조건문/비교에서 실수 줄이기&lt;/h3&gt;
&lt;p data-end=&quot;1300&quot; data-start=&quot;1264&quot; data-ke-size=&quot;size16&quot;&gt;아래처럼 엄격 비교를 하거나, 다른 함수에 넘길 때도 안정적이다.&lt;/p&gt;
&lt;h3 data-end=&quot;1396&quot; data-start=&quot;1371&quot; data-ke-size=&quot;size23&quot;&gt;3) &amp;ldquo;없으면 false&amp;rdquo; 기본값 처리&lt;/h3&gt;
&lt;p data-end=&quot;1461&quot; data-start=&quot;1397&quot; data-ke-size=&quot;size16&quot;&gt;값이 없으면 undefined &amp;rarr; !!undefined는 false라서&lt;br /&gt;기본 OFF 동작을 쉽게 만든다.&lt;/p&gt;
&lt;p data-end=&quot;1461&quot; data-start=&quot;1397&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1508&quot; data-start=&quot;1468&quot; data-ke-size=&quot;size26&quot;&gt;4) BUT: !!가 &amp;ldquo;정확한 의미&amp;rdquo;를 보장하진 않는다 (주의)&lt;/h2&gt;
&lt;p data-end=&quot;1562&quot; data-start=&quot;1510&quot; data-ke-size=&quot;size16&quot;&gt;!!는 &lt;b&gt;값의 의미를 해석하지 않고&lt;/b&gt;, JS의 truthy/falsy 규칙만 적용한다.&lt;/p&gt;
&lt;p data-end=&quot;1710&quot; data-start=&quot;1705&quot; data-ke-size=&quot;size16&quot;&gt;✅ 결론:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1790&quot; data-start=&quot;1711&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1734&quot; data-start=&quot;1711&quot;&gt;!!는 &lt;b&gt;타입 일관성&lt;/b&gt;에는 도움&lt;/li&gt;
&lt;li data-end=&quot;1790&quot; data-start=&quot;1735&quot;&gt;하지만 &quot;false&quot;, &quot;0&quot; 같은 값이 들어오면 &lt;b&gt;의도와 다른 결과&lt;/b&gt;가 될 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;2619&quot; data-start=&quot;2607&quot; data-ke-size=&quot;size26&quot;&gt;5) 한 줄 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2794&quot; data-start=&quot;2621&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2670&quot; data-start=&quot;2621&quot;&gt;!!value는 &lt;b&gt;값을 boolean(true/false)로 강제 변환&lt;/b&gt;한다.&lt;/li&gt;
&lt;li data-end=&quot;2702&quot; data-start=&quot;2671&quot;&gt;조건문에서 &lt;b&gt;타입 혼란을 줄여주는 장점&lt;/b&gt;이 있다.&lt;/li&gt;
&lt;li data-end=&quot;2794&quot; data-start=&quot;2703&quot;&gt;하지만 &quot;false&quot; 같은 문자열은 truthy라서 &lt;b&gt;의도와 다른 결과&lt;/b&gt;가 날 수 있어,&lt;br /&gt;입력이 문자열일 가능성이 있으면 &lt;b&gt;명시적으로 파싱&lt;/b&gt;하자.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/163</guid>
      <comments>https://new-crystal.tistory.com/163#entry163comment</comments>
      <pubDate>Mon, 19 Jan 2026 12:53:41 +0900</pubDate>
    </item>
    <item>
      <title>2.0 + 4.4 + 6.2 + 2.4 = 15.000000000000002 이 되는 자바스크립트의 부동소수점 오차</title>
      <link>https://new-crystal.tistory.com/162</link>
      <description>&lt;h2 data-end=&quot;159&quot; data-start=&quot;104&quot; data-ke-size=&quot;size26&quot;&gt;1. 왜 2.0 + 4.4 + 6.2 + 2.4 이 15.000000000000002가 될까?&lt;/h2&gt;
&lt;p data-end=&quot;224&quot; data-start=&quot;161&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트에서 숫자는 대부분 &lt;b&gt;IEEE 754 64-bit 부동소수점&lt;/b&gt; 형식으로 저장돼요.&lt;br /&gt;쉽게 말하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;346&quot; data-start=&quot;226&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;346&quot; data-start=&quot;226&quot;&gt;고정된 64개의 비트 안에
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;346&quot; data-start=&quot;245&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;259&quot; data-start=&quot;245&quot;&gt;부호(sign) 1비트&lt;/li&gt;
&lt;li data-end=&quot;281&quot; data-start=&quot;262&quot;&gt;지수(exponent) 11비트&lt;/li&gt;
&lt;li data-end=&quot;346&quot; data-start=&quot;284&quot;&gt;가수(fraction, mantissa) 52비트&lt;br /&gt;이렇게 나눠서 **&amp;ldquo;2진수 실수&amp;rdquo;**를 표현합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;409&quot; data-start=&quot;348&quot; data-ke-size=&quot;size16&quot;&gt;문제는 우리가 사용하는 &lt;b&gt;10진수 소수&lt;/b&gt;가&lt;br /&gt;**2진수로 &amp;ldquo;딱 떨어지지 않는 경우가 많다&amp;rdquo;**는 겁니다.&lt;/p&gt;
&lt;p data-end=&quot;417&quot; data-start=&quot;411&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;462&quot; data-start=&quot;419&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;431&quot; data-start=&quot;419&quot;&gt;1/10 (0.1)&lt;/li&gt;
&lt;li data-end=&quot;444&quot; data-start=&quot;432&quot;&gt;2/10 (0.2)&lt;/li&gt;
&lt;li data-end=&quot;462&quot; data-start=&quot;445&quot;&gt;4.4, 6.2, 2.4 &amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;545&quot; data-start=&quot;464&quot; data-ke-size=&quot;size16&quot;&gt;이런 수들은 2진수로 표현하면 &lt;b&gt;무한히 반복되는 소수&lt;/b&gt;가 됩니다.&lt;br /&gt;10진수에서 1/3 = 0.33333... 끝없이 반복되는 것처럼요.&lt;/p&gt;
&lt;p data-end=&quot;604&quot; data-start=&quot;547&quot; data-ke-size=&quot;size16&quot;&gt;컴퓨터는 52비트까지만 저장할 수 있으니&lt;br /&gt;중간에 &lt;b&gt;잘라서(반올림해서) 저장&lt;/b&gt;할 수밖에 없습니다.&lt;/p&gt;
&lt;p data-end=&quot;615&quot; data-start=&quot;606&quot; data-ke-size=&quot;size16&quot;&gt;그래서 실제로는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;738&quot; data-start=&quot;617&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;663&quot; data-start=&quot;617&quot;&gt;4.4 &amp;rarr; 내부적으로는 4.400000000000000355... 비슷한 값&lt;/li&gt;
&lt;li data-end=&quot;738&quot; data-start=&quot;664&quot;&gt;6.2 &amp;rarr; 6.200000000000000177... 비슷한 값&lt;br /&gt;이런 식의 &lt;b&gt;아주 살짝 틀어진 값&lt;/b&gt;으로 저장됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;941&quot; data-start=&quot;897&quot; data-ke-size=&quot;size26&quot;&gt;2. &amp;ldquo;콘솔에는 4.4로 보이는데?&amp;rdquo; &amp;rarr; 출력할 때 깎아서 보여줘서 그래요&lt;/h2&gt;
&lt;p data-end=&quot;1014&quot; data-start=&quot;943&quot; data-ke-size=&quot;size16&quot;&gt;console.log(4.4)를 찍으면 브라우저는&lt;br /&gt;&lt;b&gt;사람이 보기 좋게 적당한 자리까지만&lt;/b&gt; 문자열로 만들어 보여줍니다.&lt;/p&gt;
&lt;p data-end=&quot;1088&quot; data-start=&quot;1016&quot; data-ke-size=&quot;size16&quot;&gt;실제 내부 값은 4.400000000000000355...인데&lt;br /&gt;보여줄 때는 4.4로 &lt;b&gt;반올림해서 출력&lt;/b&gt;하는 거예요.&lt;/p&gt;
&lt;p data-end=&quot;1173&quot; data-start=&quot;1090&quot; data-ke-size=&quot;size16&quot;&gt;하지만 덧셈을 하면 &lt;b&gt;진짜 저장된 값끼리&lt;/b&gt; 계산하기 때문에&lt;br /&gt;오차가 눈에 보이는 숫자로 튀어나오는 거죠 &amp;rarr; 15.000000000000002&lt;/p&gt;
&lt;hr data-end=&quot;1178&quot; data-start=&quot;1175&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1201&quot; data-start=&quot;1180&quot; data-ke-size=&quot;size26&quot;&gt;3. 부동소수점의 핵심 개념 정리&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1541&quot; data-start=&quot;1203&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1275&quot; data-start=&quot;1203&quot;&gt;&lt;b&gt;2진수 기반 실수 표현&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1275&quot; data-start=&quot;1226&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1275&quot; data-start=&quot;1226&quot;&gt;컴퓨터는 2진수만 다룰 수 있어서&lt;br /&gt;실수도 &amp;ldquo;2진수 소수&amp;rdquo;로 표현해야 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1357&quot; data-start=&quot;1276&quot;&gt;&lt;b&gt;표현 가능한 숫자가 한정됨&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1357&quot; data-start=&quot;1301&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1357&quot; data-start=&quot;1301&quot;&gt;64비트 안에서만 표현해야 하므로&lt;br /&gt;대부분의 10진수 소수는 &lt;b&gt;근사값&lt;/b&gt;으로 저장됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1471&quot; data-start=&quot;1358&quot;&gt;&lt;b&gt;근사값의 합 = 또 다른 근사값&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1471&quot; data-start=&quot;1386&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1471&quot; data-start=&quot;1386&quot;&gt;오차들이 더해지고 빼이다 보면&lt;br /&gt;어느 순간 0.30000000000000004, 15.000000000000002 같은 결과 등장.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1541&quot; data-start=&quot;1472&quot;&gt;자바스크립트, 파이썬, 자바, C 등 거의 모든 언어가&lt;br /&gt;기본 실수 타입으로 같은 방식을 씀(IEEE 754).&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-end=&quot;1572&quot; data-start=&quot;1548&quot; data-ke-size=&quot;size26&quot;&gt;4. 그래서 실무에서 어떻게 처리하나?&lt;/h2&gt;
&lt;h3 data-end=&quot;1603&quot; data-start=&quot;1574&quot; data-ke-size=&quot;size23&quot;&gt;1) 표시할 때 반올림해서 보여주기 (UI용)&lt;/h3&gt;
&lt;p data-end=&quot;1633&quot; data-start=&quot;1605&quot; data-ke-size=&quot;size16&quot;&gt;점수처럼 &lt;b&gt;소수점 한 자리까지&lt;/b&gt;면 충분한 경우:&lt;/p&gt;
&lt;pre id=&quot;code_1763706227406&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const sum = Number(value1) + Number(value2) + Number(value3) + Number(value4);

// 소수 1자리까지 반올림해서 숫자로
const rounded = Math.round(sum * 10) / 10;
sumTd.innerText = rounded;

// 항상 한 자리까지 문자열로 보여주고 싶으면
sumTd.innerText = rounded.toFixed(1); // &quot;15.0&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1932&quot; data-start=&quot;1902&quot; data-ke-size=&quot;size23&quot;&gt;2) 정수로 바꿔서 계산하기 (고정소수점 느낌)&lt;/h3&gt;
&lt;p data-end=&quot;1995&quot; data-start=&quot;1934&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 점수가 항상 &amp;ldquo;소수 첫째 자리까지&amp;rdquo;라면&lt;br /&gt;&lt;b&gt;10을 곱해서 정수로&lt;/b&gt; 계산하는 방법도 자주 써요.&lt;/p&gt;
&lt;pre id=&quot;code_1763706252790&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function toInt10(v) {
  return Math.round(Number(v) * 10); // 예: 4.4 -&amp;gt; 44
}

const sum10 =
  toInt10(value1) +
  toInt10(value2) +
  toInt10(value3) +
  toInt10(value4);

const sum = sum10 / 10; // 다시 10으로 나누기
sumTd.innerText = sum.toFixed(1);  // &quot;15.0&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;2315&quot; data-start=&quot;2272&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 내부적으로는 정수 덧셈이라&lt;br /&gt;부동소수점 오차가 훨씬 줄어듭니다.&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/162</guid>
      <comments>https://new-crystal.tistory.com/162#entry162comment</comments>
      <pubDate>Fri, 21 Nov 2025 15:24:38 +0900</pubDate>
    </item>
    <item>
      <title>변수 선언하지 않고 JS에서 HTML 요소 사용할 수 있다?</title>
      <link>https://new-crystal.tistory.com/161</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황&lt;/h2&gt;
&lt;pre id=&quot;code_1758848226124&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; &amp;lt;button  class=&quot;w-[150px] h-[40px] bg-indigo-950 mt-20 mb-20 hover:bg-slate-300 active:bg-slate-300 text-white&quot; type=&quot;button&quot; id=&quot;faculty_btn&quot;&amp;gt;페컬티정보조회&amp;lt;/button&amp;gt;

 faculty_btn.addEventListener(&quot;click&quot;, ()=&amp;gt;{
      //... 동작
    })&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;faculty_btn이 변수로 선언하지 않고 스크립트에서 동작하고 있는 걸 발견함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상황이 신기해서 찾아봄&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 설명&lt;/h2&gt;
&lt;h2 data-end=&quot;88&quot; data-start=&quot;71&quot; data-ke-size=&quot;size26&quot;&gt;1) 메커니즘: 왜 되는가&lt;/h2&gt;
&lt;p data-end=&quot;385&quot; data-start=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;브라우저는 문서를 파싱할 때, id(그리고 일부 태그의 name)가 있는 요소들을 **window 객체의 &amp;ldquo;이름 있는 프로퍼티(named properties)&amp;rdquo;**로 노출합니다. 그래서 id=&quot;faculty_btn&quot; 이 있으면 window.faculty_btn이 생기고, 일반 스크립트에선 전역 식별자처럼 faculty_btn으로 바로 접근이 됩니다. 이를 **&amp;ldquo;Named access on the Window object&amp;rdquo;**라고 합니다. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;span&gt;+2&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;MDN 웹 문서&lt;/span&gt;&lt;span&gt;+2&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;647&quot; data-start=&quot;387&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;490&quot; data-start=&quot;387&quot;&gt;모든 &lt;b&gt;HTML 요소의 id&lt;/b&gt; 값은 전역으로 노출됩니다. (예: window.faculty_btn) &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/id?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;MDN 웹 문서&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;647&quot; data-start=&quot;491&quot;&gt;&lt;b&gt;name 속성&lt;/b&gt;은 일부 태그(form, iframe, img, object, embed)에 한해 전역으로 노출됩니다. (예: &amp;lt;form name=&quot;f&quot;&amp;gt; &amp;rarr; window.f) &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;MDN 웹 문서&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;726&quot; data-start=&quot;649&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;726&quot; data-start=&quot;651&quot; data-ke-size=&quot;size16&quot;&gt;즉, 지금 코드가 &amp;ldquo;변수 선언 없이&amp;rdquo; 돌아간 건 &lt;b&gt;브라우저가 자동으로 window.faculty_btn을 제공&lt;/b&gt;했기 때문이에요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;731&quot; data-start=&quot;728&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;757&quot; data-start=&quot;733&quot; data-ke-size=&quot;size26&quot;&gt;2) 언제 되지 않나 (깨지는 조건들)&lt;/h2&gt;
&lt;p data-end=&quot;802&quot; data-start=&quot;758&quot; data-ke-size=&quot;size16&quot;&gt;이 동작은 &amp;ldquo;레거시 편의 기능&amp;rdquo;이라 &lt;b&gt;상황에 따라 바로 깨질 수&lt;/b&gt; 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1412&quot; data-start=&quot;804&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1013&quot; data-start=&quot;804&quot;&gt;&lt;b&gt;ES 모듈(&amp;lt;script type=&quot;module&quot;&amp;gt;)&lt;/b&gt;: 모듈의 최상위 스코프는 모듈 스코프이므로, **맨바닥 식별자 faculty_btn은 ReferenceError**가 납니다. (대신 window.faculty_btn은 존재할 수 있지만, &lt;b&gt;맨바닥 식별자&lt;/b&gt;로는 접근 안 됨) &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://eslint.org/docs/latest/rules/no-implicit-globals?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;eslint.org&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1109&quot; data-start=&quot;1014&quot;&gt;&lt;b&gt;파싱 순서&lt;/b&gt;: 스크립트가 요소 &lt;b&gt;보다 먼저 실행&lt;/b&gt;되면 그 시점엔 아직 window.faculty_btn이 없으므로 실패합니다. (DOM 생성 후에만 생김)&lt;/li&gt;
&lt;li data-end=&quot;1257&quot; data-start=&quot;1110&quot;&gt;&lt;b&gt;이름 충돌&lt;/b&gt;: 전역의 다른 속성/함수 이름과 겹치면(예: open, name 등) &lt;b&gt;헷갈리거나 접근 자체가 달라질&lt;/b&gt; 수 있습니다. (이 때문에 사양도 &amp;ldquo;의존하지 말라&amp;rdquo;고 경고) &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1412&quot; data-start=&quot;1258&quot;&gt;&lt;b&gt;중복 id&lt;/b&gt;(유효하지 않은 HTML): 같은 id가 여러 개면, 사양상 그 이름으로 접근하면 **단일 요소가 아닌 HTMLCollection**이 반환될 수 있어 코드가 깨질 수 있습니다. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1417&quot; data-start=&quot;1414&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1446&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size26&quot;&gt;3) 이건 &amp;lsquo;변수&amp;rsquo;가 아니다 (미묘한 차이)&lt;/h2&gt;
&lt;p data-end=&quot;1530&quot; data-start=&quot;1447&quot; data-ke-size=&quot;size16&quot;&gt;faculty_btn은 let/const/var로 만든 &lt;b&gt;변수 바인딩&lt;/b&gt;이 아니라, &lt;b&gt;window의 동적 프로퍼티&lt;/b&gt;에 더 가깝습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2008&quot; data-start=&quot;1532&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2008&quot; data-start=&quot;1532&quot;&gt;예시:즉, &lt;b&gt;맨바닥 식별자 해석 규칙&lt;/b&gt;과 window 프로퍼티가 섞여 동작하기 때문에, 유지보수/도구(린터&amp;middot;번들러) 관점에서 취약해집니다. 사양도 &amp;ldquo;이 방식에 의존하면 깨지기 쉽다(brittle)&amp;rdquo;고 못박고 있어요. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1846&quot; data-start=&quot;1540&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;// HTML 어딘가에 &amp;lt;button id=&quot;faculty_btn&quot;&amp;gt;&amp;lt;/button&amp;gt; 존재&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;console&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;log&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;window&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;faculty_btn&lt;/span&gt;&lt;/span&gt;&lt;span&gt; === &lt;/span&gt;&lt;span&gt;&lt;span&gt;document&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;getElementById&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;'faculty_btn'&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)); &lt;/span&gt;&lt;span&gt;&lt;span&gt;// true&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;let&lt;/span&gt;&lt;/span&gt;&lt;span&gt; faculty_btn = &lt;/span&gt;&lt;span&gt;&lt;span&gt;123&lt;/span&gt;&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;&lt;span&gt;console&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;log&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(faculty_btn); &lt;/span&gt;&lt;span&gt;&lt;span&gt;// 123 (이제는 '변수'가 우선)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;console&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;log&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;window&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;faculty_btn&lt;/span&gt;&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;&lt;span&gt;// &amp;lt;button ...&amp;gt; (여전히 window엔 요소가 존재)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;2013&quot; data-start=&quot;2010&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2042&quot; data-start=&quot;2015&quot; data-ke-size=&quot;size26&quot;&gt;4) 보안 측면(DOM Clobbering)&lt;/h2&gt;
&lt;p data-end=&quot;2288&quot; data-start=&quot;2043&quot; data-ke-size=&quot;size16&quot;&gt;이 기능을 악용해, 공격자가 특정 id/name으로 요소를 삽입해 전역 변수/함수를 &lt;b&gt;덮어쓰게(clobber)&lt;/b&gt; 만드는 기법이 있습니다. 예컨대 어떤 코드가 전역 config를 기대할 때, &amp;lt;a id=&quot;config&quot; href=...&amp;gt; 같은 마크업으로 &lt;b&gt;흐름을 교란&lt;/b&gt;할 수 있습니다. 그래서 보안 가이드들도 &lt;b&gt;이 기능에 의존하지 말라&lt;/b&gt;고 권고해요. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/DOM_Clobbering_Prevention_Cheat_Sheet.html?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;OWASP Cheat Sheet Series&lt;/span&gt;&lt;span&gt;+1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr data-end=&quot;2293&quot; data-start=&quot;2290&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2319&quot; data-start=&quot;2295&quot; data-ke-size=&quot;size26&quot;&gt;5) 실무 권장 패턴 (당장 적용 팁)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2340&quot; data-start=&quot;2320&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2340&quot; data-start=&quot;2320&quot;&gt;&lt;b&gt;항상 명시적으로 선택&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;document&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;addEventListener&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;&quot;DOMContentLoaded&quot;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;() =&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;/span&gt;&lt;span&gt; facultyBtn = &lt;/span&gt;&lt;span&gt;&lt;span&gt;document&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;getElementById&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;&quot;faculty_btn&quot;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;&lt;span&gt;// ✅&lt;/span&gt;&lt;/span&gt;&lt;span&gt; facultyBtn?.&lt;/span&gt;&lt;span&gt;&lt;span&gt;addEventListener&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;&quot;click&quot;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;() =&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;&lt;span&gt;/* ... */&lt;/span&gt;&lt;/span&gt;&lt;span&gt; }); }); &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2870&quot; data-start=&quot;2540&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2645&quot; data-start=&quot;2540&quot;&gt;&lt;b&gt;헷갈리는 전역 이름 피하기&lt;/b&gt;&lt;br /&gt;open, name, status 같은 전역과 겹치는 id를 피하고, 변수명도 openBtn, nameInput처럼 구체적으로.&lt;/li&gt;
&lt;li data-end=&quot;2696&quot; data-start=&quot;2647&quot;&gt;&lt;b&gt;중복 id 금지&lt;/b&gt;&lt;br /&gt;유효한 HTML 유지(한 문서에 같은 id는 1개).&lt;/li&gt;
&lt;li data-end=&quot;2870&quot; data-start=&quot;2698&quot;&gt;&lt;b&gt;모듈을 쓰면 더 안전&lt;/b&gt;&lt;br /&gt;점진적으로 &amp;lt;script type=&quot;module&quot;&amp;gt;로 옮기면 &amp;ldquo;맨바닥 식별자 = 전역&amp;rdquo; 패턴이 차단되어 버그가 줄어듭니다. (필요 시 window.faculty_btn처럼 &lt;b&gt;명시적&lt;/b&gt;으로 접근) &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://eslint.org/docs/latest/rules/no-implicit-globals?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;eslint.org&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-end=&quot;2875&quot; data-start=&quot;2872&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2885&quot; data-start=&quot;2877&quot; data-ke-size=&quot;size26&quot;&gt;핵심 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3195&quot; data-start=&quot;2886&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2983&quot; data-start=&quot;2886&quot;&gt;&lt;b&gt;왜 되나?&lt;/b&gt; id가 자동으로 window의 &lt;b&gt;이름 있는 프로퍼티&lt;/b&gt;로 노출되기 때문. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;span&gt;+1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3081&quot; data-start=&quot;2984&quot;&gt;&lt;b&gt;하지만&lt;/b&gt; 모듈/파싱순서/이름충돌/중복id 등에서 &lt;b&gt;쉽게 깨지고&lt;/b&gt;, 보안 리스크도 있다. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://html.spec.whatwg.org/multipage/nav-history-apis.html&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;HTML Living Standard&lt;/span&gt;&lt;span&gt;+1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;3195&quot; data-start=&quot;3082&quot;&gt;&lt;b&gt;그래서&lt;/b&gt; 실전에서는 &lt;b&gt;항상 getElementById/querySelector로 변수 선언&lt;/b&gt;해서 쓰는 게 정석. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window?utm_source=chatgpt.com&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;MDN 웹 문서&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3287&quot; data-start=&quot;3197&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/161</guid>
      <comments>https://new-crystal.tistory.com/161#entry161comment</comments>
      <pubDate>Fri, 26 Sep 2025 09:58:46 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript - Pinch Zoom 보완하기</title>
      <link>https://new-crystal.tistory.com/160</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 코드&amp;nbsp;&lt;/h3&gt;
&lt;pre id=&quot;code_1753346620857&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;title&amp;gt;PinchZoom Demo&amp;lt;/title&amp;gt;
  &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0, user-scalable=no&quot;&amp;gt;
  &amp;lt;style&amp;gt;
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      overflow: hidden;
    }

    .pinch-zoom-container {
      width: 500px;
      height: 500px;
      position: relative;
      overflow: hidden;
      border: 2px solid salmon;
    }

    .pinch-zoom img {
      width: 100%;
      height: auto;
      display: block;
      position: absolute;
      top: 0;
      left: 0;
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div class=&quot;pinch-zoom-container&quot; id=&quot;screen&quot;&amp;gt;
    &amp;lt;div class=&quot;pinch-zoom&quot;&amp;gt;
      &amp;lt;img src=&quot;./kitten-tall.jpg&quot; alt=&quot;Zoomable&quot; id=&quot;target&quot;&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;script&amp;gt;
const target = document.getElementById('target');
const screen = document.getElementById('screen');
touchInit(screen, target);

function touchInit(screen, target) {
  const state = { x: 0, y: 0, scale: 1 };

 const setState = ({ x, y, scale }) =&amp;gt; {
    const containerRect = screen.getBoundingClientRect();
    const containerWidth = containerRect.width;
    const containerHeight = containerRect.height;

    const img = target;
    const imgRatio = img.naturalWidth / img.naturalHeight;

    const baseWidth = containerWidth;
    const baseHeight = containerWidth / imgRatio;

    const scaledWidth = baseWidth * scale;
    const scaledHeight = baseHeight * scale;

    let clampedX = x;
    let clampedY = y;

    if (scaledWidth &amp;lt;= containerWidth) {
        clampedX = (containerWidth - scaledWidth) / 2;
    } else {
        const minX = containerWidth - scaledWidth;
        clampedX = Math.min(0, Math.max(minX, x));
    }

    if (scaledHeight &amp;lt;= containerHeight) {
        clampedY = (containerHeight - scaledHeight) / 2;
    } else {
        const minY = containerHeight - scaledHeight;
        clampedY = Math.min(0, Math.max(minY, y));
    }

    state.x = clampedX;
    state.y = clampedY;
    state.scale = scale;

    target.style.transform = `translate(${clampedX}px, ${clampedY}px) scale(${scale})`;
    target.style.transformOrigin = '0 0';
    };


  const getState = () =&amp;gt; state;

  pinchZoomAndDrag({ screen, setState, getState });
}

function pinchZoomAndDrag({ screen, setState, getState }) {
  let isDragging = false;
  let lastTouch = null;

  screen.addEventListener('touchstart', (ev) =&amp;gt; {
    if (ev.touches.length === 1 &amp;amp;&amp;amp; getState().scale &amp;gt; 1) {
      isDragging = true;
      lastTouch = ev.touches[0];
    } else {
      isDragging = false;
    }
    touchStartHandler(ev);
  });

  screen.addEventListener('touchmove', (ev) =&amp;gt; {
    if (isDragging &amp;amp;&amp;amp; ev.touches.length === 1) {
      const current = ev.touches[0];
      const dx = current.clientX - lastTouch.clientX;
      const dy = current.clientY - lastTouch.clientY;
      const { x, y, scale } = getState();
      setState({ x: x + dx, y: y + dy, scale });
      lastTouch = current;
      ev.preventDefault();
    } else {
      touchMoveHandler(ev, (info) =&amp;gt; handlePinch(info, setState, getState));
    }
  });

  screen.addEventListener('touchend', (ev) =&amp;gt; {
    isDragging = false;
    touchEndHandler(ev);
  });

  screen.addEventListener('touchcancel', (ev) =&amp;gt; {
    isDragging = false;
    touchEndHandler(ev);
  });
}

function handlePinch({ zoom, centerX, centerY }, setState, getState) {
  if (zoom === 0) return;

  const { x, y, scale } = getState();
  const zoomWeight = 0.018;
  const nextScale = scale + (zoom &amp;gt; 0 ? zoomWeight : -zoomWeight);
  const clampedScale = Math.min(Math.max(nextScale, 1), 3);

  const biasX = ((centerX - x) * (clampedScale / scale)) - (centerX - x);
  const biasY = ((centerY - y) * (clampedScale / scale)) - (centerY - y);

  const nextX = x - biasX;
  const nextY = y - biasY;

  setState({ x: nextX, y: nextY, scale: clampedScale });
}

let prevDiff = -1;
const evHistory = [];
let requestId = null;

function touchStartHandler(ev) {
  const touches = ev.changedTouches;
  if (evHistory.length + touches.length &amp;lt;= 2) {
    for (let i = 0; i &amp;lt; touches.length; i++) {
      evHistory.push(touches[i]);
    }
  }
}

function touchEndHandler(ev) {
  const touches = ev.changedTouches;
  for (let i = 0; i &amp;lt; touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.findIndex(cached =&amp;gt; cached.identifier === touch.identifier);
    if (index &amp;gt; -1) evHistory.splice(index, 1);
  }
  if (evHistory.length &amp;lt; 2) prevDiff = -1;
}

function touchMoveHandler(ev, onPinch) {
  if (ev.cancelable &amp;amp;&amp;amp; evHistory.length === 2) ev.preventDefault();

  const touches = ev.changedTouches;
  for (let i = 0; i &amp;lt; touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.findIndex(cached =&amp;gt; cached.identifier === touch.identifier);
    if (index !== -1) {
      evHistory[index] = touch;

      if (
        evHistory.length === 2 &amp;amp;&amp;amp;
        evHistory[0] &amp;amp;&amp;amp; evHistory[1] &amp;amp;&amp;amp;
        typeof evHistory[0].clientX === 'number' &amp;amp;&amp;amp;
        typeof evHistory[1].clientX === 'number'
      ) {
        const xDiff = evHistory[0].clientX - evHistory[1].clientX;
        const yDiff = evHistory[0].clientY - evHistory[1].clientY;
        const curDiff = Math.sqrt(xDiff * xDiff + yDiff * yDiff);

        if (prevDiff &amp;gt; 0) {
          const zoom = curDiff - prevDiff;

          if (!requestId) {
            requestId = requestAnimationFrame(() =&amp;gt; {
              if (
                evHistory[0] &amp;amp;&amp;amp; evHistory[1] &amp;amp;&amp;amp;
                typeof evHistory[0].clientX === 'number' &amp;amp;&amp;amp;
                typeof evHistory[1].clientX === 'number'
              ) {
                const x = (evHistory[0].clientX + evHistory[1].clientX) / 2;
                const y = (evHistory[0].clientY + evHistory[1].clientY) / 2;

                const { top, left } = target.getBoundingClientRect();
                const newX = x - left;
                const newY = y - top;

                onPinch({ zoom, centerX: newX, centerY: newY });
              }
              requestId = null;
            });
          }
        }
        prevDiff = curDiff;
      }
    }
  }
}
&amp;lt;/script&amp;gt;

&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;kakaotv&quot; data-video-url=&quot;https://tv.kakao.com/v/456783368&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/bdSYlJ/hyZqStv9eN/zrxzDV1vre8kCV0PL5cruK/img.jpg?width=576&amp;amp;height=588&amp;amp;face=0_0_576_588,https://scrap.kakaocdn.net/dn/bsZm0N/hyZq1xeeGf/LkKnOSoKTjHRjVWeJhqApK/img.jpg?width=576&amp;amp;height=588&amp;amp;face=0_0_576_588&quot; data-video-width=&quot;576&quot; data-video-height=&quot;588&quot; data-video-origin-width=&quot;576&quot; data-video-origin-height=&quot;588&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-title=&quot;'김 양의 멋따라 개발따기'에서 업로드한 동영상&quot; data-video-play-service=&quot;daum_tistory&quot; data-original-url=&quot;&quot;&gt;&lt;iframe src=&quot;https://play-tv.kakao.com/embed/player/cliplink/456783368?service=daum_tistory&quot; width=&quot;576&quot; height=&quot;588&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/160</guid>
      <comments>https://new-crystal.tistory.com/160#entry160comment</comments>
      <pubDate>Thu, 24 Jul 2025 17:44:16 +0900</pubDate>
    </item>
    <item>
      <title>집에 가고 싶을 때 퇴근 타이머 만들기</title>
      <link>https://new-crystal.tistory.com/159</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;퇴근이 너무 하고 싶어서 만든 18시 퇴근 기준 퇴근 타이머 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1752220079560&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;title&amp;gt;퇴근 타이머&amp;lt;/title&amp;gt;
  &amp;lt;style&amp;gt;
    body {
      font-family: 'Pretendard', sans-serif;
      text-align: center;
      margin-top: 100px;
      background-color: #f2f2f2;
    }
    #timer {
      font-size: 48px;
      color: #333;
    }
    #message {
      margin-top: 30px;
      font-size: 24px;
      color: green;
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;h1&amp;gt;퇴근까지 남은 시간 ⏰&amp;lt;/h1&amp;gt;
  &amp;lt;div id=&quot;timer&quot;&amp;gt;계산 중...&amp;lt;/div&amp;gt;
  &amp;lt;div id=&quot;message&quot;&amp;gt;&amp;lt;/div&amp;gt;

  &amp;lt;script&amp;gt;
    //   퇴근 시간 설정 (오후 6시)
    const endHour = 18;
    const endMinute = 0;

    function updateTimer() {
      const now = new Date();
      const end = new Date();

      end.setHours(endHour, endMinute, 0, 0); // 오늘 18:00:00

      const diff = end - now;

      if (diff &amp;lt;= 0) {
        document.getElementById(&quot;timer&quot;).innerText = &quot;00:00:00&quot;;
        document.getElementById(&quot;message&quot;).innerText = &quot;✨ 퇴근하세요! ✨&quot;;
        return;
      }

      const hours = String(Math.floor(diff / (1000 * 60 * 60))).padStart(2, &quot;0&quot;);
      const minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, &quot;0&quot;);
      const seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, &quot;0&quot;);

      document.getElementById(&quot;timer&quot;).innerText = `${hours}:${minutes}:${seconds}`;
    }

    setInterval(updateTimer, 1000);
    updateTimer(); // 즉시 실행
  &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과물&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 아직 한 시간 넘게 남았다....&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;467&quot; data-origin-height=&quot;257&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IUOMC/btsPffgmyoQ/J0DEtKseCmgvPhJ6MWHAy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IUOMC/btsPffgmyoQ/J0DEtKseCmgvPhJ6MWHAy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IUOMC/btsPffgmyoQ/J0DEtKseCmgvPhJ6MWHAy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIUOMC%2FbtsPffgmyoQ%2FJ0DEtKseCmgvPhJ6MWHAy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;467&quot; height=&quot;257&quot; data-origin-width=&quot;467&quot; data-origin-height=&quot;257&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/159</guid>
      <comments>https://new-crystal.tistory.com/159#entry159comment</comments>
      <pubDate>Fri, 11 Jul 2025 16:48:57 +0900</pubDate>
    </item>
    <item>
      <title>Index DB 사용해보기</title>
      <link>https://new-crystal.tistory.com/158</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Index DB&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Index DB 는 브라우저에 데이터를 &lt;b&gt;로컬 저장&lt;/b&gt;할 수 있는 API로, &lt;b&gt;오프라인 앱&lt;/b&gt;, &lt;b&gt;캐시&lt;/b&gt;, &lt;b&gt;장바구니 데이터 저장&lt;/b&gt;, PWA 등에 자주 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1b1b1b; text-align: start;&quot;&gt;IndexedDB는 사용자의 브라우저에 데이터를 영구적으로 저장할 수 있는 방법 중 하나입니다. IndexedDB를 사용하여 네트워크 상태에 상관없이 풍부한 쿼리 기능을 이용할 수 있는 웹 어플리케이션을 만들 수 있기 때문에, 여러분의 웹 어플리케이션은 온라인과 오프라인 환경에서 모두 동작할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1b1b1b; text-align: start;&quot;&gt;2.&amp;nbsp; CRUD&amp;nbsp;&lt;/span&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1750307292375&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. DB 생성
let db;
const request = indexedDB.open(&quot;MyAppDB&quot;, 1); // name, version

request.onupgradeneeded = function (event) {
  db = event.target.result;

  // object store 생성 (keyPath는 고유 키)
  const store = db.createObjectStore(&quot;users&quot;, { keyPath: &quot;id&quot; });

  // 인덱스 생성 (검색 편의용)
  store.createIndex(&quot;name&quot;, &quot;name&quot;, { unique: false });
};

request.onsuccess = function (event) {
  db = event.target.result;
  console.log(&quot;IndexedDB 연결 완료&quot;);
};

request.onerror = function (event) {
  console.error(&quot;IndexedDB 에러:&quot;, event.target.error);
};


// Create
function addUser(user) {
  const tx = db.transaction(&quot;users&quot;, &quot;readwrite&quot;);
  const store = tx.objectStore(&quot;users&quot;);
  const request = store.add(user); // user = { id: 1, name: &quot;철수&quot; }

  request.onsuccess = () =&amp;gt; console.log(&quot;사용자 추가 완료&quot;);
  request.onerror = () =&amp;gt; console.error(&quot;추가 실패&quot;);
}


//Read
function getUser(id) {
  const tx = db.transaction(&quot;users&quot;, &quot;readonly&quot;);
  const store = tx.objectStore(&quot;users&quot;);
  const request = store.get(id);

  request.onsuccess = () =&amp;gt; {
    console.log(&quot;조회된 사용자:&quot;, request.result);
  };
}


//Delete
function deleteUser(id) {
  const tx = db.transaction(&quot;users&quot;, &quot;readwrite&quot;);
  const store = tx.objectStore(&quot;users&quot;);
  store.delete(id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1b1b1b; text-align: start;&quot;&gt;출처 : &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1750307189676&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;IndexedDB 사용하기 - Web API | MDN&quot; data-og-description=&quot;IndexedDB는 사용자의 브라우저에 데이터를 영구적으로 저장할 수 있는 방법 중 하나입니다. IndexedDB를 사용하여 네트워크 상태에 상관없이 풍부한 쿼리 기능을 이용할 수 있는 웹 어플리케이션을 &quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&quot; data-og-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cq94py/hyZccMsNxO/bV56CYlaj6nD8Z3v6ERkMk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cq94py/hyZccMsNxO/bV56CYlaj6nD8Z3v6ERkMk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;IndexedDB 사용하기 - Web API | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;IndexedDB는 사용자의 브라우저에 데이터를 영구적으로 저장할 수 있는 방법 중 하나입니다. IndexedDB를 사용하여 네트워크 상태에 상관없이 풍부한 쿼리 기능을 이용할 수 있는 웹 어플리케이션을&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/158</guid>
      <comments>https://new-crystal.tistory.com/158#entry158comment</comments>
      <pubDate>Thu, 19 Jun 2025 13:28:16 +0900</pubDate>
    </item>
    <item>
      <title>CKEDITOR - 글자 크기 옵션 늘리기</title>
      <link>https://new-crystal.tistory.com/157</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CKEDITOR을 사용하던 중 글자 크기 옵션 늘려달라는 요청을 받음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 라이브러리 자체에 글자 크기를 직접 입력 받는 방식은 없었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션을 추가하여 글자 크기를 선택할 수 있도록 코드를 짬&lt;/p&gt;
&lt;pre id=&quot;code_1745818634153&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;///ckeditor/plugins/fontSizeInput/plugin.js

CKEDITOR.plugins.add('fontSizeInput', {
    init: function(editor) {
        editor.ui.addRichCombo('FontSizeInput', {
            label: '글자 크기',
            title: '글자 크기',
            toolbar: 'styles',
            panel: {
                css: [ editor.config.contentsCss ],
                multiSelect: false,
                attributes: { 'aria-label': 'Font Size' }
            },

            init: function() {
                // 기본 폰트 사이즈 옵션을 설정할 수도 있음
                this.startGroup('글자 크기');
                this.add('50px', '50px', '50px');
                this.add('54px', '54px', '54px');
                this.add('58px', '58px', '58px');
                this.add('60px', '60px', '60px');
                this.add('64px', '64px', '64px');
                this.add('68px', '68px', '68px');
            },

            onClick: function(value) {
                editor.applyStyle(new CKEDITOR.style({ element: 'span', attributes: { 'style': 'font-size:' + value } }));
            }
        });
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1745818670961&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//config.js

CKEDITOR.editorConfig = function( config ) {
	// Define changes to default configuration here. For example:
	// config.language = 'fr';
	// config.uiColor = '#AADC6E';
    config.enterMode = CKEDITOR.ENTER_BR;
	config.extraPlugins = 'fontSizeInput'; //외부 플러그인 추가!
	config.toolbarGroups = [
		{ name: 'document', groups: [ 'mode', 'document', 'doctools' ] },
		{ name: 'clipboard', groups: [ 'clipboard', 'undo' ] },
		{ name: 'editing', groups: [ 'find', 'selection', 'spellchecker', 'editing' ] },
		{ name: 'forms', groups: [ 'forms' ] },
		{ name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },
		{ name: 'paragraph', groups: [ 'list', 'indent', 'blocks', 'align', 'bidi', 'paragraph' ] },
		{ name: 'links', groups: [ 'links' ] },
		{ name: 'insert', groups: [ 'insert' ] },
		{ name: 'styles', groups: [ 'styles' ] },
		{ name: 'colors', groups: [ 'colors' ] },
		{ name: 'tools', groups: [ 'tools' ] },
		{ name: 'others', groups: [ 'others' ] },
		{ name: 'about', groups: [ 'about' ] }
	];

	config.removeButtons = 'Source,Save,Templates,NewPage,ExportPdf,Preview,Print,PasteFromWord,RemoveFormat,CopyFormatting,NumberedList,BulletedList,Indent,Outdent,Blockquote,BidiLtr,BidiRtl,Language,Flash';
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;246&quot; data-origin-height=&quot;202&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Bjxlq/btsNBK3RgHG/a9tkpZuCq1VUsI3EKgXqb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Bjxlq/btsNBK3RgHG/a9tkpZuCq1VUsI3EKgXqb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Bjxlq/btsNBK3RgHG/a9tkpZuCq1VUsI3EKgXqb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBjxlq%2FbtsNBK3RgHG%2Fa9tkpZuCq1VUsI3EKgXqb1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;246&quot; height=&quot;202&quot; data-origin-width=&quot;246&quot; data-origin-height=&quot;202&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;크기 옆에 글자 크기 옵션이 생겨 좀 더 세분화해서 글자 크기 선택 가능해짐!&lt;/p&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/157</guid>
      <comments>https://new-crystal.tistory.com/157#entry157comment</comments>
      <pubDate>Mon, 28 Apr 2025 14:38:52 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript - 핀치줌(Pinch Zoom) 구현하기</title>
      <link>https://new-crystal.tistory.com/156</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. html 코드&lt;/h2&gt;
&lt;pre id=&quot;code_1735273138444&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div id=&quot;screen&quot;&amp;gt;
     &amp;lt;img  id=&quot;target&quot; src=&quot;&quot; style=&quot;transform: scale(1) translate(0px, 0px);&quot;&amp;gt;   
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. JavaScript 코드&lt;/h2&gt;
&lt;pre id=&quot;code_1735273183469&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; function touchInit(targetElement) {
    zoomStatus = true;
  
    let startX = 0, startY = 0;
    let offsetX = 0, offsetY = 0;
    let scale = 1;  // 초기 스케일
    let isDragging = false;
    let initialDistance = 0;
  
    let touchCenterX = 0, touchCenterY = 0; // 터치 중심점
    const maxScale = 2;  // 최대 확대 배율 설정
  
    // 기준값 설정
    const baseTranslateX = 270;
    const baseTranslateY = 270;
  
    // 요소의 위치 정보
    const rect = targetElement.getBoundingClientRect();
    const elementOffsetX = rect.left; // 요소의 X 위치
    const elementOffsetY = rect.top;  // 요소의 Y 위치
  
    // 값 제한 함수
    function clamp(value, min, max) {
      return Math.max(min, Math.min(value, max));
    }
  
    // 거리 계산 함수
    function getDistance(touches) {
      const [touch1, touch2] = touches;
      const deltaX = touch2.clientX - touch1.clientX;
      const deltaY = touch2.clientY - touch1.clientY;
      return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
    }
  
    // 터치 시작
    targetElement.addEventListener('touchstart', (event) =&amp;gt; {
      if (event.touches.length === 2) {
        // 핀치 줌 시작
        initialDistance = getDistance(event.touches);
  
        // 중심점 계산 (요소의 위치를 기준으로 보정)
        const touch1 = event.touches[0];
        const touch2 = event.touches[1];
        touchCenterX = ((touch1.clientX + touch2.clientX) / 2) - elementOffsetX;
        touchCenterY = ((touch1.clientY + touch2.clientY) / 2) - elementOffsetY;
      } else if (event.touches.length === 1) {
        // 드래그 시작
        isDragging = true;
        startX = event.touches[0].clientX - offsetX;
        startY = event.touches[0].clientY - offsetY;
      }
    });
  
    // 터치 이동
    targetElement.addEventListener('touchmove', (event) =&amp;gt; {
      if (event.touches.length === 2) {
        // 핀치 줌 동작
        const currentDistance = getDistance(event.touches);
        const zoomFactor = currentDistance / initialDistance;
  
        // 스케일 업데이트 및 제한
        scale = clamp(scale * zoomFactor, 1, maxScale); // 확대 범위 제한
        initialDistance = currentDistance;
  
        // 중심점 기준 확대 조정
        offsetX = (scale - 1) * (baseTranslateX - touchCenterX);
        offsetY = (scale - 1) * (baseTranslateY - touchCenterY);
  
        // 이동 경계 제한 추가
        const minOffsetX = -((scale - 1) * baseTranslateX);
        const maxOffsetX = (scale - 1) * baseTranslateX;
        const minOffsetY = -((scale - 1) * baseTranslateY);
        const maxOffsetY = (scale - 1) * baseTranslateY;
  
        offsetX = clamp(offsetX, minOffsetX, maxOffsetX);
        offsetY = clamp(offsetY, minOffsetY, maxOffsetY);
  
        // 변환 적용
        targetElement.style.transform = `scale(${scale}) translate(${offsetX}px, ${offsetY}px)`;
      } else if (event.touches.length === 1 &amp;amp;&amp;amp; isDragging) {
        // 드래그 동작
        offsetX = event.touches[0].clientX - startX;
        offsetY = event.touches[0].clientY - startY;
  
        // 이동 경계 제한
        const minOffsetX = -((scale - 1) * baseTranslateX);
        const maxOffsetX = (scale - 1) * baseTranslateX;
        const minOffsetY = -((scale - 1) * baseTranslateY);
        const maxOffsetY = (scale - 1) * baseTranslateY;
  
        offsetX = clamp(offsetX, minOffsetX, maxOffsetX);
        offsetY = clamp(offsetY, minOffsetY, maxOffsetY);
  
        // 변환 적용
        targetElement.style.transform = `scale(${scale}) translate(${offsetX}px, ${offsetY}px)`;
      }
    });
  
    // 터치 종료
    targetElement.addEventListener('touchend', () =&amp;gt; {
      isDragging = false;
    });
  }&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Javascript</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/156</guid>
      <comments>https://new-crystal.tistory.com/156#entry156comment</comments>
      <pubDate>Fri, 27 Dec 2024 13:19:48 +0900</pubDate>
    </item>
    <item>
      <title>PHPWord - 여러 문서 파일(docx)을 하나의 문서(docx)로 병합하기</title>
      <link>https://new-crystal.tistory.com/155</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 문서 파일을 하나의 문서 파일로 병합해야 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 해결&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PHPWord 라이브러리를 사용했습니다.&lt;/li&gt;
&lt;li&gt;코드&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1733462466675&quot; class=&quot;php&quot; data-ke-language=&quot;php&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;?php 

require_once '/vendor/autoload.php'; // 라이브러리 경로

use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;



// PhpWord 인스턴스 생성
$phpWord = new PhpWord();

// 파일 경로 배열 생성
$fileArray = [] // 파일 경로 배열

// 각 파일을 PhpWord로 로드하고 병합
foreach ($fileArray as $file) {
    if (!file_exists($file)) {
        echo &quot;파일을 찾을 수 없습니다: $file\n&quot;;
        continue;
    }

    try {
        $source = IOFactory::load($file);
        if (!$source) {
            echo &quot;파일 로드 실패: $file\n&quot;;
            continue;
        }
        $sections = $source-&amp;gt;getSections();

        // 섹션별로 문서에 추가
        foreach ($sections as $section) {
            $newSection = $phpWord-&amp;gt;addSection();
            foreach ($section-&amp;gt;getElements() as $element) {
                $newSection-&amp;gt;addElement(clone $element);
            }
        }
    } catch (Exception $e) {
        echo &quot;문서 처리 중 오류 발생: &quot; . $e-&amp;gt;getMessage() . &quot;\n&quot;;
    }
}

// 병합된 문서를 저장
$outputFile = __DIR__ . '/abstract_All.docx';

try {
    $objWriter = IOFactory::createWriter($phpWord, 'Word2007');
    $objWriter-&amp;gt;save($outputFile);
    echo &quot;문서 병합 성공: $outputFile\n&quot;;
} catch (Exception $e) {
    echo &quot;문서 저장 중 오류 발생: &quot; . $e-&amp;gt;getMessage() . &quot;\n&quot;;
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/155</guid>
      <comments>https://new-crystal.tistory.com/155#entry155comment</comments>
      <pubDate>Fri, 6 Dec 2024 14:21:10 +0900</pubDate>
    </item>
    <item>
      <title>PHPWord - docx 파일을 화면에 보이게 하기</title>
      <link>https://new-crystal.tistory.com/154</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제상황&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;docx 파일을 입력 받아 저장시키는 상황에서 화면에서 파일을 페이지로 띄워야 했습니다.&lt;/li&gt;
&lt;li&gt;PHPWord 라이브러리를 알게 되어 적용시켰습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. PHPWord 설치하기&lt;/p&gt;
&lt;pre id=&quot;code_1733370620834&quot; class=&quot;php&quot; data-ke-language=&quot;php&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;composer require phpoffice/phpword&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 경로 받아와서 화면에 보이기&lt;/p&gt;
&lt;pre id=&quot;code_1733370700017&quot; class=&quot;php&quot; data-ke-language=&quot;php&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;?php
// Composer를 통해 PHPWord 라이브러리 사용
require_once '../vendor/autoload.php'; // 라이브러리 위치

$docxFilePath = isset($_GET['file']) ? $_GET['file'] : '기본 파일 경로'; //파일 경로 동적 할당

if (file_exists($docxFilePath)) {
    try {
        $phpWord = \PhpOffice\PhpWord\IOFactory::load($docxFilePath);
        $htmlWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'HTML');

        ob_start();
        $htmlWriter-&amp;gt;save('php://output');
        $htmlContent = ob_get_clean();

        ?&amp;gt;
        &amp;lt;!DOCTYPE html&amp;gt;
        &amp;lt;html lang=&quot;ko&quot;&amp;gt;
        &amp;lt;head&amp;gt;
            &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
            &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
            &amp;lt;title&amp;gt;DOCX 파일 보기&amp;lt;/title&amp;gt;
            &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;./css/style.css&quot;&amp;gt;
        &amp;lt;/head&amp;gt;
        &amp;lt;body style=&quot;width: 100%;&quot;&amp;gt;
            &amp;lt;div class=&quot;docx_wrap&quot;&amp;gt;
                &amp;lt;?= $htmlContent; ?&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
        &amp;lt;?php
    } catch (Exception $e) {
        echo &quot;&amp;lt;p&amp;gt;PHPWord 변환 중 오류 발생: &quot; . $e-&amp;gt;getMessage() . &quot;&amp;lt;/p&amp;gt;&quot;;
    }
} else {
    echo &quot;&amp;lt;p&amp;gt;파일이 존재하지 않습니다: $docxFilePath&amp;lt;/p&amp;gt;&quot;;
}
?&amp;gt;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <author>개발따라김양</author>
      <guid isPermaLink="true">https://new-crystal.tistory.com/154</guid>
      <comments>https://new-crystal.tistory.com/154#entry154comment</comments>
      <pubDate>Thu, 5 Dec 2024 12:52:04 +0900</pubDate>
    </item>
  </channel>
</rss>