<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jaeiklee-dev.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Sun, 05 Oct 2025 15:12:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jaeiklee-dev.log</title>
            <url>https://velog.velcdn.com/images/jaeiklee-dev/profile/6cf16dd1-4056-4ec6-8501-458f5efd6fe9/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jaeiklee-dev.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jaeiklee-dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[설마 노션 이력서 PDF 뽑아서 그대로 제출하시나요?]]></title>
            <link>https://velog.io/@jaeiklee-dev/notion-db-powered-resume-template</link>
            <guid>https://velog.io/@jaeiklee-dev/notion-db-powered-resume-template</guid>
            <pubDate>Sun, 05 Oct 2025 15:12:43 GMT</pubDate>
            <description><![CDATA[<h2 id="1-프로젝트-요약">1. 프로젝트 요약</h2>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/d318f5d3-3999-4ec7-b9c5-7ec0c635d225/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/587c5463-6bbf-44d4-9aaa-e7a36e496829/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>Notion 데이터베이스와 연동하여 내용을 수정할 수 있는 이력서 웹사이트 템플릿입니다.<br>컴팩트한 2열 포맷으로 이력서를 PDF 출력할 수 있습니다.
개발에 Cursor를 적극 활용했습니다.</p>
<h3 id="주요-기능">주요 기능</h3>
<ul>
<li><strong>Notion 연동</strong>: Notion DB 수정 → 재배포하면 업데이트 완료</li>
<li><strong>PDF 출력</strong>: 컴팩트한 2열 포맷의 PDF 출력 가능</li>
<li><strong>모바일 최적화</strong>: 채용담당자들의 모바일 확인을 고려한 반응형 디자인</li>
<li><strong>무료 호스팅</strong>: GitHub Pages + Vercel로 유지비용 없이 운영</li>
</ul>
<h3 id="관련-링크">관련 링크</h3>
<ul>
<li><a href="https://jaeikleedev.github.io/resume/">이력서 메인 페이지</a></li>
<li><a href="https://jaeiklee-resume.vercel.app/">이력서 PDF 출력용 페이지</a></li>
<li><a href="https://fluorescent-airplane-153.notion.site/Jaeik-Lee-Resume-Database-278b61feddfe80628aadf4982bcb492a">이력서 Notion 데이터베이스</a></li>
<li><a href="https://github.com/JaeikLeeDev/resume/blob/main/README.md">GitHub repo &gt; 템플릿 이용 가이드</a></li>
</ul>
<h2 id="2-프로젝트-구성">2. 프로젝트 구성</h2>
<h3 id="아키텍처">아키텍처</h3>
<ul>
<li><strong>Notion 데이터베이스</strong>: 이력서 내용 저장</li>
<li><strong>GitHub Pages</strong>: 공개 이력서 페이지 호스팅</li>
<li><strong>Vercel</strong>: PDF 출력 기능을 담당하는 별도 페이지</li>
</ul>
<h3 id="기술스택">기술스택</h3>
<ul>
<li><strong>Frontend</strong>: React, Next.js, TypeScript</li>
<li><strong>Styling</strong>: Tailwind CSS</li>
<li><strong>API</strong>: Notion API</li>
<li><strong>PDF 생성</strong>: Puppeteer-core + @sparticuz/chromium</li>
<li><strong>배포</strong>: GitHub Actions (GitHub Pages) + Vercel</li>
</ul>
<h3 id="이력서-디자인">이력서 디자인</h3>
<p>개발자 현섭님과 유용우님의 이력서 사이트를 참고하였습니다.</p>
<ul>
<li><a href="https://hyunseob.github.io/resume/">https://hyunseob.github.io/resume/</a></li>
<li><a href="https://resume.yowu.dev/">https://resume.yowu.dev/</a></li>
</ul>
<h2 id="3-프로젝트-배경">3. 프로젝트 배경</h2>
<h3 id="구직-과정에서-발견한-이력서의-한계">구직 과정에서 발견한 이력서의 한계</h3>
<p>이력서 작성의 첫 시작은 사람인, 잡코리아, 원티드 등의 잡사이트 이력서를 완성하는 것이었습니다. 경력(회사 경험)과 경력기술서 섹션으로 구성된 양식에 따라 채워나가면 나름 깔끔한 결과물을 얻을 수 있었습니다. 디자인에 자신이 없다면 잡사이트 이력서 포맷을 사용하고 PDF 출력 기능을 쓰라는 조언도 있었습니다.</p>
<p>원티드, 잡코리아의 즉시지원 기능으로 여기저기 지원하기 시작했습니다. 지원할 때마다 회사에 맞는 이력만 들어가도록 적절히 수정했습니다. 하지만 10군데 가량 서류 탈락했습니다.</p>
<h3 id="헤드헌터를-통한-피드백-핵심-역량의-발견">헤드헌터를 통한 피드백: &#39;핵심 역량&#39;의 발견</h3>
<p>이력서를 열심히 수정하기 시작했습니다. 잡사이트 이력서도 구직중으로 변경했습니다. 조금씩 헤드헌터에게 연락이 오기 시작했고, 이들을 통한 구직의 장점은 구체적인 피드백을 받을 수 있다는 것이었습니다. 에이전시에서 만든 이력서 양식과 함께 각 항목을 어떻게 써야 하는지 세세한 가이드라인을 제공했습니다.</p>
<p>여러 헤드헌터들의 피드백과 양식을 분석하며 공통점을 발견했습니다. 잡사이트 이력서의 일반적인 구성인 경력, 경력기술서 섹션 외에 <strong>&#39;핵심 역량&#39; 섹션</strong>이 있었습니다. 이것이 이력서의 핵심이라는 생각이 들었습니다. 특히 저에게는 더욱 중요했습니다.</p>
<h3 id="si-개발자-다양한-스택-명확하지-않은-강점">SI 개발자, 다양한 스택, 명확하지 않은 강점</h3>
<p>저는 임베디드 개발로 경력을 시작했습니다. 하지만 최근 2년은 앱, 웹, 머신러닝 등 다양한 기술스택의 외주 개발을 했습니다. 하나의 기술스택으로 어필하기 어려운 상황이었습니다. 업무경험과 프로젝트를 단순 나열하는 방식으로는 제가 가진 강점이 명확하게 드러나지 않았습니다.</p>
<p>이력서 앞부분에 &#39;핵심 역량&#39; 섹션을 따로 만들어야겠다고 판단했습니다. 제가 자신 있고 어필하고 싶은 기술과 역량을 중심축으로 이력을 재구성할 필요가 있었습니다. 나만의 이력서를 만들어야 할 때였습니다.</p>
<h3 id="자유양식-이력서-제작의-막막함">자유양식 이력서 제작의 막막함</h3>
<p>자유양식 이력서를 만드는 게 생각보다 막막했습니다. Figma로 만들어볼까 했지만, 내용이 수정될 때마다 디자인까지 조정해야 한다는 점이 비효율적이었습니다. Figma, Canva의 이력서 템플릿들도 마음에 들지 않았고, 제 상황에 맞춰 커스터마이징하는 것도 상당한 작업이었습니다.</p>
<h3 id="notion-시도와-한계-발견">Notion 시도와 한계 발견</h3>
<p>일단 Notion으로 시도했습니다. 작성은 정말 편했습니다. 텍스트 위계를 자유롭게 표현할 수 있어 업무경험과 프로젝트 부분의 가독성이 잡사이트 이력서보다 훨씬 좋았습니다. 그러나 몇 가지 문제가 있었습니다.</p>
<p><strong>도메인 문제</strong></p>
<p>도메인이 깔끔하지 않았습니다. 예를 들면 <a href="https://fluorescent-airplane-153.notion.site/Jaeik-Lee-Resume-Database-278b61feddfe80628aadf4982bcb492a">https://fluorescent-airplane-153.notion.site/Jaeik-Lee-Resume-Database-278b61feddfe80628aadf4982bcb492a</a> 와 같은 식으로 나오는데, 공개용 이력서 사이트로 쓰기에는 적합하지 않습니다. 커스텀 도메인을 쓰려면 별도 구매가 필요했습니다.</p>
<p><strong>PDF 출력 품질</strong></p>
<p>가장 치명적인 문제였습니다. 자유양식 이력서를 요구하는 경우 PDF 제출을 요구하는 경우가 많은데, Notion 페이지를 PDF로 출력한 결과물은 Notion 상에서 보던 것과 차이가 조금 있습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/24683f94-7b4e-4ac9-9288-980e4d914237/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/df48fa53-3019-45b3-8781-8da67988c06c/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>스케일도 다르고(export시 조절 가능하긴 합니다만) 줄 간격 등 디자인 시스템에도 약간의 차이가 있어보입니다. 전반적으로 깔끔하고 가독성 높은 결과물이 나오지 않아 아쉬웠습니다.</p>
<p><strong>비효율적인 레이아웃</strong></p>
<p>인쇄용 양식으로 쓰기에는 낭비되는 지면이 너무 많았습니다. 1열 배치 방식이라 한 페이지에 들어오는 양이 적었습니다. Scale 조정 기능이 있었지만 근본적인 해결책은 아니었습니다. 헤드헌터들이 제공한 양식들은 모두 웹사이트같은 양식보다는 인쇄에 적합한 구조로 컴팩트하게 정보를 담고 있었습니다.</p>
<h3 id="직접-만들어보자">직접 만들어보자!</h3>
<p>이런 과정을 거치며 직접 이력서 시스템을 만들기로 결정했습니다. 핵심 역량을 효과적으로 어필하고, 인쇄에 적합한 레이아웃의 PDF 출력이 가능한 시스템을 만들어 보기로 했습니다. 또한 저와 비슷한 고충을 가지신 분들이 있을 거라 생각하여, 누구나 사용가능한 템플릿 형태로 완성해보기로 했습니다.</p>
<h3 id="목표">목표</h3>
<ul>
<li><strong>공개 이력서 사이트</strong>: 깔끔한 도메인으로 접근 가능</li>
<li><strong>디자인 자동화</strong>: 내용 수정에만 집중할 수 있도록</li>
<li><strong>편리한 업데이트</strong>: 주기적 업데이트가 쉬운 형태</li>
<li><strong>PDF 출력</strong>: 인쇄에 적합하도록 컴팩트한 포맷</li>
<li><strong>무료 운영</strong>: 유지비용 없이 운영</li>
</ul>
<h2 id="4-이력서-기능-소개">4. 이력서 기능 소개</h2>
<h3 id="14개-섹션">14개 섹션</h3>
<p>이력서는 총 14개의 섹션으로 구성되어 있습니다:</p>
<ol>
<li>개인 정보 (Personal Info)</li>
<li>기술 스택 (Skill)</li>
<li>핵심 역량 (Core Competency)</li>
<li>업무 경험 (Work Summary, Work Achievement)</li>
<li>프로젝트 경험 (Project)</li>
<li>포트폴리오 (Portfolio)</li>
<li>수상 (Award)</li>
<li>활동 (Activity)</li>
<li>기타 경험 (Other Experience)</li>
<li>가치관 (Value)</li>
<li>개발 외 툴 활용 역량 (Other Tool)</li>
<li>학력 (Education)</li>
<li>자격증 (Certification)</li>
<li>병역 (Military Service)</li>
</ol>
<p>Notion API를 사용하여 14개 섹션을 15개의 Notion DB와 연동했습니다.</p>
<h3 id="notion-db-입력-방식">Notion DB 입력 방식</h3>
<p>Notion DB의 각 프로퍼티는 이력서 화면에 매핑됩니다. <code>skills</code> 프로퍼티(Multi-select 타입)는 기술 스택 칩(Tech Chip) 형태로 표현되고, <code>details</code> 프로퍼티는 <code>-</code>로 시작하는 텍스트를 자동으로 bullet point로 변환합니다. Skill 섹션의 경우 같은 카테고리(<code>title</code>)끼리 그룹핑하여 표현됩니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/cdb08281-0c9e-493a-9413-6241ed7c91a3/image.png" alt="Notion DB"></th>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/23d9e318-bb0d-4065-9ac7-b6c4302ed1df/image.png" alt="이력서 화면"></th>
</tr>
</thead>
</table>
<p>DB가 비어있으면 해당 섹션이 자동으로 숨겨지고, 입력하지 않은 프로퍼티는 표시되지 않습니다. 지원하는 포지션에 따라 특정 내용을 선택적으로 표시할 수 있도록 <code>show</code> 프로퍼티로 개별 항목을 ON/OFF할 수 있으며, <code>order</code> 프로퍼티로 순서를 설정할 수 있습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/5e233068-708b-4d5e-9f6a-cd0b54b06765/image.png" alt="Notion DB"></th>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/712a5862-df99-489b-99eb-43a0accda50d/image.png" alt="이력서 화면"></th>
</tr>
</thead>
</table>
<h3 id="모바일-최적화">모바일 최적화</h3>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/06546561-ab57-4f7a-b3b1-613b5e2e6dbe/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/a55f7c0c-992a-4820-ad39-af5d67364070/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>채용담당자들이 모바일로 이력서를 확인하는 경우가 많다는 점을 고려하여 반응형 디자인을 구현했습니다. 2열 레이아웃(프로필 사진+연락처, 업무경험 부분)을 1열로 변경하고, 모바일 화면에 맞춰 글씨 크기와 타이포그래피 위계 대비를 조정했습니다.</p>
<h3 id="pdf-출력">PDF 출력</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/1ec2c6f1-5164-4390-b334-56078536d6c5/image.png" alt=""></p>
<p>인쇄에 적합한 컴팩트한 PDF 출력 기능을 구현했습니다. CSS <code>column-count</code>를 사용하여 2열로 배치함으로써 페이지 공간을 효율적으로 활용하고, 인쇄에 맞춰 글씨 크기와 타이포그래피 위계 대비를 조정했습니다. 공개 이력서 페이지 하단의 &#39;PDF로 출력하기&#39; 버튼을 클릭하면 Vercel 페이지로 이동하여 PDF를 다운로드할 수 있습니다.</p>
<h3 id="이력서-업데이트-방법">이력서 업데이트 방법</h3>
<p>코드를 로컬 환경에서 실행할 필요 없이 GitHub 웹에서 업데이트할 수 있습니다. Notion DB에서 이력서 내용을 수정한 후, GitHub Actions workflow를 실행하면 공개 이력서 페이지가 자동으로 업데이트됩니다. PDF 출력 페이지 업데이트가 필요한 경우 Vercel에서 redeploy를 실행하면 됩니다.</p>
<h2 id="5-github-pages--vercel-구조">5. GitHub Pages + Vercel 구조</h2>
<h3 id="구조-선택의-배경">구조 선택의 배경</h3>
<p>처음에는 이력서에 어울리는 도메인(<code>username.github.io</code>)을 제공하는 GitHub Pages를 사용해 구현하려 했습니다. 하지만 GitHub Pages는 정적 사이트 호스팅만 지원하기 때문에, PDF 출력 기능 구현에 제약이 있었습니다.</p>
<p>GitHub Pages에서 사용 가능한 PDF 출력 방식은 jsPDF 같은 클라이언트 사이드 라이브러리입니다. 이는 Canvas 기반으로 화면을 이미지화하여 PDF로 변환하는 방식이라 텍스트 선택이 불가능하고 링크도 사라지는 문제가 있었습니다. 일반적으로 사용하는 PDF 출력 라이브러리(Puppeteer, Playwright 등)는 서버사이드에서 실행되는 백엔드가 필요했습니다.</p>
<p>서버리스 함수를 지원하는 Vercel로 마이그레이션을 시도했습니다. PDF 출력 기능은 구현할 수 있었지만, 또 다른 문제를 마주했습니다. Vercel의 기본 도메인(<code>projectname.vercel.app</code>)은 이력서 사이트로 사용하기에는 신뢰감이 떨어졌습니다. 커스텀 도메인을 사용하려면 별도 구매가 필요했습니다.</p>
<h3 id="최종-구조-결정">최종 구조 결정</h3>
<p>두 플랫폼의 장점을 모두 활용하는 하이브리드 구조를 선택했습니다. 공개 이력서 페이지는 GitHub Pages에 호스팅하여 깔끔한 도메인을 확보하고, PDF 출력 기능을 위한 별도 페이지는 Vercel에 호스팅했습니다. 공개 이력서 페이지에서 &#39;PDF로 출력하기&#39; 버튼을 통해 Vercel 페이지로 이동할 수 있도록 구성했습니다. 이 구조를 통해 신뢰감 있는 도메인과 강력한 PDF 출력 기능을 모두 확보하면서, 유지비용은 전혀 들지 않게 됩니다.</p>
<h3 id="플랫폼별-역할">플랫폼별 역할</h3>
<p><strong>GitHub Pages</strong></p>
<ul>
<li><strong>역할</strong>: 공개 이력서 페이지 호스팅</li>
<li><strong>장점</strong>: 깔끔하고 이력서용으로 적합한 도메인</li>
<li><strong>한계</strong>: 정적 컨텐츠 호스팅만 가능, 원하는 방식의 PDF 출력 기능 구현 불가</li>
</ul>
<p><strong>Vercel</strong></p>
<ul>
<li><strong>역할</strong>: PDF 출력 기능을 담당하는 별도 페이지</li>
<li><strong>특징</strong>: 서버리스 웹서버로 정적 파일은 CDN에서 빠르게 서빙하고, 웹 애플리케이션은 필요할 때만 서버리스 함수 실행</li>
<li><strong>장점</strong>: 서버 설정, 확장, 보안 등 자동 관리</li>
<li><strong>한계</strong>: 도메인이 임시 사이트 같음 (도메인 구매 가능하지만 유지비용 발생)</li>
</ul>
<h2 id="6-pdf-출력-기능-구현-기술-선택-과정">6. PDF 출력 기능 구현: 기술 선택 과정</h2>
<p>Vercel의 서버리스 환경에서 PDF 출력 기능을 구현하는 과정에서 여러 시행착오를 겪었습니다.</p>
<h3 id="puppeteer--playwright-시도">Puppeteer &amp; Playwright 시도</h3>
<p>Puppeteer(Google)와 Playwright(Microsoft)는 브라우저 자동화 분야에서 널리 사용되는 라이브러리입니다. 웹 페이지를 실제 브라우저로 렌더링하여 PDF로 변환하기 때문에, 텍스트 선택과 링크 기능이 모두 유지됩니다.</p>
<p>하지만 두 라이브러리 모두 Chromium 바이너리를 포함하고 있어 번들 크기가 매우 컸습니다. Puppeteer (full version)는 약 282MB, Playwright는 약 300MB(Chromium, Firefox, WebKit 모두 포함)로 Vercel의 서버리스 함수 번들 크기 제한을 초과하여 배포할 수 없었습니다.</p>
<h3 id="puppeteer-core--sparticuzchromium">Puppeteer-core + @sparticuz/chromium</h3>
<p>Vercel 공식 문서에서 권장하는 서버리스 환경 솔루션입니다. Puppeteer-core는 브라우저 바이너리를 포함하지 않는 경량 버전이고, @sparticuz/chromium은 서버리스 환경에 최적화된 경량 Chromium 바이너리(약 35MB)를 별도로 제공합니다.</p>
<p>핵심은 바이너리 분리였습니다. Puppeteer-core는 브라우저 제어 API만 제공하고, @sparticuz/chromium이 서버리스 환경용 경량 Chromium 바이너리를 제공합니다. Puppeteer의 <code>executablePath</code> 옵션으로 @sparticuz/chromium 바이너리를 지정하는 방식으로, 함수 번들 크기를 약 35MB로 줄여 Vercel의 제한을 통과할 수 있었습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Puppeteer &amp; Playwright</th>
<th>Puppeteer-core + @sparticuz/chromium</th>
</tr>
</thead>
<tbody><tr>
<td><strong>크롬 바이너리</strong></td>
<td>라이브러리에 포함</td>
<td>외부 바이너리 사용</td>
</tr>
<tr>
<td><strong>함수 번들 크기</strong></td>
<td>제한 초과 (282-300MB)</td>
<td>제한 내 (35MB)</td>
</tr>
<tr>
<td><strong>결과</strong></td>
<td>Vercel 배포 실패</td>
<td>Vercel 배포 성공</td>
</tr>
</tbody></table>
<h2 id="7-notion-api-이미지-url-만료-문제">7. Notion API 이미지 URL 만료 문제</h2>
<p>개발 중에는 문제가 없었는데, 시간이 지난 후 다시 확인했을 때 프로필 이미지가 깨져있는 현상이 반복적으로 발생했습니다. 원인을 찾아보니 Notion API의 특성 때문이었습니다.</p>
<p>처음에는 Notion 데이터베이스에서 제공하는 이미지 URL을 통해 이미지를 가져와 보여주는 방식을 사용했습니다. 알고보니 Notion API가 제공하는 이미지 URL은 1시간의 유효시간을 가지고 있었습니다(<a href="https://developers.notion.com/reference/file-object">Notion API 문서</a>). 지속적으로 이미지를 표시하려면 1시간마다 API를 호출하여 새로운 URL을 받아와야 했습니다. 정적 사이트 특성상 이는 현실적이지 않은 방법이었습니다.</p>
<p>최종적으로 GitHub 저장소의 <code>public/images/</code> 디렉토리에 이미지를 직접 포함시키는 방식을 선택했습니다. 다행히 GitHub 웹 인터페이스를 통해 코드를 로컬 환경에 받지 않고도 이미지를 관리할 수 있었습니다. URL 만료 문제는 완전히 해결되었지만, Notion에서 직접 이미지를 관리할 수 없어 편의성 측면에서는 개선이 필요한 부분입니다.</p>
<h2 id="8-한계점과-개선-방향">8. 한계점과 개선 방향</h2>
<p>현재 템플릿은 fork하여 사용할 수 있을 정도의 안정성과 <a href="https://github.com/JaeikLeeDev/resume/blob/main/README.md">템플릿 이용 가이드 문서</a>를 갖추고 있습니다. 하지만 보다 편리한 이용을 위해 몇몇 아쉬운 부분들을 개선해나가려 합니다. </p>
<h3 id="pdf-출력-포맷-및-미리보기">PDF 출력 포맷 및 미리보기</h3>
<p>PDF 출력 결과물은 2열인데 웹페이지에는 1열 화면이 보여져 괴리감이 있습니다. 또한 사용자에 따라 페이지 수가 늘어나더라도 큰 글자로 쾌적하게 보길 선호할 수 있습니다. 추후 1열, 2열 포맷 선택 기능과 실시간 미리보기 UI를 제공하는 방식으로 개선해보려 합니다.</p>
<h3 id="관리-편의성">관리 편의성</h3>
<p>Notion Integration, GitHub Actions, Vercel을 각각 연동해야 하는 초기 세팅 과정이 복잡합니다. 이력서 내용 업데이트 시에도 GitHub Actions workflow 실행과 Vercel redeploy를 수동으로 진행해야 하는 점이 사용자 입장에서 직관적이지 않습니다.</p>
<p>필요한 작업들을 추상화하여 Notion Token, DB ID, 프로필, 파비콘만 입력하면 자동으로 모든 것이 세팅되는 통합 관리 플랫폼으로 개선해보고자 합니다.</p>
<h3 id="실시간-연동">실시간 연동</h3>
<p>Notion 데이터베이스를 수정했을 때 자동으로, 또는 주기적으로 재배포를 하면 편하지 않을까 생각했었습니다. 한편으로는 Notion에서 이력서를 수정하는 과정이 일일이 실시간으로 업데이트되는 것은 썩 좋지 않겠다는 생각도 듭니다. 공개 이력서는 불특정 다수가 보는 것입니다. 오히려 충분히 수정 작업이 완료되었을 때, 사용자가 원할 때만 업데이트를 하는 것이 더 적절할 수도 있을 것 같습니다. 어떤 방식으로 연동 편의성을 높일 수 있을지 더 고민이 필요할 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[주니어가 Cursor를 똑똑하게 활용하는 방법]]></title>
            <link>https://velog.io/@jaeiklee-dev/how-clever-junior-collaborate-with-cursor</link>
            <guid>https://velog.io/@jaeiklee-dev/how-clever-junior-collaborate-with-cursor</guid>
            <pubDate>Fri, 19 Sep 2025 08:01:41 GMT</pubDate>
            <description><![CDATA[<p>관련 글: <a href="https://velog.io/@jaeiklee-dev/aladin-used-book-buy-opimizer-with-cursor">https://velog.io/@jaeiklee-dev/aladin-used-book-buy-opimizer-with-cursor</a></p>
<blockquote>
<p>&quot;AI는 내가 아는 것을 대신 시키는 도구여야 합니다. 시켰던 것은 이해하고 가야 합니다. 그래야 내가 성장하고 서비스도 지속적으로 유지보수가 가능합니다.&quot;</p>
</blockquote>
<h2 id="들어가며-ai와-함께-만든-중고책-최적-구매-도우미">들어가며: AI와 함께 만든 중고책 최적 구매 도우미</h2>
<p>최근 Cursor와 함께 <strong>알라딘 중고책 배송비 절약 서비스</strong>를 개발했습니다. React + Node.js로 프론트엔드와 백엔드를 구성하고, Vercel + Railway로 배포까지 완료한 프로젝트입니다.</p>
<p>Cursor는 팝업 UI, 크롤링 기능 등 뻔한 코드는 금방 짜줍니다. 서비스를 배포하는 데까지 필요한 작업을 A-Z까지 순서대로 알려준다든지, 코드를 리뷰해 주기도 합니다. <strong>때로는 주니어 같고, 때로는 시니어</strong> 같습니다.</p>
<p>어느 쪽이든 간에, 공통으로 느낀 것이 있습니다. Cursor는 내가 아는 것을 대신 시키는 도구여야 한다는 것입니다. 정확히는, Cursor에게 시켰던 것은 적어도 70% 이상은 이해하고 가야 내가 성장하고, 서비스 자체도 지속적으로 유지보수가 가능할 것이란 생각이 들었습니다.</p>
<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>중고 책을 여러 권 구매할 때 같은 셀러 묶음배송을 고려하여 가장 저렴한 조합을 찾아주는 웹 서비스</p>
<blockquote>
<p><a href="https://aladin-used-book-optimizer.com/">https://aladin-used-book-optimizer.com/</a></p>
</blockquote>
<p><strong>기술 스택</strong>:</p>
<ul>
<li>Frontend: React 19 + TypeScript + Vite + Tailwind CSS</li>
<li>Backend: Node.js + Express + TypeScript</li>
<li>배포: Vercel (Frontend) + Railway (Backend)</li>
<li>크롤링: Axios + Cheerio (Puppeteer 제거)</li>
</ul>
<p><strong>핵심 기능</strong>:</p>
<ul>
<li>알라딘 Open API를 통한 도서 검색</li>
<li>중고책 정보 크롤링 및 파싱</li>
<li>브루트 포스 알고리즘으로 최적 구매 조합 계산</li>
<li>같은 셀러 묶음 배송을 통한 배송비 절약</li>
</ul>
<h2 id="🧒-cursor가-주니어가-될-때-아는-분야를-개발할-때">🧒 Cursor가 주니어가 될 때: 아는 분야를 개발할 때</h2>
<h3 id="1-내가-아는-것을-대신-시키는-도구로-활용하기">1. &quot;내가 아는 것을 대신 시키는 도구&quot;로 활용하기</h3>
<p>UI의 경우 Cursor가 실수해도 금방 원인을 찾고 수정이 가능했습니다. 문제는 복잡한 알고리즘을 짤 때였습니다. 저의 경우, 여러 권의 중고책을 구매할 때 최저가 조합을 찾아주는 알고리즘을 작성해야 했습니다. 알라딘 중고 매물 정보를 가져와서, 같은 셀러별로 묶음 배송시 절약되는 배송비를 고려해서 최저가 조합을 찾아주는 알고리즘입니다.</p>
<blockquote>
</blockquote>
<ol>
<li>각 책의 모든 중고책들의 조합을 만든다. 예를 들어 4가지 책이 있고, 각 책의 중고책 수가 3권, 5권, 7권, 10권이라면 모든 조합의 수는 357*10가지가 된다.</li>
<li>각 조합마다 다음을 수행한다.
 a. 조합을 구성하는 책들의 책값을 먼저 더해 책값의 총합을 구한다.
 b. 조합을 구성하는 책들의 배송비를 하나씩 더한다. 이때, 먼저 배송비를 더한 책 중 책 중 같은 셀러가 판매하고 있는 책이 있다면 배송비를 더하지 않는다. 즉, 같은 셀러가 판매하는 책들 모두에 대하여 배송비는 한 번만 적용한다.
 c. 배송비까지 모두 더하여 총 가격을 산출한다.</li>
<li>2.번을 수행할 때마다 총 가격을 비교하여 최저가 조합을 갱신한다.<blockquote>
</blockquote>
</li>
</ol>
<p>Cursor는 생각보다 세세한 조건을 고려해서 정확하게 짜지 못했습니다. 또한 오류가 있는 경우 디버깅도 어려웠습니다. 평소 &#39;코딩테스트가 실무에서 큰 의미가 있나?&#39; 생각했었는데, 기본적인 알고리즘 지식의 필요성을 실감하는 경험이었습니다.</p>
<h3 id="2-코드-리뷰는-필수">2. 코드 리뷰는 필수</h3>
<p>1번과 같은 맥락입니다. 코드 리뷰는 두 가지 측면에서 필요합니다.</p>
<ul>
<li>Cursor가 기획에 어긋나지 않게 구현했는지 점검하기</li>
<li>개발 진행 상황을 내 컨트롤 아래 두기</li>
</ul>
<p>간단한 서비스, 유지보수하지 않을 서비스는 완성만 하면 되니 상관없습니다. 하지만 지속적으로 운영할 서비스라면 언젠가는 개발자가 개입해야 하는 순간이 올 것입니다. 또한 서비스가 커지다 보면 Cursor가 전체 맥락을 고려하지 못하기 시작하는 지점이 옵니다.</p>
<p>언제든 개발자가 스스로 개발에 투입할 수 있도록 Cursor가 하는 작업을 리뷰하고, 이해하고 넘어가야만 합니다.</p>
<h3 id="3-개발자가-컨트롤-가능한-단위로-작업하기">3. 개발자가 컨트롤 가능한 단위로 작업하기</h3>
<p>Cursor의 작업을 이해하고 넘어가기 위해 만들었던 원칙이 있습니다. 평소 개발을 할 때와 같이 이슈 단위로 커밋하는 것입니다. 커밋 단위 즉, 충분히 이해할 수 있는 단위로 작업을 쪼개서 Cursor에게 작업을 할당하는 것입니다.</p>
<p>Cursor는 요청사항을 얘기하면 묻지도 않고 A-Z까지 순식간에 진행해 버립니다. 가령, &quot;React에서 책 제목을 입력받는 검색창과 검색 버튼 만들어줘&quot;라고 요청하면 Cursor는:</p>
<ul>
<li>BookSearch 컴포넌트</li>
<li>useBookSearch 훅</li>
<li>ApiService.searchBooks 메서드</li>
<li>Book, SearchResult 타입 정의</li>
<li>에러 처리 로직</li>
<li>로딩 상태 관리</li>
<li>검색 결과 페이지네이션</li>
</ul>
<p>까지 한 번에 만들어버렸습니다. 한 번에 수십 개의 수정 사항이 생깁니다. 이대로 커밋을 해버리면 추후 변경사항을 추적하기 어렵습니다. 문제가 발생했을 때 어떤 수정에서 비롯된 것인지도 알기 어렵게 됩니다.</p>
<p>그래서 저는 평소 Git을 사용해 개발 진행 상황을 관리하던 것을 생각했습니다. 커밋을 하는 단위에 맞춰서 Cursor에게 요청하고, Cursor가 개발한 내용을 이해하여 커밋메세지를 작성하려고 노력했습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/552e201c-d409-4937-8260-a7e96a760306/image.png" alt=""></p>
<h3 id="4-지속적인-리팩토링과-코드-품질-관리">4. 지속적인 리팩토링과 코드 품질 관리</h3>
<p>Cursor가 개발한 코드를 그대로 두면 시간이 지날수록 코드 품질이 떨어지고 유지보수가 어려워집니다. 지속적인 리팩토링과 코드 품질 관리가 필수입니다. Cursor에게 주기적으로 다음의 작업을 시켰습니다.</p>
<ul>
<li>코드 중복 제거</li>
<li>기능 완성 후: 전체 아키텍처 검토 및 알고리즘 오류 점검</li>
<li>배포 전: 성능 최적화</li>
</ul>
<h2 id="👴🏻-cursor가-시니어가-될-때-모르는-분야를-개발할-때">👴🏻 Cursor가 시니어가 될 때: 모르는 분야를 개발할 때</h2>
<p>개발자가 지식이 많고, 그 지식 안에서 Cursor를 주니어처럼 사용할 수 있다면 최선일 것입니다. 하지만 주니어 개발자는 지식의 절대량이 부족하므로 애초 불가능한 일입니다.</p>
<p>이번에 Cursor와 작업을 진행하면서 시니어 한 분을 옆에 모시고 개발을 진행하는 것 같은 기분이 들었습니다. 저는 아직 React와 백엔드 개발 전체 사이클에 대한 지식이 많이 부족한데, Cursor는 제가 목표하는 서비스를 완성하는 데에 필요한 로드맵을 그려주고, 순서대로 진행할 수 있도록 알려주기 때문입니다.</p>
<p>하지만 스스로가 모르는 분야를 Cursor에게 모두 맡긴다면, 그 서비스는 유지보수가 불가능한 서비스가 될 것입니다. 저는 개발한 서비스를 제 것으로 만들기 위해 다음과 같은 방식으로 작업을 진행했습니다.</p>
<h3 id="1-무엇을-해야-하는지-알려줘-작업은-내가-할게">1. 무엇을 해야 하는지 알려줘. 작업은 내가 할게</h3>
<p>제가 어느 정도 코드 리뷰가 가능한 부분은 Cursor에게 개발을 맡겼습니다. 하지만 전혀 모르는 분야에 대해서는 반대로 진행했습니다. Cursor에게 무엇을 해야 하는지 작은 단위로 쪼개서 알려달라고 했습니다. 작업은 내가 직접 할 것이니, 알려만 달라고 말입니다. Cursor가 해야 할 작업을 알려주면, 모르는 부분은 적절히 공부도 해가면서 직접 개발을 해나갔습니다. 물론, 모든 코드를 직접 작성하지는 않았지만, 모든 것을 Cursor가 진행해버리도록 두지 않았습니다.</p>
<h4 id="구체적인-예시들">구체적인 예시들:</h4>
<p><strong>1. CI/CD 파이프라인 구축</strong>
&quot;서비스 배포하는 데에 필요한 작업 알려줘. CI/CD까지 적용해서 자동으로 배포할 수 있도록 하는 데에 필요한 작업 알려줘&quot;</p>
<ul>
<li>GitHub Actions 워크플로우 설정</li>
<li>빌드 및 테스트 자동화</li>
<li>Vercel과 Railway 연동 설정</li>
<li>환경변수 관리 방법</li>
<li>도메인 연결</li>
<li>배포 스크립트 작성</li>
</ul>
<p><strong>2. 배포 과정에서의 적용</strong>
&quot;Vercel과 Railway로 배포하는 방법을 알려줘&quot;</p>
<ul>
<li>Vercel CLI 설치 및 로그인</li>
<li>vercel.json 설정 파일 생성</li>
<li>환경변수 설정 방법</li>
<li>Railway CLI 설치 및 프로젝트 연결</li>
<li>Railway 환경변수 설정</li>
<li>도메인 연결 방법</li>
</ul>
<p>이 모든 과정을 직접 따라하면서 하나씩 이해해갔습니다. 특히 백엔드는 GCP의 Cloud Functions 등을 활용한 경험은 있지만 Node.js로 웹애플리케이션 서버를 구성해 본 경험은 없어서 추가로 공부가 필요했습니다. 이 경험을 통해서 Express.js의 미들웨어 개념, 라우팅 구조, 그리고 실제 웹 서버가 어떻게 동작하는지에 대한 이해를 얻을 수 있었습니다. </p>
<p>또한 Railway와 같은 클라우드 플랫폼에서 Node.js 애플리케이션을 배포하는 과정에서 환경변수 관리, 포트 설정, 헬스체크 등의 실제 운영 환경에서 필요한 지식들도 학습할 수 있었습니다. 이런 것조차 이제는 구글링할 필요없이 Cursor에게 물어보면 즉각 알 수 있으니 훨씬 빠르게 학습할 수 있었습니다.</p>
<p>물론 이렇게 하면 Cursor에게 다 맡기는 것보다는 개발에 소요되는 시간이 길어질 것입니다. 그럼에도 예전과 비교를 한다면, 주니어에게는 어떤 서비스를 구현하기 위해 무엇을 해야하는지를 알아내는 것부터가 허들이었습니다. 그 시간을 줄이는 것만 해도 엄청난 효율 증대입니다.</p>
<p>계속 운영할 서비스라면 서비스가 커져도 개발자가 직접 유지보수가 가능할 수 있는 상태를 유지해야 합니다. 문제가 발생했을 때 원인을 파악하고, 어떤 식으로 문제를 해결하라고 Cursor에게 지시할 수 있는 수준으로 서비스를 이해하고 있어야 합니다.</p>
<h3 id="2-문서화">2. 문서화</h3>
<p>Cursor가 작업한 부분을 기억하고 이해하고 있기 위해서 한 것이 하나 더 있다면 문서화입니다. 특히 저는 어떤 서비스를 오랜만에 개발하려고 할 때 서비스 전체 아키텍쳐, 재배포하는 방법 등을 파악하는 데 에너지를 많이 써야 했던 경험이 있습니다. 그래서 평소에도 개발하면서 문서화하는 것을 중요하게 생각하는 편입니다. Cursor와 협업을 하면서도, 빠르게 진행되는 작업 상황을 지속적으로 follow-up 할 수 있도록 주기적으로 Cursor에게 문서화, 문서 업데이트를 부탁했습니다.</p>
<p><strong>1. 프로젝트 구조 문서화</strong></p>
<pre><code class="language-markdown">#### 프로젝트 구조 ####

aladin-used-book-buy-optimizer/
├── frontend/                 # Vercel 배포
│   ├── src/
│   │   ├── components/      # React 컴포넌트
│   │   ├── hooks/          # 커스텀 훅
│   │   ├── types/          # TypeScript 타입
│   │   └── utils/          # 유틸리티 함수
│   ├── vercel.json         # Vercel 설정
│   └── package.json
├── backend/                 # Railway 배포
│   ├── src/
│   │   ├── routes/         # Express 라우터
│   │   ├── services/       # 비즈니스 로직
│   │   ├── lib/           # 크롤링 라이브러리
│   │   └── utils/         # 유틸리티 함수
└── scripts/                # 배포 스크립트
</code></pre>
<p><strong>2. 배포 가이드 문서화</strong></p>
<pre><code class="language-markdown">#### 배포 과정 ####

### 1. 자동 테스트 (GitHub Actions)
git push origin develop  # develop 브랜치에 push하면 자동 테스트

### 2. 배포 도구 설치 및 로그인
# Vercel CLI 설치 및 로그인
npm install -g vercel@latest
vercel login

# Railway CLI 설치 및 로그인
npm install -g @railway/cli@latest
railway login
</code></pre>
<p><strong>3. 아키텍처 다이어그램 문서화</strong></p>
<pre><code class="language-markdown">#### 전체 구조 ####

┌─────────────────┐    ┌─────────────────┐
│   Vercel CDN    │    │   Railway API   │
│   (Frontend)    │◄──►│   (Backend)     │
│                 │    │                 │
│ • React + Vite  │    │ • Node.js + Exp │
│ • Edge CDN      │    │ • Auto Scale    │
└─────────────────┘    └─────────────────┘
</code></pre>
<p><strong>4. API 엔드포인트 문서화</strong></p>
<pre><code class="language-markdown">#### API 엔드포인트 ####

- GET /api/books/search - 도서 검색
- GET /api/books/:isbn/used - 특정 도서의 중고책 정보
- POST /api/optimization/calculate - 최적 구매 조합 계산
- GET /health - 서버 상태 확인
</code></pre>
<p><strong>5. 개발 가이드 문서화</strong></p>
<pre><code class="language-markdown">#### 개발 가이드 ####

### 로컬 개발
npm run dev  # 전체 프로젝트 개발 서버 실행
npm run dev:frontend  # 프론트엔드만
npm run dev:backend   # 백엔드만

### 배포
npm run deploy:vercel  # 프론트엔드 배포
npm run deploy:railway # 백엔드 배포
</code></pre>
<p>이렇게 문서화를 통해 프로젝트의 전체 구조, 배포 과정, API 사용법 등을 명확하게 정리할 수 있었습니다. 특히 Cursor와 협업하면서 빠르게 진행되는 작업 상황을 지속적으로 follow-up 할 수 있었고, 나중에 프로젝트를 다시 볼 때도 쉽게 이해할 수 있었습니다.</p>
<h2 id="마치며">마치며</h2>
<p>Cursor는 정말 강력한 도구입니다. Cursor를 사용해서 서비스 하나를 배포까지 해 본 것은 처음입니다. 7일 정도 걸렸습니다. 만약 Cursor가 모든 것을 하도록 했다면 훨씬 금방 했을지도 모릅니다. 아니, 오히려 중간에 막혔을지도 모르겠네요. 네, 저는 후자에 더 무게를 싣고 있습니다.</p>
<p>Cursor에게 개발을 시키면서도, 이해가 안 가는 부분은 실시간으로 물어봤습니다. 주니어가 학습할 수 있는 속도가 정말 빨라졌다고 느낍니다. 물론, 실패하면서 배우는 경험은 줄어들겠지만요. 목표한 것을 이루는 데에 필요한 지식을 효율적으로 얻는 데에는 정말 큰 도움이 된다고 느꼈습니다.</p>
<p>Cursor에게 모든 것을 시키는 주니어는 계속 주니어에 머물러 있을 것입니다. 더 빠르게 성장하는 주니어가 되기 위해 Cursor와 어떻게 협업해야 하는지를 고민해 보았습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[7일만에 알라딘 중고책 배송비 절약 서비스 개발, 배포까지 (feat. Cursor)]]></title>
            <link>https://velog.io/@jaeiklee-dev/aladin-used-book-buy-opimizer-with-cursor</link>
            <guid>https://velog.io/@jaeiklee-dev/aladin-used-book-buy-opimizer-with-cursor</guid>
            <pubDate>Fri, 19 Sep 2025 00:35:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>React 경험이 거의 없는 개발자가 Cursor와 함께 React, Node, 알라딘 Open API를 사용한 서비스를 7일 동안 개발한 이야기입니다.</p>
</blockquote>
<h2 id="🎯-프로젝트-소개">🎯 프로젝트 소개</h2>
<p>저는 보통 책을 중고로 삽니다. 종종 여러 권을 한 번에 살 때도 있습니다. 이럴 때 아쉬운 점은 배송비입니다. 새 책은 일정 금액이 넘으면 보통 무료배송이니까요. 중고는 무료배송이 잘 없는 데다 <strong>판매자마다 배송비가 따로 붙어서</strong> 왠지 더 억울합니다.</p>
<p>한 번은 1, 2, 3권 시리즈 책을 사려고 했습니다. 배송비가 따로 붙는 게 싫어서 세 권을 모두 갖고 있는 판매자를 찾았습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/2a832bf1-95ed-49f1-b351-723fe7ff7348/image.png" alt=""></p>
<p>묶음배송 조합을 찾는 데는 성공했지만, 문제가 있었습니다. 그 판매자의 매물이 최저가가 아니었습니다. 차라리 서로 다른 판매자의 최저가 매물을 각각 사는 게 더 싼 겁니다. 결국 1, 2, 3권의 페이지를 각각 열어놓고, <strong>판매자가 같으면서도 저렴한 매물을 찾다가</strong> 눈이 빠질 뻔 한 경험이 있습니다. 그렇게 이 서비스를 기획하게 되었습니다.</p>
<h2 id="📍-핵심-기능-소개">📍 핵심 기능 소개</h2>
<p><a href="https://aladin-used-book-optimizer.com">https://aladin-used-book-optimizer.com</a></p>
<h3 id="📱-홈-화면">📱 홈 화면</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/e1211cd6-8f64-4314-ae24-59b59ba668df/image.png" alt=""></p>
<h3 id="🔍-알라딘-open-api를-통한-도서-검색-위시리스트에-등록">🔍 알라딘 Open API를 통한 도서 검색, 위시리스트에 등록</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/33956168-d095-42fd-9357-16f654006a5d/image.png" alt=""></p>
<h3 id="🛒-위시리스트에-등록된-각-도서별-중고-매물-정보">🛒 위시리스트에 등록된 각 도서별 중고 매물 정보</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/d45e88e7-4330-4705-9d36-3ee0643475aa/image.png" alt=""></p>
<h3 id="💰-같은-셀러-묶음배송을-활용한-최저가-조합을-추천">💰 같은 셀러 묶음배송을 활용한 최저가 조합을 추천</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/23022e48-fa29-4be3-b6ec-bff06caade52/image.png" alt=""></p>
<h2 id="🛠-기술-스택">🛠 기술 스택</h2>
<h4 id="프론트엔드">프론트엔드</h4>
<ul>
<li>React 19 + TypeScript</li>
<li>Vite</li>
<li>Tailwind CSS</li>
<li>React Hooks</li>
</ul>
<h4 id="백엔드">백엔드</h4>
<ul>
<li>Node.js 20 + Express</li>
<li>Axios: HTTP 클라이언트</li>
<li>Cheerio: HTML 파싱</li>
</ul>
<h4 id="인프라--배포">인프라 &amp; 배포</h4>
<ul>
<li>Vercel: 프론트엔드 호스팅</li>
<li>Railway: 백엔드 호스팅</li>
<li>GitHub Actions: CI/CD 파이프라인</li>
</ul>
<h2 id="1-첫번째-프롬프트-틀-구현">1. 첫번째 프롬프트, 틀 구현</h2>
<p>Cursor의 도움이 컸습니다. 저는 React 경험이 거의 없기 때문입니다. 일단 무작정 기획안을 넣어봤습니다. 내용은 대충 다음과 같았습니다.</p>
<blockquote>
</blockquote>
<h3 id="기획">기획</h3>
<blockquote>
</blockquote>
<p>중고 책을 한 번에 여러 권 구매하고자 할 때, 책마다 배송비가 따로 붙어서 오히려 새 책을 사는 것이나 별 다를 바 없는 가격이 되는 경우가 있다. 만약에 내가 중고로 사고자 하는 책이 같은 셀러로부터 합리적인 가격에 제공되고 있는 것을 찾을 수 있다면 배송비를 아낄 수 있을 것이다.</p>
<blockquote>
</blockquote>
<p>내가 중고로 사고자 하는 책을 리스트업한다. 각 책의 최저가만 골라서 사는 방법, 내가 사고자 하는 책 중 두 권 이상의 책을 판매하고 있는 셀러에게 한꺼번에 사는 방법 등 여러 가지 방법을 고려하여 가장 싸게 살 수 있는 조합을 알려준다.</p>
<blockquote>
</blockquote>
<h3 id="형태">형태</h3>
<blockquote>
</blockquote>
<ul>
<li>웹사이트: React + typescript + 백엔드? 어떻게 구성할 지 모르겠어</li>
<li>알라딘 Open API 사용. 아래 링크 참고해줘<ul>
<li>Open API 매뉴얼: <a href="https://blog.aladin.co.kr/openapi">https://blog.aladin.co.kr/openapi</a></li>
<li>Open API Doc: <a href="https://docs.google.com/document/d/1mX-WxuoGs8Hy-QalhHcvuV17n50uGI2Sg_GHofgiePE/edit?pli=1&amp;tab=t.0">https://docs.google.com/document/d/1mX-WxuoGs8Hy-QalhHcvuV17n50uGI2Sg_GHofgiePE/edit?pli=1&amp;tab=t.0</a></li>
<li>API key 발급: <a href="https://www.aladin.co.kr/ttb/wblog_manage.aspx">https://www.aladin.co.kr/ttb/wblog_manage.aspx</a><blockquote>
</blockquote>
<h3 id="유저-플로우">유저 플로우</h3>
<blockquote>
</blockquote>
<h4 id="구매하고자-하는-중고책-리스트-만들기">구매하고자 하는 중고책 리스트 만들기</h4>
</li>
</ul>
</li>
</ul>
<ol>
<li>검색 API를 사용하여 중고 책을 검색 → 검색 결과 노출</li>
<li>검색 결과 중 내가 사려는 중고 책을 선택</li>
<li>중고 책 리스트에 선택한 중고 책이 등록됨</li>
<li>1.-3.를 반복<blockquote>
</blockquote>
<h4 id="가장-싸게-살-수-있는-조합-찾기">가장 싸게 살 수 있는 조합 찾기</h4>
<blockquote>
</blockquote>
</li>
<li>‘최저가 조합 찾기’ 버튼 클릭</li>
<li><strong>책마다 각각 가격 + 배송비 합이 가장 저렴한 매물로 조합하기</strong></li>
<li>‘2.’의 책값 + 배송비 총합보다 저렴한 모든 매물 조합 찾기</li>
<li>‘3.’의 리스트 중 셀러가 같은 곳이 있다면 배송비 제외하기</li>
<li>‘4.’의 리스트 중 ‘2.’보다 싼 곳이 있는지 비교<blockquote>
</blockquote>
</li>
</ol>
<p>Cursor는 생각보다 저돌적입니다. 진행할까요? 묻지 않고 그냥 해버립니다. 프롬프트를 넣으니 1분도 안 걸려서 웹사이트를 뚝딱 완성합니다. </p>
<p>돌려보니 에러가 있었습니다. localhost:5173에 접속해도 빈 화면만 나왔습니다. 일단 에러 메세지를 던져줬더니 알아서 문제 해결을 위해 필요한 태스크를 정의하고 수행합니다. 마지막으로는 제대로 수정이 되었는지 스스로 테스트까지 합니다.
<img src="https://velog.velcdn.com/images/jaeiklee-dev/post/3f9f6e1e-dc9e-4dbb-ae61-2d99a02cd917/image.png" alt=""></p>
<p>머지 않아 다음과 같은 화면이 나타났습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/a195bc24-a477-4a51-acdb-18cdcfdd780a/image.png" alt=""></p>
<h2 id="2-알라딘-open-api-연결">2. 알라딘 Open API 연결</h2>
<p>제가 첨부한 링크를 읽었는지, 이미 알라딘 책 정보를 가져오는 것까지 거의 정확하게 구현이 돼 있었습니다.</p>
<pre><code class="language-typescript">/* aladinApiService.ts */

import dotenv from &#39;dotenv&#39;;
import axios from &#39;axios&#39;;
import { Book } from &#39;../types&#39;;

// Load environment variables
dotenv.config();
const ALADIN_API_URL = process.env.ALADIN_API_URL || &#39;https://www.aladin.co.kr/ttb/api&#39;;
const ALADIN_API_KEY = process.env.ALADIN_API_KEY;

async searchBooks(query: string, page: number = 1, maxResults: number = 20): Promise&lt;Book[]&gt; {
    const response = await axios.get(`${ALADIN_API_URL}/ItemSearch.aspx`, {
        params: {
            ttbkey: this.apiKey,
            Query: query,
            QueryType: &#39;Title&#39;,
            MaxResults: maxResults,
            start: (page - 1) * maxResults + 1,
            SearchTarget: &#39;Book&#39;,
            output: &#39;js&#39;,
            Version: &#39;20131101&#39;
        }
    });

    const data = response.data;

    const books = data.item.map((item: any) =&gt; ({
        isbn: item.isbn,
        title: item.title,
        author: item.author,
        publisher: item.publisher,
        pubDate: item.pubDate,
        cover: item.cover,
        price: parseInt(item.priceStandard),
        discount: parseInt(item.priceSales),
        description: item.description,
        link: item.link
    }));

    // Error 처리

    return books;

}

async getBookDetails(isbn: string): Promise&lt;any&gt; {
    const response = await axios.get(`${ALADIN_API_URL}/ItemLookUp.aspx`, {
        params: {
            ttbkey: this.apiKey,
            itemIdType: &#39;ISBN&#39;,
            ItemId: isbn,
            output: &#39;js&#39;,
            Version: &#39;20131101&#39;,
            OptResult: &#39;usedList&#39;
        }
    });

    // Error 처리

    return response.data;
}</code></pre>
<p>제가 할 일이라고는 <a href="https://www.aladin.co.kr/login/wlogin.aspx?returnurl=https%3a%2f%2fwww.aladin.co.kr%2fttb%2fwblog_manage.aspx">알라딘 Open API</a>에 로그인하여 key를 발급하고, 이를 환경변수에 적용시키는 것뿐이었습니다. 아직 URL이 없어서 일단 제 블로그 주소를 넣어주었습니다. &#39;추가&#39;를 누르면 바로 Open API 인증키가 생성됩니다. </p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/bb8ae761-a110-4981-a106-e3bf111d0f24/image.png" alt=""></p>
<p>실제 알라딘 API 키를 넣어주고 나니 책 검색이 제대로 되기 시작합니다. </p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/9828672d-5dd6-4309-8253-0a7dac9d1a8d/image.png" alt=""></p>
<p>실제 알라딘 검색 결과는 다음과 같았습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/7f7eef32-f203-47fa-a20f-af224669de6f/image.png" alt=""></p>
<p>차이가 조금 있는데, API 호출 기본값이 국내 도서만 가져오게 되어있어서 그렇습니다. API 호출 시 &#39;SearchTarget&#39; 파라미터를 설정하여 바꿀 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/9202a56a-1761-4462-974b-39346e537c6b/image.png" alt=""></p>
<h2 id="3-각-책의-중고-매물-정보-가져오기">3. 각 책의 중고 매물 정보 가져오기</h2>
<p>책을 위시리스트에 등록하면 해당 책의 중고 매물 리스트를 가져오게 하고 싶었습니다. 문제는 <a href="https://docs.google.com/document/d/1mX-WxuoGs8Hy-QalhHcvuV17n50uGI2Sg_GHofgiePE/edit?pli=1&amp;tab=t.0">알라딘 상품 조회 API 응답</a>에는 <strong>해당책의 개별 중고 매물 정보가 없다</strong>는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/84e8b42f-8537-40ec-94ba-d28107460b01/image.png" alt=""></p>
<p>대신 알라딘 직접배송, 이 광활한 우주점, 개인 셀러 각각의 중고 <strong>매물의 수와 페이지 링크를 제공</strong>합니다. 매물 리스트 페이지를 puppeteer, cheerio로 <strong>크롤링하여 중고 매물을 가져오게</strong> 했습니다.</p>
<pre><code class="language-typescript">// 알라딘 중고책 크롤링 핵심 코드
import puppeteer from &#39;puppeteer&#39;;
import * as cheerio from &#39;cheerio&#39;;

interface UsedBook {
    price: number;
    shippingCost: number;
    seller: string;
    condition: string;
    originalPrice: number;
}

async function crawlUsedBooks(url: string): Promise&lt;UsedBook[]&gt; {
    // 1. 브라우저 실행
    const browser = await puppeteer.launch({
        headless: true,
        args: [&#39;--no-sandbox&#39;, &#39;--disable-setuid-sandbox&#39;]
    });

    try {
        const page = await browser.newPage();

        // 2. User-Agent 설정 (봇 차단 방지)
        await page.setUserAgent(&#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36&#39;);

        // 3. 페이지 로드 및 대기
        await page.goto(url, { waitUntil: &#39;networkidle2&#39;, timeout: 30000 });
        await page.waitForNetworkIdle({ timeout: 3000, idleTime: 1000 });

        // 4. HTML 내용 가져오기
        const content = await page.content();
        const $ = cheerio.load(content);

        const usedBooks: UsedBook[] = [];

        // 5. 중고책 테이블 파싱
        $(&#39;.Ere_usedsell_table tbody tr&#39;).each((index: number, element: cheerio.Element) =&gt; {
            const $row = $(element);

            // 헤더 행 건너뛰기
            if ($row.find(&#39;.sell_tableTF&#39;).length &gt; 0) return;

            // 가격 추출
            const priceElement = $row.find(&#39;.price .Ere_sub_pink span.Ere_fs20&#39;).first();
            const priceText = priceElement.text().replace(/[^0-9]/g, &#39;&#39;);
            const price = parseInt(priceText) || 0;

            // 배송비 추출
            const shippingLi = $row.find(&#39;.price li&#39;).filter(function() {
                return $(this).text().includes(&#39;배송비&#39;);
            }).first();
            const shippingCost = shippingLi.length &gt; 0 
                ? parseInt(shippingLi.text().replace(/[^0-9]/g, &#39;&#39;)) || 2500 
                : 2500;

            // 판매자 정보
            const seller = $row.find(&#39;.seller a&#39;).first().text().trim() || `판매자${index + 1}`;

            // 상태 정보
            const condition = $row.find(&#39;.Ere_sub_top span&#39;).first().text().trim() || &#39;중&#39;;

            if (price &gt; 0) {
                usedBooks.push({
                    price: price + shippingCost,
                    shippingCost: shippingCost,
                    seller: seller,
                    condition: condition,
                    originalPrice: price
                });
            }
        });

        return usedBooks.sort((a, b) =&gt; a.price - b.price);

    } finally {
        await browser.close();
    }
}
</code></pre>
<h2 id="4-최저가-조합-추천-알고리즘">4. 최저가 조합 추천 알고리즘</h2>
<p>서비스의 핵심이 되는 로직입니다. 이 부분에서 아직 AI가 대체하기 어려운 부분이 있음을 느꼈습니다. 오류가 꽤 많아서 직접 짜야 했습니다.</p>
<blockquote>
</blockquote>
<ol>
<li>각 책의 모든 중고책들의 조합을 만든다. 예를 들어 4가지 책이 있고, 각 책의 중고책 수가 3권, 5권, 7권, 10권 이라면 모든 조합의 수는 357*10가지가 된다.</li>
<li>각 조합마다 다음을 수행한다.
a. 조합을 구성하는 책들의 책값을 먼저 더해 책값의 총합을 구한다.
b. 조합을 구성하는 책들의 배송비를 하나씩 더한다. 이때, 먼저 배송비를 더한 책 중 책 중 같은 셀러가 판매하고 있는 책이 있다면 배송비를 더하지 않는다. 즉, 같은 셀러가 판매하는 책들 모두에 대하여 배송비는 한 번만 적용한다.
c. 배송비까지 모두 더하여 총 가격을 산출한다.</li>
<li>2.번을 수행할 때마다 총 가격을 비교하여 최저가 조합을 갱신한다.<blockquote>
</blockquote>
</li>
</ol>
<pre><code class="language-typescript">/**
 * 중고책 구매 최적화 메인 함수
 * 
 * @param wishlist - 최적화할 책 목록
 * @returns 최적 조합과 총 비용
 */
function optimizeBookPurchase(wishlist: WishlistItem[]): {
    totalCost: number;
    combinations: UsedBook[];
    stats: { totalCombinations: number; evaluatedCombinations: number; pruningRate: string };
} {
    // 1. 단순 최저가 조합 계산 (첫 번째 책 선택)
    const simpleMinBooks = wishlist.map(item =&gt; item.usedBooks[0]);

    // 단순 최저가 조합의 배송비 계산 (같은 셀러는 한 번만)
    const sellerShippingCosts = new Map&lt;string, number&gt;();
    simpleMinBooks.forEach(book =&gt; {
        if (!sellerShippingCosts.has(book.seller)) {
            sellerShippingCosts.set(book.seller, book.shippingCost);
        } else {
            const currentShipping = sellerShippingCosts.get(book.seller)!;
            if (book.shippingCost &gt; currentShipping) {
                sellerShippingCosts.set(book.seller, book.shippingCost);
            }
        }
    });
    const simpleMinShippingCost = Array.from(sellerShippingCosts.values())
        .reduce((sum, cost) =&gt; sum + cost, 0);

    console.log(`💰 단순 최저가 조합 총 배송비: ${simpleMinShippingCost}원`);

    // 2. 브루트포스로 최적 조합 탐색
    let bestCombination: UsedBook[] = [];
    let bestTotalCost = Infinity;
    let evaluatedCombinations = 0;

    // 총 조합 수 계산
    const totalCombinations = wishlist.reduce((total, item) =&gt; total * item.usedBooks.length, 1);
    console.log(`🔢 총 가능한 조합 수: ${totalCombinations.toLocaleString()}개`);

    // 재귀 함수로 모든 조합 탐색
    function findOptimalCombination(bookIndex: number, currentCombination: number[]): void {
        // 모든 책을 선택했으면 조합 평가
        if (bookIndex &gt;= wishlist.length) {
            evaluatedCombinations++;

            // 조합에 해당하는 중고책들 선택
            const selectedBooks: UsedBook[] = [];
            for (let i = 0; i &lt; currentCombination.length; i++) {
                selectedBooks.push(wishlist[i].usedBooks[currentCombination[i]]);
            }

            // 총 비용 계산 (책값 + 배송비)
            const totalBookCost = selectedBooks.reduce((sum, book) =&gt; sum + book.price, 0);

            // 배송비 계산 (같은 셀러는 한 번만)
            const sellerShippingCosts = new Map&lt;string, number&gt;();
            selectedBooks.forEach(book =&gt; {
                if (!sellerShippingCosts.has(book.seller)) {
                    sellerShippingCosts.set(book.seller, book.shippingCost);
                } else {
                    const currentShipping = sellerShippingCosts.get(book.seller)!;
                    if (book.shippingCost &gt; currentShipping) {
                        sellerShippingCosts.set(book.seller, book.shippingCost);
                    }
                }
            });
            const totalShippingCost = Array.from(sellerShippingCosts.values())
                .reduce((sum, cost) =&gt; sum + cost, 0);

            const totalCost = totalBookCost + totalShippingCost;

            // 최적해 업데이트
            if (totalCost &lt; bestTotalCost) {
                bestTotalCost = totalCost;
                bestCombination = [...selectedBooks];
            }
            return;
        }

        // 현재 책의 중고책들에 대해 반복
        for (let i = 0; i &lt; wishlist[bookIndex].usedBooks.length; i++) {
            findOptimalCombination(bookIndex + 1, [...currentCombination, i]);
        }
    }

    // 3. 최적화 실행
    findOptimalCombination(0, []);

    // 통계 계산
    console.log(`📊 총 ${evaluatedCombinations.toLocaleString()}개 조합 평가 완료`);

    return {
        totalCost: bestTotalCost,
        combinations: bestCombination,
        stats: {
            totalCombinations,
            evaluatedCombinations,
            pruningRate: &#39;0.00&#39;
        }
    };
}</code></pre>
<p>구현 후 확인한 결과, 위시리스트의 책이 3-4권 정도까지는 거의 즉각 결과가 나오는데, <strong>5권부터는 시간이 꽤 걸렸습니다</strong>. 알고리즘 개선이 필요해 보였습니다. 애초에 최적 조합을 벗어나는 케이스를 연산에서 제외시킴으로써 성능을 개선해보기로 했습니다.</p>
<blockquote>
</blockquote>
<h4 id="변경되는-점">변경되는 점</h4>
<blockquote>
</blockquote>
<ul>
<li>각 책별 중고책 리스트를 오름차순 정렬을 먼저 하여, 단순 최저가 조합을 찾을 때, 각 책의 중고책 리스트의 첫번째 책만 가져와 조합하면 되도록 함.</li>
<li><strong>책값이 이미 배송비 절약을 무마할 정도로 비싸다면 연산에서 제외</strong><blockquote>
</blockquote>
<h4 id="성능-최적화를-위한-가지치기-알고리즘">성능 최적화를 위한 가지치기 알고리즘</h4>
<blockquote>
</blockquote>
</li>
<li>위시리스트에 n가지 종류의 책이 있다 (0 &lt; n)</li>
<li>각 책에는 m(n)개의 중고책이 있다. (0 &lt; m(n))</li>
<li>m(n)개의 중고책을 책 가격 기준 오름차순으로 정렬</li>
<li>단순 최저가 구하기: n가지 책의 책별 중고책 리스트의 첫번째 중고책(책값 가장 저렴)들의 조합의 총 가격(총 책값 + 총 배송비, 같은 셀러 있을 시 배송비 절약 적용)</li>
<li>단순 최저가 조합의 총 배송비 구하기</li>
<li>n번째 책의 0번째 중고책의 책값과 i(0 &lt;= i &lt; m(n))번째의 중고책의 책값의 차이가 단순 최저가 조합의 총 배송비보다 크다면 단순 최저가 조합이 나으므로, iterate을 마친다.<blockquote>
</blockquote>
</li>
</ul>
<p>이를 반영하여 알고리즘을 다음과 같이 개선했습니다.</p>
<pre><code class="language-typescript">function optimizeBookPurchase(wishlist: WishlistItem[]): {
    totalCost: number;
    combinations: UsedBook[];
    stats: { totalCombinations: number; evaluatedCombinations: number; pruningRate: string };
} {
    // 1. 각 책의 중고책을 가격 기준 오름차순 정렬
    const sortedWishlist = wishlist.map(item =&gt; ({
        ...item,
        usedBooks: [...item.usedBooks].sort((a, b) =&gt; a.price - b.price)
    }));

    // 2. 단순 최저가 조합 계산 (가지치기 기준용)
    const simpleMinBooks = sortedWishlist.map(item =&gt; item.usedBooks[0]);

    // 단순 최저가 조합의 배송비 계산 (같은 셀러는 한 번만)
    const sellerShippingCosts = new Map&lt;string, number&gt;();
    simpleMinBooks.forEach(book =&gt; {
        if (!sellerShippingCosts.has(book.seller)) {
            sellerShippingCosts.set(book.seller, book.shippingCost);
        } else {
            const currentShipping = sellerShippingCosts.get(book.seller)!;
            if (book.shippingCost &gt; currentShipping) {
                sellerShippingCosts.set(book.seller, book.shippingCost);
            }
        }
    });
    const simpleMinShippingCost = Array.from(sellerShippingCosts.values())
        .reduce((sum, cost) =&gt; sum + cost, 0);

    console.log(`💰 단순 최저가 조합 총 배송비: ${simpleMinShippingCost}원 (가지치기 기준)`);

    // 3. 브루트포스 + 가지치기로 최적 조합 탐색
    let bestCombination: UsedBook[] = [];
    let bestTotalCost = Infinity;
    let evaluatedCombinations = 0;

    // 총 조합 수 계산
    const totalCombinations = sortedWishlist.reduce((total, item) =&gt; total * item.usedBooks.length, 1);
    console.log(`🔢 총 가능한 조합 수: ${totalCombinations.toLocaleString()}개`);

    // 재귀 함수로 모든 조합 탐색
    function findOptimalCombination(bookIndex: number, currentCombination: number[]): void {
        // 모든 책을 선택했으면 조합 평가
        if (bookIndex &gt;= sortedWishlist.length) {
            evaluatedCombinations++;

            // 조합에 해당하는 중고책들 선택
            const selectedBooks: UsedBook[] = [];
            for (let i = 0; i &lt; currentCombination.length; i++) {
                selectedBooks.push(sortedWishlist[i].usedBooks[currentCombination[i]]);
            }

            // 총 비용 계산 (책값 + 배송비)
            const totalBookCost = selectedBooks.reduce((sum, book) =&gt; sum + book.price, 0);

            // 배송비 계산 (같은 셀러는 한 번만)
            const sellerShippingCosts = new Map&lt;string, number&gt;();
            selectedBooks.forEach(book =&gt; {
                if (!sellerShippingCosts.has(book.seller)) {
                    sellerShippingCosts.set(book.seller, book.shippingCost);
                } else {
                    const currentShipping = sellerShippingCosts.get(book.seller)!;
                    if (book.shippingCost &gt; currentShipping) {
                        sellerShippingCosts.set(book.seller, book.shippingCost);
                    }
                }
            });
            const totalShippingCost = Array.from(sellerShippingCosts.values())
                .reduce((sum, cost) =&gt; sum + cost, 0);

            const totalCost = totalBookCost + totalShippingCost;

            // 최적해 업데이트
            if (totalCost &lt; bestTotalCost) {
                bestTotalCost = totalCost;
                bestCombination = [...selectedBooks];
            }
            return;
        }

        // 현재 책의 중고책들에 대해 반복
        for (let i = 0; i &lt; sortedWishlist[bookIndex].usedBooks.length; i++) {
            // 가지치기 조건: 가격 차이가 배송비 절약 효과보다 크면 건너뛰기
            if (i &gt; 0) {
                const currentBook = sortedWishlist[bookIndex];
                const priceDifference = currentBook.usedBooks[i].price - currentBook.usedBooks[0].price;
                if (priceDifference &gt; simpleMinShippingCost) {
                    break; // 더 이상 탐색할 필요 없음
                }
            }

            findOptimalCombination(bookIndex + 1, [...currentCombination, i]);
        }
    }

    // 최적화 실행
    findOptimalCombination(0, []);

    // 가지치기 통계 계산
    const skippedCombinations = totalCombinations - evaluatedCombinations;
    const pruningRate = totalCombinations &gt; 0 ? (skippedCombinations / totalCombinations * 100).toFixed(2) : &#39;0.00&#39;;

    console.log(`✂️ 가지치기 통계: ${evaluatedCombinations.toLocaleString()}개 조합 평가, ${skippedCombinations.toLocaleString()}개 건너뛰기 (${pruningRate}%)`);

    return {
        totalCost: bestTotalCost,
        combinations: bestCombination,
        stats: {
            totalCombinations,
            evaluatedCombinations,
            pruningRate
        }
    };
}</code></pre>
<p>확인 결과, 경우에 따라 <strong>최대 20% 정도 연산량을 줄일 수</strong> 있었습니다. 아무래도 권수가 많아질수록, 즉 개별 책의 배송비 합이 커질수록 많이 비싼 책만 가지치기 될 것입니다. 이 방법은 추가 개선이 필요해 보입니다.</p>
<h2 id="5-크롤링으로-인한-리소스-부족-문제">5. 크롤링으로 인한 리소스 부족 문제</h2>
<p>로컬에서 테스트할 때는 성공적이었습니다. 속도도 나쁘지 않게 나와주었습니다. GitHub Actions로 CI/CD, AWS EC2 t3.micro에 자동 배포까지 세팅 후에도, 처음 테스트할 때는 양호한 성능을 보여주었습니다. 하지만 얼마 지나지 않아 심각한 문제가 발생했습니다.</p>
<p><strong>크롤링 속도가 갑자기 심각하게 느려졌습니다.</strong> 심지어는 EC2 서버가 뻗어서 계속 reboot을 해야했습니다.</p>
<p>처음에는 코드 문제인 줄 알았는데, 알고 보니 EC2의 크레딧 정책 때문이었습니다. t3.micro 인스턴스는 CPU 크레딧을 소모하는 방식이라, 지속적인 크롤링 작업으로 인해 크레딧이 부족해져 성능이 급격히 저하된 것이었습니다. 크롤링이 이렇게 리소스를 많이 사용하는 작업인지도 몰랐습니다.</p>
<h4 id="1-cpu-크레딧-구조">1. CPU 크레딧 구조</h4>
<ul>
<li><strong>t3.micro는 burstable 인스턴스</strong>라서 CPU를 자유롭게 무한정 쓰는 게 아니라, <strong>CPU 크레딧</strong>을 기반으로 동작합니다.</li>
<li>t3.micro는 <strong>시간당 12 CPU 크레딧</strong>을 얻습니다. <strong>1 CPU 크레딧 = vCPU 100% 사용 1분</strong>입니다.</li>
<li>즉, 한 시간 동안 쌓이는 크레딧으로는 <strong>약 12분 정도 full CPU 사용</strong>만 가능합니다.</li>
<li>baseline 성능은 <strong>약 20% CPU</strong>에 불과해서, 크레딧이 다 떨어지면 사실상 <strong>느려터진 서버</strong>가 됩니다.</li>
</ul>
<h4 id="2-puppeteer의-자원-소모">2. Puppeteer의 자원 소모</h4>
<ul>
<li>Puppeteer는 Chromium을 띄우기 때문에 실행만 해도 <strong>수백 MB 메모리</strong>를 잡아먹습니다.</li>
<li>페이지 로딩이나 JS 렌더링 과정에서 CPU 사용률이 <strong>거의 100%</strong>에 근접합니다.</li>
<li>즉, <strong>t3.micro는 몇 분 돌리면 CPU 크레딧이 고갈 → 이후에는 baseline(20% CPU)에서만 실행 → 극심하게 느려집니다.</strong></li>
</ul>
<h4 id="3-메모리-한계">3. 메모리 한계</h4>
<ul>
<li>t3.micro의 메모리는 <strong>1GiB</strong>뿐입니다.</li>
<li>Puppeteer가 Chromium 띄우는 순간 메모리의 절반 이상이 날아갑니다.</li>
<li>동시에 여러 페이지를 크롤링하거나, 조금만 무거운 사이트를 다루면 <strong>OOM(Out Of Memory)</strong> 로 크래시가 나기 쉽습니다.</li>
</ul>
<h4 id="4-결과적으로">4. 결과적으로</h4>
<ul>
<li>t3.micro에 <strong>웹 애플리케이션 서버</strong>(Express, Flask 같은 가벼운 서버) 자체만 돌리는 건 가능합니다.</li>
<li>하지만 <strong>Puppeteer 같은 브라우저 기반 크롤러를 동시에 실행</strong>하면:<ol>
<li>CPU 크레딧 소진 → baseline 제한으로 성능 급락</li>
<li>메모리 부족 → 크롤러 프로세스 죽음</li>
<li>웹 서버까지 영향 받아 응답 속도 저하</li>
</ol>
</li>
</ul>
<h2 id="6-ec2-→-railway-puppeteer-→-axios">6. EC2 → Railway, Puppeteer → Axios</h2>
<p>EC2의 한계를 깨달은 후, 더 적합한 서비스로 이전하기로 했습니다:</p>
<ul>
<li><strong>프론트엔드</strong>: Vercel (무료 티어)</li>
<li><strong>백엔드</strong>: Railway (자동 스케일링)</li>
</ul>
<h3 id="ec2-vs-vercel--railway">EC2 vs Vercel + Railway</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>EC2</th>
<th>Vercel + Railway</th>
</tr>
</thead>
<tbody><tr>
<td><strong>크롤링 요청 처리</strong></td>
<td>고정 리소스로 처리 → 리소스 부족 시 서버 크래시</td>
<td>동적 리소스 할당 → 자동 확장으로 안정적 처리</td>
</tr>
<tr>
<td><strong>동시 요청 대응</strong></td>
<td>여러 크롤링 요청 시 리소스 경합 → 서버 다운</td>
<td>각 요청을 독립적으로 처리 → 서버 안정성 유지</td>
</tr>
<tr>
<td><strong>장애 복구</strong></td>
<td>서버 다운 시 수동 재시작 필요</td>
<td>자동으로 새 인스턴스 생성</td>
</tr>
</tbody></table>
<h4 id="ec2의-한계">EC2의 한계:</h4>
<ul>
<li>고정된 리소스로는 트래픽 급증에 대응 불가</li>
<li>수동 관리로 인한 복구 지연</li>
</ul>
<h4 id="vercel--railway">Vercel + Railway</h4>
<ul>
<li>자동으로 리소스 확장</li>
<li>자동 복구로 서비스 중단 최소화</li>
</ul>
<h3 id="puppeteer-vs-axios">Puppeteer vs Axios</h3>
<p>동시에 Puppeteer의 무거움도 문제였습니다. 메모리 사용량이 많고, 서버리스 환경에서는 Cold Start 시간이 길어집니다. 그래서 <strong>Axios + Cheerio</strong>로 변경했습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Puppeteer</th>
<th>Axios</th>
</tr>
</thead>
<tbody><tr>
<td><strong>브라우저</strong></td>
<td>실제 브라우저 실행</td>
<td>HTTP 요청만</td>
</tr>
<tr>
<td><strong>JavaScript</strong></td>
<td>동적 콘텐츠 처리 가능</td>
<td>정적 HTML만</td>
</tr>
<tr>
<td><strong>성능</strong></td>
<td>느림 (브라우저 오버헤드)</td>
<td>빠름</td>
</tr>
<tr>
<td><strong>메모리</strong></td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td><strong>복잡도</strong></td>
<td>높음</td>
<td>낮음</td>
</tr>
</tbody></table>
<h4 id="axios-방식의-장점">Axios 방식의 장점:</h4>
<ul>
<li>빠른 속도 - 브라우저 없이 HTTP 요청만</li>
<li>낮은 리소스 사용량 - 메모리 효율적</li>
<li>간단한 구조 - 단순한 HTTP 요청/응답</li>
</ul>
<h4 id="axios-방식의-한계">Axios 방식의 한계:</h4>
<ul>
<li>JavaScript 렌더링 불가 - 동적 콘텐츠 크롤링 어려움</li>
<li>봇 차단에 취약 - User-Agent만으로는 한계</li>
<li>복잡한 인증 처리 어려움</li>
</ul>
<p>알라딘의 <strong>중고책 페이지는 대부분 정적 HTML로 구성되어 있어서 Axios 방식으로도 충분</strong>하다고 봤습니다.</p>
<h4 id="성능-개선-결과">성능 개선 결과</h4>
<ul>
<li>메모리 사용량: 1GB → 100MB</li>
<li>응답 속도: 20-30초 → 3-5초</li>
<li>Cold Start 시간: 10-15초 → 1-2초</li>
</ul>
<h2 id="cursor와의-협업-전략">Cursor와의 협업 전략</h2>
<h3 id="점점-따라가기-어려워지는-코드">점점 따라가기 어려워지는 코드</h3>
<p>개발이 진행될수록 Cursor가 생성하는 코드가 복잡해지고, 수정사항이 많아지기 시작했습니다:</p>
<ul>
<li><strong>코드 이해도 저하</strong>: Cursor가 만든 코드를 완전히 이해하지 못하는 경우 발생</li>
<li><strong>유지보수 어려움</strong>: 수정이 필요할 때 어디를 건드려야 할지 모르겠음</li>
<li><strong>버그 추적 어려움</strong>: 에러 발생 시 원인을 찾기 어려움</li>
</ul>
<h3 id="해결-전략-내-컨트롤-안에-두기">해결 전략: 내 컨트롤 안에 두기</h3>
<h4 id="1-모든-수정사항-코드리뷰하기">1. 모든 수정사항 코드리뷰하기</h4>
<p>Cursor가 제안하는 모든 코드를 무조건 수락하지 않고, <strong>반드시 검토가 필요하다</strong>는 생각이 들었습니다. 특히 최저가 조합 찾기 등 조금만 알고리즘이 복잡해져도 간간히 오류를 만들곤 했습니다. 간단한 UI 수정 정도는 큰 문제가 아닙니다. 복잡한 알고리즘을 온전히 Cursor에게 맡기면, 점점 유지보수 불가능한 코드가 될 것입니다.</p>
<h4 id="2-한-번에-하나의-기능씩만-커밋하기">2. 한 번에 하나의 기능씩만 커밋하기</h4>
<p>Cursor는 말하면 &#39;뚝딱&#39; 해버립니다. 한 번에 변경된 파일이 수십개가 생기기도 합니다. <strong>코드가 내 컨트롤을 벗어나기 시작</strong>합니다. 변경사항과 이유를 명확히 알 수 있는 단위로 작업하고 커밋하듯이, Cursor에게 일을 시킬 때도, <strong>이해하고 컨트롤할 수 있는 단위로 작업을 시키고, 동시에 커밋을 남겨가면서 진행</strong>하는 게 좋습니다.</p>
<h4 id="3-수시로-리팩토링하기">3. 수시로 리팩토링하기</h4>
<p>Cursor가 생성한 초기 코드는 보통 하나의 큰 컴포넌트에 모든 로직이 들어있었습니다. 이를 작은 컴포넌트로 나누고, 커스텀 훅으로 로직을 분리하는 작업을 매번 했습니다.</p>
<h3 id="결과">결과</h3>
<p>7일이라는 짧은 시간에 완전한 웹서비스를 만들 수 있었습니다. 웬만한 부분은 생각 없이 에러메세지만 복붙해도 해결이 가능했습니다. </p>
<p>하지만 어느 지점을 넘어서 서비스가 커지기 시작하면 Cursor가 일관성을 잃기 시작하는 지점이 오는 것을 느꼈습니다. 점점 성능이 올라가겠지만, 현재로서는 개발자 스스로의 코드에 대한 이해 없이 Cursor만으로 개발하는 것은 한계가 있어 보입니다. </p>
<p>기술적 깊이가 필요한 부분이나 성능 최적화는 여전히 개발자의 경험과 판단이 필요했습니다. 복잡한 최적화 알고리즘은 여전히 직접 구현해야 합니다. Puppeteer vs Cheerio, EC2 vs Railway 같은 기술 선택은 개발자의 경험이 필요합니다. 경험이 있었더라면 애초에 Puppeteer를 선택하지 않았을 것입니다.</p>
<p>보통 AI를 주니어급 개발자에 비유하지만, 이번에 개발하면서 <strong>종종 시니어를 데리고 개발하는 기분이 들기도 했습니다.</strong> 로드맵을 짜주고, Cursor가 짠 코드 중 이해 안 가는 부분에 대해 설명을 부탁하기도 했습니다. </p>
<p>처음으로 AI를 써서 서비스 하나를 완성해봤습니다. 전에는 AI가 주니어 개발자인 나의 자리를 대체하는 건 아닐까 생각했습니다. 지금은 <strong>Cursor 덕분에 내가 훨씬 빠르게 성장할 수 있겠다</strong>는 생각이 듭니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[카페24 커스텀 자사몰 개발기: ② 카페24 App + GCP로 관리자 데이터 가져오기]]></title>
            <link>https://velog.io/@jaeiklee-dev/cafe24-custom-project-2-cafe24app-gcp</link>
            <guid>https://velog.io/@jaeiklee-dev/cafe24-custom-project-2-cafe24app-gcp</guid>
            <pubDate>Thu, 18 Sep 2025 14:03:45 GMT</pubDate>
            <description><![CDATA[<p>이전 글: <a href="https://velog.io/@jaeiklee-dev/cafe24-custom-project-1-select-solution">카페24 커스텀 자사몰 개발기: ① 쇼핑몰 솔루션 선택하기</a></p>
<h2 id="🎯-프로젝트-소개">🎯 프로젝트 소개</h2>
<p>카페24를 커스텀하여 자사몰을 구축하는 외주 프로젝트를 진행했습니다. 고객 요구사항 중 카페24에서 지원하지 않는 기능이 있어 별도로 개발을 진행했습니다. 프로젝트 기간은 2022년 11월 ~ 2023년 4월입니다. 본 글은 당시 노트를 기반으로 작성했습니다.</p>
<ul>
<li>현재 운영 중인 서비스라 서비스명은 밝히지 않았습니다.</li>
</ul>
<h3 id="요구사항">요구사항</h3>
<ul>
<li>카페24 쇼핑몰 메인 화면에 누적 판매량 노출</li>
<li>관리자 데이터(Admin API) 기반으로 일관된 수치 제공</li>
<li>운영자 개입 없이 안정적 갱신</li>
</ul>
<h3 id="제약">제약</h3>
<ul>
<li>Admin API는 브라우저에서 직접 호출 불가</li>
<li>카페24 APP을 통해 권한 위임 필요</li>
</ul>
<h2 id="아키텍처-개요">아키텍처 개요</h2>
<h3 id="구성요소">구성요소</h3>
<ul>
<li>카페24 App(백엔드 권한 주체)</li>
<li>GCP 백엔드(API 프록시, 토큰 및 데이터 저장)</li>
<li>카페24 프론트 스킨</li>
</ul>
<h3 id="데이터-흐름요약">데이터 흐름(요약)</h3>
<ol>
<li>사용자가 메인 페이지 접속</li>
<li>프론트 스크립트가 Cloud Functions 호출</li>
<li>Cloud Functions는 저장된 액세스 토큰으로 카페24 Admin API 호출</li>
<li>카페24 API 서버에서 Token 검증 및 요청에 대한 데이터 반환</li>
<li>Cloud Functions가 프론트에 데이터 전달 → 숫자 표시</li>
</ol>
<h2 id="개발-과정-요약">개발 과정 요약</h2>
<ol>
<li>카페24 Developers 계정 생성</li>
<li>카페24 Developers에 App 생성, 등록</li>
<li>App을 돌릴 백엔드 설계 → GCP로 구성</li>
<li>GCP로 구성한 백엔드<ol>
<li>Cloud Functions: 판매량 가져오기</li>
<li>Cloud Scheduler, Pub/Sub: 주기적으로 토큰 갱신 트리거</li>
<li>Firestore: 분기별 판매량 데이터 및 토큰값 저장</li>
</ol>
</li>
<li>카페24 프론트엔드 코드 수정하여 누적 판매량 수치 표시</li>
</ol>
<h2 id="1-카페24-app-생성">1. 카페24 App 생성</h2>
<p><strong>카페24 관리자 데이터 필요로 하는 기능을 추가하려면 ‘카페24 APP’을 통해야</strong> 합니다. 카페24에 설치할 수 있는 일종의 플러그인입니다. 카페24 Admin API를 호출하는 백엔드는 직접 개발해야 합니다. 카페24 APP의 역할은 그 백엔드 API가 카페24 Admin API를 호출할 수 있도록 권한을 위임하는 것입니다. </p>
<p>제가 필요로 하는 상품 판매량은 관리자 데이터이기 때문에 <a href="https://developers.cafe24.com/admin/dashboard/main/front/app">카페24 developers</a>에서 APP을 생성해 주었습니다. </p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/087d2341-f663-491e-9e30-b763419df2fb/image.png" alt=""></p>
<p>실제 프론트에서 호출하는 것은 카페24 Admin API를 대리 호출할 백엔드입니다. Admin API는 프론트에서 직접 호출이 불가능하기 때문입니다. 저는 GCP의 Cloud Functions로 백엔드를 구축했습니다.</p>
<ul>
<li>APP URL: https://&lt;Cloud Functions 도메인&gt; - 앱 최초 실행시 호출될 URL</li>
<li>Redirect URI: https://&lt;Cloud Functions 도메인&gt;/redirect</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/8faff766-6bc3-45d4-8eb8-442d1c8b1de7/image.png" alt=""></p>
<p>판매량 정보는 <a href="https://developers.cafe24.com/docs/ko/api/admin/#retrieve-a-list-of-orders">‘주문’ API</a>에서 찾을 수 있었습니다. 읽기만 하면 되기 때문에 ‘주문’ API에만 Read 권한을 부여했습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/275ea6e6-e026-45bc-9dbb-f7466db53b6f/image.png" alt=""></p>
<h2 id="2-gcp-백엔드-설계">2. GCP 백엔드 설계</h2>
<p>이 프로젝트를 처음 맡았을 때, 웹 경험이 전무한 상태였습니다. 웹의 원리, 백엔드 개념 자체가 없었습니다. 처음에는 카페24 웹호스팅이라는 제품을 써서 PHP로 개발하려고 했습니다. 일주일을 삽질한 끝에, 웹애플리케이션을 돌릴 수 있는 제품이 아니라는 것을 깨달았습니다. 두 번째 시도한 것은 카페24 Node.js 서버입니다. 어떻게든 카페24 제품 안에서 모든 것을 해결하고자 했습니다. 이 제품도 문제가 있었습니다. SSL 적용이 불가능했습니다. </p>
<p>결국 찾아낸 것이 GCP, AWS였습니다. AWS를 더 많이 사용하지만, GCP가 압도적으로 문서화가 잘돼 있고 인터페이스도 직관적이라 접근성이 좋았습니다. 무료 구간도 두 배나 넉넉했습니다. 결국 GCP로 백엔드를 구성하기로 했습니다.</p>
<h3 id="제약-사항">제약 사항</h3>
<h4 id="카페24-admin-api-액세스-토큰-유효-시간">카페24 Admin API 액세스 토큰 유효 시간</h4>
<p>카페24 Admin API를 호출하려면 액세스 토큰이 필요합니다. 액세스 토큰을 발급하려면 인증 코드가 필요합니다. 인증코드는 1분간 유효합니다. 액세스 토큰은 2시간 동안 유효합니다. 주기적으로 재발급이 필요합니다. 액세스 토큰이 발급될 때, 재발급에 사용하는 리프레시 토큰이 함께 제공됩니다. 리프레시 토큰은 14일간 유효하며, 한 번 사용하면 폐기됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/c03ce9eb-2970-4e9d-a9a8-f5dff6b6e9f8/image.png" alt=""></p>
<h4 id="order-api-검색-기간">order API 검색 기간</h4>
<p>카페24 Admin의 order API는 파라미터로 start_date, end_date을 넣게 되어있는데, 최대 설정 가능 기간이 3개월이었습니다. 누적 판매량이 필요했기 때문에 분기가 넘어갈 때마다 분기별 판매량을 Firestore에 저장하도록 했습니다.</p>
<h3 id="백엔드에-필요한-기능-구성">백엔드에 필요한 기능 구성</h3>
<h4 id="인증">인증</h4>
<ol>
<li>인증 코드 발급: Cloud Functions</li>
<li>인증 코드 → 액세스 토큰 발급: Cloud Functions</li>
<li>발급받은 액세스 토큰, 리프레시 토큰 저장: Firestore</li>
<li>리프레시 토큰 사용하여 주기적으로 액세스 토큰 재발급: Cloud Scheduler, Pub/Sub, Cloud Functions</li>
</ol>
<h4 id="카페24-admin-api-호출">카페24 Admin API 호출</h4>
<ol>
<li>분기가 넘어갈 때마다 분기별 판매량 저장하기: Cloud Sheduler, Pub/Sub, Cloud Functions, Firestore</li>
<li>현재 분기의 판매량 가져오고 이전 분기의 판매량과 더해 누적 판매량 계산하기: Cloud Functions</li>
</ol>
<h3 id="cloud-functions">Cloud Functions</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/bd33244d-a298-4bdf-8f42-20d8cd6917b0/image.png" alt=""></p>
<ul>
<li>get-sales-count: 액세스 토큰을 사용하여 카페24 Admin API를 호출하는 함수. http 트리거</li>
<li>update-prev-sales: 주기적으로 분기별 판매량을 Firestore에 저장하는 함수. Cloud Pub/Sub 트리거</li>
<li>update-token: 주기적으로 액세스 토큰을 재발급하여 Firestore에 저장하는 함수. Cloud Pub/Sub 트리거</li>
</ul>
<h3 id="cloud-pubsub">Cloud Pub/Sub</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/cbbd89b2-69c9-408a-92ee-4455f84e1333/image.png" alt=""></p>
<h3 id="cloud-scheduler">Cloud Scheduler</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/7f98e84a-96d6-4d68-9ed0-f639337100a2/image.png" alt=""></p>
<ul>
<li>update_prev_sales: 매월 1일에 Cloud Pub/Sub이 update-prev-sales 함수를 트리거하도록 하는 스케줄러</li>
<li>update_token: 매시 5분에 Cloud Pub/Sub이 update-token 함수를 트리거하도록 하는 스케줄러</li>
</ul>
<h3 id="firestore">Firestore</h3>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/8ce7a40e-2270-43e1-b9ed-28ac5e015543/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/61ae6f16-ffa4-431c-9ae6-a8b735c9f92c/image.png" alt=""></p>
<ul>
<li>sales: 분기별 판매량을 저장</li>
<li>tokens: 액세스 토큰과 리프레시 토큰을 저장</li>
</ul>
<h2 id="3-액세스-토큰-관리">3. 액세스 토큰 관리</h2>
<p>액세스 토큰을 사용하여 카페24 API에 접근하는 과정은 다음과 같습니다. ‘Resource Owner’가 쇼핑몰 프론트엔드이고, ‘Client’가 카페24 APP(백엔드)입니다</p>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/3e525951-7e05-46ac-8a8b-08a3356da5f9/image.png" alt=""></p>
<p>인증 코드는 앱 최초 실행 시 발급됩니다. ‘앱 최초 실행’이란 쇼핑몰에 앱을 설치하는 것을 말합니다. 직접 제작한 앱을 내 쇼핑몰에 설치하는 것은 카페24 Developers &gt; Apps &gt; 해당 앱의 ‘개발 정보’에서 ‘테스트 실행’ 으로 가능합니다.</p>
<h3 id="인증-코드-발급-cloud-functions-get-sales">인증 코드 발급: Cloud Functions get-sales</h3>
<p>‘개발 정보’의 ‘APP URL’에 입력한 URL입니다. 앱 최초 실행시 호출됩니다.</p>
<pre><code class="language-jsx">app.get(&#39;/&#39;, (req, res) =&gt; {
    const hmac = req.query.hmac;
    const url = req.url;
    var query = url.split(&#39;?&#39;)[1];
    if (query == null) {
        console.log(&quot;query is null&quot;);
        res.status(401).send(&#39;Abnormal access&#39;);
        return;
    }
    const queryWithoutHmac = query.substring(0, query.lastIndexOf(&#39;&amp;&#39;));

    // Create the hash value
    const hash = crypto.createHmac(&#39;sha256&#39;, appClientSecretKey).update(queryWithoutHmac).digest(&#39;base64&#39;);
    const pass = (hmac === hash);

    if (pass) {
        res.writeHead(301, {
            Location: `https://${mallId}.cafe24api.com/api/v2/oauth/authorize?\
            response_type=code&amp;client_id=${appClientId}&amp;state=${mallId}${appClientId}&amp;\
            redirect_uri=${redirectUri}&amp;scope=${scope}`
        });
    }
    else {
        res.status(401).send(&#39;Authentication Failed by HMAC verification&#39;);
    }
    res.end();
});</code></pre>
<p>client ID, secret key는 <a href="https://developers.cafe24.com/admin/dashboard/main/front/app">카페24 Developers &gt; 앱</a> &gt; ‘개발 정보’에서 확인하실 수 있습니다. client ID, secret key, scope(권한 설정), 등이 일치하면 인증 코드가 발급되어 앱의 ‘개발 정보’에 입력한 ‘redirect URL’로 반환됩니다.  </p>
<h3 id="토큰-발급-cloud-functions-get-salesredirect">토큰 발급: Cloud Functions get-sales/redirect</h3>
<p>인증 코드로 액세스 토큰을 발급합니다. 발급받은 액세스 토큰과 리프레시 토큰을 Firestore에 저장합니다.</p>
<pre><code class="language-jsx">app.get(&#39;/redirect&#39;, (req, res) =&gt; {
    if (req.query.state === `${mallId}${appClientId}`) {
        issueToken(req.query.code, res);
    }
    else {
        res.send(&#39;Error: State code not match. It may have been modified by other&#39;);
    }
});

async function issueToken(authCode, res) {
    await requestToken(`grant_type=authorization_code&amp;code=${authCode}&amp;redirect_uri=${redirectUri}`);
    res.send(&#39;Successfully completed installation!&#39;);
}

async function requestToken(postBody) {
    base64EncodedText = Buffer.from(`${appClientId}:${appClientSecretKey}`, &quot;utf8&quot;).toString(&#39;base64&#39;);
    const postReqOptions = {
        hostname: `${mallId}.cafe24api.com`,
        path: &#39;/api/v2/oauth/token&#39;,
        method: &#39;POST&#39;,
        headers: {
            &#39;Authorization&#39;: `Basic ${base64EncodedText}`,
            &#39;Content-Type&#39;: &#39;application/x-www-form-urlencoded&#39;
        }
    };
    const postReq = https.request(postReqOptions, (response) =&gt; {
        let body = &#39;&#39;;
        response.on(&#39;data&#39;, (chunk) =&gt; {
            body += chunk;
        });
        response.on(&#39;end&#39;, () =&gt; {
                // 발급한 액세스 토큰, 리프레시 토큰 저장
            firestoreUpdateToken(body);
        });
    });
    postReq.on(&#39;error&#39;, (e) =&gt; {
        console.error(e);
    });
    postReq.write(postBody);
    postReq.end();
}</code></pre>
<h3 id="토큰-재발급-cloud-functions-update-token">토큰 재발급: Cloud Functions update-token</h3>
<p>주기적으로 Firestore에 저장해두었던 리프레시 토큰을 사용하여 액세스 토큰을 재발급합니다. 새로 발급된 액세스 토큰과 리프레시 토큰을 Firestore에 저장합니다. Cloud Scheduler, Pub/Sub에 의해 트리거되어 매 시 5분마다 실행됩니다.</p>
<pre><code class="language-jsx">functions.cloudEvent(&#39;updateToken&#39;, cloudEvent =&gt; {
  _updateToken();
});

async function _updateToken() {
    const refreshToken = await tokensRef.doc(&#39;refresh_token&#39;).get();
    requestToken(`grant_type=refresh_token&amp;refresh_token=${refreshToken.data().token}`);
}

async function requestToken(postBody) {
    base64EncodedText = Buffer.from(`${appClientId}:${appClientSecretKey}`, &quot;utf8&quot;).toString(&#39;base64&#39;);
    const postReqOptions = {
        hostname: `${mallId}.cafe24api.com`,
        path: &#39;/api/v2/oauth/token&#39;,
        method: &#39;POST&#39;,
        headers: {
            &#39;Authorization&#39;: `Basic ${base64EncodedText}`,
            &#39;Content-Type&#39;: &#39;application/x-www-form-urlencoded&#39;
        }
    };
    const postReq = https.request(postReqOptions, (response) =&gt; {
        let body = &#39;&#39;;
        response.on(&#39;data&#39;, (chunk) =&gt; {
            body += chunk;
        });
        response.on(&#39;end&#39;, () =&gt; {
                // 재발급한 액세스 토큰, 리프레시 토큰 저장
            firestoreUpdateToken(body);
        });
    });
    postReq.on(&#39;error&#39;, (e) =&gt; {
        console.error(e);
    });
    postReq.write(postBody);
    postReq.end();
}</code></pre>
<h2 id="4-상품-누적-판매량-가져오기">4. 상품 누적 판매량 가져오기</h2>
<h3 id="분기별-판매량-업데이트하기-cloud-functions-update-prev-sales">분기별 판매량 업데이트하기: Cloud Functions update-prev-sales</h3>
<p>주기적으로 Firestore에 분기별 상품 판매량을 업데이트합니다. Cloud Scheduler, Pub/Sub에 의해 트리거되어 매월 1일마다 실행됩니다.</p>
<pre><code class="language-jsx">functions.cloudEvent(&#39;updatePrevSales&#39;, cloudEvent =&gt; {
  updatePrevSales();
});

async function updatePrevSales() {
    const accessToken = await tokensRef.doc(&#39;access_token&#39;).get();
    const auth = &quot;Bearer &quot; + accessToken.data().token;

    // 지난 분기 구하기
    // ex) 오늘 2022년 1월 3일 -&gt; 지난 분기: 2021-4
    var date = new Date();  // now (GMT+0000)
    date.setUTCHours(date.getUTCHours() + 9);  // The time after 9h from now (GMT+0000)
    date.setUTCMonth(date.getUTCMonth() - 3);  // previous quarter
    const year = date.getUTCFullYear(); // 지난 분기가 몇년도인지 구하기
    const quarter = getQuarter(date.getUTCMonth() + 1); // 지난 분기(1/2/3/4) 구하기
    var options = salesReqOptionsByQuarter(year, quarter, auth);

    // 카페24 Admin API 호출하여 지난 분기의 판매량 가져오기
    const prevQuarterCnt = await getSalesByQuarter(options);

    var newDocName = year + &quot;-&quot; + quarter;
    // 분기의 판매량을 업데이트
    firestoreUpdateSales(newDocName, prevQuarterCnt);
}

function getSalesByQuarter(options) {
    return new Promise((resolve, reject) =&gt; {
        const req = https.request(options, (res) =&gt; {
            let body = &#39;&#39;; 
            var count;
            res.on(&quot;data&quot;, (chunk) =&gt; { 
                body += chunk; 
            }); 
            res.on(&quot;error&quot;, (error) =&gt; { throw new Error(error); });
            res.on(&quot;end&quot;, () =&gt; { 
                try { count = JSON.parse(body).count; }
                catch (e) { reject(e); }
                resolve(count);
            });
        });
        req.on(&#39;error&#39;, function(err) {
            reject(err);
        });
        req.end();
    });
}

    // 지난 분기 판매량을 가져오기 위한 카페24 Admin API 쿼리문을 짜는 함수
function salesReqOptionsByQuarter(year, quarter, auth) {
    var startDate, endDate;
    switch(quarter) {
        case 1:
            startDate = year + &quot;-01-01&quot;
            endDate = year + &quot;-03-31&quot;
            break;
        case 2:
            startDate = year + &quot;-04-01&quot;
            endDate = year + &quot;-06-30&quot;
            break;
        case 3:
            startDate = year + &quot;-07-01&quot;
            endDate = year + &quot;-09-30&quot;
            break;
        case 4:
            startDate = year + &quot;-10-01&quot;
            endDate = year + &quot;-12-31&quot;
            break;
        default:
            break;
    }
    const path = &quot;/api/v2/admin/orders/count?shop_no=1&quot;
        + &quot;&amp;start_date=&quot; + startDate + &quot;&amp;end_date=&quot; + endDate
        + &quot;&amp;order_status=N00,N10,N20,N21,N22,N30,N40,N50&quot;
        + &quot;&amp;date_type=order_date&quot;;
    console.log(path);

    return {
        hostname: `${mallId}.cafe24api.com`,
        path: path,
        method: &#39;GET&#39;,
        headers: {
            &#39;Authorization&#39;: auth,
            &#39;Content-Type&#39;: &quot;application/json&quot;,
            &#39;X-Cafe24-Api-Version&#39;: &quot;2022-09-01&quot;
        }
    };
}
</code></pre>
<h3 id="누적-판매량-가져오기-cloud-functions-get-sales-countgetsales">누적 판매량 가져오기: Cloud Functions get-sales-count/getsales</h3>
<p>Firestore에 저장된 분기별 상품 판매량과 카페24 Admin API 통해 가져온 이번 분기의 판매량을 모두 더합니다. 누적 판매량을 response로 전달합니다. </p>
<pre><code class="language-jsx">app.get(&#39;/getsales&#39;, (req, res) =&gt; {
    getSales(res);
});
async function getSales(res) {
        // 지난 분기까지의 판매량 가져오기
    const sales = await salesRef.get();
    var salesSum = 0;
    sales.forEach(quarter =&gt; {
        salesSum += quarter.data().count;
    });
    const accessToken = await tokensRef.doc(&#39;access_token&#39;).get();
    const auth = &quot;Bearer &quot; + accessToken.data().token;

    // 이번 분기의 판매량 가져오기
    var date = new Date();  // now (GMT+0000)
    date.setUTCHours(date.getUTCHours() + 9);  // 한국 시간
    var options = salesReqOptionsByQuarter(date.getUTCFullYear(), getQuarter(date.getUTCMonth() + 1), auth);
    const thisQuartCnt = await getSalesByQuarter(options);

        // 누적 판매량 response로 전달하기
    respBody = { count: thisQuartCnt + salesSum };
    res.setHeader(&#39;Access-Control-Allow-Origin&#39;, `https://${mallId}.cafe24.com`);
    res.setHeader(&#39;Access-Control-Allow-Methods&#39;, &quot;GET, OPTIONS&quot;);
    res.json(respBody);
}
function getSalesByQuarter(options) {
    return new Promise((resolve, reject) =&gt; {
        const req = https.request(options, (res) =&gt; {
            let body = &#39;&#39;; 
            var count;
            res.on(&quot;data&quot;, (chunk) =&gt; { 
                body += chunk; 
            }); 
            res.on(&quot;error&quot;, (error) =&gt; { throw new Error(error); });
            res.on(&quot;end&quot;, () =&gt; { 
                try { count = JSON.parse(body).count; }
                catch (e) { reject(e); }
                resolve(count);
            });
        });
        req.on(&#39;error&#39;, function(err) {
            reject(err);
        });
        req.end();
    });
}</code></pre>
<h3 id="메인-페이지에서-누적-판매량-보여주기">메인 페이지에서 누적 판매량 보여주기</h3>
<p>카페24 프론트엔드 코드에 다음 JavaScript 코드를 삽입하여 누적판매량을 가져오도록 했습니다.</p>
<pre><code class="language-jsx">var order_count = 0;
fetch(&lt;Cloud Functions:get-sales-count URL&gt;/getsales, {
    mtehod: &#39;GET&#39;,
    mode: &#39;cors&#39;,
}).then(response =&gt; {
    if (!response.ok) {
        throw new Error(&#39;Network response was not ok&#39;);
      } else {
        return response.json();
    }
}).then(data =&gt; {
    order_count = data.count;
}).catch(error =&gt; {
    console.error(&#39;There was a problem with the fetch operation:&#39;, error);
});</code></pre>
<h2 id="후기">후기</h2>
<p>카페24에 종속된 구조라 개발 제약이 많았습니다. 권한과 조회 범위, 배포 방식까지 선택지가 좁았고, 국내 솔루션 특성상 참고 자료도 적어 문제를 찾고 검증하는 데 시간이 더 걸렸습니다. 프론트엔드 코드 수정은 카페24 HTML 디자인 편집기 즉, 웹상에서 카페24의 에디터를 사용해야하는데 이게 정말 불편합니다... 그럼에도 카페24 기술지원이 빠르고 적극적으로 응대해 주셔서 막힐 때마다 방향을 잡을 수 있었습니다.</p>
<p>웹이 처음이라 매일이 터널처럼 느껴졌습니다. 오늘 해결해도 내일 다시 막히곤 했지만, 끝까지 밀고 나가며 모르는 내용을 빠르게 파고들고 기록으로 정리하는 습관을 갖게 되었습니다. 이번 프로젝트에서 가장 큰 성장은 그 지점이었다고 생각합니다. 지금 하면 한 달이면 완성할 것 같은데, 그 때는 정말 힘들었네요. 덕분에 웹에 대한 전반적인 개념을 잡을 수 있었던 고마운 프로젝트였습니다.</p>
<h3 id="참고-문서">참고 문서</h3>
<p>카페24 Developers: <a href="https://developers.cafe24.com/admin/dashboard/main/front/app">https://developers.cafe24.com/admin/dashboard/main/front/app</a>
카페24 APP 개발 가이드: <a href="https://developers.cafe24.com/app/front/app/develop">https://developers.cafe24.com/app/front/app/develop</a>
API Doc: <a href="https://developers.cafe24.com/docs/api/#introduction">https://developers.cafe24.com/docs/api/#introduction</a>
API Index: <a href="https://developers.cafe24.com/docs/ko/api/admin/#api-index">https://developers.cafe24.com/docs/ko/api/admin/#api-index</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[카페24 커스텀 자사몰 개발기: ① 쇼핑몰 솔루션 선택하기]]></title>
            <link>https://velog.io/@jaeiklee-dev/cafe24-custom-project-1-select-solution</link>
            <guid>https://velog.io/@jaeiklee-dev/cafe24-custom-project-1-select-solution</guid>
            <pubDate>Wed, 26 Feb 2025 19:24:05 GMT</pubDate>
            <description><![CDATA[<p>다음 글: <a href="https://velog.io/@jaeiklee-dev/cafe24-custom-project-2-cafe24app-gcp">카페24 커스텀 자사몰 개발기: ② 카페24 App + GCP로 관리자 데이터 가져오기</a></p>
<h2 id="📜-요약">📜 요약</h2>
<p>웹개념이 없는 사람이 커스터마이징 가능한 쇼핑몰 솔루션 찾는 과정.(2022년 11월)
커스텀 내용: 관리자데이터(판매량)를 홈페이지에 가져와 수치, 이미지 보여주기</p>
<h4 id="솔루션-선정-과정">솔루션 선정 과정</h4>
<ol>
<li><strong>커스텀 가능 여부 확인</strong>: 솔루션 업체 문의 → 프로토타이핑</li>
<li>커스텀 영역이 프론트엔드 코드 수정, 관리자API 제공 여부임을 깨달음</li>
<li><strong>결론</strong>: 아임웹 불가능, 고도몰과 카페24 가능</li>
<li><strong>최종 선택</strong>: 카페24 (구독료 없음)</li>
</ol>
<h4 id="2025년-3월-조사-결과">2025년 3월 조사 결과</h4>
<ol>
<li>고도몰 DB 접근 용이</li>
<li>아임웹 직접 기능 추가 가능</li>
</ol>
<h4 id="깨달음-아쉬움">깨달음, 아쉬움</h4>
<ol>
<li>기능 커스텀 범위는 소스코드 수정, DB 접근 가능여부로 파악하기</li>
<li>관리자페이지 인터페이스 운영, 관리 편의성도 고려할 것</li>
</ol>
<hr>
<h2 id="프로젝트-소개">프로젝트 소개</h2>
<p>카페24를 커스텀하여 자사몰을 구축하는 외주 프로젝트를 진행했습니다. 고객 요구사항 중 카페24에서 지원하지 않는 기능이 있어 별도로 개발을 진행했습니다. 프로젝트 기간은 2022년 11월 ~ 2023년 4월입니다. 본 글은 당시 노트를 기반으로 작성했습니다. 최대한 현재 유효한 정보인지 확인하고, 그렇지 않은 부분은 하단에 추가 설명을 달았습니다.
* 현재 운영 중인 서비스라 서비스명은 밝히지 않았습니다.</p>
<h3 id="🔧-프로젝트-각-단계에-사용된-기술">🔧 프로젝트 각 단계에 사용된 기술</h3>
<ul>
<li>쇼핑몰 솔루션:<ul>
<li><a href="https://www.cafe24.com/">카페24</a></li>
</ul>
</li>
<li>디자인<ul>
<li><a href="https://d.cafe24.com/home">카페24 디자인센터</a> 유료 스킨 (+ 고객사 자체 디자인 적용)</li>
</ul>
</li>
<li>카페24 미지원 기능 추가 개발 (기능 커스텀)<ul>
<li><a href="https://developers.cafe24.com/ko/app/front/app/develop">카페24 앱</a></li>
<li>Google Cloud <a href="https://cloud.google.com/functions">Functions</a>, <a href="https://cloud.google.com/pubsub">Pub/Sub</a>, <a href="https://cloud.google.com/scheduler">Scheduler</a>, <a href="https://cloud.google.com/firestore">Firestore</a></li>
</ul>
</li>
</ul>
<hr>
<p>프로젝트 전 과정을 여러 편에 나누어 작성할 예정입니다. 이 글은 시리즈의 첫번째 글입니다. 고객사의 요구사항을 확인하고 기획에 맞는 쇼핑몰 솔루션을 선택하기까지의 내용을 다루겠습니다. 추가 개발이 필요했던 기능을 각 쇼핑몰 솔루션에서 구현 가능할지 판단하는 과정에 대한 내용이 중심입니다.</p>
<h2 id="📍-핵심-요구사항-확인">📍 핵심 요구사항 확인</h2>
<p>쇼핑몰을 자체 개발하는 것은 굉장한 비용과 시간이 듭니다. 고객에게 보여지는 페이지뿐 아니라 디자인 관리 페이지, 회원, 주문, 배송, 공지글 등 쇼핑몰의 모든 것을 관리하기 위한 관리자 페이지까지 생각해야합니다. 자사 개발자가 없다면 기능 확장, 수정에도 제약이 큽니다. 고객사의 예산 및 주어진 프로젝트 기간, 추후 관리 편의를 고려하여 쇼핑몰 솔루션을 사용하는 방향으로 가기로 했습니다.</p>
<p>고객사는 실리콘 소재의 친환경(다회용) 용기를 판매하는 기업이었습니다. 요구사항은 크게 두가지였습니다.</p>
<blockquote>
</blockquote>
<ol>
<li>온라인 제품 판매 채널. 타사와 차별화된 브랜딩을 위해 자사몰로 구축 (스마트스토어 X)</li>
<li>친환경 제품을 구매(사용)함으로 인해 달성한 탄소 저감 효과를 홈페이지에 보여줄 것<ul>
<li>제품 판매량 → 개당 탄소 저감 효과 수치로 환산</li>
<li>환산된 탄소 저감 수치를 시각적으로, 이미지로 보여주기</li>
</ul>
</li>
</ol>
<p>제품 판매를 위한 기능에 대한 상세 기획이나 특이한 요구사항은 없었습니다. 자사 이미지가 잘 전달될 수 있도록 고객사에서 직접 제작한 디자인을 적용하는 것 정도였습니다. 두번째 요구사항이 특별했습니다. 특정 데이터를 프론트에 가져와 탄소 저감 수치 도출 계산식을 적용하고, 이에 어울리는 이미지를 보여줘야했습니다. 위 요구사항을 토대로 적합한 쇼핑몰 솔루션을 찾아보기로 했습니다.</p>
<h2 id="🚀-솔루션-선정-과정">🚀 솔루션 선정 과정</h2>
<p>많이 이용하는 웹빌딩 솔루션으로 아임웹, 카페24, 고도몰, 식스샵, 메이크샵 정도를 찾을 수 있었습니다. 요구사항에 적합한 솔루션을 추리기 위해 아래와 같이 기준을 설정했습니다. </p>
<blockquote>
</blockquote>
<ol>
<li>요구사항-2 구현 가능 여부</li>
<li>기능 및 디자인 커스텀 가능 여부</li>
<li>운영 비용</li>
<li>추후 관리, 운영 편의성</li>
<li>쇼핑몰 관리를 위해 필요한 기능(회원, 주문 및 배송, 판매 통계 등) 다양성, 지원 여부</li>
</ol>
<p><strong>커스터마이징 가능여부를 우선순위에</strong> 두기로 했습니다. 첫번째 요구사항(제품 판매)은 대부분의 쇼핑몰 솔루션에서 기본적으로 지원하는 기능에 포함될 것이라고 봤습니다. 위 솔루션 중 하나를 사용한다면 적어도 제품 판매 자체가 불가능한 경우는 없습니다. 반면 두번째 요구사항은 쇼핑몰 필수 기능이 아니기 때문에 각 솔루션 자체 기능만으로 구현이 어려울 가능성이 높다고 생각했습니다. 솔루션을 사용하게 되면 기능, 디자인이 쇼핑몰이 제공하는 범위에 한정될 것입니다. 예를 들어 쇼핑몰 랜딩페이지에 &#39;제품을 가장 많이 구매한 고객 top 10&#39;과 같은 것을 보여주고 싶다고 하겠습니다. 도입한 솔루션의 페이지 디자인 작업 인터페이스에서 고객별 구매량 데이터 제공하지 않으면 이 기능은 구현이 불가능한 것입니다. 그런 경우, 적어도 직접 개발할 수 있는 기능을 지원해야합니다. PG사 계약, 제품 등록, 등 쇼핑몰 세팅을 모두 마친 후에 기능 구현이 불가능함을 알게 된다면 낭패입니다.</p>
<h3 id="후보-추리기">후보 추리기</h3>
<p>아임웹, 카페24, 고도몰, 식스샵, 메이크샵 중 위 기준에 적합한 솔루션이 무엇인지 좁혀가 봤습니다.</p>
<h4 id="후보-1-아임웹-httpsimwebme">후보 1. 아임웹: <a href="https://imweb.me/">https://imweb.me/</a></h4>
<p align="center">
    <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/9aee638a-3294-4b27-8428-8a29fdb40804/image.png" alt="아임웹 로고" style="margin:50px 0 5px 0" width="50%" height="n%">
</p>


<p>첫번째 후보는 아임웹이었습니다. 다른 프로젝트를 통해 경험한 바 있어 우선으로 알아보기 시작했습니다. 아임웹은 관리자 페이지 인터페이스가 직관적입니다. WYSIWYG 방식으로 홈페이지 디자인이 가능하며 반응형 UI를 기본으로 제공합니다. 전반적으로 운영, 관리 편의성이 훌륭합니다. 사실 솔루션 조사 과정에서 아임웹은 추가 기능 구현이나 기능 커스텀이 거의 불가능하다는 글을 많이 보았습니다. 그럼에도 위와 같은 인터페이스 편의성 때문에 실제로 요구사항-2 구현이 불가능한지 직접 확인해보기로 했습니다.</p>
<p>요구사항-2 구현 가능 여부 확인을 위해 프론트엔드에서 특정 숫자나 이미지를 보여줄 수 있는지 조사했습니다. <a href="https://www.designstudiom.co.kr/66/?q=YToxOntzOjEyOiJrZXl3b3JkX3R5cGUiO3M6MzoiYWxsIjt9&amp;bmode=view&amp;idx=2430434&amp;t=board">한 업체의 글</a>을 통해 약간의 html 코드 수정이 가능하다는 점, 실시간으로 변하는 숫자를 페이지에 넣을 수 있다는 점을 확인했습니다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/a1f514f9-ac8e-4637-95d7-eba3a61e2aa9/image.png" alt="숫자 올라가는 효과" style="margin:50px 0 5px 0" width="100%" height="n%">
  출처: <a href="https://www.designstudiom.co.kr/66/?q=YToxOntzOjEyOiJrZXl3b3JkX3R5cGUiO3M6MzoiYWxsIjt9&bmode=view&idx=2430434&t=board">designstudiom</a>
</p>

<p>문제는 숫자, 이미지로 보여줄 판매량 데이터를 가져오는 기능을 찾을 수 없었습니다. 결국 아임웹 고객센터에 직접 문의를 했습니다. 문의 결과, 아쉽게도 해당 요구사항은 아임웹에서 제공하는 기능으로 구현이 불가능하다는 답을 받았습니다. </p>
<blockquote>
<p><strong>아임웹은 판매량 데이터 접근 및 기능 커스텀이 불가능</strong>하여 후보에서 제외(2022년 11월 기준)</p>
</blockquote>
<p>+
식스샵, 메이크샵도 아임웹과 같은 이유로 후보에서 제외하였습니다. 아주 제한적으로 html을 수정하여 디자인을 변경할 수 있는 수준이었습니다. 판매량 데이터를 가져오는 기능은 없었습니다.</p>
<h4 id="후보-2-고도몰-카페24">후보 2. 고도몰, 카페24</h4>
<p align="center">
    <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/3df0237e-8ce9-4e9d-bd27-27a43bcfc43f/image.png" alt="고도몰 로고, 카페24 로고" style="margin:50px 0 5px 0" width="100%" height="n%">
</p>

<p>남은 후보는 고도몰, 카페24였습니다. 가장 많이 이용하는 쇼핑몰 솔루션입니다. 둘 모두 디자인 관리와 운영 편의성도 모두 준수하고 회원, 쿠폰, 상품, 주문 관리 등 쇼핑몰 운영에 필요한 웬만한 기능을 잘 갖추고 있었습니다. 솔루션 조사 당시 많은 글에서 <strong>디자인 및 기능도 커스텀할 수 있도록 지원</strong>하는 것으로 확인했었습니다. 솔루션에서 지원하지 않는 기능이 있을 경우 제휴된 업체(<a href="https://haedream.nhn-commerce.com/">카페24 Expert</a>, <a href="https://haedream.nhn-commerce.com/">해드림</a>)를 통해서 커스텀을 의뢰하는 것도 가능했습니다. 구현 가능한 여러가지 옵션이 열려있으니 일단은 후보군을 고도몰, 카페24 두곳으로 좁혀도 되겠다고 판단했습니다.</p>
<blockquote>
<p>후보 &gt; <strong>고도몰, 카페24</strong></p>
</blockquote>
<h3 id="커스텀-지원-범위-확인하기">커스텀 지원 범위 확인하기</h3>
<p>이제 중요한 것은 커스터마이징 범위가 프로젝트 요구사항을 커버하는지 확인하는 것이었습니다. 개발 착수 전에 가장 신경을 많이 썼고, 오래 걸린 부분입니다. 검증이 필요한 커스텀 기능의 동작을 정리해보니 다음과 같았습니다.</p>
<ol>
<li>자사몰 홈페이지 화면에 제품 판매량을 가져온다</li>
<li>판매량 수치를 특정 계산식을 거쳐 탄소 저감 수치로 환산한다</li>
<li>환산된 수치를 시각적 이미지로 보여준다. 수치 변화에 따라 이미지도 변하도록 한다.</li>
</ol>
<p>카페24, 고도몰 각각 가입하여 쇼핑몰 기본세팅을 준비하고, 위 동작의 핵심 기능만 간단히 구현해보기로 했습니다. 맨 먼저 만난 것은 각 솔루션의 디자인 소스코드 수정 기능입니다. 둘 모두 관리자페이지에서 소스코드를 수정하여 디자인을 변경할 수 있도록 제공하고 있었습니다. 페이지에 텍스트를 추가하는 것을 포함하여 폭넓은 소스코드 수정이 가능하며 관리자 정의 변수 등을 가져올 수 있는 것도 확인했습니다. 그러나 아임웹과 동일한 문제에 봉착했습니다. 판매량 데이터를 가져오는 방법은 찾을 수 없었습니다.</p>
<p>결국 확인을 위해 카페24, 고도몰 각각의 고객센터에 다음과 같이 문의하였습니다.</p>
<hr>
<p>10가지 종류의 운동 기구를 판매하려합니다. 쇼핑몰 제작에 앞서 쇼핑몰 페이지에서 보여주고자 하는 것이 구현 가능한지 문의드립니다. 메인 페이지에 각 제품의 판매량 데이터를 가지고 다음과 같은 것을 보여주고 싶습니다.</p>
<ol>
<li>현시점까지 판매된 제품의 총 금액</li>
<li>현시점까지 판매된 아령 + 덤벨의 총 무게 (kg)</li>
<li>아령 + 덤벨의 총 무게에 따라 다른 이미지를 보여주고싶습니다.
→ 1000kg 달성했을 때 아기 코끼리, 2000kg 달성했을 때 소년 코끼리, 3000kg 달성했을 때 어른 코끼리, …</li>
</ol>
<hr>
<p>질문 1, 2는 제품의 판매량 데이터를 가져오는 것이 가능한지, 질문 3은 그 데이터를 갖고 프론트에서 특정 계산식을 적용하는 것이 가능한지 확인하기 위한 질문이었습니다. 뜻밖에도, 카페24와 고도몰 모두 긍정적인 답을 받지 못했습니다.</p>
<p>
  카페24 1:1문의 답변
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/76dd32ae-26cc-46e3-b939-449236bdb7f4/image.png" alt="카페24 1:1문의 답변" style="margin:10px 0 30px 0" width="100%" height="n%">
</p>

<p>
  고도몰 1:1문의 답변
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/41a00e80-01c6-4586-a8f5-a2c4806d7c55/image.png" alt="고도몰 1:1문의 답변" style="margin:10px 0 30px 0" width="100%" height="n%">
</p>

<p>조사를 통해 기능 커스텀이 가능한 것으로 확인했는데 이상했습니다. ‘“가능하다”는 것이 제휴 업체를 통해서만 가능하다는 것인지’ 등 카페24, 고도몰에 후속 문의, 전화 상담을 진행했습니다.</p>
<p>
  고도몰 재문의
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/8aed676d-c436-40d6-bc17-0e5e6cf95e4c/image.png" alt="고도몰 재문의" style="margin:10px 0 30px 0" width="100%" height="n%">
</p>

<p>돌아보니, 웹 지식이 없어서 기술적으로 정확한 질문을 하지 못했던 것 같습니다. 약 일주일에 걸쳐 5-6번 가량 재문의하고 재시도하길 반복했습니다. 이 과정을 거치면서 각 솔루션의 Admin API, 개발자센터, 앱, 플러그인 구조가 조금 그려졌습니다. 결국 고도몰, 카페24 모두 고객사 요구사항을 커스텀으로 구현 가능하다는 결론에 도달할 수 있었습니다.</p>
<blockquote>
<p>고도몰, 카페24 모두 <strong>커스텀 기능 구현</strong> 가능</p>
</blockquote>
<h3 id="고도몰-카페24의-커스텀-기능">고도몰, 카페24의 커스텀 기능</h3>
<h4 id="1-소스코드-수정">1. 소스코드 수정</h4>
<p>각 업체에서 제공하는 인터페이스와 가이드라인을 따라 커스텀할 수 있도록 되어있습니다. 카페24의 경우 관리자페이지의 디자인 수정 메뉴에서 html, css, javascript 수정하여 디자인을 변경할 수 있도록 지원합니다. 고도몰도 마찬가지로 소스코드 수정을 통한 디자인 변경이 가능합니다. 고도몰은 추가로 약간의 모듈 수정도 가능합니다.</p>
<p>
  카페24 스마트디자인: <br/>
  - <a href="https://www.cafe24.com/commerce/design/smart.html">https://www.cafe24.com/commerce/design/smart.html</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/297f4402-28d7-43f8-ad62-110c44866cc2/image.png" style="margin:10px 0 40px 0" width="100%" height="n%">
</p>

<p>
  고도몰 디자인 수정:
  <br/>
  - <a href="https://godomall-help.nhn-commerce.com/biginner/design/skin_setting/pc">고도몰 디자인 스킨 수정하기</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/e3d95562-6711-444a-bbca-52920010e8e1/image.png" style="margin:10px 0 40px 0" width="100%" height="n%">
</p>

<p>
  고도몰 모듈 수정:
  <br/>
  - <a href="https://devcenter-help.nhn-commerce.com/">고도몰 튜닝 가이드</a>
  <br/>
  - <a href="https://devcenter-help.nhn-commerce.com/guide/sample/undefined-6">사용자 페이지 수정하기</a>
  <br/>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/083f162d-c7d5-48cf-9766-7fb63e3d75f0/image.png" style="margin:10px 0 10px 0" width="100%" height="n%">
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/0f5b5f6e-f8ed-43cf-80d2-b546bb65723c/image.png" style="margin:10px 0 40px 0" width="100%" height="n%">
</p>

<h4 id="2-쇼핑몰-데이터-접근">2. 쇼핑몰 데이터 접근</h4>
<p>쇼핑몰 데이터는 프론트에서 바로 접근 가능한 데이터(이하 &quot;프론트 데이터&quot;)와 Admin API 호출을 통해 접근가능한 데이터(이하 &quot;Admin 데이터&quot;)로 나뉩니다. 필요한 데이터가 Admin 데이터에 속하는 경우 Admin API를 호출하기 위한 인증키를 발급받아야 합니다.</p>
<p>아래 링크는 모두 현재(2025년 2월) 기준입니다. 프로젝트 당시와는 차이가 있을 수 있습니다.</p>
<p>고도몰 프론트 데이터 리스트:
<a href="http://doc.godomall5.godomall.com/godo/database/table_layout.php">http://doc.godomall5.godomall.com/godo/database/table_layout.php</a></p>
<p>고도몰 Admin API:
<a href="https://devcenter.nhn-commerce.com/godomall5/openapi/spec">https://devcenter.nhn-commerce.com/godomall5/openapi/spec</a></p>
<p>카페24 프론트 데이터 (카페24 변수):
<a href="https://sdsupport.cafe24.com/board/tip/read_intro.html?no=191&amp;board_no=5">https://sdsupport.cafe24.com/board/tip/read_intro.html?no=191&amp;board_no=5</a></p>
<p>카페24 Admin API:
<a href="https://developers.cafe24.com/docs/api/admin/#api-index">https://developers.cafe24.com/docs/api/admin/#api-index</a></p>
<p>카페24의 경우 카페24앱을 통해 Admin API를 호출할 수 있도록 돼있었습니다. 카페24앱은 카페24 쇼핑몰에 설치할 수 있는 플러그인이라고 보시면 됩니다. 카페24에서 지원하지 않는 기능을 제휴 업체 또는 개발자센터에 등록된 개발자가 직접 개발하여 <a href="https://store.cafe24.com/kr/apps">카페24 앱스토어</a>에 등록합니다. 쇼핑몰 관리자는 필요한 기능이 스토어에 플러그인 중에 있다면 쇼핑몰에 설치하여 사용할 수 있습니다. <strong>개발 능력이 있다면, 쇼핑몰에 필요한 기능을 직접 만들면 됩니다.</strong> 앱을 앱스토어에 등록하지 않아도 내 쇼핑몰에 설치는 가능합니다. 필요한 기능을 카페24앱으로 개발하고, 자사 쇼핑몰에 설치해서 사용하시면 됩니다.</p>
<p>카페24 앱 제작하여 기능 추가하기:
<a href="https://developers.cafe24.com/app/front/common/concepts/appconcepts">https://developers.cafe24.com/app/front/common/concepts/appconcepts</a></p>
<blockquote>
<p>고도몰, 카페24 둘의 커스텀 방식에 차이는 있으나 기본적인 개념은 비슷했습니다. <strong>관리자페이지를 통해 프론트엔드 소스코드 수정이 가능하며 API를 사용하여 관리자데이터를 가져올 수 있음</strong>을 확인했습니다.</p>
</blockquote>
<h2 id="✌-최종선택은-카페24">✌ 최종선택은 카페24</h2>
<p><img src="https://velog.velcdn.com/images/jaeiklee-dev/post/03343fc5-8bc0-4dc7-81d7-2ca1e81c53fe/image.png" alt=""></p>
<p>최종 선택한 솔루션은 카페24였습니다. 고도몰, 카페24 모두 프로젝트의 요구사항을 만족했습니다. 대부분의 조건에서 비슷했으나 비용에서 차이가 났습니다. <strong>카페24는 무료로 시작이 가능</strong>했습니다. 반면 고도몰은 기능 커스텀이 가능하려면 적어도 Pro 플랜을 이용해야 했습니다. 고도몰의 Pro 플랜은 이용료가 월 33,000원으로 1년에 40만원 가까운 비용이 발생합니다. 카페24는 사용하는 부가기능에 따라 추가 비용이 들 수 있으나, 고객사의 요구사항을 고려했을 때, 무료로 시작하는 것이 더 유리하다고 판단했습니다.</p>
<h2 id="😓-돌아보니-아쉬운-것">😓 돌아보니 아쉬운 것</h2>
<h4 id="커스텀-지원-범위-파악에-시간-소요">커스텀 지원 범위 파악에 시간 소요</h4>
<p>고도몰, 카페24의 기능 커스텀 지원 범위 확인 하는 데에 생각보다 많은 시간이 소요되었습니다. 당시 웹개발이 처음이라, 웹 구조에 대한 개념이 없었습니다. 정확히 어떤 부분에 초점을 맞춰 물어봐야하는지 모르니 고객센터에 문의를 할 때도 질문이 두루뭉술해졌습니다. 돌아보니 기획자/클라이언트가 개발자에게 할 법한 질문에 더 가까워보입니다. 프로젝트를 진행하면서 웹의 퍼즐을 하나씩 맞춰갔습니다. 어느정도 퍼즐이 맞춰지니, 문의했던 내용에 대한 문서가 이미 있다는 것도 깨닫게 됐습니다.</p>
<p>다음과 같이 검색했더라면, 또는 다음과 같이 질문을 했더라면 좀 더 빠르고 명확하게 답을 내릴 수 있지 않았을까 싶습니다.</p>
<ul>
<li>소스코드 수정이 가능한지</li>
<li>수정 가능한 소스코드의 범위가 어디까지인지</li>
<li>판매량 데이터를 프론트에서 직접 접근 가능한지</li>
<li>판매량 데이터 접근이 가능한 API가 있는지</li>
</ul>
<h4 id="생각보다-중요한-관리-운영-편의성">생각보다 중요한 관리, 운영 편의성</h4>
<p>커스텀 기능 요구사항을 우선으로 솔루션을 결정하느라 <strong>관리, 운영에 대한 부분은 비교적 간과</strong>되었던 것이 아쉽습니다. 사실 개발자보다는 운영자를 위해서 고려할 필요가 있는 부분입니다. 쇼핑몰 런칭 후 5-6개월 정도 운영을 도와드렸습니다. 이미 다 세팅된 쇼핑몰 운영, 관리하는 것만 해도 따로 시간 들여 배워야할 정도로 기능이 많고 복잡다고 느꼈습니다. 특정 제품에 신규가입회원 쿠폰만 적용하려는 경우, 해당 쿠폰이 일정시간이 지나면 만료되도록 하고싶은 경우, … 디테일하게 설정하고 싶을수록 더 복잡합니다. 기능은 모두 갖추고 있지만, 기능이 많은 만큼 찾기가 어렵습니다. 런칭 후 두세달 간 카페24 고객센터 통화 문의 기록이 50건은 족히 될 것 같습니다.</p>
<p>쇼핑몰 솔루션마다 상품 정보, 가격, 회원, 판매 및 배송 정책 등 운영 관련 세팅해야하는 내용을 매뉴얼로 제공하고 있습니다. 매뉴얼을 따라 세팅을 해보시면서 어떤 솔루션이 편리한지 판단해보시면 좋을 겁니다. 쇼핑몰이 처음이라면 운영에 필요한 정책이 어떤 것이 있는지 파악하시는 데에도 도움이 되실 것입니다. </p>
<p>카페24 홈 대시보드 알아보기:
<a href="https://support.cafe24.com/hc/ko/articles/24147192065817-%ED%99%88-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">https://support.cafe24.com/hc/ko/articles/24147192065817-%ED%99%88-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</a></p>
<p>고도몰 관리자 화면 이해하기:
<a href="https://godomall-help.nhn-commerce.com/biginner/starter/admin">https://godomall-help.nhn-commerce.com/biginner/starter/admin</a></p>
<h2 id="😝-기능-커스텀-지금-다시-해도-카페24일까">😝 기능 커스텀, 지금 다시 해도 카페24일까?</h2>
<p>오래 전(2022년 말) 기록을 바탕으로 하기 때문에 현재는 유효하지 않은 정보가 있을 수 있다고 생각했습니다. 솔루션 선정의 핵심 기준이었던 커스텀 지원 범위를 중심으로 다시 조사해봤습니다. 당시에는 아임웹, 고도몰, 카페24 모두 관리자페이지에서 수정가능한 소스코드 내에서 직접 판매량 데이터를 가져오는 방법은 찾지 못했습니다. 조사결과, 카페24는 큰 차이가 없었습니다. 고도몰은 당시 파악했던 내용과 조금 차이가 있었습니다. 아임웹도 성장하여 그때와는 많이 달라졌습니다. 이제 아임웹도 카페24처럼 앱 제작을 통해 아임웹 내에서 Admin API를 호출할 수 있도록 지원합니다. </p>
<h3 id="고도몰-프론트에-판매량-데이터-가져오기🙆">고도몰 프론트에 판매량 데이터 가져오기🙆</h3>
<p>고객센터 통해 특정 제품 판매량을 가져오는 방법과, API 호출방식에 대해 문의했습니다. 현재 판매량은 API를 호출하지 않고도 고도몰에서 제공하는 테이블(프론트 데이터)에서 가져올 수 있다고 답을 받았습니다.</p>
<p>
  고도몰 1:1문의 (2025년 2월 25일)
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/3d7cf889-fda2-46e7-9053-a05c016f73cd/image.png" alt="고도몰 1:1문의 답변 2025-02-25" style="margin:10px 0 30px 0" width="100%" height="n%">
</p>

<p>
  답변 내용대로, 프론트에서 접근가능한 데이터 리스트에 위 판매량 데이터가 있음을 확인했습니다.
  <br/>
  - <a href="http://doc.godomall5.godomall.com/godo/database/table_layout.php">Godomall 5 database</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/9f3c8ccd-fa95-45f0-b60b-e71302ebe5e5/image.png" alt="Godomall 5 Database" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<h3 id="아임웹-개발자센터🐣">아임웹 개발자센터🐣</h3>
<p>
  아임웹도 카페24와 유사하게 앱스토어를 통해 쇼핑몰에 기능을 추가할 수 있습니다. 
  <br/>
  - <a href="https://imweb.me/appstore/">아임웹 앱스토어</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/48bdd95a-e428-4acf-9d5b-68d9719b5149/image.png" alt="Imweb 앱스토어" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<p>
  기능을 개발하고 앱스토어에 등록하려면 아임웹 개발자센터로 가야합니다. 좌측 상단에 'Developers beta'라 돼있는 것을 보니 공개된 지 얼마 안 된 것 같습니다. 
  <br/>
  - <a href="https://developers.imweb.me/">Imweb Developers</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/c7fdbdb6-87ab-4c60-bf59-2361a4434f22/image.png" alt="Imweb Developers Beta" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<h4 id="예전-개발자-센터api--외부-서비스에서-접근용">예전 개발자 센터(API) = 외부 서비스에서 접근용</h4>
<p>
  전에도 개발자 센터가 있었습니다. 차이가 있다면, 이때는 아임웹이 아닌 타서비스에 API를 제공하는 목적이었습니다. 아래는 이전 API 문서의 소개글입니다. 
  <br/>
  - <a href="https://old-developers.imweb.me">Imweb Developers (old)</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/6dc4a2ec-1d3b-4908-8deb-78450c8f3362/image.png" alt="Imweb Developers old 소개" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<h4 id="imweb-developers-현재">Imweb developers (현재!)</h4>
<p>현재는 카페24처럼 <a href="https://developers-docs.imweb.me/guide/imweb-developers">아임웹 개발자센터</a>를 통해 누구나 앱을 만들고 <a href="https://imweb.me/appstore">아임웹 앱스토어</a>에 등록할 수 있게 되었습니다. 단, 개발한 앱을 자신의 쇼핑몰에서만 사용하려는 경우에도, 앱스토어에 등록되어 모든 사용자에게 노출됩니다. 또한 앱이 스토어에 게시되려면 승인 절차를 거쳐야 하는데 이 점은 프로젝트 진행의 허들이 될 것으로 보입니다.</p>
<p>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/6475fa08-cf52-4bee-a514-4ec088bef73d/image.png" alt="Imweb Developers Beta 사용가능 대상" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<p>
  API 레퍼런스를 보니, 주문 목록 조회도 가능합니다.
  <br/>
  - <a href="https://developers-docs.imweb.me/reference#tag/order/">Imweb Developers API Reference - Order</a>
  <img src="https://velog.velcdn.com/images/jaeiklee-dev/post/9e6711e6-20a3-4c5f-9b60-d9be8acc07d5/image.png" alt="Imweb Developers API Reference - Order" style="margin:10px 0 50px 0" width="100%" height="n%">
</p>

<p>지금이라면 아임웹으로도 본 프로젝트 구현이 가능할지도 모르겠습니다. 당시에는 아임웹은 후보에서 제외되고, 비슷한 기능의 두 솔루션 중 저렴한 쪽을 선택했으니 보다 단순했습니다. 지금은 아임웹이 고려대상에 올라오고, 유료라서 선택하지 않았던 고도몰이 커스텀 지원 면에서 우위를 보입니다. 각 후보의 서로다른 장점이 절묘한 균형을 이뤄 더 복잡해졌습니다. 아임웹은 관리자인터페이스가 좋고, 고도몰은 커스텀 편의성이 좋습니다. 카페24는 무료입니다. 기능커스텀 가능성에 대한 검증이 끝난 상태라면, 쇼핑몰 관리자(고객사)가 어떤 점을 더 중요하게 생각하는지에 따라 결정하게 될 것 같습니다.</p>
<blockquote>
</blockquote>
<p>현재(2025년 2월) 조사 결과</p>
<ul>
<li>고도몰은 Admin API 없이 상품 판매량 데이터 접근이 가능합니다.</li>
<li>아임웹도 개발자센터, 앱스토어를 직접 기능 추가가 가능합니다.</li>
</ul>
<hr>
<p>저의 개발블로그 첫 글이었습니다. 읽어주셔서 감사합니다🙏</p>
]]></description>
        </item>
    </channel>
</rss>