<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>blog of testorian</title>
    <link>https://23life.tistory.com/</link>
    <description>Test. Learn. Iterate.</description>
    <language>ko</language>
    <pubDate>Tue, 21 Jul 2026 13:37:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>mattew4483</managingEditor>
    <image>
      <title>blog of testorian</title>
      <url>https://tistory1.daumcdn.net/tistory/4332282/attach/763e2e885d914dfb851f7ece0acbc026</url>
      <link>https://23life.tistory.com</link>
    </image>
    <item>
      <title>프로그래머스 - 시소 짝꿍 (Map과 조합으로 중복 제거하기)</title>
      <link>https://23life.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EC%8B%9C%EC%86%8C-%EC%A7%9D%EA%BF%8D-Map%EA%B3%BC-%EC%A1%B0%ED%95%A9%EC%9C%BC%EB%A1%9C-%EC%A4%91%EB%B3%B5-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/152996#&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://school.programmers.co.kr/learn/courses/30/lessons/152996#&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1765436452944&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;프로그래머스&quot; data-og-description=&quot;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&quot; data-og-host=&quot;programmers.co.kr&quot; data-og-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/152996#&quot; data-og-url=&quot;https://programmers.co.kr/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/brwDWE/hyZPqcdkjG/GFVrtKGsAE8UeKCc26h5hK/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/j83WC/hyZO7k1DLI/68kAHgSN0btSXdIBzYEkA1/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/152996#&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/152996#&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/brwDWE/hyZPqcdkjG/GFVrtKGsAE8UeKCc26h5hK/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/j83WC/hyZO7k1DLI/68kAHgSN0btSXdIBzYEkA1/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960');&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;프로그래머스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;programmers.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;문제 이해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시소에는 중심으로부터 2m, 3m, 4m 거리에 좌석이 있고, 두 사람이 탈 때 무게 &amp;times; 거리가 양쪽 다 같으면 시소 짝꿍이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;180kg이 4m에 앉고, 360kg이 2m에 앉으면: 180 &amp;times; 4 = 360 &amp;times; 2 = 720 (짝꿍!)&lt;/li&gt;
&lt;li&gt;180kg이 3m에 앉고, 270kg이 2m에 앉으면: 180 &amp;times; 3 = 270 &amp;times; 2 = 540 (짝꿍!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주어진 몸무게 배열에서 시소 짝꿍이 몇 쌍인지 구하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;접근 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 이중 for문 (O(n&amp;sup2;))&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적으로 떠오르는 방법은 모든 쌍을 확인하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;for (let i = 0; i &amp;lt; weights.length; i++) {
    for (let j = i+1; j &amp;lt; weights.length; j++) {
        // weights[i]에 [2,3,4] 곱한 값들
        // weights[j]에 [2,3,4] 곱한 값들
        // 교집합이 있으면 짝꿍
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 weights 최대 길이가 100,000이므로&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;100,000&amp;sup2; = 10,000,000,000 (100억 연산)&lt;/li&gt;
&lt;li&gt;시간 초과 확실&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Map으로 최적화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이중 for문이 필요했던 이유는, weights에 다른 무게들이 몇 개 있는지 확인하기 위해!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; O(1)으로도 특정 무게가 몇 개 존재하는지 알 수 있지 않나?&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const weightMap = new Map();
weights.forEach(w =&amp;gt; {
    weightMap.set(w, (weightMap.get(w) || 0) + 1);
});
// {100: 2, 180: 1, 270: 1, 360: 1}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 각 무게에 대해:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가능한 짝꿍 무게들을 계산&lt;/li&gt;
&lt;li&gt;Map에서 O(1)로 존재 여부 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;for (const [weight, count] of weightMap) {
    [2,3,4].forEach(d1 =&amp;gt; {
        [2,3,4].forEach(d2 =&amp;gt; {
            const pairWeight = weight * d1 / d2;
            if (weightMap.has(pairWeight)) {
                // 짝꿍 발견!
            }
        });
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 흐름은 이와 같이 진행하면 될 것 같다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이대로 실행하면 &lt;b&gt;중복으로 카운트되는 문제&lt;/b&gt;가 발생한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 1: 양쪽에서 중복 카운트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// [100,180,360,100,270] 에서
180 차례: 180 &amp;times; 4 / 2 = 360 찾음 ✅
360 차례: 360 &amp;times; 2 / 4 = 180 찾음 ✅
&amp;rarr; 같은 쌍을 2번 셈!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;rarr; &lt;/b&gt;pairWeight &amp;gt; weight 조건으로 큰 무게만 찾도록 제한하자!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 2: 같은 무게 쌍을 여러 번 카운트&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;weight = 100, count = 2

d1=2, d2=2 &amp;rarr; 100 &amp;times; 2 / 2 = 100 &amp;rarr; answer += ...
d1=3, d2=3 &amp;rarr; 100 &amp;times; 3 / 3 = 100 &amp;rarr; answer += ...
d1=4, d2=4 &amp;rarr; 100 &amp;times; 4 / 4 = 100 &amp;rarr; answer += ...
&amp;rarr; {100, 100} 쌍을 3번 셈!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;&amp;rarr; &lt;/b&gt;&lt;/b&gt;d1 === d2인 경우 분기 처리 해주자!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 3: 같은 무게가 여러 개일 때 조합 계산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100이 3개 있으면 몇 쌍?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(1번, 2번), (1번, 3번), (2번, 3번) = 3쌍&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;rarr; &lt;/b&gt;조합 공식(count &amp;times; (count - 1) / 2)을 사용하자!&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;최종 해결책&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function solution(weights) {
    let answer = 0;
    
    // 1. 무게별 개수 저장
    const weightMap = new Map();
    weights.forEach((w) =&amp;gt; {
        weightMap.set(w, (weightMap.get(w) || 0) + 1);
    });
    
    // 2. 고유 무게마다 짝꿍 찾기
    for (const [weight, count] of weightMap) {
        let isSameCounted = false;
        
        [2,3,4].forEach((d1) =&amp;gt; {
            [2,3,4].forEach((d2) =&amp;gt; {
                if (d1 === d2) {
                    // 같은 거리 = 같은 무게만 가능
                    // 한 번만 계산 (중복 방지)
                    if (!isSameCounted) {
                        answer += count * (count - 1) / 2;  // 조합
                        isSameCounted = true;
                    }
                } else {
                    // 다른 거리 = 다른 무게와 짝꿍
                    const pairWeight = weight * d1 / d2;
                    
                    // 큰 무게만 찾아서 중복 방지
                    if (pairWeight &amp;gt; weight &amp;amp;&amp;amp; weightMap.has(pairWeight)) {
                        answer += count * weightMap.get(pairWeight);
                    }
                }
            })
        })
    }
    
    return answer;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시간/공간 복잡도&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;시간복잡도&lt;/b&gt;: O(n)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Map 생성: O(n)&lt;/li&gt;
&lt;li&gt;고유 무게 순회: O(M), M &amp;le; 901&lt;/li&gt;
&lt;li&gt;각 무게마다 9번 고정 연산&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;공간복잡도&lt;/b&gt;: O(M)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Map 크기: 최대 901 (무게 범위 100~1000)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Learn/Algorithm</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/429</guid>
      <comments>https://23life.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EC%8B%9C%EC%86%8C-%EC%A7%9D%EA%BF%8D-Map%EA%B3%BC-%EC%A1%B0%ED%95%A9%EC%9C%BC%EB%A1%9C-%EC%A4%91%EB%B3%B5-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0#entry429comment</comments>
      <pubDate>Thu, 11 Dec 2025 22:46:00 +0900</pubDate>
    </item>
    <item>
      <title>프로그래머스 - 마법의 엘리베이터 (구현 + 숫자 다루기)</title>
      <link>https://23life.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EB%A7%88%EB%B2%95%EC%9D%98-%EC%97%98%EB%A6%AC%EB%B2%A0%EC%9D%B4%ED%84%B0-%EA%B5%AC%ED%98%84-%EC%88%AB%EC%9E%90-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/148653&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://school.programmers.co.kr/learn/courses/30/lessons/148653&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1765349326845&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;프로그래머스&quot; data-og-description=&quot;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&quot; data-og-host=&quot;programmers.co.kr&quot; data-og-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/148653&quot; data-og-url=&quot;https://programmers.co.kr/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bU4wON/hyZOKXwjyO/Oo7eKUGqKVG1OxGXfj0Uo0/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/blnmDf/hyZPcl1ptr/rn86rLUYwwWBr0nmZGlcdK/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/148653&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/148653&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bU4wON/hyZOKXwjyO/Oo7eKUGqKVG1OxGXfj0Uo0/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/blnmDf/hyZPcl1ptr/rn86rLUYwwWBr0nmZGlcdK/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960');&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;프로그래머스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;programmers.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 이해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 특정 층에서 0층으로 내려가는데 필요한 &lt;b&gt;최소 버튼 횟수&lt;/b&gt;를 구하는 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 조건:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버튼: -1, +1, -10, +10, -100, +100 등 (10의 거듭제곱 형태)&lt;/li&gt;
&lt;li&gt;버튼 1번 = 마법의 돌 1개 소모&lt;/li&gt;
&lt;li&gt;목표: 최소한의 버튼으로 0층 도달&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 분석:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;16층 &amp;rarr; 0층
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;방법1: -1 6번 + -10 1번 = 7번&lt;/li&gt;
&lt;li&gt;방법2: +1 4번 + -10 2번 = 6번 ✅&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 방법2가 더 효율적일까? 16의 일의 자리가 6이니까, 20으로 올린 후 -10 2번이 -1 6번보다 적기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;접근 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적으로 봤을 때 &amp;rarr; 맨 마지막 자릿수에 따라 올릴지 내릴지가 결정된다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 맨 마지막 자릿수가 6,7,8,9면 올려야하고 1,2,3,4면 내리면 될테다.&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;그런데... 마지막 자릿수가 5일 때는 어떻게 해야하지?&lt;/p&gt;
&lt;pre id=&quot;code_1765349639754&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;case 1: 165
올림 : 일의자리 +1 5번, 십의자리 +1 3번, 백의자리 -1 2번 =&amp;gt; 10번!
내림 : 일의자리 -1 5번, 십의자리 +1 4번, 백의자리 -1 2번 =&amp;gt; 11번

case 2: 135
올림 : 일의자리 +1 5번, 십의자리 -1 4번, 백의자리 -1 1번 =&amp;gt; 10번
내림 : 일의자리 -1 5번, 십의자리 -1 3번, 백의자리 -1 1번 =&amp;gt; 9번!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쩔 때는 올려야하고, 어쩔 때는 내려야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이를 유발하는 것은.... &lt;b&gt;그 앞자리 숫자의 크기&lt;/b&gt;임을 볼 수 있다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;165의 경우, 5의 앞자리 숫자가 6이고, 6은 다음 계산에서 어차피 올림을 할 것이기 때문에, 5도 +1 을 5번을 해주는게 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;135의 경우, 5의 앞자리 숫자가 3이고,&lt;/span&gt;&amp;nbsp;3은 다음 계산에서 어차피 내림을 할 것이기 때문에, 5도 -1 을 5번을 해주는게 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 아하! 그 앞자리 숫자가 5보다 큰지 작은지에 따라 올릴지 내릴지를 결정해주면 되겠다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 배열로 자릿수 관리&lt;/h3&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;// 1998 &amp;rarr; [1, 9, 9, 8]
let 배열 = storey.toString().split('').map(Number);

while (배열.length &amp;gt; 0) {
  let 현재 = 배열.pop();
  
  if (현재 === 5) {
    let 다음 = 배열[배열.length - 1]; // peek
    if (다음 &amp;gt;= 5) {
      배열[배열.length - 1] += 1; // 올림 처리
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 각 자릿수를 배열로 관리하려했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는... 95처럼 계산 과정에서 자릿수가 벗어나는 경우를 체크하기 복잡하다는 것!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(ex 99999는, 연쇄적으로 다음 자리를 올려줘야한다)&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;b&gt;숫자 연산&lt;/b&gt;으로 처리하면 자리올림이 자동으로 해결된다!&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;storey = Math.floor(storey / 10) + 1; // 1997 &amp;rarr; 200 (자동 올림)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;storey % 10으로 현재 자릿수 추출&lt;/li&gt;
&lt;li&gt;Math.floor(storey / 10) % 10으로 다음 자릿수 확인&lt;/li&gt;
&lt;li&gt;Math.floor(storey / 10) + 1로 올림 처리 시 자동 전파&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1765350117441&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function solution(storey) {
    let count = 0;
    
    while (storey &amp;gt; 0) {
        const 마지막자리 = storey % 10
        if (마지막자리 &amp;gt; 5) {
            count += (10 - 마지막자리)
            storey = Math.floor(storey / 10) + 1
        } else if (마지막자리 &amp;lt; 5) {
            count += 마지막자리
            storey = Math.floor(storey / 10)
        } else {
            const 마지막자리앞 = Math.floor(storey / 10) % 10
            if (마지막자리앞 &amp;gt;= 5) {    
                count += (10 - 마지막자리);
                storey = Math.floor(storey / 10) + 1;
            } else {
                count += (10 - 마지막자리);
                storey = Math.floor(storey / 10)
            }
        }
    }

    return count;
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Learn/Algorithm</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/428</guid>
      <comments>https://23life.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EB%A7%88%EB%B2%95%EC%9D%98-%EC%97%98%EB%A6%AC%EB%B2%A0%EC%9D%B4%ED%84%B0-%EA%B5%AC%ED%98%84-%EC%88%AB%EC%9E%90-%EB%8B%A4%EB%A3%A8%EA%B8%B0#entry428comment</comments>
      <pubDate>Wed, 10 Dec 2025 21:30:40 +0900</pubDate>
    </item>
    <item>
      <title>네이버 블로그&amp;middot;카페 댓글 추첨 방법</title>
      <link>https://23life.tistory.com/entry/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8-%EB%B0%A9%EB%B2%95-2025%EB%85%84-%EC%B5%9C%EC%8B%A0-5%EC%B4%88-%EC%99%84%EB%A3%8C</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;블로그&amp;middot;카페 이벤트 추첨, 아직도 손으로 하시나요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이버 블로그나 카페에서 이벤트를 진행하다 보면 늘 같은 고민이 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;댓글이 100개가 넘는데 일일이 복사해서 엑셀에 붙여넣기?&lt;/li&gt;
&lt;li&gt;같은 사람이 여러 번 댓글 달았는데 어떻게 걸러내지?&lt;/li&gt;
&lt;li&gt;추첨 과정을 영상으로 찍어야 하는데 화면 녹화까지 해야 하나?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론부터 말하면, 이제 5초면 끝납니다.&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1763819610129&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;네이버 블로그 댓글 추첨기 - Chrome 웹 스토어&quot; data-og-description=&quot;블로그 댓글을 쉽고 빠르게 추첨하세요.&quot; data-og-host=&quot;chromewebstore.google.com&quot; data-og-source-url=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; data-og-url=&quot;https://chromewebstore.google.com/detail/네이버-블로그-댓글-추첨기/ngngagacgffclgphdljaijkmnlicpjam&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/egwtXI/hyZNAtMQ3e/tiKZS0kVJekJ41Xw2A6EZ0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/egwtXI/hyZNAtMQ3e/tiKZS0kVJekJ41Xw2A6EZ0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128');&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;네이버 블로그 댓글 추첨기 - Chrome 웹 스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;블로그 댓글을 쉽고 빠르게 추첨하세요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chromewebstore.google.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 방법의 문제점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Windows 전용 프로그램의 한계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Mac 사용자는 아예 사용 불가&lt;/li&gt;
&lt;li&gt;설치 파일 다운로드 &amp;rarr; 설치 &amp;rarr; 실행 &amp;rarr; 설정 과정이 복잡&lt;/li&gt;
&lt;li&gt;2010년대 UI로 사용성 떨어짐&lt;/li&gt;
&lt;li&gt;최대 가능 당첨자 수 등 제한 존재  &lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 수동 엑셀 작업의 비효율&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;댓글 100개 복사하는데만 10분&lt;/li&gt;
&lt;li&gt;중복 참여자 찾으려고 스크롤 왔다갔다&lt;/li&gt;
&lt;li&gt;사람이 하는 일이라 실수 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2025년 최신 방법: 크롬 확장 프로그램&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;네이버 블로그&amp;middot;카페 댓글 추첨기&lt;/b&gt;&lt;/a&gt;를 사용하면 위 문제가 모두 해결됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;작동 원리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;크롬 웹스토어에서 확장 프로그램 설치 (30초)&lt;/li&gt;
&lt;li&gt;이벤트 진행할 블로그 포스트로 이동&lt;/li&gt;
&lt;li&gt;확장 프로그램 아이콘 클릭&lt;/li&gt;
&lt;li&gt;당첨 인원 입력 &amp;rarr; 추첨 버튼 클릭&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완료&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계: 설치하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롬 익스텐션 스토어에서 &lt;b&gt;네이버 블로그&amp;middot;카페 댓글 추첨기&lt;/b&gt;를 설치하세요.&lt;/p&gt;
&lt;figure id=&quot;og_1763819688140&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;네이버 블로그 댓글 추첨기 - Chrome 웹 스토어&quot; data-og-description=&quot;블로그 댓글을 쉽고 빠르게 추첨하세요.&quot; data-og-host=&quot;chromewebstore.google.com&quot; data-og-source-url=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; data-og-url=&quot;https://chromewebstore.google.com/detail/네이버-블로그-댓글-추첨기/ngngagacgffclgphdljaijkmnlicpjam&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/egwtXI/hyZNAtMQ3e/tiKZS0kVJekJ41Xw2A6EZ0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/egwtXI/hyZNAtMQ3e/tiKZS0kVJekJ41Xw2A6EZ0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128');&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;네이버 블로그 댓글 추첨기 - Chrome 웹 스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;블로그 댓글을 쉽고 빠르게 추첨하세요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chromewebstore.google.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'Chrome에 추가' 버튼 클릭 한 번이면 끝. Mac, Windows, Linux 모두 사용 가능합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: 추첨할 포스트 열기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트를 진행할 네이버 블로그 포스트로 이동합니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: 확장 프로그램 실행 후 댓글 수집&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver-2.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btFbDo/dJMcafkIfmC/rlF9l8Xhto4uL5Ko0VwHxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btFbDo/dJMcafkIfmC/rlF9l8Xhto4uL5Ko0VwHxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btFbDo/dJMcafkIfmC/rlF9l8Xhto4uL5Ko0VwHxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtFbDo%2FdJMcafkIfmC%2FrlF9l8Xhto4uL5Ko0VwHxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;641&quot; height=&quot;401&quot; data-filename=&quot;naver-2.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 오른쪽 상단 퍼즐 아이콘 &amp;rarr; '네이버 블로그 댓글 추첨기'를 클릭하면, 우측 사이드바가 활성화됩니다.&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-filename=&quot;naver-3.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xk4Mv/dJMcadtCp5t/LGVhacKy847pMqnb98aqOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xk4Mv/dJMcadtCp5t/LGVhacKy847pMqnb98aqOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xk4Mv/dJMcadtCp5t/LGVhacKy847pMqnb98aqOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fxk4Mv%2FdJMcadtCp5t%2FLGVhacKy847pMqnb98aqOk%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;581&quot; height=&quot;363&quot; data-filename=&quot;naver-3.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'댓글 추첨 시작' 버튼을 클릭하면, 익스텐션이&amp;nbsp;&lt;b&gt;자동으로 해당 포스트의 모든 댓글을 수집&lt;/b&gt;합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집된 댓글들을 기반으로 추첨을 진행돼죠!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: 옵션 설정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver-4.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o3oSQ/dJMcagDVLJR/3w1zOZoFPJh3HQazUHVvzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o3oSQ/dJMcagDVLJR/3w1zOZoFPJh3HQazUHVvzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o3oSQ/dJMcagDVLJR/3w1zOZoFPJh3HQazUHVvzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo3oSQ%2FdJMcagDVLJR%2F3w1zOZoFPJh3HQazUHVvzK%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;674&quot; height=&quot;421&quot; data-filename=&quot;naver-4.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 당첨 인원 수와 원하는 필터 옵션을 설정한 후, 추첨하기 버튼을 누르세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용 가능한 필터 옵션은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;b&gt;중복 참여자 제거&lt;/b&gt;: 같은 사람이 여러 댓글 달아도 1회만 인정&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;대댓글 제외&lt;/b&gt;: 댓글의 댓글은 제외&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;특정 키워드 필터링&lt;/b&gt;: &quot;응모합니다&quot; 등 특정 문구 포함 댓글만 추첨&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계: 추첨 확인 및 결과 다운로드&amp;nbsp;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;naver-5.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCF3cj/dJMcaacDodJ/O2VOPVaEUAovBfGqF1uOk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCF3cj/dJMcaacDodJ/O2VOPVaEUAovBfGqF1uOk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCF3cj/dJMcaacDodJ/O2VOPVaEUAovBfGqF1uOk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCF3cj%2FdJMcaacDodJ%2FO2VOPVaEUAovBfGqF1uOk1%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;684&quot; height=&quot;428&quot; data-filename=&quot;naver-5.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'엑셀 다운로드' 버튼을 누르면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;전체 댓글 목록&lt;/li&gt;
&lt;li&gt;당첨자 표시&lt;/li&gt;
&lt;li&gt;작성 일시, 공감 수 등 모든 정보&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 포함된 xlsx 파일이 다운로드됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 도구 vs 네이버 블로그&amp;middot;카페 댓글 추첨기&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 133px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style6&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;비교 항목&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;기존 Windows 프로그램&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&amp;nbsp; 네이버 블로그 댓글 추첨기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;OS 지원&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Windows만&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;모든 OS (Mac, Linux 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;설치&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;파일 다운로드 + 실행  &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;크롬 스토어 원클릭  &lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;중복 제거&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;수동&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;자동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;사용 시간&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;5~10분&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;5초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;추첨 가능 인원&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;최대 100명&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;제한 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이런 분들께 강력 추천합니다&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;✅ 파워블로거&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월 2~3회 이벤트 진행 시 매번 10분씩 절약 = 월 30분 절약&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;h4 data-ke-size=&quot;size20&quot;&gt;✅ 인플루언서&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구독자 이벤트 추첨의 공정성을 투명하게 증명&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 바로 시작하세요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 이상 수동 작업으로 시간을 낭비하지 마세요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;icon.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQiAyw/dJMcadG9utY/4jXIomA8q0vGSFnk6L20rK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQiAyw/dJMcadG9utY/4jXIomA8q0vGSFnk6L20rK/img.png&quot; data-alt=&quot;네이버 블로그&amp;amp;middot;카페 댓글 추첨기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQiAyw/dJMcadG9utY/4jXIomA8q0vGSFnk6L20rK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQiAyw%2FdJMcadG9utY%2F4jXIomA8q0vGSFnk6L20rK%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;297&quot; height=&quot;297&quot; data-filename=&quot;icon.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네이버 블로그&amp;middot;카페 댓글 추첨기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8%EA%B8%B0/ngngagacgffclgphdljaijkmnlicpjam&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;[크롬 웹스토어에서 무료 설치하기]&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 후 5초면 첫 추첨이 완료됩니다.&lt;/p&gt;</description>
      <category>Test/네이버 블로그 댓글 추첨기</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/427</guid>
      <comments>https://23life.tistory.com/entry/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%8C%93%EA%B8%80-%EC%B6%94%EC%B2%A8-%EB%B0%A9%EB%B2%95-2025%EB%85%84-%EC%B5%9C%EC%8B%A0-5%EC%B4%88-%EC%99%84%EB%A3%8C#entry427comment</comments>
      <pubDate>Sat, 22 Nov 2025 23:07:19 +0900</pubDate>
    </item>
    <item>
      <title>지속 가능한 프론트엔드 단위 테스트&amp;nbsp;작성법</title>
      <link>https://23life.tistory.com/entry/%EC%A7%80%EC%86%8D-%EA%B0%80%EB%8A%A5%ED%95%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95-%EC%98%A4%EB%8A%98%EB%8F%84-%EC%8B%A4%ED%8C%A8%ED%95%9C-%EC%9A%B0%EB%A6%AC%EC%9D%98-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9C%84%ED%95%B4</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;프론트엔드는 테스트가 필요 없다?!&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 애플리케이션의 복잡성과 중요성이 증가하면서, 단위 테스트의 필요성을 주장하는 목소리가 점점 커지고 있습니다. 하지만 자주 변경되는 UI로 인해 쉽게 깨지는 테스트 코드를 보며, 프론트엔드 단위 테스트가 과연 효율적인지 의문을 품는 시선도 여전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 여러 프론트엔드 프로젝트 경험을 통해 &lt;b&gt;단위 테스트는 필수&lt;/b&gt;라는 결론에 도달했습니다. 단위 테스트는 리팩토링, 코드 품질 유지, 오류 예방에 중요한 역할을 하며, 코드베이스가 커지고 협업 인원이 늘어날수록 필요한 작업입니다. 프론트엔드라고 다를 것은 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 열심히 작성한 테스트가 UI 변경 한 번에 와르르 실패하는 것을 보며 컴포넌트, 즉 &lt;b&gt;UI에 집중된 단위 테스트는 절대 지속될 수 없음&lt;/b&gt;을 깨닫게 되었습니다. (조만간 실패할 게 뻔한데, 테스트 코드를 왜 작성해야하죠?!  )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 &lt;b&gt;지속 가능한 프론트엔드 단위 테스트&lt;/b&gt;는 어떻게 작성할 수 있을까요? 이번 글에서는 다음과 같은 흐름으로 내용을 다뤄 보겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;GUI 테스트에 대한 기본 원칙&lt;/li&gt;
&lt;li&gt;프론트엔드 단위 테스트 작성의 어려움&lt;/li&gt;
&lt;li&gt;지속 가능한 단위 테스트 작성 방법&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 로버트 C. 마틴의 원칙: &amp;ldquo;GUI를 테스트하지&amp;nbsp;말라&amp;rdquo;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로버트 C. 마틴(Robert C. Martin)은 클린 아키텍처와 SOLID 원칙으로 잘 알려진 인물로, 소프트웨어 설계에서의 응집도(cohesion)와 결합도(coupling), 그리고 &lt;b&gt;테스트 주도 개발(TDD)&lt;/b&gt;의 중요성을 강조한 선구자입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XWVAj/dJMcaklSm79/HIF12GMaFUtjk9dK9wCDeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XWVAj/dJMcaklSm79/HIF12GMaFUtjk9dK9wCDeK/img.png&quot; data-alt=&quot;TDD 하쇼!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XWVAj/dJMcaklSm79/HIF12GMaFUtjk9dK9wCDeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXWVAj%2FdJMcaklSm79%2FHIF12GMaFUtjk9dK9wCDeK%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;400&quot; height=&quot;400&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TDD 하쇼!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그의 저서 &amp;lsquo;소프트웨어 장인 정신 이야기&amp;rsquo;에서, 마틴은 GUI 테스트에 대해 아래와 같이 얘기합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;GUI 테스트의 규칙은 다음과 같다.&lt;br /&gt;1. GUI를 테스트하지 말라.&lt;br /&gt;2. 모든 것을 테스트하라. GUI만 빼고.&lt;br /&gt;3. GUI는 여러분 생각보다 작다.&amp;rdquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그는 UI가 가장 빈번하게 변경되는 요소이기에, UI 테스트는 자주 깨지고 유지보수가 어렵다고 설명합니다. &lt;b&gt;UI는 단순히 데이터를 받아 화면을 그리는 역할&lt;/b&gt;에 그쳐야 하며, 중요한 비즈니스 로직은 UI 바깥에서 테스트해야 한다는 것이 그의 철학입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클린 아키텍처와 GUI의&amp;nbsp;역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마틴의 주장을 이해하기 위해서는, 그가 제시한 &lt;b&gt;클린 아키텍처&lt;/b&gt; 개념을 살펴볼 필요가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;266&quot; data-origin-height=&quot;189&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vZ0Bl/dJMcacOUFRB/DMJiAAUfmIXFqMj0T7amdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vZ0Bl/dJMcacOUFRB/DMJiAAUfmIXFqMj0T7amdK/img.png&quot; data-alt=&quot;우리 모두가 경험해본&amp;amp;nbsp;그래프&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vZ0Bl/dJMcacOUFRB/DMJiAAUfmIXFqMj0T7amdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvZ0Bl%2FdJMcacOUFRB%2FDMJiAAUfmIXFqMj0T7amdK%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;336&quot; height=&quot;239&quot; data-origin-width=&quot;266&quot; data-origin-height=&quot;189&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;우리 모두가 경험해본&amp;nbsp;그래프&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SW를 개발하다 보면, 언제부터인가 시간과 노력을 쏟아도 요구 사항을 반영하는 속도가 점점 늦어지는 순간이 찾아옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 일이 벌어질까요? 간단합니다. &lt;b&gt;프로젝트가 지나치게 복잡&lt;/b&gt;해졌기 때문이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 수정사항이 그와 관계없어 보이는 변경사항을 만들어내고, 이로 인해 SW 전체가 정상적으로 동작하지 않는 상황. 복잡한 프로젝트에서는 이런 상황이 끊임없이 발생합니다. 이것이 반복되면, 개발자는 기존 코드를 수정하기 꺼리며, 새로운 요구 사항의 반영 속도는 점점 느려집니다. 자연스레 생산성이 하락하고 마는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마틴은 이러한 복잡함의 원인을 &lt;b&gt;고수준 비즈니스 정책과 저수준 세부사항의 결합&lt;/b&gt;에서 찾았습니다. &lt;b&gt;애플리케이션의 핵심 비즈니스 정책&lt;/b&gt;이 외부 라이브러리나 UI, 데이터베이스와 같은 &lt;b&gt;저수준 세부사항에 의존&lt;/b&gt;하는 순간, SW 복잡도가 기하급수적으로 증대된다는 것이죠.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;772&quot; data-origin-height=&quot;567&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qPfSP/dJMcaklSm7Z/qqXWfhxYNaH4X8OUmJK4t1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qPfSP/dJMcaklSm7Z/qqXWfhxYNaH4X8OUmJK4t1/img.jpg&quot; data-alt=&quot;클린 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qPfSP/dJMcaklSm7Z/qqXWfhxYNaH4X8OUmJK4t1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqPfSP%2FdJMcaklSm7Z%2FqqXWfhxYNaH4X8OUmJK4t1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;659&quot; height=&quot;484&quot; data-origin-width=&quot;772&quot; data-origin-height=&quot;567&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;클린 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 그는 &lt;b&gt;고수준 비즈니스 정책은 저수준 세부사항에 의존하지 않아야 한다&lt;/b&gt;는 원칙을 강조했습니다. 즉, 저수준 세부사항이 변경되더라도 비즈니스 로직에는 영향을 미치지 않도록 분리해야 한다는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 관점에서 UI Framework를 포함한 GUI 언제든 변경 가능한, &lt;b&gt;저수준 세부사항에 해당&lt;/b&gt;합니다. 즉 상태 관리를 위해 Context API를 사용하던 Redux를 사용하던, 더 나아가 React를 사용하던 Angular를 사용하던, 애플리케이션의 핵심적인 기능과 동작에는 아무런 영향이 없어야만 한다는 것이죠!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프론트엔드에 클린 아키텍처 적용하기 (feat. Feature-Sliced Design)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 클린 아키텍처 개념을 프론트엔드에 직접 적용하기란 쉽지 않은 일입니다. GUI를 다루는 FE 작업에서 GUI에 의존하지 말라니! 대부분의 프론트엔드 개발자들에게 익숙하지 않은 개념일뿐더러, 그렇게 해야 할 이유가 크게 체감되지도 않죠. (Angular에서 React로 변경해야 하는 상황이 그렇게 빈번할까요?  )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그럼에도 불구하고, 클린 아키텍처 핵심 철학 자체는 프론트엔드 개발에서도 반드시 지켜져야 합니다. 즉, &lt;b&gt;고수준 정책과 저수준 세부사항을 분리하고, 저수준 세부사항이 고수준 정책에 의존해야 한다(그 반대가 되어서는 안 된다)&lt;/b&gt;는 철학이 프론트엔드 개발에서만 예외가 될 수는 없습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;348&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qWbZ9/dJMcabJeMxn/UFrQCAp5huzdfVkg2WpKf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qWbZ9/dJMcabJeMxn/UFrQCAp5huzdfVkg2WpKf0/img.png&quot; data-alt=&quot;중요한 것은 분리, 그리고 의존성의&amp;amp;nbsp;뱡향&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qWbZ9/dJMcabJeMxn/UFrQCAp5huzdfVkg2WpKf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqWbZ9%2FdJMcabJeMxn%2FUFrQCAp5huzdfVkg2WpKf0%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;348&quot; height=&quot;524&quot; data-origin-width=&quot;348&quot; data-origin-height=&quot;524&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;중요한 것은 분리, 그리고 의존성의&amp;nbsp;뱡향&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 프론트엔드 영역에서 고수준의 비즈니스 정책과 저수준의 세부사항을 대체 어떻게 구분해야 할까요? 기획안에 작성된 내용 = 비즈니스 정책 으로 이해하면 될까요? UI는 세부사항이고, 그 이외는 모두 비즈니스 정책으로 구분하면 되는 걸까요? 이전에 논의되었던 container &amp;harr; presenter 패턴으로 되돌아가야 하는 것일까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 여기에 정해진 정답은 없습니다. 각 상황마다 대답이 달라질 수밖에 없죠. 핵심은 프론트엔드에서도 애플리케이션 내 모든 코드들을 &lt;b&gt;위계에 따라 구분 짓고,&lt;/b&gt; 이렇게 구분된 계층들의 &lt;b&gt;의존 방향을 하나로 통일해야 한다는&lt;/b&gt; 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1355&quot; data-origin-height=&quot;754&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lvTtj/dJMcaklSm7X/1xM55025ubkW0B2J8UKDTk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lvTtj/dJMcaklSm7X/1xM55025ubkW0B2J8UKDTk/img.jpg&quot; data-alt=&quot;Feature-Sliced Design&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lvTtj/dJMcaklSm7X/1xM55025ubkW0B2J8UKDTk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlvTtj%2FdJMcaklSm7X%2F1xM55025ubkW0B2J8UKDTk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;403&quot; data-origin-width=&quot;1355&quot; data-origin-height=&quot;754&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Feature-Sliced Design&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://feature-sliced.design/docs/get-started/overview&quot; data-href=&quot;https://feature-sliced.design/docs/get-started/overview&quot;&gt;&lt;b&gt;Feature-Sliced Design(FSD)&lt;/b&gt;&lt;/a&gt;은 이러한 개념을 프론트엔드에 접목시킨 대표적인 폴더 구조 아키텍처입니다. FSD 패턴에서는 모든 소스 코드를 &lt;b&gt;7개의 계층&lt;/b&gt;으로 나누며, &lt;b&gt;하위 계층은 상위 계층에 의존할 수 없다&lt;/b&gt;는 원칙을 제시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 하위/상위 계층무엇을 기준으로 나뉘는 걸까요? 여기에서 FSD 패턴과 C.마틴의 클린 아키텍처와의 공통 철학이 드러납니다. 즉 아래쪽에 위치한 계층일수록 고수준 비즈니스 정책(애플리케이션 도메인)에 가깝고, 위쪽에 위치한 계층일수록 저수준 세부사항(애플리케이션과 무관한, 외부 세계)에 가까운 것이죠!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eMCPSU/dJMcacOUFRD/2ax75EcwkzTEkBY4yEHLRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eMCPSU/dJMcacOUFRD/2ax75EcwkzTEkBY4yEHLRK/img.png&quot; data-alt=&quot;클린 아키텍처 철학을 FE에 구현한 모습,&amp;amp;nbsp;느껴지시나요?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eMCPSU/dJMcacOUFRD/2ax75EcwkzTEkBY4yEHLRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeMCPSU%2FdJMcacOUFRD%2F2ax75EcwkzTEkBY4yEHLRK%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;640&quot; height=&quot;339&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;클린 아키텍처 철학을 FE에 구현한 모습,&amp;nbsp;느껴지시나요?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 코드가 도메인 영역에 위치할수록 애플리케이션이 무엇이고, 어떻게 동작해야 하는지(즉 비즈니스 정책)를 정의하는 역할을 담당합니다. &amp;lsquo;우리의 애플리케이션은 이러이러하게 동작해야 해&amp;rsquo;가 주된 관심사이며, 이것을 GUI로도, api 요청으로도, 데이터 모델이나 유틸리티 함수로도 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한편 소스 코드가 외부 세계 영역에 위치 할수록 실제 사용자가 애플리케이션을 사용할 수 있도록 구현하는 역할을 담당합니다. 변경이 잦고 예측하기 어려운 외부 세계와 애플리케이션이 상호작용하는 주요 지점들을 제공하며, 따라서 도메인 영역과 철저하게 분리해 해당 영역의 변경사항이 애플리케이션 동작에 영향을 주지 않도록 제어해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서 단위&amp;nbsp;테스트는?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지의 흐름은 아래와 같이 요약할 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;생산성 향상을 위해, 애플리케이션의 복잡도가 지나치게 커지지 않도록 제어해야 한다.&lt;/li&gt;
&lt;li&gt;애플리케이션의 복잡도를 제어하기 위해, 고수준의 비즈니스(도메인) 정책과 저수준의 세부 구현 사항을 분리해야 한다.&lt;/li&gt;
&lt;li&gt;FSD 아키텍처를 통해, 고수준 정책과 저수준 세부사항의 분리를 달성할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 꼭 앞서 소개한 FSD 아키텍처를 적용할 필요는 없습니다. 핵심은 &lt;b&gt;비즈니스 영역과 세부 구현 사항이 분리&lt;/b&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. 프론트엔드 단위 테스트 코드: 우리는 단위 테스트가&amp;nbsp;싫다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발자들이 단위 테스트 작성에 (마음먹었다가도) 실패하는 이유는, 대부분 아래와 같은 장벽들을 마주하기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;테스트할 대상을 찾기 어렵다&lt;/b&gt;: 페이지 내 요소들이 상호작용하는 경우가 많아 테스트 환경 구축이 까다롭습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI 변경 시 테스트 코드가 쉽게 깨진다&lt;/b&gt;: UI는 요구사항이 잦아 빠르게 변경되며, 이로 인해 테스트 코드 유지보수가 어려워집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브라우저에서 직접 확인하는 것이 빠르다&lt;/b&gt;: 코드 작성 공수 대비 수동 테스트가 효율적일 때가 많습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이들은 크게 두 가지로 요약할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 너무 쉽게, 자주 실패하는 테스트&amp;nbsp;코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 단위 테스트와 관련된 예제들을 보면, 대부분 아래와 같이 작성되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;// 참고) 코드와 함께 살펴보는 프론트엔드 단위 테스트 &amp;ndash; Part 2. 실전 편
// https://techblog.woowahan.com/17721/

const defaultIdentificationProps = {
  referrer: '',
  onFinish: () =&amp;gt; {},
};
describe('Identification 단위 테스트', () =&amp;gt; {
  // ...
  it('인증 정보 API 호출하며 성공 시 페이지 제목이 노출된다', async () =&amp;gt; {
    /* MSW 성공 응답 설정 */
    server.use(
      http.get('인증정보 API URL', () =&amp;gt; {
        return HttpResponse.json({
          // 성공 응답 JSON
        });
      }),
    );
    render(&amp;lt;Identification {...defaultIdentificationProps} /&amp;gt;);
    await waitFor(() =&amp;gt; {
      expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
    });
    expect(screen.getByText('인증을 시작합니다')).toBeInTheDocument();
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 테스트의 성공/실패는 각 expect 구문에 의해 결정됩니다. 그리고 이들은 &amp;lsquo;&lt;b&gt;화면에 특정 글자가 존재하는지&lt;/b&gt;&amp;rsquo;를 통해 테스트 성공 여부를 결정하죠. (특정 id, 태그, label 등으로 결정하는 것 역시 맥락은 동일합니다. 요점은 &amp;lsquo;화면에 의도한 UI 요소가 존재하는지&amp;rsquo;로 테스트 결과가 달라진다는 것)&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;lsquo;보이는지&amp;rsquo;도 반드시 테스트 해야만 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1544&quot; data-origin-height=&quot;756&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SeUqX/dJMcacH84fr/QslIWMKfaIdRKmHfWDJLo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SeUqX/dJMcacH84fr/QslIWMKfaIdRKmHfWDJLo0/img.png&quot; data-alt=&quot;버튼 이름이 하나 바뀌면, 단위 테스트 10개가&amp;amp;nbsp;실패합니다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SeUqX/dJMcacH84fr/QslIWMKfaIdRKmHfWDJLo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSeUqX%2FdJMcacH84fr%2FQslIWMKfaIdRKmHfWDJLo0%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;1544&quot; height=&quot;756&quot; data-origin-width=&quot;1544&quot; data-origin-height=&quot;756&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;버튼 이름이 하나 바뀌면, 단위 테스트 10개가&amp;nbsp;실패합니다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이와 동시에, 우리는 너무나도 잘 알고 있습니다. 고객들이 얼마나 변덕스러운지. 기획과 디자인이 얼마나 자주 바뀌는지. 화면에 &lt;b&gt;보이는 &lt;/b&gt;요소 즉 UI가 얼마나 빨리 나타났다, 수정되었다, 사라졌다, 다시 나타나는지를 말이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한창 개발이 진행 중인 프로덕트나 기능일수록, UI 관련 요구 사항은 시도 때도 없이 변경됩니다. 그리고 이는 &lt;b&gt;UI에 의존&lt;/b&gt;하는 테스트 코드는 &lt;b&gt;조만간 실패할 것&lt;/b&gt;이 불 보듯 뻔함 을 의미합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 무엇을 테스트할 것인가 에 대한&amp;nbsp;의문&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대체 무엇을 테스트해야 하는가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트 개발자들이 테스트 코드 작성을 망설이게 만드는 두 번째 고민입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 프론트엔드 단위 테스트 관련 예제들을 보면, 대부분 &lt;b&gt;특정 페이지 단위&lt;/b&gt;로 테스트 코드가 작성된 것을 볼 수 있습니다. 해당 페이지에서 요구되는 동작이나, 존재하는 기능이 몇 개 없을 때는 이것이 크게 어렵지 않죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 우리가 실제로 마주하는 제품들은 하나의 페이지에 수많은 요소와 로직들이 얽혀있기 마련입니다. 이로 인해 소스코드 작성보다, 테스트 환경 구축 자체에 많은 시간과 노력이 소요되고 맙니다. 하나의 페이지를 제대로 테스트하려면 관련된 &lt;b&gt;모든 상태 관리, API 호출 등을 모킹 하거나 스파이&lt;/b&gt;로 설정해야 하는데&amp;hellip; 이 과정이 그리 달가울 리가 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;b&gt;작은 컴포넌트 단위로 테스트를 작성&lt;/b&gt;하면, 테스트 자체가 너무 당연한 것들(예: &amp;ldquo;화면에 이 컴포넌트가 나타난다&amp;rdquo;)을 검증하는 것이 아닌가 하는 의문이 들 수 있습니다. 이는 실제 비즈니스 로직을 검증하기보다는 단순히 &lt;b&gt;UI 요소가 표시 되는지 여부만 확인&lt;/b&gt;하게 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, &lt;b&gt;특정 상태를 변경하는 로직이나 복잡한 Hook을 테스트하려고 해도&lt;/b&gt; 다양한 상태 관리 로직을 모킹해야 하며, 이 과정 역시 상당히 까다롭고 번거롭습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 막상 테스트 코드를 작성하려고 해도, 무엇을 어디에서부터 어떻게 테스트할 것인지에 대한 의문이 발생해, 몇 번 테스트 코드를 작성하다 금방 포기해버리고 마는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 지속 가능한 단위 테스트&amp;nbsp;작성법&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테스트 코드가 외부 요인으로 인해 너무 쉽게 깨진다.&lt;/li&gt;
&lt;li&gt;무엇을 테스트해야 하는지 모호하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 요인은 프론트 개발자들이 단위 테스트와 멀어지게 만듭니다. 그리고 우리는 &lt;b&gt;이를 받아들여야만 합니다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무리 테스트 코드의 중요성을 설파해도, 간단한 테스트 도구들이 나타나도, 이 두 가지 원인이 존재하는 이상 프론트엔드 개발자는 절대로 테스트 코드와 가까워질 수가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 어떻게 해야 할까요? 어떻게 해야 프론트엔드 영역에서도 단위 테스트를 지속 가능하게 작성할 수 있을까요? 저는 그 대답을 앞서 설명한 클린 아키텍처 철학에서 찾을 수 있었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;도메인 영역에 대한 단위 테스트를 작성하라.&lt;/b&gt;&lt;/blockquote&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;1100&quot; data-origin-height=&quot;592&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1Ak6K/dJMcacOUFRE/9GVLCQCdvCM5a4AjklMprk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1Ak6K/dJMcacOUFRE/9GVLCQCdvCM5a4AjklMprk/img.png&quot; data-alt=&quot;소스 코드가 도메인 영역에 가까울 수록, 단위 테스트를 작성해야만&amp;amp;nbsp;합니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1Ak6K/dJMcacOUFRE/9GVLCQCdvCM5a4AjklMprk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1Ak6K%2FdJMcacOUFRE%2F9GVLCQCdvCM5a4AjklMprk%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;583&quot; height=&quot;314&quot; data-origin-width=&quot;1100&quot; data-origin-height=&quot;592&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;소스 코드가 도메인 영역에 가까울 수록, 단위 테스트를 작성해야만&amp;nbsp;합니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;도메인 영역에 대한 단위 테스트를 작성하라.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 원칙을 앞서 얘기한 FSD 패턴에 적용한다면, 하위 계층, 즉 entity나 feature에 속하는 segement(FSD 패턴 계층 구조 중 하나. ui, api, model, lib 등 목적에 의해 그룹화된 코드를 의미합니다)에 대한 단위 테스트를 작성하라는 의미로 이해할 수 있겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 저는 왜 이런 원칙을 제시한 걸까요? 이 원칙을 통해 1) 테스트 코드가 외부 요인으로 인해 너무 쉽게 깨진다 2) 무엇을 테스트해야 하는지 모호하다 라는 두 걸림돌이 제거되기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단위 테스트는 도메인이 변경될 때만&amp;nbsp;실패한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 영역의 코드는 애플리케이션의 핵심 비즈니스 로직과 규칙을 정의하는 코드로, 외부 UI 요소나 프레임워크와의 연계보다는 애플리케이션 자체의 동작을 결정하는 부분입니다. 이러한 영역의 코드에 대한 테스트는 UI 변화와 무관하게 비즈니스 로직을 검증할 수 있으므로, UI 요구사항이 바뀌더라도 테스트 코드가 쉽게 깨지지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;1022&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bE1o2i/dJMcabJeMxs/PG2poKKVLfBkSZjgKNApS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bE1o2i/dJMcabJeMxs/PG2poKKVLfBkSZjgKNApS1/img.png&quot; data-alt=&quot;도메인 영역에 의존하는 단위&amp;amp;nbsp;테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bE1o2i/dJMcabJeMxs/PG2poKKVLfBkSZjgKNApS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbE1o2i%2FdJMcabJeMxs%2FPG2poKKVLfBkSZjgKNApS1%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;709&quot; height=&quot;557&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;1022&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도메인 영역에 의존하는 단위&amp;nbsp;테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이것이 모든 UI에 대한 단위 테스트 코드를 작성하지 않는다는 의미는 아닙니다. 만약 어떠한 UI 요소가 도메인 영역에 포함된다면, 해당 UI 컴포넌트에 대한 테스트 코드는 작성해야겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 중요한 것은 &lt;b&gt;해당 소스코드가 도메인 영역에 가까운지&lt;/b&gt;를 판단하는 것이며, 이 판단 과정에서 발생한 계층의 &lt;b&gt;분리&lt;/b&gt;를 통해 우리의 애플리케이션(과 테스트 코드를)을 외부의 변경사항에 무관하게 유지할 수 있게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무엇을 테스트할 것인지&amp;nbsp;명확해진다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇을 테스트할 것인가. 이제 답변은 간단해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;도메인 영역에 소스 코드를 추가&lt;/b&gt;할 때, &lt;b&gt;단위 테스트를 작성&lt;/b&gt;하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 대상이 React 컴포넌트인지, 유틸 함수인지, 특정 라이브러리인지는 중요하지 않습니다. 테스트 환경 구축이나 테스트 케이스 작성이 복잡하더라도, 이것이 도메인 영역에 속한다고 판단했다면, 단위 테스트를 작성해야만 합니다.&lt;/p&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결국 중요한 건? (+ 우리는 무엇에 집중해야&amp;nbsp;하는가)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇습니다. 프론트엔드에서 지속 가능한 단위 테스트를 작성하기 위해서는, 반드시 애플리케이션 내 모든 소스코드들이 &lt;b&gt;계층에 따라 구분&lt;/b&gt;되어 있어야 합니다. 각 구분된 계층들이 의도(도메인 영역에 가까운가, 비즈니스 정책을 얼마만큼 구현하는가)에 따라 나뉘어있고, 이들의 의존 방향이 하나로 정해져 있어야만, 지속 가능한 단위 테스트를 작성할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czgbwZ/dJMcaklSm8o/bz5n2TmitcHtY15BVheBeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czgbwZ/dJMcaklSm8o/bz5n2TmitcHtY15BVheBeK/img.png&quot; data-alt=&quot;도구 이전에 우리가 해야할&amp;amp;nbsp;고민은?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czgbwZ/dJMcaklSm8o/bz5n2TmitcHtY15BVheBeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczgbwZ%2FdJMcaklSm8o%2Fbz5n2TmitcHtY15BVheBeK%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;503&quot; height=&quot;287&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도구 이전에 우리가 해야할&amp;nbsp;고민은?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 프론트엔드 테스트 코드의 주된 논의는 &lt;b&gt;얼마나 사용자와 동일한 환경에서 테스트할 수 있는가&lt;/b&gt;에 집중되어 있다고 느낍니다. 테스트 환경에서 무언가를 입력하고, 클릭하고, 화면에 의도한 요소가 뜨는지를 확인한다. 이를 돕는 많은 라이브러리들이 나왔고, 덕분에 테스트 코드를 작성하는 것도 굉장히 쉬워졌습니다. 이는 분명한 사실이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여러 프론트엔드 프로젝트들을 담당하면서, 도메인 영역이 분리되지 않은 상태의 단위 테스트 코드는, 작성하는데 들이는 노력 대비 이점이 적고 그 수명도 지나치게 짧다는 것을 느꼈습니다. FIRST 원칙을 지키지 못하는, 이름만 단위 테스트인 코드들이 탄생할 수밖에 없었습니다. 그리고 이는 팀 내부적으로 &amp;lsquo;단위 테스트는 아무런 도움도 주지 못한다&amp;rsquo;라는 분위기로까지 이어지고 말았습니다.&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;FSD 패턴을 통해 각 모듈들을 계층에 따라 분리하고, 도메인 영역에 대한 단위 테스트를 작성하자 여러 문제점들이 해결되었습니다. 단위 테스트가 정상 동작하는 애플리케이션을 만드는 데 실질적인 도움을 주기 시작했습니다. 코드를 수정하는 속도는 빨라졌고, 수정사항을 반영할 때 망설임이 크게 줄었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;우리가 속한 도메인은 무엇인가. &lt;br /&gt;각 코드들의 계층은 어떻게 나눌 것인가. &lt;br /&gt;해당 모듈의 책임과 관심사는 무엇인가.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 프론트엔드 개발자들은 이러한 질문을 던져야만 합니다. 이러한 고민이 선행되어야만, 우리는 지속 가능한 단위 테스트를 작성할 수 있습니다. 그리고 지속 가능한 단위 테스트가 점차 쌓였을 때, 우리는 요구 사항을 올바르게 구현하고 수정 사항을 빠르게 반영하는, 좋은 소프트웨어를 만들어낼 힘을 비로소 갖게 됩니다.&lt;/p&gt;</description>
      <category>Learn/FrontEnd</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/415</guid>
      <comments>https://23life.tistory.com/entry/%EC%A7%80%EC%86%8D-%EA%B0%80%EB%8A%A5%ED%95%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95-%EC%98%A4%EB%8A%98%EB%8F%84-%EC%8B%A4%ED%8C%A8%ED%95%9C-%EC%9A%B0%EB%A6%AC%EC%9D%98-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9C%84%ED%95%B4#entry415comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:54 +0900</pubDate>
    </item>
    <item>
      <title>StepBy AWS S3 + CloudFront 도입기</title>
      <link>https://23life.tistory.com/entry/AWS-S3-CloudFront-%EB%8F%84%EC%9E%85%EA%B8%B0-%EA%B8%80%EB%A1%9C%EB%B2%8C-SDK-%EC%84%9C%EB%B9%99%EC%9D%98-%EC%8B%9C%ED%96%89%EC%B0%A9%EC%98%A4%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;JavaScript SDK란?&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;StepBy는 고객사에서 생성한 가이드를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;코드로 직접 실행&lt;/b&gt;할 수 있는 기능을 제공합니다. 이를 통해 안정적으로 인앱 가이드를 테스트하고 배포할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 위해 JavaScript SDK 파일을 제공하고 있으며, 고객사는 &amp;lt;script&amp;gt; 태그를 통해 손쉽게 자신의 애플리케이션에 삽입해 사용할 수 있었습니다. 이 SDK 파일(index.min.js)은 StepBy 서비스와 고객사의 애플리케이션 간 연결을 담당하는 핵심적인 구성 요소였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;SDK 파일을 어떻게 서빙할지&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;결정하는 과정에서 여러 고민과 시행착오를 겪었고, 최종적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;AWS S3와 CloudFront를 조합&lt;/b&gt;한 방식으로 문제를 해결했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 AWS S3와 CloudFront를 도입하며 겪었던 기술적 고민과 시행착오에 대해 이야기하려 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;고려사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK 파일 서빙 방식을 결정하기 전 아래 사항들을 고려해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 고객사의 애플리케이션 로드 속도에 미치는 영향&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK 파일은 고객사의 애플리케이션에 삽입됩니다. 따라서 이 파일의 다운로드 속도가 느리거나 가용성이 떨어질 경우, 고객사의 페이지 로드 속도와 사용자 경험에 악영향을 미칠 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 글로벌 서비스 및 사용자 대응&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 POC 기업 중에는 통합 클라우드 운영 관리 플랫폼인 B사가 있었습니다. B사는 글로벌 사용자를 대상으로 서비스를 제공하고 있었기 때문에, 전 세계에 분포한 사용자를 대상으로 SDK 파일을 빠르고 안정적으로 제공하는 것 역시 중요한 과제였습니다. (StepBy 또한 글로벌 서비스를 목표로 하기 때문에, 이를 반드시 고려해야 했죠.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 상황에서 선택 가능한 후보들은 다음과 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;1. &lt;b&gt;단순 웹 서버 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적인 방법으로, 서버에 SDK 파일을 업로드하고 이를 정적 파일로 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설정이 간단하고 초기 도입 비용이 적음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성능 저하: 사용자와 서버 간 물리적 거리가 멀어질수록 파일 응답 속도가 느려질 가능성이 높음.&lt;/li&gt;
&lt;li&gt;확장성 부족: 고객사 요청이 급격히 증가할 경우 서버 과부하 위험이 있음.&lt;/li&gt;
&lt;li&gt;글로벌 서비스에 비효율적: 글로벌 사용자를 대상으로 하기에 적합하지 않음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;2. &lt;b&gt;지역별 데이터센터 분산&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자와의 물리적 거리를 줄이기 위해 각 지역에 데이터센터를 설정하고, 사용자 위치에 맞는 서버에서 SDK 파일을 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자와 가까운 서버에서 SDK를 제공하여 응답 속도 개선 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자와 가까운 서버에서 SDK를 제공하여 응답 속도 개선 가능.&lt;/li&gt;
&lt;li&gt;운영 비용 상승: 각 데이터센터를 운영하고 관리하는 비용이 상당히 큼.&lt;/li&gt;
&lt;li&gt;초기 투자 부담: 소규모 스타트업이 감당하기 어려운 초기 설정 비용이 발생. (사실상 불가능하죠!)&lt;/li&gt;
&lt;li&gt;운영 복잡성: 데이터센터 설정, 유지보수, 라우팅 로직 구현 등의 작업이 번거로움.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;3. &lt;b&gt;CDN 서비스 이용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글로벌 캐시 서버를 활용한 CDN(Content Delivery Network)을 통해, 직접 지역별 데이터센터를 구축하지 않고도 운영 복잡성을 줄이고 초기 투자 부담 없이 빠른 콘텐츠 전달이라는 장점을 누릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CDN이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CDN은 콘텐츠 전달 네트워크로, 사용자와 가까운 위치에 캐시 서버를 두어 정적 파일(예: 이미지, 동영상, JavaScript 등)을 빠르고 안정적으로 전달하는 기술입니다. 글로벌 서비스를 지원하는 데 필수적인 도구로, 사용자가 어디에 있든 일정한 속도로 파일을 다운로드할 수 있게 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성능 보장: 사용자와 가까운 글로벌 캐시 서버를 통해 빠른 파일 전달이 가능함.&lt;/li&gt;
&lt;li&gt;운영 간소화: 데이터센터 구축 없이도 글로벌 서비스를 손쉽게 제공 가능.&lt;/li&gt;
&lt;li&gt;비용 효율성: 초기 투자 비용 없이 사용량에 따라 지불하는 유연한 비용 구조.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;학습 비용: 다양한 CDN 서비스(AWS CloudFront, Akamai, Cloudflare 등)에 대한 이해와 설정 방법을 학습해야 하며, 최적화 작업이 필요함.&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;해결책: AWS S3 + CloudFront&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy는 대부분의 서비스를 AWS 인프라에서 운영하고 있습니다. 서버, 데이터베이스, 로깅 등 다양한 AWS 서비스를 이미 활용하고 있었기에, &lt;b&gt;AWS S3와 CloudFront&lt;/b&gt;를 선택하면 기존 인프라와의 통합이 용이하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 생태계를 활용함으로써 유지보수와 설정 작업을 간소화할 수 있었고, 글로벌 사용자에게 안정적으로 파일을 제공할 수 있는 CDN 기능까지 함께 확보할 수 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;S3란? CloudFront란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://aws.amazon.com/s3/&quot; data-href=&quot;https://aws.amazon.com/s3/&quot;&gt;S3&lt;/a&gt;(Simple Storage Service): AWS에서 제공하는 정적 파일 스토리지 서비스로, 안정적이고 확장 가능한 파일 저장소입니다.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://aws.amazon.com/cloudfront/&quot; data-href=&quot;https://aws.amazon.com/cloudfront/&quot;&gt;CloudFront&lt;/a&gt;: AWS의 글로벌 콘텐츠 전송 네트워크(CDN) 서비스로, 전 세계에 분산된 엣지 서버를 통해 파일을 빠르고 안정적으로 제공합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AWS S3 + CloudFront 조합하기&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1048&quot; data-origin-height=&quot;1132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRq8L0/dJMcafLDz28/eMkZ1krHG3ptjELnM0F3K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRq8L0/dJMcafLDz28/eMkZ1krHG3ptjELnM0F3K0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRq8L0/dJMcafLDz28/eMkZ1krHG3ptjELnM0F3K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRq8L0%2FdJMcafLDz28%2FeMkZ1krHG3ptjELnM0F3K0%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;518&quot; height=&quot;560&quot; data-origin-width=&quot;1048&quot; data-origin-height=&quot;1132&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS S3와 CloudFront를 조합해 다음과 같은 이점을 얻을 수 있었습니다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;빠른 응답 속도: CloudFront의 글로벌 엣지 서버가 사용자의 위치에 맞는 서버에서 SDK 파일을 제공합니다.&lt;/li&gt;
&lt;li&gt;확장성: 대규모 요청을 처리할 수 있는 S3의 확장성과 CloudFront의 캐싱 기술을 활용.&lt;/li&gt;
&lt;li&gt;유지보수 간소화: 파일 업로드는 S3에서, 글로벌 서빙은 CloudFront에서 처리하여 관리가 간단해졌습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;S3 + CloudFront 조합으로 겪었던 문제와 해결&amp;nbsp;방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 브라우저 캐시로 인한 최신 버전 다운로드&amp;nbsp;불가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK 파일 업데이트 이후에도 일부 고객사에서 최신 버전이 다운로드되지 않는 문제가 발생했습니다. 이는 브라우저와 CDN의 캐싱 정책 때문이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CloudFront는 기본적으로 캐싱을 활용하여 빠른 응답 속도를 제공합니다. 하지만 캐시가 무효화되지 않으면 이전 버전의 파일을 계속 제공하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;해결 방법&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub Actions를 활용해 SDK 파일 배포 시 자동으로 &lt;b&gt;캐시 무효화&lt;/b&gt; 작업을 수행했습니다.&lt;/li&gt;
&lt;li&gt;캐시 무효화는 CloudFront의 Invalidation API를 통해 구현했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;dsconfig&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;- name: Deploy to S3
  run: |
  aws s3 cp ./dist/index.min.js s3://your-bucket-name/sdk/ --acl public-read

- name: Invalidate CloudFront Cache
  run: |
  aws cloudfront create-invalidation \
    --distribution-id YOUR_DISTRIBUTION_ID \
    --paths &quot;/sdk/index.min.js&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. CORS로 인한 SDK 다운로드&amp;nbsp;불가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 고객사에서 &amp;lt;script&amp;gt; 태그를 통해 SDK를 삽입했을 때, CORS(Cross-Origin Resource Sharing) 에러가 발생하는 상황이 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;257&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kwQ7p/dJMb99LqacN/vsQVZidFkuCLneoPVM91k1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kwQ7p/dJMb99LqacN/vsQVZidFkuCLneoPVM91k1/img.png&quot; data-alt=&quot;아찔했던&amp;amp;hellip;  &quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kwQ7p/dJMb99LqacN/vsQVZidFkuCLneoPVM91k1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkwQ7p%2FdJMb99LqacN%2FvsQVZidFkuCLneoPVM91k1%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;624&quot; height=&quot;100&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;257&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;아찔했던&amp;hellip;  &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;S3 데이터는 보안상의 이유로 모든 퍼블릭 엑세스를 차단했으며, 버킷 정책을 토해 CloudFront를 통해서만 접근 가능하도록 설정한 상태였습니다.&lt;/li&gt;
&lt;li&gt;CloudFront에서 &lt;b&gt;원본 요청 정책(Origin Request Policy)&lt;/b&gt; 을 CORS-S3Origin으로 설정했으나, 이 설정이 S3에서 반환되는 응답 헤더에 Access-Control-Allow-Origin 값이 포함되지 않아 발생한 문제였습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;해결 방법&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;S3 버킷 정책&lt;/b&gt;: S3 데이터는 CloudFront에서만 접근 가능하도록 제한하며, 모든 퍼블릭 엑세스를 차단했습니다. 이를 위해 S3 버킷 정책에서 CloudFront의 OAI(Origin Access Identity)를 허용하도록 설정했습니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-code-block-lang=&quot;json&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// s3 버킷 정책
{
  &quot;Version&quot;: &quot;2008-10-17&quot;,
  &quot;Id&quot;: &quot;PolicyForCloudFrontPrivateContent&quot;,
  &quot;Statement&quot;: [
      {
          &quot;Sid&quot;: &quot;1&quot;,
          &quot;Effect&quot;: &quot;Allow&quot;,
          &quot;Principal&quot;: {
              &quot;AWS&quot;: &quot;arn:aws:iam::cloudfront:origin-access-identity/EXAMPLE&quot;
          },
          &quot;Action&quot;: &quot;s3:GetObject&quot;,
          &quot;Resource&quot;: &quot;arn:aws:s3:::stepby-bucket-name/*&quot;
      }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CloudFront 원본 요청 정책&lt;/b&gt;: CORS-S3Origin을 사용했으나, 기본적으로 응답 헤더에 Access-Control-Allow-Origin이 누락되는 문제가 있었습니다. 이를 해결하기 위해 &lt;b&gt;응답 헤더 정책(Response Header Policy)&lt;/b&gt; 에 사용자 정의 정책을 추가해 관련 CORS 헤더를 명시적으로 설정했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;930&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RRozB/dJMcafLDz29/NofZdFCsoZdIa0hJXiwkq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RRozB/dJMcafLDz29/NofZdFCsoZdIa0hJXiwkq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RRozB/dJMcafLDz29/NofZdFCsoZdIa0hJXiwkq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRRozB%2FdJMcafLDz29%2FNofZdFCsoZdIa0hJXiwkq1%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;1600&quot; height=&quot;930&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;930&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cloudfront 배포 -&amp;gt; 동작 생성 으로 직접 응답 헤더 정책을 생성해 적용!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;최종 구현&amp;nbsp;사항&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy의 SDK 배포를 최종적으로 구현한 모습입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. S3 버킷에 SDK 파일 업로드&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;index.min.js 파일을 S3에 저장하며, 모든 퍼블릭 엑세스를 차단.&lt;/li&gt;
&lt;li&gt;CloudFront만 접근할 수 있도록 버킷 정책 설정.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. CloudFront 배포 설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;속도&lt;/b&gt;: 글로벌 엣지 서버를 통해 빠른 콘텐츠 전달.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최신성&lt;/b&gt;: 캐시 무효화(Cache Invalidation)로 항상 최신 파일 제공.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안&lt;/b&gt;: HTTPS 프로토콜 적용 및 CORS 문제 해결.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;b&gt;자동화된 배포&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub Actions를 사용해 S3 업로드 및 CloudFront 캐시 무효화를 자동화.&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;&lt;b&gt;마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS S3와 CloudFront의 조합을 통해 JavaScript SDK 파일을 글로벌 사용자에게 안정적으로 서빙했습니다. 이를 통해 페이지 로드 속도를 최적화하고, 글로벌 사용자를 위한 확장성을 확보했으며, 보안과 최신성을 보장하는 배포 환경을 구축할 수 있었습니다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>Learn/software</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/417</guid>
      <comments>https://23life.tistory.com/entry/AWS-S3-CloudFront-%EB%8F%84%EC%9E%85%EA%B8%B0-%EA%B8%80%EB%A1%9C%EB%B2%8C-SDK-%EC%84%9C%EB%B9%99%EC%9D%98-%EC%8B%9C%ED%96%89%EC%B0%A9%EC%98%A4%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95#entry417comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:51 +0900</pubDate>
    </item>
    <item>
      <title>IntersectionObserver로 매끄러운 인앱 가이드 만들기</title>
      <link>https://23life.tistory.com/entry/IntersectionObserver%EB%A1%9C-%EB%A7%A4%EB%81%84%EB%9F%AC%EC%9A%B4-%EC%9D%B8%EC%95%B1-%EA%B0%80%EC%9D%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9A%94%EC%86%8C-%EA%B0%80%EC%8B%9C%EC%84%B1-%ED%8C%90%EB%8B%A8-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C-%EB%A7%8C%EB%82%9C-%EC%98%88%EC%99%B8-%EC%83%81%ED%99%A9%EA%B3%BC-%EA%B7%B8-%EC%9B%90%EC%9D%B8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy는 고객사의 웹 서비스에서 동작하는 인앱 가이드를 제공하는 서비스입니다. 고객사 별 다양한 예외 상황에 효과적으로 대응하기 위해 노력하고 있으며, 이를 위해서는 브라우저의 기본 동작을 이해하는 것이 무엇보다 필수적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 특정 요소가 화면에 &lt;b&gt;&amp;lsquo;보이는지&amp;rsquo;&lt;/b&gt; 여부를 판단하는 과정에서 마주한 예외 상황에 대해 공유하고자 합니다. &lt;b&gt;IntersectionObserver&lt;/b&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;보이냐, 보이지 않느냐.&amp;nbsp;그것이&amp;hellip;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;튜토리얼을 경험하는 사용자는 진행 중에도 웹 페이지와 상호 작용 할 수 있습니다. 이때 인앱 가이드에서 가리키고 있던 요소가 상호 작용을 통해 화면에서 &lt;b&gt;&amp;lsquo;보이지 않게&amp;rsquo;&lt;/b&gt;되면, 인앱 가이드 역시 이를 감지해 적절한 UI를 띄워줘야 하죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 브라우저 API인 IntersectionObserver를 사용했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;IntersectionObserver란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot; data-href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot;&gt;IntersectionObserver&lt;/a&gt;는 특정 요소가 뷰포트(또는 다른 요소)와 교차하는 상태를 감지할 수 있는 브라우저 API입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;const target = document.querySelector('#target');
const options = { root: null, threshold: 0.5 }; // 뷰포트 기준, 요소가 50% 보일 때 트리거
const observer = new IntersectionObserver((entries) =&amp;gt; {
  entries.forEach((entry) =&amp;gt; {
    console.log(entry.isIntersecting); // true: 보임, false: 보이지 않음
  });
}, options);
observer.observe(target);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무한 스크롤 구현 시 새로운 데이터 조회 시점을 판단하거나, Lazy Loading 이미지를 화면에 보이는 시점에 로드할 때 유용하게 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;주요 속성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. &lt;b&gt;entry.isIntersecting&lt;/b&gt;&amp;nbsp;: 관찰 대상이 root와 교차하고 있는지 여부&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;true: 요소가 화면에 보이기 시작&lt;/li&gt;
&lt;li&gt;false: 요소가 화면에서 사라짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;root&amp;nbsp;: &lt;/b&gt;교차 여부를 판단할 기준이 되는 요소&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;null: 브라우저 뷰포트를 기준으로 설정합니다.&lt;/li&gt;
&lt;li&gt;특정 DOM 요소: 지정된 요소를 기준으로 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;b&gt;threshold&lt;/b&gt;&amp;nbsp;: 타겟 요소의 가시 비율(0.0~1.0)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;0: 요소가 한 픽셀이라도 보이면 callback 호출&lt;/li&gt;
&lt;li&gt;1: 요소가 완전히 보일 때만 callback 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 속성들을 통해 현재 타겟 요소가 화면에 보이는 상태인지 판단할 수 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// use-target-element-visibility.ts
export const useTargetElementVisibility = () =&amp;gt; {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() =&amp;gt; {
    const observer = new IntersectionObserver(
      ([entry]) =&amp;gt; {
        setIsVisible(entry.isIntersecting); // 가시성 상태 업데이트
      },
      {
        threshold: 0.3,
        root: null,
      },
    );
    observer.observe(targetElement); // 타겟 요소 관찰 시작
  }, []);

  return isVisible; // 가시성 여부 반환
}&lt;/code&gt;&lt;/pre&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;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bO2IZg/dJMcacai7ua/j2Z41CypUHMkik4YmpOz3K/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO2IZg/dJMcacai7ua/j2Z41CypUHMkik4YmpOz3K/img.gif&quot; data-alt=&quot;스크롤로 인해 타겟 요소가 보이지 않을 경우, 툴팁이 사라지는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO2IZg/dJMcacai7ua/j2Z41CypUHMkik4YmpOz3K/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bO2IZg/dJMcacai7ua/j2Z41CypUHMkik4YmpOz3K/img.gif&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;720&quot; height=&quot;405&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스크롤로 인해 타겟 요소가 보이지 않을 경우, 툴팁이 사라지는 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황: 모달 내부 요소 가시성 판단하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 얼마 지나지 않아 한 고객사의 VOC가 발생했습니다. 스크롤 발생 시, 툴팁이 오류처럼 동작한다는 제보였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pKlx2/dJMcacai7ub/hjzHSgdrRAPfqAxkdQUpV1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pKlx2/dJMcacai7ub/hjzHSgdrRAPfqAxkdQUpV1/img.gif&quot; data-alt=&quot;분명 툴팁이 사라져야할 것&amp;amp;nbsp;같은데..?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pKlx2/dJMcacai7ub/hjzHSgdrRAPfqAxkdQUpV1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/pKlx2/dJMcacai7ub/hjzHSgdrRAPfqAxkdQUpV1/img.gif&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;720&quot; height=&quot;405&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분명 툴팁이 사라져야할 것&amp;nbsp;같은데..?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 간단합니다. IntersectionObserver 옵션 중 &lt;b&gt;root가 null로 설정&lt;/b&gt;된 경우 &lt;b&gt;뷰포트를 기준&lt;/b&gt;으로 가시성 판단이 이루어지기 때문이었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1098&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2Uf4w/dJMcabJeNb5/ufCYLUt5aPcOjksXjd86Tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2Uf4w/dJMcabJeNb5/ufCYLUt5aPcOjksXjd86Tk/img.png&quot; data-alt=&quot;뷰포트와는 교차하고 있군요&amp;amp;nbsp; &quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2Uf4w/dJMcabJeNb5/ufCYLUt5aPcOjksXjd86Tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2Uf4w%2FdJMcabJeNb5%2FufCYLUt5aPcOjksXjd86Tk%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;1600&quot; height=&quot;1098&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1098&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;뷰포트와는 교차하고 있군요&amp;nbsp; &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모달에 overflow 속성을 적용해 시각적으로 보이지 않는 것일 뿐, 타겟 요소는 뷰포트와 교차하고 있기 때문에 isIntersecting이 true로 반환되는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 어떻게 해결할 수 있을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 해당 요소가 사라지기를 기대하는 이유는, 타겟 요소가&lt;b&gt; 스크롤이 발생하는 요소와 교차하기 않기 때문&lt;/b&gt;입니다. 따라서 1) 타겟 요소 기준 스크롤이 발생하는 요소를 찾은 후 2) 해당 요소를 기준으로 교차 여부를 확인하면 되겠죠.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;export const useTargetElementVisibility = () =&amp;gt; {
  const [isVisible, setIsVisible] = useState(false);
  
  useEffect(() =&amp;gt; {
    // targetElement를 거슬러 올라가며, 스크롤이 발생하는 요소를 조회
    const scrollParent = getScrollParent(targetElement);

    const observer = new IntersectionObserver(
      ([entry]) =&amp;gt; {
        setIsVisible(entry.isIntersecting);
      },
      {
        threshold: 0.3,
        // scrollParent가 존재할 경우 해당 요소를, 존재하지 않을 경우 뷰포트를 기준으로 교차 여부를 판단
        root: scrollParent instanceof HTMLElement ? scrollParent : null,
      },
    );
    observer.observe(targetElement);
  }, []);

  return isVisible;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 타겟 요소의 부모 중 스크롤이 존재하는(저는 &lt;b&gt;getComputedStyle&lt;/b&gt;을 통해 &lt;b&gt;overflow&lt;/b&gt; 속성을 확인했습니다) 요소가 존재할 경우, 해당 요소를 기준으로 교차 여부를 판단하게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHN7N7/dJMcajHgPy0/yFp7gkMiuu9f7kB3VpjuM1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHN7N7/dJMcajHgPy0/yFp7gkMiuu9f7kB3VpjuM1/img.gif&quot; data-alt=&quot;타겟 요소가 모달을 벗어나게 되면 자연스래 툴팁이&amp;amp;nbsp;사라집니다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHN7N7/dJMcajHgPy0/yFp7gkMiuu9f7kB3VpjuM1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cHN7N7/dJMcajHgPy0/yFp7gkMiuu9f7kB3VpjuM1/img.gif&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;720&quot; height=&quot;405&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;타겟 요소가 모달을 벗어나게 되면 자연스래 툴팁이&amp;nbsp;사라집니다&lt;/figcaption&gt;
&lt;/figure&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;p data-ke-size=&quot;size16&quot;&gt;하지만 (또) 얼마 지나지 않아 다른 고객사의 VOC가 발생했습니다. 툴팁이 화면에 보이지 않는다는 제보였죠.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1416&quot; data-origin-height=&quot;840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGgbZg/dJMcajHgPyY/dFMP2yNlt8bLCMALeEBXyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGgbZg/dJMcajHgPyY/dFMP2yNlt8bLCMALeEBXyk/img.png&quot; data-alt=&quot;테스트 환경에서 재현한 모습. 타겟 요소는 아무리 봐도 &amp;amp;ldquo;보이는&amp;amp;rdquo;&amp;amp;nbsp;상태입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGgbZg/dJMcajHgPyY/dFMP2yNlt8bLCMALeEBXyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGgbZg%2FdJMcajHgPyY%2FdFMP2yNlt8bLCMALeEBXyk%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;631&quot; height=&quot;374&quot; data-origin-width=&quot;1416&quot; data-origin-height=&quot;840&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 환경에서 재현한 모습. 타겟 요소는 아무리 봐도 &amp;ldquo;보이는&amp;rdquo;&amp;nbsp;상태입니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디버깅을 해보니, 분명 화면에 &lt;b&gt;보이는 요소&lt;/b&gt;임에도 불구하고 &lt;b&gt;isIntersecting이 false로 반환&lt;/b&gt;되고 있었습니다. 이유를 찾기 위해 해당 고객사 페이지를 개발자 도구로 분석했고, 그 결과 아래와 같이 스타일이 적용되어 있음을 확인했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;타겟 요소는 position: absolute 속성 설정&lt;/li&gt;
&lt;li&gt;타겟 요소의 바로 부모 요소에 overflow: auto 속성 적용, 그러나 position: relative는 아님&lt;/li&gt;
&lt;li&gt;타겟 요소와 가장 가까운 position: relative는 아예 다른 요소에 적용&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &lt;b&gt;IntersectionObserver의 타겟이 root 요소를 기준으로 위치하고 있지 않은 상황&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이게 왜 문제가 될까요? 어찌되었든 두 요소가 겹쳐져 있으니, isIntersecting이 true여야 하는 것 아닐까요? 이 의문을 해결하기 위해, IntersectionObserver의 동작 방식을 좀 더 살펴보았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;IntersectionObserver의 가시성 판단&amp;nbsp;방식&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntersectionObserver는 기본적으로 루트와 타겟의 좌표를 비교하여 두 요소가 교차하는 영역을 계산합니다. 루트 요소의 좌표에 타겟 요소의 좌표가 포함되는 경우, 두 요소가 교차하는 것으로 이해할 수 있겠죠.&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;하지만 IntersectionObserver의 동작 방식을 좀 더 탐구해본 결과, 교차 여부 판단이 단순히 두 요소의 &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Element/getBoundingClientRect&quot; data-href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Element/getBoundingClientRect&quot;&gt;뷰포트 기준 top, left, bottom, right 값&lt;/a&gt;을 비교하는 것에서 그치지 않음을 알게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히 말하자면, IntersectionObserver의 교차 여부 판단은 1) 브라우저가 생성한 렌더 트리에서 2) 각 노드들의 좌표 체계에 기반해 3) 크기와 위치를 계산한 뒤 이들을 비교하는 방식으로 진행됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 렌더 트리 (render-tree)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;렌더 트리는 HTML의 DOM(Document Object Model)과 CSSOM(CSS Object Model)을 결합하여 만들어진 &lt;b&gt;화면 표시 전용 데이터 구조&lt;/b&gt;입니다. 화면에 표시되는 요소들만 포함하며, 각 요소의 스타일, 위치, 크기 등이 계산되죠. 이때 렌더링 트리의 각 노드는 화면에 표시될 요소를 나타내며, 각 노드는 특정한 &lt;b&gt;좌표 체계&lt;/b&gt;를 기반으로 크기와 위치를 계산합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 좌표 체계 (Coordinate System)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌표 체계는 브라우저가 요소의 위치와 크기를 정의하기 위해 사용하는 참조 시스템입니다. 각 요소는 자신의 좌표 체계를 가지며, 부모 요소 또는 글로벌 뷰포트를 기준으로 상대적 위치가 정의됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 특징&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;로컬 좌표&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;부모 요소를 기준으로 계산됩니다.&lt;/li&gt;
&lt;li&gt;CSS의 position, transform, overflow 등의 속성에 따라 변합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;글로벌 좌표&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;뷰포트 또는 문서 전체를 기준으로 계산됩니다.&lt;/li&gt;
&lt;li&gt;스크롤 위치와 뷰포트 크기에 따라 달라집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. IntersectionObserver의 크기/위치&amp;nbsp;계산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 중요한 것은 렌더링 트리에서 노드들의 크기와 위치를 계산할 때, 각 노드들의 좌표 체계에 영향을 받는다는 점입니다. 노드의 위치를 계산할 때 부모 노드의 로컬 좌표 체계를 기준으로 자식 노드의 위치를 계산하거나, 최상위 노드(root)에서부터 현재 노드까지의 상대 좌표를 누적 계산해 글로벌 좌표를 얻는 식으로 말이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 특정 노드의 좌표 체계는, 아래와 같은 상황이 발생했을 때 다른 좌표 체계들과 &lt;b&gt;분리&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CSS Transform:&lt;/b&gt; transform: translate(50px, 50px)처럼 변환 속성이 적용되면 새로운 로컬 좌표 체계가 생성됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Stacking Context:&lt;/b&gt; z-index나 opacity 등이 적용된 경우, 독립적인 좌표 체계가 만들어집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Position 속성:&lt;/b&gt; position: fixed나 absolute는 글로벌 또는 특정 부모 기준으로 좌표를 재설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 위 케이스의 경우, 타겟 요소는 position absolute로 인해 &lt;b&gt;루트와 다른 좌표 체계에 속하고 있는 것&lt;/b&gt;이죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이렇게 &lt;b&gt;루트와 타겟의 좌표 체계가 분리&lt;/b&gt;된 경우, IntersectionObserver의 교차 여부 판단은 &lt;b&gt;인간의 시각으로 봤을 때의 결과와 달라질 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.w3.org/TR/intersection-observer/#calculate-intersection-rect-algo&quot; data-href=&quot;https://www.w3.org/TR/intersection-observer/#calculate-intersection-rect-algo&quot;&gt;IntersectionObserver는 교차 여부를 판단&lt;/a&gt;하기 위해, 루트와 타겟요소 각각에 대해 bounding box 계산한 뒤 겹치는 영역의 크기를 계산합니다. 이때 좌표 체계가 다르면, 루트와 타겟 요소의 bounding box를 비교하기 위한 &lt;b&gt;공통 기준점을 찾는 데 문제가 발생&lt;/b&gt;합니다. 각 요소의 좌표가 서로 다른 기준에서 계산되므로, 교차 여부를 판단을 위한 비교가 정확히 이뤄지지 않는 것이죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 교차 여부 판단 시 루트와 타겟의 좌표를 공통 좌표 체계로 변환한 뒤 비교해야 하는데, 위 케이스에서는 두 요소가 서로 다른 좌표 체계에 속하고 있기 때문에 비교 과정에서 공통 기준을 찾을 수 없었고, 이로 인해 가시성 여부를 올바르게 판단하지 못했던 것이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 경험을 통해 다양한 고객사 환경에 대응하기 위해서는 브라우저의 기본 동작을 올바르게 이해하는 것이 필수적임을 깨달았습니다. 특히 &lt;b&gt;브라우저 API&lt;/b&gt;들의 근본 원리를 파악하고 이를 기반으로 문제를 해결해야만, &lt;b&gt;모든 고객사 환경&lt;/b&gt;에서 &lt;b&gt;동일한 동작을 보장&lt;/b&gt;할 수 있음을 느꼈습니다.&lt;/p&gt;</description>
      <category>Learn/FrontEnd</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/419</guid>
      <comments>https://23life.tistory.com/entry/IntersectionObserver%EB%A1%9C-%EB%A7%A4%EB%81%84%EB%9F%AC%EC%9A%B4-%EC%9D%B8%EC%95%B1-%EA%B0%80%EC%9D%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9A%94%EC%86%8C-%EA%B0%80%EC%8B%9C%EC%84%B1-%ED%8C%90%EB%8B%A8-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C-%EB%A7%8C%EB%82%9C-%EC%98%88%EC%99%B8-%EC%83%81%ED%99%A9%EA%B3%BC-%EA%B7%B8-%EC%9B%90%EC%9D%B8#entry419comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:50 +0900</pubDate>
    </item>
    <item>
      <title>선언형 패턴으로 권한 검사 리팩토링하기</title>
      <link>https://23life.tistory.com/entry/%EC%84%A0%EC%96%B8%ED%98%95-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EA%B6%8C%ED%95%9C-%EA%B2%80%EC%82%AC-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%ED%95%98%EA%B8%B0-%EB%AA%85%EB%A0%B9%ED%98%95-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-%EC%84%A0%EC%96%B8%ED%98%95-%EC%BD%94%EB%93%9C%EB%A1%9C-%EA%B0%80%EB%8F%85%EC%84%B1%EA%B3%BC-%ED%99%95%EC%9E%A5%EC%84%B1%EC%9D%84-%EB%86%92%EC%9D%B4%EB%8A%94-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;React 개발에서 &lt;b&gt;선언적 패턴&lt;/b&gt;을 활용하면 코드의 &lt;b&gt;가독성, 재사용성, 유지보수성&lt;/b&gt;을 크게 개선할 수 있습니다. 이번 글에서는 &lt;b&gt;StepBy의 구독 권한 검사&lt;/b&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;기존 코드: 명령형 방식의&amp;nbsp;한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy에서는 특정 기능(예: 멤버 초대, 가이드 생성 등)을 사용하려면 &lt;b&gt;구독 여부&lt;/b&gt;에 따른 권한이 존재해야 합니다. 권한이 없는 사용자가 해당 기능을 사용하기 위해 버튼을 클릭하면, 구독 안내 모달을 띄워줘야 했죠.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJdEx3/dJMcagjtANH/pOalZfeUSlGRi23hy9QWh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJdEx3/dJMcagjtANH/pOalZfeUSlGRi23hy9QWh0/img.png&quot; style=&quot;width: 45.3144%; margin-right: 10px;&quot; data-origin-width=&quot;362&quot; data-origin-height=&quot;124&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;46.39&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJdEx3/dJMcagjtANH/pOalZfeUSlGRi23hy9QWh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJdEx3%2FdJMcagjtANH%2FpOalZfeUSlGRi23hy9QWh0%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;362&quot; height=&quot;124&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blG6JC/dJMcahJsf6u/v5utS3liT1efAu4XP3Ji0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blG6JC/dJMcahJsf6u/v5utS3liT1efAu4XP3Ji0K/img.png&quot; style=&quot;width: 29.1494%; margin-right: 10px;&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;426&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;29.84&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blG6JC/dJMcahJsf6u/v5utS3liT1efAu4XP3Ji0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblG6JC%2FdJMcahJsf6u%2Fv5utS3liT1efAu4XP3Ji0K%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;800&quot; height=&quot;426&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpyg0b/dJMcaklSp93/E5O5paUAL851nNnMeyq030/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpyg0b/dJMcaklSp93/E5O5paUAL851nNnMeyq030/img.png&quot; style=&quot;width: 23.2106%;&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;535&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;23.77&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpyg0b/dJMcaklSp93/E5O5paUAL851nNnMeyq030/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpyg0b%2FdJMcaklSp93%2FE5O5paUAL851nNnMeyq030%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;800&quot; height=&quot;535&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;똑같은 가이드 생성 버튼을 눌러도, 권한 여부에 따라 실행되는 동작이 달라야 했습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;초기에는 아래와 같은 명령형 방식으로 코드를 작성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;const handleInviteMember = () =&amp;gt; {
  if (
    // 권한 여부 조회
    isPermissionGranted(&quot;INVITE_MEMBER&quot;)
  ) {
    const inviteEmail = email;
    try {
      // 권한이 존재할 경우, 멤버 초대 로직 실행
      (...)
    } catch {
      // 멤버 초대 실패 예외 처리
      (...)
    }
  } else {
    // 권한이 존재하지 않을 경우, 안내 모달 활성화
    showPermissionModal({ type: 'INVITE_MEMBER' });
  }
};

return &amp;lt;button onClick={handleInviteMember}&amp;gt;멤버 초대하기&amp;lt;/button&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lsquo;멤버 초대하기&amp;rsquo; 버튼을 눌렀을 때 실행되는 handleInviteMember 함수 내부에 &lt;b&gt;1) 권한 체크&lt;/b&gt; &lt;b&gt;2) 멤버 초대 로직 실행&lt;/b&gt; &lt;b&gt;3) 권한 안내 모달 활성화&lt;/b&gt; 라는 세 개의 기능이 섞여 있는 모습입니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;// InviteMemberButton.tsx
const handleInviteMember = () =&amp;gt; {
  if (isPermissionGranted(&quot;INVITE_MEMBER&quot;)) {
    // 멤버 초대 로직
    (...)
  } else {
    showPermissionModal({ type: 'INVITE_MEMBER' });
  }
};

// AnalyzeInsightButton.tsx
const handleAnalyzeInsight = () =&amp;gt; {
  if (isPermissionGranted(&quot;INSIGHT&quot;)) {
    // 인사이트 분석 로직
    (...)
  } else {
    showPermissionModal({ type: 'INSIGHT' });
  }
};

// CreateGuideButton.tsx
const handleAdvanced= () =&amp;gt; {
  if (isPermissionGranted(&quot;CREATE_GUIDE&quot;)) {
    // 가이드 생성 로직
    (...)
  } else {
    showPermissionModal({ type: 'CREATE_GUIDE' });
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 권한 검사가 필요한 모든 함수들이 위와 같이 작성된다면 어떤 문제점이 발생할까요?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 맥락 이해&amp;nbsp;어려움&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 동작에 대한 &lt;b&gt;비즈니스 로직&lt;/b&gt;과, 이와 전혀 무관한 &lt;b&gt;권한 체크 + 예외 처리(권한 안내 모달)&lt;/b&gt; &lt;b&gt;로직&lt;/b&gt;이 매번 결합되어 코드의 흐름을 파악하기 어렵습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// InviteMemberButton.tsx
const handleInviteMember = () =&amp;gt; {
  if (isPermissionGranted(&quot;INVITE_MEMBER&quot;)) {
    const inviteEmail = email;
    try {
      // 이메일 유효성 검사
      // API 호출
      // 멤버 초대 성공 처리
    } catch {
      // 멤버 초대 실패 처리
    }
  } else {
    showPermissionModal({ type: 'INVITE_MEMBER' });
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 예시를 들었던 멤버 초대 기능의 경우 아래와 같은 흐름으로 코드가 동작해야 합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;이메일 유효성을 검사하고 &amp;mdash; 초대 메일 전송 API를 호출한 뒤 &amp;mdash; API 응답(성공/실패)을 처리한다&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InviteMemberButton.tsx 파일의 handleInviteMember 함수를 읽는 개발자는 당연히 이러한 맥락이 코드에 나타나길 기대하겠죠.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;// InviteMemberButton.tsx
const handleInviteMember = () =&amp;gt; {
  if (isPermissionGranted(&quot;INVITE_MEMBER&quot;)) {
    // (...?)
  } else {
    showPermissionModal({ type: 'INVITE_MEMBER' });
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 handleInviteMember 함수 처음부터 등장하는 권한 검사 조건문으로 인해, 멤버 초대 기능의 &lt;b&gt;맥락이 흐려집니다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 이 함수를 처음 보는 개발자라면? 조건문에 존재하는 isPermissionGranted와 showPermissionModal이 무엇인지부터 파악하려 하겠죠. &lt;b&gt;멤버 초대 기능과 아무 관련이 없는데도 말이죠!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 코드들이 쌓이면 쌓일수록 코드의 가독성은 떨어지고, 불필요한 결합도는 증대되며, 프로젝트 전체의 유지보수 비용도 점차 늘어납니다.  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 재사용성&amp;nbsp;저하&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 검사가 필요한 다른 기능(예: 인사이트 분석, 가이드 생성)에도 동일한 방식을 적용하면 어떻게 될까요?&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;go&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// AnalyzeInsightButton.tsx
const handleAnalyzeInsight = () =&amp;gt; {
  if (
    // INSIGHT 권한 검사
    isPermissionGranted(&quot;INSIGHT&quot;)
  ) {
    // 기존 비즈니스 로직 (인사이트 분석)
  } else {
    // INSIGHT 권한 안내 모달
    showPermissionModal({ type: 'INSIGHT' });
  }
};

// CreateGuideButton.tsx
const handleCreateGuide= () =&amp;gt; {
if (
    // CREATE_GUIDE 권한 검사
    isPermissionGranted(&quot;CREATE_GUIDE&quot;)
  ) {
    // 기존 비즈니스 로직 (가이드 생성)
  } else {
    // CREATE_GUIDE 권한 안내 모달
    showPermissionModal({ type: 'CREATE_GUIDE' });
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 비즈니스 로직들을 권한 검사-예외처리 조건문에 추가한 모습입니다. 이 과정에서, 검사한 권한에 맞는(&lt;i&gt;isPermissionGranted(&amp;ldquo;INSIGHT&amp;rdquo;)&lt;/i&gt;) 권한 안내 모달을 띄우는(&lt;i&gt;showPermissionModal({ type: &amp;lsquo;INSIGHT&amp;rsquo; })&lt;/i&gt;) 로직이 반복됨을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한이 존재하지 않을 경우 &amp;rarr; &lt;b&gt;해당 권한에 대한&lt;/b&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;명령형 방식의 문제점: 흐름을 방해하는 권한&amp;nbsp;검사&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 살펴본 코드에서 공통적으로 등장하는 패턴이 하나 있습니다. 바로 &lt;b&gt;권한 검사 로직이 각 기능의 핵심 로직과 뒤섞여 있다는 점&lt;/b&gt;이죠.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;go&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// 1) 주요 기능을 실행하기 전
const handleSomething= () =&amp;gt; {
if (
    // 2) 매번 권한을 검사하고
    isPermissionGranted(&quot;SOME_GRANT&quot;)
  ) {
  } else {
    // 3) 권한 안내 모달을 띄우는 방식
    showPermissionModal({ type: 'SOME_GRANT' });
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 코드 구조는 &lt;b&gt;비즈니스 로직의 맥락을 흐리게 만들고&lt;/b&gt;, 중복된 코드가 늘어나면서 유지보수성을 떨어뜨립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 이런 문제를 어떻게 해결할 수 있을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 우리가 고민해야 할 것은 &lt;b&gt;&amp;ldquo;권한 검사를 기능별로 직접 처리해야 하는가?&amp;rdquo;입니다.&lt;/b&gt; 사실상 &lt;b&gt;권한이 없을 때 모달을 띄우는 로직은 모든 버튼에서 동일&lt;/b&gt;하기 때문에, 이를 개별 함수에서 직접 처리할 필요는 없겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해, 코드 작성 방식을 &lt;b&gt;명령형에서 선언형으로 바꿔볼 수 있습니다.&lt;/b&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;명령형 vs 선언형&amp;nbsp;비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그래밍에서 코드를 작성하는 방식에는 크게 두 가지 접근법이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 명령형(Imperative) 패턴&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;어떻게(How)&amp;rdquo; 동작해야 하는지를 단계별로 명시하는 방식&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 배열 [1, 2, 3, 4, 5]의 총합을 구한다고 가정해 보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;const numbers = [1, 2, 3, 4, 5];
let sum = 0;

for (let i = 0; i &amp;lt; numbers.length; i++) {
  sum += numbers[i];
}

console.log(sum); // 15t&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드의 흐름을 보면,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;sum 변수를 만들고&lt;/li&gt;
&lt;li&gt;for 문을 사용해 배열을 순회하면서&lt;/li&gt;
&lt;li&gt;sum에 값을 하나씩 더하는 절차를 직접 명시하고 있습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;&amp;ldquo;어떻게(HOW) 합을 구할 것인가&amp;rdquo;를 하나하나 직접 설명&lt;/b&gt;하는 방식이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 코드의 실행 흐름을 직관적으로 보여준다는 장점이 있지만, &lt;b&gt;중복이 많아지고 유지보수가 어려워지는 단점&lt;/b&gt;이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 선언형(Declarative) 패턴&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;무엇(What)&amp;ldquo;을 해야 하는지를 설명하는 방식&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령형 패턴과 달리, 선언형 패턴은 무엇을 할 것인가 라는 &lt;b&gt;의도&lt;/b&gt;를 중심으로 코드를 작성하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 명령형 코드를 reduce를 활용해 선언형 패턴으로 바꿀 수 있죠.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, cur) =&amp;gt; acc + cur, 0);

console.log(sum); // 15&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 &lt;b&gt;&amp;ldquo;배열을 모두 더해라(What)&amp;rdquo;라는&lt;/b&gt; 의도를 더 명확하게 나타냅니다. &lt;b&gt;반복문이 직접적으로 보이지 않고&lt;/b&gt;, 내부적으로 reduce가 알아서 루프를 돌려가며 계산을 수행하죠.&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;Wrapper Pattern을 통한 &lt;b&gt;선언형 코드&amp;nbsp;개선&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 앞서 살펴본 명령형 방식의 한계를 해결하기 위해, &lt;b&gt;Wrapper Pattern&lt;/b&gt;을 활용한 선언형 방식으로 코드를 개선했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 검사를 개별 함수에서 직접 처리하는 대신, &lt;b&gt;컴포넌트 레벨에서 추상화&lt;/b&gt;하여 코드의 가독성과 재사용성을 높이는 것이 핵심이죠!&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 멤버 초대 버튼 컴포넌트는 아래와 같이 작성됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// InviteMemberButton.tsx 
const InviteMemberButton = () =&amp;gt; {
  const handleInviteMember = () =&amp;gt; {
    const inviteEmail = email;
    // 멤버 초대 로직
  };

  return (
    &amp;lt;PermissionGuard permission={&quot;INVITE_MEMBER&quot;}&amp;gt;
      &amp;lt;button onClick={handleInviteMember}&amp;gt;멤버 초대하기&amp;lt;/button&amp;gt;
    &amp;lt;/PermissionGuard&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InviteMemberButton에는 &lt;b&gt;권한 검사와 관련된 로직들이 모두 사라지고&lt;/b&gt;, PermissionGuard라는 Wrapper 컴포넌트만 존재하는 것을 확인할 수 있습니다!&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;INVITE_MEMBER 권한이 존재하는 사용자가 버튼을 클릭할 경우, 멤버 초대 비즈니스 로직(handleInviteMember함수)이 실행되며, 권한이 존재하지 않는 사용자가 버튼을 클릭할 경우, INVITE_MEMBER 권한에 맞는 안내 모달이 활성화되죠.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 버튼들 역시 마찬가지입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// AnalyzeInsightButton.tsx
&amp;lt;PermissionGuard permission={&quot;INSIGHT&quot;}&amp;gt;
  &amp;lt;button onClick={handleAnalyzeInsight}&amp;gt;인사이트 분석하기&amp;lt;/button&amp;gt;
&amp;lt;/PermissionGuard&amp;gt;

// CreateGuideButton.tsx
&amp;lt;PermissionGuard permission={&quot;CREATE_GUIDE&quot;}&amp;gt;
  &amp;lt;button onClick={handleCreateGuide}&amp;gt;가이드 생성하기&amp;lt;/button&amp;gt;
&amp;lt;/PermissionGuard&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 이상 기존 비즈니스 로직(handleAnalyzeInsight, handleCreateGuide 함수 내부)에 권한 검사 &amp;rarr; 안내모달 활성화 라는 맥락이 뒤섞이지 않습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnalyzeInsightButton.tsx, CreateGuideButton.tsx 파일을 읽는 개발자는 &amp;ldquo;통계 분석하는 로직&quot;, &amp;ldquo;가이드 생성하는 로직&quot;에만 신경 쓸 수 있죠!&lt;/p&gt;
&lt;pre class=&quot;xml&quot; data-code-block-lang=&quot;xml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// SomeComponent.tsx
&amp;lt;PermissionGuard permission={&quot;SOME_PERMISSION&quot;}&amp;gt;
  &amp;lt;button onClick={handleSomething}&amp;gt;do something&amp;lt;/button&amp;gt;
&amp;lt;/PermissionGuard&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 검사가 필요한 버튼들이 추가되는 경우에도, Wrapper 컴포넌트인 PermissionGuard를 작성하고, 해당 권한을 선언하기만 하면 됩니다. (와!)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PermissionGuard 세부 구현&amp;nbsp;방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 PermissionGuard 컴포넌트는 어떻게 구현되어 있을까요?&lt;/p&gt;
&lt;pre class=&quot;dart&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;type PermissionType = &quot;INVITE_MEMBER&quot; | &quot;INSIGHT&quot; | &quot;CREATE_GUIDE&quot;;

interface PermissionGuardProps {
  children: ReactElement;
  permission: PermissionType;
}

export const PermissionGuard = ({
  children,
  permission,
}: PermissionGuardProps) =&amp;gt; {
  // 로컬 상태 저장소 (예시에서는 react-query 사용)
  const workspacePermissions = useQuery({
    queryKey: [&quot;workspace-permissions&quot;],
    queryFn: getWorkspacePermissions,
  });

  // 권한 안내 모달
  const [permissionModalVisible, setPermissionModalVisible] = useState(false);

  // Props로 전달받은 permission이 존재하는 워크스페이스인지 검사
  const hasPermission = workspacePermissions.find(
    (workspacePermission) =&amp;gt; permission === workspacePermission
  );
  // 권한이 존재하는 경우, children을 그대로 반환
  if (hasPermission) {
    return children;
  }

  // 권한이 존재하지 않는 경우, 1) 기존 Click 이벤트 실행 방지 2) 안내 모달 활성화
  return (
    &amp;lt;&amp;gt;
      {cloneElement(children, {
        onClick: (e: React.MouseEvent&amp;lt;HTMLElement&amp;gt;) =&amp;gt; {
          e.preventDefault();
          setPermissionModalVisible(true);
        },
      })}
      {permissionModalVisible &amp;amp;&amp;amp; &amp;lt;PermissionNoticeModal permission={permission}/&amp;gt;}
    &amp;lt;/&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PermissionGuard 컴포넌트는 단 하나의 동작만을 책임집니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;권한이 존재하지 않는 사용자가 자식 컴포넌트를 클릭한 경우&amp;nbsp;&lt;br /&gt;&amp;rarr; 해당 권한에 맞는 안내 모달을 띄운다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 비즈니스 로직을 PermissionGuard 컴포넌트가 책임지게 되면서, 다른 컴포넌트들은 해당 관심사에 더 이상 신경쓰지 않을 수 있게 된 것이죠.&lt;/p&gt;
&lt;pre class=&quot;dts&quot; data-code-block-lang=&quot;php&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// 로컬 상태 저장소 (예시에서는 react-query 사용)
const workspacePermissions = useQuery({
  queryKey: [&quot;workspace-permissions&quot;],
  queryFn: getWorkspacePermissions,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 시작할 때 조회한 사용자(워크스페이스)별 권한 목록을 react-query로 관리하고 있고,&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// Props로 전달받은 permission이 존재하는 워크스페이스인지 검사
const hasPermission = workspacePermissions.find(
  (workspacePermission) =&amp;gt; permission === workspacePermission
);
// 권한이 존재하는 경우, children을 그대로 반환
if (hasPermission) {
  return children;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Props로 전달받은 권한이 존재할 경우, children을 그대로 반환해, 기존 동작에 아무런 변경사항이 발생하지 않도록 작성했으며,&lt;/p&gt;
&lt;pre class=&quot;dart&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// 권한 안내 모달
  const [permissionModalVisible, setPermissionModalVisible] = useState(false);

  // 권한이 존재하지 않는 경우, 1) 기존 Click 이벤트 실행 방지 2) 안내 모달 활성화
  return (
    &amp;lt;&amp;gt;
      {cloneElement(children, {
        onClick: (e: React.MouseEvent&amp;lt;HTMLElement&amp;gt;) =&amp;gt; {
          e.preventDefault();
          setPermissionModalVisible(true);
        },
      })}
      {permissionModalVisible &amp;amp;&amp;amp; &amp;lt;PermissionNoticeModal permission={permission}/&amp;gt;}
    &amp;lt;/&amp;gt;
  );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한이 존재하지 않을 경우, children의 onClick 속성을 오버라이딩한 동일 컴포넌트를 반환해(React.cloneElement 함수를 사용), 클릭해도 기존 동작이 실행되는 것이 아니라, 권한 안내 모달이 활성화될 수 있게 해 줬습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 명령형 방식에서는 &lt;b&gt;비즈니스 로직과 권한 검사 로직이 뒤섞여 코드의 가독성과 재사용성이 떨어졌습니다. &lt;/b&gt;&lt;b&gt;&lt;/b&gt;하지만 Wrapper Pattern을 활용한 선언형 방식을 적용해, 아래와 같은 장점들을 누릴 수 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비즈니스 로직과 권한 검사가 분리되어 가독성이 향상&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중복된 코드가 사라지고, 유지보수가 쉬워짐&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새로운 기능 추가 시 확장성이 높아짐&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 이러한 선언적 방식은 react의 1) &lt;a href=&quot;https://legacy.reactjs.org/docs/higher-order-components.html&quot; data-href=&quot;https://legacy.reactjs.org/docs/higher-order-components.html&quot;&gt;고차 컴포넌트(higher-order-component) 패턴이나&lt;/a&gt; 2) custom hooks를 통해 유사하게 구현할 수 있습니다. 하지만 저의 경우 1) 별도 파일로 분리되지 않은 버튼 element들이 많았고 2) 기존 비즈니스 로직 함수들을 최대한 변경하지 않기를 바랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 상황에서 children을 입력받고, 입력받은 요소를 상황에 맞게 제어하는 Wrapper Pattern을 사용해, 선언형 코드의 장점을 살리고 요구사항을 효과적으로 구현해 낼 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Learn/FrontEnd</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/420</guid>
      <comments>https://23life.tistory.com/entry/%EC%84%A0%EC%96%B8%ED%98%95-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EA%B6%8C%ED%95%9C-%EA%B2%80%EC%82%AC-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%ED%95%98%EA%B8%B0-%EB%AA%85%EB%A0%B9%ED%98%95-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-%EC%84%A0%EC%96%B8%ED%98%95-%EC%BD%94%EB%93%9C%EB%A1%9C-%EA%B0%80%EB%8F%85%EC%84%B1%EA%B3%BC-%ED%99%95%EC%9E%A5%EC%84%B1%EC%9D%84-%EB%86%92%EC%9D%B4%EB%8A%94-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EB%B0%A9%EB%B2%95#entry420comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:47 +0900</pubDate>
    </item>
    <item>
      <title>StepBy에서 크롬 익스텐션 배포 과정을 자동화한 방법</title>
      <link>https://23life.tistory.com/entry/StepBy%EC%97%90%EC%84%9C-%ED%81%AC%EB%A1%AC-%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%B0%B0%ED%8F%AC-%EA%B3%BC%EC%A0%95%EC%9D%84-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%9C-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;저는 현재 노코드 온보딩 빌더 StepBy의 프론트엔드 개발 총괄을 담당하고 있습니다. &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;StepBy는 크롬 익스텐션을 통해 클릭 몇 번으로 인앱 가이드를 손쉽게 만들 수 있는 기능을 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 크롬 익스텐션을 빌드하고 스토어에 배포하는 과정에서 겪었던 문제와, 이를 어떻게 해결하였는지를 공유하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경 - 기존 방식의 문제점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 익스텐션을 빌드하고 스토어에 배포하는 과정은 아래와 같았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rvkUC/dJMcagDMj4u/2xmnv06Tiua9svXhkmHJ3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rvkUC/dJMcagDMj4u/2xmnv06Tiua9svXhkmHJ3K/img.png&quot; data-alt=&quot;모든 과정이 개발자 로컬 환경에서, 수동으로&amp;amp;nbsp;이뤄졌습니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rvkUC/dJMcagDMj4u/2xmnv06Tiua9svXhkmHJ3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrvkUC%2FdJMcagDMj4u%2F2xmnv06Tiua9svXhkmHJ3K%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;1480&quot; height=&quot;436&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;436&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모든 과정이 개발자 로컬 환경에서, 수동으로&amp;nbsp;이뤄졌습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 별 다른 설명이 필요 없을 정도로 간단한 과정입니다. 스토어 업데이트 담당 개발자가 로컬에서 소스코드를 빌드하고, 결과물을 스토어에 업로드하면 끝이었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 얼마 지나지 않아 아래와 같은 문제점들이 발생하고 말았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 로컬 환경에 따른 빌드 결과물 차이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 개인이 직접 소스코드를 빌드하다보니, 로컬 환경에 따라 빌드 결과물이 달라지는 문제가 발생했습니다. 작업 중이던 로컬 변경 사항이 의도치 않게 빌드 결과물에 포함될 수밖에 없었고 이로 인해 스토어 업데이트가 필요할 때마다 &lt;b&gt;작업 사항을 억지로 병합하거나 아예 날려야 해&lt;/b&gt;, 좋지 않은 개발 경험을 겪게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 환경 변수 설정 오류&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy에서는 보안상의 이유로 원격 저장소에 업로드할 수 없는 값(DB 주소, 인증 키 등)을 환경 변수로 관리하고 있습니다. 이들 중 실행 환경(개발 / 스테이징 / 프로덕션 등)에 따라 달라지는 값들을 &lt;b&gt;빌드할 때마다 수동으로 확인&lt;/b&gt;해야하는 불편함이 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경 변수 파일은 원격 저장소에 업로드되지 않다 보니, 변경사항이 발생하더라도 이를 파악하기 어렵고 각 환경과 일치하는 값이 무엇인지도 한눈에 알 수 없습니다. 빌드할 때마다 이를 수동으로 확인해야 해 개발자의 피로감이 커지게 되었고, 의도치 않은 실수가 발생할 여지도 많았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;560&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXUmTt/dJMcaesqvIn/EeZAdylzspPOWM7f8YUNI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXUmTt/dJMcaesqvIn/EeZAdylzspPOWM7f8YUNI0/img.png&quot; data-alt=&quot;어찌나 아찔하던지&amp;amp;hellip;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXUmTt/dJMcaesqvIn/EeZAdylzspPOWM7f8YUNI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXUmTt%2FdJMcaesqvIn%2FEeZAdylzspPOWM7f8YUNI0%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;560&quot; height=&quot;168&quot; data-origin-width=&quot;560&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;어찌나 아찔하던지&amp;hellip;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(실제로 초기 POC 당시, 프로덕션 결과물에 스테이징 서버 주소가 배포되는 사태가 벌어지기도 했습니다.  )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. 반복적이고 비효율적인 작업&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy는 스테이징 / 프로덕션 익스텐션이 따로 분리되어있습니다. 프로덕션 업로드 전 내부 테스트 과정에서 스테이징 익스텐션을 사용하는데, 각 환경 별 익스텐션을 업로드할 때마다 이름, 버전, 설명 등을 수동으로 변경해야 하는 번거로움이 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제점들은 개발자로 하여금 의도하지 않은 실수를 유발하게 만들었고, 팀의 전반적인 생산성을 저하시켰습니다. POC 기업이 점차 늘어나면서 이들의 요구사항을 빠르게 반영해야했는데, 스토어 배포 과정에서 병목이 발생해 강제로 배포 주기가 늦춰지는 상황이 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;따라서 안정적이고 자동화된 빌드 시스템을 구축해 이러한 문제점들을 해결하고자 했습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 시스템 구축을 통해 아래와 같은 목표를 달성하고자 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 원격 소스코드 빌드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 과정을 원격에서 이뤄지도록 하여, 로컬 환경에 따른 빌드 결과물 차이를 해결합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 반복 행위 자동화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 파일 생성, 버전 관리, 배포 프로세스를 통합하여 효율적인 스토어 업데이트 과정을 구축하고자 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적인 빌드 및 스토어 배포 과정은 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;852&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfAXXt/dJMcaesqvIt/CzrIPYI7bGPwPFkisUM7O1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfAXXt/dJMcaesqvIt/CzrIPYI7bGPwPFkisUM7O1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfAXXt/dJMcaesqvIt/CzrIPYI7bGPwPFkisUM7O1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfAXXt%2FdJMcaesqvIt%2FCzrIPYI7bGPwPFkisUM7O1%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;704&quot; height=&quot;411&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;852&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;빌드 브랜치에 PR 병합 시, 환경 별 GitHub Actions 실행&lt;/li&gt;
&lt;li&gt;GitHub Actions를 통한 빌드&lt;/li&gt;
&lt;li&gt;GitHub Release를 통한 배포 파일 공유&lt;/li&gt;
&lt;li&gt;배포 파일을 다운 받아 크롬 익스텐션 스토어에 배포&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 과정을 자세히 살펴보도록 하겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 빌드 브랜치에 PR 병합 시, 환경 별 GitHub Actions 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions는 소스 코드 변경 시 자동으로 빌드, 테스트, 배포 등의 작업을 수행할 수 있도록 지원하는 강력한 CI/CD 도구입니다. 다양한 이벤트(예: 푸시, PR 병합 등)에 따라 특정 워크플로우를 실행할 수 있으며, 다양한 프로그래밍 언어와 플랫폼에 맞춘 자동화 시스템을 구축할 수 있습니다.&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;StepBy에서는 모든 개발 사항을 PR을 통해 관리하고 있었고, 따라서 PR이 닫힐 때 빌드가 자동으로 실행되게 했습니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;# production-release.yml
name: production-release 
on:
  pull_request:
    branches: [&quot;production&quot;] # production 브랜치 PR이 닫히면 실행
    types: [closed]&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dts&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;# staging-release.yml
name: staging-release
on:
  pull_request:
    branches: [&quot;staging&quot;] # staging 브랜치 PR이 닫히면 실행
    types: [closed]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치 별로 정의된 action에서 자신의 배포 환경에 맞는 환경 변수를 참조할 수 있게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;# production-release.yml
- name: Get Env
  # production 환경 변수로 env 파일 생성
  run: |
    touch .env
    echo &quot;${{secrets.PRODUCTION_ENV}}&quot; &amp;gt; .env 
  shell: bash&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dockerfile&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;# staging-release.yml
- name: Get Env
  # staging 환경 변수로 env 파일 생성
  run: |
    touch .env
    echo &quot;${{secrets.STAGING_ENV}}&quot; &amp;gt; .env
  shell: bash&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 각 환경 변수 값은 GitHub의 보안 저장소인 &lt;a href=&quot;https://docs.github.com/ko/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions&quot; data-href=&quot;https://docs.github.com/ko/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions&quot;&gt;GitHub Secrets&lt;/a&gt;에 저장할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;994&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qZdcN/dJMcaelEWh2/ZGPkofmtvKnLXecKyTRsLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qZdcN/dJMcaelEWh2/ZGPkofmtvKnLXecKyTRsLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qZdcN/dJMcaelEWh2/ZGPkofmtvKnLXecKyTRsLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqZdcN%2FdJMcaelEWh2%2FZGPkofmtvKnLXecKyTRsLK%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;694&quot; height=&quot;431&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;994&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 한번 GitHub Secrets에 환경 변수를 설정해두기만 하면, 각 action에서 자신의 환경에 맞는 값으로&amp;nbsp;.env 파일을 생성할 수 있는 것이죠!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. GitHub Actions를 통한 빌드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions를 통해 원격 환경에서 빌드 작업을 수행합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;- name: Install dependencies
  run: yarn install

- name: Build
  run: yarn build # 빌드 실행 -&amp;gt; 빌드 결과물이 정의된 경로(ex ./dist)에 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Approved된 PR을 기반으로 빌드 작업을 수행하기 때문에 로컬 환경에 따른 빌드 결과물 차이를 해결할 수 있습니다.&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;추가적으로, 각 빌드 결과물에 대한 반복 작업을 자동화할 수 있습니다. 예를 들어 manifest.json에 버전 정보를 입력하는 작업을 자동화할 수 있죠.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;# manifest.json 파일의 version을 자동으로 증가
- name: Update manifest.json
  run: |
    current_version=$(jq -r '.version' ./public/manifest.json)
    new_version=$(echo $current_version | awk -F. -v OFS=. '{$NF++; if($NF==100) {$NF=0; $(NF-1)++} print}')
    jq - arg new_version &quot;$new_version&quot; '.version = $new_version' ./public/manifest.json &amp;gt; tmp.json &amp;amp;&amp;amp; mv tmp.json ./public/manifest.json&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. GitHub Release를 통한 배포 파일 공유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이렇게 자동으로 빌드된 소스코드를 스토어에 업로드하기만 하면 되겠죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 현재 크롬 익스텐션 스토어는 &lt;b&gt;CLI 기반 업로드 방식을 지원하지 않습니다&lt;/b&gt;. 따라서 개발자가 직접 배포 파일을 다운로드하여 수동으로 업로드해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이 배포 파일을 어떻게 다운받으면 좋을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 좋은 방법은 자체 DB에 저장하는 것입니다. 즉 GitHub Actions로 생성된 파일을 DB에 업로드한 뒤, 해당 파일을 원하는 곳에서 다운로드할 수 있게 하는 것이죠. 하지만 이를 위해서는 DB 업로드 로직을 구현하고, 다운로드 링크를 생성하는 등의 작업이 필요합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boHR9w/dJMcagDMj4t/FAXYemjwhz23vm4gQtK2vk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boHR9w/dJMcagDMj4t/FAXYemjwhz23vm4gQtK2vk/img.png&quot; data-alt=&quot;빌드 종료 후 GitHub Release를 자동으로&amp;amp;nbsp;생성합니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boHR9w/dJMcagDMj4t/FAXYemjwhz23vm4gQtK2vk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboHR9w%2FdJMcagDMj4t%2FFAXYemjwhz23vm4gQtK2vk%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;678&quot; height=&quot;196&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;빌드 종료 후 GitHub Release를 자동으로&amp;nbsp;생성합니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy에서는 스토어 업로드를 내부 개발자가 담당하고 있기 때문에, 대신 GitHub Release를 통해 배포 파일을 공유하는 것이 더 효율적이라고 판단했습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; data-code-block-lang=&quot;yaml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;- name: Create ZIP file
  run: zip -r dist.zip ./dist # 빌드된 파일이 있는 디렉토리를 압축

- name: Create release
  id: create_release
  uses: actions/create-release@v1 # GitHub Release 생성
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ steps.extract_version_name.outputs.version }}
    release_name: v${{ steps.extract_version_name.outputs.version }}
    body: ${{ github.event.pull_request.body }}
    draft: false
    prerelease: false

- name: Upload Release Assets
  id: upload-release-assets
  uses: actions/upload-release-asset@v1 # GitHub Release에 배포 파일 업로드
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    upload_url: ${{ steps.create_release.outputs.upload_url }}
    asset_path: ./dist.zip # 압축된 파일의 경로
    asset_name: dist.zip # 업로드할 파일 이름
    asset_content_type: application/zip # 파일의 콘텐츠 타입&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 빌드 결과물을 압축하고, GitHub Release를 생성한 뒤, 압축한 파일을 업로드 하는 과정 역시 자동화할 수 있습니다.&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;4. 배포 파일을 다운 받아 크롬 익스텐션 스토어에 배포&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1312&quot; data-origin-height=&quot;342&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFw7rN/dJMcaiBAVl6/H3G9aeQKIusA4RWerMfn01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFw7rN/dJMcaiBAVl6/H3G9aeQKIusA4RWerMfn01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFw7rN/dJMcaiBAVl6/H3G9aeQKIusA4RWerMfn01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFw7rN%2FdJMcaiBAVl6%2FH3G9aeQKIusA4RWerMfn01%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;645&quot; height=&quot;168&quot; data-origin-width=&quot;1312&quot; data-origin-height=&quot;342&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 GitHub Release를 확인하면, 아래와 같이 압축된 배포 파일이 생성된 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 파일을 다운받아 익스텐션 스토어에 업로드하면 끝!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로, 빌드 및 스토어 업데이트 과정은 아래와 같이 단순화되었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;지정된 브랜치에 PR 병합&lt;/li&gt;
&lt;li&gt;GitHub Release에 업로드 된 배포 파일 다운로드 후 스토어에 업로드&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 개발자는&lt;b&gt; 어떠한 추가 작업도 수행할 필요가 없습니다!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 이상 로컬 개발 사항을 변경할 필요도, 환경 변수를 일일이 확인할 필요도, 반복적인 버전 관리 작업도 수행할 필요도 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자는 PR에 추가된 소스코드 변경 사항에만 집중할 수 있으며, 자동화된 시스템이 빌드 결과물을 의도대로 생성함을 보장하게 됩니다. 이는 자연스레 빌드 및 배포 속도와 정확성 향상으로 이어졌으며, 모든 팀원이 동일한 워크플로우를 통해 효율적으로 작업할 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리 - 작은 변화로 큰 임팩트 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스타트업에서는 당장의 제품 개발과 사용자 확보가 가장 큰 우선순위를 차지합니다. 하지만 간과하기 쉬운 배포 과정의 비효율성은 시간이 지날수록 발목을 잡고, 팀의 생산성을 저하시키는 요인이 되곤 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FwKDT/dJMcajHgOZP/E4QGUNrcE8nGMFCDnuzXQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FwKDT/dJMcajHgOZP/E4QGUNrcE8nGMFCDnuzXQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FwKDT/dJMcajHgOZP/E4QGUNrcE8nGMFCDnuzXQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFwKDT%2FdJMcajHgOZP%2FE4QGUNrcE8nGMFCDnuzXQK%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;678&quot; height=&quot;387&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저 역시 크롬 익스텐션 배포 초기에는 이러한 문제를 경험했습니다. 하지만 시스템을 자동화하고 프로세스를 정비한 뒤, 배포 과정이 훨씬 간단해졌고 팀원들이 본질적인 업무에 더 집중할 수 있게 되었습니다. 작은 변화를 통해 팀의 성장 속도를 높이고, 더 나아가 사용자들에게 더 나은 서비스를 제공하게 된 것이죠.&lt;/p&gt;</description>
      <category>Learn/etc</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/416</guid>
      <comments>https://23life.tistory.com/entry/StepBy%EC%97%90%EC%84%9C-%ED%81%AC%EB%A1%AC-%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%B0%B0%ED%8F%AC-%EA%B3%BC%EC%A0%95%EC%9D%84-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%9C-%EB%B0%A9%EB%B2%95#entry416comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:46 +0900</pubDate>
    </item>
    <item>
      <title>Expo OTA 업데이트 전략 - 사용자 경험을 고려한 강제 업데이트 관리 방법</title>
      <link>https://23life.tistory.com/entry/Expo-OTA-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EC%A0%84%EB%9E%B5-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B2%BD%ED%97%98%EC%9D%84-%EA%B3%A0%EB%A0%A4%ED%95%9C-%EA%B0%95%EC%A0%9C-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;초기 스타트업에서는 모든 기능을 완벽히 구현한 상태로 서비스를 시작하기 어렵습니다. 구독과 같은 필수 기능도 사용자가 어느 정도 확보된 뒤에야 추가되는 경우가 많죠. 제가 운영했던 애견미용샵 관리 애플리케이션,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.groomernote.com/&quot; data-href=&quot;https://www.groomernote.com/&quot;&gt;그루머노트&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;역시 비슷한 과정을 겪었습니다. 초기에는 예약 관리와 알림톡 전송 기능만으로 출시했지만, 점차 사용자가 늘어나면서 월 구독 정책을 추가하게 되었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구독 상태에 따라 주요 기능 사용을 제한&lt;/b&gt;해야했고, 이를 위해서는 모든 사용자가 &lt;b&gt;최신 버전을 사용하도록 강제&lt;/b&gt;할 필요가 있었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문제는 기존 사용자들이었습니다. 구독 정책을 반영한 최신 버전이 배포 되었음에도 불구하고, &lt;b&gt;일부 사용자는 이전 버전을 계속 사용&lt;/b&gt;하고 있어 구독 정책이 제대로 적용되지 않는 문제가 발생했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 React Native와 Expo를 기반으로 만든 그루머노트에서 OTA 업데이트 문제를 해결하기 위해 도입한 두 가지 정책을 공유하고자 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;업데이트 좀&amp;nbsp;해주세요!&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OTA 업데이트란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OTA(Over-The-Air) 업데이트는 사용자가 앱스토어에 접속하지 않고, 애플리케이션 내부에서 자동으로 최신 파일을 다운로드하고 적용하는 방식의 업데이트입니다. 수동으로 업데이트를 확인하거나 앱스토어를 통해 직접 업데이트하지 않아도 되기 때문에, 앱 업데이트와 배포를 효율적으로 관리할 수 있습니다.&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;Expo는 &lt;a href=&quot;https://docs.expo.dev/deploy/send-over-the-air-updates/&quot; data-href=&quot;https://docs.expo.dev/deploy/send-over-the-air-updates/&quot;&gt;OTA 업데이트&lt;/a&gt;를 지원하며, 이를 통해 앱의 새로운 버전을 빠르고 손쉽게 배포할 수 있습니다. 그루머노트 역시 OTA 업데이트를 통해 새로운 기능이나 버그 수정 등을 신속하게 제공할 수 있었죠.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;import React, { useEffect } from &quot;react&quot;;
import * as Updates from &quot;expo-updates&quot;;

const App = () =&amp;gt; {
  useEffect(() =&amp;gt; {
    // 앱 실행 시 OTA 업데이트 확인
    const checkForUpdates = async () =&amp;gt; {
      try {
        // 업데이트가 가능한지 확인
        const update = await Updates.checkForUpdateAsync();
        if (update.isAvailable) {
          // 업데이트가 있으면 다운로드
          await Updates.fetchUpdateAsync();
          // 다운로드 후 앱을 리로드하여 최신 버전으로 적용
          Updates.reloadAsync();
        }
      } catch (e) {
        console.error(&quot;Failed to check for updates&quot;, e);
      }
    };
    checkForUpdates();
  }, []);

  return &amp;lt;div&amp;gt;{/* 앱 UI */}&amp;lt;/div&amp;gt;;
};

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 사용자가 앱을 백그라운드에 두고 다시 열었을 때, 문제가 발생했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앱 상태와 관련된&amp;nbsp;문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Native에서는 앱의 상태를 크게 세 가지로 분류합니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Active: 앱이 포그라운드에서 활성 상태로 실행 중인 경우.&lt;/li&gt;
&lt;li&gt;Background: 사용자가 앱을 닫지 않고 백그라운드로 전환한 상태.&lt;/li&gt;
&lt;li&gt;Inactive: 시스템 전환 또는 전화 수신 등으로 앱이 일시적으로 중단된 상태.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 앱을 &lt;b&gt;완전히 종료한 뒤 재접속&lt;/b&gt;하면, 의도대로 처럼 OTA 확인 및 업데이트 다운로드 로직이 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 앱을 &lt;b&gt;백그라운드에 두고 다시 열었을 때&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 백그라운드에서 활성 상태로 돌아온다고 해도 앱의 초기화 과정이 다시 실행되지는 않습니다. 더 구체적으로 말하면, 사용자가 앱 내에서 특정 행동을 취하지 않는 한 아무런 코드도 실행되지 않죠. 따라서 OTA 업데이트 로직 역시 실행되지 않으며, 이로 인해 &lt;b&gt;최신 버전이 존재함에도 불구&lt;/b&gt;하고 사용자는 여전히 구독 정책이 반영되지 않은 &lt;b&gt;이전 버전의 앱을 사용&lt;/b&gt;하고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책: 강제 업데이트 전략&amp;nbsp;적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 아래와 같은 전략을 세웠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;앱이 백그라운드에서 활성 상태로 돌아올 때마다, 앱 버전을 확인한다.&lt;/li&gt;
&lt;li&gt;현재 사용자 앱 버전이 최소 지원 버전보다 낮을 경우, 강제 업데이트 모달을 표시한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 설명은 아래와 같습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 앱 상태 이벤트 수신으로 실시간 업데이트&amp;nbsp;확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Native의 AppState 모듈을 활용하면 앱의 상태 변화를 감지할 수 있습니다. 이를 통해 앱이 백그라운드에서 포그라운드로 전환될 때 OTA 업데이트를 확인하도록 설정했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;AppState 이벤트 등록: AppState.addEventListener를 사용해 상태 변화를 수신.&lt;/li&gt;
&lt;li&gt;포그라운드로 전환 시 업데이트 확인: 사용자가 다시 앱으로 돌아오면 최신 OTA 버전을 확인.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;typescript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;import React, { useEffect, useState } from &quot;react&quot;;
import { AppState } from &quot;react-native&quot;;

const App = () =&amp;gt; {
  const [appState, setAppState] = useState(AppState.currentState);

  useEffect(() =&amp;gt; {
    const subscription = AppState.addEventListener(&quot;change&quot;, (nextAppState) =&amp;gt; {
      if (appState === &quot;background&quot; &amp;amp;&amp;amp; nextAppState === &quot;active&quot;) {
        // 포그라운드 전환 시 실행
        checkForUpdates();
      }
      setAppState(nextAppState);
    });

    return () =&amp;gt; {
      subscription.remove(); // 이벤트 리스너 해제
    };
  }, [appState]);

  const checkForUpdates = () =&amp;gt; {
    // OTA 업데이트 확인 로직
    console.log(&quot;Checking for updates...&quot;);
  };

  return null;
};
export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 사용자가 앱을 재실행하지 않아도, 백그라운드에서 포그라운드로 전환 시 자동으로 업데이트를 확인할 수 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 최소 버전 관리로 필수 업데이트&amp;nbsp;제어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;hellip; 사용자 앱 버전이 OTA 최신 버전보다 낮다고 해서 항상 강제 업데이트를 시킬 필요는 없습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 제품 특성상 OTA 업데이트가 빈번하게 일어나는데, 이 때마다 업데이트로 인해 앱 강제 재실행이 발생하면&amp;hellip; &lt;b&gt;좋지 않은 사용자 경험&lt;/b&gt;을 제공하게 되겠죠.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, &lt;b&gt;강제 업데이트가 필요한 최소 지원 버전&lt;/b&gt;을 &lt;b&gt;서버에서 관리&lt;/b&gt;하도록 설계했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최소 버전 정보 관리: DB에 최소 지원 버전을 저장하고 API로 제공.&lt;/li&gt;
&lt;li&gt;최소 버전 확인: 앱 실행 시와 포그라운드 전환 시, 현재 앱 버전과 최소 버전을 비교.&lt;/li&gt;
&lt;li&gt;모달로 업데이트 안내: 현재 앱 버전이 최소 지원 버전보다 낮을 경우 업데이트 모달 표시.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;const checkMinimumVersion = async () =&amp;gt; {
  const response = await fetch(&quot;https://api.example.com/minimum-version&quot;);
  const { minimumVersion } = await response.json();

  if (currentAppVersion &amp;lt; minimumVersion) {
    showUpdateModal();
  }
};

const showUpdateModal = () =&amp;gt; {
  Alert.alert(
    &quot;업데이트 필요&quot;,
    &quot;최신 버전을 사용해야 앱을 계속 이용할 수 있습니다.&quot;,
    [{ text: &quot;업데이트&quot;, onPress: () =&amp;gt; redirectToStore() }]
  );
};

const redirectToStore = () =&amp;gt; {
  Linking.openURL(&quot;https://appstore.com/your-app&quot;); // 앱스토어 링크
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사용&amp;nbsp;예시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구독 정책 도입과 같이 모든 사용자가 필수적으로 최신 버전으로 업데이트해야 하는 경우, 아래 흐름을 따를 수 있었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;OTA 업데이트 (ex 2.0.0)&lt;/li&gt;
&lt;li&gt;DB 최소 버전 업데이트 (ex 2.0.0)&lt;/li&gt;
&lt;li&gt;이전 버전(1.9.0) 사용자 앱 활성화 &amp;rarr; AppState 이벤트 감지&lt;/li&gt;
&lt;li&gt;현제 버전(1.9.0)과 최소 버전(2.0.0) 비교 &amp;rarr; 강제 업데이트 모달 활성화&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zWTQ1/dJMcahQdN9B/iLvIYqP03Hnls3kcuZDGu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zWTQ1/dJMcahQdN9B/iLvIYqP03Hnls3kcuZDGu0/img.png&quot; data-alt=&quot;최소 버전 이하 버전 사용자에게 강제 업데이트 모달이&amp;amp;nbsp;표시됩니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zWTQ1/dJMcahQdN9B/iLvIYqP03Hnls3kcuZDGu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzWTQ1%2FdJMcahQdN9B%2FiLvIYqP03Hnls3kcuZDGu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;675&quot; height=&quot;682&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;최소 버전 이하 버전 사용자에게 강제 업데이트 모달이&amp;nbsp;표시됩니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 필수 업데이트를 원하는 시점에, 원하는 버전으로 유도할 수 있었습니다. 구독 정책 역시 모든 사용자에게 의도대로 적용할 수 있었죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 특정 배포 버전을 강제하는 것이 항상 바람직하지는 않습니다. 하지만 서비스를 운영하다 보면 &lt;b&gt;특정 버전&lt;/b&gt;을 &lt;b&gt;모든 사용자에게 반드시 적용&lt;/b&gt;해야하는 순간이 찾아오기도 하죠. 이 때 중요한 것은 사용자 경험을 해치지 않으면서도, 효과적으로 업데이트를 강제할 수 있는 방법을 찾는 것입니다. 오늘 소개한 그루머노트의 강제 업데이트 전략이 이러한 문제를 해결하려는 분들에게 하나의 좋은 참고 자료가 되었으면 좋겠습니다.&lt;/p&gt;</description>
      <category>Learn/React Native</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/418</guid>
      <comments>https://23life.tistory.com/entry/Expo-OTA-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EC%A0%84%EB%9E%B5-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B2%BD%ED%97%98%EC%9D%84-%EA%B3%A0%EB%A0%A4%ED%95%9C-%EA%B0%95%EC%A0%9C-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EB%B2%95#entry418comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:35 +0900</pubDate>
    </item>
    <item>
      <title>StepBy SDK에서 고객사 페이지 로딩 속도를 1/2로 낮춘 방법</title>
      <link>https://23life.tistory.com/entry/StepBy-SDK%EC%97%90%EC%84%9C-%EA%B3%A0%EA%B0%9D%EC%82%AC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EB%A5%BC-12%EB%A1%9C-%EB%82%AE%EC%B6%98-%EB%B0%A9%EB%B2%95-Dynamic-Import%EB%A1%9C-%EA%B3%A0%EA%B0%9D%EC%82%AC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 성능 최적화는 사용자 경험(UX)과 서비스 품질을 좌우하는 중요한 요소입니다.&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;저는 StepBy SDK를 사용하는 고객사들의 페이지 로딩 속도 개선을 위해, Dynamic Import를 활용한 코드 스플리팅을 적용했습니다. 그 결과 JavaScript 파일 크기를 &lt;b&gt;87KB &amp;rarr; 11KB&lt;/b&gt;로 줄이고, 로딩 속도를 &lt;b&gt;38ms &amp;rarr; 18ms&lt;/b&gt;로 절반 가까이 단축할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 이 과정을 어떻게 진행했는지, 어떤 최적화 전략이 효과적이었는지 다뤄보도록 하겠습니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy는 고객사에서 온보딩 구축에 필요한 다양한 기능들을 직접 제어할 수 있도록 JavaScript SDK를 제공합니다. 서비스 초기에는 기능 자체가 적었고, SDK 파일도 그리 크지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 기능이 점점 추가되면서 SDK 파일 크기가 커졌고, 여러 기업들과의 POC 과정에서 극소수의 기업만 사용하는 기능이 생겨나기도 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 문제가 발생했습니다.  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;lt;script&amp;gt; 태그를 통해 SDK가 로드되므로, &lt;b&gt;블로킹(blocking) 발생&lt;/b&gt; 가능성&lt;/li&gt;
&lt;li&gt;일부 기능은 극소수의 기업만 사용하지만, 모든 고객사가 불필요한 코드까지 로드&lt;/li&gt;
&lt;li&gt;규모가 큰 신규 기능 추가 예정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고객사 별로 사용하는 기능이 다름에도 불구하고, 매번 &lt;b&gt;일부 고객사들을 위한 기능까지 로드해야 하는 것&lt;/b&gt;이 비효율적으로 느껴졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 신규 기능이 추가될 수록 SDK 파일의 크기는 점차 늘어나며, 이는 다운로드 시간 증대 &amp;rarr; &lt;b&gt;고객사 서비스 최초 로딩 속도 지연&lt;/b&gt; 으로 이어질 것이 분명했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제들을 해결하기 위해, Dynamic Import를 활용한 코드 스플리팅을 적용했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Dynamic Import란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 JavaScript 파일은 정적(Static) Import 방식으로 불러옵니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// main.ts
import { featureA, featureB, featureC } from &quot;./features.ts&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 특정 파일을 번들링 할 경우, &lt;b&gt;번들링 결과물&lt;/b&gt;에는 해당 모듈에서 사용되는(=import하는) &lt;b&gt;모든 모듈들이 포함&lt;/b&gt;됨을 의미합니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; data-code-block-lang=&quot;xml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;&amp;lt;script   
  src=&quot;https://cdn.stepby.com/sdk/main.js&quot;
  type=&quot;text/javascript&quot;
&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &amp;lt;script&amp;gt;태그를 사용해 StepBy SDK 를 로드할 경우&amp;hellip; 브라우저에서 로드하는 main.js 파일에는&lt;b&gt;,&lt;/b&gt; &lt;b&gt;SDK에서 제공하는 모든 모듈들이 포함&lt;/b&gt;되어있는 것이죠! (위 문제들도 이러한 동작 방식에서 비롯된 것이었습니다.  &amp;zwj; )&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1324&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbi082/dJMcacai9wL/63ZqknERwcsIN9HzHeuSuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbi082/dJMcacai9wL/63ZqknERwcsIN9HzHeuSuk/img.png&quot; data-alt=&quot;feature-a만 사용하는 고객사도, 300Kb짜리 파일을 다운받아야 합니다.&amp;amp;nbsp; &quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbi082/dJMcacai9wL/63ZqknERwcsIN9HzHeuSuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbbi082%2FdJMcacai9wL%2F63ZqknERwcsIN9HzHeuSuk%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;756&quot; height=&quot;343&quot; data-origin-width=&quot;1324&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;feature-a만 사용하는 고객사도, 300Kb짜리 파일을 다운받아야 합니다.&amp;nbsp; &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;하지만 꼭 이럴 필요가 있을까요?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;최초에 로드되는 main.js 파일에는 SDK 실행에 꼭 필요한 로직만 남겨두고 나머지 파일들은 실제로 해당 기능을 사용할 때 다운로드한다면,&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;고객사들은 자신이 사용하는 기능들만 다운받을 수 있고,&lt;/li&gt;
&lt;li&gt;아무리 신규 기능이 추가되어도 최초 로딩 시간은 동일하게 유지할 수 있겠죠!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;850&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4mEPK/dJMcafrkShU/MJHkc5t80GBD85JIPtL9Xk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4mEPK/dJMcafrkShU/MJHkc5t80GBD85JIPtL9Xk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4mEPK/dJMcafrkShU/MJHkc5t80GBD85JIPtL9Xk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4mEPK%2FdJMcafrkShU%2FMJHkc5t80GBD85JIPtL9Xk%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;745&quot; height=&quot;434&quot; data-origin-width=&quot;1458&quot; data-origin-height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Import를 사용하면 JavaScript에서 이와 같은 방식으로 모듈을 불러올 수 있습니다. 즉 필요한 시점에, 필요한 코드를 비동기적으로 가져올 수 있게 되는 것이죠.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Dynamic Import의 기본 문법과&amp;nbsp;장점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript의 import() 함수는 Promise 기반으로 모듈을 동적으로 로드할 수 있도록 도와줍니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;csharp&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt; // 특정 기능이 필요할 때만 동적으로 로드
async function loadFeature() {
  const { featureA } = await import(&quot;./feature-a.js&quot;);
  featureA();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 아래와 같은 이점을 얻을 수 있죠.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최초 로딩 속도 개선&lt;br /&gt;처음에는 필수적인 기능만 로드하고, 추가적인 기능은 사용자가 실제로 필요할 때 다운로드하여 로딩 속도를 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;파일 크기 감소&lt;br /&gt;&amp;nbsp;SDK 전체를 한 번에 로드하는 대신, 필요한 기능만 개별적으로 불러올 수 있어 초기 다운로드 크기를 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;불필요한 코드 실행 방지&lt;br /&gt;사용되지 않는 기능이 실행되지 않아 메모리 사용량을 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;브라우저 캐싱 효율 증가&lt;br /&gt;코드가 여러 개의 작은 파일(Chunk)로 분리되면, 변경된 부분만 다시 다운로드하면 되므로 브라우저 캐싱이 더 효과적으로 동작할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;StepBy SDK에 Dynamic Import&amp;nbsp;적용하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 대상 모듈&amp;nbsp;결정하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Import를 적용하기 전, 정적/동적 불러올 모듈의 기준을 세웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 모듈 크기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy SDK는 내부적으로 Shadow DOM을 생성한 후 React를 사용해 UI를 렌더링 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 react + react-dom 패키지의 번들 크기가 상대적으로 컸기 때문에, 이들에 의존하는 모듈들은 Dynamic Import를 적용해 필요할 때만 로드하기로 결정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 사용 빈도&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 얘기한 것처럼 SDK의 기능 중 일부는 극소수의 기업에서만 사용되고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한두 개의 기업만 사용하는 모듈을 모든 고객사의 초기 로드에 포함할 필요는 없다고 판단, 해당 모듈들도 동적으로 불러오기로 결정했습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 코드 수정 및 Dynamic Import 적용하기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;typescript&quot; data-code-block-mode=&quot;2&quot;&gt;&lt;code&gt;// 기존 방식
// main.ts
import { bigModule } from &quot;./big-module.ts&quot;;

class StepBySDK {
  constructor() {
    this.isInitialized = false;
  }

  async featureA() {
    const somethingBig = bigModule();
    somethingBig.render();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 정적(Static) Import 방식은 SDK가 로드될 때 모든 모듈을 한 번에 가져옵니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// 개선된 방식
// main.ts
class StepBySDK {
  constructor() {
    this.isInitialized = false;
  }
  async featureA() {
    const { bigModule } = await import(&quot;./big-module.ts&quot;);
    bigModule.render();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 Dynamic Import 방식으로 변경하여 필요할 때만 모듈을 로드하도록 수정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 featureA()가 실행될 때만 big-module.ts가 동적으로 로드되죠! 그 결과 초기 로딩 속도가 개선되며, 필요한 기능만 로드하여 성능을 최적화할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 배포 환경에 Dynamic Import 적용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepBy SDK는 고객사의 웹사이트에서 원격으로 로드됩니다. 즉, 고객사의 HTML 파일에서 &amp;lt;script&amp;gt; 태그를 통해 불러와 사용됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot; data-code-block-lang=&quot;xml&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;&amp;lt;script   
  src=&quot;https://cdn.stepby.com/sdk/main.js&quot;
  type=&quot;text/javascript&quot;
&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우, Dynamic Import를 사용하면 상대 경로(./module.js 등)를 사용할 수 없는 문제가 발생합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;csharp&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// 원격으로 로드되는 SDK에서는 상대 경로 사용 불가
async function featureA() {
  const { bigModule } = await import(&quot;./big-module.ts&quot;); // ❌ 오류 발생
  bigModule.render();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;import(&amp;ldquo;./big-module.ts&amp;rdquo;)는 상대 경로를 기반으로 동작하기 때문에 개발 환경에서는 아무런 문제가 없지만, 배포된 원격 환경에서는 해당 파일을 찾을 수 없어 에러가 발생하고 말죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해, webpack의 publicPath 설정을 활용하여 동적 로드되는 파일의 경로를 지정했습니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot; data-code-block-lang=&quot;java&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// webpack.config.ts
module.exports = {
  output: {
    filename: &quot;main.js&quot;,
    publicPath: &quot;https://cdn.stepby.com/sdk/&quot;, // Dynamic Import 경로 지정
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 웹팩이 빌드 시 big-module.ts를 &lt;a href=&quot;https://cdn.stepby.com/sdk/big-module.js&quot;&gt;https://cdn.stepby.com/sdk/big-module.js&lt;/a&gt;에서 가져오도록 설정합니다. 그 결과 원격 SDK 환경에서도 Dynamic Import가 정상 동작하게 됩니다.  &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;결과 및 성능 개선&amp;nbsp;효과&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;58&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmHNw0/dJMcacai9wK/ILFs7wqs4caH3UgcmjXPd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmHNw0/dJMcacai9wK/ILFs7wqs4caH3UgcmjXPd0/img.png&quot; data-alt=&quot;적용 전 &amp;amp;rarr; 최초 파일 크기&amp;amp;nbsp;: 87.1 kb / 최초 로드 시간&amp;amp;nbsp;:&amp;amp;nbsp;38ms&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmHNw0/dJMcacai9wK/ILFs7wqs4caH3UgcmjXPd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmHNw0%2FdJMcacai9wK%2FILFs7wqs4caH3UgcmjXPd0%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;1396&quot; height=&quot;58&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;58&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;적용 전 &amp;rarr; 최초 파일 크기&amp;nbsp;: 87.1 kb / 최초 로드 시간&amp;nbsp;:&amp;nbsp;38ms&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1422&quot; data-origin-height=&quot;50&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dzcWbf/dJMcajgcuRP/h9xAtpY3gKkC8QyF4AsUW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dzcWbf/dJMcajgcuRP/h9xAtpY3gKkC8QyF4AsUW0/img.png&quot; data-alt=&quot;적용 후&amp;amp;rarr; 최초 파일 크기&amp;amp;nbsp;: 11.2 kb / 최초 로드 시간&amp;amp;nbsp;:&amp;amp;nbsp;18ms&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dzcWbf/dJMcajgcuRP/h9xAtpY3gKkC8QyF4AsUW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdzcWbf%2FdJMcajgcuRP%2Fh9xAtpY3gKkC8QyF4AsUW0%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;1422&quot; height=&quot;50&quot; data-origin-width=&quot;1422&quot; data-origin-height=&quot;50&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;적용 후&amp;rarr; 최초 파일 크기&amp;nbsp;: 11.2 kb / 최초 로드 시간&amp;nbsp;:&amp;nbsp;18ms&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Dynamic Import는&amp;nbsp;만병통치약?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 모든 모듈들을 Dynamic Import로 불러오면 되는 걸까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 아닙니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb6y5z/dJMcajtJHlO/6tljk0myLE2CjoSBNxNgl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb6y5z/dJMcajtJHlO/6tljk0myLE2CjoSBNxNgl0/img.png&quot; data-alt=&quot;네트워크 비용과&amp;amp;nbsp;맞바꾼&amp;amp;hellip;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb6y5z/dJMcajtJHlO/6tljk0myLE2CjoSBNxNgl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb6y5z%2FdJMcajtJHlO%2F6tljk0myLE2CjoSBNxNgl0%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;1012&quot; height=&quot;710&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;710&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네트워크 비용과&amp;nbsp;맞바꾼&amp;hellip;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Import를 사용하면, 해당 모듈을 불러오는 시점에 네트워크 요청이 발생합니다. 즉, 불필요한 곳까지 남발하면 오히려 네트워크 병목이 발생하여 성능이 저하될 수도 있는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 자주 사용되는 모듈을 Dynamic Import로 불러오면, 오히려 네트워크 지연이 발생하여 사용자 경험이 나빠질 수 있습니다. 따라서 사용 형태를 분석해 정말 필요한 곳에만 적용할 수 있어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Chunk 파일 버전&amp;nbsp;관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 webpack에서 Dynamic Import를 적용하면, 번들링 된 결과물(chunks)이 자동으로 생성됩니다. 하지만 이로 인해 발생하는 문제점이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;번들링 과정에서 webpack은 파일명을 자동으로 생성합니다.&lt;/li&gt;
&lt;li&gt;즉, import 방식이나 내부 코드 변경사항이 발생할 경우, chunk 파일명이 변경될 수 있습니다.&lt;/li&gt;
&lt;li&gt;따라서 특정 chunk 파일만 변경되었을때도, 파일명 불일치로 인해 전체 파일을 다시 번들링하고 배포해야 하는 문제가 발생합니다.&lt;/li&gt;
&lt;li&gt;그 결과, 브라우저 캐싱을 100% 활용하지 못하고 &lt;b&gt;불필요한 리소스 다운로드&lt;/b&gt;가 발생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해, manifest.json 파일로 각 chunk 파일들의 버전을 관리하는 방식을 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// webpack.config.ts
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const path = require('path');

module.exports = {
  entry: {
    main: './src/index.js',
    featureA: './src/featureA.js',
    featureB: './src/featureB.js',
  },
  output: {
    filename: (pathData) =&amp;gt; {
      return pathData.chunk.name === 'main' 
        ? '[name].js' // main.js는 고정
        : '[name].[contenthash].js'; // Dynamic Import 파일들은 해시 적용
    },
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'https://cdn.stepby.com/sdk/',
  },
  plugins: [
    new WebpackManifestPlugin({
      fileName: 'manifest.json',  // 번들 매핑 정보 저장
      publicPath: 'https://cdn.stepby.com/sdk/',
      filter: (file) =&amp;gt; file.name !== 'main.js' // main.js는 manifest.json에 포함 X(고정된 파일이므로!)
    }),
  ],
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;webpack-manifest-plugin을 통해 번들 매핑 정보를 저장한 뒤&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-code-block-lang=&quot;json&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;// dist/manifest.json
{
  &quot;featureA.js&quot;: &quot;featureA.456def.js&quot;,
  &quot;featureB.js&quot;: &quot;featureB.789ghi.js&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 파일에 &lt;b&gt;contenthash가 적용된 파일명&lt;/b&gt;을 매핑해 주는 것이죠.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-code-block-lang=&quot;javascript&quot; data-code-block-mode=&quot;1&quot;&gt;&lt;code&gt;class StepBySDK {
  constructor() {
    this.manifest = null;
    this.manifestURL = 'https://cdn.stepby.com/sdk/manifest.json';
  }

  async loadManifest() {
    if (!this.manifest) {
      this.manifest = await fetch(this.manifestURL).then(res =&amp;gt; res.json());
    }
    return this.manifest;
  }

  async loadFeatureA() {
    if (process.env.NODE_ENV === 'development') {
      // 개발 환경에서는 로컬 파일 import
      const { featureA } = await import('./src/featureA.js');
      featureA();
    } else {
      // 운영 환경에서는 manifest.json에서 최신 파일명 가져와서 import
      const manifest = await this.loadManifest();
      const featureAFile = manifest['featureA.js'];
      const { featureA } = await import(`https://cdn.stepby.com/sdk/${featureAFile}`);
      featureA();
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;manifest.json을 활용해 최신 번들 파일명을 관리하고, Dynamic Import 시 manifest.json를 통해 최신 파일을 로드함으로써,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 전체 번들링을 방지하고 &lt;b&gt;브라우저 캐싱 &lt;/b&gt;&amp;amp; &lt;b&gt;네트워크 비용 절감 &lt;/b&gt;의 이점을 누릴 수 있게 되었습니다!&lt;/p&gt;</description>
      <category>Learn/software</category>
      <author>mattew4483</author>
      <guid isPermaLink="true">https://23life.tistory.com/421</guid>
      <comments>https://23life.tistory.com/entry/StepBy-SDK%EC%97%90%EC%84%9C-%EA%B3%A0%EA%B0%9D%EC%82%AC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EB%A5%BC-12%EB%A1%9C-%EB%82%AE%EC%B6%98-%EB%B0%A9%EB%B2%95-Dynamic-Import%EB%A1%9C-%EA%B3%A0%EA%B0%9D%EC%82%AC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0#entry421comment</comments>
      <pubDate>Tue, 28 Oct 2025 22:00:25 +0900</pubDate>
    </item>
  </channel>
</rss>