<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-03-27T13:58:01+00:00</updated><id>/feed.xml</id><title type="html">FUREWEB</title><subtitle>개인적으로 관심있는 기술들에 대한 포스트가 모여있는 블로그가 될 예정입니다.
</subtitle><entry><title type="html">Cloudflare WAF Custom Rules로 백엔드 API 보안 강화하기</title><link href="/blog/2026/03/26/cloudflare-waf-custom-rules-api-security.html" rel="alternate" type="text/html" title="Cloudflare WAF Custom Rules로 백엔드 API 보안 강화하기" /><published>2026-03-26T15:00:00+00:00</published><updated>2026-03-26T15:00:00+00:00</updated><id>/blog/2026/03/26/cloudflare-waf-custom-rules-api-security</id><content type="html" xml:base="/blog/2026/03/26/cloudflare-waf-custom-rules-api-security.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2026/03/26/cloudflare-waf-custom-rules-api-security.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>

<hr />

<p>백엔드 API 서버를 운영하다 보면 자동화된 취약점 스캐너의 요청이 끊임없이 들어옵니다. <code class="language-plaintext highlighter-rouge">.env</code>, <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>, <code class="language-plaintext highlighter-rouge">aws.env.json</code>, <code class="language-plaintext highlighter-rouge">.git/config</code> 같은 설정 파일을 탐색하는 요청들인데, 서비스와 무관한 경로임에도 서버가 매번 처리하고 404를 응답해야 합니다.</p>

<p>이 글에서는 <strong>Cloudflare의 무료 WAF Custom Rules</strong>를 활용하여 이런 악성 요청을 서버에 도달하기 전에 차단하는 방법을 다룹니다.</p>

<h2 id="문제-자동화된-취약점-스캐닝">문제: 자동화된 취약점 스캐닝</h2>

<p>서버 로그를 확인하면 아래와 같은 요청이 반복적으로 들어오는 것을 볼 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /app/docker-compose.yml
GET /aws-codecommit/
GET /docker/overlay/config.json
GET /opt/mailcow-dockerized/mailcow.conf
GET /aws.env.json
GET /serverless.yml
GET /sam-template.yaml
GET /attacker/docker-compose.yml
GET /amplify/team-provider-info.json
GET /.git/config
GET /.env
</code></pre></div></div>

<p>이들은 대부분 VPS나 호스팅 서버에서 실행되는 자동화된 스캐너입니다. 설정 파일이 실수로 노출된 서버를 찾아 API 키, 데이터베이스 자격 증명 등을 탈취하려는 목적입니다.</p>

<p>서버에서 이 요청들을 하나하나 처리하면 불필요한 리소스가 소모됩니다. 더 좋은 방법은 <strong>서버 앞단에서 차단</strong>하는 것입니다.</p>

<h2 id="전제-조건">전제 조건</h2>

<ul>
  <li>Cloudflare에 도메인이 등록되어 있어야 합니다.</li>
  <li>API 서버의 DNS 레코드가 <strong>Proxied(주황색 구름)</strong> 상태여야 합니다.</li>
</ul>

<p>Cloudflare 대시보드에서 <strong>DNS</strong> 페이지를 열고 API 서버 레코드를 확인합니다.</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Name</th>
      <th>Proxy status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td>api</td>
      <td>Proxied (주황색 구름)</td>
    </tr>
  </tbody>
</table>

<p>회색 구름(DNS only)이면 클릭하여 주황색으로 변경합니다. Proxied 상태에서만 Cloudflare의 보안 기능이 적용됩니다.</p>

<h2 id="방법-1-서버-측-경로-화이트리스트">방법 1: 서버 측 경로 화이트리스트</h2>

<p>Cloudflare 설정 전에, 서버 자체에서도 방어 계층을 추가하는 것이 좋습니다. API 서버에는 정해진 경로만 존재하므로, 허용된 경로 외에는 즉시 차단합니다.</p>

<p>Node.js(Fastify) 예시:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">ALLOWED_PREFIXES</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">/keys</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/auth</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/billing</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/admin</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/health</span><span class="dl">'</span><span class="p">]</span>

<span class="nx">fastify</span><span class="p">.</span><span class="nx">addHook</span><span class="p">(</span><span class="dl">'</span><span class="s1">onRequest</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">?</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>

  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ALLOWED_PREFIXES</span><span class="p">.</span><span class="nx">some</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">url</span> <span class="o">===</span> <span class="nx">p</span> <span class="o">||</span> <span class="nx">url</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">p</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">)))</span> <span class="p">{</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">code</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nx">header</span><span class="p">(</span><span class="dl">'</span><span class="s1">connection</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">close</span><span class="dl">'</span><span class="p">).</span><span class="nx">send</span><span class="p">()</span>
    <span class="k">return</span>
  <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>이 훅은 라우팅 전에 실행되므로, Fastify가 라우트 매칭을 시도하지 않아 처리 비용이 최소화됩니다. <code class="language-plaintext highlighter-rouge">connection: close</code> 헤더로 연결도 즉시 종료합니다.</p>

<p>Express라면 미들웨어로 동일하게 구현할 수 있습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">((</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">path</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ALLOWED_PREFIXES</span><span class="p">.</span><span class="nx">some</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">url</span> <span class="o">===</span> <span class="nx">p</span> <span class="o">||</span> <span class="nx">url</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">p</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">)))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="kd">set</span><span class="p">(</span><span class="dl">'</span><span class="s1">connection</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">close</span><span class="dl">'</span><span class="p">).</span><span class="nx">end</span><span class="p">()</span>
  <span class="p">}</span>
  <span class="nx">next</span><span class="p">()</span>
<span class="p">})</span>
</code></pre></div></div>

<p>이것만으로도 서버 측 방어는 되지만, 요청이 서버까지 도달한다는 점은 변하지 않습니다.</p>

<h2 id="방법-2-cloudflare-waf-custom-rules">방법 2: Cloudflare WAF Custom Rules</h2>

<p>Cloudflare의 Custom Rules를 사용하면 요청이 <strong>서버에 도달하기 전에</strong> 차단할 수 있습니다. Free 플랜에서 <strong>5개까지 무료</strong>로 사용 가능합니다.</p>

<h3 id="설정-방법">설정 방법</h3>

<ol>
  <li>Cloudflare 대시보드 접속</li>
  <li><strong>Security &gt; Security rules</strong> 이동</li>
  <li><strong>Create rule &gt; Custom rules</strong> 선택</li>
</ol>

<h3 id="규칙-api-경로-외-모든-요청-차단">규칙: API 경로 외 모든 요청 차단</h3>

<p>가장 효과적인 방식은 허용할 경로만 명시하고 나머지를 모두 차단하는 것입니다.</p>

<p><strong>Rule name:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Allow only API paths
</code></pre></div></div>

<p><strong>Edit expression</strong> 클릭 후 아래 내용을 붙여넣습니다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(http.host eq "api.example.com" and not starts_with(http.request.uri.path, "/keys") and not starts_with(http.request.uri.path, "/auth") and not starts_with(http.request.uri.path, "/billing") and not starts_with(http.request.uri.path, "/admin") and not starts_with(http.request.uri.path, "/users") and not starts_with(http.request.uri.path, "/health"))
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">api.example.com</code>은 실제 API 도메인으로 변경하고, 경로 목록은 서비스의 실제 API 경로에 맞게 수정합니다.</p>

<p><strong>Choose action:</strong> <code class="language-plaintext highlighter-rouge">Block</code></p>

<p><strong>Deploy</strong> 클릭으로 즉시 적용됩니다.</p>

<h3 id="expression-구조-설명">Expression 구조 설명</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(
  http.host eq "api.example.com"          // API 도메인에만 적용
  and not starts_with(uri.path, "/keys")  // /keys/* 허용
  and not starts_with(uri.path, "/auth")  // /auth/* 허용
  ...                                     // 나머지 허용 경로
)
</code></pre></div></div>

<p>조건을 모두 만족하는 요청, 즉 <strong>API 도메인이면서 허용된 경로가 아닌 요청</strong>이 Block 대상이 됩니다.</p>

<h3 id="규칙이-적용되면">규칙이 적용되면</h3>

<p>스캐너의 요청은 Cloudflare 엣지에서 즉시 차단됩니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /docker-compose.yml     → Cloudflare에서 Block (서버 미도달)
GET /.env                   → Cloudflare에서 Block (서버 미도달)
GET /aws.env.json           → Cloudflare에서 Block (서버 미도달)
GET /keys/abc123            → 서버로 정상 전달
GET /auth/me                → 서버로 정상 전달
</code></pre></div></div>

<p>서버 로그에 스캐너 요청이 더 이상 나타나지 않고, 서버 리소스도 절약됩니다.</p>

<h2 id="추가-규칙-예시">추가 규칙 예시</h2>

<p>Custom Rules는 5개까지 사용할 수 있으므로, 필요에 따라 추가 규칙을 만들 수 있습니다.</p>

<h3 id="특정-국가-차단">특정 국가 차단</h3>

<p>서비스 대상이 아닌 국가에서 오는 악성 요청을 차단합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(http.host eq "api.example.com" and ip.geoip.country in {"RU" "CN"})
</code></pre></div></div>

<h3 id="특정-user-agent-차단">특정 User-Agent 차단</h3>

<p>알려진 스캐너 도구의 User-Agent를 차단합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(http.host eq "api.example.com" and (
  http.user_agent contains "sqlmap" or
  http.user_agent contains "nikto" or
  http.user_agent contains "dirbuster"
))
</code></pre></div></div>

<h2 id="서버--cloudflare-이중-방어">서버 + Cloudflare 이중 방어</h2>

<p>서버 측 화이트리스트와 Cloudflare WAF를 함께 사용하면 이중 방어가 됩니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[스캐너 요청]
    ↓
[Cloudflare WAF] → Block (대부분 여기서 차단)
    ↓ (허용된 경로)
[서버 화이트리스트] → 2차 검증
    ↓ (통과)
[API 라우터] → 정상 처리
</code></pre></div></div>

<p>Cloudflare를 우회하여 서버 IP로 직접 접근하는 경우를 대비해 서버 측 방어도 유지하는 것이 좋습니다. 서버 방화벽(iptables, ufw 등)에서 Cloudflare IP 대역만 허용하면 직접 접근 자체를 차단할 수도 있습니다.</p>

<h2 id="cloudflare-free-플랜-보안-기능-정리">Cloudflare Free 플랜 보안 기능 정리</h2>

<table>
  <thead>
    <tr>
      <th>기능</th>
      <th>Free</th>
      <th>Pro ($20/월)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Custom Rules</td>
      <td>5개</td>
      <td>20개</td>
    </tr>
    <tr>
      <td>Rate Limiting Rules</td>
      <td>1개</td>
      <td>2개</td>
    </tr>
    <tr>
      <td>Managed Rules (자동 WAF)</td>
      <td>X</td>
      <td>O</td>
    </tr>
    <tr>
      <td>Bot Management</td>
      <td>X</td>
      <td>O</td>
    </tr>
  </tbody>
</table>

<p>Free 플랜의 Custom Rules 5개만으로도 기본적인 API 보안은 충분히 구성할 수 있습니다.</p>

<h2 id="마무리">마무리</h2>

<p>백엔드 API 서버를 운영한다면 자동화된 스캐너 요청은 피할 수 없습니다. 서버에서 일일이 처리하기보다, Cloudflare 같은 CDN/WAF를 앞단에 두고 엣지에서 차단하는 것이 훨씬 효율적입니다.</p>

<p>Cloudflare Free 플랜만으로도 Custom Rules를 통해 API 경로 화이트리스트를 구성할 수 있고, 서버 측 화이트리스트와 함께 사용하면 이중 방어가 완성됩니다. 설정에 드는 시간은 몇 분이지만, 서버 리소스 절약과 보안 강화 효과는 상당합니다.</p>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Cloudflare Pages + GitHub Actions Self-hosted Runner로 프론트/백엔드 자동 배포 파이프라인 구축하기</title><link href="/blog/2026/03/26/cloudflare-pages-github-actions-self-hosted-runner-deploy-pipeline.html" rel="alternate" type="text/html" title="Cloudflare Pages + GitHub Actions Self-hosted Runner로 프론트/백엔드 자동 배포 파이프라인 구축하기" /><published>2026-03-26T14:00:00+00:00</published><updated>2026-03-26T14:00:00+00:00</updated><id>/blog/2026/03/26/cloudflare-pages-github-actions-self-hosted-runner-deploy-pipeline</id><content type="html" xml:base="/blog/2026/03/26/cloudflare-pages-github-actions-self-hosted-runner-deploy-pipeline.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2026/03/26/cloudflare-pages-github-actions-self-hosted-runner-deploy-pipeline.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>

<hr />

<p>모노레포에서 프론트엔드와 백엔드를 각각 다른 인프라에 배포해야 하는 경우가 많습니다. 프론트엔드는 Cloudflare Pages 같은 정적 호스팅에, 백엔드는 직접 관리하는 VM에 배포하는 구성이 대표적입니다.</p>

<p>이 글에서는 하나의 GitHub 저장소에서 <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 push할 때 <strong>프론트엔드는 Cloudflare Pages로</strong>, <strong>백엔드는 Self-hosted Runner를 통해 VM으로</strong> 자동 배포하는 파이프라인을 구축하는 방법을 다룹니다.</p>

<h2 id="전체-구조">전체 구조</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>repo/
├── frontend/          # Cloudflare Pages로 배포
├── backend/           # VM에 배포 (Self-hosted Runner)
└── .github/workflows/
    ├── deploy-fe.yml  # 프론트엔드 배포
    └── deploy-be.yml  # 백엔드 배포
</code></pre></div></div>

<p>두 워크플로우 모두 <code class="language-plaintext highlighter-rouge">main</code> push에 반응하되, <code class="language-plaintext highlighter-rouge">paths</code> 필터로 각자 담당하는 디렉토리가 변경된 경우에만 실행됩니다.</p>

<h2 id="1-프론트엔드-cloudflare-pages-배포">1. 프론트엔드: Cloudflare Pages 배포</h2>

<p>Cloudflare Pages는 GitHub Actions에서 <code class="language-plaintext highlighter-rouge">wrangler</code>를 통해 배포할 수 있습니다. GitHub의 ubuntu 러너에서 빌드 후 결과물을 Cloudflare에 업로드하는 방식입니다.</p>

<h3 id="워크플로우-deploy-feyml">워크플로우 (deploy-fe.yml)</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Frontend</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">frontend/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.github/workflows/deploy-fe.yml'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">deploy</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">defaults</span><span class="pi">:</span>
      <span class="na">run</span><span class="pi">:</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="m">22</span>
          <span class="na">cache</span><span class="pi">:</span> <span class="s">npm</span>
          <span class="na">cache-dependency-path</span><span class="pi">:</span> <span class="s">frontend/package-lock.json</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install dependencies</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm ci</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm run build</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to Cloudflare Pages</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">cloudflare/wrangler-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">apiToken</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">accountId</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">command</span><span class="pi">:</span> <span class="s">pages deploy dist/ --project-name=my-project --commit-dirty=true</span>
          <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">frontend</span>
</code></pre></div></div>

<h3 id="필요한-secrets">필요한 Secrets</h3>

<table>
  <thead>
    <tr>
      <th>Secret</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CLOUDFLARE_API_TOKEN</code></td>
      <td>Cloudflare API 토큰 (Edit Cloudflare Pages 권한)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CLOUDFLARE_ACCOUNT_ID</code></td>
      <td>Cloudflare 계정 ID</td>
    </tr>
  </tbody>
</table>

<p>Cloudflare 대시보드에서 API 토큰을 생성할 때 <strong>Cloudflare Pages &gt; Edit</strong> 권한을 부여해야 합니다.</p>

<h2 id="2-백엔드-self-hosted-runner-배포">2. 백엔드: Self-hosted Runner 배포</h2>

<p>백엔드 서버가 외부에서 SSH 접근이 어려운 환경(사설 네트워크, VPN 등)에 있다면 <strong>Self-hosted Runner</strong>가 좋은 선택입니다. 서버 VM에 runner를 설치하면 GitHub Actions가 해당 서버에서 직접 명령을 실행할 수 있습니다.</p>

<h3 id="2-1-runner-설치-ubuntu">2-1. Runner 설치 (Ubuntu)</h3>

<p>서버 VM에 SSH 접속 후 실행합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># runner 디렉토리 생성 및 다운로드</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/actions-runner <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/actions-runner
curl <span class="nt">-o</span> actions-runner-linux-x64-2.322.0.tar.gz <span class="nt">-L</span> <span class="se">\</span>
  https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz
<span class="nb">tar </span>xzf ./actions-runner-linux-x64-2.322.0.tar.gz
</code></pre></div></div>

<p>GitHub에서 등록 토큰을 발급받습니다:</p>

<blockquote>
  <p>Repository &gt; Settings &gt; Actions &gt; Runners &gt; New self-hosted runner</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># runner 등록</span>
./config.sh <span class="nt">--url</span> https://github.com/&lt;owner&gt;/&lt;repo&gt; <span class="nt">--token</span> &lt;YOUR_TOKEN&gt;
</code></pre></div></div>

<p>등록 시 물어보는 항목:</p>
<ul>
  <li><strong>Runner name</strong>: 원하는 이름 (예: <code class="language-plaintext highlighter-rouge">my-server</code>)</li>
  <li><strong>Labels</strong>: 워크플로우에서 식별할 라벨 (예: <code class="language-plaintext highlighter-rouge">backend</code>)</li>
  <li>나머지는 기본값(엔터)</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 시스템 서비스로 등록 (재부팅 시 자동 시작)</span>
<span class="nb">sudo</span> ./svc.sh <span class="nb">install
sudo</span> ./svc.sh start
</code></pre></div></div>

<h3 id="2-2-워크플로우-deploy-beyml">2-2. 워크플로우 (deploy-be.yml)</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Backend</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">backend/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.github/workflows/deploy-be.yml'</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">DEPLOY_DIR</span><span class="pi">:</span> <span class="s">/home/ubuntu/my-project</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">deploy</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">self-hosted</span><span class="pi">,</span> <span class="nv">backend</span><span class="pi">]</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Pull latest code</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">&gt;</span>
          <span class="s">cd $DEPLOY_DIR &amp;&amp;</span>
          <span class="s">git pull https://x-access-token:$@github.com/&lt;owner&gt;/&lt;repo&gt;.git main</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Restart backend</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">bash -l {0}</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">cd $DEPLOY_DIR/backend</span>

          <span class="s">STATUS=$(npx pm2 jlist 2&gt;/dev/null | node -e "</span>
            <span class="s">const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8') || '[]');</span>
            <span class="s">const p = d.find(x =&gt; x.name === 'server');</span>
            <span class="s">console.log(p ? p.pm2_env.status : 'not_found');</span>
          <span class="s">" 2&gt;/dev/null || echo "no_daemon")</span>

          <span class="s">echo "PM2 server status: $STATUS"</span>

          <span class="s">case "$STATUS" in</span>
            <span class="s">online)</span>
              <span class="s">npx pm2 reload server --update-env</span>
              <span class="s">;;</span>
            <span class="s">stopped|errored)</span>
              <span class="s">npx pm2 delete server</span>
              <span class="s">NODE_ENV=production npx pm2 start server.js --name server</span>
              <span class="s">;;</span>
            <span class="s">*)</span>
              <span class="s">npx pm2 delete server 2&gt;/dev/null</span>
              <span class="s">NODE_ENV=production npx pm2 start server.js --name server</span>
              <span class="s">;;</span>
          <span class="s">esac</span>

          <span class="s">npx pm2 save</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Health check</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">for i in 1 2 3 4 5; do</span>
            <span class="s">if curl -sf http://localhost:4000/ &gt; /dev/null 2&gt;&amp;1; then</span>
              <span class="s">echo "Health check passed"</span>
              <span class="s">exit 0</span>
            <span class="s">fi</span>
            <span class="s">sleep 2</span>
          <span class="s">done</span>
          <span class="s">echo "Health check failed after 10s"</span>
          <span class="s">cd $DEPLOY_DIR/backend &amp;&amp; npx pm2 logs server --lines 20 --nostream</span>
          <span class="s">exit 1</span>
</code></pre></div></div>

<h3 id="워크플로우-핵심-포인트">워크플로우 핵심 포인트</h3>

<p><strong><code class="language-plaintext highlighter-rouge">shell: bash -l {0}</code></strong> — 로그인 셸로 실행합니다. Self-hosted runner는 서비스로 동작하기 때문에 사용자의 <code class="language-plaintext highlighter-rouge">.bashrc</code>, <code class="language-plaintext highlighter-rouge">.profile</code>이 자동으로 로드되지 않습니다. nvm이나 특정 PATH가 필요한 경우 이 설정이 필수입니다.</p>

<p><strong><code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>으로 HTTPS pull</strong> — 서버의 git remote가 SSH로 설정되어 있어도, runner 환경에서는 GitHub의 host key 문제가 발생할 수 있습니다. <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>을 활용한 HTTPS pull이 가장 안정적입니다.</p>

<p><strong>절대 경로 사용</strong> — <code class="language-plaintext highlighter-rouge">~</code>(틸드)는 YAML의 <code class="language-plaintext highlighter-rouge">working-directory</code>에서 shell 확장이 되지 않습니다. 반드시 <code class="language-plaintext highlighter-rouge">/home/ubuntu/...</code> 같은 절대 경로를 사용해야 합니다.</p>

<p><strong>PM2 상태별 분기</strong> — <code class="language-plaintext highlighter-rouge">pm2 kill</code>(데몬 전체 종료) 대신 프로세스 상태를 확인하고 분기 처리합니다:</p>

<table>
  <thead>
    <tr>
      <th>상태</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">online</code></td>
      <td><code class="language-plaintext highlighter-rouge">pm2 reload</code> (graceful restart, 무중단)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">stopped</code> / <code class="language-plaintext highlighter-rouge">errored</code></td>
      <td><code class="language-plaintext highlighter-rouge">pm2 delete</code> 후 새로 <code class="language-plaintext highlighter-rouge">pm2 start</code></td>
    </tr>
    <tr>
      <td>프로세스 없음 / 데몬 없음</td>
      <td>새로 <code class="language-plaintext highlighter-rouge">pm2 start</code></td>
    </tr>
  </tbody>
</table>

<p>서버 재부팅이나 비정상 종료 등 어떤 상황에서도 배포가 정상 동작합니다.</p>

<p><strong>npx 사용</strong> — PM2를 글로벌 설치하지 않고 프로젝트의 <code class="language-plaintext highlighter-rouge">node_modules</code>에 있는 PM2를 <code class="language-plaintext highlighter-rouge">npx</code>로 실행합니다. 글로벌 패키지 의존성을 줄일 수 있습니다.</p>

<p><strong>헬스체크</strong> — 배포 후 서버가 실제로 응답하는지 확인합니다. 2초 간격으로 5회 재시도하고, 실패 시 PM2 로그를 출력하여 원인 파악이 가능합니다.</p>

<h2 id="3-삽질-회고-self-hosted-runner-도입-시-주의할-점">3. 삽질 회고: Self-hosted Runner 도입 시 주의할 점</h2>

<p>Self-hosted runner를 처음 도입하면 예상치 못한 문제를 만나게 됩니다. 실제로 겪었던 문제들을 정리합니다.</p>

<h3 id="ssh-host-key-문제">SSH host key 문제</h3>

<p>서버의 git remote가 SSH(<code class="language-plaintext highlighter-rouge">git@github.com:...</code>)로 설정되어 있으면, runner에서 <code class="language-plaintext highlighter-rouge">git pull origin main</code> 실행 시 <code class="language-plaintext highlighter-rouge">Host key verification failed</code> 에러가 발생합니다. runner 서비스 환경에는 <code class="language-plaintext highlighter-rouge">known_hosts</code>가 설정되어 있지 않기 때문입니다.</p>

<p><strong>해결</strong>: <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>을 활용한 HTTPS URL로 pull합니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">run</span><span class="pi">:</span> <span class="s">git pull https://x-access-token:$@github.com/owner/repo.git main</span>
</code></pre></div></div>

<h3 id="path--환경변수-누락">PATH / 환경변수 누락</h3>

<p>Runner가 시스템 서비스로 동작하면 사용자의 shell profile이 로드되지 않습니다. <code class="language-plaintext highlighter-rouge">node</code>, <code class="language-plaintext highlighter-rouge">npm</code>, <code class="language-plaintext highlighter-rouge">pm2</code> 등의 명령어를 찾지 못하는 <code class="language-plaintext highlighter-rouge">command not found</code> 에러가 발생합니다.</p>

<p><strong>해결</strong>: <code class="language-plaintext highlighter-rouge">shell: bash -l {0}</code>으로 로그인 셸을 명시합니다.</p>

<h3 id="틸드-경로-미확장">틸드(<code class="language-plaintext highlighter-rouge">~</code>) 경로 미확장</h3>

<p>GitHub Actions의 <code class="language-plaintext highlighter-rouge">working-directory</code> 설정에서 <code class="language-plaintext highlighter-rouge">~/my-project</code>처럼 틸드를 사용하면 문자 그대로 <code class="language-plaintext highlighter-rouge">~/my-project</code>라는 디렉토리를 찾으려 합니다.</p>

<p><strong>해결</strong>: <code class="language-plaintext highlighter-rouge">env</code>로 절대 경로를 정의하고 <code class="language-plaintext highlighter-rouge">run</code> 블록 내에서 <code class="language-plaintext highlighter-rouge">cd</code>로 이동합니다.</p>

<h3 id="pm2-데몬-전체-종료-문제">PM2 데몬 전체 종료 문제</h3>

<p><code class="language-plaintext highlighter-rouge">pm2 kill</code>은 PM2 데몬 자체를 종료합니다. Runner 환경에서 데몬을 죽인 뒤 다시 시작하면 환경 차이로 프로세스가 제대로 뜨지 않을 수 있습니다.</p>

<p><strong>해결</strong>: <code class="language-plaintext highlighter-rouge">pm2 reload</code>나 <code class="language-plaintext highlighter-rouge">pm2 restart</code>로 프로세스만 재시작하고, 프로세스가 없는 경우에만 <code class="language-plaintext highlighter-rouge">pm2 start</code>로 새로 등록합니다. <code class="language-plaintext highlighter-rouge">pm2 save</code>로 상태를 저장해두면 서버 재부팅 후에도 PM2가 자동 복구합니다.</p>

<h2 id="4-runner-관리-명령어">4. Runner 관리 명령어</h2>

<p>설치 이후 운영에 필요한 명령어를 정리합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 서비스 상태 확인</span>
<span class="nb">sudo</span> ./svc.sh status

<span class="c"># 서비스 중지 / 시작</span>
<span class="nb">sudo</span> ./svc.sh stop
<span class="nb">sudo</span> ./svc.sh start

<span class="c"># 서비스 제거 (runner 해제 시)</span>
<span class="nb">sudo</span> ./svc.sh uninstall

<span class="c"># runner 등록 해제</span>
./config.sh remove <span class="nt">--token</span> &lt;TOKEN&gt;

<span class="c"># 로그 확인</span>
journalctl <span class="nt">-u</span> actions.runner.&lt;서비스명&gt; <span class="nt">-f</span>
</code></pre></div></div>

<h2 id="마무리">마무리</h2>

<p>이 구성의 장점은 <strong>네트워크 제약 없이</strong> 배포를 자동화할 수 있다는 것입니다. 프론트엔드는 GitHub의 클라우드 러너에서 빌드하여 Cloudflare에 배포하고, 백엔드는 서버에 설치된 Self-hosted runner가 직접 pull하고 재시작합니다.</p>

<p>SSH 포트를 외부에 열거나, 별도의 CI/CD 서버를 구축하지 않아도 됩니다. <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 push하면 변경된 디렉토리에 따라 각각의 워크플로우가 독립적으로 실행됩니다.</p>

<p>제가 운영 중인 서비스의 경우, 처음에는 프론트엔드를 Vercel에서 서비스하다가 Cloudflare Pages로 이전했고, 백엔드는 집에서만 접속 가능한 NAS를 경유해 VM에 수동으로 접근한 뒤 git pull과 PM2 재시작을 직접 수행하는 방식이었습니다. 이번에 위 구조로 정리하고 나니, <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 push만 하면 프론트와 백엔드가 각각 자동으로 배포되어 훨씬 간편해졌습니다.</p>

<p>다만 백엔드 배포 시 PM2가 프로세스를 재시작하는 동안 짧은 다운타임이 발생한다는 점은 아직 과제로 남아 있습니다. 다음 글에서는 이 부분을 어떻게 개선할 수 있을지 고민한 결과를 공유해 보겠습니다.</p>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">SaaS 프로젝트를 위한 Stripe 구독 결제 연동 가이드</title><link href="/blog/2026/03/09/stripe-subscription-setup-guide-for-saas-projects.html" rel="alternate" type="text/html" title="SaaS 프로젝트를 위한 Stripe 구독 결제 연동 가이드" /><published>2026-03-09T10:00:00+00:00</published><updated>2026-03-09T10:00:00+00:00</updated><id>/blog/2026/03/09/stripe-subscription-setup-guide-for-saas-projects</id><content type="html" xml:base="/blog/2026/03/09/stripe-subscription-setup-guide-for-saas-projects.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2026/03/09/stripe-subscription-setup-guide-for-saas-projects.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<p>Stripe를 사용하여 SaaS 프로젝트에 구독 결제를 연동하는 방법을 단계별로 정리했습니다. Stripe Checkout, Customer Portal, Webhook까지 실제 운영에 필요한 전체 플로우를 다룹니다.</p>

<hr />

<h2 id="사전-준비">사전 준비</h2>

<ul>
  <li><a href="https://dashboard.stripe.com/">Stripe</a> 계정</li>
  <li>Node.js 백엔드 서버 (Express, Fastify 등)</li>
  <li>Stripe CLI (로컬 웹훅 테스트용)</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Stripe CLI 설치 (macOS)</span>
brew <span class="nb">install </span>stripe/stripe-cli/stripe

<span class="c"># 로그인</span>
stripe login
</code></pre></div></div>

<hr />

<h2 id="1-sandbox테스트-환경-생성">1. Sandbox(테스트 환경) 생성</h2>

<p>Stripe Dashboard에서 Sandbox를 생성합니다. Sandbox는 실제 결제가 발생하지 않는 격리된 테스트 환경입니다.</p>

<ol>
  <li>Dashboard 좌측 상단의 환경 선택 드롭다운 클릭</li>
  <li><strong>+ New sandbox</strong> 또는 <strong>Create sandbox</strong> 클릭</li>
  <li>이름 지정 후 생성</li>
</ol>

<p>생성 후 <strong>Developers → API keys</strong>에서 키를 확인합니다:</p>

<table>
  <thead>
    <tr>
      <th>키</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Publishable key</strong> (<code class="language-plaintext highlighter-rouge">pk_test_...</code>)</td>
      <td>프론트엔드용 (Checkout Session 방식이면 미사용)</td>
    </tr>
    <tr>
      <td><strong>Secret key</strong> (<code class="language-plaintext highlighter-rouge">sk_test_...</code>)</td>
      <td>백엔드 서버에서 사용</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>Secret key는 절대 프론트엔드에 노출하면 안 됩니다.</p>
</blockquote>

<hr />

<h2 id="2-상품product-및-가격price-생성">2. 상품(Product) 및 가격(Price) 생성</h2>

<p>Dashboard에서 <strong>Product catalog → Add product</strong>로 상품을 생성합니다.</p>

<h3 id="상품-생성-예시">상품 생성 예시</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Name</td>
      <td>Pro</td>
    </tr>
    <tr>
      <td>Description</td>
      <td>상품 설명</td>
    </tr>
    <tr>
      <td>Pricing model</td>
      <td>Standard pricing</td>
    </tr>
    <tr>
      <td>Price</td>
      <td>$10.00 USD</td>
    </tr>
    <tr>
      <td>Billing period</td>
      <td>Monthly</td>
    </tr>
  </tbody>
</table>

<h3 id="price-id-확인">Price ID 확인</h3>

<p>상품 생성 후 해당 상품 페이지의 <strong>Pricing</strong> 섹션에서 Price를 클릭하면 <code class="language-plaintext highlighter-rouge">price_</code>로 시작하는 ID를 확인할 수 있습니다.</p>

<blockquote>
  <p><strong>Product ID (<code class="language-plaintext highlighter-rouge">prod_xxx</code>)와 Price ID (<code class="language-plaintext highlighter-rouge">price_xxx</code>)는 다릅니다.</strong>
Checkout Session을 생성할 때 사용하는 것은 <strong>Price ID</strong>입니다.</p>
</blockquote>

<hr />

<h2 id="3-백엔드-연동">3. 백엔드 연동</h2>

<h3 id="패키지-설치">패키지 설치</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install </span>stripe dotenv
</code></pre></div></div>

<h3 id="환경변수">환경변수</h3>

<pre><code class="language-env">STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRO_PRICE_ID=price_xxx
</code></pre>

<h3 id="stripe-초기화">Stripe 초기화</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">dotenv</span><span class="dl">'</span><span class="p">).</span><span class="nx">config</span><span class="p">()</span>
<span class="kd">const</span> <span class="nx">stripe</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">stripe</span><span class="dl">'</span><span class="p">)(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">STRIPE_SECRET_KEY</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="checkout-session-생성">Checkout Session 생성</h3>

<p>사용자가 구독을 시작할 때 Stripe Checkout 페이지로 리다이렉트합니다. Stripe이 결제 UI를 제공하므로 직접 카드 입력 폼을 만들 필요가 없습니다.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/billing/checkout</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">priceId</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span>

  <span class="c1">// Stripe Customer 생성 (또는 기존 Customer 재사용)</span>
  <span class="kd">const</span> <span class="nx">customer</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">customers</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span>
    <span class="na">email</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span>
    <span class="na">metadata</span><span class="p">:</span> <span class="p">{</span> <span class="na">userId</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
  <span class="p">})</span>

  <span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">checkout</span><span class="p">.</span><span class="nx">sessions</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span>
    <span class="na">mode</span><span class="p">:</span> <span class="dl">'</span><span class="s1">subscription</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">customer</span><span class="p">:</span> <span class="nx">customer</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
    <span class="na">line_items</span><span class="p">:</span> <span class="p">[{</span> <span class="na">price</span><span class="p">:</span> <span class="nx">priceId</span><span class="p">,</span> <span class="na">quantity</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}],</span>
    <span class="na">success_url</span><span class="p">:</span> <span class="dl">'</span><span class="s1">https://yourapp.com?billing=success</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">cancel_url</span><span class="p">:</span> <span class="dl">'</span><span class="s1">https://yourapp.com?billing=cancel</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">subscription_data</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">metadata</span><span class="p">:</span> <span class="p">{</span> <span class="na">userId</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
    <span class="p">},</span>
  <span class="p">})</span>

  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">url</span><span class="p">:</span> <span class="nx">session</span><span class="p">.</span><span class="nx">url</span> <span class="p">})</span>
<span class="p">})</span>
</code></pre></div></div>

<p>프론트엔드에서는 응답받은 <code class="language-plaintext highlighter-rouge">url</code>로 <code class="language-plaintext highlighter-rouge">window.location.href</code>를 설정하면 됩니다.</p>

<h3 id="customer-portal-세션-생성">Customer Portal 세션 생성</h3>

<p>기존 구독자가 플랜을 변경하거나 취소할 때 Stripe Customer Portal로 보냅니다.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/billing/portal</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">billingPortal</span><span class="p">.</span><span class="nx">sessions</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span>
    <span class="na">customer</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">stripeCustomerId</span><span class="p">,</span>
    <span class="na">return_url</span><span class="p">:</span> <span class="dl">'</span><span class="s1">https://yourapp.com</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">})</span>

  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">url</span><span class="p">:</span> <span class="nx">session</span><span class="p">.</span><span class="nx">url</span> <span class="p">})</span>
<span class="p">})</span>
</code></pre></div></div>

<hr />

<h2 id="4-webhook-설정">4. Webhook 설정</h2>

<p>Stripe에서 발생하는 이벤트(결제 완료, 구독 변경, 취소 등)를 서버에서 수신하려면 Webhook을 설정해야 합니다.</p>

<h3 id="왜-webhook이-필요한가요">왜 Webhook이 필요한가요?</h3>

<p>Checkout 완료 후 <code class="language-plaintext highlighter-rouge">?billing=success</code>로 리다이렉트되지만, 이 시점에 실제 결제 처리가 완료되었다는 보장이 없습니다. Webhook은 Stripe 서버에서 직접 보내는 이벤트이므로, 이를 통해 DB를 업데이트하는 것이 안전합니다.</p>

<h3 id="로컬-개발-stripe-cli">로컬 개발 (Stripe CLI)</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stripe listen <span class="nt">--forward-to</span> localhost:4000/billing/webhook
</code></pre></div></div>

<p>실행 시 출력되는 <code class="language-plaintext highlighter-rouge">whsec_...</code> 값을 <code class="language-plaintext highlighter-rouge">.env</code>의 <code class="language-plaintext highlighter-rouge">STRIPE_WEBHOOK_SECRET</code>에 설정합니다.</p>

<h3 id="프로덕션">프로덕션</h3>

<p>Stripe Dashboard → <strong>Developers → Webhooks → Add endpoint</strong>:</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Endpoint URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.yourapp.com/billing/webhook</code></td>
    </tr>
    <tr>
      <td>Events</td>
      <td><code class="language-plaintext highlighter-rouge">checkout.session.completed</code>, <code class="language-plaintext highlighter-rouge">customer.subscription.updated</code>, <code class="language-plaintext highlighter-rouge">customer.subscription.deleted</code></td>
    </tr>
  </tbody>
</table>

<p>생성 후 <strong>Signing secret</strong> (<code class="language-plaintext highlighter-rouge">whsec_...</code>)을 프로덕션 환경변수에 설정합니다.</p>

<blockquote>
  <p>로컬 CLI의 <code class="language-plaintext highlighter-rouge">whsec_</code>와 Dashboard Webhook의 <code class="language-plaintext highlighter-rouge">whsec_</code>는 별개의 값입니다.</p>
</blockquote>

<h3 id="webhook-엔드포인트-구현">Webhook 엔드포인트 구현</h3>

<p>Webhook 요청의 body를 <strong>raw Buffer 상태</strong>로 받아야 서명 검증이 가능합니다. JSON으로 파싱된 body를 사용하면 서명 검증에 실패합니다.</p>

<h4 id="express">Express</h4>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// express.json()보다 먼저 등록해야 합니다</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/billing/webhook</span><span class="dl">'</span><span class="p">,</span>
  <span class="nx">express</span><span class="p">.</span><span class="nx">raw</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span> <span class="p">}),</span>
  <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">sig</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">'</span><span class="s1">stripe-signature</span><span class="dl">'</span><span class="p">]</span>
    <span class="kd">let</span> <span class="nx">event</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="nx">event</span> <span class="o">=</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">webhooks</span><span class="p">.</span><span class="nx">constructEvent</span><span class="p">(</span>
        <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span>  <span class="c1">// raw Buffer</span>
        <span class="nx">sig</span><span class="p">,</span>
        <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">STRIPE_WEBHOOK_SECRET</span><span class="p">,</span>
      <span class="p">)</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">`Webhook signature verification failed: </span><span class="p">${</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
      <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">send</span><span class="p">(</span><span class="dl">'</span><span class="s1">Invalid signature</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">switch</span> <span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">case</span> <span class="dl">'</span><span class="s1">checkout.session.completed</span><span class="dl">'</span><span class="p">:</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">object</span>
        <span class="c1">// session.customer, session.subscription 등으로 DB 업데이트</span>
        <span class="k">break</span>
      <span class="p">}</span>
      <span class="k">case</span> <span class="dl">'</span><span class="s1">customer.subscription.updated</span><span class="dl">'</span><span class="p">:</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">object</span>
        <span class="c1">// subscription.items.data[0].price.id로 플랜 변경 반영</span>
        <span class="k">break</span>
      <span class="p">}</span>
      <span class="k">case</span> <span class="dl">'</span><span class="s1">customer.subscription.deleted</span><span class="dl">'</span><span class="p">:</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">object</span>
        <span class="c1">// 플랜을 free로 변경</span>
        <span class="k">break</span>
      <span class="p">}</span>
    <span class="p">}</span>

    <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">received</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<h4 id="fastify">Fastify</h4>

<p>Fastify는 기본적으로 <code class="language-plaintext highlighter-rouge">application/json</code>을 자동 파싱하므로, Webhook 엔드포인트에서 raw Buffer를 받으려면 <strong>별도의 플러그인 스코프</strong>에서 content type parser를 오버라이드해야 합니다.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 별도 플러그인 스코프로 등록해야 다른 라우터에 영향을 주지 않습니다</span>
<span class="nx">fastify</span><span class="p">.</span><span class="nx">register</span><span class="p">(</span><span class="k">async</span> <span class="kd">function</span> <span class="nx">webhookPlugin</span><span class="p">(</span><span class="nx">app</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">app</span><span class="p">.</span><span class="nx">addContentTypeParser</span><span class="p">(</span>
    <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span><span class="p">,</span>
    <span class="p">{</span> <span class="na">parseAs</span><span class="p">:</span> <span class="dl">'</span><span class="s1">buffer</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">(</span><span class="nx">_req</span><span class="p">,</span> <span class="nx">body</span><span class="p">,</span> <span class="nx">done</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">done</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="nx">body</span><span class="p">),</span>
  <span class="p">)</span>

  <span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/billing/webhook</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">sig</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">'</span><span class="s1">stripe-signature</span><span class="dl">'</span><span class="p">]</span>
    <span class="kd">let</span> <span class="nx">event</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="nx">event</span> <span class="o">=</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">webhooks</span><span class="p">.</span><span class="nx">constructEvent</span><span class="p">(</span>
        <span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span>  <span class="c1">// Buffer 그대로 전달</span>
        <span class="nx">sig</span><span class="p">,</span>
        <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">STRIPE_WEBHOOK_SECRET</span><span class="p">,</span>
      <span class="p">)</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">fastify</span><span class="p">.</span><span class="nx">log</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">`Webhook signature verification failed: </span><span class="p">${</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
      <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">code</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Invalid signature</span><span class="dl">'</span> <span class="p">})</span>
    <span class="p">}</span>

    <span class="c1">// 이벤트 처리 (위 Express 예시와 동일)</span>

    <span class="nx">reply</span><span class="p">.</span><span class="nx">send</span><span class="p">({</span> <span class="na">received</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
  <span class="p">})</span>
<span class="p">})</span>
</code></pre></div></div>

<blockquote>
  <p><strong>주의</strong>: <code class="language-plaintext highlighter-rouge">req.rawBody</code>에 저장하고 나중에 <code class="language-plaintext highlighter-rouge">request.raw.rawBody</code>로 접근하는 방식은 Fastify의 플러그인 스코프에서 동작하지 않을 수 있습니다. <code class="language-plaintext highlighter-rouge">request.body</code>에 Buffer를 직접 전달하는 것이 가장 확실한 방법입니다.</p>
</blockquote>

<hr />

<h2 id="5-customer-portal-설정">5. Customer Portal 설정</h2>

<p>사용자가 플랜 변경, 결제 수단 변경, 구독 취소 등을 직접 관리할 수 있는 페이지를 Stripe이 제공합니다.</p>

<p>Stripe Dashboard → <strong>Settings → Billing → Customer portal</strong></p>

<h3 id="subscriptions">Subscriptions</h3>

<table>
  <thead>
    <tr>
      <th>설정</th>
      <th>권장값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cancel subscriptions</td>
      <td>활성화</td>
    </tr>
    <tr>
      <td>Update subscriptions</td>
      <td>활성화</td>
    </tr>
    <tr>
      <td>When customers change plans</td>
      <td><strong>Prorate charges and credits</strong></td>
    </tr>
    <tr>
      <td>Charge timing</td>
      <td><strong>Invoice prorations immediately at the time of the update</strong></td>
    </tr>
    <tr>
      <td>Products</td>
      <td>전환 가능한 모든 상품 추가</td>
    </tr>
  </tbody>
</table>

<h3 id="기타">기타</h3>

<table>
  <thead>
    <tr>
      <th>설정</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Return URL</td>
      <td>Portal에서 돌아올 URL (예: <code class="language-plaintext highlighter-rouge">https://yourapp.com</code>)</td>
    </tr>
    <tr>
      <td>Payment methods</td>
      <td>결제 수단 변경 허용</td>
    </tr>
    <tr>
      <td>Invoices</td>
      <td>청구서 내역 조회 허용</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>“Update subscriptions” 또는 “Switch plans” 옵션이 보이지 않을 때:</strong></p>
  <ul>
    <li>“Subscriptions” 섹션이 비활성화되어 있으면 먼저 토글을 켜주세요.</li>
    <li>Products가 등록되어 있지 않으면 플랜 전환 옵션이 나타나지 않습니다.</li>
    <li>Sandbox에서는 메뉴명이 다를 수 있습니다 (“Update subscriptions”, “Switching plans”, “Plan changes” 등).</li>
  </ul>
</blockquote>

<hr />

<h2 id="6-플랜-변경-흐름-설계">6. 플랜 변경 흐름 설계</h2>

<table>
  <thead>
    <tr>
      <th>변경 방향</th>
      <th>처리 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>무료 → 유료</td>
      <td>Stripe Checkout으로 새 구독 생성</td>
    </tr>
    <tr>
      <td>유료 → 다른 유료 (업그레이드/다운그레이드)</td>
      <td>Customer Portal에서 플랜 변경</td>
    </tr>
    <tr>
      <td>유료 → 무료 (취소)</td>
      <td>Customer Portal에서 구독 취소</td>
    </tr>
  </tbody>
</table>

<p>업그레이드/다운그레이드 시:</p>
<ul>
  <li><strong>업그레이드</strong>: 차액이 즉시 과금됩니다 (prorate 설정 시)</li>
  <li><strong>다운그레이드</strong>: 남은 기간에 대한 크레딧이 발생하고 다음 결제에 반영됩니다</li>
</ul>

<p>플랜 변경 시 Stripe가 <code class="language-plaintext highlighter-rouge">customer.subscription.updated</code> 웹훅을 발생시키므로, 이 이벤트에서 DB의 플랜 정보를 업데이트하면 됩니다.</p>

<hr />

<h2 id="7-테스트">7. 테스트</h2>

<h3 id="테스트-카드-번호">테스트 카드 번호</h3>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>카드 번호</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>결제 성공</td>
      <td><code class="language-plaintext highlighter-rouge">4242 4242 4242 4242</code></td>
    </tr>
    <tr>
      <td>결제 실패 (거부)</td>
      <td><code class="language-plaintext highlighter-rouge">4000 0000 0000 0002</code></td>
    </tr>
    <tr>
      <td>3D Secure 인증 필요</td>
      <td><code class="language-plaintext highlighter-rouge">4000 0025 0000 3155</code></td>
    </tr>
    <tr>
      <td>잔액 부족</td>
      <td><code class="language-plaintext highlighter-rouge">4000 0000 0000 9995</code></td>
    </tr>
  </tbody>
</table>

<ul>
  <li>만료일: 미래 아무 날짜 (예: <code class="language-plaintext highlighter-rouge">12/34</code>)</li>
  <li>CVC: 아무 3자리 (예: <code class="language-plaintext highlighter-rouge">123</code>)</li>
</ul>

<h3 id="테스트-플로우">테스트 플로우</h3>

<ol>
  <li>구독하기 클릭 → Stripe Checkout으로 이동</li>
  <li>테스트 카드로 결제 → 성공 URL로 리다이렉트</li>
  <li><code class="language-plaintext highlighter-rouge">stripe listen</code> 터미널에서 Webhook 이벤트 수신 확인</li>
  <li>DB에서 플랜 변경 확인</li>
  <li>Customer Portal에서 플랜 변경/취소 테스트</li>
</ol>

<h3 id="webhook-수동-트리거">Webhook 수동 트리거</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># checkout.session.completed 이벤트</span>
stripe trigger checkout.session.completed

<span class="c"># 구독 변경 이벤트</span>
stripe trigger customer.subscription.updated

<span class="c"># 구독 취소 이벤트</span>
stripe trigger customer.subscription.deleted
</code></pre></div></div>

<hr />

<h2 id="8-프로덕션-체크리스트">8. 프로덕션 체크리스트</h2>

<p>라이브 전환 전에 확인해야 할 사항입니다:</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Stripe Dashboard에서 Live 모드로 전환</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Live 모드의 API keys로 환경변수 교체 (<code class="language-plaintext highlighter-rouge">sk_live_...</code>)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Live 모드에서 상품/가격 재생성 (테스트 모드 데이터는 이관되지 않음)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Live 모드의 Price ID로 환경변수 교체</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Dashboard에서 프로덕션 Webhook endpoint 등록 및 Signing secret 설정</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Customer Portal 설정 확인 (Live 모드에서 별도 설정 필요)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Checkout의 <code class="language-plaintext highlighter-rouge">success_url</code>, <code class="language-plaintext highlighter-rouge">cancel_url</code>이 프로덕션 도메인인지 확인</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Portal의 <code class="language-plaintext highlighter-rouge">return_url</code>이 프로덕션 도메인인지 확인</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />HTTPS 적용 확인 (Stripe는 HTTPS를 권장)</li>
</ul>

<hr />

<h2 id="9-자주-겪는-문제">9. 자주 겪는 문제</h2>

<h3 id="no-webhook-payload-was-provided">“No webhook payload was provided”</h3>

<p>Webhook 서명 검증에 실패하는 가장 흔한 원인입니다. <code class="language-plaintext highlighter-rouge">stripe.webhooks.constructEvent()</code>에 전달하는 body가 raw Buffer가 아닌 JSON 파싱된 객체이면 발생합니다.</p>

<p><strong>해결</strong>: Webhook 엔드포인트에서 body를 JSON으로 파싱하지 않고 raw Buffer 상태로 받아야 합니다. 위 4번의 코드 예시를 참고해주세요.</p>

<h3 id="webhook은-수신되는데-db가-업데이트되지-않음">Webhook은 수신되는데 DB가 업데이트되지 않음</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">checkout.session.completed</code> 이벤트에서 <code class="language-plaintext highlighter-rouge">session.subscription</code>으로 구독 정보를 조회하고 있는지 확인해주세요.</li>
  <li><code class="language-plaintext highlighter-rouge">metadata</code>에 <code class="language-plaintext highlighter-rouge">userId</code>를 포함시켰는지 확인해주세요. Checkout Session의 <code class="language-plaintext highlighter-rouge">subscription_data.metadata</code>에 설정해야 Subscription 객체에서도 접근 가능합니다.</li>
</ul>

<h3 id="customer-portal에서-플랜-변경-옵션이-없음">Customer Portal에서 플랜 변경 옵션이 없음</h3>

<ul>
  <li>Customer Portal 설정에서 “Update subscriptions”가 활성화되어 있는지 확인해주세요.</li>
  <li>전환 가능한 Products가 등록되어 있는지 확인해주세요.</li>
  <li>상품이 1개뿐이면 전환할 대상이 없으므로 옵션이 나타나지 않습니다.</li>
</ul>

<h3 id="sandbox의-데이터를-live로-이관하고-싶음">Sandbox의 데이터를 Live로 이관하고 싶음</h3>

<p>Stripe는 테스트 모드와 라이브 모드의 데이터를 완전히 분리합니다. 상품, 가격, 고객, 구독 등 모든 데이터를 라이브 모드에서 새로 생성해야 합니다.</p>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://docs.stripe.com/checkout">Stripe Docs - Checkout</a></li>
  <li><a href="https://docs.stripe.com/customer-management/activate-no-code-customer-portal">Stripe Docs - Customer Portal</a></li>
  <li><a href="https://docs.stripe.com/webhooks">Stripe Docs - Webhooks</a></li>
  <li><a href="https://docs.stripe.com/testing">Stripe Docs - Testing</a></li>
  <li><a href="https://docs.stripe.com/stripe-cli">Stripe CLI</a></li>
</ul>

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2026/03/09/stripe-subscription-setup-guide-for-saas-projects.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">nuxt.js 기반 국제화 처리 자동화 컨셉 및 구현 이야기</title><link href="/blog/2021/03/16/story-of-development-about-nuxt-i18n-automation-example-project.html" rel="alternate" type="text/html" title="nuxt.js 기반 국제화 처리 자동화 컨셉 및 구현 이야기" /><published>2021-03-16T14:40:00+00:00</published><updated>2021-03-16T14:40:00+00:00</updated><id>/blog/2021/03/16/story-of-development-about-nuxt-i18n-automation-example-project</id><content type="html" xml:base="/blog/2021/03/16/story-of-development-about-nuxt-i18n-automation-example-project.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2021/03/16/story-of-development-about-nuxt-i18n-automation-example-project.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<!-- contents start -->

<h3 id="tldr">TL;DR</h3>

<p>국제화 처리를 위한 파일을 직접 작성 없이 구글 시트에 입력된 내용을 기반으로 파일을 생성하고,
나아가 파일이 없어도 국제화 처리를 할 수 있도록 예시를 들어 설명합니다.</p>

<p><a href="https://goree.kr/kw0auA">샘플 웹 페이지</a></p>

<p><a href="https://goree.kr/XpCFNQ">소스코드 저장소로 이동</a></p>

<h3 id="들어가며">들어가며</h3>

<p>국제화를 고려한 웹 애플리케이션을 작성해야 하는 경우, 화면 내에 등장하는 모든 텍스트가 별도의 국제화 처리기를 통해 표현될 수 있도록 준비해야 합니다.</p>

<p>저는 현재 <a href="https://nuxtjs.org/">nuxt.js</a>로 웹 앱을 주로 만들고 있고, 국제화 처리를 위해 <a href="https://i18n.nuxtjs.org/">nuxt-i18n</a> 모듈을 함께 사용하고 있습니다.</p>

<p>이런 환경에서, 국제화 처리를 위해 별도로 key:value 형태로 텍스트를 모아 둔 파일(이하 사전으로 표현)을 다음과 같이 만들어 왔습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// locales/ko/index.js 파일</span>
<span class="k">export</span> <span class="k">default</span> <span class="p">{</span>
  <span class="na">common</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">제목</span><span class="dl">'</span><span class="p">,</span>
    <span class="c1">// ...</span>
  <span class="p">},</span>
  <span class="c1">// 다른 속성들</span>
<span class="p">}</span>
</code></pre></div></div>

<p>당연한 얘기이지만 위와 같은 형태로 작업하면, 번역된 텍스트가 변경 될 때마다 해당 텍스트 파일을 수정해야 하고, 그 이후에 별도로 배포를 해야 해당 환경에 적용 됩니다.</p>

<p>이렇게 수동으로 사전 파일을 작성해야 한다는 것은 매우 불편하고, 또 실수하기 쉬운 일이라고 생각했습니다.</p>

<p>그래서 이 파일을 손으로 직접 작성하지 않고, 구글 시트에 저장된 내용을 기반으로 파일이 만들어지도록 하는 컨셉에서, 더 나아가 아예 파일을 만들지 않고 구글 시트의 내용을 바로 끌어다 쓰면 어떨까 하는 생각에서 이 작업을 시작하게 되었습니다.</p>

<p>이를 구현하기 위해, 이전에 만들었던 <a href="https://www.npmjs.com/package/public-google-sheets-parser">public-google-sheets-parser</a> 라이브러리를 기반으로 한 <a href="https://www.npmjs.com/package/nuxt-google-sheets-parser">nuxt-google-sheets-parser</a> 모듈을 통해 예시용 웹앱을 한번 작성 해 보려고 합니다.</p>

<p>실제로 얼마나 유용하게 쓰일 수 있을지는 잘 모르겠지만, 최소한 개발 단계에서는 사전용 파일을 만들지 않아도, 원하는 키에 원하는 값들을 편리하게 정의하고 사용할 수 있다는 점이 장점이 될 것이라 생각하며 글 작성을 시작합니다.</p>

<h3 id="설계-과정">설계 과정</h3>

<p>먼저, <a href="https://goree.kr/jA9VAA">구글 시트 문서</a>를 다음과 같은 형태로 준비합니다.</p>

<table>
  <thead>
    <tr>
      <th>ko</th>
      <th>en</th>
      <th>ja</th>
      <th>key</th>
      <th>key1</th>
      <th>key2</th>
      <th>…</th>
      <th>key10</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>제목</td>
      <td>title</td>
      <td>タイトル</td>
      <td><strong>수식</strong></td>
      <td>common</td>
      <td>title</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>내용</td>
      <td>Contents</td>
      <td>内容</td>
      <td><strong>수식</strong></td>
      <td>common</td>
      <td>description</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>버튼</td>
      <td>button</td>
      <td>ボタン</td>
      <td><strong>수식</strong></td>
      <td>common</td>
      <td>button</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>문서 경로</td>
      <td>Documents path</td>
      <td>ドキュメントパス</td>
      <td><strong>수식</strong></td>
      <td>common</td>
      <td>sheetsPath</td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<ul>
  <li>
    <p><a href="https://goree.kr/jA9VAA">참고용 샘플 문서 링크</a></p>
  </li>
  <li>
    <p>위 문서는 ko 필드에만 한글 텍스트를 넣었고, en, ja 필드 값은 전부 <a href="https://goree.kr/imaDrw">GOOGLETRANSLATE 함수</a>를 통해 구글이 번역해준 결과를 사용하도록 해 두었습니다. 실제 번역된 텍스트가 나오기 전 까지 임시로 사용할 수 있을 것이며, 이 부분은 최종적으로 검수를 마친 텍스트로 대체되어야 할 것입니다.</p>
  </li>
  <li>
    <p>입력이 불편해서 시트의 key 열이 key10의 오른쪽에 있었으면 하는 생각이 드신다면, 그렇게 이동하시고 위 함수의 참조 셀들의 값을 변경하시면 됩니다. 또한 key10이 너무 많다면, 불필요한 만큼 지우고 사용하셔도 됩니다.</p>
  </li>
</ul>

<p>각각의 <strong>key</strong> 셀에는 다음과 같은 함수를 넣어, key1 ~ key10까지의 값을 dot(.)을 기준으로 병합되여 표현되도록 했습니다. depth가 더 필요한 경우엔 그만큼 추가하거나, 감소할 수 있습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// D2 셀 기준, 그 아래 셀은 이 수식을 복사하여 사용</span>
<span class="o">=</span><span class="nx">CONCATENATE</span><span class="p">(</span>
  <span class="nx">E2</span><span class="p">,</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">F2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">F2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">G2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">G2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">H2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">H2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">I2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">I2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">J2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">J2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">K2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">K2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">L2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">L2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">M2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">M2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">N2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">N2</span><span class="p">,</span> <span class="dl">""</span><span class="p">),</span>
  <span class="nx">IF</span><span class="p">(</span><span class="nx">O2</span> <span class="o">&lt;&gt;</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="o">&amp;</span><span class="nx">O2</span><span class="p">,</span> <span class="dl">""</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>이렇게 만들어진 key와 locale(ko, en, ja, …) 셀에 담긴 값들을 기준삼아 Javascript의 Object로는 다음과 같이 표현되기를 기대했습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// key필드에 담긴 값이 'common.title'이고, ko 필드에 담긴 값이 '제목'인 경우</span>
<span class="p">{</span>
  <span class="nl">common</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">제목</span><span class="dl">'</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>위 형태로 얻은 결과를 사전으로 사용할 수 있도록, locale/${locale}/index.js 파일을 다음과 같은 형태로 작성했습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// locale/base.js</span>
<span class="k">import</span> <span class="kd">set</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">lodash/set</span><span class="dl">'</span>

<span class="k">export</span> <span class="k">default</span> <span class="k">async</span> <span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">locale</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// https://docs.google.com/spreadsheets/d/1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ/edit</span>
  <span class="kd">const</span> <span class="nx">sheetId</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ</span><span class="dl">'</span>
  <span class="kd">const</span> <span class="nx">sheetName</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">dictionary</span><span class="dl">'</span>
  <span class="kd">const</span> <span class="nx">dictionary</span> <span class="o">=</span> <span class="p">{}</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nx">$gsparser</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">sheetId</span><span class="p">,</span> <span class="nx">sheetName</span><span class="p">)</span>
  <span class="nx">response</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">item</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kd">set</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">,</span> <span class="nx">item</span><span class="p">.</span><span class="nx">key</span><span class="p">,</span> <span class="nx">item</span><span class="p">[</span><span class="nx">locale</span><span class="p">]))</span>

  <span class="k">return</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1">// locale/ko/index.js</span>
<span class="k">import</span> <span class="nx">base</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">../base</span><span class="dl">'</span>

<span class="k">export</span> <span class="k">default</span> <span class="p">(</span><span class="nx">context</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">base</span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="dl">'</span><span class="s1">ko</span><span class="dl">'</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1">// locale/en/index.js</span>
<span class="k">import</span> <span class="nx">base</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">../base</span><span class="dl">'</span>

<span class="k">export</span> <span class="k">default</span> <span class="p">(</span><span class="nx">context</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">base</span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="dl">'</span><span class="s1">ko</span><span class="dl">'</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1">// 그 외 필요한 만큼 생성..</span>
</code></pre></div></div>

<p>이렇게 준비한 뒤, nuxt.config.js 파일의 nuxt-i18n 설정을 다음과 같이 했습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// nuxt.config.js</span>
<span class="p">{</span>
  <span class="c1">// ...다른 설정 생략...</span>

  <span class="nl">modules</span><span class="p">:</span> <span class="p">[</span>
    <span class="p">[</span>
      <span class="dl">'</span><span class="s1">nuxt-i18n</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">{</span>
        <span class="na">locales</span><span class="p">:</span> <span class="p">[</span>
          <span class="p">{</span> <span class="na">code</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ko</span><span class="dl">'</span><span class="p">,</span> <span class="na">iso</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ko-KR</span><span class="dl">'</span><span class="p">,</span> <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ko/index.js</span><span class="dl">'</span> <span class="p">},</span>
          <span class="p">{</span> <span class="na">code</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">,</span> <span class="na">iso</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en-US</span><span class="dl">'</span><span class="p">,</span> <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en/index.js</span><span class="dl">'</span> <span class="p">},</span>
          <span class="p">{</span> <span class="na">code</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ja</span><span class="dl">'</span><span class="p">,</span> <span class="na">iso</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ja-JP</span><span class="dl">'</span><span class="p">,</span> <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ja/index.js</span><span class="dl">'</span> <span class="p">},</span>
        <span class="p">],</span>
        <span class="na">langDir</span><span class="p">:</span> <span class="dl">'</span><span class="s1">locales/</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">lazy</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="na">defaultLocale</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ko</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">strategy</span><span class="p">:</span> <span class="dl">'</span><span class="s1">prefix_and_default</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">vuex</span><span class="p">:</span> <span class="p">{</span>
          <span class="na">moduleName</span><span class="p">:</span> <span class="dl">'</span><span class="s1">i18n</span><span class="dl">'</span><span class="p">,</span>
          <span class="na">syncLocale</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
          <span class="na">syncMessages</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
          <span class="na">syncRouteParams</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="p">},</span>
      <span class="p">},</span>
    <span class="p">],</span>
    <span class="c1">// 반드시 nuxt-i18n 모듈 등록 이후에 등록되어야 합니다.</span>
    <span class="dl">'</span><span class="s1">nuxt-google-sheets-parser</span><span class="dl">'</span><span class="p">,</span>
    <span class="c1">// 다른 모듈 생략</span>
  <span class="p">],</span>

  <span class="c1">// ...다른 설정 생략...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>그리고 이렇게 연결된 상태라면, 다음과 같이 template 내에서 사용할 수 있습니다.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;template&gt;</span>
  <span class="nt">&lt;div&gt;</span>
    <span class="c">&lt;!-- key가 common.title인 국제화 텍스트가 알맞게 표현됩니다. --&gt;</span>
    <span class="nt">&lt;h1</span> <span class="na">:text=</span><span class="s">"$t('common.title')"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/template&gt;</span>
</code></pre></div></div>

<p>이처럼 연결시켜 두면, 별도의 파일을 만들지 않고도 국제화 텍스트를 쉽게 표현할 수 있습니다.</p>

<p>하지만 이렇게 두면 구글 시트 API에 장애가 발생했을 때, 사이트의 모든 텍스트가 깨질 수 있다는 치명적인 단점이 있습니다.</p>

<p>이 부분은 구글 시트 파일을 기반으로 사전용 파일을 자동 생성하여 소스코드에 포함되도록 해 주는 방식으로 커버할 수 있을 것이라고 생각했습니다.</p>

<p>그래서 아래와 같은 스크립트를 통해 locale별 fallback.json 파일이 자동으로 생성될 수 있도록 했습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// makeDictionary.js</span>
<span class="kd">const</span> <span class="nx">fs</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">fs</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">_</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">lodash</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">PublicGoogleSheetsParser</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">public-google-sheets-parser</span><span class="dl">'</span><span class="p">)</span>

<span class="kd">const</span> <span class="nx">parser</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PublicGoogleSheetsParser</span><span class="p">()</span>
<span class="kd">const</span> <span class="nx">sheetId</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">sheetName</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">dictionary</span><span class="dl">'</span>

<span class="kd">const</span> <span class="nx">targetLanguages</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">ko</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">ja</span><span class="dl">'</span><span class="p">]</span>

<span class="nx">parser</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">sheetId</span><span class="p">,</span> <span class="nx">sheetName</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">rows</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">dictionary</span> <span class="o">=</span> <span class="p">{}</span>

  <span class="c1">// 언어별 사전을 생성합니다. { [locale]: { key: value, key: value, ... } } 형태가 되도록 합니다.</span>
  <span class="nx">rows</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">targetLanguages</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">lang</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">_</span><span class="p">.</span><span class="kd">set</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">,</span> <span class="s2">`</span><span class="p">${</span><span class="nx">lang</span><span class="p">}</span><span class="s2">.</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">key</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="nx">row</span><span class="p">[</span><span class="nx">lang</span><span class="p">])))</span>

  <span class="c1">// 필요한 언어들에 대한 fallback 파일을 생성합니다.</span>
  <span class="nx">targetLanguages</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">lang</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">fs</span><span class="p">.</span><span class="nx">writeFileSync</span><span class="p">(</span><span class="s2">`./locales/</span><span class="p">${</span><span class="nx">lang</span><span class="p">}</span><span class="s2">/fallback.json`</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">[</span><span class="nx">lang</span><span class="p">])))</span>

  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">targetLanguages</span><span class="p">.</span><span class="nx">length</span><span class="p">}</span><span class="s2"> files created.`</span><span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<p>이제 필요할 때 마다 위 스크립트를 실행하면, 언어별 fallback 파일이 작성되도록 준비가 되었습니다. 터미널에서 아래 명령을 실행하면, locales 디렉토리에 각각의 fallback 파일이 생성됩니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>node makeDictionary
<span class="c"># 3 files created.</span>
</code></pre></div></div>

<p>위 부분을 package.json에 넣어서 사용해도 되고, 필요할 때 마다 그냥 호출해서 사용할 수 있을 것입니다.</p>

<p>추가된 fallback 파일의 지원을 위해, 위에 작성했던 base.js파일에 response가 falsy한 경우 fallback 파일을 읽어와서 사전으로 사용하도록 처리를 추가합니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="kd">set</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">lodash/set</span><span class="dl">'</span>

<span class="k">export</span> <span class="k">default</span> <span class="k">async</span> <span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">locale</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// https://docs.google.com/spreadsheets/d/1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ/edit</span>
  <span class="kd">const</span> <span class="nx">sheetId</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ</span><span class="dl">'</span>
  <span class="kd">const</span> <span class="nx">sheetName</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">dictionary</span><span class="dl">'</span>
  <span class="kd">const</span> <span class="nx">dictionary</span> <span class="o">=</span> <span class="p">{}</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nx">$gsparser</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">sheetId</span><span class="p">,</span> <span class="nx">sheetName</span><span class="p">)</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">response</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">item</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kd">set</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">,</span> <span class="nx">item</span><span class="p">.</span><span class="nx">key</span><span class="p">,</span> <span class="nx">item</span><span class="p">[</span><span class="nx">locale</span><span class="p">]))</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="na">default</span><span class="p">:</span> <span class="nx">fallbackDictionary</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s2">`./</span><span class="p">${</span><span class="nx">locale</span><span class="p">}</span><span class="s2">/fallback.json`</span><span class="p">)</span>
    <span class="nb">Object</span><span class="p">.</span><span class="nx">assign</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">,</span> <span class="nx">fallbackDictionary</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">dictionary</span><span class="p">)</span>
<span class="p">}</span>

</code></pre></div></div>

<p>이렇게 하면, API로부터 제대로된 응답을 돌려받지 못하게 되는 경우, fallback 파일을 사용할 수 있게 됩니다.</p>

<p>이런 방식으로 국제화를 구현하게 되면 개발 중에는 최대한 편하게 진행할 수 있는 것 같습니다.</p>

<p>만약 사전에 더 이상 추가될 것도, 수정될 것도 없다면, 파일만 가져와서 동작하도록 base.js 파일의 내용을 변경해서 사용할 수 있을 것 같습니다.</p>

<h3 id="결론">결론</h3>

<p>json파일을 신경쓰지 않고, 공개 권한을 가진 스프레드시트만으로 최대한 간단하게 국제화 처리를 구현할 수 있는 방법을 소개 해 보았습니다.</p>

<p>개발 단계이거나, 아직 안정화가 덜 되어 문서를 수시로 수정해야 하는 경우에 특히 요긴하게 사용할 수 있을 거라고 생각했습니다.</p>

<p>운영 환경에서는 동적으로 가져오지 않고, 등록된 fallback.json 파일만 사용하겠다면 그런 방식으로 설정해서 사용할 수도 있습니다.</p>

<p>이 내용을 간단히 정리하여 <a href="https://goree.kr/XpCFNQ">공개 저장소</a>에 등록 해 두었습니다.</p>

<p>읽어주신 분들에게 조금이라도 도움이 되는 내용이었으면 합니다.</p>

<p>읽어주셔서 감사합니다!</p>

<!-- contents end -->

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2021/03/16/story-of-development-about-nuxt-i18n-automation-example-project.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Typesense를 활용한 검색용 API 서버 그리고 데모용 웹페이지 만들기</title><link href="/blog/2020/12/15/creating-an-api-server-for-search-using-typesense-and-a-web-page-for-demo.html" rel="alternate" type="text/html" title="Typesense를 활용한 검색용 API 서버 그리고 데모용 웹페이지 만들기" /><published>2020-12-15T14:55:00+00:00</published><updated>2020-12-15T14:55:00+00:00</updated><id>/blog/2020/12/15/creating-an-api-server-for-search-using-typesense-and-a-web-page-for-demo</id><content type="html" xml:base="/blog/2020/12/15/creating-an-api-server-for-search-using-typesense-and-a-web-page-for-demo.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/12/15/creating-an-api-server-for-search-using-typesense-and-a-web-page-for-demo.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<!-- contents start -->

<p><img src="/assets/img/posts/20201215-typesense.png" alt="샘플 스프레드시트" /></p>

<h3 id="들어가며">들어가며</h3>

<p>개인적으로 몇 년 동안 머릿속에 담아두고 제대로 꺼내놓지 못한 상태의 프로젝트가 있습니다. 이 것을 구현하려면 빠른 검색 서버가 반드시 필요합니다. 이 상태에서 <a href="https://recipe-search.typesense.org/">2백만개 음식 레시피 검색 데모 사이트</a>를 처음 보자마자 Typesense라는 검색엔진에 특별한 관심을 가지게 되었던 것 같습니다. 데모 사이트에서 키워드로 검색해 본 결과가 엄청나게 빨랐거든요.</p>

<p>원래 <a href="https://fureweb-com.github.io/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html">우분투 20.04에서 typesense 서버 설치부터 node.js 클라이언트 예제 실행까지</a> 게시글을 통해 샘플 웹 페이지를 작성해서 한국어로 검색하는 부분까지 작성 해 보고 싶었는데, 그냥 검색엔진이 설치된 서버만 만들고 같은 서버 내에서 클라이언트로 요청 해 보는 아주 간소화된 공식 가이드 버전 답습하기 수준으로 마무리가 되어, 나머지 부분에 대한 내용을 별도로 정리하자 마음을 먹고 이 글을 작성하게 되었습니다.</p>

<p>이 글은 제가 깊이가 부족하여 그런 것도 있지만, 각각의 기술에 대한 깊이 있는 활용 방법보다는 Typesense라는 오픈소스 검색 엔진을 이용하여, 검색을 위한 웹 API 서버를 구축하기위해 여러 프로그램 및 서비스들을 어떻게 활용했는지에 대한 작업 과정을 개인적으로 정리해 봄과 동시에 이쪽에 관심을 가지고 계시는 다른 분들에게 공유하기 위한 목적으로 작성합니다.</p>

<p>아직 Typesense에는 한국어 형태소 분석기가 없는 것으로 알고 있는데요. 잠깐 본 것이 전부였지만, 정확하고 빠른 한국어 검색 결과를 얻지 못하는 한계점이 가장 아쉬웠습니다. 그래서 실제 묵혀두었던 아이디어를 구현하는 시점에는 Elasticsearch를 통해 검색 서버를 구현하게 될 것 같습니다. 하지만 정확하게 일치하는 키워드에 대한 검색 결과를 얻어야 하는 상황에서는 충분히 사용할 만한 가치가 있다고 생각합니다.</p>

<h3 id="데모용-웹-페이지">데모용 웹 페이지</h3>

<p>이 게시글을 작성하기 전에 <a href="https://api.fureweb.com/typesense.html">데모용 웹페이지</a>를 하나 만들어 두었습니다. 해당 페이지는 전국 행정동 정보를 담은 <a href="https://docs.google.com/spreadsheets/d/1aXq4ISWG-ionVc8b0SPgASxJi7WdbQ8oz2pJ56zooCY">구글 스프레드시트 문서</a>에 담긴 정보를 Typesense 검색엔진을 이용하여 빠른 검색 결과를 확인할 수 있도록 했습니다. Oracle의 무료 계층 인스턴스로도 충분히 빠른 검색 결과를 얻는 모습을 확인하실 수 있습니다. 다만 아웃바운드 트래픽을 마냥 낭비할 수는 없어, 최대 응답 갯수는 10개로 두었으나 무자비하도록 빠른 검색 요청-응답을 개발자도구 네트워크탭에서 확인하실 수 있도록 debounce를 적용하지 않았고, nginx의 burst 제한도 풀어 두었습니다. 그럴리는 없겠지만, 이후 과도한 트래픽이 유입된다면 클라이언트에는 debounce, 웹서버에는 burst 제한을 걸고 내려주는 응답의 갯수를 10개에서 n배 이상으로 늘리는 형태로 변경 될 수도 있습니다.</p>
<ul>
  <li>데모용 웹페이지: <a href="https://api.fureweb.com/typesense.html">https://api.fureweb.com/typesense.html</a></li>
  <li>구글 스프레드시트 문서: <a href="https://docs.google.com/spreadsheets/d/1aXq4ISWG-ionVc8b0SPgASxJi7WdbQ8oz2pJ56zooCY">https://docs.google.com/spreadsheets/d/1aXq4ISWG-ionVc8b0SPgASxJi7WdbQ8oz2pJ56zooCY</a></li>
</ul>

<p>위에서 언급한 데모용 웹페이지에서 사용된 <code class="language-plaintext highlighter-rouge">API 서버</code>와 <code class="language-plaintext highlighter-rouge">검색 서버</code>를 구축하기 위해 사용된 기술들을 정리 해 보고, 직접 구축하는 방법을 알아보겠습니다.</p>

<p><br /></p>

<h3 id="사용된-주요-기술">사용된 주요 기술</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">기술</th>
      <th style="text-align: left">설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Oracle Cloud VM 인스턴스<br />(VM.Standard.E2.1.Micro 2대, 항상 무료스펙)</td>
      <td style="text-align: left">웹 API용, Typesense용 서버 각 1대</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://ubuntu.com/">Ubuntu 20.04 LTS</a></td>
      <td style="text-align: left">운영체제</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://www.nginx.com/">nginx 1.18.0</a></td>
      <td style="text-align: left">웹 서버</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://letsencrypt.org/">Let’s Encrypt</a> using <a href="https://packages.debian.org/buster/python3-certbot-nginx">python3-certbot-nginx</a></td>
      <td style="text-align: left">TLS 인증서 발급용 (이번 게시글에서는 따로 설명하지 않습니다.)</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://typesense.org/">Typesense 0.17.0</a></td>
      <td style="text-align: left">검색엔진</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://nodejs.org">node.js v14.15.1</a></td>
      <td style="text-align: left">자바스크립트 런타임</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://www.npmjs.com/package/fastify">fastify 3.9.1</a></td>
      <td style="text-align: left">node.js용 web framework</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://www.npmjs.com/package/pm2">pm2 4.5.0</a></td>
      <td style="text-align: left">node.js용 프로세스 관리자</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://www.npmjs.com/package/typesense">typesense-js 0.9.1</a></td>
      <td style="text-align: left">node.js용 Typesense 클라이언트</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://www.npmjs.com/package/public-google-sheets-parser">public-google-sheets-parser 1.0.24</a></td>
      <td style="text-align: left">구글 스프레드시트 공개 문서를 JSON Array로 파싱하기 위한 라이브러리</td>
    </tr>
    <tr>
      <td style="text-align: left"><a href="https://vuejs.org/">Vue.js</a> / <a href="https://getbootstrap.com/">bootstrap v4.5</a> / <a href="https://www.npmjs.com/package/axios">axios</a></td>
      <td style="text-align: left">샘플 웹 페이지 제작을 위한 js/css 웹 프레임워크 및 http 클라이언트</td>
    </tr>
  </tbody>
</table>

<p><br /></p>

<h3 id="검색-키워드-입력-부터-응답을-받기까지의-주요-과정-설명">검색 키워드 입력 부터 응답을 받기까지의 주요 과정 설명</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">순서</th>
      <th style="text-align: left">처리주체</th>
      <th style="text-align: left">내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">1</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">웹페이지</code></td>
      <td style="text-align: left">웹 페이지의 input창에 focus된 상태에서 키보드가 keyup 될 때 마다 axios를 이용하여 검색 API 호출<br />(debounce 없이, trim된 키워드가 truthy 하다면 API 서버에 검색 요청)</td>
    </tr>
    <tr>
      <td style="text-align: left">2</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">API서버</code></td>
      <td style="text-align: left">전달받은 키워드가 truthy한 경우, typesense 클라이언트를 이용해 검색 서버에 질의</td>
    </tr>
    <tr>
      <td style="text-align: left">3</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">검색서버</code></td>
      <td style="text-align: left">질의 결과를 API 서버로 응답</td>
    </tr>
    <tr>
      <td style="text-align: left">4</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">API서버</code></td>
      <td style="text-align: left">검색서버로부터 전달받은 응답을 highlight된 snippet만 내려주도록 가공하여 응답 처리</td>
    </tr>
    <tr>
      <td style="text-align: left">5</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">웹페이지</code></td>
      <td style="text-align: left">웹 페이지에서 발생시킨 http 요청에 대한 응답을 vue.js를 이용하여 화면에 표현</td>
    </tr>
  </tbody>
</table>

<p><br /></p>

<h3 id="각각의-서버를-직접-구축하는-방법">각각의 서버를 직접 구축하는 방법</h3>

<h4 id="검색-서버-구축">검색 서버 구축</h4>
<p><a href="https://fureweb-com.github.io/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html">이 전 게시글</a>에서 설명한 대로 Typesense를 설치한 서버가 준비되어있어야 합니다. GPLv3 라이센스가 걸려있지만 검색 엔진은 별도의 서버 또는 Docker를 통해 운영되기때문에 라이센스로 인한 문제가 발생할 일은 없을 것으로 보입니다.</p>

<p>이 전 게시글을 통해 검색 서버 설치가 완료 되었다면
1) <code class="language-plaintext highlighter-rouge">~/typesense-client</code> 디렉토리로 이동한 뒤,
2) <code class="language-plaintext highlighter-rouge">vi make-data.js</code> 명령을 실행하여 <strong>아래의 코드</strong>를 복사 &amp; 붙여넣기 해 줍니다.
3) 이 후 <code class="language-plaintext highlighter-rouge">node make-data.js</code> 명령을 실행하면, <code class="language-plaintext highlighter-rouge">address</code> collection과 documents가 생성되고, 테스트 쿼리 결과가 콘솔에 출력됩니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// make-data.js</span>
<span class="kd">const</span> <span class="nx">Typesense</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">typesense</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">PublicGoogleSheetsParser</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">public-google-sheets-parser</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">parser</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PublicGoogleSheetsParser</span><span class="p">()</span>

<span class="c1">// collection 및 document 생성을 위한 client 준비</span>
<span class="kd">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Typesense</span><span class="p">.</span><span class="nx">Client</span><span class="p">({</span>
  <span class="na">nodes</span><span class="p">:</span> <span class="p">[{</span>
    <span class="na">host</span><span class="p">:</span> <span class="dl">'</span><span class="s1">localhost</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">port</span><span class="p">:</span> <span class="dl">'</span><span class="s1">8108</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">protocol</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http</span><span class="dl">'</span>
  <span class="p">}],</span>
  <span class="na">apiKey</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/etc/typesense/typesense-server.ini에 기록된 키</span><span class="dl">'</span><span class="p">,</span> <span class="c1">// 반드시 수정 해 주세요</span>
  <span class="na">connectionTimeoutSeconds</span><span class="p">:</span> <span class="mi">2</span>
<span class="p">})</span>

<span class="c1">// collection 생성</span>
<span class="kd">const</span> <span class="nx">collectionName</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">address</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">addressSchema</span> <span class="o">=</span> <span class="p">{</span>
  <span class="dl">'</span><span class="s1">name</span><span class="dl">'</span><span class="p">:</span> <span class="nx">collectionName</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">fields</span><span class="dl">'</span><span class="p">:</span> <span class="p">[</span>
    <span class="p">{</span><span class="dl">'</span><span class="s1">name</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span><span class="dl">'</span><span class="s1">name</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">index</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">int32</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">],</span>
  <span class="dl">'</span><span class="s1">default_sorting_field</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">index</span><span class="dl">'</span>
<span class="p">}</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">().</span><span class="nx">create</span><span class="p">(</span><span class="nx">addressSchema</span><span class="p">)</span>

<span class="c1">// documents import 처리</span>
<span class="kd">const</span> <span class="nx">rawAddress</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">parser</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="dl">'</span><span class="s1">1aXq4ISWG-ionVc8b0SPgASxJi7WdbQ8oz2pJ56zooCY</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">address</span> <span class="o">=</span> <span class="nx">rawAddress</span><span class="p">.</span><span class="nx">map</span><span class="p">(({</span> <span class="nx">id</span> <span class="p">},</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="nx">id</span><span class="p">,</span> <span class="nx">index</span> <span class="p">}))</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="nx">collectionName</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="k">import</span><span class="p">(</span><span class="nx">address</span><span class="p">,</span> <span class="p">{</span> <span class="na">action</span><span class="p">:</span> <span class="dl">'</span><span class="s1">create</span><span class="dl">'</span> <span class="p">})</span>

<span class="c1">// documents가 잘 저장되었는지 테스트 쿼리 전송 및 결과 확인</span>
<span class="kd">const</span> <span class="nx">searchParameters</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">q</span><span class="p">:</span> <span class="dl">'</span><span class="s1">수지구 죽전동</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">query_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">per_page</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">searchResult</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="nx">collectionName</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="nx">search</span><span class="p">(</span><span class="nx">searchParameters</span><span class="p">)</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">searchResult</span><span class="p">)</span>
</code></pre></div></div>

<p>검색 서버 &lt;-&gt; API 서버는 HTTP 프로토콜만으로 통신하도록 해 두면 검색 서버에는 별도의 TLS 인증서를 설치하지 않아도 됩니다.</p>

<p>저는 검색서버의 80번 포트를 열었고, 80번 포트로 요청이 들어오면 nginx의 proxy_pass를 통해 typesense의 기본 포트인 8108쪽으로 요청과 응답이 처리되도록 설정 해 두었습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 다음의 명령으로 검색서버용 nginx 설정파일을 하나 만들어 줍니다.</span>
<span class="nb">sudo </span>vi /etc/nginx/conf.d/search.conf
</code></pre></div></div>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/nginx/conf.d/search.conf 파일 내용:
</span><span class="n">server</span> {
  <span class="n">listen</span> <span class="m">80</span>;
  <span class="n">location</span> / {
    <span class="n">proxy_pass</span> <span class="n">http</span>://<span class="m">127</span>.<span class="m">0</span>.<span class="m">0</span>.<span class="m">1</span>:<span class="m">8108</span>;
    <span class="n">proxy_http_version</span> <span class="m">1</span>.<span class="m">1</span>;
  }
}
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 다음의 명령으로 nginx를 reload합니다.</span>
<span class="nb">sudo </span>systemctl reload nginx

<span class="c"># 다음의 명령을 통해 검색서버의 public IP를 확인 해 두세요.</span>
curl https://api.fureweb.com/ip

<span class="c"># 위에서 확인한 ip의 80번 포트로 HTTP GET 요청이 정상적으로 되는지 확인 해 봅니다.</span>
curl 위에서-얻은-public-ip
<span class="c"># { "message": "Not Found"} 이런 응답이 와야 정상입니다.</span>
</code></pre></div></div>

<p>만약 응답이 오지 않는다면, 80번 포트가 정상적으로 열려있는지 확인 후 조치가 필요합니다. 이 부분은 클라우드 업체마다 다를 수 있어서 별도 확인 후 처리를 진행 해 주세요.</p>

<p>이제 검색서버에서 할 일은 모두 마무리 되었습니다. 클라우드 서비스의 방화벽 설정에서 API 서버가 아닌 다른 IP에서 요청이 들어오는 경우 Drop 처리되도록 하는 설정 등은 선택적으로 진행하셔도 됩니다.</p>

<h4 id="api-서버-구축">API 서버 구축</h4>
<p>저는 <a href="https://www.npmjs.com/package/fastify">fastify</a>를 이용해서 간단하게 API 서버를 만들었습니다. 아래 경로는 원하는대로 사용하셔도 됩니다.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># api 디렉토리를 사용자 홈 하위에 생성한 뒤 이동합니다.</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/api <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/api
<span class="c"># 초기화 후 fastify API 서버 코드를 작성하기 위한 패키지들을 설치합니다.</span>
npm init <span class="nt">-y</span> <span class="o">&amp;&amp;</span> npm <span class="nb">install </span>fastify fastify-cors pm2 http-errors typesense @babel/runtime
<span class="c"># pm2를 통해 실행 시킬 파일을 하나 생성합니다.</span>
vi app.js
</code></pre></div></div>

<p>아래의 코드를 app.js 내용으로 만들어 주세요. 단, 내용 중 검색 서버 IP와 apiKey는 상황에 맞게 수정 해 주셔야합니다. 실제로는 이런 형태로 코드를 작성하지 않지만, 최대한 간단하게 설명하기 위해 하나의 파일로 묶어 두었습니다.</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app.js (아래 검색서버 IP와 apiKey는 반드시 변경 해 주셔야 합니다.)</span>
<span class="kd">const</span> <span class="nx">httpErrors</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">http-errors</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">Typesense</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">typesense</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">typesenseClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Typesense</span><span class="p">.</span><span class="nx">Client</span><span class="p">({</span>
  <span class="na">nodes</span><span class="p">:</span> <span class="p">[{</span>
    <span class="na">host</span><span class="p">:</span> <span class="dl">'</span><span class="s1">typesense 서버 IP</span><span class="dl">'</span><span class="p">,</span> <span class="c1">// 반드시 변경 해 주세요</span>
    <span class="na">port</span><span class="p">:</span> <span class="dl">'</span><span class="s1">80</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">protocol</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http</span><span class="dl">'</span>
  <span class="p">}],</span>
  <span class="na">apiKey</span><span class="p">:</span> <span class="dl">'</span><span class="s1">master-or-search-only-api-key</span><span class="dl">'</span><span class="p">,</span> <span class="c1">// 반드시 변경 해 주세요</span>
  <span class="na">connectionTimeoutSeconds</span><span class="p">:</span> <span class="mi">2</span>
<span class="p">})</span>

<span class="kd">const</span> <span class="nx">fastify</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">fastify</span><span class="dl">'</span><span class="p">)({</span> <span class="na">trustProxy</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>

<span class="c1">// cors 허용을 위한 플러그인 등록</span>
<span class="nx">fastify</span><span class="p">.</span><span class="nx">register</span><span class="p">(</span><span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">fastify-cors</span><span class="dl">'</span><span class="p">))</span>

<span class="c1">// routes에 추가</span>
<span class="nx">fastify</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/search/address</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">keyword</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">query</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">keyword</span><span class="p">)</span> <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">httpErrors</span><span class="p">.</span><span class="nx">BadRequest</span><span class="p">(</span><span class="dl">'</span><span class="s1">keyword is required</span><span class="dl">'</span><span class="p">))</span>

  <span class="c1">// 검색 서버로 질의 및 응답용 결과 가공</span>
  <span class="kd">const</span> <span class="nx">searchParameters</span> <span class="o">=</span> <span class="p">{</span> <span class="na">q</span><span class="p">:</span> <span class="nx">keyword</span><span class="p">,</span> <span class="na">query_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">,</span> <span class="na">per_page</span><span class="p">:</span> <span class="mi">10</span> <span class="p">}</span>
  <span class="kd">const</span> <span class="nx">searchResults</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">typesenseClient</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="dl">'</span><span class="s1">address</span><span class="dl">'</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="nx">search</span><span class="p">(</span><span class="nx">searchParameters</span><span class="p">)</span>
  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nx">searchResults</span><span class="p">.</span><span class="nx">hits</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">highlights</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">||</span> <span class="p">{}).</span><span class="nx">snippet</span> <span class="o">||</span> <span class="nx">res</span><span class="p">.</span><span class="nb">document</span><span class="p">.</span><span class="nx">id</span><span class="p">)</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">result</span><span class="p">)</span>
<span class="p">})</span>

<span class="c1">// API 서버 실행</span>
<span class="nx">fastify</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">3000</span><span class="p">,</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">address</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="k">throw</span> <span class="nx">err</span>
  <span class="nx">fastify</span><span class="p">.</span><span class="nx">log</span><span class="p">.</span><span class="nx">info</span><span class="p">(</span><span class="s2">`server listening on </span><span class="p">${</span><span class="nx">address</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<p>app.js 파일을 정상적으로 생성했다면, 다음 명령을 통해 pm2로 서버를 실행시킵니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pm2를 이용하여 app.js 파일 실행</span>
npx pm2 start app.js

<span class="c"># API 서버가 실행됐다면, 아래의 명령을 통해 응답이 정상적으로 내려오는지 확인합니다.</span>
curl localhost:3000/search/address?keyword<span class="o">=</span>%EC%A3%BD%EC%A0%84%EB%8F%99
</code></pre></div></div>

<p>이제 API 서버도 준비가 완료 되었습니다. 검색 서버에서 nginx config파일을 추가한 뒤 reload 한 것 처럼, API 서버도 같은 작업을 진행 해 줍니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 서버 IP 80번 포트에 대한 기본 설정 파일에 대한 링크를 삭제합니다.</span>
<span class="c"># sites-enabled 디렉토리 내 파일은 /etc/nginx/sites-available 파일에 대한 심볼릭 링크입니다.</span>
<span class="nb">sudo rm</span> /etc/nginx/sites-enabled/default

<span class="c"># 다음의 명령으로 API 서버용 nginx 설정파일을 하나 만들어 줍니다.</span>
<span class="nb">sudo </span>vi /etc/nginx/conf.d/api.conf
</code></pre></div></div>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/nginx/conf.d/api.conf 파일 내용:
</span><span class="n">server</span> {
  <span class="n">listen</span> <span class="m">80</span>;
  <span class="n">location</span> / {
    <span class="n">proxy_pass</span> <span class="n">http</span>://<span class="m">127</span>.<span class="m">0</span>.<span class="m">0</span>.<span class="m">1</span>:<span class="m">3000</span>$<span class="n">uri</span>$<span class="n">is_args</span>$<span class="n">args</span>; <span class="c"># URI와 파라미터를 모두 fastfy쪽으로 전달합니다.
</span>    <span class="n">proxy_http_version</span> <span class="m">1</span>.<span class="m">1</span>;
  }
}
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 다음의 명령으로 nginx를 reload합니다.</span>
<span class="nb">sudo </span>systemctl reload nginx

<span class="c"># 다음의 명령을 통해 API 서버의 public IP를 확인 해 보세요.</span>
curl https://api.fureweb.com/ip

<span class="c"># API 서버의 public IP로 검색 요청을 해 보고 정상 요청이 일어나는지 확인 해 봅니다.</span>
curl 위에서-얻은-public-ip/search/address?keyword<span class="o">=</span>%EC%A3%BD%EC%A0%84%EB%8F%99
<span class="c"># ["경상북도 상주시 &lt;mark&gt;죽전동&lt;/mark&gt;",... 같은 형태의 응답이 내려와야 정상입니다.</span>
</code></pre></div></div>

<p>만약 응답이 오지 않는다면, 80번 포트가 정상적으로 열려있는지 확인 후 조치가 필요합니다. 이 부분은 클라우드 업체마다 다를 수 있어서 별도 확인 후 처리를 진행 해 주세요.</p>

<p>서버의 public IP로 요청한 응답이 기대한 대로 내려왔다면, API 서버에 대한 설정도 완료 되었습니다.</p>

<p><br /></p>

<h3 id="데모용-웹-페이지-만들기">데모용 웹 페이지 만들기</h3>
<p>현재 사용중인 PC의 적절한 위치에 다음과 같은 내용을 담은 HTML 파일을 하나 만들어 봅니다.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"ko"</span><span class="nt">&gt;</span>
<span class="nt">&lt;head&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"viewport"</span> <span class="na">content=</span><span class="s">"width=device-width, initial-scale=1.0"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;title&gt;</span>Typesense 검색 엔진을 이용한 행정동 한글 검색 성능 테스트<span class="nt">&lt;/title&gt;</span>
  <span class="nt">&lt;link</span> <span class="na">href=</span><span class="s">"https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&amp;display=swap"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"</span> <span class="na">integrity=</span><span class="s">"sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2"</span> <span class="na">crossorigin=</span><span class="s">"anonymous"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;style&gt;</span>
    <span class="nt">body</span> <span class="p">{</span>
      <span class="nl">font-size</span><span class="p">:</span> <span class="m">16px</span><span class="p">;</span>
      <span class="nl">font-family</span><span class="p">:</span> <span class="s2">'Noto Sans KR'</span><span class="p">,</span> <span class="nb">sans-serif</span><span class="p">;</span>
      <span class="nl">max-width</span><span class="p">:</span> <span class="m">1000px</span><span class="p">;</span>
      <span class="nl">margin</span><span class="p">:</span> <span class="m">0</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nc">.search-wrap</span> <span class="p">{</span>
      <span class="nl">padding</span><span class="p">:</span> <span class="m">1.5rem</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nc">.search-result</span> <span class="p">{</span>
      <span class="nl">margin-top</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nc">.card</span> <span class="p">{</span>
      <span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nc">.card-body</span> <span class="p">{</span>
      <span class="nl">background-color</span><span class="p">:</span> <span class="m">#efffff</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nc">.search-result</span> <span class="p">{</span>
      <span class="nl">min-height</span><span class="p">:</span> <span class="m">300px</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="nt">&lt;/style&gt;</span>
<span class="nt">&lt;/head&gt;</span>
<span class="nt">&lt;body&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"search-wrap"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h3&gt;</span>Typesense 검색 엔진을 이용한 행정동 한글 검색 성능 테스트<span class="nt">&lt;/h3&gt;</span>
    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"keyword"</span><span class="nt">&gt;</span>검색 키워드<span class="nt">&lt;/label&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"keyword"</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">autocomplete=</span><span class="s">"off"</span> <span class="na">class=</span><span class="s">"form-control"</span> <span class="na">placeholder=</span><span class="s">"검색할 행정동 키워드를 입력하세요. 예) 수지구 죽전동"</span> <span class="err">@</span><span class="na">input=</span><span class="s">"keyword = $event.target.value"</span> <span class="err">@</span><span class="na">keyup=</span><span class="s">"search"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;section</span> <span class="na">class=</span><span class="s">"search-result"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;h4&gt;</span>검색 결과<span class="nt">&lt;/h4&gt;</span>
      <span class="nt">&lt;ul</span> <span class="na">v-if=</span><span class="s">"searchResult.length &gt; 0"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;li</span> <span class="na">v-for=</span><span class="s">"result of searchResult"</span> <span class="na">v-html=</span><span class="s">"result"</span><span class="nt">&gt;&lt;/li&gt;</span>
      <span class="nt">&lt;/ul&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">v-else</span><span class="nt">&gt;</span>검색 결과가 없습니다.<span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/section&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/vue@2.6.0"</span><span class="nt">&gt;&lt;/script&gt;</span>
  <span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://unpkg.com/axios/dist/axios.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
  <span class="nt">&lt;script&gt;</span>
  <span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">APIServer</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">API 서버의 ip 주소를 입력해 주세요</span><span class="dl">'</span>

    <span class="k">new</span> <span class="nx">Vue</span><span class="p">({</span>
      <span class="na">el</span><span class="p">:</span> <span class="dl">'</span><span class="s1">.search-wrap</span><span class="dl">'</span><span class="p">,</span>
      <span class="nx">data</span> <span class="p">()</span> <span class="p">{</span>
        <span class="k">return</span> <span class="p">{</span>
          <span class="na">keyword</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
          <span class="na">searchResult</span><span class="p">:</span> <span class="p">[],</span>
        <span class="p">}</span>
      <span class="p">},</span>
      <span class="na">methods</span><span class="p">:</span> <span class="p">{</span>
        <span class="k">async</span> <span class="nx">search</span> <span class="p">()</span> <span class="p">{</span>
          <span class="kd">const</span> <span class="nx">keyword</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">keyword</span><span class="p">).</span><span class="nx">trim</span><span class="p">()</span>
          <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">keyword</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">searchResult</span> <span class="o">=</span> <span class="p">[]</span>
          <span class="p">}</span>

          <span class="k">this</span><span class="p">.</span><span class="nx">searchResult</span> <span class="o">=</span> <span class="p">(</span><span class="k">await</span> <span class="nx">axios</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="s2">`http://</span><span class="p">${</span><span class="nx">APIServer</span><span class="p">}</span><span class="s2">/search/address?keyword=</span><span class="p">${</span><span class="nx">keyword</span><span class="p">}</span><span class="s2">`</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">data</span><span class="p">))</span> <span class="o">||</span> <span class="p">[]</span>
        <span class="p">},</span>
      <span class="p">},</span>
    <span class="p">})</span>
  <span class="p">})()</span>
  <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>위 내용 중 <code class="language-plaintext highlighter-rouge">APIServer</code> 변수의 값은 API 서버의 public 아이피로 업데이트 해 주세요.</p>

<p>위 html 페이지를 원하는 이름으로 저장한 뒤, 해당 파일을 브라우저에서 열어 API 서버와 검색 서버의 응답이 얼마나 빨리 내려오는지 한번 확인 해 보세요!</p>

<p><br /></p>

<h3 id="마무리">마무리</h3>

<p>웹페이지에서 각 요청에 대한 네트워크 응답속도를 확인 해 보고 너무 놀랐었는데, 다른분들은 어떠셨을지 잘 모르겠네요. 요청자의 ip만 내려주는 간단한 API도 응답에 6~15ms가 소요되었는데, API 서버에서 검색 서버까지 들렀다 오는데도 많이 차이가 나지 않는 수준의 응답속도를 보여준다는게 정말 믿겨지지 않았습니다.</p>

<p>얼마나 검색 속도가 빠를까, 내가 직접 만들어볼 수 있을까? 라는 질문에서 시작하긴 했던 컨텐츠였는데 생각보다는 큰 어려움이 없었습니다. 런타임에 컬렉션에 대한 수정을 한다거나, 기록 시 발생할 수 있는 동시성 문제 등에 대해서는 경험 해 보지 못했기때문에 이 부분도 조금 궁금함으로 남기고 마무리 하게 되는 것 같습니다. 무엇보다도, 아직 한국어 형태소 분석기의 부재로 정확한 결과를 얻을 수 없다는 점이 가장 아쉬운 점인 것 같네요.</p>

<p>뭔가 정리하다보니 원래 이렇게 쓰려고 했던게 맞는지 잊어버리고 급 마무리가 되는 느낌이긴 합니다 ^^;</p>

<p>위에 작성해 둔 코드들의 실행 여부를 확인하며 게시글을 작성했지만, 정리하는 과정에서 누락되거나 충분하지 않은 설명이 있을 수 있으니 만약 내용과 관련하여 궁금하신 점이 있다면 언제든 문의를 남겨주세요.</p>

<p>긴 글 읽어주셔서 감사합니다!</p>

<!-- contents end -->

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/12/15/creating-an-api-server-for-search-using-typesense-and-a-web-page-for-demo.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">우분투 20.04에서 typesense 서버 설치부터 node.js 클라이언트 예제 실행까지</title><link href="/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html" rel="alternate" type="text/html" title="우분투 20.04에서 typesense 서버 설치부터 node.js 클라이언트 예제 실행까지" /><published>2020-12-13T15:30:00+00:00</published><updated>2020-12-13T15:30:00+00:00</updated><id>/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04</id><content type="html" xml:base="/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<!-- contents start -->

<p><img src="/assets/img/posts/20201214-typesense-intro.png" alt="샘플 스프레드시트" /></p>

<h3 id="들어가며">들어가며</h3>

<p>얼마 전 <a href="https://news.hada.io/topic?id=3370">geeknews에 등록된 2백만개 음식 레시피 검색 엔진</a> 게시글을 읽어본 뒤, 이 엔진의 검색 속도가 정말 빠르기에 한번 사용해보고싶다는 생각이 들어서 간단하게 서버에 설치 후 클라이언트를 사용 해 보았습니다.
현재의 게시글에서는 웹애플리케이션을 통해 검색결과를 표현하는 형태가 아닌, 공식 가이드 문서에서 설명하고 있는 방법을 한글 샘플 데이터로 간단히 사용하는 방법을 알아보려고 합니다. 실제로 한번 설치 후 사용 해 보니 그리 어렵지 않게 사용할 수 있을 것 같아서, 정리할 겸 이렇게 게시글을 작성하게 되었습니다.</p>

<p><br /></p>

<h3 id="사전-준비">사전 준비</h3>
<h4 id="필수">필수</h4>
<ul>
  <li>Ubuntu 운영체제 (아래 모든 코드는 우분투 20.04 기준으로 테스트 되었습니다.)</li>
  <li>root 또는 sudo 명령을 사용할 수 있는 권한을 가진 사용자로 서버에 SSH 접속이 가능해야 함</li>
</ul>

<h4 id="선택">선택</h4>
<ul>
  <li>node.js v14.x 사전 설치 (현재 설치되어있지 않은 경우 아래 과정을 통해 설치 
가능)</li>
</ul>

<p><br /></p>

<h3 id="typesense-및-nodejs-v14x-설치">Typesense 및 Node.js v14.x 설치</h3>

<h4 id="ubuntu용-pre-built-바이너리-다운로드">Ubuntu용 pre-built 바이너리 다운로드</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 현재 기준 최신버전 0.17.0</span>
wget <span class="nt">--trust-server-names</span> https://dl.typesense.org/releases/0.17.0/typesense-server-0.17.0-amd64.deb
</code></pre></div></div>

<h4 id="바이너리-설치">바이너리 설치</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 설치 성공 후 자동으로 typesense-server가 실행됨</span>
<span class="nb">sudo </span>apt <span class="nb">install</span> ./typesense-server-0.17.0-amd64.deb
</code></pre></div></div>

<h4 id="기본-설정-파일-확인">기본 설정 파일 확인</h4>
<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">; /etc/typesense/typesense-server.ini
; Typesense Configuration
</span>
<span class="nn">[server]</span>

<span class="py">api-address</span> <span class="p">=</span> <span class="s">0.0.0.0</span>
<span class="py">api-port</span> <span class="p">=</span> <span class="s">8108</span>
<span class="py">data-dir</span> <span class="p">=</span> <span class="s">/var/lib/typesense</span>
<span class="py">api-key</span> <span class="p">=</span> <span class="s">자동으로생성된마스터API키</span>
<span class="py">log-dir</span> <span class="p">=</span> <span class="s">/var/log/typesense</span>
</code></pre></div></div>

<h4 id="서버-실행상태-확인">서버 실행상태 확인</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl http://localhost:8108/health
<span class="c"># 기대 응답&gt; {"ok":true}</span>
</code></pre></div></div>

<h4 id="nodejs-v14x-설치">node.js v14.x 설치</h4>
<ul>
  <li><strong>이미 설치되어 있는 경우 생략 가능</strong></li>
  <li><a href="https://github.com/nodesource/distributions/blob/master/README.md">설치 참고 문서</a></li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sL</span> https://deb.nodesource.com/setup_14.x | <span class="nb">sudo</span> <span class="nt">-E</span> bash -
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> nodejs
</code></pre></div></div>

<p><br /></p>

<h3 id="typesense-client-설치">Typesense client 설치</h3>

<h4 id="같은-서버의-typesense-client-디렉토리에-실습을-위한-nodejs용-패키지-설치">같은 서버의 ~/typesense-client 디렉토리에 실습을 위한 node.js용 패키지 설치</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> ~/typesense-client
<span class="nb">cd</span> ~/typesense-client
npm <span class="nb">install </span>typesense @babel/runtime public-google-sheets-parser
</code></pre></div></div>

<p><br /></p>

<h3 id="예제-코드-실습">예제 코드 실습</h3>
<h4 id="node-repl-실행">node REPL 실행</h4>
<ul>
  <li>node v14.x에서 await를 async 함수 본문이 아니어도 사용할 수 있도록 하는 <code class="language-plaintext highlighter-rouge">--experimental-repl-await</code> 옵션을 준 뒤 REPL을 실행합니다.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node <span class="nt">--experimental-repl-await</span>
</code></pre></div></div>

<h4 id="실습에-필요한-패키지-선언-및-클라이언트-객체-생성">실습에 필요한 패키지 선언 및 클라이언트 객체 생성</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 아래의 순서대로 쭉 작성 해 봅니다.(공식 문서에 나와있는 내용 거의 그대로 진행)</span>
<span class="c"># apiKey는 /etc/typesense/typesense-server.ini 파일에 입력된 내용을 미리 확인하여 준비 해 주세요.</span>
const Typesense <span class="o">=</span> require<span class="o">(</span><span class="s1">'typesense'</span><span class="o">)</span>
const PublicGoogleSheetsParser <span class="o">=</span> require<span class="o">(</span><span class="s1">'public-google-sheets-parser'</span><span class="o">)</span>
const parser <span class="o">=</span> new PublicGoogleSheetsParser<span class="o">()</span>

const client <span class="o">=</span> new Typesense.Client<span class="o">({</span>
  nodes: <span class="o">[{</span>
    host: <span class="s1">'localhost'</span>,
    port: <span class="s1">'8108'</span>,
    protocol: <span class="s1">'http'</span>
  <span class="o">}]</span>,
  apiKey: <span class="s1">'/etc/typesense/typesense-server.ini에 기록된 키'</span>,
  connectionTimeoutSeconds: 2
<span class="o">})</span>
</code></pre></div></div>

<h4 id="books-컬렉션-생성-후-결과-확인">books 컬렉션 생성 후 결과 확인</h4>

<ul>
  <li>컬렉션은 관계형 데이터베이스에서의 Table과 거의 같은 개념(roughly equivalent to a table in a relational database)입니다. 도큐먼트는 Table의 한 row를 의미한다고 생각하시면 될 것 같습니다.</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">booksSchema</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">books</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">fields</span><span class="p">:</span> <span class="p">[</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">title</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">authors</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string[]</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">publisher</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span> <span class="p">},</span>

    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">publication_year</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">int32</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ratings_count</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">int32</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">average_rating</span><span class="dl">'</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">float</span><span class="dl">'</span> <span class="p">},</span>

    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">authors_facet</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string[]</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">facet</span><span class="dl">'</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">publication_year_facet</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">facet</span><span class="dl">'</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span>
  <span class="p">],</span>
  <span class="na">default_sorting_field</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ratings_count</span><span class="dl">'</span><span class="p">,</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">afterCreateBooksSchema</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">().</span><span class="nx">create</span><span class="p">(</span><span class="nx">booksSchema</span><span class="p">)</span>
<span class="c1">// 궁금하시다면 afterCreateBooksSchema를 확인 해 보세요. 여기서는 부연설명을 하지 않겠습니다.</span>
</code></pre></div></div>

<h4 id="books-컬렉션에-도서-등록">books 컬렉션에 도서 등록</h4>
<ul>
  <li><a href="https://www.npmjs.com/package/public-google-sheets-parser">public-google-sheets-parser</a>를 이용해 이미 만들어 둔 샘플 도서 정보를 받아서, books 컬렉션의 document로 등록합니다.</li>
  <li><a href="https://docs.google.com/spreadsheets/d/16i6SZEmwZ_F1MO2pGNJeq7WLJ6bP0QJkXKA_vA0GTG8/edit#gid=0">16i6SZEmwZ_F1MO2pGNJeq7WLJ6bP0QJkXKA_vA0GTG8</a></li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">booksSpreadsheetId</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">16i6SZEmwZ_F1MO2pGNJeq7WLJ6bP0QJkXKA_vA0GTG8</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">rawBooks</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">parser</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">booksSpreadsheetId</span><span class="p">)</span>

<span class="kd">const</span> <span class="nx">books</span> <span class="o">=</span> <span class="nx">rawBooks</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">book</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// 각각 올바른 타입으로 형변환 합니다.</span>
  <span class="nx">book</span><span class="p">.</span><span class="nx">authors</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">book</span><span class="p">.</span><span class="nx">authors</span><span class="p">)</span>
  <span class="nx">book</span><span class="p">.</span><span class="nx">authors_facet</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">book</span><span class="p">.</span><span class="nx">authors_facet</span><span class="p">)</span>
  <span class="nx">book</span><span class="p">.</span><span class="nx">publication_year_facet</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">book</span><span class="p">.</span><span class="nx">publication_year_facet</span><span class="p">}</span><span class="s2">`</span>
  <span class="k">return</span> <span class="nx">book</span>
<span class="p">})</span>

<span class="c1">// 얻어온 book 정보를 books 컬렉션에 document로 각각 추가합니다.</span>
<span class="nx">books</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">book</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="dl">'</span><span class="s1">books</span><span class="dl">'</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="nx">create</span><span class="p">(</span><span class="nx">book</span><span class="p">))</span>
</code></pre></div></div>

<p><br /></p>

<h3 id="books-검색">books 검색</h3>
<h4 id="제목에-파이썬이-들어간-도서-찾기">제목에 파이썬이 들어간 도서 찾기</h4>
<ul>
  <li>q, query_by, sort_by 속성을 이용하여 document를 검색합니다. sort_by 속성의 콜론을 확인 해 보시면 내림차순 정렬을 간단하게 할 수 있음을 확인할 수 있습니다.</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 아래 실행할 코드에서 재활용하기 위해 let으로 선언합니다. (이하 마찬가지)</span>
<span class="kd">let</span> <span class="nx">searchParameters</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">q</span><span class="p">:</span> <span class="dl">'</span><span class="s1">파이썬</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">query_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">title</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">sort_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ratings_count:desc</span><span class="dl">'</span>
<span class="p">}</span>

<span class="kd">let</span> <span class="nx">searchResults</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="dl">'</span><span class="s1">books</span><span class="dl">'</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="nx">search</span><span class="p">(</span><span class="nx">searchParameters</span><span class="p">)</span>
</code></pre></div></div>

<pre>
/** 결과는 다음과 같습니다.
{
  facet_counts: [],
  found: 7, // 파이썬이라는 키워드가 들어간 책이 15권 중에 7권 이라니.. 엄청난 인기군요 ^^;
  hits: [
    {
      document: {
        authors: [ '박응용' ],
        authors_facet: [ '박응용' ],
        average_rating: 9.5,
        id: '0',
        publication_year: 2019,
        publication_year_facet: '2019',
        publisher: '이지스퍼블리싱',
        ratings_count: 49,
        title: 'Do it! 점프 투 파이썬'
      },
      highlights: [
        {
          field: 'title',
          matched_tokens: [ '파이썬' ],
          snippet: 'Do it! 점프 투 <mark>파이썬</mark>'
        }
      ],
      text_match: 33488996
    },
    // ... 생략
  ],
  page: 1,
  request_params: { per_page: 10, q: '파이썬' },
  search_time_ms: 0
}
*/
</pre>

<ul>
  <li>편의상 hits의 첫 번째 아이템만 펼쳐 보았습니다. hit된 document는 원본 정보를, highlights에는 대상 필드에 일치된 키워드에 <code class="language-plaintext highlighter-rouge">mark</code>태그가 붙은 결과를 snippet 속성에 담아 내려줍니다.</li>
</ul>

<h4 id="평점이-96-이상인-파이썬-도서만-검색하기">평점이 9.6 이상인 파이썬 도서만 검색하기</h4>
<ul>
  <li>filter_by 조건을 통해 원하는 조건을 만족하는 도큐먼트만 추릴 수 있습니다. 만약 <strong>여러 조건으로 필터링</strong> 해야한다면 <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> 연산자를 사용하여 추가 조건을 계속 나열할 수 있습니다.</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">searchParameters</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">q</span><span class="p">:</span> <span class="dl">'</span><span class="s1">파이썬</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">query_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">title</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">filter_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">average_rating:&gt;9.6</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">sort_by</span><span class="p">:</span> <span class="dl">'</span><span class="s1">average_rating:desc</span><span class="dl">'</span>
<span class="p">}</span>

<span class="nx">searchResults</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">collections</span><span class="p">(</span><span class="dl">'</span><span class="s1">books</span><span class="dl">'</span><span class="p">).</span><span class="nx">documents</span><span class="p">().</span><span class="nx">search</span><span class="p">(</span><span class="nx">searchParameters</span><span class="p">)</span>
</code></pre></div></div>

<pre>
/** 결과는 다음과 같습니다.
{
  facet_counts: [],
  found: 1,
  hits: [
    {
      document: {
        authors: [ '권철민' ],
        authors_facet: [ '권철민' ],
        average_rating: 10,
        id: '7',
        publication_year: 2020,
        publication_year_facet: '2020',
        publisher: '위키북스',
        ratings_count: 2,
        title: '파이썬 머신러닝 완벽 가이드'
      },
      highlights: [
        {
          field: 'title',
          matched_tokens: [Array],
          snippet: '<mark>파이썬</mark> 머신러닝 완벽 가이드'
        }
      ],
      text_match: 33488996
    }
  ],
  page: 1,
  request_params: { per_page: 10, q: '파이썬' },
  search_time_ms: 0
}
*/
</pre>

<h3 id="마치며">마치며</h3>
<p><a href="https://typesense.org/docs/0.17.0/guide/">공식 가이드 문서</a>에는 패싯 검색 등 다른 예제가 더 있지만, 제가 이 부분을 위한 데이터를 만들기가 귀찮(-_-;)아서 일단은 여기까지만 간단하게 사용 방법을 확인 해 보았습니다. 관심이 있으시다면 <a href="https://typesense.org/docs/0.17.0/guide/">해당 문서</a>를 확인 해 보세요!</p>

<p><a href="https://typesense.org/docs/0.17.0/guide/#high-availability">고가용성(High Availability)</a>을 위한 설정도 매우 간단하고, 검색 속도도 굉장히 빠르기 때문에 검색서버를 운영할 계획이 있으시다면 관심가지고 한번 확인 해 보시는것이 좋을 것 같습니다. 단, GPLv3 라이센스이기때문에 이 부분은 확실히 확인 후 결정하시면 될 것 같습니다. (검색용 API 서버로 분리해 두면 별 이슈는 없는 것으로 알고 있습니다.)</p>

<p>충분히 많은 데이터셋을 통해 웹 브라우저에서 검색 속도 테스트를 해 보고 싶은데, 이 부분은 다음에 데모용 데이터셋을 많이 만들어낸 뒤, 데모 웹사이트를 만들어 다른 게시글을 통해 소개할 수 있도록 해 볼 계획입니다.</p>

<p>읽어주셔서 감사합니다~</p>

<!-- contents end -->

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/12/13/from-installing-the-typesense-server-to-running-nodejs-client-example-on-ubuntu-20-04.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">How to receive mock api response in the form what I want using a public google spreadsheet document</title><link href="/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document.html" rel="alternate" type="text/html" title="How to receive mock api response in the form what I want using a public google spreadsheet document" /><published>2020-11-18T07:00:00+00:00</published><updated>2020-11-18T07:00:00+00:00</updated><id>/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document</id><content type="html" xml:base="/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<p><a href="https://fureweb-com.github.io/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document.html">한국어 게시글은 이 링크 클릭</a></p>

<p><img src="/assets/img/posts/20201116-introduction.png" alt="Introduction" /></p>

<blockquote>
  <p>First of all, I ask for your understanding that it may not be smooth because it is written with the help of Google Translator because I’m not good at English.</p>
</blockquote>

<p>I created a free API using a library called <a href="https://www.npmjs.com/package/public-google-sheets-parser">Public Google Sheets Parser</a> and wrote a post to share how to use it.</p>

<p>I used AWS Lightsail to create an inexpensive server in the ap-northeast-2 region, where I deployed the <a href="https://api.fureweb.com">https://api.fureweb.com</a> service with a simple document. If your country is far from South Korea, the API response may be slow.</p>

<p>To use this API, you need to create a Google Spreadsheet document, fill in the header for the first row, data from the second row, and get the Spreadsheet ID. Also, the document’s view permission must be set to public.</p>

<p>Spreadsheet ID refers to the value between <code class="language-plaintext highlighter-rouge">https://docs.google.com/spreadsheets/d/</code> and <code class="language-plaintext highlighter-rouge">/edit#gid=0</code>.</p>

<h3 id="examples-of-api-calls">Examples of API Calls</h3>

<p>Click the sample Google Spreadsheet document link below to see the structure and content of the document.</p>

<p><a href="https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit">https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit</a></p>

<p>If you request through curl like the following,</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> GET <span class="s2">"https://api.fureweb.com/spreadsheets/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E"</span> <span class="nt">-H</span> <span class="s2">"accept: */*"</span>
</code></pre></div></div>

<p>Then, you can get a response like below.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 2"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 2"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 3"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 3"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 4"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 4"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 5"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 5"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 6"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 6"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 7"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 7"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 8"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 8"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 9"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 9"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 10"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 10"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If the spreadsheet ID is invalid or you enter a document ID for which do not have access permission, you will receive an empty array of responses as follows.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Please refer to the simple form written in <a href="https://api.fureweb.com">API document</a> use before.</p>

<p>By sending an HTTP request, you can get JSON response such as user list, product list, etc., created based on the contents of the spreadsheet you created, with the desired key and value.</p>

<p>If the ID does not exist, or an unauthorized document, the response is always ‘200 OK’.</p>

<h3 id="conclusion">Conclusion</h3>

<p>From the client developer’s point of view, it can be cumbersome to develop the screen first if the server’s API response has not yet been confirmed.</p>

<p>At this time, you can register the actual API response that you expect to receive from the server in Google Spreadsheet and receive the desired JSON response.</p>

<p>There is a big limitation that only public documents can be used, but I think it has its own advantages because API Key is not required. I think it’s a good way to use it when it fits in such a case, so I share the content.</p>

<p>I hope it is used well.</p>

<p>Thanks for reading.</p>

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">공개 구글 스프레드시트 문서를 이용하여 내가 원하는 형태의 mock API 응답받기</title><link href="/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document.html" rel="alternate" type="text/html" title="공개 구글 스프레드시트 문서를 이용하여 내가 원하는 형태의 mock API 응답받기" /><published>2020-11-17T20:00:00+00:00</published><updated>2020-11-17T20:00:00+00:00</updated><id>/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document</id><content type="html" xml:base="/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document.html"><![CDATA[<style>a, li, code { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<p><a href="https://fureweb-com.github.io/blog/2020/11/18/how-to-receive-mock-api-response-in-the-form-what-i-want-using-a-public-google-spreadsheet-document.html">English contents click here.</a></p>

<p><img src="/assets/img/posts/20201116-introduction.png" alt="데이터 변환" /></p>

<p>안녕하세요. 이 전의 <a href="https://fureweb-com.github.io/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser.html">브라우저에서 Google Sheets를 서버 없이 데이터베이스처럼 사용하기</a> 게시글을 작성하기 위해 만들었던 <a href="https://www.npmjs.com/package/public-google-sheets-parser">Public Google Sheets Parser</a>라는 라이브러리를 이용한 무료 API를 만들어, 사용 방법을 공유하기위해 게시글을 작성하게 되었습니다.</p>

<p>AWS Lightsail을 이용해 가장 저렴한 서버를 생성했고, 그곳에 <a href="https://api.fureweb.com">https://api.fureweb.com</a> 서비스를 간단한 문서와 함께 배포 했습니다.</p>

<p>구글 스프레드시트 문서를 하나 만든 뒤, 첫번째 행은 머리글, 두번째 행 부터는 데이터를 입력한 뒤 <strong>반드시 공개 보기 설정</strong>을 해 둔 상태에서 스프레드시트 ID만 가져와 사용하시면 편하게 사용하실 수 있을 것 같습니다.</p>

<p>스프레드시트 ID는 https://docs.google.com/spreadsheets/d/ 와 /edit 사이에 있는 값을 의미합니다.</p>

<h3 id="api-호출-예제">API 호출 예제</h3>

<p>아래의 샘플 구글 스프레드시트 문서 링크를 클릭해서 내용을 확인 해 보세요.
<a href="https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit">https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit</a></p>

<p>만약 다음과 같이 curl을 통해 요청한다면,</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> GET <span class="s2">"https://api.fureweb.com/spreadsheets/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E"</span> <span class="nt">-H</span> <span class="s2">"accept: */*"</span>
</code></pre></div></div>

<p>아래와 같은 응답을 받을 수 있습니다.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 2"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 2"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 3"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 3"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 4"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 4"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 5"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 5"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 6"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 6"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 7"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 7"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 8"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 8"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 9"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 9"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a title of 10"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a description of 10"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"modifiedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-11-18"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>id가 유효하지 않거나, 접근 권한이 없는 문서 ID를 입력한 경우 다음과 같이 빈 배열의 응답을 받게 됩니다.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><a href="https://api.fureweb.com">API 문서</a>에 적어 둔 형태로 HTTP 요청을 보내주시면, 본인이 작성해 둔 스프레드시트의 원하는 사용자 리스트, 상품 리스트 등의 데이터를 원하는 key와 value를 가진 JSON Array로 돌려받을 수 있으며, ID가 존재하지 않거나, 권한이 없는 문서여서 실패하는 경우라도 응답은 모두 200 OK로 내려가게 해 두었습니다.</p>

<h3 id="마치며">마치며</h3>

<p>개인적으로는 웹이든 앱이든 클라이언트 입장에서 화면을 개발해야 할 때, 아직 실제 API 응답을 내려받을 수 없을 때 요긴하게 사용할 수 있을 것 같다는 생각을 해서 이렇게 만들어 보게 되었는데요.</p>

<p>기획 롤을 맡은 분이 스프레드시트로 필드명과 값들을 입력 해 둔 상태에서 클라이언트 개발을 맡은 분에게, 특정 스프레드시트 문서에 입력해 둔 내용을 JSON 응답을 받을 수 있으니, 이걸 기반으로 개발 해 주세요~ 하는 식의 요청도 할 수 있지 않을까 생각 해 보았습니다 ㅎㅎ;</p>

<p>얼마나 쓸모가 있을지는 잘 모르겠지만, 클라이언트 개발 시 요긴하게 사용될 수 있었으면 합니다.</p>

<p>읽어주셔서 감사합니다!</p>

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/11/17/how-to-receive-mock-api-response-in-the-form-i-want-using-a-public-google-spreadsheet-document.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">브라우저에서 Google Sheets를 서버 없이 데이터베이스처럼 사용하기</title><link href="/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser.html" rel="alternate" type="text/html" title="브라우저에서 Google Sheets를 서버 없이 데이터베이스처럼 사용하기" /><published>2020-11-16T14:57:00+00:00</published><updated>2020-11-16T14:57:00+00:00</updated><id>/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser</id><content type="html" xml:base="/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser.html"><![CDATA[<style>li { word-break: break-all; }</style>

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<p><img src="/assets/img/posts/20201116-introduction.png" alt="소개 이미지" /></p>

<p>서버 없이 브라우저를 통해 구글 시트의 데이터를 데이터베이스에 저장되어있는 데이터 처럼 활용할 수 있는 방법을 혹시 알고 계셨나요? 매우 간단한 방법이지만 많이 알지 못하고 계시는 것 같아서 공유 해 보려고 합니다.</p>

<p>아주 간단히 동적인 컨텐츠를 제공해야 하는 웹페이지 입장에서 데이터베이스 서버와 웹서버를 구축하여 API로 뽑아내 내려주거나, AWS Lambda같은 서버리스 기능을 사용하는것이 무겁고 어쩌면 사치라고 느껴지는 경우에 요긴할 것 같다고 생각합니다. 또는 프론트엔드 화면을 빨리 만들어야 할 때, 가짜 데이터를 만들기 위한 좋은 대안이 될 수 있지 않을까 생각합니다.</p>

<p><a href="https://caniuse.com/fetch">fetch API를 지원하지 않는 브라우저</a>에서는 사용할 수 없다는 점, 문서가 누구나 읽을 수 있는 공개 상태여야한다는 점, 시트를 지정할 수 없이 맨 처음에 위치한 시트의 데이터만 가져올 수 있다는 점이 가장 큰 제약사항이기는 합니다. 하지만 그만큼 API 키가 필요 없고, 간단히 쓰기 편한 것 같다고 느껴 공유할 생각을 하게 되었던 것 같아요.</p>

<p>원래는 그냥 직접 호출해서 가공하는 방법을 풀어 써 보려다가 이 글을 쓰기 위한 <a href="https://www.npmjs.com/package/public-google-sheets-parser">Public Google Sheets Parser</a>라는 이름의 라이브러리를 간단히 만들어 보았기에, 이것을 어떻게 사용할 수 있는지 설명 해 보겠습니다.</p>

<h3 id="데모-페이지-소개-및-사용-방법">데모 페이지 소개 및 사용 방법</h3>
<p><a href="http://fureweb.com/public-google-sheets-parser.html">http://fureweb.com/public-google-sheets-parser.html</a> 경로에 데모 페이지를 업데이트 해 두었습니다.</p>

<p>사용 해 보는 방법은 매우 간단합니다.</p>

<ol>
  <li><a href="https://drive.google.com/drive/u/0/my-drive">구글 드라이브 홈</a>으로 이동합니다.</li>
  <li>좌측의 새로만들기 버튼 -&gt; Google 스프레드시트 목록을 선택하여 새로운 스프레드시트 입력을 위한 페이지를 띄웁니다.</li>
  <li>브라우저의 주소표시줄을 확인 해 보면, <code class="language-plaintext highlighter-rouge">https://docs.google.com/spreadsheets/d/스프레드시트ID/edit#gid=0</code> 형태로 되어있음을 알 수 있습니다. 여기서 <code class="language-plaintext highlighter-rouge">스프레드시트ID</code>로 표시해 둔 영역의 범위를 미리 잘 확인 해 주세요. 그리고 우측 상단의 공유 버튼을 눌러 호출되는 팝업 페이지에서 <code class="language-plaintext highlighter-rouge">변경</code> 버튼을 눌러 <code class="language-plaintext highlighter-rouge">링크가 있는 모든 사용자에게 공개</code>를 선택한 뒤 완료 버튼을 눌러 공개 상태로 문서를 만들어 준 뒤, 아래와 같이 데이터를 입력 해 보겠습니다.
<img src="/assets/img/posts/20201116-spreadsheet-example.png" alt="스프레드시트 값 입력 예제" style="width:100%" /></li>
  <li>3번에서 말씀드린 <code class="language-plaintext highlighter-rouge">스프레드시트ID</code>를 정확하게 복사한 뒤, <a href="http://fureweb.com/public-google-sheets-parser.html">데모 페이지</a>로 이동하여 input창에 붙여넣기 후 GET ITEMS 버튼을 눌러 응답받은 결과를 확인 해 보세요. 아니면 창에 떠 있는 SAMPLE ID로 주어진 값을 복사해서 input창에 붙여넣어 버튼을 눌러보세요. 방금 직접 입력하셨던 머리글과 그 값이 가공되어 배열로 내려오는 것을 확인하실 수 있습니다. 응답이 정상적으로 안내려온다면, 문서 권한에 모두 읽기 권한이 없어서 그럴 가능성이 가장 높습니다.
<img src="/assets/img/posts/20201116-sample-page.png" alt="샘플 페이지 응답 결과" style="width:100%" /></li>
</ol>

<h3 id="소스-코드">소스 코드</h3>
<p>코드가 많이 부끄럽긴 하지만, 관심이 있으시다면 <a href="https://github.com/fureweb-com/public-google-sheets-parser/blob/master/src/index.js">GitHub에 등록한 소스코드</a>에서 확인하실 수 있습니다.</p>

<p>Google Visualization API에서 내려주는 문자열을 직접 가공해서 필요한 형태의 배열로 만들어 주는게 전부인데, 너무 간단한 내용이라 자세히 설명하기 뭐해서 궁금하시면 소스코드를 확인 해 주시면 될 것 같습니다.</p>

<p>스프레드시트에 form으로 입력받은 내용을 리스트로 뿌려서 보여준다거나, 제목과 상세내용 그리고 이미지 경로 등을 머리글로 만들어 두고 데이터를 넣으면, 간단한 mock용 API로도 활용할 수 있지 않을까 생각합니다.</p>

<h3 id="nodejs-지원-추가">Node.js 지원 추가</h3>
<p>Node.js에서도 다음과 같이 사용할 수 있습니다.</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// public-google-sheets-parser 모듈 설치</span>
<span class="nx">$</span> <span class="nx">npm</span> <span class="nx">i</span> <span class="kr">public</span><span class="o">-</span><span class="nx">google</span><span class="o">-</span><span class="nx">sheets</span><span class="o">-</span><span class="nx">parser</span>

<span class="c1">// node.js를 통해 다음의 코드를 실행</span>
<span class="kd">const</span> <span class="nx">PublicGoogleSheetsParser</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">public-google-sheets-parser</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">spreadsheetId</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">10WDbAPAY7Xl5DT36VuMheTPTTpqx9x0C5sDCnh4BGps</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">parser</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">PublicGoogleSheetsParser</span><span class="p">(</span><span class="nx">spreadsheetId</span><span class="p">)</span>
<span class="nx">parser</span><span class="p">.</span><span class="nx">parse</span><span class="p">().</span><span class="nx">then</span><span class="p">((</span><span class="nx">items</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// items는 다음과 같습니다: [{ a: 1, b: 2, c: 3}, { a: 4, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">items</span><span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<h3 id="마무리">마무리</h3>
<p>보안에 크게 문제될 부분이 없는 데이터를 서비스하는 정적인 웹 사이트에서 사용하면 특히 괜찮은 방법이지 않을까 생각하고 있습니다.</p>

<p>아직 여러 플랫폼에서 지원하는 형태로 npm 모듈로 만들어 배포하는 방법을 잘 몰라서, CDN을 통한 브라우저에서의 사용과 node.js에서 require로 사용하는 부분밖에 지원하지 못하고 있는 상태입니다. 이 부분과 태스크 러너를 통해 배포용 파일을 만드는 부분을 공부해서 적용할 계획입니다.</p>

<p>읽어주셔서 감사합니다!</p>

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/11/16/using-google-sheets-as-a-database-without-a-server-in-the-browser.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">브라우저에서 자식의 너비에 따라 부모가 말줄임표 처리 되었는지 확인하기</title><link href="/blog/2020/11/13/how-to-check-parent-has-been-ellipsized-by-child-s-width.html" rel="alternate" type="text/html" title="브라우저에서 자식의 너비에 따라 부모가 말줄임표 처리 되었는지 확인하기" /><published>2020-11-13T14:30:00+00:00</published><updated>2020-11-13T14:30:00+00:00</updated><id>/blog/2020/11/13/how-to-check-parent-has-been-ellipsized-by-child-s-width</id><content type="html" xml:base="/blog/2020/11/13/how-to-check-parent-has-been-ellipsized-by-child-s-width.html"><![CDATA[<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-121955159-1"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-121955159-1');
</script>

<script async="" src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

<!-- fureweb-github -->
<p><ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6234418861743010" data-ad-slot="8427857156" data-ad-format="auto"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script></p>

<div class="fb-like" data-href="https://fureweb-com.github.io/blog/2020/11/13/how-to-check-parent-has-been-ellipsized-by-child-s-width.html" data-layout="button_count" data-action="like" data-size="small" data-show-faces="true" data-share="true"></div>
<p><br /></p>

<h1 id="브라우저에서-자식의-너비에-따라-부모가-말줄임표-처리-되었는지-확인하기">브라우저에서 자식의 너비에 따라 부모가 말줄임표 처리 되었는지 확인하기</h1>

<h3 id="서론">서론</h3>
<p>아래 얘기들은 어쩌면 너무 당연한 얘기일 수도 있는데, 처리해 본 적이 없으면 삽질을 많이 하게 되는 경우가 있습니다.</p>

<p>저 같은 경우는 특정 HTML 요소의 높이가 변경될 가능성이 없는 경우에는 요구사항에 적혀있지 않더라도 기본적으로 말줄임표 처리가 될 수 있도록 작업을 하고 있습니다.</p>

<p>부모 요소가 자식들 보다 더 좁은 너비를 가져 모든 자식들이 표현되기 어려운 상황이라면, 스크롤 바를 두어 모든 자식들이 스크롤을 통해 보일 수 있도록 기회를 주거나 아니면 말줄임표 처리가 되도록 많이 하고 있을텐데요.</p>

<p>스크롤 바를 제공한다면 모든 자식들이 사용자에게 노출 될 기회가 있을것이고, 말줄임표 처리를 하게 된다면 앞선 자식들만 노출되고 나머지 자식들은 부모가 충분한 너비를 가지지 못한다면 영원히 사람들에게 잊혀진 존재가 될 것입니다.</p>

<p>이처럼 말줄임표 처리도 포기할 수 없고, 숨겨진 자식들도 빛을 봐 주게 해야한다면 어떻게 해야할까요?</p>

<h3 id="말줄임표-적용하기">말줄임표 적용하기</h3>
<p>제목처럼, 자식의 너비를 확인 해 봤더니 부모의 너비보다 더 넓었다는게 확인되었다면, 이 때 마우스를 부모 위에 올렸을 때 자식들이 모두 표현될 수 있도록 툴팁을 바로 아래 표현을 하거나 하는 방법으로 목표를 달성할 수 있을 것 같습니다.</p>

<p>저는 이런 상황에서 아래와 같은 방법으로 문제를 해결했습니다.</p>

<p>우선, 말줄임표 처리를 위해서는 부모에 다음과 같은 css 속성을 가지게 해 주면 됩니다.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;style&gt;</span>
<span class="nc">.parent</span> <span class="p">{</span>
  <span class="nl">width</span><span class="p">:</span> <span class="m">130px</span><span class="p">;</span>
  <span class="nl">white-space</span><span class="p">:</span> <span class="nb">nowrap</span><span class="p">;</span>
  <span class="nl">overflow</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
  <span class="nl">text-overflow</span><span class="p">:</span> <span class="n">ellipsis</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">&lt;/style&gt;</span>

<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"parent"</span><span class="nt">&gt;</span>
  <span class="c">&lt;!-- 아래 label은 16px 기준으로 200px 정도의 너비를 가집니다. --&gt;</span> 
  <span class="nt">&lt;label&gt;</span>이건 뭐 하는 새끼손가락일까요?<span class="nt">&lt;/label&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>위와 같은 상황이라면, 부모는 130px이고 자식은 200px이 되기때문에 자식이 가진 모든 텍스트를 보여주지 못하고</p>

<p><img src="/assets/img/posts/20201113-what-the.png" alt="이건 뭐 하는 새끼..." style="width:100%" /></p>

<p>라는 욕설로 변하게 될 수가 있습니다. -_-;</p>

<p>아무튼 다음의 css 속성들을 이용하여 말줄임표 처리를 할 수 있습니다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">속성</th>
      <th style="text-align: left">값</th>
      <th style="text-align: left">설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">white-space</td>
      <td style="text-align: left">nowrap</td>
      <td style="text-align: left">부모가 가진 width를 초과하는 자식의 텍스트가 있더라도, 부모가 주어진 높이 내에서 계속 옆으로 이어 작성될 수 있도록 해 주는 설정</td>
    </tr>
    <tr>
      <td style="text-align: left">overflow</td>
      <td style="text-align: left">hidden</td>
      <td style="text-align: left">부모가 가진 영역 바깥에 자식이 표현되지 않도록 해 주는 설정</td>
    </tr>
    <tr>
      <td style="text-align: left">text-overflow</td>
      <td style="text-align: left">ellipsis</td>
      <td style="text-align: left">위 설정을 통해 자식의 너비가 부모 너비를 초과한 경우에 적절하게 말줄임표 처리를 하도록 하는 설정</td>
    </tr>
  </tbody>
</table>

<p>이제 다음과 같은 HTML 코드가 있다고 해 보겠습니다.</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;style&gt;</span>
<span class="nc">.text-container</span> <span class="p">{</span>
  <span class="nl">font-size</span><span class="p">:</span> <span class="m">40px</span><span class="p">;</span>
  <span class="nl">white-space</span><span class="p">:</span> <span class="nb">nowrap</span><span class="p">;</span>
  <span class="nl">overflow</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
  <span class="nl">text-overflow</span><span class="p">:</span> <span class="n">ellipsis</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">&lt;/style&gt;</span>

<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"text-container"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;label</span> <span class="na">class=</span><span class="s">"text"</span><span class="nt">&gt;</span>난 text-container의 너비에 따라 말줄임표 처리가 될 수 있어!<span class="nt">&lt;/label&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>위와 같이 코드를 작성한 상황에서, text class에 담긴 문자의 길이가 길어져 브라우저에서 말줄임표 처리가 되었음을 알기 위해선 어떤 방법이 있을까요?</p>

<p>처음엔 HTMLElement에서 overflowed된 상태를 혹시 제공해주는건 아닐까 찾아보았으나.. 존재하는데 제가 잘못알고 있는 것일 수도 있지만, 그런건 존재하지 않았습니다.</p>

<p>그래서 결국 text를 감싸고 있는 text-container의 너비와, 그 안의 text의 너비를 비교해 본 뒤, text가 text-container보다 더 너비가 넓은지를 비교해 보는 방법으로 말줄임표 처리가 되었는지 확인 해 보았습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">textContainerElement</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">.text-container</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">textElement</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">.text</span><span class="dl">'</span><span class="p">)</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">textElement</span><span class="p">.</span><span class="nx">offsetWidth</span> <span class="o">&gt;</span> <span class="nx">textContainerElement</span><span class="p">.</span><span class="nx">offsetWidth</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// 말줄임표 처리 된 상태</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="자식의-수와-총-너비가-항상-변하는-경우를-대비해야할-때">자식의 수와 총 너비가 항상 변하는 경우를 대비해야할 때</h3>
<p>하지만 실제 환경에서는 이처럼 고정된 너비들을 가진 요소들만 제어할 리가 없겠죠? 저는 다음과 같은 시나리오를 어떻게 처리해야하는지 고민 해 본 적이 있었습니다.</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.flexible-options</span> <span class="p">{</span>
  <span class="nl">height</span><span class="p">:</span> <span class="m">40px</span><span class="p">;</span>
  <span class="nl">overflow</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
  <span class="nl">white-space</span><span class="p">:</span> <span class="nb">nowrap</span><span class="p">;</span>
  <span class="nl">text-overflow</span><span class="p">:</span> <span class="n">ellipsis</span><span class="p">;</span>
  <span class="nc">.option</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="n">inline-block</span><span class="p">;</span>
    <span class="nl">border</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="no">black</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;dd</span> <span class="na">class=</span><span class="s">"flexible-options"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;dl</span> <span class="na">class=</span><span class="s">"option"</span><span class="nt">&gt;</span>동적으로 변하는 옵션 1<span class="nt">&lt;/dl&gt;</span>
  <span class="nt">&lt;dl</span> <span class="na">class=</span><span class="s">"option"</span><span class="nt">&gt;</span>동적으로 변하는 옵션 2<span class="nt">&lt;/dl&gt;</span>
  <span class="nt">&lt;dl</span> <span class="na">class=</span><span class="s">"option"</span><span class="nt">&gt;</span>동적으로 변하는 옵션 3<span class="nt">&lt;/dl&gt;</span>
  <span class="nt">&lt;dl</span> <span class="na">class=</span><span class="s">"option"</span><span class="nt">&gt;</span>...<span class="nt">&lt;/dl&gt;</span>
<span class="nt">&lt;/dd&gt;</span>
</code></pre></div></div>

<p>위와 같은 상황에서는, 각 option이 차지하는 너비에 따라 flexible-options가 말줄임표 처리가 될 수도, 아닐 수도 있게 됩니다.</p>

<p>결국 flexible-options 내에 children들이 존재하는 상태에서 children의 offsetWidth 값을 모두 더해 봐야만 말줄임표 처리가 되었는지 확인할 수 있습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">flexibleOptionsElement</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">.flexible-options</span><span class="dl">'</span><span class="p">)</span>

<span class="kd">const</span> <span class="nx">parentWidth</span> <span class="o">=</span> <span class="nx">flexibleOptionsElement</span><span class="p">.</span><span class="nx">offsetWidth</span>
<span class="kd">const</span> <span class="nx">childrenWidth</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">flexibleOptionsElement</span><span class="p">.</span><span class="nx">children</span><span class="p">]</span>
  <span class="p">.</span><span class="nx">map</span><span class="p">(({</span> <span class="nx">offsetWidth</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">offsetWidth</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">reduce</span><span class="p">((</span><span class="nx">p</span><span class="p">,</span> <span class="nx">c</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">p</span> <span class="o">+</span> <span class="nx">c</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">childrenWidth</span> <span class="o">&gt;</span> <span class="nx">parentWidth</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// 말줄임표 처리 된 상태</span>
<span class="p">}</span>
</code></pre></div></div>

<p>윈도우 resize 또는 option의 텍스트가 변경 될 때 마다 위 요소들의 offsetWidth를 확인해서 말줄임표 처리가 되었는지 확인할 수 있게 됩니다.</p>

<p>만약 최신 상태의 자식의 너비가 부모 너비보다 더 넓은 경우, 해당 parent에 마우스를 올렸을 때 툴팁을 표현하여 모든 요소가 보일 수 있도록 작업을 해 주는 형태 또는 부모의 높이/너비 등을 충분히 넓혀주는 식으로 처리하게 된다면 자식이 더 넓어서 화면에 보여지지 않을 수 있는 상황에서 모두 노출시킬 수 있게 됩니다.</p>

<div class="fb-comments" data-href="https://fureweb-com.github.io/blog/2020/11/13/how-to-check-parent-has-been-ellipsized-by-child-s-width.html" data-width="100%" data-numposts="10"></div>

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/ko_KR/sdk.js#xfbml=1&version=v2.10&appId=403216550080274";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>]]></content><author><name></name></author><category term="blog" /><summary type="html"><![CDATA[]]></summary></entry></feed>