<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ej-rarus.log</title>
        <link>https://velog.io/</link>
        <description>철학하는 개발자출신 Project Manager</description>
        <lastBuildDate>Wed, 24 Dec 2025 02:39:34 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ej-rarus.log</title>
            <url>https://velog.velcdn.com/images/ej-rarus/profile/33dff308-167f-486c-a242-2ececc963d21/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ej-rarus.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ej-rarus" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[AX] 기획서, 사람이 쓰지 마세요: 5명의 AI 에이전트 팀 구축기]]></title>
            <link>https://velog.io/@ej-rarus/AX-%EA%B8%B0%ED%9A%8D%EC%84%9C-%EC%82%AC%EB%9E%8C%EC%9D%B4-%EC%93%B0%EC%A7%80-%EB%A7%88%EC%84%B8%EC%9A%94-5%EB%AA%85%EC%9D%98-AI-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%ED%8C%80-%EA%B5%AC%EC%B6%95%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/AX-%EA%B8%B0%ED%9A%8D%EC%84%9C-%EC%82%AC%EB%9E%8C%EC%9D%B4-%EC%93%B0%EC%A7%80-%EB%A7%88%EC%84%B8%EC%9A%94-5%EB%AA%85%EC%9D%98-AI-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%ED%8C%80-%EA%B5%AC%EC%B6%95%EA%B8%B0</guid>
            <pubDate>Wed, 24 Dec 2025 02:39:34 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>&quot;기획서에 구멍이 너무 많아요.&quot;</strong>
<strong>&quot;이거 쇼피파이에서는 안 되는 기능인데요?&quot;</strong>
<strong>&quot;예외 처리는 어떻게 하나요?&quot;</strong></p>
</blockquote>
<p>개발자 분들에게 이런 피드백, 한 번쯤 들어보셨을 겁니다. 저 역시 마찬가지였습니다. 사람이 작성하는 기획서(Spec)는 필연적으로 주관이 개입되고, 논리적 빈틈이 생기기 마련이니까요.</p>
<p>오늘은 단순한 업무 보조 도구로서의 AI가 아닌, <strong>시스템적으로 기획 프로세스를 혁신(AX, AI Experience)</strong>하기 위해 시도한 <strong>&#39;Spec Owner 서브에이전트 체계&#39;</strong> 도입 과정을 공유하려 합니다.</p>
<hr>
<h2 id="1-왜-ax인가-the-problem">1. 왜 AX인가? (The Problem)</h2>
<p>쇼피파이(Shopify) 에이전시로서 저희는 수많은 프로젝트를 진행합니다. 하지만 매번 반복되는 고질적인 문제들이 있었습니다.</p>
<ol>
<li><strong>기획자의 주관:</strong> 담당 기획자의 숙련도에 따라 스펙의 퀄리티가 들쑥날쑥함.</li>
<li><strong>플랫폼 제약 미인지:</strong> 쇼피파이의 기술적 한계(Rate Limit, Schema 구조)를 고려하지 않은 기획.</li>
<li><strong>엣지 케이스 누락:</strong> &quot;설마 사용자가 이렇게까지 하겠어?&quot;라는 안일함이 만드는 치명적인 버그.</li>
</ol>
<p>우리는 결심했습니다. <strong>&quot;완벽한 스펙을 위해 기획자의 역할을 해체하고, AI에게 전문성을 부여하자.&quot;</strong></p>
<hr>
<h2 id="2-spec-owner를-5명의-ai로-분열시키다">2. &#39;Spec Owner&#39;를 5명의 AI로 분열시키다</h2>
<p>단순히 &quot;AI에게 기획서 써줘&quot;라고 명령하는 것만으로는 충분하지 않습니다. 우리는 <strong>Spec Owner(기획 총괄)</strong>의 역할을 5개의 전문화된 페르소나(Sub-Agent)로 쪼개고, 이들이 서로 견제하고 협력하는 시스템을 구축했습니다.</p>
<h3 id="🤖-team-spec-owner-라인업">🤖 Team Spec Owner 라인업</h3>
<table>
<thead>
<tr>
<th align="center">코드명</th>
<th align="left">역할 (Role)</th>
<th align="left">성격 및 특징</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>Scope Guard</strong></td>
<td align="left"><strong>경계 관리자</strong></td>
<td align="left">&quot;그 기능, 비즈니스 목표에 정말 필수인가요?&quot; 불필요한 스펙을 쳐내고 범위를 확정합니다.</td>
</tr>
<tr>
<td align="center"><strong>Shopify Architect</strong></td>
<td align="left"><strong>데이터 설계자</strong></td>
<td align="left">&quot;이건 Metaobjects로 구현해야 효율적입니다.&quot; 요구사항을 쇼피파이 DB 구조와 API에 매핑합니다.</td>
</tr>
<tr>
<td align="center"><strong>Edge Case Hunter</strong></td>
<td align="left"><strong>예외 사냥꾼</strong></td>
<td align="left">&quot;네트워크가 끊기면요? 결제가 0원이면요?&quot; 비관적인 시각으로 실패 시나리오를 찾아냅니다.</td>
</tr>
<tr>
<td align="center"><strong>WBS Orchestrator</strong></td>
<td align="left"><strong>일정 관리자</strong></td>
<td align="left">&quot;이 작업은 3일 소요됩니다.&quot; 작업 단위(Task)를 쪼개고 실행 순서를 정렬합니다.</td>
</tr>
<tr>
<td align="center"><strong>AC Standardizer</strong></td>
<td align="left"><strong>검증 표준화</strong></td>
<td align="left">&quot;딱 잘라 말해서, 합격 아니면 불합격.&quot; 애매한 표현을 배제하고 정량적 테스트 기준을 세웁니다.</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-어떻게-일하나요-workflow">3. 어떻게 일하나요? (Workflow)</h2>
<p>이 시스템의 핵심은 <strong>&#39;단계별 검증(Sequential Verification)&#39;</strong>입니다.</p>
<ol>
<li><strong>Input:</strong> 고객의 날것 그대로의 요구사항이 입력됩니다.</li>
<li><strong>Filter (Scope Guard):</strong> &quot;이건 현재 범위 밖입니다&quot;라고 1차 필터링을 거칩니다.</li>
<li><strong>Design (Architect):</strong> 쇼피파이 환경에서 구현 가능한 최적의 스키마를 설계합니다.</li>
<li><strong>Attack (Hunter):</strong> 설계된 스펙을 공격하여 논리적 구멍을 찾습니다.</li>
<li><strong>Plan (Orchestrator):</strong> 개발/디자인 팀이 바로 착수할 수 있게 작업을 세분화합니다.</li>
<li><strong>Criteria (Standardizer):</strong> QA 팀이 사용할 무결한 체크리스트를 출력합니다.</li>
</ol>
<p>이 모든 과정에서 사람은 <strong>&#39;최종 의사결정(Decision Making)&#39;</strong>에만 집중합니다.</p>
<hr>
<h2 id="4-도입-효과-expected-benefits">4. 도입 효과 (Expected Benefits)</h2>
<p>이 체계를 정의하고 실무에 시범 도입해본 결과, 다음과 같은 긍정적인 변화를 기대하고 있습니다.</p>
<ul>
<li><strong>논리적 결함 90% 사전 차단:</strong> 개발자가 코드를 짜기 전에, <strong>Edge Case Hunter</strong>가 논리적 오류를 미리 잡아냅니다.</li>
<li><strong>커뮤니케이션 비용 절감:</strong> &quot;이 부분 어떻게 처리해요?&quot;라는 질문 대신, 명확한 API 스케치와 AC(Acceptance Criteria)가 공유됩니다.</li>
<li><strong>스펙의 표준화:</strong> 어떤 기획자가 투입되더라도 일정한 수준 이상의 결과물이 보장됩니다.</li>
</ul>
<hr>
<h2 id="5-마치며-ai-native로-가는-길">5. 마치며: AI-Native로 가는 길</h2>
<p><strong>AX(AI Experience)</strong>는 단순히 AI 툴을 사용하는 기술적 변화가 아니라, <strong>&#39;일하는 방식의 근본적인 재정의&#39;</strong>였습니다.</p>
<p>저희 팀은 이제 기획서의 &#39;초안&#39;을 직접 쓰지 않습니다. AI 에이전트들이 가져온 5가지 관점의 리포트를 &#39;검토&#39;하고 &#39;승인&#39;할 뿐입니다. 이를 통해 기획자는 단순 문서 작업에서 해방되어, 진짜 <strong>&#39;가치 창출&#39;</strong>과 <strong>&#39;비즈니스 전략&#39;</strong>에 더 많은 시간을 쏟을 수 있게 되었습니다.</p>
<p>앞으로 이 5명의 에이전트들이 실제 쇼피파이 프로젝트에서 어떻게 협력하는지, 구체적인 케이스 스터디로 다시 찾아오겠습니다.</p>
<hr>
<p><strong>Tony Lee (Eunjae Lee)</strong>
<em>Project Manager / Spec Owner</em>
<a href="https://github.com/ej-rarus/spec-owner-kit">GitHub: ej-rarus/spec-owner-kit</a></p>
<p>#AX #AI #ProductManagement #기획 #Shopify #Agile #SpecOwner #Claude</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그인 북마크렛 생성기 개발기 🔐]]></title>
            <link>https://velog.io/@ej-rarus/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B6%81%EB%A7%88%ED%81%AC%EB%A0%9B-%EC%83%9D%EC%84%B1%EA%B8%B0-%EA%B0%9C%EB%B0%9C%EA%B8%B0-lh6m4t2q</link>
            <guid>https://velog.io/@ej-rarus/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B6%81%EB%A7%88%ED%81%AC%EB%A0%9B-%EC%83%9D%EC%84%B1%EA%B8%B0-%EA%B0%9C%EB%B0%9C%EA%B8%B0-lh6m4t2q</guid>
            <pubDate>Thu, 11 Dec 2025 09:02:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>테스트 계정으로 반복 로그인하기 귀찮아서 만든 자동 로그인 도구</p>
</blockquote>
<h2 id="🎯-프로젝트-배경">🎯 프로젝트 배경</h2>
<p>웹 개발을 하다 보면 테스트 환경에서 여러 계정으로 로그인/로그아웃을 반복해야 할 때가 많습니다. 매번 아이디와 비밀번호를 입력하고 로그인 버튼을 누르는 작업이 생각보다 귀찮더라고요.</p>
<p>&quot;북마크 하나로 자동 로그인되면 얼마나 편할까?&quot; 라는 생각에서 시작된 프로젝트입니다.</p>
<h3 id="목표">목표</h3>
<ul>
<li>코딩 지식이 없는 사람도 쉽게 사용할 수 있어야 함</li>
<li>HTML만 업로드하면 자동으로 북마크렛 생성</li>
<li>여러 계정을 관리하기 쉬워야 함</li>
</ul>
<h2 id="🛠-기술-스택">🛠 기술 스택</h2>
<ul>
<li><strong>순수 HTML/CSS/JavaScript</strong> - 별도의 프레임워크 없이 가볍게</li>
<li><strong>브라우저 API</strong> - FileReader, DOMParser, iframe 활용</li>
<li><strong>SheetJS (xlsx)</strong> - 엑셀 파일 파싱용</li>
</ul>
<p>서버가 필요 없는 <strong>100% 클라이언트 사이드</strong> 애플리케이션으로 설계했습니다.</p>
<h2 id="✨-주요-기능">✨ 주요 기능</h2>
<h3 id="1-자동-로그인-폼-감지">1. 자동 로그인 폼 감지</h3>
<p>HTML 파일을 업로드하면 로그인 폼 요소를 자동으로 찾아냅니다.</p>
<pre><code class="language-javascript">function detectLoginFormElements(doc) {
    // 비밀번호 필드 먼저 찾기 (가장 확실한 요소)
    const passwordInput = doc.querySelector(&#39;input[type=&quot;password&quot;]&#39;);

    // ID 필드 찾기 - name 속성 우선 검색
    const candidates = [
        &#39;input[name=&quot;username&quot;]&#39;,
        &#39;input[name=&quot;user&quot;]&#39;,
        &#39;input[type=&quot;email&quot;]&#39;,
        // ... 더 많은 패턴
    ];

    // 로그인 버튼 찾기
    const submitButton = doc.querySelector(&#39;button[type=&quot;submit&quot;]&#39;);

    // ...
}</code></pre>
<h3 id="2-시각적-요소-선택-기능">2. 시각적 요소 선택 기능</h3>
<p>자동 감지가 실패할 때를 대비한 <strong>핵심 기능</strong>입니다. 사용자가 직접 눈으로 보고 클릭해서 요소를 선택할 수 있습니다.</p>
<h4 id="개발-과정의-핵심-문제들">개발 과정의 핵심 문제들</h4>
<p><strong>문제 1: iframe 하이라이트가 안 보임</strong></p>
<ul>
<li>원인: iframe 내부에 CSS가 주입되지 않음</li>
<li>해결: iframe 로드 완료 후 동적으로 스타일 주입</li>
</ul>
<pre><code class="language-javascript">function injectStylesIntoIframe(iframe) {
    const iframeDoc = iframe.contentDocument;
    const style = iframeDoc.createElement(&#39;style&#39;);
    style.textContent = `
        .element-highlight {
            outline: 3px solid #4caf50 !important;
            outline-offset: 2px;
            background: rgba(76, 175, 80, 0.1) !important;
        }
    `;
    iframeDoc.head.appendChild(style);
}</code></pre>
<p><strong>문제 2: iframe이 프리즈되어 클릭이 안 됨</strong></p>
<ul>
<li>원인: 오버레이가 iframe 위를 덮어서 클릭 이벤트 차단</li>
<li>해결: <code>pointer-events: none</code>으로 오버레이는 클릭을 통과시키고, 가이드 박스만 클릭 가능하게 설정</li>
</ul>
<pre><code class="language-css">.selection-overlay {
    pointer-events: none; /* iframe 클릭 허용 */
}

.selection-guide {
    pointer-events: auto; /* 가이드 박스는 클릭 가능 */
}</code></pre>
<p>이 문제를 해결하는 데 가장 많은 시간이 걸렸는데, 해결하고 나니 정말 뿌듯했습니다! 🎉</p>
<p><strong>문제 3: 이벤트 리스너가 불안정함</strong></p>
<ul>
<li>원인: 개별 요소에 이벤트를 붙이는 방식</li>
<li>해결: 이벤트 위임(Event Delegation) 방식으로 변경</li>
</ul>
<pre><code class="language-javascript">// Before: 개별 요소마다 이벤트 추가 (불안정)
allElements.forEach(element =&gt; {
    element.addEventListener(&#39;click&#39;, handleClick);
});

// After: 부모에 하나의 리스너만 (안정적)
iframeDoc.body.addEventListener(&#39;click&#39;, function(e) {
    if (e.target.matches(&#39;input, button, a&#39;)) {
        handleElementSelection(e.target);
    }
}, true);</code></pre>
<h3 id="3-북마크렛-코드-생성">3. 북마크렛 코드 생성</h3>
<p>선택된 요소 정보를 바탕으로 북마크렛을 생성합니다.</p>
<pre><code class="language-javascript">function createBookmarkletCode(account) {
    const code = `
(function() {
    const idInput = document.querySelector(&#39;${idSelector}&#39;);
    const pwInput = document.querySelector(&#39;${passwordSelector}&#39;);

    if (idInput) idInput.value = &#39;${account.id}&#39;;
    if (pwInput) pwInput.value = &#39;${account.password}&#39;;

    const btn = document.querySelector(&#39;${buttonSelector}&#39;);
    if (btn) btn.click();
})();
    `.trim();

    return `javascript:${encodeURIComponent(code)}`;
}</code></pre>
<p>북마크 URL에 JavaScript 코드를 저장하는 방식입니다. <code>javascript:</code> 프로토콜을 사용하면 북마크 클릭 시 해당 코드가 실행됩니다.</p>
<h3 id="4-계정-관리">4. 계정 관리</h3>
<p><strong>엑셀 업로드 방식</strong></p>
<pre><code class="language-javascript">function handleExcelFile(file) {
    const reader = new FileReader();
    reader.onload = (e) =&gt; {
        const data = new Uint8Array(e.target.result);
        const workbook = XLSX.read(data, { type: &#39;array&#39; });
        const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
        const jsonData = XLSX.utils.sheet_to_json(firstSheet);

        // ID, Password 컬럼 자동 감지 (대소문자 무시)
        accountList = jsonData.map(row =&gt; {
            const idKey = Object.keys(row).find(key =&gt;
                key.toLowerCase().includes(&#39;id&#39;) ||
                key.toLowerCase().includes(&#39;user&#39;)
            );
            // ...
        });
    };
}</code></pre>
<p><strong>수동 입력 방식</strong></p>
<ul>
<li>동적으로 행 추가/삭제 가능</li>
<li>실시간으로 계정 개수 표시</li>
</ul>
<h3 id="5-북마크-등록-방법">5. 북마크 등록 방법</h3>
<p>생성된 북마크렛을 브라우저에 등록하는 <strong>세 가지 방법</strong>을 제공합니다.</p>
<h4 id="방법-1-드래그-앤-드롭-가장-쉬움">방법 1: 드래그 앤 드롭 (가장 쉬움!)</h4>
<p>HTML5 Drag and Drop API를 활용한 직관적인 방법입니다.</p>
<pre><code class="language-html">&lt;a href=&quot;${bookmarkletCode}&quot; class=&quot;bookmarklet-link&quot; draggable=&quot;true&quot;&gt;
    📌 ${account.name}
&lt;/a&gt;</code></pre>
<p>사용자가 생성된 링크를 브라우저 북마크바로 드래그하기만 하면 자동으로 북마크가 등록됩니다. 별도의 복사/붙여넣기 과정이 필요 없어 <strong>가장 빠르고 편리한 방법</strong>입니다.</p>
<h4 id="방법-2-html-파일-내보내기-대량-등록">방법 2: HTML 파일 내보내기 (대량 등록)</h4>
<p>여러 계정을 한 번에 등록할 때 유용한 방법입니다. <strong>Netscape Bookmark File Format</strong>을 사용해 브라우저가 인식할 수 있는 북마크 파일을 생성합니다.</p>
<pre><code class="language-javascript">function exportBookmarks() {
    let bookmarksHTML = `&lt;!DOCTYPE NETSCAPE-Bookmark-file-1&gt;
&lt;META HTTP-EQUIV=&quot;Content-Type&quot; CONTENT=&quot;text/html; charset=UTF-8&quot;&gt;
&lt;TITLE&gt;Bookmarks&lt;/TITLE&gt;
&lt;H1&gt;Bookmarks&lt;/H1&gt;
&lt;DL&gt;&lt;p&gt;
    &lt;DT&gt;&lt;H3&gt;로그인 북마크렛&lt;/H3&gt;
    &lt;DL&gt;&lt;p&gt;`;

    allAccounts.forEach(account =&gt; {
        const bookmarkletCode = createBookmarkletCode(account);
        const escapedName = account.name
            .replace(/&amp;/g, &#39;&amp;amp;&#39;)
            .replace(/&lt;/g, &#39;&amp;lt;&#39;)
            .replace(/&gt;/g, &#39;&amp;gt;&#39;)
            .replace(/&quot;/g, &#39;&amp;quot;&#39;);
        bookmarksHTML += `        &lt;DT&gt;&lt;A HREF=&quot;${bookmarkletCode}&quot;&gt;${escapedName}&lt;/A&gt;\n`;
    });

    bookmarksHTML += `    &lt;/DL&gt;&lt;p&gt;\n&lt;/DL&gt;&lt;p&gt;`;

    // Blob으로 파일 생성 및 다운로드
    const blob = new Blob([bookmarksHTML], { type: &#39;text/html&#39; });
    const url = URL.createObjectURL(blob);
    const a = document.createElement(&#39;a&#39;);
    a.href = url;
    a.download = &#39;login_bookmarklets.html&#39;;
    a.click();
}</code></pre>
<p><strong>Netscape Bookmark Format</strong>은 모든 주요 브라우저(Chrome, Firefox, Edge, Safari)에서 지원하는 표준 형식입니다. 생성된 HTML 파일을 브라우저의 &quot;북마크 가져오기&quot; 기능으로 불러오면 모든 북마크가 한 번에 등록됩니다.</p>
<h4 id="방법-3-코드-복사-수동">방법 3: 코드 복사 (수동)</h4>
<p>각 북마크렛의 코드를 복사해서 수동으로 북마크를 만드는 전통적인 방법입니다.</p>
<pre><code class="language-javascript">function copyBookmarkletCode(code, index) {
    navigator.clipboard.writeText(code).then(() =&gt; {
        showCopyFeedback(index);
    });
}</code></pre>
<p>Clipboard API를 사용해 원클릭 복사를 구현했습니다.</p>
<h3 id="6-ui-개선-목록형-디자인">6. UI 개선: 목록형 디자인</h3>
<p>초기 버전의 카드 기반 UI에서 <strong>테이블 기반 목록형 UI</strong>로 전면 개편했습니다.</p>
<h4 id="변경-배경">변경 배경</h4>
<ul>
<li>카드 UI는 계정이 많을 때 세로로 길어져서 스크롤이 많이 필요했음</li>
<li>정보 밀도가 낮아 한 화면에 적은 수의 북마크만 표시됨</li>
<li>드래그 앤 드롭 링크가 시각적으로 덜 돋보였음</li>
</ul>
<h4 id="새로운-테이블-ui">새로운 테이블 UI</h4>
<pre><code class="language-javascript">function generateBookmarklets() {
    resultsDiv.innerHTML = `
        &lt;table class=&quot;bookmarklet-table&quot;&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;계정 정보&lt;/th&gt;
                    &lt;th&gt;북마크 드래그&lt;/th&gt;
                    &lt;th&gt;동작&lt;/th&gt;
                &lt;/tr&gt;
            &lt;/thead&gt;
            &lt;tbody id=&quot;bookmarkletTableBody&quot;&gt;&lt;/tbody&gt;
        &lt;/table&gt;
    `;

    allAccounts.forEach((account, index) =&gt; {
        const row = tbody.insertRow();
        row.innerHTML = `
            &lt;td&gt;
                &lt;div class=&quot;account-name&quot;&gt;${account.name}&lt;/div&gt;
                &lt;div class=&quot;account-id&quot;&gt;ID: ${account.id}&lt;/div&gt;
            &lt;/td&gt;
            &lt;td&gt;
                &lt;a href=&quot;${bookmarkletCode}&quot; class=&quot;bookmarklet-link&quot; draggable=&quot;true&quot;&gt;
                    📌 ${account.name}
                &lt;/a&gt;
            &lt;/td&gt;
            &lt;td&gt;
                &lt;button class=&quot;small-copy-btn&quot; onclick=&quot;copyBookmarkletCode(...)&quot;&gt;
                    📋 코드 복사
                &lt;/button&gt;
            &lt;/td&gt;
        `;
    });
}</code></pre>
<h4 id="개선-효과">개선 효과</h4>
<ul>
<li><strong>공간 효율성 300% 향상</strong>: 같은 화면 크기에 3배 이상의 북마크 표시</li>
<li><strong>명확한 정보 계층</strong>: 계정 정보 → 드래그 링크 → 액션 버튼 순으로 시각적 흐름 개선</li>
<li><strong>드래그 타겟 명확화</strong>: 📌 아이콘으로 드래그 가능한 요소를 직관적으로 표시</li>
</ul>
<h2 id="🎨-ux-개선-과정">🎨 UX 개선 과정</h2>
<h3 id="사용자-피드백-반영">사용자 피드백 반영</h3>
<p><strong>피드백 1: &quot;HTML 파일 준비가 복잡해요&quot;</strong></p>
<ul>
<li>기존: 페이지 소스 보기 → 복사 → 메모장에 붙여넣기 → 저장</li>
<li>개선: 로그인 페이지에서 <code>Ctrl+S</code> 누르기만 하면 끝!</li>
</ul>
<p>튜토리얼에도 이 내용을 강조했습니다.</p>
<p><strong>피드백 2: &quot;코드를 모르는데 어떻게 요소를 선택하나요?&quot;</strong></p>
<p>이 피드백이 <strong>시각적 선택 기능</strong>을 만든 계기였습니다.</p>
<ul>
<li>코드 대신 눈으로 보고 클릭</li>
<li>초록색 하이라이트로 시각적 피드백</li>
<li>잘못 선택하면 &quot;↶ 이전 단계&quot; 버튼으로 되돌리기</li>
</ul>
<p><strong>피드백 3: &quot;FAQ 섹션이 디자인이 달라서 어색해요&quot;</strong></p>
<ul>
<li>문제: h3 태그로 질문을 표시해서 다른 섹션과 스타일이 달랐음</li>
<li>해결: FAQ도 step-card로 감싸서 디자인 통일</li>
</ul>
<pre><code class="language-html">&lt;!-- Before --&gt;
&lt;h3&gt;Q1. 북마크렛이 작동하지 않아요&lt;/h3&gt;
&lt;p&gt;답변...&lt;/p&gt;

&lt;!-- After --&gt;
&lt;div class=&quot;step-card&quot;&gt;
    &lt;h4&gt;Q1. 북마크렛이 작동하지 않아요&lt;/h4&gt;
    &lt;p&gt;답변...&lt;/p&gt;
&lt;/div&gt;</code></pre>
<p><strong>피드백 4: &quot;북마크를 어떻게 등록하나요?&quot;</strong></p>
<ul>
<li>문제: 북마크렛 코드를 복사한 후 수동으로 북마크를 만드는 과정이 복잡했음</li>
<li>해결: 세 가지 등록 방법 제공<ol>
<li>드래그 앤 드롭 (가장 직관적)</li>
<li>HTML 파일 내보내기 (대량 등록)</li>
<li>코드 복사 (전통적 방법)</li>
</ol>
</li>
</ul>
<pre><code class="language-html">&lt;div class=&quot;method-section&quot;&gt;
    &lt;div class=&quot;method-number&quot;&gt;1&lt;/div&gt;
    &lt;div class=&quot;method-content&quot;&gt;
        &lt;strong&gt;🖱️ 드래그 앤 드롭 (가장 쉬움!)&lt;/strong&gt;
        &lt;p&gt;생성된 표에서 &lt;code&gt;📌 계정 이름&lt;/code&gt; 링크를 브라우저 북마크바로 드래그&lt;/p&gt;
    &lt;/div&gt;
&lt;/div&gt;</code></pre>
<p><strong>피드백 5: &quot;카드 UI가 공간을 많이 차지해요&quot;</strong></p>
<ul>
<li>문제: 계정이 10개만 되어도 스크롤이 너무 길어짐</li>
<li>해결: 테이블 기반 목록형 UI로 재설계<ul>
<li>정보 밀도 3배 향상</li>
<li>한눈에 더 많은 북마크 확인 가능</li>
<li>드래그 타겟이 더 명확해짐</li>
</ul>
</li>
</ul>
<h3 id="css-최적화">CSS 최적화</h3>
<p>UI 개편과 함께 불필요한 스타일을 대폭 정리했습니다.</p>
<p><strong>제거한 스타일</strong>:</p>
<ul>
<li><code>.copy-btn</code> (통합 복사 버튼)</li>
<li><code>.drag-handle</code> (개별 드래그 핸들)</li>
<li><code>.result-item</code> (카드 레이아웃)</li>
<li><code>.bookmarklet-card</code> (카드 컴포넌트)</li>
</ul>
<p><strong>새로 추가한 스타일</strong>:</p>
<pre><code class="language-css">.bookmarklet-table {
    width: 100%;
    border-collapse: collapse;
    background: white;
    border-radius: 8px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.method-section {
    display: flex;
    gap: 15px;
    padding: 20px;
    background: #f8f9fa;
    border-radius: 8px;
}

.method-number {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    font-weight: bold;
    /* ... */
}</code></pre>
<p>재사용 가능한 컴포넌트 중심으로 CSS를 재구성해 유지보수성을 크게 개선했습니다.</p>
<h2 id="📱-반응형-디자인">📱 반응형 디자인</h2>
<p>데스크톱과 모바일 모두에서 사용 가능하도록 설계했습니다.</p>
<pre><code class="language-css">@media (max-width: 1024px) {
    .main-wrapper {
        flex-direction: column;
    }

    .sidebar {
        width: 100%;
        position: static;
    }

    .sidebar-menu {
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
    }
}</code></pre>
<h2 id="🔒-보안-고려사항">🔒 보안 고려사항</h2>
<h3 id="클라이언트-사이드-처리">클라이언트 사이드 처리</h3>
<ul>
<li>모든 데이터 처리는 브라우저 내부에서만 이루어짐</li>
<li>서버로 파일이나 계정 정보가 전송되지 않음</li>
<li>네트워크 요청 없이 완전히 오프라인에서도 작동 가능</li>
</ul>
<h3 id="주의사항-표시">주의사항 표시</h3>
<pre><code class="language-markdown">⚠️ 주의사항
북마크렛에는 계정 정보가 평문으로 저장됩니다.
실제 프로덕션 계정이 아닌 테스트 계정에만 사용하세요!</code></pre>
<p>튜토리얼에 경고 박스로 명확히 안내했습니다.</p>
<h2 id="💡-배운-점">💡 배운 점</h2>
<h3 id="1-사용자-중심-설계의-중요성">1. 사용자 중심 설계의 중요성</h3>
<p>처음엔 &quot;자동 감지만 잘 되면 되겠지&quot;라고 생각했는데, 실제로는 다양한 로그인 폼 구조 때문에 감지가 실패하는 경우가 많았습니다. <strong>시각적 선택 기능</strong>을 추가하면서 &quot;기술이 완벽하지 않더라도, 사용자가 쉽게 대안을 선택할 수 있어야 한다&quot;는 걸 배웠습니다.</p>
<h3 id="2-iframe의-까다로움">2. iframe의 까다로움</h3>
<p>iframe은 보안상의 이유로 많은 제약이 있습니다:</p>
<ul>
<li>같은 origin이어야 접근 가능</li>
<li>sandbox 속성 설정 필요</li>
<li>스타일은 내부에 주입해야 함</li>
<li>이벤트는 위임 방식이 안정적</li>
</ul>
<p>이런 제약들을 하나씩 해결하면서 iframe에 대한 이해도가 많이 높아졌습니다.</p>
<h3 id="3-점진적-개선">3. 점진적 개선</h3>
<p>완벽한 결과물을 한 번에 만들려고 하지 않고:</p>
<ol>
<li>기본 기능 구현 (자동 감지)</li>
<li>문제 발견 (감지 실패)</li>
<li>대안 추가 (시각적 선택)</li>
<li>UX 개선 (Ctrl+S, 이전 단계 버튼)</li>
<li>디자인 통일 (FAQ 스타일)</li>
<li>북마크 등록 간소화 (드래그 앤 드롭)</li>
<li>UI 재설계 (테이블 형식)</li>
<li>CSS 최적화</li>
</ol>
<p>이렇게 단계적으로 개선해나가는 과정이 더 효과적이었습니다.</p>
<h3 id="4-브라우저-표준-api-활용">4. 브라우저 표준 API 활용</h3>
<p>프레임워크 없이 순수 JavaScript만으로 구현하면서 다양한 브라우저 API를 활용했습니다:</p>
<ul>
<li><strong>HTML5 Drag and Drop API</strong>: 드래그 앤 드롭 구현</li>
<li><strong>Blob API</strong>: 클라이언트 사이드에서 파일 생성 및 다운로드</li>
<li><strong>Clipboard API</strong>: 원클릭 복사 기능</li>
<li><strong>URL.createObjectURL()</strong>: 메모리 효율적인 파일 다운로드</li>
</ul>
<p>특히 <strong>Netscape Bookmark Format</strong>을 발견한 것이 큰 도움이 되었습니다. 1990년대부터 사용된 오래된 표준이지만, 2025년 현재까지도 모든 주요 브라우저에서 지원하는 것을 보고 <strong>웹 표준의 힘</strong>을 다시 한번 느꼈습니다.</p>
<h3 id="5-정보-밀도와-사용성의-균형">5. 정보 밀도와 사용성의 균형</h3>
<p>카드 UI에서 테이블 UI로 변경하면서 배운 점:</p>
<ul>
<li><strong>더 많은 정보 ≠ 복잡함</strong>: 정보를 잘 정리하면 오히려 이해하기 쉬워짐</li>
<li><strong>공간 효율성의 중요성</strong>: 스크롤을 줄이면 사용자 경험이 크게 개선됨</li>
<li><strong>시각적 계층 구조</strong>: 테이블의 열 구조가 정보의 우선순위를 명확히 전달함</li>
</ul>
<h2 id="🎯-개선하고-싶은-점">🎯 개선하고 싶은 점</h2>
<h3 id="완료된-기능-✅">완료된 기능 ✅</h3>
<ul>
<li><input checked="" disabled="" type="checkbox"> <strong>북마크 내보내기/가져오기 기능</strong><ul>
<li>HTML 파일 내보내기 (Netscape Bookmark Format)</li>
<li>드래그 앤 드롭으로 즉시 등록</li>
<li>코드 복사 기능</li>
</ul>
</li>
<li><input checked="" disabled="" type="checkbox"> <strong>UI 개선</strong><ul>
<li>목록형 테이블 디자인</li>
<li>정보 밀도 300% 향상</li>
<li>반응형 레이아웃</li>
</ul>
</li>
</ul>
<h3 id="향후-계획">향후 계획</h3>
<ul>
<li><input disabled="" type="checkbox"> 브라우저 확장 프로그램 버전<ul>
<li>현재: 북마크렛 방식 (제한적)</li>
<li>목표: Chrome/Firefox 확장 프로그램으로 더 강력한 기능 제공</li>
</ul>
</li>
<li><input disabled="" type="checkbox"> 더 다양한 로그인 폼 패턴 지원<ul>
<li>reCAPTCHA 우회 방법 안내</li>
<li>2단계 인증 고려</li>
<li>SPA(Single Page Application) 로그인 폼 지원</li>
</ul>
</li>
<li><input disabled="" type="checkbox"> 다국어 지원 (영어, 일본어 등)<ul>
<li>현재: 한국어만 지원</li>
<li>목표: 글로벌 개발자들도 사용할 수 있게</li>
</ul>
</li>
<li><input disabled="" type="checkbox"> 북마크렛 테스트 기능<ul>
<li>페이지 내에서 미리 북마크렛 동작 테스트</li>
<li>오류 디버깅 도구 제공</li>
</ul>
</li>
<li><input disabled="" type="checkbox"> 북마크 그룹 관리<ul>
<li>프로젝트별/환경별로 북마크 그룹화</li>
<li>그룹 단위 내보내기/가져오기</li>
</ul>
</li>
</ul>
<h2 id="📊-성과">📊 성과</h2>
<h3 id="주요-지표">주요 지표</h3>
<ul>
<li><strong>순수 JavaScript로 구현</strong> (프레임워크 없음, 의존성 최소화)</li>
<li><strong>파일 크기</strong>: 약 50KB (압축되지 않은 상태에서도 가볍고 빠름)</li>
<li><strong>서버 비용</strong>: $0 (정적 호스팅만 필요, GitHub Pages 무료 배포 가능)</li>
<li><strong>지원 브라우저</strong>: Chrome, Firefox, Edge, Safari (모든 주요 브라우저)</li>
<li><strong>오프라인 작동</strong>: 네트워크 연결 없이도 100% 사용 가능</li>
</ul>
<h3 id="기능-통계">기능 통계</h3>
<ul>
<li><strong>계정 관리 방식</strong>: 2가지 (엑셀 업로드, 수동 입력)</li>
<li><strong>북마크 등록 방법</strong>: 3가지 (드래그 앤 드롭, HTML 내보내기, 코드 복사)</li>
<li><strong>폼 요소 감지</strong>: 자동 감지 + 시각적 수동 선택</li>
<li><strong>지원 파일 형식</strong>: HTML, XLSX (Excel)</li>
</ul>
<h3 id="사용자-경험-개선">사용자 경험 개선</h3>
<p><strong>기존 방식</strong> (수동 로그인):</p>
<ul>
<li>HTML 파일 준비: 30초 (소스 보기 → 복사 → 붙여넣기 → 저장)</li>
<li>북마크 만들기: 60초 (북마크 추가 → 이름 입력 → URL 붙여넣기)</li>
<li>로그인 실행: 10초 (ID 입력 → 비밀번호 입력 → 버튼 클릭)</li>
</ul>
<p><strong>개선된 방식</strong> (북마크렛):</p>
<ul>
<li>HTML 파일 준비: 5초 (Ctrl+S)</li>
<li>북마크 만들기: 3초 (드래그 앤 드롭)</li>
<li>로그인 실행: 1초 (북마크 클릭 한 번)</li>
</ul>
<p><strong>전체 프로세스 시간 절약</strong>: 100초 → 9초 = <strong>91% 시간 절약</strong> 🚀</p>
<h3 id="ui-개선-효과">UI 개선 효과</h3>
<ul>
<li><strong>카드 UI</strong> (초기): 10개 계정 = 약 1800px 세로 높이</li>
<li><strong>테이블 UI</strong> (개선): 10개 계정 = 약 600px 세로 높이</li>
<li><strong>스크롤 감소</strong>: 67% ↓</li>
<li><strong>정보 밀도</strong>: 300% ↑</li>
</ul>
<h2 id="🎉-마무리">🎉 마무리</h2>
<p>처음엔 &quot;간단한 도구&quot;로 시작했지만, 실제로 만들어보니 예상보다 고려해야 할 것들이 많았습니다. 특히 <strong>코딩을 모르는 사용자도 쉽게 사용할 수 있게</strong> 만드는 과정에서 많은 것을 배웠습니다.</p>
<h3 id="개발-과정에서-가장-기억에-남는-순간들">개발 과정에서 가장 기억에 남는 순간들</h3>
<p><strong>1. iframe 프리즈 문제 해결</strong>
<code>pointer-events: none</code>이라는 간단한 CSS 속성 하나로 해결되는 문제였지만, 그걸 찾기까지의 과정이 정말 값진 경험이었습니다. 몇 시간 동안 JavaScript 이벤트 처리 방법을 바꿔보고, z-index를 조정하고, 심지어 iframe을 다시 구현하려고까지 했는데, 결국 답은 CSS에 있었습니다.</p>
<p><strong>2. 드래그 앤 드롭 구현의 즐거움</strong>
&quot;북마크를 어떻게 쉽게 등록할까?&quot; 고민하다가 드래그 앤 드롭을 떠올렸을 때의 짜릿함! HTML5 API만으로 이렇게 직관적인 UX를 만들 수 있다는 게 신기했습니다. 복잡한 프레임워크 없이도 충분히 모던한 인터페이스를 만들 수 있다는 자신감을 얻었습니다.</p>
<p><strong>3. Netscape Bookmark Format 발견</strong>
&quot;브라우저에 어떻게 자동으로 북마크를 추가하지?&quot; 검색하다가 1990년대 포맷을 발견하고 놀랐습니다. 30년 전 표준이 2025년에도 모든 브라우저에서 작동한다는 사실이 <strong>웹 표준의 지속성</strong>을 보여주는 완벽한 예시였습니다.</p>
<p><strong>4. UI 재설계 결정</strong>
카드 UI가 &quot;예쁘긴 한데 실용적이지 않다&quot;는 피드백을 받고 테이블로 전환했을 때, 처음엔 &quot;디자인이 너무 평범해지는 거 아닌가?&quot; 걱정했습니다. 하지만 완성하고 보니 <strong>기능이 곧 디자인</strong>이라는 걸 깨달았습니다. 사용자가 원하는 건 화려한 UI가 아니라 빠르고 명확한 UI였습니다.</p>
<h3 id="핵심-교훈">핵심 교훈</h3>
<p>이 프로젝트를 통해 <strong>&quot;사용자가 쉽게 사용할 수 있는 도구&quot;</strong>를 만드는 것이 <strong>&quot;기술적으로 완벽한 도구&quot;</strong>를 만드는 것보다 훨씬 중요하다는 걸 깨달았습니다.</p>
<ul>
<li>자동 감지가 100% 작동하지 않아도 → 시각적 선택으로 대안 제공</li>
<li>코드를 복사/붙여넣기하기 번거로워도 → 드래그 앤 드롭으로 간소화</li>
<li>화려한 카드 디자인이 공간을 많이 차지해도 → 실용적인 테이블로 전환</li>
</ul>
<p><strong>완벽한 기술보다 사용자 친화적인 경험이 더 중요합니다.</strong></p>
<hr>
<h2 id="🔗-링크">🔗 링크</h2>
<ul>
<li><a href="https://github.com/ej-rarus/login-bookmarklet-generator">GitHub Repository</a></li>
<li><a href="https://vercel.com/ej-rarus-projects/login-bookmarklet-generator">Live Service</a></li>
</ul>
<h2 id="📝-기술-블로그-시리즈">📝 기술 블로그 시리즈</h2>
<p>이 프로젝트에서 배운 내용을 시리즈로 정리할 예정입니다:</p>
<ol>
<li><p><strong>로그인 북마크렛 생성기 개발기</strong> ← 현재 글<img src="https://velog.velcdn.com/images/ej-rarus/post/5a9a4b9e-4c30-48c4-9426-e2e36ca738e9/image.png" alt=""></p>
</li>
<li><p><strong>iframe 다루기: pointer-events와 이벤트 위임</strong></p>
<ul>
<li>iframe 내부 스타일 동적 주입</li>
<li>pointer-events로 클릭 이벤트 제어</li>
<li>이벤트 위임 패턴의 장점</li>
</ul>
</li>
<li><p><strong>순수 JavaScript로 파일 업로드 구현하기</strong></p>
<ul>
<li>FileReader API 활용</li>
<li>Blob과 URL.createObjectURL()</li>
<li>Clipboard API로 복사 기능 구현</li>
</ul>
</li>
<li><p><strong>드래그 앤 드롭과 브라우저 북마크 포맷</strong></p>
<ul>
<li>HTML5 Drag and Drop API</li>
<li>Netscape Bookmark Format 파싱</li>
<li>크로스 브라우저 호환성</li>
</ul>
</li>
</ol>
<hr>
<p><strong>Made with ❤️ by <a href="https://leeeunjae.com">EJ Lee</a></strong></p>
<blockquote>
<p>이 프로젝트가 도움이 되셨나요? ⭐️ Star를 눌러주세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mermaid 인터랙티브 가이드 개발기 🎨]]></title>
            <link>https://velog.io/@ej-rarus/Mermaid-%EC%9D%B8%ED%84%B0%EB%9E%99%ED%8B%B0%EB%B8%8C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/Mermaid-%EC%9D%B8%ED%84%B0%EB%9E%99%ED%8B%B0%EB%B8%8C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Wed, 10 Dec 2025 07:48:27 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ej-rarus/post/4dd6cd3b-73fa-4cef-99db-cbb5f65f05e0/image.jpg" alt=""></p>
<p><strong>배포 링크</strong>: <a href="https://mermaid-interactive-guide.vercel.app">https://mermaid-interactive-guide.vercel.app</a>
<strong>GitHub</strong>: <a href="https://github.com/yourusername/mermaid-guide">Repository</a></p>
<hr>
<h2 id="🤔-왜-만들었나요">🤔 왜 만들었나요?</h2>
<p>회사에서 기획서나 문서를 작성할 때마다 다이어그램이 필요했습니다. PowerPoint로 그리자니 시간이 오래 걸리고, 수정할 때마다 일일이 박스를 움직여야 했죠.</p>
<p>그러던 중 <strong>Mermaid</strong>라는 도구를 알게 되었습니다. 텍스트 몇 줄이면 다이어그램이 완성되는 신기한 도구였어요! 하지만 주변 동료들은 &quot;어려워 보인다&quot;, &quot;배우기 힘들 것 같다&quot;는 반응이었습니다.</p>
<p><strong>&quot;5분이면 누구나 배울 수 있다&quot;</strong>는 걸 보여주고 싶어서 이 가이드를 만들게 되었습니다.</p>
<hr>
<h2 id="💡-핵심-아이디어">💡 핵심 아이디어</h2>
<h3 id="1-인터랙티브-학습">1. <strong>인터랙티브 학습</strong></h3>
<p>단순히 보기만 하는 튜토리얼이 아니라, <strong>직접 코드를 수정하고 결과를 바로 확인</strong>할 수 있도록 만들었습니다.</p>
<pre><code class="language-mermaid">graph LR
    A[코드 수정] --&gt; B[버튼 클릭] --&gt; C[결과 확인]</code></pre>
<p>각 레슨마다 &quot;✏️ 직접 수정해보기&quot; 버튼을 누르면 코드 에디터가 나타나고, 사용자가 직접 수정해볼 수 있습니다.</p>
<h3 id="2-단계별-학습">2. <strong>단계별 학습</strong></h3>
<p>초보자도 따라올 수 있도록 <strong>13개의 단계</strong>로 나누었습니다:</p>
<ul>
<li><strong>Lesson 0-9</strong>: 기본 문법 (박스, 화살표, 방향, 분기 등)</li>
<li><strong>Lesson 10</strong>: Claude AI와 연동하는 법</li>
<li><strong>Lesson 11</strong>: 이미지로 저장하는 법</li>
<li><strong>Lesson 12</strong>: 실무 활용 사례</li>
<li><strong>Lesson 13</strong>: 완료!</li>
</ul>
<h3 id="3-claude-ai-연동-강조">3. <strong>Claude AI 연동 강조</strong></h3>
<p>가장 중요한 포인트는 <strong>&quot;직접 그리지 마세요!&quot;</strong>입니다.</p>
<p>Mermaid 문법을 외울 필요 없이, Claude한테 &quot;○○○ 다이어그램 그려줘&quot;라고 하면 몇 초만에 완성됩니다. 이 가이드의 목적은:</p>
<ul>
<li>Mermaid가 무엇인지 이해하기</li>
<li>Claude가 만든 다이어그램을 읽고 수정할 수 있게 되기</li>
<li>실무에서 어떻게 활용할지 감 잡기</li>
</ul>
<hr>
<h2 id="🛠️-기술-스택">🛠️ 기술 스택</h2>
<h3 id="심플하게">심플하게!</h3>
<ul>
<li><strong>Mermaid.js v10</strong> - 다이어그램 렌더링</li>
<li><strong>Vanilla JavaScript</strong> - 인터랙티브 기능</li>
<li><strong>CSS3</strong> - 반응형 디자인 &amp; 애니메이션</li>
<li><strong>Vercel</strong> - 배포</li>
</ul>
<p>프레임워크 없이 순수 HTML/CSS/JS로 만들었습니다. 가볍고 빠르게 동작하는 것이 목표였어요!</p>
<hr>
<h2 id="🎨-디자인--ux">🎨 디자인 &amp; UX</h2>
<h3 id="그라디언트와-애니메이션">그라디언트와 애니메이션</h3>
<p>보라색 그라디언트(<code>#667eea</code> → <code>#764ba2</code>)를 메인 컬러로 사용했습니다. 시각적으로 눈에 띄면서도 부담스럽지 않은 색상이에요.</p>
<pre><code class="language-css">background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);</code></pre>
<p>&quot;✏️ 직접 수정해보기&quot; 버튼에는 <strong>펄스 애니메이션</strong>을 적용해서 &quot;클릭해보세요!&quot;라는 느낌을 주었습니다:</p>
<pre><code class="language-css">@keyframes pulse-button {
    0%, 100% {
        box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
    }
    50% {
        box-shadow: 0 4px 25px rgba(102, 126, 234, 0.6);
        transform: scale(1.02);
    }
}</code></pre>
<h3 id="목차-네비게이션">목차 네비게이션</h3>
<p>오른쪽에 고정된 목차(TOC)를 만들어서 언제든지 원하는 레슨으로 점프할 수 있게 했습니다. 모바일에서는 자동으로 숨겨지고, 📑 버튼을 눌러서 열 수 있어요.</p>
<hr>
<h2 id="🚧-개발-과정에서-겪은-문제들">🚧 개발 과정에서 겪은 문제들</h2>
<h3 id="1-다이어그램-렌더링-이슈">1. <strong>다이어그램 렌더링 이슈</strong></h3>
<p>사용자가 코드를 수정하고 &quot;🎨 그리기&quot; 버튼을 누를 때, Mermaid가 제대로 렌더링되지 않는 문제가 있었습니다.</p>
<p><strong>해결 방법</strong>: 기존 DOM을 완전히 제거하고 새로 만든 다음, <code>mermaid.init()</code>을 다시 호출했습니다.</p>
<pre><code class="language-javascript">function renderCode(lessonNum) {
    const code = document.getElementById(&#39;code-&#39; + lessonNum).value;
    const preview = document.getElementById(&#39;preview-&#39; + lessonNum);

    // 기존 내용 제거
    preview.innerHTML = &#39;&#39;;

    // 임시 div 생성
    const tempDiv = document.createElement(&#39;div&#39;);
    tempDiv.innerHTML = &#39;&lt;div class=&quot;mermaid&quot;&gt;&#39; + code + &#39;&lt;/div&gt;&#39;;

    // preview에 추가
    preview.appendChild(tempDiv);

    // Mermaid 다시 초기화
    mermaid.init(undefined, tempDiv.querySelector(&#39;.mermaid&#39;));
}</code></pre>
<h3 id="2-다이어그램-크기-조절">2. <strong>다이어그램 크기 조절</strong></h3>
<p>사용자가 큰 다이어그램을 만들면 화면을 넘어가는 문제가 있었습니다.</p>
<p><strong>해결 방법</strong>:</p>
<ul>
<li>SVG의 <code>max-width</code>를 500px로 제한</li>
<li>스크롤이 가능하도록 <code>overflow: auto</code> 적용</li>
<li>모바일에서는 100% width로 자동 조절</li>
</ul>
<pre><code class="language-css">.editor-container .preview-box svg {
    max-width: 500px !important;
    max-height: 500px !important;
    width: auto !important;
    height: auto !important;
}</code></pre>
<h3 id="3-seo--소셜-미디어-최적화">3. <strong>SEO &amp; 소셜 미디어 최적화</strong></h3>
<p>카카오톡이나 슬랙에 링크를 공유했을 때 예쁜 미리보기가 나오도록 <strong>Open Graph 메타 태그</strong>를 추가했습니다.</p>
<pre><code class="language-html">&lt;meta property=&quot;og:title&quot; content=&quot;Mermaid 다이어그램 - 인터랙티브 가이드&quot;&gt;
&lt;meta property=&quot;og:description&quot; content=&quot;텍스트로 다이어그램을 그리는 Mermaid를 5분만에 배워보세요!&quot;&gt;
&lt;meta property=&quot;og:image&quot; content=&quot;https://mermaid-interactive-guide.vercel.app/OG_image.png&quot;&gt;</code></pre>
<p>OG 이미지는 1792x1024 크기의 PNG 파일로 만들었어요!</p>
<hr>
<h2 id="📈-반복적인-개선">📈 반복적인 개선</h2>
<p>Git 커밋 히스토리를 보면 <strong>반복적으로 개선</strong>한 흔적이 보입니다:</p>
<ol>
<li><strong>첫 번째 버전</strong>: 기본 가이드 완성 (<code>bf715f9</code>)</li>
<li><strong>시스템 아키텍처 추가</strong> (<code>dc4e49c</code>)</li>
<li><strong>Vercel 배포 URL 추가</strong> (<code>79e7c81</code>)</li>
<li><strong>&quot;직접 해보세요&quot; 버튼 강조</strong> (<code>c52623b</code>) - 사용자가 인터랙티브 요소를 놓치지 않도록!</li>
<li><strong>제작자 크레딧 추가</strong> (<code>e840a79</code>)</li>
<li><strong>다이어그램 오버플로우 수정</strong> (<code>ca1c152</code>, <code>77063d8</code>, ...)</li>
<li><strong>SVG 크기 조절</strong> (<code>ea1319f</code>, <code>b841265</code>, ...)</li>
<li><strong>재시작 버튼 추가</strong> (<code>3131110</code>)</li>
<li><strong>Favicon &amp; 소셜 미디어 태그</strong> (<code>6e393f8</code>)</li>
<li><strong>OG 이미지 업데이트</strong> (<code>0ce030c</code>)</li>
</ol>
<p>특히 다이어그램 크기 문제는 여러 번 커밋을 거쳐 해결했습니다. 사용자 경험을 위해 계속 테스트하고 개선했어요!</p>
<hr>
<h2 id="🎯-타겟-사용자">🎯 타겟 사용자</h2>
<p>이 가이드는 다음 분들을 위해 만들었습니다:</p>
<ul>
<li>📝 <strong>기획자</strong>: 기획서에 플로우차트를 추가하고 싶은데 어떻게 해야 할지 모르는 분</li>
<li>💻 <strong>개발자</strong>: API 플로우나 시스템 구조를 설명해야 하는데 PPT 그리기 귀찮은 분</li>
<li>📊 <strong>PM</strong>: 프로젝트 프로세스를 시각화하고 싶은 분</li>
<li>🎓 <strong>다이어그램 툴 입문자</strong>: 처음 배우는 모든 분</li>
</ul>
<hr>
<h2 id="💪-실무-활용-예시">💪 실무 활용 예시</h2>
<p>가이드에서 소개한 실무 활용 사례입니다:</p>
<h3 id="1-기획자-좋아요-버튼-클릭-플로우">1. 기획자: 좋아요 버튼 클릭 플로우</h3>
<pre><code class="language-mermaid">graph LR
    Click[좋아요 버튼 클릭] --&gt; Check{로그인&lt;br/&gt;했나요?}
    Check --&gt;|아니오| Login[로그인 화면]
    Check --&gt;|예| Like[하트 빨강게 변함]
    Like --&gt; Count[숫자 1 증가]</code></pre>
<h3 id="2-개발자-코드-수정--배포-프로세스">2. 개발자: 코드 수정 &amp; 배포 프로세스</h3>
<pre><code class="language-mermaid">graph LR
    Code[코드 수정] --&gt; Test[내 컴퓨터에서 테스트]
    Test --&gt; Push[GitHub에 올리기]
    Push --&gt; Review[팀장님 확인]
    Review --&gt; Deploy[서버에 배포]</code></pre>
<h3 id="3-pm-고객-문의-처리">3. PM: 고객 문의 처리</h3>
<pre><code class="language-mermaid">graph TD
    Start([문의 접수]) --&gt; Type{문의 유형?}
    Type --&gt;|배송 문의| Ship[배송팀에 전달]
    Type --&gt;|환불 요청| Refund[환불 처리]
    Type --&gt;|상품 문의| Answer[답변 작성]

    Ship --&gt; Done([완료])
    Refund --&gt; Done
    Answer --&gt; Done</code></pre>
<hr>
<h2 id="🚀-배포">🚀 배포</h2>
<p><strong>Vercel</strong>로 배포했습니다. 설정이 거의 필요 없고, Git push만 하면 자동으로 배포되어서 정말 편리했어요!</p>
<p>배포 과정:</p>
<ol>
<li>GitHub에 레포 push</li>
<li>Vercel에서 &quot;New Project&quot; 클릭</li>
<li>GitHub 레포 선택</li>
<li>Deploy 버튼 클릭</li>
<li>완료! 🎉</li>
</ol>
<p>커스텀 도메인도 쉽게 연결할 수 있지만, 일단 Vercel 도메인(<code>mermaid-interactive-guide.vercel.app</code>)을 사용하고 있습니다.</p>
<hr>
<h2 id="📊-성과--피드백">📊 성과 &amp; 피드백</h2>
<h3 id="목표-달성">목표 달성</h3>
<ul>
<li>✅ 5분만에 Mermaid 기본 문법 이해</li>
<li>✅ Claude AI와 연동하는 법 학습</li>
<li>✅ 실무 활용 방법 습득</li>
<li>✅ 인터랙티브하게 직접 수정하며 배우기</li>
</ul>
<h3 id="앞으로의-계획">앞으로의 계획</h3>
<ul>
<li><input disabled="" type="checkbox"> 더 많은 다이어그램 타입 추가 (시퀀스 다이어그램, 클래스 다이어그램 등)</li>
<li><input disabled="" type="checkbox"> 실전 예제 더 추가</li>
<li><input disabled="" type="checkbox"> 다국어 지원 (영문 버전)</li>
<li><input disabled="" type="checkbox"> 사용자 피드백 수집 &amp; 개선</li>
</ul>
<hr>
<h2 id="🎓-배운-점">🎓 배운 점</h2>
<h3 id="1-단순함의-힘">1. <strong>단순함의 힘</strong></h3>
<p>프레임워크 없이 Vanilla JS로 만들었지만, 충분히 인터랙티브하고 반응성 있는 웹사이트를 만들 수 있었습니다. 때로는 단순한 것이 더 강력합니다!</p>
<h3 id="2-사용자-경험이-최우선">2. <strong>사용자 경험이 최우선</strong></h3>
<p>다이어그램 크기, 버튼 애니메이션, 목차 네비게이션 등 작은 디테일이 사용자 경험을 크게 바꿉니다. 커밋 히스토리를 보면 사용자 경험 개선에 많은 시간을 투자했다는 걸 알 수 있어요.</p>
<h3 id="3-반복적인-개선의-중요성">3. <strong>반복적인 개선의 중요성</strong></h3>
<p>첫 번째 버전은 완벽하지 않았습니다. 하지만 계속 테스트하고 개선하면서 점점 나아졌어요. <strong>&quot;완벽해질 때까지 기다리지 말고, 일단 만들고 개선하자!&quot;</strong></p>
<h3 id="4-메타-태그의-중요성">4. <strong>메타 태그의 중요성</strong></h3>
<p>OG 이미지와 메타 태그를 추가하니 링크를 공유했을 때 훨씬 전문적으로 보였습니다. SEO와 소셜 미디어 최적화는 정말 중요해요!</p>
<hr>
<h2 id="🙏-마치며">🙏 마치며</h2>
<p>처음에는 &quot;동료들에게 Mermaid를 알려주고 싶다&quot;는 단순한 목표로 시작했지만, 만들면서 많은 걸 배웠습니다.</p>
<p>특히 <strong>&quot;사용자가 직접 수정하며 배우는 것&quot;</strong>의 중요성을 깨달았어요. 단순히 보는 것과 직접 해보는 것은 완전히 다릅니다!</p>
<p>이 가이드가 더 많은 분들에게 Mermaid의 편리함을 알리는 계기가 되었으면 좋겠습니다 🎉</p>
<hr>
<p><strong>배포 링크</strong>: <a href="https://mermaid-interactive-guide.vercel.app">https://mermaid-interactive-guide.vercel.app</a></p>
<p>질문이나 피드백은 언제든지 환영합니다! 슬랙 <strong>@루쿠쿠 이은재 Tony</strong>로 연락주세요 🙌</p>
<p>Happy Diagramming! 🎨✨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Shopify 스토어 대시보드 만들기: 여러 API를 한 번에 조회하고 데이터 시각화하기]]></title>
            <link>https://velog.io/@ej-rarus/Shopify-%EC%8A%A4%ED%86%A0%EC%96%B4-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%97%AC%EB%9F%AC-API%EB%A5%BC-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%A1%B0%ED%9A%8C%ED%95%98%EA%B3%A0-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EA%B0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/Shopify-%EC%8A%A4%ED%86%A0%EC%96%B4-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%97%AC%EB%9F%AC-API%EB%A5%BC-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%A1%B0%ED%9A%8C%ED%95%98%EA%B3%A0-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EA%B0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 08 Dec 2025 03:50:47 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>제품, 주문 등 여러 데이터를 동시에 가져와 스토어 현황을 한눈에 보여주는 대시보드를 구현합니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>실제 비즈니스에서 가장 중요한 것은 <strong>현황 파악</strong>입니다. 제품이 몇 개나 있고, 주문은 얼마나 들어왔고, 매출은 얼마인지 한눈에 볼 수 있어야 합니다.</p>
<p>이전 포스트에서 제품 목록 페이지를 만들었다면, 이번에는 한 단계 더 나아가 <strong>여러 데이터를 통합해서 보여주는 대시보드</strong>를 만들어보겠습니다.</p>
<hr>
<h2 id="완성-미리보기">완성 미리보기</h2>
<pre><code>┌─────────────────────────────────────────────────────┐
│  스토어 대시보드                                        ![](https://velog.velcdn.com/images/ej-rarus/post/02ae66e2-5371-4d44-977f-a425172772c3/image.png)
│
├─────────────────────────────────────────────────────┤
│  My Store                                           │
│  store@example.com  •  플랜: Basic Shopify           │
│                                                     │
│  ┌─────────────┬─────────────┬─────────────┐        │
│  │  총 제품 수   │  총 주문 수    │  총 매출     │        │
│  │     25      │     143     │KRW 4,250,000│        │
│  └─────────────┴─────────────┴─────────────┘        │
│                                                     │
│  최근 주문                                            │
│  ┌─────────────────────────────────────────┐        │
│  │ #1043  •  2025년 12월 8일                 │        │
│  │ KRW 89,000  •  결제 완료                   │        │
│  └─────────────────────────────────────────┘        │
│  ...                                                │
│                                                     │
│  [사이드바]                                           │
│  재고 부족 알림                                        │
│  • Red T-Shirt (재고: 5개)                            │
│  • Blue Cap (재고: 3개)                               │
└─────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="1-대시보드란">1. 대시보드란?</h2>
<h3 id="대시보드의-목적">대시보드의 목적</h3>
<p><strong>대시보드(Dashboard)</strong>는 여러 데이터를 <strong>한 화면에 모아서</strong> 보여주는 페이지입니다.</p>
<p><strong>일반 페이지 vs 대시보드</strong>:</p>
<pre><code>제품 목록 페이지:
- 1개의 API 호출 (products)
- 1가지 데이터 표시 (제품)

대시보드:
- 여러 API 동시 호출 (products, orders, shop)
- 여러 데이터 통합 표시 (통계, 차트, 알림)
- 데이터 가공 (합계, 평균, 필터링)</code></pre><h3 id="우리가-만들-대시보드">우리가 만들 대시보드</h3>
<p><strong>표시할 정보</strong>:</p>
<ol>
<li><strong>스토어 정보</strong>: 이름, 이메일, 플랜</li>
<li><strong>주요 지표 카드</strong>: 총 제품 수, 총 주문 수, 총 매출</li>
<li><strong>최근 주문</strong>: 최근 5개 주문 내역</li>
<li><strong>재고 부족 알림</strong>: 재고 10개 미만 제품</li>
</ol>
<hr>
<h2 id="2-파일-생성-및-타입-정의">2. 파일 생성 및 타입 정의</h2>
<h3 id="파일-생성">파일 생성</h3>
<p><strong><code>app/routes/app.dashboard.tsx</code></strong> 파일을 생성합니다.</p>
<h3 id="typescript-타입-정의">TypeScript 타입 정의</h3>
<pre><code class="language-typescript">import type { LoaderFunctionArgs } from &quot;react-router&quot;;
import { useLoaderData } from &quot;react-router&quot;;
import { authenticate } from &quot;../shopify.server&quot;;

interface DashboardData {
  shop: {
    name: string;
    email: string;
    currencyCode: string;
    plan: {
      displayName: string;
    };
  };
  productsCount: number;
  ordersCount: number;
  totalSales: string;
  recentOrders: Array&lt;{
    id: string;
    name: string;
    createdAt: string;
    currentTotalPrice: string;
    displayFinancialStatus: string;
  }&gt;;
  lowStockProducts: Array&lt;{
    id: string;
    title: string;
    totalInventory: number;
  }&gt;;
}</code></pre>
<p><strong>포인트</strong>: 여러 종류의 데이터를 하나의 인터페이스로 정의합니다.</p>
<hr>
<h2 id="3-loader-함수-여러-api-동시-호출">3. Loader 함수: 여러 API 동시 호출</h2>
<p>대시보드의 핵심은 <strong>여러 데이터를 효율적으로 가져오는 것</strong>입니다.</p>
<h3 id="전체-구조">전체 구조</h3>
<pre><code class="language-typescript">export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  const { admin } = await authenticate.admin(request);

  // ① 스토어 정보 조회
  const shopResponse = await admin.graphql(`...`);

  // ② 제품 목록 조회
  const productsResponse = await admin.graphql(`...`);

  // ③ 주문 목록 조회
  const ordersResponse = await admin.graphql(`...`);

  // ④ 데이터 파싱 및 가공
  const shopData = await shopResponse.json();
  const productsData = await productsResponse.json();
  const ordersData = await ordersResponse.json();

  // ⑤ 통계 계산 및 반환
  return {
    shop: shopData.data.shop,
    productsCount: products.length,
    ordersCount: orders.length,
    totalSales: calculateTotalSales(orders),
    recentOrders: getRecentOrders(orders, 5),
    lowStockProducts: getLowStockProducts(products, 10),
  };
};</code></pre>
<h3 id="①-스토어-정보-조회">① 스토어 정보 조회</h3>
<pre><code class="language-typescript">const shopResponse = await admin.graphql(
  `#graphql
    query getShopInfo {
      shop {
        name
        email
        currencyCode
        plan {
          displayName
        }
      }
    }
  `
);
const shopData = await shopResponse.json();</code></pre>
<p><strong>가져오는 정보</strong>:</p>
<ul>
<li><code>name</code>: 스토어명</li>
<li><code>email</code>: 스토어 이메일</li>
<li><code>currencyCode</code>: 통화 코드 (KRW, USD 등)</li>
<li><code>plan.displayName</code>: 요금제 (Basic, Shopify, Advanced 등)</li>
</ul>
<h3 id="②-제품-목록-조회">② 제품 목록 조회</h3>
<pre><code class="language-typescript">const productsResponse = await admin.graphql(
  `#graphql
    query getProducts {
      products(first: 250) {
        edges {
          node {
            id
            title
            totalInventory
          }
        }
      }
    }
  `
);
const productsData = await productsResponse.json();
const products = productsData.data?.products?.edges.map((edge: any) =&gt; edge.node) || [];</code></pre>
<p><strong>왜 250개?</strong></p>
<ul>
<li>Shopify GraphQL은 한 번에 최대 250개까지 조회 가능</li>
<li>더 많은 데이터는 페이지네이션 필요</li>
</ul>
<h3 id="③-주문-목록-조회">③ 주문 목록 조회</h3>
<pre><code class="language-typescript">const ordersResponse = await admin.graphql(
  `#graphql
    query getOrders {
      orders(first: 250) {
        edges {
          node {
            id
            name
            createdAt
            currentTotalPriceSet {
              shopMoney {
                amount
                currencyCode
              }
            }
            displayFinancialStatus
          }
        }
      }
    }
  `
);
const ordersData = await ordersResponse.json();
const orders = ordersData.data?.orders?.edges.map((edge: any) =&gt; edge.node) || [];</code></pre>
<p><strong>주요 필드</strong>:</p>
<ul>
<li><code>name</code>: 주문 번호 (#1001)</li>
<li><code>createdAt</code>: 주문 일시</li>
<li><code>currentTotalPriceSet</code>: 주문 금액 (통화 포함)</li>
<li><code>displayFinancialStatus</code>: 결제 상태 (PAID, PENDING, REFUNDED 등)</li>
</ul>
<hr>
<h2 id="4-데이터-가공-및-계산">4. 데이터 가공 및 계산</h2>
<p>조회한 데이터를 대시보드에 맞게 가공합니다.</p>
<h3 id="총-매출-계산">총 매출 계산</h3>
<pre><code class="language-typescript">const totalSales = orders.reduce((sum: number, order: any) =&gt; {
  return sum + parseFloat(order.currentTotalPriceSet?.shopMoney?.amount || &quot;0&quot;);
}, 0);</code></pre>
<p><strong>동작 원리</strong>:</p>
<pre><code class="language-javascript">// orders 배열
[
  { amount: &quot;50000&quot; },
  { amount: &quot;30000&quot; },
  { amount: &quot;20000&quot; }
]

// reduce 계산
0 + 50000 = 50000
50000 + 30000 = 80000
80000 + 20000 = 100000

// 결과: 100000</code></pre>
<h3 id="최근-주문-추출">최근 주문 추출</h3>
<pre><code class="language-typescript">const recentOrders = orders
  .slice(0, 5)  // 처음 5개만
  .map((order: any) =&gt; ({
    id: order.id,
    name: order.name,
    createdAt: order.createdAt,
    currentTotalPrice: order.currentTotalPriceSet?.shopMoney?.amount || &quot;0&quot;,
    displayFinancialStatus: order.displayFinancialStatus,
  }));</code></pre>
<p><strong><code>slice(0, 5)</code></strong>: 배열의 처음 5개 요소만 선택</p>
<h3 id="재고-부족-제품-필터링">재고 부족 제품 필터링</h3>
<pre><code class="language-typescript">const lowStockProducts = products
  .filter((p: any) =&gt; p.totalInventory &gt; 0 &amp;&amp; p.totalInventory &lt; 10)
  .slice(0, 5)
  .map((p: any) =&gt; ({
    id: p.id,
    title: p.title,
    totalInventory: p.totalInventory,
  }));</code></pre>
<p><strong>로직</strong>:</p>
<ol>
<li><code>filter</code>: 재고가 1~9개인 제품만 선택</li>
<li><code>slice(0, 5)</code>: 최대 5개만</li>
<li><code>map</code>: 필요한 필드만 추출</li>
</ol>
<hr>
<h2 id="5-ui-컴포넌트-카드와-통계">5. UI 컴포넌트: 카드와 통계</h2>
<p>대시보드는 <strong>시각적으로 정보를 전달</strong>해야 합니다.</p>
<h3 id="스토어-정보-섹션">스토어 정보 섹션</h3>
<pre><code class="language-tsx">&lt;s-section&gt;
  &lt;s-stack direction=&quot;block&quot; gap=&quot;base&quot;&gt;
    &lt;s-text variant=&quot;heading-lg&quot;&gt;{data.shop.name}&lt;/s-text&gt;
    &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
      &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
        {data.shop.email}
      &lt;/s-text&gt;
      &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
        플랜: {data.shop.plan?.displayName}
      &lt;/s-text&gt;
    &lt;/s-stack&gt;
  &lt;/s-stack&gt;
&lt;/s-section&gt;</code></pre>
<h3 id="주요-지표-카드-3개">주요 지표 카드 (3개)</h3>
<pre><code class="language-tsx">&lt;s-section heading=&quot;주요 지표&quot;&gt;
  &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
    {/* 제품 수 카드 */}
    &lt;s-box
      padding=&quot;large&quot;
      borderWidth=&quot;base&quot;
      borderRadius=&quot;large&quot;
      background=&quot;surface&quot;
      style={{ flex: 1 }}
    &gt;
      &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
        &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
          {data.productsCount}
        &lt;/s-text&gt;
        &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
          총 제품 수
        &lt;/s-text&gt;
      &lt;/s-stack&gt;
    &lt;/s-box&gt;

    {/* 주문 수 카드 */}
    &lt;s-box
      padding=&quot;large&quot;
      borderWidth=&quot;base&quot;
      borderRadius=&quot;large&quot;
      background=&quot;surface&quot;
      style={{ flex: 1 }}
    &gt;
      &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
        &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
          {data.ordersCount}
        &lt;/s-text&gt;
        &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
          총 주문 수
        &lt;/s-text&gt;
      &lt;/s-stack&gt;
    &lt;/s-box&gt;

    {/* 매출 카드 */}
    &lt;s-box
      padding=&quot;large&quot;
      borderWidth=&quot;base&quot;
      borderRadius=&quot;large&quot;
      background=&quot;surface&quot;
      style={{ flex: 1 }}
    &gt;
      &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
        &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
          {formatCurrency(data.totalSales)}
        &lt;/s-text&gt;
        &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
          총 매출
        &lt;/s-text&gt;
      &lt;/s-stack&gt;
    &lt;/s-box&gt;
  &lt;/s-stack&gt;
&lt;/s-section&gt;</code></pre>
<p><strong>핵심 스타일링</strong>:</p>
<ul>
<li><code>direction=&quot;inline&quot;</code>: 가로로 나열</li>
<li><code>style={{ flex: 1 }}</code>: 동일한 너비로 분할</li>
<li><code>variant=&quot;heading-2xl&quot;</code>: 큰 숫자 강조</li>
</ul>
<h3 id="최근-주문-목록">최근 주문 목록</h3>
<pre><code class="language-tsx">&lt;s-section heading=&quot;최근 주문&quot;&gt;
  {data.recentOrders.length === 0 ? (
    &lt;s-text tone=&quot;subdued&quot;&gt;주문이 없습니다.&lt;/s-text&gt;
  ) : (
    &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
      {data.recentOrders.map((order) =&gt; (
        &lt;s-box
          key={order.id}
          padding=&quot;base&quot;
          borderWidth=&quot;base&quot;
          borderRadius=&quot;base&quot;
          background=&quot;surface&quot;
        &gt;
          &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot; align=&quot;center&quot;&gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; style={{ flex: 1 }}&gt;
              &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                {order.name}
              &lt;/s-text&gt;
              &lt;s-text variant=&quot;body-sm&quot; tone=&quot;subdued&quot;&gt;
                {formatDate(order.createdAt)}
              &lt;/s-text&gt;
            &lt;/s-stack&gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; align=&quot;end&quot;&gt;
              &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                {formatCurrency(order.currentTotalPrice)}
              &lt;/s-text&gt;
              &lt;s-text
                variant=&quot;body-sm&quot;
                tone={
                  order.displayFinancialStatus === &quot;PAID&quot;
                    ? &quot;success&quot;
                    : &quot;subdued&quot;
                }
              &gt;
                {order.displayFinancialStatus === &quot;PAID&quot;
                  ? &quot;결제 완료&quot;
                  : order.displayFinancialStatus === &quot;PENDING&quot;
                  ? &quot;결제 대기&quot;
                  : order.displayFinancialStatus}
              &lt;/s-text&gt;
            &lt;/s-stack&gt;
          &lt;/s-stack&gt;
        &lt;/s-box&gt;
      ))}
    &lt;/s-stack&gt;
  )}
&lt;/s-section&gt;</code></pre>
<p><strong>조건부 색상</strong>:</p>
<ul>
<li><code>tone=&quot;success&quot;</code>: 결제 완료 시 녹색</li>
<li><code>tone=&quot;subdued&quot;</code>: 그 외 회색</li>
</ul>
<h3 id="재고-부족-알림-사이드바">재고 부족 알림 (사이드바)</h3>
<pre><code class="language-tsx">&lt;s-section slot=&quot;aside&quot; heading=&quot;재고 부족 알림&quot;&gt;
  {data.lowStockProducts.length === 0 ? (
    &lt;s-text tone=&quot;subdued&quot;&gt;재고 부족 제품이 없습니다.&lt;/s-text&gt;
  ) : (
    &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
      {data.lowStockProducts.map((product) =&gt; (
        &lt;s-box
          key={product.id}
          padding=&quot;base&quot;
          borderWidth=&quot;base&quot;
          borderRadius=&quot;base&quot;
          background=&quot;surface&quot;
        &gt;
          &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
            &lt;s-text variant=&quot;body-md&quot; fontWeight=&quot;bold&quot;&gt;
              {product.title}
            &lt;/s-text&gt;
            &lt;s-text variant=&quot;body-sm&quot; tone=&quot;critical&quot;&gt;
              재고: {product.totalInventory}개
            &lt;/s-text&gt;
          &lt;/s-stack&gt;
        &lt;/s-box&gt;
      ))}
    &lt;/s-stack&gt;
  )}
&lt;/s-section&gt;</code></pre>
<p><strong><code>slot=&quot;aside&quot;</code></strong>: Polaris의 사이드바 슬롯에 배치</p>
<p><strong><code>tone=&quot;critical&quot;</code></strong>: 빨간색 강조 (경고)</p>
<hr>
<h2 id="6-유틸리티-함수">6. 유틸리티 함수</h2>
<p>데이터를 보기 좋게 포맷팅하는 함수들입니다.</p>
<h3 id="날짜-포맷팅">날짜 포맷팅</h3>
<pre><code class="language-typescript">const formatDate = (dateString: string) =&gt; {
  const date = new Date(dateString);
  return new Intl.DateTimeFormat(&quot;ko-KR&quot;, {
    year: &quot;numeric&quot;,
    month: &quot;long&quot;,
    day: &quot;numeric&quot;,
    hour: &quot;2-digit&quot;,
    minute: &quot;2-digit&quot;,
  }).format(date);
};

// 사용 예
formatDate(&quot;2025-12-08T10:30:00Z&quot;)
// → &quot;2025년 12월 8일 오전 10:30&quot;</code></pre>
<h3 id="통화-포맷팅">통화 포맷팅</h3>
<pre><code class="language-typescript">const formatCurrency = (amount: string) =&gt; {
  return `${data.shop.currencyCode} ${Number(amount).toLocaleString()}`;
};

// 사용 예
formatCurrency(&quot;1234567&quot;)
// → &quot;KRW 1,234,567&quot;</code></pre>
<p><strong><code>toLocaleString()</code></strong>: 천 단위 구분 기호 자동 추가</p>
<hr>
<h2 id="7-권한-설정">7. 권한 설정</h2>
<p>주문 데이터를 조회하려면 권한이 필요합니다.</p>
<h3 id="shopifyapptoml-수정"><code>shopify.app.toml</code> 수정</h3>
<pre><code class="language-toml">[access_scopes]
scopes = &quot;write_products,read_orders&quot;</code></pre>
<p><strong>필요한 권한</strong>:</p>
<ul>
<li><code>write_products</code>: 제품 조회 (이미 있음)</li>
<li><code>read_orders</code>: 주문 조회 (추가 필요)</li>
</ul>
<h3 id="서버-재시작">서버 재시작</h3>
<pre><code class="language-bash"># 기존 서버 종료 (Ctrl+C)
npm run dev</code></pre>
<p><strong>개발 모드에서는 자동으로 권한 승인됩니다!</strong></p>
<hr>
<h2 id="8-protected-customer-data-이슈">8. Protected Customer Data 이슈</h2>
<h3 id="문제-발생">문제 발생</h3>
<p>처음에 고객 데이터까지 표시하려고 했지만:</p>
<pre><code class="language-typescript">// ❌ 에러 발생!
const customersResponse = await admin.graphql(`
  query {
    customers(first: 10) {
      edges {
        node {
          id
          email
          firstName
        }
      }
    }
  }
`);

// Error: This app is not approved to access the Customer object.</code></pre>
<h3 id="이유">이유</h3>
<p>Shopify는 <strong>고객 개인정보 보호</strong>를 위해 특별한 승인 절차가 필요합니다:</p>
<ul>
<li>개인정보 보호 정책 제출</li>
<li>앱 심사 및 승인</li>
<li>Shopify의 승인 대기</li>
</ul>
<p>개발 단계에서는 접근 불가!</p>
<h3 id="해결책">해결책</h3>
<p>대시보드에서 고객 수 카드를 제거하고, 주문의 고객 이름도 표시하지 않습니다:</p>
<pre><code class="language-typescript">// ✅ 고객 데이터 없이도 충분히 유용한 대시보드
return {
  shop: shopData.data.shop,
  productsCount: products.length,
  ordersCount: orders.length,
  totalSales: totalSales.toFixed(2),
  recentOrders,  // 고객 이름 제외
  lowStockProducts,
};</code></pre>
<hr>
<h2 id="9-네비게이션-추가">9. 네비게이션 추가</h2>
<h3 id="approutesapptsx-수정"><code>app/routes/app.tsx</code> 수정</h3>
<pre><code class="language-tsx">&lt;s-app-nav&gt;
  &lt;s-link href=&quot;/app&quot;&gt;Home&lt;/s-link&gt;
  &lt;s-link href=&quot;/app/dashboard&quot;&gt;대시보드&lt;/s-link&gt;  {/* ← 추가 */}
  &lt;s-link href=&quot;/app/products&quot;&gt;제품 목록&lt;/s-link&gt;
&lt;/s-app-nav&gt;</code></pre>
<hr>
<h2 id="10-완성-코드">10. 완성 코드</h2>
<h3 id="전체-파일">전체 파일</h3>
<p><strong><code>app/routes/app.dashboard.tsx</code></strong>:</p>
<pre><code class="language-typescript">import type { LoaderFunctionArgs } from &quot;react-router&quot;;
import { useLoaderData } from &quot;react-router&quot;;
import { authenticate } from &quot;../shopify.server&quot;;

interface DashboardData {
  shop: {
    name: string;
    email: string;
    currencyCode: string;
    plan: {
      displayName: string;
    };
  };
  productsCount: number;
  ordersCount: number;
  totalSales: string;
  recentOrders: Array&lt;{
    id: string;
    name: string;
    createdAt: string;
    currentTotalPrice: string;
    displayFinancialStatus: string;
  }&gt;;
  lowStockProducts: Array&lt;{
    id: string;
    title: string;
    totalInventory: number;
  }&gt;;
}

export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  const { admin } = await authenticate.admin(request);

  // 스토어 정보
  const shopResponse = await admin.graphql(
    `#graphql
      query getShopInfo {
        shop {
          name
          email
          currencyCode
          plan {
            displayName
          }
        }
      }
    `
  );
  const shopData = await shopResponse.json();

  // 제품 통계
  const productsResponse = await admin.graphql(
    `#graphql
      query getProducts {
        products(first: 250) {
          edges {
            node {
              id
              title
              totalInventory
            }
          }
        }
      }
    `
  );
  const productsData = await productsResponse.json();
  const products = productsData.data?.products?.edges.map((edge: any) =&gt; edge.node) || [];

  // 주문 통계
  const ordersResponse = await admin.graphql(
    `#graphql
      query getOrders {
        orders(first: 250) {
          edges {
            node {
              id
              name
              createdAt
              currentTotalPriceSet {
                shopMoney {
                  amount
                  currencyCode
                }
              }
              displayFinancialStatus
            }
          }
        }
      }
    `
  );
  const ordersData = await ordersResponse.json();
  const orders = ordersData.data?.orders?.edges.map((edge: any) =&gt; edge.node) || [];

  // 데이터 가공
  const totalSales = orders.reduce((sum: number, order: any) =&gt; {
    return sum + parseFloat(order.currentTotalPriceSet?.shopMoney?.amount || &quot;0&quot;);
  }, 0);

  const recentOrders = orders
    .slice(0, 5)
    .map((order: any) =&gt; ({
      id: order.id,
      name: order.name,
      createdAt: order.createdAt,
      currentTotalPrice: order.currentTotalPriceSet?.shopMoney?.amount || &quot;0&quot;,
      displayFinancialStatus: order.displayFinancialStatus,
    }));

  const lowStockProducts = products
    .filter((p: any) =&gt; p.totalInventory &gt; 0 &amp;&amp; p.totalInventory &lt; 10)
    .slice(0, 5)
    .map((p: any) =&gt; ({
      id: p.id,
      title: p.title,
      totalInventory: p.totalInventory,
    }));

  return {
    shop: shopData.data?.shop || {},
    productsCount: products.length,
    ordersCount: orders.length,
    totalSales: totalSales.toFixed(2),
    recentOrders,
    lowStockProducts,
  };
};

export default function DashboardPage() {
  const data = useLoaderData&lt;DashboardData&gt;();

  const formatDate = (dateString: string) =&gt; {
    const date = new Date(dateString);
    return new Intl.DateTimeFormat(&quot;ko-KR&quot;, {
      year: &quot;numeric&quot;,
      month: &quot;long&quot;,
      day: &quot;numeric&quot;,
      hour: &quot;2-digit&quot;,
      minute: &quot;2-digit&quot;,
    }).format(date);
  };

  const formatCurrency = (amount: string) =&gt; {
    return `${data.shop.currencyCode} ${Number(amount).toLocaleString()}`;
  };

  return (
    &lt;s-page heading=&quot;스토어 대시보드&quot;&gt;
      {/* 스토어 정보 */}
      &lt;s-section&gt;
        &lt;s-stack direction=&quot;block&quot; gap=&quot;base&quot;&gt;
          &lt;s-text variant=&quot;heading-lg&quot;&gt;{data.shop.name}&lt;/s-text&gt;
          &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
            &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
              {data.shop.email}
            &lt;/s-text&gt;
            &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
              플랜: {data.shop.plan?.displayName}
            &lt;/s-text&gt;
          &lt;/s-stack&gt;
        &lt;/s-stack&gt;
      &lt;/s-section&gt;

      {/* 주요 지표 */}
      &lt;s-section heading=&quot;주요 지표&quot;&gt;
        &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
          &lt;s-box
            padding=&quot;large&quot;
            borderWidth=&quot;base&quot;
            borderRadius=&quot;large&quot;
            background=&quot;surface&quot;
            style={{ flex: 1 }}
          &gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
              &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
                {data.productsCount}
              &lt;/s-text&gt;
              &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
                총 제품 수
              &lt;/s-text&gt;
            &lt;/s-stack&gt;
          &lt;/s-box&gt;

          &lt;s-box
            padding=&quot;large&quot;
            borderWidth=&quot;base&quot;
            borderRadius=&quot;large&quot;
            background=&quot;surface&quot;
            style={{ flex: 1 }}
          &gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
              &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
                {data.ordersCount}
              &lt;/s-text&gt;
              &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
                총 주문 수
              &lt;/s-text&gt;
            &lt;/s-stack&gt;
          &lt;/s-box&gt;

          &lt;s-box
            padding=&quot;large&quot;
            borderWidth=&quot;base&quot;
            borderRadius=&quot;large&quot;
            background=&quot;surface&quot;
            style={{ flex: 1 }}
          &gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
              &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
                {formatCurrency(data.totalSales)}
              &lt;/s-text&gt;
              &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
                총 매출
              &lt;/s-text&gt;
            &lt;/s-stack&gt;
          &lt;/s-box&gt;
        &lt;/s-stack&gt;
      &lt;/s-section&gt;

      {/* 최근 주문 */}
      &lt;s-section heading=&quot;최근 주문&quot;&gt;
        {data.recentOrders.length === 0 ? (
          &lt;s-text tone=&quot;subdued&quot;&gt;주문이 없습니다.&lt;/s-text&gt;
        ) : (
          &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
            {data.recentOrders.map((order) =&gt; (
              &lt;s-box
                key={order.id}
                padding=&quot;base&quot;
                borderWidth=&quot;base&quot;
                borderRadius=&quot;base&quot;
                background=&quot;surface&quot;
              &gt;
                &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot; align=&quot;center&quot;&gt;
                  &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; style={{ flex: 1 }}&gt;
                    &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                      {order.name}
                    &lt;/s-text&gt;
                    &lt;s-text variant=&quot;body-sm&quot; tone=&quot;subdued&quot;&gt;
                      {formatDate(order.createdAt)}
                    &lt;/s-text&gt;
                  &lt;/s-stack&gt;
                  &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; align=&quot;end&quot;&gt;
                    &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                      {formatCurrency(order.currentTotalPrice)}
                    &lt;/s-text&gt;
                    &lt;s-text
                      variant=&quot;body-sm&quot;
                      tone={
                        order.displayFinancialStatus === &quot;PAID&quot;
                          ? &quot;success&quot;
                          : &quot;subdued&quot;
                      }
                    &gt;
                      {order.displayFinancialStatus === &quot;PAID&quot;
                        ? &quot;결제 완료&quot;
                        : order.displayFinancialStatus === &quot;PENDING&quot;
                        ? &quot;결제 대기&quot;
                        : order.displayFinancialStatus}
                    &lt;/s-text&gt;
                  &lt;/s-stack&gt;
                &lt;/s-stack&gt;
              &lt;/s-box&gt;
            ))}
          &lt;/s-stack&gt;
        )}
      &lt;/s-section&gt;

      {/* 재고 부족 제품 */}
      &lt;s-section slot=&quot;aside&quot; heading=&quot;재고 부족 알림&quot;&gt;
        {data.lowStockProducts.length === 0 ? (
          &lt;s-text tone=&quot;subdued&quot;&gt;재고 부족 제품이 없습니다.&lt;/s-text&gt;
        ) : (
          &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
            {data.lowStockProducts.map((product) =&gt; (
              &lt;s-box
                key={product.id}
                padding=&quot;base&quot;
                borderWidth=&quot;base&quot;
                borderRadius=&quot;base&quot;
                background=&quot;surface&quot;
              &gt;
                &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot;&gt;
                  &lt;s-text variant=&quot;body-md&quot; fontWeight=&quot;bold&quot;&gt;
                    {product.title}
                  &lt;/s-text&gt;
                  &lt;s-text variant=&quot;body-sm&quot; tone=&quot;critical&quot;&gt;
                    재고: {product.totalInventory}개
                  &lt;/s-text&gt;
                &lt;/s-stack&gt;
              &lt;/s-box&gt;
            ))}
          &lt;/s-stack&gt;
        )}
      &lt;/s-section&gt;

      {/* 안내 */}
      &lt;s-section slot=&quot;aside&quot; heading=&quot;대시보드 정보&quot;&gt;
        &lt;s-paragraph&gt;
          이 대시보드는 스토어의 주요 지표를 실시간으로 표시합니다.
        &lt;/s-paragraph&gt;
        &lt;s-unordered-list&gt;
          &lt;s-list-item&gt;제품, 주문 통계&lt;/s-list-item&gt;
          &lt;s-list-item&gt;최근 5개 주문 내역&lt;/s-list-item&gt;
          &lt;s-list-item&gt;재고 10개 미만 제품 알림&lt;/s-list-item&gt;
        &lt;/s-unordered-list&gt;
        &lt;s-paragraph&gt;
          참고: 고객 데이터는 Shopify의 보호된 데이터 정책으로 인해 표시되지 않습니다.
        &lt;/s-paragraph&gt;
      &lt;/s-section&gt;
    &lt;/s-page&gt;
  );
}</code></pre>
<hr>
<h2 id="11-확장-아이디어">11. 확장 아이디어</h2>
<h3 id="일별-매출-차트">일별 매출 차트</h3>
<pre><code class="language-typescript">// Chart.js 또는 Recharts 라이브러리 사용
const salesByDate = orders.reduce((acc, order) =&gt; {
  const date = order.createdAt.split(&#39;T&#39;)[0];
  acc[date] = (acc[date] || 0) + parseFloat(order.currentTotalPrice);
  return acc;
}, {});</code></pre>
<h3 id="베스트셀러-top-5">베스트셀러 TOP 5</h3>
<p>주문의 <code>lineItems</code>를 분석해서 가장 많이 팔린 제품 추출:</p>
<pre><code class="language-typescript">const bestSellers = lineItems
  .reduce((acc, item) =&gt; {
    acc[item.productId] = (acc[item.productId] || 0) + item.quantity;
    return acc;
  }, {})
  .sort((a, b) =&gt; b.quantity - a.quantity)
  .slice(0, 5);</code></pre>
<h3 id="실시간-업데이트">실시간 업데이트</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const interval = setInterval(() =&gt; {
    revalidate();  // React Router의 revalidate 사용
  }, 30000);  // 30초마다 갱신

  return () =&gt; clearInterval(interval);
}, []);</code></pre>
<h3 id="기간-선택-필터">기간 선택 필터</h3>
<pre><code class="language-typescript">const [period, setPeriod] = useState(&#39;week&#39;); // week, month, year

const filteredOrders = orders.filter(order =&gt; {
  const orderDate = new Date(order.createdAt);
  const now = new Date();
  const diff = now - orderDate;

  if (period === &#39;week&#39;) return diff &lt; 7 * 24 * 60 * 60 * 1000;
  if (period === &#39;month&#39;) return diff &lt; 30 * 24 * 60 * 60 * 1000;
  return true;
});</code></pre>
<hr>
<h2 id="12-핵심-개념-정리">12. 핵심 개념 정리</h2>
<h3 id="여러-api-병렬-호출">여러 API 병렬 호출</h3>
<pre><code class="language-typescript">// ✅ 좋은 예: 순차 호출 (하나씩)
const shop = await admin.graphql(`query { shop {...} }`);
const products = await admin.graphql(`query { products {...} }`);
const orders = await admin.graphql(`query { orders {...} }`);

// 총 시간: T1 + T2 + T3

// ⚠️ 더 좋은 예: 병렬 호출 (동시에)
const [shopRes, productsRes, ordersRes] = await Promise.all([
  admin.graphql(`query { shop {...} }`),
  admin.graphql(`query { products {...} }`),
  admin.graphql(`query { orders {...} }`)
]);

// 총 시간: max(T1, T2, T3)</code></pre>
<p>현재 코드는 순차 호출이지만, <code>Promise.all</code>을 사용하면 더 빠르게 개선 가능!</p>
<h3 id="array-메서드-활용">Array 메서드 활용</h3>
<pre><code class="language-javascript">// filter: 조건에 맞는 항목 선택
products.filter(p =&gt; p.inventory &lt; 10)

// map: 각 항목 변환
products.map(p =&gt; ({ id: p.id, name: p.title }))

// reduce: 합계 계산
orders.reduce((sum, order) =&gt; sum + order.price, 0)

// slice: 일부만 선택
orders.slice(0, 5)  // 처음 5개</code></pre>
<h3 id="데이터-가공의-중요성">데이터 가공의 중요성</h3>
<pre><code>원시 데이터 (Raw Data)
    ↓ filter, map, reduce
가공된 데이터 (Processed Data)
    ↓ formatCurrency, formatDate
표시용 데이터 (Display Data)</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>축하합니다! 스토어 대시보드를 완성했습니다! 🎉</p>
<p>이 대시보드를 만들면서:</p>
<p>✅ <strong>여러 API를 한 번에 호출</strong>하는 방법을 배웠습니다
✅ <strong>데이터를 가공하고 계산</strong>하는 로직을 구현했습니다
✅ <strong>카드 형태의 통계 UI</strong>를 만들었습니다
✅ <strong>조건부 스타일링</strong>으로 상태를 시각적으로 표현했습니다
✅ <strong>Protected Customer Data</strong> 이슈를 처리했습니다</p>
<p><strong>제품 목록 페이지</strong>는 단순 조회였다면, <strong>대시보드</strong>는 여러 데이터를 통합하고 가공하는 한 단계 높은 수준의 기능입니다.</p>
<p>다음 단계로는:</p>
<ul>
<li>차트 라이브러리로 시각화 강화</li>
<li>기간별 필터링 기능</li>
<li>실시간 업데이트</li>
<li>데이터 내보내기 (CSV, Excel)</li>
</ul>
<p>실전 비즈니스에서 가장 중요한 것은 <strong>데이터를 얼마나 잘 보여주느냐</strong>입니다. 이 대시보드가 그 시작점이 될 것입니다!</p>
<hr>
<p><strong>시리즈 글</strong>:</p>
<ul>
<li><a href="%EB%A7%81%ED%81%AC">Shopify 커스텀 앱 개발 입문</a></li>
<li><a href="%EB%A7%81%ED%81%AC">Shopify Admin API 완벽 가이드</a></li>
<li><a href="%EB%A7%81%ED%81%AC">제품 목록 페이지 만들기</a></li>
<li><strong>스토어 대시보드 만들기</strong> (현재 글)</li>
</ul>
<p><strong>GitHub 저장소</strong>: <a href="https://github.com/ej-rarus/integrated-projection-app">https://github.com/ej-rarus/integrated-projection-app</a></p>
<p><strong>질문이나 피드백은 댓글로 남겨주세요!</strong> 💬</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Shopify API로 제품 목록 페이지 만들기: 첫 번째 실전 기능 구현]]></title>
            <link>https://velog.io/@ej-rarus/Shopify-API%EB%A1%9C-%EC%A0%9C%ED%92%88-%EB%AA%A9%EB%A1%9D-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@ej-rarus/Shopify-API%EB%A1%9C-%EC%A0%9C%ED%92%88-%EB%AA%A9%EB%A1%9D-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 08 Dec 2025 03:26:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Shopify Admin GraphQL API를 활용해 실제 스토어 데이터를 조회하고 화면에 표시하는 방법을 단계별로 알아봅니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>이전 포스트에서 Shopify 커스텀 앱의 기본 구조와 API 사용법을 배웠습니다. 이제는 실제로 <strong>스토어 데이터를 다루는 기능</strong>을 만들어볼 차례입니다.</p>
<p>첫 번째 실전 프로젝트로 <strong>제품 목록 페이지</strong>를 만들어보겠습니다. 이 페이지는:</p>
<ul>
<li>스토어의 모든 제품을 조회하고</li>
<li>제품 이미지, 이름, 가격, 재고를 표시하며</li>
<li>Shopify 스타일의 깔끔한 UI로 구현됩니다</li>
</ul>
<hr>
<h2 id="완성-미리보기">완성 미리보기</h2>
<p>이 튜토리얼을 마치면 다음과 같은 페이지가 만들어집니다:</p>
<pre><code>┌─────────────────────────────────────────────────┐
│  제품 목록                                        │
├─────────────────────────────────────────────────┤
│  총 25개의 제품                                    │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ [이미지]  Red Snowboard                   │    │
│  │          가격: ₩100,000  재고: 50개        │    │
│  │          상태: 활성                       │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ [이미지]  Blue T-Shirt                    │    │
│  │          가격: ₩29,000  재고: 100개        │    │
│  │          상태: 활성                        │   │
│  │                                         │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
│  ...                                            │
└─────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="1-새-페이지-파일-생성">1. 새 페이지 파일 생성</h2>
<p>React Router는 <strong>파일명이 곧 URL</strong>입니다.</p>
<h3 id="파일-생성">파일 생성</h3>
<p><strong><code>app/routes/app.products.tsx</code></strong> 파일을 생성합니다:</p>
<pre><code>app/routes/
├── app.tsx              # 레이아웃 (이미 있음)
├── app._index.tsx       # Home (이미 있음)
└── app.products.tsx     # ← 새로 생성!</code></pre><p><strong>URL</strong>: <code>/app/products</code></p>
<hr>
<h2 id="2-typescript-타입-정의">2. TypeScript 타입 정의</h2>
<p>먼저 제품 데이터의 타입을 정의합니다:</p>
<pre><code class="language-typescript">// app/routes/app.products.tsx

import type { LoaderFunctionArgs } from &quot;react-router&quot;;
import { useLoaderData } from &quot;react-router&quot;;
import { authenticate } from &quot;../shopify.server&quot;;

// 제품 데이터 타입 정의
interface Product {
  id: string;
  title: string;
  status: string;
  totalInventory: number;
  featuredImage?: {
    url: string;
  };
  variants: {
    edges: Array&lt;{
      node: {
        price: string;
      };
    }&gt;;
  };
}

// Loader가 반환하는 데이터 타입
interface LoaderData {
  products: Product[];
}</code></pre>
<p><strong>왜 타입을 정의하나요?</strong></p>
<ul>
<li>TypeScript의 자동완성 지원</li>
<li>런타임 에러 방지</li>
<li>코드 가독성 향상</li>
</ul>
<hr>
<h2 id="3-loader-함수-데이터-가져오기">3. Loader 함수: 데이터 가져오기</h2>
<p>React Router의 <strong>Loader</strong>는 페이지 로드 시 서버에서 데이터를 가져오는 함수입니다.</p>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-typescript">export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  // ① 인증된 admin 객체 가져오기
  const { admin } = await authenticate.admin(request);

  // ② Shopify GraphQL API로 제품 목록 가져오기
  const response = await admin.graphql(
    `#graphql
      query getProducts {
        products(first: 50) {
          edges {
            node {
              id
              title
              status
              totalInventory
              featuredImage {
                url
              }
              variants(first: 1) {
                edges {
                  node {
                    price
                  }
                }
              }
            }
          }
        }
      }
    `
  );

  // ③ 응답을 JSON으로 파싱
  const responseJson = await response.json();

  // ④ GraphQL의 edges 구조에서 실제 데이터 추출
  const products = responseJson.data?.products?.edges.map((edge: any) =&gt; edge.node) || [];

  // ⑤ 컴포넌트로 데이터 전달
  return { products };
};</code></pre>
<h3 id="단계별-설명">단계별 설명</h3>
<h4 id="①-인증-객체-가져오기">① 인증 객체 가져오기</h4>
<pre><code class="language-typescript">const { admin } = await authenticate.admin(request);</code></pre>
<ul>
<li><code>authenticate.admin(request)</code>: 현재 세션 검증</li>
<li><code>admin</code>: Shopify API 호출을 위한 클라이언트</li>
<li>인증 실패 시 자동으로 로그인 페이지로 리다이렉트</li>
</ul>
<h4 id="②-graphql-쿼리-작성">② GraphQL 쿼리 작성</h4>
<pre><code class="language-graphql">query getProducts {
  products(first: 50) {        # 제품 50개 가져오기
    edges {                    # GraphQL 페이지네이션 구조
      node {                   # 실제 제품 데이터
        id                     # 제품 ID
        title                  # 제품명
        status                 # 상태 (ACTIVE, DRAFT, ARCHIVED)
        totalInventory         # 총 재고
        featuredImage {        # 대표 이미지
          url
        }
        variants(first: 1) {   # 첫 번째 변형 (가격 가져오기용)
          edges {
            node {
              price
            }
          }
        }
      }
    }
  }
}</code></pre>
<p><strong>필드 선택 이유</strong>:</p>
<ul>
<li><code>id</code>: 각 제품의 고유 식별자 (React key로 사용)</li>
<li><code>title</code>: 화면에 표시할 제품명</li>
<li><code>status</code>: 활성/비활성 상태 표시</li>
<li><code>totalInventory</code>: 재고 수량</li>
<li><code>featuredImage</code>: 썸네일 이미지</li>
<li><code>variants[0].price</code>: 기본 가격 (첫 번째 변형의 가격)</li>
</ul>
<h4 id="③-json-파싱">③ JSON 파싱</h4>
<pre><code class="language-typescript">const responseJson = await response.json();</code></pre>
<p>GraphQL 응답은 다음과 같은 구조입니다:</p>
<pre><code class="language-json">{
  &quot;data&quot;: {
    &quot;products&quot;: {
      &quot;edges&quot;: [
        {
          &quot;node&quot;: {
            &quot;id&quot;: &quot;gid://shopify/Product/123&quot;,
            &quot;title&quot;: &quot;Red Snowboard&quot;,
            &quot;status&quot;: &quot;ACTIVE&quot;,
            &quot;totalInventory&quot;: 50,
            &quot;featuredImage&quot;: {
              &quot;url&quot;: &quot;https://cdn.shopify.com/...&quot;
            },
            &quot;variants&quot;: {
              &quot;edges&quot;: [
                {
                  &quot;node&quot;: {
                    &quot;price&quot;: &quot;100.00&quot;
                  }
                }
              ]
            }
          }
        }
      ]
    }
  }
}</code></pre>
<h4 id="④-데이터-추출">④ 데이터 추출</h4>
<pre><code class="language-typescript">const products = responseJson.data?.products?.edges.map((edge: any) =&gt; edge.node) || [];</code></pre>
<p>GraphQL의 <code>edges</code> 구조를 평탄화:</p>
<pre><code class="language-javascript">// 변환 전
[
  { node: { id: &quot;123&quot;, title: &quot;Product A&quot; } },
  { node: { id: &quot;456&quot;, title: &quot;Product B&quot; } }
]

// 변환 후
[
  { id: &quot;123&quot;, title: &quot;Product A&quot; },
  { id: &quot;456&quot;, title: &quot;Product B&quot; }
]</code></pre>
<hr>
<h2 id="4-ui-컴포넌트-제품-목록-표시">4. UI 컴포넌트: 제품 목록 표시</h2>
<p>이제 가져온 데이터를 화면에 보여주는 컴포넌트를 만듭니다.</p>
<h3 id="전체-코드-1">전체 코드</h3>
<pre><code class="language-typescript">export default function ProductsPage() {
  // Loader에서 반환한 데이터 가져오기
  const { products } = useLoaderData&lt;LoaderData&gt;();

  return (
    &lt;s-page heading=&quot;제품 목록&quot;&gt;
      &lt;s-section&gt;
        {/* 제품이 없을 때 */}
        {products.length === 0 ? (
          &lt;s-box padding=&quot;large&quot;&gt;
            &lt;s-text&gt;제품이 없습니다. Shopify Admin에서 제품을 추가해보세요!&lt;/s-text&gt;
          &lt;/s-box&gt;
        ) : (
          /* 제품이 있을 때 */
          &lt;s-stack direction=&quot;block&quot; gap=&quot;base&quot;&gt;
            &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
              총 {products.length}개의 제품
            &lt;/s-text&gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
              {products.map((product) =&gt; (
                &lt;s-box
                  key={product.id}
                  padding=&quot;base&quot;
                  borderWidth=&quot;base&quot;
                  borderRadius=&quot;base&quot;
                  background=&quot;surface&quot;
                &gt;
                  &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot; align=&quot;center&quot;&gt;
                    {/* 제품 이미지 */}
                    {product.featuredImage &amp;&amp; (
                      &lt;img
                        src={product.featuredImage.url}
                        alt={product.title}
                        style={{
                          width: &quot;60px&quot;,
                          height: &quot;60px&quot;,
                          objectFit: &quot;cover&quot;,
                          borderRadius: &quot;8px&quot;,
                        }}
                      /&gt;
                    )}

                    {/* 제품 정보 */}
                    &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; style={{ flex: 1 }}&gt;
                      &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                        {product.title}
                      &lt;/s-text&gt;
                      &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          가격:{&quot; &quot;}
                          {product.variants.edges[0]?.node.price
                            ? `₩${Number(product.variants.edges[0].node.price).toLocaleString()}`
                            : &quot;가격 없음&quot;}
                        &lt;/s-text&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          재고: {product.totalInventory}개
                        &lt;/s-text&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          상태:{&quot; &quot;}
                          {product.status === &quot;ACTIVE&quot; ? &quot;활성&quot; : &quot;비활성&quot;}
                        &lt;/s-text&gt;
                      &lt;/s-stack&gt;
                    &lt;/s-stack&gt;
                  &lt;/s-stack&gt;
                &lt;/s-box&gt;
              ))}
            &lt;/s-stack&gt;
          &lt;/s-stack&gt;
        )}
      &lt;/s-section&gt;

      {/* 사이드바 안내 */}
      &lt;s-section slot=&quot;aside&quot; heading=&quot;안내&quot;&gt;
        &lt;s-paragraph&gt;
          이 페이지는 Shopify Admin GraphQL API를 사용하여 스토어의 제품 목록을 가져옵니다.
        &lt;/s-paragraph&gt;
        &lt;s-paragraph&gt;
          제품을 추가하려면 Shopify Admin → 제품 메뉴에서 추가하세요.
        &lt;/s-paragraph&gt;
      &lt;/s-section&gt;
    &lt;/s-page&gt;
  );
}</code></pre>
<h3 id="컴포넌트-분석">컴포넌트 분석</h3>
<h4 id="polaris-web-components">Polaris Web Components</h4>
<p>Shopify 스타일의 UI를 만들기 위해 <code>&lt;s-*&gt;</code> 태그를 사용합니다:</p>
<pre><code class="language-tsx">&lt;s-page heading=&quot;제품 목록&quot;&gt;        {/* 페이지 컨테이너 */}
  &lt;s-section&gt;                       {/* 섹션 */}
    &lt;s-stack direction=&quot;block&quot;&gt;     {/* 세로 스택 */}
      &lt;s-box padding=&quot;base&quot;&gt;        {/* 카드 박스 */}
        &lt;s-text variant=&quot;heading&quot;&gt;  {/* 텍스트 */}</code></pre>
<h4 id="조건부-렌더링">조건부 렌더링</h4>
<pre><code class="language-tsx">{products.length === 0 ? (
  &lt;s-text&gt;제품이 없습니다&lt;/s-text&gt;
) : (
  &lt;s-stack&gt;
    {/* 제품 목록 */}
  &lt;/s-stack&gt;
)}</code></pre>
<p>제품이 없을 때와 있을 때 다른 UI를 표시합니다.</p>
<h4 id="리스트-렌더링">리스트 렌더링</h4>
<pre><code class="language-tsx">{products.map((product) =&gt; (
  &lt;s-box key={product.id}&gt;
    {/* 제품 카드 */}
  &lt;/s-box&gt;
))}</code></pre>
<ul>
<li><code>map()</code>: 배열의 각 항목을 컴포넌트로 변환</li>
<li><code>key={product.id}</code>: React가 각 항목을 추적하기 위한 고유 키</li>
</ul>
<h4 id="가격-포맷팅">가격 포맷팅</h4>
<pre><code class="language-tsx">{`₩${Number(product.variants.edges[0].node.price).toLocaleString()}`}</code></pre>
<ul>
<li><code>Number()</code>: 문자열을 숫자로 변환</li>
<li><code>toLocaleString()</code>: 천 단위 구분 (100000 → 100,000)</li>
<li>결과: <code>₩100,000</code></li>
</ul>
<hr>
<h2 id="5-네비게이션-추가">5. 네비게이션 추가</h2>
<p>마지막으로 앱 메뉴에 &quot;제품 목록&quot; 링크를 추가합니다.</p>
<h3 id="approutesapptsx-수정"><code>app/routes/app.tsx</code> 수정</h3>
<pre><code class="language-tsx">export default function App() {
  const { apiKey } = useLoaderData&lt;typeof loader&gt;();

  return (
    &lt;AppProvider embedded apiKey={apiKey}&gt;
      &lt;s-app-nav&gt;
        &lt;s-link href=&quot;/app&quot;&gt;Home&lt;/s-link&gt;
        &lt;s-link href=&quot;/app/products&quot;&gt;제품 목록&lt;/s-link&gt;  {/* ← 추가 */}
        &lt;s-link href=&quot;/app/additional&quot;&gt;Additional page&lt;/s-link&gt;
      &lt;/s-app-nav&gt;
      &lt;Outlet /&gt;
    &lt;/AppProvider&gt;
  );
}</code></pre>
<p>이제 상단 네비게이션에 &quot;제품 목록&quot; 탭이 표시됩니다!</p>
<hr>
<h2 id="6-완성-및-테스트">6. 완성 및 테스트</h2>
<h3 id="전체-파일">전체 파일</h3>
<p><strong><code>app/routes/app.products.tsx</code></strong> (전체):</p>
<pre><code class="language-typescript">import type { LoaderFunctionArgs } from &quot;react-router&quot;;
import { useLoaderData } from &quot;react-router&quot;;
import { authenticate } from &quot;../shopify.server&quot;;

interface Product {
  id: string;
  title: string;
  status: string;
  totalInventory: number;
  featuredImage?: {
    url: string;
  };
  variants: {
    edges: Array&lt;{
      node: {
        price: string;
      };
    }&gt;;
  };
}

interface LoaderData {
  products: Product[];
}

export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  const { admin } = await authenticate.admin(request);

  const response = await admin.graphql(
    `#graphql
      query getProducts {
        products(first: 50) {
          edges {
            node {
              id
              title
              status
              totalInventory
              featuredImage {
                url
              }
              variants(first: 1) {
                edges {
                  node {
                    price
                  }
                }
              }
            }
          }
        }
      }
    `
  );

  const responseJson = await response.json();
  const products = responseJson.data?.products?.edges.map((edge: any) =&gt; edge.node) || [];

  return { products };
};

export default function ProductsPage() {
  const { products } = useLoaderData&lt;LoaderData&gt;();

  return (
    &lt;s-page heading=&quot;제품 목록&quot;&gt;
      &lt;s-section&gt;
        {products.length === 0 ? (
          &lt;s-box padding=&quot;large&quot;&gt;
            &lt;s-text&gt;제품이 없습니다. Shopify Admin에서 제품을 추가해보세요!&lt;/s-text&gt;
          &lt;/s-box&gt;
        ) : (
          &lt;s-stack direction=&quot;block&quot; gap=&quot;base&quot;&gt;
            &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
              총 {products.length}개의 제품
            &lt;/s-text&gt;
            &lt;s-stack direction=&quot;block&quot; gap=&quot;small&quot;&gt;
              {products.map((product) =&gt; (
                &lt;s-box
                  key={product.id}
                  padding=&quot;base&quot;
                  borderWidth=&quot;base&quot;
                  borderRadius=&quot;base&quot;
                  background=&quot;surface&quot;
                &gt;
                  &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot; align=&quot;center&quot;&gt;
                    {product.featuredImage &amp;&amp; (
                      &lt;img
                        src={product.featuredImage.url}
                        alt={product.title}
                        style={{
                          width: &quot;60px&quot;,
                          height: &quot;60px&quot;,
                          objectFit: &quot;cover&quot;,
                          borderRadius: &quot;8px&quot;,
                        }}
                      /&gt;
                    )}
                    &lt;s-stack direction=&quot;block&quot; gap=&quot;xs&quot; style={{ flex: 1 }}&gt;
                      &lt;s-text variant=&quot;heading-sm&quot; fontWeight=&quot;bold&quot;&gt;
                        {product.title}
                      &lt;/s-text&gt;
                      &lt;s-stack direction=&quot;inline&quot; gap=&quot;base&quot;&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          가격:{&quot; &quot;}
                          {product.variants.edges[0]?.node.price
                            ? `₩${Number(product.variants.edges[0].node.price).toLocaleString()}`
                            : &quot;가격 없음&quot;}
                        &lt;/s-text&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          재고: {product.totalInventory}개
                        &lt;/s-text&gt;
                        &lt;s-text variant=&quot;body-sm&quot;&gt;
                          상태:{&quot; &quot;}
                          {product.status === &quot;ACTIVE&quot; ? &quot;활성&quot; : &quot;비활성&quot;}
                        &lt;/s-text&gt;
                      &lt;/s-stack&gt;
                    &lt;/s-stack&gt;
                  &lt;/s-stack&gt;
                &lt;/s-box&gt;
              ))}
            &lt;/s-stack&gt;
          &lt;/s-stack&gt;
        )}
      &lt;/s-section&gt;

      &lt;s-section slot=&quot;aside&quot; heading=&quot;안내&quot;&gt;
        &lt;s-paragraph&gt;
          이 페이지는 Shopify Admin GraphQL API를 사용하여 스토어의 제품 목록을 가져옵니다.
        &lt;/s-paragraph&gt;
        &lt;s-paragraph&gt;
          제품을 추가하려면 Shopify Admin → 제품 메뉴에서 추가하세요.
        &lt;/s-paragraph&gt;
      &lt;/s-section&gt;
    &lt;/s-page&gt;
  );
}</code></pre>
<h3 id="테스트">테스트</h3>
<ol>
<li><p><strong>개발 서버 실행</strong>:</p>
<pre><code class="language-bash">npm run dev</code></pre>
</li>
<li><p><strong>Shopify Admin에서 앱 열기</strong>:</p>
<pre><code>https://your-store.myshopify.com/admin/apps/integrated-projection-app</code></pre></li>
<li><p><strong>&quot;제품 목록&quot; 탭 클릭</strong></p>
</li>
<li><p><strong>결과 확인</strong>:</p>
<ul>
<li>스토어의 모든 제품이 목록으로 표시됨</li>
<li>각 제품의 이미지, 이름, 가격, 재고, 상태 확인</li>
</ul>
</li>
</ol>
<hr>
<h2 id="7-확장-아이디어">7. 확장 아이디어</h2>
<p>이 기본 제품 목록 페이지를 다양하게 확장할 수 있습니다:</p>
<h3 id="검색-기능">검색 기능</h3>
<pre><code class="language-tsx">const [searchTerm, setSearchTerm] = useState(&quot;&quot;);
const filteredProducts = products.filter(p =&gt;
  p.title.toLowerCase().includes(searchTerm.toLowerCase())
);</code></pre>
<h3 id="정렬-기능">정렬 기능</h3>
<pre><code class="language-tsx">const sortedProducts = [...products].sort((a, b) =&gt; {
  if (sortBy === &quot;price&quot;) {
    return parseFloat(a.variants.edges[0]?.node.price || &quot;0&quot;) -
           parseFloat(b.variants.edges[0]?.node.price || &quot;0&quot;);
  }
  if (sortBy === &quot;inventory&quot;) {
    return a.totalInventory - b.totalInventory;
  }
  return 0;
});</code></pre>
<h3 id="페이지네이션">페이지네이션</h3>
<pre><code class="language-typescript">// 다음 50개 제품 가져오기
const response = await admin.graphql(`
  query getProducts($cursor: String) {
    products(first: 50, after: $cursor) {
      edges {
        node { ... }
        cursor
      }
      pageInfo {
        hasNextPage
      }
    }
  }
`, { variables: { cursor: lastCursor } });</code></pre>
<h3 id="제품-상세-페이지">제품 상세 페이지</h3>
<pre><code>app/routes/app.products.$id.tsx</code></pre><p>제품 ID를 동적 매개변수로 받아 상세 정보 표시</p>
<hr>
<h2 id="8-배운-핵심-개념">8. 배운 핵심 개념</h2>
<h3 id="react-router-loader">React Router Loader</h3>
<pre><code class="language-typescript">export const loader = async ({ request }) =&gt; {
  // 서버에서 데이터 가져오기
  const data = await fetchData();
  return { data };
};

export default function Component() {
  const { data } = useLoaderData();
  // 데이터 사용
}</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>페이지 로드 전에 데이터 준비</li>
<li>로딩 상태 자동 관리</li>
<li>SEO 친화적 (서버 사이드 렌더링)</li>
</ul>
<h3 id="graphql-쿼리-최적화">GraphQL 쿼리 최적화</h3>
<p>필요한 필드만 정확히 요청:</p>
<pre><code class="language-graphql"># ✅ 좋은 예: 필요한 것만
query {
  products(first: 50) {
    edges {
      node {
        id
        title
        totalInventory
      }
    }
  }
}

# ❌ 나쁜 예: 모든 필드
query {
  products(first: 50) {
    edges {
      node {
        # 수십 개 필드 모두 요청
      }
    }
  }
}</code></pre>
<h3 id="polaris-web-components-1">Polaris Web Components</h3>
<p>Shopify 스타일 UI를 쉽게 구현:</p>
<pre><code class="language-tsx">&lt;s-page&gt;      {/* 페이지 레이아웃 */}
&lt;s-section&gt;   {/* 섹션 구분 */}
&lt;s-stack&gt;     {/* flexbox 레이아웃 */}
&lt;s-box&gt;       {/* 카드 컨테이너 */}
&lt;s-text&gt;      {/* 타이포그래피 */}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>축하합니다! 첫 번째 실전 Shopify 앱 기능을 완성했습니다! 🎉</p>
<p>이 제품 목록 페이지를 만들면서:</p>
<p>✅ Shopify GraphQL API로 실제 데이터를 조회했습니다
✅ React Router Loader로 서버 사이드 데이터 페칭을 구현했습니다
✅ Polaris Web Components로 Shopify 스타일 UI를 만들었습니다
✅ TypeScript로 타입 안전성을 확보했습니다</p>
<p>다음 단계로는:</p>
<ul>
<li>제품 상세 페이지 만들기</li>
<li>제품 생성/수정 기능 추가</li>
<li>스토어 대시보드 구현</li>
<li>주문 관리 기능 개발</li>
</ul>
<p>실제로 움직이는 앱을 만들면서 배우는 것이 가장 빠른 학습 방법입니다!</p>
<hr>
<p><strong>다음 글 예고</strong>: Shopify API로 스토어 대시보드 만들기</p>
<p><strong>GitHub 저장소</strong>: [링크 추가 예정]</p>
<p><strong>질문이나 피드백은 댓글로 남겨주세요!</strong> 💬</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Shopify Admin API 완벽 가이드: GraphQL로 스토어 데이터 다루기]]></title>
            <link>https://velog.io/@ej-rarus/Shopify-Admin-API-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-GraphQL%EB%A1%9C-%EC%8A%A4%ED%86%A0%EC%96%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/Shopify-Admin-API-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-GraphQL%EB%A1%9C-%EC%8A%A4%ED%86%A0%EC%96%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%8B%A4%EB%A3%A8%EA%B8%B0</guid>
            <pubDate>Mon, 08 Dec 2025 03:14:36 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Shopify 커스텀 앱에서 API를 사용해 스토어 데이터를 조회하고 수정하는 방법을 상세히 알아봅니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>Shopify 커스텀 앱을 만들면서 가장 중요한 것은 <strong>Shopify Admin API</strong>를 이해하는 것입니다. API를 통해 제품, 주문, 고객 등 스토어의 모든 데이터에 접근할 수 있습니다.</p>
<p>이번 글에서는 Shopify Admin GraphQL API의 동작 원리부터 실제 사용법까지 모두 다루겠습니다.</p>
<hr>
<h2 id="1-shopify-admin-api란">1. Shopify Admin API란?</h2>
<h3 id="api의-역할">API의 역할</h3>
<p>Shopify Admin API는 <strong>스토어의 데이터베이스에 접근</strong>할 수 있게 해주는 인터페이스입니다.</p>
<pre><code>커스텀 앱 (React)
    ↓
API 요청 (GraphQL)
    ↓
Shopify 서버
    ↓
스토어 데이터베이스
    ↓
응답 (JSON)
    ↓
커스텀 앱에서 표시</code></pre><h3 id="graphql-vs-rest">GraphQL vs REST</h3>
<p>Shopify는 두 가지 API를 제공하지만, <strong>GraphQL을 권장</strong>합니다:</p>
<table>
<thead>
<tr>
<th>특징</th>
<th>REST API</th>
<th>GraphQL API</th>
</tr>
</thead>
<tbody><tr>
<td>요청 횟수</td>
<td>여러 번 필요</td>
<td>한 번으로 해결</td>
</tr>
<tr>
<td>데이터</td>
<td>전체 반환 (오버페칭)</td>
<td>필요한 것만 요청</td>
</tr>
<tr>
<td>타입 안정성</td>
<td>약함</td>
<td>강함</td>
</tr>
<tr>
<td>최신 기능</td>
<td>제한적</td>
<td>우선 지원</td>
</tr>
</tbody></table>
<p><strong>예시 비교</strong>:</p>
<pre><code class="language-javascript">// REST API - 3번 요청 필요
const product = await fetch(&#39;/admin/api/2025-01/products/123.json&#39;);
const images = await fetch(&#39;/admin/api/2025-01/products/123/images.json&#39;);
const variants = await fetch(&#39;/admin/api/2025-01/products/123/variants.json&#39;);

// GraphQL - 1번 요청으로 모두 가져오기
const data = await admin.graphql(`
  query {
    product(id: &quot;gid://shopify/Product/123&quot;) {
      title
      images(first: 10) { edges { node { url } } }
      variants(first: 10) { edges { node { price } } }
    }
  }
`);</code></pre>
<hr>
<h2 id="2-api-통신-방식-이해하기">2. API 통신 방식 이해하기</h2>
<h3 id="기본-구조">기본 구조</h3>
<p>커스텀 앱에서 Shopify API를 호출하는 전체 플로우:</p>
<pre><code class="language-typescript">// app/routes/app.products.tsx
export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  // ① 인증된 admin 객체 가져오기
  const { admin } = await authenticate.admin(request);

  // ② GraphQL 쿼리 실행
  const response = await admin.graphql(`
    query getProducts {
      products(first: 50) {
        edges {
          node {
            id
            title
            totalInventory
          }
        }
      }
    }
  `);

  // ③ 응답 파싱
  const responseJson = await response.json();

  // ④ 데이터 추출 및 반환
  const products = responseJson.data?.products?.edges.map(e =&gt; e.node);
  return { products };
};</code></pre>
<h3 id="실제-http-요청-내부">실제 HTTP 요청 내부</h3>
<p><code>admin.graphql()</code> 호출 시 내부적으로 일어나는 일:</p>
<pre><code>1. HTTP POST 요청 전송
   ↓
   URL: https://your-store.myshopify.com/admin/api/2025-10/graphql.json

   Headers:
     - X-Shopify-Access-Token: [accessToken from DB]
     - Content-Type: application/json

   Body:
     {
       &quot;query&quot;: &quot;query getProducts { products(first: 50) { ... } }&quot;
     }

2. Shopify 서버 처리
   ↓
   - 토큰 검증
   - 권한(scopes) 확인
   - 데이터베이스 쿼리 실행

3. JSON 응답 반환
   ↓
   {
     &quot;data&quot;: {
       &quot;products&quot;: {
         &quot;edges&quot;: [
           {
             &quot;node&quot;: {
               &quot;id&quot;: &quot;gid://shopify/Product/123&quot;,
               &quot;title&quot;: &quot;멋진 티셔츠&quot;,
               &quot;totalInventory&quot;: 100
             }
           }
         ]
       }
     }
   }</code></pre><hr>
<h2 id="3-graphql-쿼리-구조-파헤치기">3. GraphQL 쿼리 구조 파헤치기</h2>
<h3 id="기본-문법">기본 문법</h3>
<pre><code class="language-graphql">query 쿼리이름 {           # 쿼리 이름 (선택사항)
  리소스(인자들) {         # products, orders 등
    필드1                 # id, title 등
    필드2
    관계필드 {            # 중첩된 데이터
      하위필드
    }
  }
}</code></pre>
<h3 id="실전-예제-제품-조회">실전 예제: 제품 조회</h3>
<pre><code class="language-graphql">query getProducts {
  products(first: 50) {          # 제품 50개 가져와
    edges {                      # GraphQL 페이지네이션 구조
      node {                     # 실제 제품 데이터
        id                       # 제품 ID
        title                    # 제품명
        description              # 설명
        status                   # ACTIVE, DRAFT, ARCHIVED
        totalInventory           # 총 재고

        # 중첩: 이미지 정보
        images(first: 5) {
          edges {
            node {
              url
              altText
            }
          }
        }

        # 중첩: 변형 정보 (사이즈, 색상 등)
        variants(first: 10) {
          edges {
            node {
              id
              title
              price
              sku
              inventoryQuantity
            }
          }
        }
      }
    }
  }
}</code></pre>
<h3 id="응답-구조">응답 구조</h3>
<pre><code class="language-json">{
  &quot;data&quot;: {
    &quot;products&quot;: {
      &quot;edges&quot;: [
        {
          &quot;node&quot;: {
            &quot;id&quot;: &quot;gid://shopify/Product/8453618196773&quot;,
            &quot;title&quot;: &quot;Red Snowboard&quot;,
            &quot;description&quot;: &quot;A cool red snowboard&quot;,
            &quot;status&quot;: &quot;ACTIVE&quot;,
            &quot;totalInventory&quot;: 100,
            &quot;images&quot;: {
              &quot;edges&quot;: [
                {
                  &quot;node&quot;: {
                    &quot;url&quot;: &quot;https://cdn.shopify.com/...&quot;,
                    &quot;altText&quot;: &quot;Red snowboard front view&quot;
                  }
                }
              ]
            },
            &quot;variants&quot;: {
              &quot;edges&quot;: [
                {
                  &quot;node&quot;: {
                    &quot;id&quot;: &quot;gid://shopify/ProductVariant/123&quot;,
                    &quot;title&quot;: &quot;Medium&quot;,
                    &quot;price&quot;: &quot;299.99&quot;,
                    &quot;sku&quot;: &quot;RED-SNO-M&quot;,
                    &quot;inventoryQuantity&quot;: 50
                  }
                }
              ]
            }
          }
        }
      ]
    }
  }
}</code></pre>
<h3 id="왜-edges와-node를-사용하나">왜 <code>edges</code>와 <code>node</code>를 사용하나?</h3>
<p><strong>GraphQL Relay 스타일</strong> 페이지네이션 때문입니다:</p>
<pre><code class="language-graphql">products {
  edges {           # 배열: 각 항목을 감싸는 래퍼
    node {          # 실제 데이터
      id
      title
    }
    cursor         # 페이지네이션용 커서
  }
  pageInfo {       # 페이지 정보
    hasNextPage
    hasPreviousPage
  }
}</code></pre>
<p>이 구조 덕분에 &quot;다음 50개&quot; 가져오기가 쉽습니다:</p>
<pre><code class="language-graphql">query {
  products(first: 50, after: &quot;커서값&quot;) {
    edges {
      node { ... }
      cursor
    }
  }
}</code></pre>
<hr>
<h2 id="4-조회-가능한-주요-데이터">4. 조회 가능한 주요 데이터</h2>
<h3 id="products-제품">Products (제품)</h3>
<pre><code class="language-graphql">query {
  products(first: 100) {
    edges {
      node {
        id
        title
        description
        descriptionHtml
        handle                # URL 슬러그
        status               # ACTIVE, DRAFT, ARCHIVED
        vendor               # 공급업체
        productType          # 제품 타입
        tags                 # 태그 배열
        createdAt
        updatedAt
        totalInventory       # 총 재고

        priceRangeV2 {       # 가격 범위
          minVariantPrice {
            amount
            currencyCode
          }
          maxVariantPrice {
            amount
            currencyCode
          }
        }

        seo {                # SEO 정보
          title
          description
        }
      }
    }
  }
}</code></pre>
<h3 id="orders-주문">Orders (주문)</h3>
<pre><code class="language-graphql">query {
  orders(first: 100) {
    edges {
      node {
        id
        name                         # 주문 번호 (#1001)
        email
        phone
        createdAt

        # 상태
        displayFulfillmentStatus     # FULFILLED, UNFULFILLED
        displayFinancialStatus       # PAID, PENDING, REFUNDED

        # 금액
        currentTotalPriceSet {
          shopMoney {
            amount
            currencyCode
          }
        }
        currentSubtotalPriceSet {
          shopMoney { amount }
        }
        totalShippingPriceSet {
          shopMoney { amount }
        }

        # 주문 아이템
        lineItems(first: 100) {
          edges {
            node {
              title
              quantity
              variant {
                id
                title
                price
                sku
              }
              originalUnitPriceSet {
                shopMoney { amount }
              }
            }
          }
        }

        # 배송지
        shippingAddress {
          firstName
          lastName
          address1
          address2
          city
          province
          country
          zip
        }

        # 배송 추적
        fulfillments {
          id
          status
          trackingNumber
          trackingUrl
        }
      }
    }
  }
}</code></pre>
<h3 id="shop-스토어-정보">Shop (스토어 정보)</h3>
<pre><code class="language-graphql">query {
  shop {
    id
    name                    # 스토어명
    email
    myshopifyDomain        # your-store.myshopify.com
    primaryDomain {
      url                  # 커스텀 도메인
      host
    }
    currencyCode           # KRW, USD 등
    timezoneAbbreviation   # KST

    billingAddress {
      address1
      city
      country
      zip
    }

    plan {
      displayName          # Basic, Shopify, Advanced
    }
  }
}</code></pre>
<h3 id="inventory-재고">Inventory (재고)</h3>
<pre><code class="language-graphql">query {
  inventoryItems(first: 50) {
    edges {
      node {
        id
        sku
        tracked              # 재고 추적 여부

        inventoryLevels(first: 10) {
          edges {
            node {
              available        # 사용 가능 재고
              incoming         # 입고 예정
              location {
                id
                name           # 창고명
                address {
                  city
                  country
                }
              }
            }
          }
        }
      }
    }
  }
}</code></pre>
<h3 id="collections-컬렉션">Collections (컬렉션)</h3>
<pre><code class="language-graphql">query {
  collections(first: 50) {
    edges {
      node {
        id
        title
        description
        handle
        productsCount

        products(first: 100) {
          edges {
            node {
              id
              title
            }
          }
        }

        image {
          url
        }
      }
    }
  }
}</code></pre>
<h3 id="discounts-할인">Discounts (할인)</h3>
<pre><code class="language-graphql">query {
  discountNodes(first: 50) {
    edges {
      node {
        id
        discount {
          ... on DiscountCodeBasic {
            title
            codes(first: 10) {
              edges {
                node {
                  code           # 할인 코드
                }
              }
            }

            customerGets {
              value {
                ... on DiscountPercentage {
                  percentage
                }
                ... on DiscountAmount {
                  amount { amount }
                }
              }
            }

            startsAt
            endsAt
            usageLimit
          }
        }
      }
    }
  }
}</code></pre>
<hr>
<h2 id="5-mutation-데이터-생성-및-수정">5. Mutation: 데이터 생성 및 수정</h2>
<p>조회만 하는 게 아니라 <strong>생성, 수정, 삭제</strong>도 가능합니다!</p>
<h3 id="제품-생성">제품 생성</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation createProduct($input: ProductCreateInput!) {
    productCreate(input: $input) {
      product {
        id
        title
        status
      }
      userErrors {
        field
        message
      }
    }
  }
`, {
  variables: {
    input: {
      title: &quot;새로운 티셔츠&quot;,
      descriptionHtml: &quot;&lt;p&gt;멋진 티셔츠입니다&lt;/p&gt;&quot;,
      vendor: &quot;내 브랜드&quot;,
      productType: &quot;의류&quot;,
      tags: [&quot;신상&quot;, &quot;베스트&quot;],
      status: &quot;ACTIVE&quot;
    }
  }
});</code></pre>
<h3 id="제품-수정">제품 수정</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation updateProduct($input: ProductInput!) {
    productUpdate(input: $input) {
      product {
        id
        title
      }
      userErrors {
        field
        message
      }
    }
  }
`, {
  variables: {
    input: {
      id: &quot;gid://shopify/Product/123&quot;,
      title: &quot;수정된 제품명&quot;,
      tags: [&quot;할인중&quot;]
    }
  }
});</code></pre>
<h3 id="제품-삭제">제품 삭제</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation deleteProduct($input: ProductDeleteInput!) {
    productDelete(input: $input) {
      deletedProductId
      userErrors {
        field
        message
      }
    }
  }
`, {
  variables: {
    input: {
      id: &quot;gid://shopify/Product/123&quot;
    }
  }
});</code></pre>
<h3 id="가격-일괄-변경">가격 일괄 변경</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation bulkUpdateVariants($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
    productVariantsBulkUpdate(productId: $productId, variants: $variants) {
      productVariants {
        id
        price
      }
      userErrors {
        field
        message
      }
    }
  }
`, {
  variables: {
    productId: &quot;gid://shopify/Product/123&quot;,
    variants: [
      { id: &quot;gid://shopify/ProductVariant/456&quot;, price: &quot;29.99&quot; },
      { id: &quot;gid://shopify/ProductVariant/789&quot;, price: &quot;39.99&quot; }
    ]
  }
});</code></pre>
<h3 id="주문-상태-업데이트">주문 상태 업데이트</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation fulfillOrder($input: FulfillmentCreateV2Input!) {
    fulfillmentCreateV2(fulfillment: $input) {
      fulfillment {
        id
        status
        trackingInfo {
          number
          url
        }
      }
      userErrors {
        field
        message
      }
    }
  }
`, {
  variables: {
    input: {
      lineItemsByFulfillmentOrder: [{
        fulfillmentOrderId: &quot;gid://shopify/FulfillmentOrder/123&quot;
      }],
      trackingInfo: {
        number: &quot;1234567890&quot;,
        url: &quot;https://tracking.example.com/1234567890&quot;
      }
    }
  }
});</code></pre>
<hr>
<h2 id="6-권한scopes-관리">6. 권한(Scopes) 관리</h2>
<h3 id="권한-설정">권한 설정</h3>
<p>API를 사용하려면 <strong>적절한 권한</strong>이 필요합니다.</p>
<p><strong><code>shopify.app.toml</code></strong>:</p>
<pre><code class="language-toml">[access_scopes]
scopes = &quot;write_products,read_orders,read_customers&quot;</code></pre>
<h3 id="주요-권한-종류">주요 권한 종류</h3>
<table>
<thead>
<tr>
<th>권한</th>
<th>설명</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>read_products</code></td>
<td>제품 조회</td>
<td>제품 목록 표시</td>
</tr>
<tr>
<td><code>write_products</code></td>
<td>제품 생성/수정/삭제</td>
<td>제품 관리</td>
</tr>
<tr>
<td><code>read_orders</code></td>
<td>주문 조회</td>
<td>주문 내역 보기</td>
</tr>
<tr>
<td><code>write_orders</code></td>
<td>주문 수정</td>
<td>주문 상태 변경</td>
</tr>
<tr>
<td><code>read_customers</code></td>
<td>고객 조회</td>
<td>고객 목록 (승인 필요)</td>
</tr>
<tr>
<td><code>write_customers</code></td>
<td>고객 생성/수정</td>
<td>고객 정보 관리 (승인 필요)</td>
</tr>
<tr>
<td><code>read_inventory</code></td>
<td>재고 조회</td>
<td>재고 현황</td>
</tr>
<tr>
<td><code>write_inventory</code></td>
<td>재고 수정</td>
<td>재고 조정</td>
</tr>
<tr>
<td><code>read_discounts</code></td>
<td>할인 조회</td>
<td>할인 코드 보기</td>
</tr>
<tr>
<td><code>write_discounts</code></td>
<td>할인 생성/수정</td>
<td>프로모션 관리</td>
</tr>
</tbody></table>
<h3 id="권한-변경-시-주의사항">권한 변경 시 주의사항</h3>
<pre><code class="language-typescript">// 1. shopify.app.toml에서 권한 추가
scopes = &quot;write_products,read_orders&quot;  // 기존
        ↓
scopes = &quot;write_products,read_orders,read_inventory&quot;  // 추가

// 2. 개발 서버 재시작
npm run dev

// 3. 개발 모드: 자동 승인
// 4. 프로덕션: 사용자가 재승인 필요!</code></pre>
<hr>
<h2 id="7-protected-customer-data-보호된-고객-데이터">7. Protected Customer Data (보호된 고객 데이터)</h2>
<h3 id="왜-고객-데이터는-특별한가">왜 고객 데이터는 특별한가?</h3>
<p>Shopify는 <strong>고객 개인정보 보호</strong>를 위해 고객 데이터 접근을 제한합니다.</p>
<pre><code class="language-typescript">// ❌ 개발 중에는 에러 발생
const response = await admin.graphql(`
  query {
    customers(first: 10) {
      edges {
        node {
          id
          email
          firstName
        }
      }
    }
  }
`);

// Error: This app is not approved to access the Customer object.</code></pre>
<h3 id="고객-데이터-접근하려면">고객 데이터 접근하려면?</h3>
<ol>
<li><strong>개인정보 보호 정책</strong> 작성 및 제출</li>
<li><strong>앱 심사</strong> 신청</li>
<li>Shopify의 <strong>승인 대기</strong></li>
<li>승인 후 사용 가능</li>
</ol>
<p>자세한 내용: <a href="https://shopify.dev/docs/apps/launch/protected-customer-data">https://shopify.dev/docs/apps/launch/protected-customer-data</a></p>
<h3 id="대안-주문의-고객-정보">대안: 주문의 고객 정보</h3>
<p>주문 데이터에는 일부 고객 정보가 포함되어 있습니다 (제한적):</p>
<pre><code class="language-graphql">query {
  orders(first: 10) {
    edges {
      node {
        email              # 주문 이메일 (허용)
        shippingAddress {  # 배송지 (허용)
          firstName
          lastName
          address1
        }
        # ✅ customer 객체는 접근 불가
      }
    }
  }
}</code></pre>
<hr>
<h2 id="8-실전-팁">8. 실전 팁</h2>
<h3 id="1-graphiql로-쿼리-테스트">1. GraphiQL로 쿼리 테스트</h3>
<p>개발 서버 실행 중 <code>http://localhost:3457</code> 접속:</p>
<pre><code class="language-bash">npm run dev
# → GraphiQL server started on port 3457</code></pre>
<p>브라우저에서 직접 쿼리를 작성하고 테스트할 수 있습니다!</p>
<h3 id="2-에러-처리">2. 에러 처리</h3>
<pre><code class="language-typescript">const response = await admin.graphql(`
  mutation createProduct($input: ProductInput!) {
    productCreate(input: $input) {
      product { id }
      userErrors {        // ✅ 항상 userErrors 체크!
        field
        message
      }
    }
  }
`, { variables: { input: {...} } });

const data = await response.json();

if (data.data?.productCreate?.userErrors?.length &gt; 0) {
  console.error(&quot;에러:&quot;, data.data.productCreate.userErrors);
  // 사용자에게 에러 표시
}</code></pre>
<h3 id="3-페이지네이션">3. 페이지네이션</h3>
<p>대량의 데이터는 페이지네이션으로:</p>
<pre><code class="language-typescript">let cursor = null;
let allProducts = [];

while (true) {
  const response = await admin.graphql(`
    query getProducts($cursor: String) {
      products(first: 250, after: $cursor) {
        edges {
          node { id title }
          cursor
        }
        pageInfo {
          hasNextPage
        }
      }
    }
  `, { variables: { cursor } });

  const data = await response.json();
  const edges = data.data.products.edges;

  allProducts.push(...edges.map(e =&gt; e.node));

  if (!data.data.products.pageInfo.hasNextPage) break;
  cursor = edges[edges.length - 1].cursor;
}

console.log(`총 ${allProducts.length}개 제품`);</code></pre>
<h3 id="4-성능-최적화">4. 성능 최적화</h3>
<p>여러 데이터를 한 번에:</p>
<pre><code class="language-typescript">// ❌ 나쁜 예: 3번 요청
const products = await admin.graphql(`query { products {...} }`);
const orders = await admin.graphql(`query { orders {...} }`);
const shop = await admin.graphql(`query { shop {...} }`);

// ✅ 좋은 예: 1번 요청
const response = await admin.graphql(`
  query getDashboardData {
    products(first: 50) { ... }
    orders(first: 50) { ... }
    shop { ... }
  }
`);</code></pre>
<hr>
<h2 id="9-주요-리소스">9. 주요 리소스</h2>
<h3 id="공식-문서">공식 문서</h3>
<ul>
<li><a href="https://shopify.dev/docs/api/admin-graphql">Shopify Admin GraphQL API Reference</a></li>
<li><a href="https://graphql.org/learn/">GraphQL 기초</a></li>
<li><a href="https://shopify.dev/docs/apps">Shopify App 개발 가이드</a></li>
</ul>
<h3 id="개발-도구">개발 도구</h3>
<ul>
<li><strong>GraphiQL</strong>: 브라우저에서 쿼리 테스트</li>
<li><strong>Shopify CLI</strong>: 앱 개발 및 배포</li>
<li><strong>GraphQL Codegen</strong>: TypeScript 타입 자동 생성</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>Shopify Admin API는 매우 강력합니다. GraphQL 덕분에:</p>
<p>✅ 필요한 데이터만 정확히 가져올 수 있습니다
✅ 한 번의 요청으로 복잡한 데이터 조회 가능
✅ 타입 안정성으로 버그 감소
✅ 실시간으로 스토어 데이터 관리</p>
<p>이제 API의 동작 원리를 이해했으니, 다음 단계는 실제로 유용한 기능을 만드는 것입니다!</p>
<p>다음 포스트에서는 이 API를 활용해 실전 기능(대시보드, 제품 관리 등)을 구현하는 방법을 다루겠습니다.</p>
<hr>
<p><strong>질문이나 피드백은 댓글로 남겨주세요!</strong> 💬</p>
<p><strong>다음 글 예고</strong>: Shopify API로 실전 대시보드 만들기</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Shopify 커스텀 앱 개발 입문: 아키텍처부터 세계시계 앱 만들기까지]]></title>
            <link>https://velog.io/@ej-rarus/Shopify-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9E%85%EB%AC%B8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%B6%80%ED%84%B0-%EC%84%B8%EA%B3%84%EC%8B%9C%EA%B3%84-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@ej-rarus/Shopify-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EC%9E%85%EB%AC%B8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%B6%80%ED%84%B0-%EC%84%B8%EA%B3%84%EC%8B%9C%EA%B3%84-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 08 Dec 2025 02:25:15 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ej-rarus/post/7fafc1b7-1696-49e4-935a-c48f673071be/image.png" alt=""></p>
<blockquote>
<p>Shopify 커스텀 앱의 구조를 이해하고, React Router 기반으로 세계시계 앱을 개발한 과정을 정리합니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>Shopify 스토어에 필요한 기능을 직접 만들어 추가할 수 있는 커스텀 앱 개발에 도전했습니다. 처음에는 앱이 어떻게 동작하는지 전혀 몰랐지만, 하나씩 파헤치며 배운 내용을 공유합니다.</p>
<hr>
<h2 id="1-shopify-커스텀-앱이란">1. Shopify 커스텀 앱이란?</h2>
<p>Shopify 커스텀 앱은 <strong>Shopify Admin 내부에 임베디드되어 실행되는 웹 애플리케이션</strong>입니다. 상점 관리자가 Shopify Admin에서 앱을 클릭하면, iframe 안에 우리가 만든 앱이 표시됩니다.</p>
<h3 id="전체-아키텍처">전체 아키텍처</h3>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│              Shopify Admin (브라우저)                         │
│          https://your-store.myshopify.com/admin             │
└────────────────────┬────────────────────────────────────────┘
                     │
                     │ (iframe으로 embedded)
                     ▼
┌─────────────────────────────────────────────────────────────┐
│               Your Custom App (React Router)                │
│                                                             │
│  ┌──────────────┐      ┌──────────────┐                     │
│  │ React 컴포넌트 │ ◄──► │ Shopify API  │                     │
│  └──────────────┘      └──────────────┘                     │
│         │                     │                             │
│         ▼                     ▼                             │
│  ┌──────────────────────────────────┐                       │
│  │    Prisma (Session Storage)      │                       │
│  │         SQLite Database           │                      │
│  └──────────────────────────────────┘                       │
└─────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="2-프로젝트-구조-이해하기">2. 프로젝트 구조 이해하기</h2>
<h3 id="디렉토리-구조">디렉토리 구조</h3>
<pre><code>integrated-projection-app/
├── app/
│   ├── routes/              # 페이지들 (라우팅)
│   │   ├── app.tsx          # 앱 레이아웃 (네비게이션 포함)
│   │   ├── app._index.tsx   # 메인 페이지 (Home)
│   │   ├── app.clock.tsx    # 시계 페이지 (우리가 만들 것!)
│   │   ├── auth.$.tsx       # OAuth 콜백
│   │   └── webhooks.*.tsx   # 웹훅 핸들러
│   ├── shopify.server.ts    # Shopify 설정 (핵심!)
│   ├── db.server.ts         # 데이터베이스 연결
│   └── root.tsx             # HTML 루트
├── prisma/
│   └── schema.prisma        # 데이터베이스 스키마
├── shopify.app.toml         # Shopify 앱 설정
└── package.json</code></pre><h3 id="핵심-파일-역할">핵심 파일 역할</h3>
<h4 id="shopifyserverts---앱의-심장"><code>shopify.server.ts</code> - 앱의 심장</h4>
<pre><code class="language-typescript">const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,        // 앱 식별자
  apiSecretKey: process.env.SHOPIFY_API_SECRET, // 비밀키
  apiVersion: ApiVersion.October25,            // API 버전
  scopes: process.env.SCOPES?.split(&quot;,&quot;),      // 권한
  sessionStorage: new PrismaSessionStorage(prisma), // 세션 저장
});</code></pre>
<p>이 파일이 하는 일:</p>
<ul>
<li>OAuth 인증 처리</li>
<li>Session 관리</li>
<li>Shopify Admin API 클라이언트 제공</li>
<li>웹훅 등록</li>
</ul>
<h4 id="prismaschemaprisma---세션-저장소"><code>prisma/schema.prisma</code> - 세션 저장소</h4>
<pre><code class="language-prisma">model Session {
  id          String    @id
  shop        String      # tony-test-store-989.myshopify.com
  accessToken String      # API 호출용 토큰
  scope       String?     # write_products
  expires     DateTime?
  userId      BigInt?
  // ...
}</code></pre>
<p>각 상점의 인증 정보를 저장합니다. 이 <code>accessToken</code>으로 Shopify API를 호출할 수 있습니다.</p>
<hr>
<h2 id="3-앱-동작-원리-플로우">3. 앱 동작 원리 (플로우)</h2>
<h3 id="step-1-인증-oauth">Step 1: 인증 (OAuth)</h3>
<pre><code>1. 사용자가 앱 클릭
   ↓
2. /auth/login → Shopify OAuth 페이지로 리다이렉트
   ↓
3. 사용자 권한 승인
   ↓
4. /auth/callback → 토큰 받아서 Prisma DB에 저장
   ↓
5. /app으로 리다이렉트 (앱 메인 페이지)</code></pre><h3 id="step-2-인증된-요청-처리">Step 2: 인증된 요청 처리</h3>
<p>모든 <code>/app/*</code> 라우트는 자동으로 인증을 체크합니다:</p>
<pre><code class="language-typescript">// app/routes/app.tsx
export const loader = async ({ request }: LoaderFunctionArgs) =&gt; {
  await authenticate.admin(request);  // ← 인증 체크!
  return { apiKey: process.env.SHOPIFY_API_KEY || &quot;&quot; };
};</code></pre>
<h3 id="step-3-shopify-api-호출">Step 3: Shopify API 호출</h3>
<pre><code class="language-typescript">export const action = async ({ request }: ActionFunctionArgs) =&gt; {
  const { admin } = await authenticate.admin(request);

  // GraphQL로 Shopify API 호출
  const response = await admin.graphql(`
    mutation populateProduct($product: ProductCreateInput!) {
      productCreate(product: $product) {
        product { id title }
      }
    }`,
    { variables: { product: { title: &quot;Red Snowboard&quot; } } }
  );

  return await response.json();
};</code></pre>
<p><strong>데이터 흐름</strong>:</p>
<pre><code>사용자 클릭 → React 컴포넌트 → Loader/Action 함수 →
Shopify API 호출 → 결과 반환 → UI 업데이트</code></pre><hr>
<h2 id="4-개발-환경-이해하기">4. 개발 환경 이해하기</h2>
<h3 id="개발-모드-vs-프로덕션">개발 모드 vs 프로덕션</h3>
<h4 id="개발-모드-현재">개발 모드 (현재)</h4>
<pre><code class="language-bash">npm run dev</code></pre>
<ul>
<li><strong>로컬</strong> 컴퓨터에서 실행 (<code>localhost:55064</code>)</li>
<li><strong>Cloudflare 터널</strong>로 임시 공개 URL 생성</li>
<li>Shopify가 터널을 통해 로컬 앱에 접속</li>
<li>무료, 빠른 테스트 가능</li>
</ul>
<h4 id="프로덕션-모드">프로덕션 모드</h4>
<pre><code class="language-bash">npm run deploy</code></pre>
<ul>
<li><strong>실제 서버</strong>에 배포 (Google Cloud Run, Fly.io 등)</li>
<li><strong>고정 도메인</strong> 사용</li>
<li>Cloudflare와 무관, 안정적 운영</li>
</ul>
<h3 id="왜-cloudflare-터널을-사용하나">왜 Cloudflare 터널을 사용하나?</h3>
<p>Shopify는 <strong>공개 URL</strong>이 필요합니다:</p>
<ul>
<li>OAuth 콜백을 받아야 함</li>
<li>웹훅을 받아야 함</li>
<li>iframe에 앱을 표시해야 함</li>
</ul>
<p>로컬 개발 중에는 <code>localhost</code>가 외부에서 접근 불가하므로, Cloudflare 터널이 <code>localhost</code>를 인터넷에 노출시켜줍니다.</p>
<hr>
<h2 id="5-실습-세계시계-앱-만들기">5. 실습: 세계시계 앱 만들기</h2>
<p>이제 배운 내용을 바탕으로 실제 기능을 만들어봅시다!</p>
<h3 id="5-1-새-페이지-생성하기">5-1. 새 페이지 생성하기</h3>
<h4 id="파일-생성-규칙">파일 생성 규칙</h4>
<p>React Router는 <strong>파일명 = URL 구조</strong> 방식입니다:</p>
<pre><code>app/routes/
├── app.tsx              → /app (레이아웃)
├── app._index.tsx       → /app (메인)
├── app.clock.tsx        → /app/clock ✅ 우리가 만들 파일!</code></pre><h4 id="approutesappclocktsx-생성"><code>app/routes/app.clock.tsx</code> 생성</h4>
<pre><code class="language-tsx">import { useEffect, useState } from &quot;react&quot;;

interface City {
  name: string;
  timezone: string;
  flag: string;
}

const cities: City[] = [
  { name: &quot;서울&quot;, timezone: &quot;Asia/Seoul&quot;, flag: &quot;🇰🇷&quot; },
  { name: &quot;런던&quot;, timezone: &quot;Europe/London&quot;, flag: &quot;🇬🇧&quot; },
  { name: &quot;상하이&quot;, timezone: &quot;Asia/Shanghai&quot;, flag: &quot;🇨🇳&quot; },
  { name: &quot;뉴욕&quot;, timezone: &quot;America/New_York&quot;, flag: &quot;🇺🇸&quot; },
  { name: &quot;파리&quot;, timezone: &quot;Europe/Paris&quot;, flag: &quot;🇫🇷&quot; },
  { name: &quot;남아공&quot;, timezone: &quot;Africa/Johannesburg&quot;, flag: &quot;🇿🇦&quot; },
];

export default function ClockPage() {
  const [currentTime, setCurrentTime] = useState(new Date());

  // 1초마다 시간 업데이트
  useEffect(() =&gt; {
    const timer = setInterval(() =&gt; {
      setCurrentTime(new Date());
    }, 1000);

    return () =&gt; clearInterval(timer);
  }, []);

  // 특정 타임존의 시간 포맷팅
  const getTimeForTimezone = (timezone: string) =&gt; {
    return new Intl.DateTimeFormat(&quot;ko-KR&quot;, {
      timeZone: timezone,
      hour: &quot;2-digit&quot;,
      minute: &quot;2-digit&quot;,
      second: &quot;2-digit&quot;,
      hour12: false,
    }).format(currentTime);
  };

  // 특정 타임존의 날짜 포맷팅
  const getDateForTimezone = (timezone: string) =&gt; {
    return new Intl.DateTimeFormat(&quot;ko-KR&quot;, {
      timeZone: timezone,
      year: &quot;numeric&quot;,
      month: &quot;long&quot;,
      day: &quot;numeric&quot;,
      weekday: &quot;long&quot;,
    }).format(currentTime);
  };

  return (
    &lt;s-page heading=&quot;세계 시계&quot;&gt;
      &lt;s-section&gt;
        &lt;s-stack direction=&quot;block&quot; gap=&quot;large&quot;&gt;
          {cities.map((city) =&gt; (
            &lt;s-box
              key={city.timezone}
              padding=&quot;large&quot;
              borderWidth=&quot;base&quot;
              borderRadius=&quot;large&quot;
              background=&quot;surface&quot;
            &gt;
              &lt;s-stack direction=&quot;block&quot; gap=&quot;base&quot;&gt;
                &lt;s-text variant=&quot;heading-lg&quot;&gt;
                  {city.flag} {city.name}
                &lt;/s-text&gt;
                &lt;s-text variant=&quot;heading-2xl&quot; fontWeight=&quot;bold&quot;&gt;
                  {getTimeForTimezone(city.timezone)}
                &lt;/s-text&gt;
                &lt;s-text variant=&quot;body-md&quot; tone=&quot;subdued&quot;&gt;
                  {getDateForTimezone(city.timezone)}
                &lt;/s-text&gt;
              &lt;/s-stack&gt;
            &lt;/s-box&gt;
          ))}
        &lt;/s-stack&gt;
      &lt;/s-section&gt;
    &lt;/s-page&gt;
  );
}</code></pre>
<h3 id="5-2-네비게이션에-링크-추가">5-2. 네비게이션에 링크 추가</h3>
<h4 id="approutesapptsx-수정"><code>app/routes/app.tsx</code> 수정</h4>
<pre><code class="language-tsx">export default function App() {
  const { apiKey } = useLoaderData&lt;typeof loader&gt;();

  return (
    &lt;AppProvider embedded apiKey={apiKey}&gt;
      &lt;s-app-nav&gt;
        &lt;s-link href=&quot;/app&quot;&gt;Home&lt;/s-link&gt;
        &lt;s-link href=&quot;/app/additional&quot;&gt;Additional page&lt;/s-link&gt;
        &lt;s-link href=&quot;/app/clock&quot;&gt;시계&lt;/s-link&gt; {/* ✅ 추가! */}
      &lt;/s-app-nav&gt;
      &lt;Outlet /&gt;
    &lt;/AppProvider&gt;
  );
}</code></pre>
<h3 id="5-3-결과-확인">5-3. 결과 확인</h3>
<p>브라우저에서 Shopify Admin → 앱 → 시계 탭 클릭!</p>
<p>상단 네비게이션:</p>
<pre><code>Home | Additional page | 시계</code></pre><p>각 도시의 시간이 실시간으로 똑딱똑딱! ⏰</p>
<hr>
<h2 id="6-핵심-개념-정리">6. 핵심 개념 정리</h2>
<h3 id="polaris-web-components">Polaris Web Components</h3>
<p>Shopify에서 제공하는 UI 라이브러리입니다. <code>&lt;s-*&gt;</code> 태그로 사용:</p>
<pre><code class="language-tsx">&lt;s-page heading=&quot;제목&quot;&gt;
  &lt;s-section&gt;
    &lt;s-button onClick={handler}&gt;버튼&lt;/s-button&gt;
    &lt;s-text&gt;텍스트&lt;/s-text&gt;
  &lt;/s-section&gt;
&lt;/s-page&gt;</code></pre>
<h3 id="react-router의-loader와-action">React Router의 Loader와 Action</h3>
<ul>
<li><strong>Loader</strong>: 페이지 로드 시 데이터 가져오기 (GET)</li>
<li><strong>Action</strong>: 폼 제출 시 데이터 처리 (POST)</li>
</ul>
<pre><code class="language-typescript">// 데이터 가져오기
export const loader = async ({ request }) =&gt; {
  const { admin } = await authenticate.admin(request);
  const data = await admin.graphql(`query { ... }`);
  return data;
};

// 데이터 처리
export const action = async ({ request }) =&gt; {
  const { admin } = await authenticate.admin(request);
  const result = await admin.graphql(`mutation { ... }`);
  return result;
};</code></pre>
<h3 id="타임존-처리">타임존 처리</h3>
<p>JavaScript의 <code>Intl.DateTimeFormat</code> API를 사용하면 타임존 변환이 간단합니다:</p>
<pre><code class="language-javascript">new Intl.DateTimeFormat(&quot;ko-KR&quot;, {
  timeZone: &quot;America/New_York&quot;,
  hour: &quot;2-digit&quot;,
  minute: &quot;2-digit&quot;,
}).format(new Date());
// → &quot;03:45&quot;</code></pre>
<hr>
<h2 id="7-배운-점과-느낀-점">7. 배운 점과 느낀 점</h2>
<h3 id="이해한-것들">이해한 것들</h3>
<p>✅ Shopify 커스텀 앱은 iframe 안에서 실행되는 웹앱이다
✅ OAuth로 상점과 안전하게 연결하고, Session을 DB에 저장한다
✅ React Router의 파일명이 곧 URL 구조다
✅ Polaris Web Components로 Shopify 스타일 UI를 쉽게 만들 수 있다
✅ 개발 모드는 Cloudflare 터널 사용, 프로덕션은 실제 서버 배포</p>
<h3 id="새로운-페이지-만들기-패턴">새로운 페이지 만들기 패턴</h3>
<ol>
<li><code>app/routes/app.페이지명.tsx</code> 파일 생성</li>
<li><code>app/routes/app.tsx</code>에 네비게이션 링크 추가</li>
<li>끝!</li>
</ol>
<h3 id="다음-단계">다음 단계</h3>
<ul>
<li>데이터베이스에 사용자 데이터 저장하기</li>
<li>Shopify Admin API로 제품/주문 관리하기</li>
<li>웹훅으로 실시간 이벤트 처리하기</li>
<li>프로덕션 배포하기</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>처음에는 막연했던 Shopify 커스텀 앱 개발이, 하나씩 뜯어보니 결국은 <strong>React 웹앱 + OAuth + API 호출</strong>의 조합이었습니다.</p>
<p>세계시계라는 간단한 기능이지만, 페이지 생성부터 라우팅, 실시간 업데이트까지 구현하며 Shopify 앱 개발의 전체 흐름을 익힐 수 있었습니다.</p>
<p>다음 포스트에서는 실제 Shopify 데이터(제품, 주문 등)를 다루는 앱을 만들어보겠습니다!</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://shopify.dev/docs/api/shopify-app-react-router">Shopify App React Router 공식 문서</a></li>
<li><a href="https://reactrouter.com">React Router 공식 문서</a></li>
<li><a href="https://shopify.dev/docs/api/app-home/polaris-web-components">Polaris Web Components</a></li>
<li><a href="https://shopify.dev/docs/api/admin-graphql">Shopify Admin GraphQL API</a></li>
</ul>
<hr>
<p><strong>GitHub 저장소</strong>: [링크 추가 예정]
<strong>질문이나 피드백은 댓글로 남겨주세요!</strong> 💬</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Shopify 독립 출판 서점 테마 제작기: The Moved Mover]]></title>
            <link>https://velog.io/@ej-rarus/Shopify-%EB%8F%85%EB%A6%BD-%EC%B6%9C%ED%8C%90-%EC%84%9C%EC%A0%90-%ED%85%8C%EB%A7%88-%EC%A0%9C%EC%9E%91%EA%B8%B0-The-Moved-Mover</link>
            <guid>https://velog.io/@ej-rarus/Shopify-%EB%8F%85%EB%A6%BD-%EC%B6%9C%ED%8C%90-%EC%84%9C%EC%A0%90-%ED%85%8C%EB%A7%88-%EC%A0%9C%EC%9E%91%EA%B8%B0-The-Moved-Mover</guid>
            <pubDate>Fri, 05 Dec 2025 02:20:01 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>&quot;The Moved Mover&quot;는 독립 출판물, 아티스트북, 그리고 창작자들의 목소리를 담은 책들을 판매하는 온라인 서점을 위한 Shopify 테마입니다.</p>
<blockquote>
<p>&quot;우리는 매 순간 흔들리고, 움직이며, 변합니다. 그리고 그 흔들림은 다시 누군가를 움직입니다. <strong>The Moved Mover</strong>는 그 파동을 기록으로 남깁니다.&quot;</p>
</blockquote>
<p><strong>프로젝트 저장소</strong>: <code>learn-shopify-theme</code>
<strong>작업 기간</strong>: 2025년 11월 ~ 12월
<strong>개발 환경</strong>: Shopify Liquid, HTML/CSS</p>
<hr>
<h2 id="프로젝트-동기">프로젝트 동기</h2>
<p>독립 출판의 가치를 전달하고, 창작자들의 목소리를 효과적으로 전달할 수 있는 온라인 플랫폼이 필요했습니다. 기존의 범용 커머스 테마들은 독립 출판의 철학과 브랜드 아이덴티티를 표현하기에 한계가 있었고, 이에 맞춤형 Shopify 테마를 직접 제작하게 되었습니다.</p>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<h3 id="핵심-기술">핵심 기술</h3>
<ul>
<li><strong>Shopify Liquid</strong>: Shopify의 템플릿 언어</li>
<li><strong>HTML5 / CSS3</strong>: 시맨틱 마크업 및 스타일링</li>
<li><strong>JavaScript</strong>: 인터랙션 및 동적 기능 구현</li>
<li><strong>Shopify CLI</strong>: 테마 개발 및 배포 도구</li>
</ul>
<h3 id="개발-도구">개발 도구</h3>
<ul>
<li><strong>VS Code</strong>: Shopify Liquid Extension 활용</li>
<li><strong>Git</strong>: 버전 관리</li>
<li><strong>Shopify Theme Dev</strong>: 로컬 개발 환경</li>
</ul>
<hr>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<pre><code>learn-shopify-theme/
├── assets/              # 정적 자산 (CSS, JS, 이미지, 비디오)
│   ├── critical.css     # 필수 CSS
│   └── video.mp4        # 히어로 배경 비디오
├── blocks/              # 재사용 가능한 UI 컴포넌트
├── config/              # 테마 전역 설정
├── layout/              # 페이지 최상위 래퍼
│   └── theme.liquid
├── locales/             # 다국어 번역 파일
├── sections/            # 페이지 섹션 컴포넌트
│   ├── home-hero.liquid
│   ├── featured-books.liquid
│   ├── books-catalog.liquid
│   ├── header.liquid
│   ├── about-hero.liquid
│   ├── submission-form.liquid
│   └── ...
├── snippets/            # 재사용 가능한 코드 조각
└── templates/           # 페이지 템플릿
    ├── index.json       # 홈페이지
    ├── collection.json  # 컬렉션 페이지
    └── ...</code></pre><hr>
<h2 id="주요-기능-구현">주요 기능 구현</h2>
<h3 id="1-홈-히어로-섹션-home-heroliquid">1. 홈 히어로 섹션 (home-hero.liquid)</h3>
<p>홈페이지의 첫인상을 결정하는 풀스크린 히어로 배너를 구현했습니다.</p>
<h4 id="주요-특징">주요 특징</h4>
<ul>
<li><strong>비디오 배경 지원</strong>: Shopify CDN에서 호스팅되는 배경 비디오</li>
<li><strong>이미지 폴백</strong>: 비디오 미지원 시 이미지로 대체</li>
<li><strong>Overlay 투명도 조절</strong>: 30% 투명도로 비디오 가시성 향상</li>
<li><strong>100vh 풀스크린</strong>: 모든 디바이스에서 전체 화면 표시</li>
<li><strong>애니메이션</strong>: Fade-in 애니메이션으로 부드러운 등장 효과</li>
<li><strong>스크롤 인디케이터</strong>: 바운스 애니메이션으로 사용자 유도</li>
</ul>
<h4 id="기술적-구현">기술적 구현</h4>
<pre><code class="language-liquid">{% if section.settings.hero_video_url != blank %}
  &lt;video class=&quot;home-hero__video&quot; autoplay muted loop playsinline&gt;
    &lt;source src=&quot;{{ section.settings.hero_video_url }}&quot; type=&quot;video/mp4&quot;&gt;
  &lt;/video&gt;
{% elsif section.settings.hero_video_asset != blank %}
  &lt;video class=&quot;home-hero__video&quot; autoplay muted loop playsinline&gt;
    &lt;source src=&quot;{{ section.settings.hero_video_asset | asset_url }}&quot; type=&quot;video/mp4&quot;&gt;
  &lt;/video&gt;
{% elsif section.settings.hero_image %}
  &lt;img src=&quot;{{ section.settings.hero_image | image_url: width: 1920 }}&quot; alt=&quot;Hero Banner&quot;&gt;
{% endif %}</code></pre>
<h4 id="반응형-디자인">반응형 디자인</h4>
<ul>
<li>모바일에서 스크롤 인디케이터 숨김</li>
<li>버튼을 세로 방향으로 재배치</li>
<li>Clamp()를 활용한 유연한 폰트 사이즈</li>
</ul>
<pre><code class="language-css">font-size: clamp(1.75rem, 5vw, 2.8rem);</code></pre>
<h3 id="2-도서-카탈로그-books-catalogliquid">2. 도서 카탈로그 (books-catalog.liquid)</h3>
<p>독립 출판물을 카테고리별로 탐색할 수 있는 카탈로그 섹션입니다.</p>
<h4 id="주요-특징-1">주요 특징</h4>
<ul>
<li><strong>카테고리 필터링</strong>: 에세이, 소설, 시, 예술, 철학 등</li>
<li><strong>검색 기능</strong>: 실시간 도서 검색</li>
<li><strong>그리드 레이아웃</strong>: 반응형 그리드 시스템</li>
<li><strong>호버 효과</strong>: 책 카드에 Overlay 및 CTA 버튼 표시</li>
<li><strong>별점 시스템</strong>: 사용자 리뷰 표시</li>
</ul>
<h4 id="css-grid-구현">CSS Grid 구현</h4>
<pre><code class="language-css">.books-catalog__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 2rem;
}</code></pre>
<h4 id="인터랙티브-필터-버튼">인터랙티브 필터 버튼</h4>
<pre><code class="language-css">.books-catalog__filter-btn--active {
  background-color: currentColor;
  color: #ffffff !important;
  box-shadow: 0 4px 12px rgba(196, 163, 106, 0.3);
  transform: translateY(-2px);
}</code></pre>
<h3 id="3-헤더-네비게이션-headerliquid">3. 헤더 네비게이션 (header.liquid)</h3>
<p>모든 페이지에서 일관된 네비게이션을 제공하는 고정 헤더입니다.</p>
<h4 id="주요-특징-2">주요 특징</h4>
<ul>
<li><strong>Sticky Header</strong>: 스크롤 시에도 상단 고정</li>
<li><strong>드롭다운 메뉴</strong>: 도서 카테고리 하위 메뉴</li>
<li><strong>장바구니 아이콘</strong>: 아이템 수 배지 표시</li>
<li><strong>로고와 메뉴</strong>: 중앙 정렬 레이아웃</li>
<li><strong>반응형</strong>: 모바일에서는 햄버거 메뉴로 전환 (향후 구현)</li>
</ul>
<h4 id="sticky-header-구현">Sticky Header 구현</h4>
<pre><code class="language-css">.header {
  position: sticky;
  top: 0;
  z-index: 100;
}</code></pre>
<h4 id="드롭다운-메뉴">드롭다운 메뉴</h4>
<pre><code class="language-css">.header__dropdown {
  position: absolute;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-10px);
  transition: all 0.2s;
}

.header__menu-item--has-dropdown:hover .header__dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}</code></pre>
<h3 id="4-about-페이지-히어로-about-heroliquid">4. About 페이지 히어로 (about-hero.liquid)</h3>
<p>출판사의 철학과 스토리를 전달하는 About 페이지 히어로 섹션입니다.</p>
<h4 id="주요-특징-3">주요 특징</h4>
<ul>
<li><strong>조건부 배경 이미지</strong>: 이미지 설정 시에만 표시</li>
<li><strong>CSS 변수 활용</strong>: 색상 관리의 일관성</li>
<li><strong>Rich Text 지원</strong>: 부제목에 서식 있는 텍스트 적용</li>
<li><strong>블랙/화이트 모드</strong>: 검은 배경에 흰색 텍스트 기본값</li>
</ul>
<h4 id="rich-text-서브타이틀-처리">Rich Text 서브타이틀 처리</h4>
<pre><code class="language-liquid">&lt;div class=&quot;about-hero__subtitle&quot; style=&quot;--subtitle-color: {{ section.settings.subtitle_color }};&quot;&gt;
  {{ section.settings.subtitle }}
&lt;/div&gt;

&lt;style&gt;
.about-hero__subtitle p {
  color: var(--subtitle-color);
  margin: 0;
}
&lt;/style&gt;</code></pre>
<hr>
<h2 id="기술적-도전과-해결">기술적 도전과 해결</h2>
<h3 id="1-비디오-배경-최적화">1. 비디오 배경 최적화</h3>
<h4 id="문제">문제</h4>
<ul>
<li>로컬 assets 폴더의 비디오 파일이 제대로 로드되지 않음</li>
<li>3MB 비디오 파일의 로딩 속도 이슈</li>
</ul>
<h4 id="해결">해결</h4>
<ul>
<li>Shopify CDN에 비디오를 업로드하여 URL로 참조</li>
<li><code>hero_video_url</code> 설정을 통해 CDN URL 직접 사용</li>
<li><code>autoplay muted loop playsinline</code> 속성으로 자동 재생 최적화</li>
</ul>
<pre><code class="language-liquid">&lt;video class=&quot;home-hero__video&quot; autoplay muted loop playsinline&gt;
  &lt;source src=&quot;https://cdn.shopify.com/videos/c/o/v/bb37e2c1a5c34d8ab30c3568194be6a1.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;</code></pre>
<h3 id="2-색상-일관성-관리">2. 색상 일관성 관리</h3>
<h4 id="문제-1">문제</h4>
<ul>
<li>여러 섹션에서 일관된 색상 사용 필요</li>
<li>Rich Text 내부의 중첩된 <code>&lt;p&gt;</code> 태그에서 색상 상속 이슈</li>
</ul>
<h4 id="해결-1">해결</h4>
<ul>
<li>CSS 변수를 활용한 색상 관리</li>
<li><code>color: inherit</code>로 중첩 요소에 색상 전달</li>
</ul>
<pre><code class="language-css">.home-hero__subtitle {
  color: {{ section.settings.subtitle_color }};
}

.home-hero__subtitle p {
  margin: 0;
  color: inherit;
}</code></pre>
<h3 id="3-반응형-레이아웃">3. 반응형 레이아웃</h3>
<h4 id="문제-2">문제</h4>
<ul>
<li>다양한 화면 크기에서 일관된 경험 제공</li>
<li>모바일에서 히어로 섹션의 가독성 저하</li>
</ul>
<h4 id="해결-2">해결</h4>
<ul>
<li>CSS <code>clamp()</code> 함수로 유연한 폰트 사이즈</li>
<li>미디어 쿼리로 모바일 전용 스타일 적용</li>
<li>Flexbox와 Grid의 조합으로 반응형 레이아웃 구현</li>
</ul>
<pre><code class="language-css">.home-hero__title {
  font-size: clamp(1.75rem, 5vw, 2.8rem);
}

@media (max-width: 768px) {
  .home-hero__buttons {
    flex-direction: column;
    width: 100%;
  }
}</code></pre>
<h3 id="4-books-catalog-필터-ux">4. Books Catalog 필터 UX</h3>
<h4 id="문제-3">문제</h4>
<ul>
<li>초기에는 &quot;All&quot; 필터 버튼이 있었으나 UX 관점에서 불필요</li>
<li>필터링 상태를 명확히 표시할 필요</li>
</ul>
<h4 id="해결-3">해결</h4>
<ul>
<li>&quot;All&quot; 버튼 제거 (커밋: <code>821ffe2</code>)</li>
<li><code>--active</code> 클래스로 활성 필터 시각화</li>
<li>Hover 효과로 인터랙션 강화</li>
</ul>
<pre><code class="language-css">.books-catalog__filter-btn--active {
  background-color: currentColor;
  color: #ffffff !important;
  transform: translateY(-2px);
}</code></pre>
<hr>
<h2 id="페이지-구성">페이지 구성</h2>
<h3 id="홈페이지-indexjson">홈페이지 (index.json)</h3>
<ol>
<li><strong>Hero</strong>: 풀스크린 비디오 배너</li>
<li><strong>Featured Books</strong>: 추천 도서 섹션</li>
<li><strong>Publishers Spotlight</strong>: 협력 출판사 소개</li>
<li><strong>Latest News</strong>: 최신 뉴스 및 이벤트</li>
<li><strong>Newsletter</strong>: 뉴스레터 구독</li>
<li><strong>Footer</strong>: 푸터 정보</li>
</ol>
<h3 id="정적-페이지">정적 페이지</h3>
<ul>
<li><strong>About (page.about.json)</strong>: 소개, 가치, 팀</li>
<li><strong>Authors (page.authors.json)</strong>: 소속 작가 소개</li>
<li><strong>Submission (page.submission.json)</strong>: 원고 투고 안내</li>
<li><strong>Contact (page.contact.json)</strong>: 문의 폼</li>
<li><strong>FAQ (page.faq.json)</strong>: 자주 묻는 질문</li>
</ul>
<h3 id="컬렉션-페이지-collectionjson">컬렉션 페이지 (collection.json)</h3>
<ul>
<li>Books Catalog 섹션 포함</li>
<li>카테고리별 필터링 기능</li>
</ul>
<hr>
<h2 id="디자인-시스템">디자인 시스템</h2>
<h3 id="색상-팔레트">색상 팔레트</h3>
<ul>
<li><strong>Primary (Accent)</strong>: <code>#c4a36a</code> - 골드 브라운</li>
<li><strong>Background</strong>: <code>#ffffff</code> / <code>#f9fafb</code> / <code>#2d2d2d</code></li>
<li><strong>Text</strong>: <code>#2d2d2d</code> / <code>#666666</code> / <code>#999999</code></li>
<li><strong>Border</strong>: <code>#e0e0e0</code></li>
</ul>
<h3 id="타이포그래피">타이포그래피</h3>
<ul>
<li><strong>Heading</strong>: 700 weight, clamp로 반응형 사이즈</li>
<li><strong>Body</strong>: 400-500 weight, 1.6-1.7 line-height</li>
<li><strong>Letter Spacing</strong>: Heading에 -0.3px 적용</li>
</ul>
<h3 id="간격-시스템">간격 시스템</h3>
<ul>
<li><strong>Section Padding</strong>: 100px - 180px (상하)</li>
<li><strong>Component Gap</strong>: 1rem - 2rem</li>
<li><strong>Grid Gap</strong>: 2rem</li>
</ul>
<hr>
<h2 id="성능-최적화">성능 최적화</h2>
<h3 id="1-이미지-최적화">1. 이미지 최적화</h3>
<ul>
<li>Shopify의 <code>image_url</code> 필터로 크기 지정</li>
<li>Lazy loading 기본 지원</li>
</ul>
<pre><code class="language-liquid">&lt;img src=&quot;{{ section.settings.hero_image | image_url: width: 1920 }}&quot;&gt;</code></pre>
<h3 id="2-css-분리">2. CSS 분리</h3>
<ul>
<li><code>critical.css</code>: 모든 페이지에 필요한 필수 CSS</li>
<li>Section별 <code>{% stylesheet %}</code> 태그: 해당 섹션에만 필요한 CSS</li>
</ul>
<h3 id="3-비디오-최적화">3. 비디오 최적화</h3>
<ul>
<li>Shopify CDN 활용</li>
<li><code>autoplay muted loop playsinline</code> 속성으로 자동 재생</li>
<li>Overlay로 콘텐츠 가독성 확보</li>
</ul>
<hr>
<h2 id="git-커밋-히스토리-하이라이트">Git 커밋 히스토리 하이라이트</h2>
<h3 id="비디오-히어로-구현-12월-4일">비디오 히어로 구현 (12월 4일)</h3>
<pre><code>560b716 - Use Shopify CDN URL for hero video
fe81ed8 - Fix video hero banner display issue
4200bb4 - Add full-screen video hero banner with video.mp4</code></pre><h3 id="books-catalog-개선-12월-4일">Books Catalog 개선 (12월 4일)</h3>
<pre><code>821ffe2 - Remove active filter button from Books Catalog section
5ccb811 - Enhance Books Catalog section design</code></pre><h3 id="about-섹션-리팩토링-12월-4일">About 섹션 리팩토링 (12월 4일)</h3>
<pre><code>dae7003 - Refactor About Hero section subtitle handling
1e43286 - Refactor About Hero section for conditional background image</code></pre><h3 id="readme-업데이트-12월-4일">README 업데이트 (12월 4일)</h3>
<pre><code>d92ce88 - Update README.md to reflect new theme name and features</code></pre><hr>
<h2 id="배운-점">배운 점</h2>
<h3 id="1-shopify-liquid-템플릿-시스템">1. Shopify Liquid 템플릿 시스템</h3>
<ul>
<li>Liquid 문법과 Shopify 객체 구조 이해</li>
<li>Section Schema를 통한 커스터마이징 옵션 제공</li>
<li><code>{% stylesheet %}</code>, <code>{% javascript %}</code> 태그의 중복 방지 기능</li>
</ul>
<h3 id="2-컴포넌트-기반-설계">2. 컴포넌트 기반 설계</h3>
<ul>
<li>Sections와 Blocks를 활용한 모듈화</li>
<li>재사용 가능한 Snippets 작성</li>
<li>JSON 템플릿으로 페이지 구성 관리</li>
</ul>
<h3 id="3-css-설계-패턴">3. CSS 설계 패턴</h3>
<ul>
<li>BEM 명명 규칙 적용</li>
<li>CSS 변수를 통한 일관된 디자인 시스템</li>
<li><code>clamp()</code>, <code>min()</code>, <code>max()</code> 함수로 반응형 구현</li>
</ul>
<h3 id="4-성능-최적화">4. 성능 최적화</h3>
<ul>
<li>Critical CSS 분리</li>
<li>Shopify CDN 활용</li>
<li>이미지 크기 최적화</li>
</ul>
<h3 id="5-사용자-경험-ux">5. 사용자 경험 (UX)</h3>
<ul>
<li>불필요한 UI 요소 제거 (&quot;All&quot; 필터 버튼)</li>
<li>명확한 인터랙션 피드백 (Hover, Active 상태)</li>
<li>접근성을 고려한 시맨틱 마크업</li>
</ul>
<hr>
<h2 id="개선-사항-및-향후-계획">개선 사항 및 향후 계획</h2>
<h3 id="단기-개선-사항">단기 개선 사항</h3>
<ul>
<li><input disabled="" type="checkbox"> 모바일 햄버거 메뉴 구현</li>
<li><input disabled="" type="checkbox"> 실제 상품 데이터와 연동</li>
<li><input disabled="" type="checkbox"> 검색 기능 JavaScript 구현</li>
<li><input disabled="" type="checkbox"> 카테고리 필터링 JavaScript 구현</li>
<li><input disabled="" type="checkbox"> 장바구니 기능 고도화</li>
</ul>
<h3 id="중기-개선-사항">중기 개선 사항</h3>
<ul>
<li><input disabled="" type="checkbox"> 다국어 지원 (영문, 한글)</li>
<li><input disabled="" type="checkbox"> 블로그 섹션 스타일링</li>
<li><input disabled="" type="checkbox"> 상품 상세 페이지 커스터마이징</li>
<li><input disabled="" type="checkbox"> Wishlist 기능 추가</li>
<li><input disabled="" type="checkbox"> 빠른 보기 (Quick View) 모달</li>
</ul>
<h3 id="장기-개선-사항">장기 개선 사항</h3>
<ul>
<li><input disabled="" type="checkbox"> 작가 인터뷰 시리즈 페이지</li>
<li><input disabled="" type="checkbox"> 커뮤니티 기능 (독자 리뷰, Q&amp;A)</li>
<li><input disabled="" type="checkbox"> 추천 시스템 (개인화)</li>
<li><input disabled="" type="checkbox"> 독립 출판 가이드 콘텐츠 허브</li>
<li><input disabled="" type="checkbox"> 이벤트 및 북토크 일정 관리</li>
</ul>
<hr>
<h2 id="프로젝트-회고">프로젝트 회고</h2>
<h3 id="잘한-점">잘한 점</h3>
<ol>
<li><strong>브랜드 아이덴티티 구현</strong>: &quot;The Moved Mover&quot;의 철학을 디자인으로 효과적으로 전달</li>
<li><strong>모듈화된 구조</strong>: Sections과 Blocks로 재사용 가능하고 확장 가능한 구조 구축</li>
<li><strong>성능 최적화</strong>: CDN 활용 및 CSS 분리로 로딩 속도 개선</li>
<li><strong>상세한 문서화</strong>: README에 프로젝트 구조와 사용법을 명확히 기록</li>
</ol>
<h3 id="아쉬운-점">아쉬운 점</h3>
<ol>
<li><strong>JavaScript 부재</strong>: 필터링과 검색 기능이 아직 정적 상태</li>
<li><strong>테스트 부족</strong>: 다양한 브라우저와 디바이스에서의 테스트 필요</li>
<li><strong>접근성</strong>: ARIA 라벨 및 키보드 네비게이션 개선 필요</li>
</ol>
<h3 id="다음-프로젝트에서-시도할-것">다음 프로젝트에서 시도할 것</h3>
<ol>
<li><strong>Alpine.js 또는 Vue.js</strong>: Liquid와 함께 사용할 경량 프레임워크</li>
<li><strong>Storybook</strong>: 컴포넌트 문서화 및 테스트</li>
<li><strong>E2E 테스트</strong>: Playwright 또는 Cypress로 테스트 자동화</li>
<li><strong>성능 모니터링</strong>: Lighthouse CI 통합</li>
</ol>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://shopify.dev/docs/storefronts/themes/architecture">Shopify Theme Architecture</a></li>
<li><a href="https://shopify.dev/docs/api/liquid">Liquid Template Language</a></li>
<li><a href="https://shopify.dev/docs/api/shopify-cli">Shopify CLI Documentation</a></li>
<li><a href="https://polaris.shopify.com/">Shopify Polaris Design System</a></li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>&quot;The Moved Mover&quot; 테마 제작을 통해 Shopify의 강력한 커스터마이징 기능과 Liquid 템플릿 시스템을 깊이 있게 이해할 수 있었습니다. 독립 출판이라는 특수한 도메인에 맞춤화된 온라인 서점을 구축하면서, 단순히 기술적인 구현을 넘어 브랜드의 철학과 가치를 디지털 경험으로 전환하는 과정을 경험했습니다.</p>
<p>앞으로 이 테마를 기반으로 실제 독립 출판사들이 자신들의 이야기를 효과적으로 전달하고, 독자들과 의미 있는 연결을 만들어갈 수 있기를 기대합니다.</p>
<blockquote>
<p>&quot;우리는 움직일 때 아름답다&quot; - The Moved Mover</p>
</blockquote>
<hr>
<p><strong>프로젝트</strong>: The Moved Mover - Shopify Theme
<strong>작성일</strong>: 2025년 12월 5일
<strong>작성자</strong>: Eunjae Tony Lee</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 기능 개발 3단계]]></title>
            <link>https://velog.io/@ej-rarus/React-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-3%EB%8B%A8%EA%B3%84</link>
            <guid>https://velog.io/@ej-rarus/React-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-3%EB%8B%A8%EA%B3%84</guid>
            <pubDate>Mon, 27 Feb 2023 06:49:40 GMT</pubDate>
            <description><![CDATA[<ol>
<li><p>state 정의하기</p>
</li>
<li><p>event listener 만들기</p>
</li>
<li><p>동작할 코드 작성</p>
</li>
</ol>
<p>a. HTML CSS 먼저 디자인 하기</p>
<p>b. 동작할 컴포넌트의 상태를 state로 선언하기</p>
<p>c. 컴포넌트의 기능 코딩하기</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] WARNING 메시지 제거하기]]></title>
            <link>https://velog.io/@ej-rarus/React-WARNING-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/React-WARNING-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 27 Feb 2023 06:02:47 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-javascript">/* eslint-disable */</code></pre>
<h2 id="react-코드를-실행하면-터미널에-이상한-경고-메시지가-떠요">React 코드를 실행하면 터미널에 이상한 경고 메시지가 떠요!</h2>
<p>no-unused-vars 같은 경고 메시지가 출력됩니다.
경고 메시지가 출력되어도 앱을 실행하는 데에는 아무런 문제가 없지만 눈에 자꾸만 거슬리고 신경이 쓰이게 되지요.
경고 메시지가 출력되는 원인과 없앨 수 있는 방법을 알아보겠습니다.</p>
<h2 id="0-eslint란">0. ESLint란?</h2>
<p>ESLint 때문입니다. 
ESLint는 자바스크립트 프로그램의 코드에 문법적인 오류가 있거나 표준에 맞지 않고 비효율적인 부분이 있을 때 이를 발견해 수정할 수 있도록 알려주는 역할을 하는 도구입니다.</p>
<p>React를 사용하면 별도로 ESLint를 설치하지 않아도 자동으로 적용되는 것이 기본이며
코드를 실행할 때 검출된 오류와 경고 메시지를 터미널에 출력합니다.</p>
<h2 id="1-eslint-오류-출력을-없애는-방법">1. ESLint 오류 출력을 없애는 방법</h2>
<h3 id="11-코드의-오류를-수정하기">1.1 코드의 오류를 수정하기</h3>
<p>당연한 이야기지만 ESLint의 권고를 받아들이고 효율적이고 읽기 편한 방향으로 자신의 코드를 수정하면 오류 메시지를 제거할 수 있습니다. 저도 그렇게 하시기를 추천합니다. 하지만 많은 경우에, ESLint가 지나치게 사소한 부분까지 경고한다고 느끼는 분들이 많으실 것 같습니다. 특히 배우는 과정에서는 작은 경고라도 신경이 쓰여서 빠르게 실습을 진행하지 못하게 되는 경우도 많습니다. 그럴 때에는 다음 방법을 참고해 보세요.</p>
<h3 id="12-eslint의-작동을-해제하기">1.2 ESLint의 작동을 해제하기</h3>
<p>사소한 오류 메시지가 출력되는 것이 싫으면 ESLint의 작동을 중지시키는 방법도 있습니다.</p>
<p>코드의 맨 윗부분에 다음과 같은 부분을 주석으로 추가해주세요.</p>
<pre><code class="language-javascript">/* eslint-disable */</code></pre>
<p>이렇게만 해도 ESLint의 자동 적용을 피할 수 있습니다.
만약 <strong>여러 개의 JS 파일이 import 되어 작동한다면 시스템 동작에 필요한 모든 JS 파일에 해당 코드를 추가</strong>해주어야 합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] onClick() 함수 사용하기]]></title>
            <link>https://velog.io/@ej-rarus/React-onClick-%ED%95%A8%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/React-onClick-%ED%95%A8%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 27 Feb 2023 05:35:47 GMT</pubDate>
            <description><![CDATA[<p><strong>1분 요약</strong></p>
<blockquote>
<pre><code class="language-javascript">&lt;span onClick={()=&gt;{실행할 코드}}&gt;&lt;/span&gt;</code></pre>
</blockquote>
<pre><code>
or

&gt; ```javascript 
&lt;span onClick={function(){실행할 코드}}&gt;&lt;/span&gt;</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[React] State를 사용하는 방법]]></title>
            <link>https://velog.io/@ej-rarus/React-State%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@ej-rarus/React-State%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Mon, 27 Feb 2023 05:28:12 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-javascript">import {useState} from &#39;react&#39;;
let [myState, setmyState] = useState(&#39;content&#39;);</code></pre>
<h2 id="0-스테이트-문법-언제-사용할까">0. 스테이트 문법 언제 사용할까?</h2>
<p>변수와 스테이트의 가장 큰 차이는 __ 입니다.
일반 변수는, 그 값이 변경되더라도 현재 출력중인 HTML 문서에 실시간으로 반영되지 않습니다.
수동으로 문서를 새로고침 하거나 변수의 변경된 값을 반영해 줄 함수를 따로 만들어 사용해야만 합니다.</p>
<p>반면에 <strong>스테이트는 그 값이 변경될 때마다 현재 출력중인 HTML 문서를 자동으로 재렌더링 하여 변경된 값을 반영</strong>해줍니다.</p>
<p>따라서 <strong>자주 변경이 필요하거나 빠르게 변경이 필요한 값을 저장할 때</strong> 일반변수보다는 스테이트를 활용하는 것이 좋습니다.</p>
<h2 id="1-usestate-훅-import-하기">1. useState 훅 import 하기</h2>
<h2 id="2-스테이트-정의하기">2. 스테이트 정의하기</h2>
<h2 id="3-스테이트-변경함수-정의하기">3. 스테이트 변경함수 정의하기</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] Element]]></title>
            <link>https://velog.io/@ej-rarus/React-Element</link>
            <guid>https://velog.io/@ej-rarus/React-Element</guid>
            <pubDate>Fri, 30 Dec 2022 05:47:41 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[[React] Props 사용하기]]></title>
            <link>https://velog.io/@ej-rarus/React-Props-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/React-Props-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 29 Dec 2022 12:03:23 GMT</pubDate>
            <description><![CDATA[<h2 id="1-props란">1. Props란?</h2>
<p>props는 읽기 전용 입니다.</p>
<h2 id="2-props-사용방법">2. Props 사용방법</h2>
<pre><code class="language-javascript">function sum(a, b) {
  return a + b;
}</code></pre>
<pre><code class="language-javascript">function withdraw(account, amount) {
  account.total -= amount;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 새 프로젝트 생성하기]]></title>
            <link>https://velog.io/@ej-rarus/React-%EC%83%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/React-%EC%83%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 24 Dec 2022 14:02:16 GMT</pubDate>
            <description><![CDATA[<h4 id="1분-요약">1분 요약</h4>
<blockquote>
<ol>
<li>원하는 경로에 새로운 폴더를 만드세요.</li>
<li>만든 폴더를 shift + 오른쪽 클릭하세요.</li>
<li>여기에  PwoerShell 창 열기를 클릭해 터미널을 켜세요.</li>
<li>터미널에 <code>npx create-react-app app_name</code> 입력하세요!</li>
</ol>
</blockquote>
<h2 id="1-nodejs-설치하기">1. Node.js 설치하기</h2>
<p><a href="https://nodejs.org/ko/">https://nodejs.org/ko/</a></p>
<p>위 링크에 방문하면 LTS 버전과 최신버전을 다운로드해서 설치할 수 있습니다.
안정적이고 신뢰도가 높은 LTS 버전을 추천합니다.</p>
<h2 id="2-vs-code-설치하기">2. VS Code 설치하기</h2>
<p><a href="https://code.visualstudio.com/">https://code.visualstudio.com/</a></p>
<p>Visual Studio Code는 코드를 편집하는 작업을 편리하게 만들어주는 편집기입니다.
위 링크에 방문하여 Visual Studio Code의 최신 안정화 버전을 다운로드 할 수 있습니다.</p>
<h2 id="3-새-react-프로젝트-생성하기">3. 새 React 프로젝트 생성하기</h2>
<h3 id="a-새-폴더-만들기">a. 새 폴더 만들기</h3>
<p><img src="https://velog.velcdn.com/images/ej-rarus/post/a4730986-ab8c-414a-a322-2a8add1e1dc5/image.png" alt=""></p>
<p>React 프로젝트를 생성하고 싶은 경로에 아무이름이나 붙여서 폴더를 하나 만들어 주세요.</p>
<p>폴더 경로에 한글이 들어있으면 작업중에 충돌을 일으키는 경우가 생길 수 있으니 폴더명을 가급적 영문으로 작성하는 것을 추천합니다.</p>
<h3 id="b-터미널-켜기">b. 터미널 켜기</h3>
<p><img src="https://velog.velcdn.com/images/ej-rarus/post/498a6472-4932-4e06-9aa9-3895dc5a4a34/image.png" alt=""></p>
<p>방금 만든 폴더를 <code>shift + 우클릭</code> 하면 <code>여기에 PowerShell 창 열기</code>가 있습니다.
클릭하면 window 터미널이 실행됩니다.</p>
<h3 id="c-react-프로젝트-생성-명령어-입력하기">c. React 프로젝트 생성 명령어 입력하기</h3>
<p><img src="https://velog.velcdn.com/images/ej-rarus/post/885593af-3b2e-41cb-ba25-10a6861f4b1f/image.png" alt=""></p>
<p>실행된 터미널에 아래 명령어를 입력하세요.
app_name 부분은 내가 원하는 이름으로 바꾸어 입력하면 됩니다.</p>
<p>대소문자를 구별하고, 띄어쓰기 공백도 중요하니 오타가 없도록 주의해 주세요.</p>
<pre><code>npx create-react-app app_name</code></pre><p>프로젝트 생성에는 1~2분 정도 시간이 소요됩니다.</p>
<h3 id="d-프로젝트-생성-성공">d. 프로젝트 생성 성공</h3>
<p><img src="https://velog.velcdn.com/images/ej-rarus/post/06576c04-3a54-407c-890d-e6056db292eb/image.png" alt=""></p>
<p>화면 아래쪽에 <code>Happy hacking!</code> 이라는 문구가 뜨면 새 React 프로젝트 생성에 성공한 것입니다.</p>
<h2 id="4-vs-code에서-react-프로젝트-열기">4. VS Code에서 React 프로젝트 열기</h2>
<h3 id="a-vs-code를-켜고-폴더-열기">a. VS Code를 켜고 폴더 열기</h3>
<p><strong>VS Code</strong>를 실행하고 화면 상단 <strong>&#39;파일(F)&#39; 탭에서 &#39;폴더 열기&#39;</strong>를 선택합니다.</p>
<p>방금 React 프로젝트를 생성했던 폴더를 찾아 열면 React 프로젝트를 VS Code에서 
편집할 수 있도록 작업공간을 연 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 컴포넌트 생성하기]]></title>
            <link>https://velog.io/@ej-rarus/React-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ej-rarus/React-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 24 Dec 2022 13:33:42 GMT</pubDate>
            <description><![CDATA[<h1 id="react-컴포넌트-생성하기">React 컴포넌트 생성하기</h1>
<p>React에서는 어플리케이션을 이루는 기본 단위로 &#39;컴포넌트&#39;를 사용합니다.</p>
<p>컴포넌트는 입력받은 데이터(Props)와 뷰(View)에 따라서 알맞은 DOM Node를 출력하는 함수입니다.</p>
<p>아래 두 블럭의 코드는 표현 방식이 서로 다르지만 완전히 동일한 컴포넌트를 출력합니다.</p>
<p>개인적으로는 함수형 컴포넌트가 보기에 깔끔한 것 같은데, 바닐라 자바스크립트에 더 익숙한 분들이라면 클래스형 컴포넌트를 더 선호하시는 것 같습니다.</p>
<p>구글링을 해보면 두 형태 모두 자주 사용되니 두 가지 방식 모두 살펴보고 익숙해지는 것이 좋겠습니다.</p>
<h2 id="1-클래스형-컴포넌트">1. 클래스형 컴포넌트</h2>
<pre><code class="language-javascript">
class Welcome extends React.Component {
  render() {
    return &lt;h1&gt;Hello, {this.props.name}&lt;/h1&gt;;
  }
}

</code></pre>
<h2 id="2-함수형-컴포넌트">2. 함수형 컴포넌트</h2>
<pre><code class="language-javascript">
function Welcome(props) {
  return &lt;h1&gt;Hello, {props.name}&lt;/h1&gt;;
}

</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Mac] Mac OS 에서 백틱(`) 입력하는 방법]]></title>
            <link>https://velog.io/@ej-rarus/Mac-Mac-OS-%EC%97%90%EC%84%9C-%EB%B0%B1%ED%8B%B1-%EC%9E%85%EB%A0%A5%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@ej-rarus/Mac-Mac-OS-%EC%97%90%EC%84%9C-%EB%B0%B1%ED%8B%B1-%EC%9E%85%EB%A0%A5%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Fri, 23 Dec 2022 02:07:36 GMT</pubDate>
            <description><![CDATA[<p><strong>맥 OS</strong>에서 <strong>백틱( ` ) 문자를 입력하는 방법</strong>은 다음과 같습니다.</p>
<p><strong>한글 입력 상태일 때</strong>
<code>option + ₩</code></p>
<p><strong>영어 입력 상태일 때</strong>
<code>₩</code> 키를 누르면 바로 <code>`</code>입력이 가능합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] React란?]]></title>
            <link>https://velog.io/@ej-rarus/React-React%EB%9E%80</link>
            <guid>https://velog.io/@ej-rarus/React-React%EB%9E%80</guid>
            <pubDate>Fri, 23 Dec 2022 01:43:44 GMT</pubDate>
            <description><![CDATA[<h4 id="1분-요약">1분 요약</h4>
<blockquote>
<p><strong>React</strong> 는 &#39;컴포넌트&#39;라는 모듈을 기본단위로 사용해서 <strong>HTML 요소들을 관리하고 동적으로 변화하도록 조작할 수 있는 JavaScript 라이브러리 입니다.</strong></p>
</blockquote>
<h2 id="1-react의-탄생-배경">1. React의 탄생 배경</h2>
<p>HTML과 CSS, JavaScript로 웹 서비스를 개발하다보면 몇 가지 난관에 이르게 됩니다. </p>
<p>우선 HTML, CSS, JavaScript <strong>각각의 문법이 서로 다르기 때문</strong>에 익히는 것이 꽤 번거롭고 모두 능숙하게 다루게 되기까지는 상당한 시간이 소요됩니다. </p>
<p>더욱 큰 문제는 웹 서비스의 기능이 다양하고 많아질수록, 특히 <strong>상호작용이 필요한 UI가 많이 추가될수록</strong> 관리해야할 데이터나 변수, 기타 구조적 요소들이 기하급수적으로 늘어난다는 것입니다.</p>
<p>게다가 그러한 작업들이 효율적으로 이루어지지 못한다면 로딩 시간이 길어진다거나 정상적인 작동을 하지 못하는 등의 문제로 곧장 이어집니다. 웹 사이트의 성능을 전혀 장담할 수 없게 되는 것입니다.</p>
<p>HTML 5, CSS 3, JavaScript ES6 기반의 웹 표준이 정착되지 않았던 시기에는 HTML에 그때그때 커스텀 태그를 만들어 도입하거나 악명높은 액티브x 플러그인을 사용해서 문제를 해결하고자 했습니다. 물론 그러한 방식의 접근은 득보다는 실이 훨씬 많았기 때문에 결과적으로 실패하고 말았습니다.</p>
<h2 id="2-react란">2. React란?</h2>
<p>이후로 웹 표준이 정착되고 표준을 준수하는 여러 가지 방식의 JavaScript 라이브러리들이 새롭게 등장하면서 중복되는 코드와 태그를 줄이고 HTML, CSS, JavaScript 사이의 장벽을 허물기 위한 다양한 시도들이 나타났습니다.</p>
<p>그중에서도 <strong>React</strong> 는 &#39;컴포넌트&#39;라는 모듈을 기본단위로 사용해서 <strong>HTML 요소들을 관리하고 동적으로 변화하도록 조작할 수 있는 JavaScript 프레임워크 입니다.</strong> </p>
<p>또, React를 사용하여 작성된 웹 페이지는 HTML에 상호작용이나 변화가 필요할 경우 페이지 전체를 다시 렌더링 하지 않고 변화한 부분만 선택적으로 다시 렌더링합니다. 따라서 페이지 로딩 속도를 향상시킬 수 있고 웹 페이지의 부드러운 동작을 가능하게 해줍니다.</p>
<h2 id="3-react-학습하기">3. React 학습하기</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JavaScript] 함수 (function)]]></title>
            <link>https://velog.io/@ej-rarus/JavaScript-%ED%95%A8%EC%88%98</link>
            <guid>https://velog.io/@ej-rarus/JavaScript-%ED%95%A8%EC%88%98</guid>
            <pubDate>Fri, 23 Dec 2022 01:36:51 GMT</pubDate>
            <description><![CDATA[<h2 id="1-함수란">1. 함수란?</h2>
<p>코드를 작성하는 과정에서, 거의 비슷하거나 완전히 같은 내용이 여러 번 되풀이되어 쓰이는 경우가 있습니다. </p>
<pre><code class="language-javascript">alert(&#39;안녕하세요, Michael!&#39;);
alert(&#39;Hello, Michael!&#39;);
alert(&#39;こんにちは, Michael!&#39;);
alert(&#39;Hola, Michael!&#39;);

alert(&#39;안녕하세요, Christine!&#39;);
alert(&#39;Hello, Christine!&#39;);
alert(&#39;こんにちは, Christine!&#39;);
alert(&#39;Hola, Christine!&#39;);
</code></pre>
<pre><code class="language-javascript">sayHello(&#39;Michael&#39;);
sayHello(&#39;Christine&#39;);</code></pre>
<h2 id="2-함수-선언하기">2. 함수 선언하기</h2>
<pre><code class="language-javascript">function sayHello(userName){
    alert(&#39;안녕하세요, &#39; + userName);
    alert(&#39;Hello, &#39; + userName);
    alert(&#39;こんにちは, &#39; + userName);
    alert(&#39;Hola, &#39; + userName);
}</code></pre>
<h2 id="3-매개변수">3. 매개변수</h2>
<h2 id="4-지역변수와-전역변수">4. 지역변수와 전역변수</h2>
<h2 id="5-기본값">5. 기본값</h2>
<h2 id="6-반환값">6. 반환값</h2>
<h2 id="7-함수-이름짓기">7. 함수 이름짓기</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JavaScript] 자료형 (data type)]]></title>
            <link>https://velog.io/@ej-rarus/JavaScript-%EC%9E%90%EB%A3%8C%ED%98%95-data-type</link>
            <guid>https://velog.io/@ej-rarus/JavaScript-%EC%9E%90%EB%A3%8C%ED%98%95-data-type</guid>
            <pubDate>Wed, 21 Dec 2022 14:08:56 GMT</pubDate>
            <description><![CDATA[<p>변수나 상수는 자료를 저장할 수 있는 공간이라는 것을 기억하시나요? (<a href="https://velog.io/@ej-rarus/JavaScript-%EB%B3%80%EC%88%98">변수</a>와 <a href="https://velog.io/@ej-rarus/JavaScript-%EC%83%81%EC%88%98">상수</a> 링크 참고.) 그렇다면 실제로 자료를 저장하고 불러내어 사용할 수 있어야 하겠습니다. 변수나 상수에 저장할 수 있는 자료에는 셀 수 없이 많은 <strong>유형</strong>이 있습니다. 새로운 유형을 만들어 낼 수도 있지요. 그 중에 가장 단순하고 자주 사용되는 <strong>기본 자료형</strong>에는 아래와 같은 것들이 있습니다.</p>
<h2 id="1-숫자형">1. 숫자형</h2>
<h2 id="2-문자형">2. 문자형</h2>
<h2 id="3-불린형-boolean-부울형-불리안형">3. 불린형 (Boolean, 부울형, 불리안형)</h2>
<h2 id="4-객체와-심볼-object--symbol">4. 객체와 심볼 (Object &amp; Symbol)</h2>
]]></description>
        </item>
    </channel>
</rss>