<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>JUJU</title>
        <link>https://velog.io/</link>
        <description>백엔드 개발자</description>
        <lastBuildDate>Thu, 21 Aug 2025 08:10:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>JUJU</title>
            <url>https://velog.velcdn.com/images/jaewon-ju/profile/26aaeb82-3ffd-4648-b8df-77d9a0c3d5b9/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. JUJU. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jaewon-ju" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[프로젝트] Velog->GitHub.io 동기화 툴]]></title>
            <link>https://velog.io/@jaewon-ju/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B2%A8%EB%A1%9C%EA%B7%B8-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EC%B3%90%EC%A3%BC%EC%84%B8%EC%9A%94%E3%85%A0%E3%85%A0</link>
            <guid>https://velog.io/@jaewon-ju/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B2%A8%EB%A1%9C%EA%B7%B8-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EC%B3%90%EC%A3%BC%EC%84%B8%EC%9A%94%E3%85%A0%E3%85%A0</guid>
            <pubDate>Thu, 21 Aug 2025 08:10:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="4줄-요약">4줄 요약</h4>
<p>velog와 github.io 포스팅을 자동으로 백업/동기화하는 툴을 만들었습니다.</p>
</blockquote>
<ul>
<li><strong>Velog → GitHub.io 자동 동기화</strong>: Velog에 올린 글을 그대로 github.io 블로그에도 반영</li>
<li><strong>기존 글 백업</strong>: Velog에 이미 작성된 글들을 한 번에 가져오기</li>
<li><strong>수정 사항 반영</strong>: Velog 글을 수정하면 GitHub Pages에도 자동 업데이트</li>
</ul>
<br>

<hr>
<h2 id="서론">서론</h2>
<p><a href=#main>서론 건너뛰기</a></p>
<p>약 1년 8개월 동안 Velog를 메인 블로그로 사용해왔습니다.
글을 쓰기 편리하고 개발자 커뮤니티가 잘 형성되어 있다는 큰 장점이 있습니다.</p>
<p>그런데 글이 쌓이다 보니 몇 가지 아쉬움이 생겼습니다.</p>
<h4 id="1-검색-문제">1. 검색 문제</h4>
<ul>
<li>Velog 내부 검색 품질이 아쉬웠습니다.</li>
<li>예를 들어, 제가 직접 작성한 글 중에 제목에 JAVA가 들어간 글이 몇 개나 있는데, 정작 Velog 검색창에 JAVA를 입력하면 아무 결과도 나오지 않았습니다. (저만 그런건지는 모르겠습니다)
<img src="https://velog.velcdn.com/images/jaewon-ju/post/0fa4d13a-ac92-4e78-9571-63fbd635583e/image.png" alt=""></li>
</ul>
<br>

<h4 id="2-부실한-통계-기능">2. 부실한 통계 기능</h4>
<ul>
<li>Velog에는 기본 제공되는 조회수 외에 상세 통계가 부족합니다.</li>
<li>크롬 익스텐션을 활용하면 통계를 볼 수는 있지만, 매번 일일이 돌려야 해서 불편했어요.</li>
</ul>
<br>

<h4 id="3-광고-불가능">3. 광고 불가능</h4>
<ul>
<li>Velog는 플랫폼 특성상 광고를 붙일 수 없어서, 수익화가 사실상 불가능합니다.</li>
<li>포스팅 조회수가 많지는 않지만...광고 붙여보고 싶어요..</li>
</ul>
<br>

<blockquote>
<p>그럼에도 불구하고 Velog는 글을 작성하기 편리하고, 개발자 독자층이 많다는 장점이 큽니다. 그래서 저는 Velog와 github.io를 동시에 운영하기로 했습니다.</p>
</blockquote>
<br>

<hr>
<h2 id="span-idmain본론span"><span id="main">본론</span></h2>
<p>이를 위해 만든 도구가 바로 velog-sync 입니다.</p>
<ul>
<li>velog-sync는 Velog와 GitHub Pages(github.io) 블로그를 자동으로 동기화해주는 CLI 도구입니다.</li>
<li>한 번 글을 Velog에 업로드하면, 동일한 글이 내 GitHub Pages에도 자동으로 반영됩니다.</li>
</ul>
<blockquote>
<p>이 도구를 만들면서 제가 목표로 한 점은 <code>Velog의 편리함 + GitHub Pages의 독립성</code> 두 가지를 동시에 누리는 것이었습니다. 따라서, 글을 두 번 작성할 필요 없이 Velog에 글을 쓰면 알아서 github.io에도 동기화됩니다.</p>
</blockquote>
<br>


<h3 id="■-velog-sync-사용-준비물">■ velog-sync 사용 준비물</h3>
<p>velog-sync를 사용하기 위해서는 다음 두 가지가 필요합니다.</p>
<h4 id="1-jekyll-테마의-githubio-블로그">1. Jekyll 테마의 gitHub.io 블로그</h4>
<ul>
<li>velog-sync는 Velog 글을 Markdown 파일로 변환해서 github.io 리포지토리 내의 폴더에 업로드합니다.</li>
<li>따라서 미리 <code>username.github.io</code> 저장소를 만들어 두고, Jekyll 테마(저는 chirpy를 사용했습니다.) 테마를 적용해 두는 것이 좋아요.</li>
</ul>
<p>준비가 안 되어 있다면 아래의 부록으로 설명해두었으니, 참고하시면 좋을 것 같습니다.
<a href=#appendix>부록으로 이동하기</a></p>
<br>

<h4 id="2-github-personal-access-token-pat">2. GitHub Personal Access Token (PAT)</h4>
<ul>
<li>GitHub 저장소에 velog-sync가 자동으로 push하기 위해 인증 토큰이 필요합니다.</li>
<li>GitHub → Settings → Developer settings → Personal Access Tokens에서 생성할 수 있습니다.</li>
<li>권한은 최소한 repo (저장소 쓰기 권한)만 체크해도 충분합니다.</li>
</ul>
<br>

<hr>
<h3 id="■-사용방법">■ 사용방법</h3>
<blockquote>
<h4 id="step1-설치">Step1. 설치</h4>
</blockquote>
<ol>
<li>Velog-Sync 저장소를 Fork 합니다.</li>
</ol>
<ul>
<li>GitHub 페이지에서 Fork 버튼을 눌러 본인 계정으로 저장소를 복사하세요.</li>
<li><a href="https://github.com/jaewon-ju/velog-sync">https://github.com/jaewon-ju/velog-sync</a></li>
</ul>
<br>

<ol start="2">
<li>fork한 저장소를 로컬로 클론합니다.</li>
</ol>
<ul>
<li>로컬 디렉토리에서 아래와 같은 커맨드를 입력하세요.<pre><code class="language-bash">git clone https://github.com/&lt;your-username&gt;/velog-sync
cd velog-sync</code></pre>
</li>
</ul>
<br>

<ol start="3">
<li>이제 본인 리포지토리 Settings → Secrets → Actions 에서 <code>GH_PAT_FOR_GHIO</code>를 등록합니다.<pre><code>GitHub 리포지토리 → Settings → Secrets and variables → Actions → New repository secret
</code></pre></li>
</ol>
<p>이름: GH_PAT_FOR_GHIO</p>
<p>값: 본인 GitHub Personal Access Token</p>
<pre><code>![](https://velog.velcdn.com/images/jaewon-ju/post/43d1aae2-6e08-467c-99d4-03cb5e1d0f8b/image.png)

&lt;br&gt;


&gt; #### Step2. 초기 설정

처음 velog-sync를 사용할 때는, 내 Velog 계정과 github.io 리포지토리 정보를 알려줘야 합니다.

```bash
velog-sync init
# 위 커맨드 실행 후 오류가 발생하면 npm link를 한 뒤 다시 실행해보세요</code></pre><br>


<p>실행하면 터미널에서 차례대로 질문이 나옵니다 👇</p>
<p><img src="https://velog.velcdn.com/images/jaewon-ju/post/3082929f-d674-4102-80ca-c16c9625739f/image.png" alt=""></p>
<ol>
<li><p>Velog 주소 또는 아이디:</p>
<ul>
<li><a href="https://velog.io/@username">https://velog.io/@username</a> 형식 전체 주소, 또는 username 만 입력해도 됩니다.</li>
</ul>
</li>
<li><p>github.io 리포지토리 URL</p>
<ul>
<li>예: <a href="https://github.com/myname/myname.github.io">https://github.com/myname/myname.github.io</a></li>
</ul>
</li>
<li><p>github.io 리포지토리 절대 경로</p>
<ul>
<li>내 컴퓨터에 클론 받아둔 블로그 폴더의 경로</li>
<li>예: /Users/projects/myname.github.io</li>
</ul>
</li>
<li><p>포스트 디렉토리 (기본값: _posts)</p>
<ul>
<li>myname.github.io 프로젝트에서, 포스팅 마크다운들을 저장하는 폴더 이름을 작성해주시면 됩니다. 여기에 자동으로 velog 포스팅이 업로드됩니다.</li>
<li>Jekyll 블로그라면 보통 _posts를 그대로 쓰면 됩니다.</li>
</ul>
</li>
<li><p>푸시할 브랜치 (기본값: main)</p>
<ul>
<li>저장소의 기본 브랜치명을 입력하세요.</li>
<li>그냥 엔터 누르셔도 됩니다.</li>
</ul>
</li>
<li><p>Git author 정보 (선택사항)</p>
<ul>
<li>커밋 시 사용할 user.name과 user.email</li>
<li>그냥 엔터 누르셔도 됩니다.</li>
</ul>
</li>
<li><p>커밋 메시지 템플릿 (선택사항)</p>
<ul>
<li>커밋 메시지를 커스텀할 수 있습니다.</li>
<li>그냥 엔터 누르셔도 됩니다.</li>
</ul>
</li>
</ol>
<br>

<p><strong>중요:</strong> 여기까지 진행한 후 push하면, GitHub Actions <code>(.github/workflows/velog-sync.yml)</code>이 자동으로 활성화됩니다.</p>
<p>즉, 이제 velog에 글을 작성하면 <code>github.io</code>에도 똑같이 글이 업로드 됩니다.(매일 오전 9시마다 복사)</p>
<br>

<blockquote>
<h4 id="step3-선택-로컬에서-수동-실행">Step3. (선택) 로컬에서 수동 실행</h4>
</blockquote>
<p>Actions 자동 실행이 잘 동작하지만, 원한다면 로컬에서도 직접 동기화를 실행할 수 있습니다:</p>
<pre><code class="language-bash">velog-sync sync</code></pre>
<br>


<p>명령어를 실행하면 다음 단계를 거칩니다:</p>
<p><em>1. 내 계정(@username)의 최신 글 목록을 가져옵니다.
2. 이미 저장된 글과 비교해서 새 글/수정된 글을 자동으로 판별합니다.
3. Velog 글을 <code>_posts</code> 디렉토리에 Markdown 파일로 저장합니다.
4. 새 글/수정된 글을 자동으로 커밋합니다.</em></p>
<br>

<hr>
<h3 id="■-결과물">■ 결과물</h3>
<p><img src="https://velog.velcdn.com/images/jaewon-ju/post/56f8448d-add6-4d72-aaaa-0cfb520777e7/image.png" alt=""></p>
<p><code>velog-sync</code>는 매일 00시(UTC 기준)에 velog 포스팅을 긁어서, 추가/수정된 포스팅을 <code>github.io</code> 리포지토리에 push 해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/jaewon-ju/post/a449f619-41e6-43f9-abb7-3bf8a8c14dae/image.png" alt=""></p>
<br>

<p>따로 관리하지 않아도, velog에 글을 쓰면 자동으로 내 <code>github.io</code>에 동기화됩니다.
찾는 글이 있으면 <code>github.io</code>에서 검색하면 됩니다.
<img src="https://velog.velcdn.com/images/jaewon-ju/post/c9446514-2100-478d-9bad-b836fbad46c0/image.png" alt=""></p>
<hr>
<br>

<h2 id="결론">결론</h2>
<p>다른 사용자에게 github 리포지토리를 공개하고, 서비스를 공유하는 것이 처음이라 오류가 있을 수 있습니다😓
댓글 남겨주시면 빠르게 패치하도록 하겠습니다.</p>
<p>원하시는 기능이 있다면 알려주세요!
pull request도 언제든 환영입니다!</p>
<br>

<ul>
<li>Velog 글 안의 이미지는 모두 다운로드하여 블로그 저장소(assets/posts/)에 저장됩니다.</li>
<li>따라서 Velog CDN이 변하더라도 내 블로그에는 영향을 주지 않습니다.</li>
</ul>
<br>
<br>

<hr>
<h2 id="span-idappendix부록-jekyll-테마-githubio-블로그-만들기span"><span id="appendix">부록: Jekyll 테마 gitHub.io 블로그 만들기</span></h2>
<p>간단하게 정리해두었습니다!
먼저 구글 검색으로 관련 가이드를 찾아보시고, 작동하지 않을 경우 이 가이드라인을 참고해 설정해보시기 바랍니다.</p>
<br>

<h3 id="1-github-리포지토리-만들기">1. GitHub 리포지토리 만들기</h3>
<ul>
<li><p>이름: 사용자명.github.io</p>
<ul>
<li>예: jaewon-ju.github.io</li>
</ul>
</li>
<li><p>이 리포지토리는 GitHub Pages에서 개인 사이트 도메인으로 자동 인식됩니다.
→ https://사용자명.github.io</p>
</li>
</ul>
<br>

<h3 id="2-로컬-프로젝트-생성-및-원격-연결">2. 로컬 프로젝트 생성 및 원격 연결</h3>
<p>로컬에서 프로젝트 폴더 생성:</p>
<pre><code class="language-bash">mkdir &lt;폴더명&gt;
cd &lt;폴더명&gt;
git init</code></pre>
<br>

<p>원격 리포지토리 연결:</p>
<pre><code>git remote add origin https://github.com/&lt;사용자명&gt;/&lt;리포지토리명&gt;</code></pre><br>

<p>초기 커밋 후 푸시:</p>
<pre><code>git add .
git commit -m &quot;Initial commit&quot;
git branch -M main
git push -u origin main</code></pre><br>

<h3 id="3-jekyll-테마-다운로드-및-적용">3. Jekyll 테마 다운로드 및 적용</h3>
<ul>
<li><a href="http://jekyllthemes.org/">http://jekyllthemes.org/</a> 에서 마음에 드는 테마를 고릅니다.</li>
<li>해당 테마 리포지토리를 클론하거나, ZIP으로 다운로드 후 압축 해제합니다.</li>
<li>테마 폴더 내용을 프로젝트 루트에 복사/붙여넣기 합니다.</li>
</ul>
<br>

<h3 id="3-macos에서-jekyll-환경-세팅">3. macOS에서 Jekyll 환경 세팅</h3>
<pre><code class="language-bash">brew install rbenv ruby-build

echo &#39;eval &quot;$(rbenv init -)&quot;&#39; &gt;&gt; ~/.zshrc
source ~/.zshrc

rbenv install 3.3.0
rbenv global 3.3.0</code></pre>
<br>

<h3 id="4-의존성-설치">4. 의존성 설치</h3>
<p>프로젝트 루트에서 아래 명령어 실행:</p>
<pre><code class="language-bash">bundle install
bundle update</code></pre>
<p>이후 다시 한 번:</p>
<pre><code>bundle install</code></pre><br>

<h3 id="5-github-actions-워크플로우-파일-생성">5. GitHub Actions 워크플로우 파일 생성</h3>
<ul>
<li>루트에 <code>.github/workflows</code> 디렉토리 만들기<pre><code>mkdir -p .github/workflows</code></pre></li>
</ul>
<ul>
<li>그 안에 pages-deploy.yml 파일 작성
<code>.github/workflows/pages-deploy.yml</code></li>
</ul>
<pre><code>name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write # 격리 커밋 푸시를 위해 필요

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: &quot;3.3&quot;

      - name: Install Dependencies
        run: |
          gem install bundler
          bundle install

      # 🔁 빌드 실패 시 문제 파일(_posts/*.md)만 _quarantine/로 이동 후 재시도
      - name: Build Jekyll Site (auto-quarantine)
        run: |
          set -eo pipefail
          mkdir -p _quarantine

          git config user.name &quot;github-actions[bot]&quot;
          git config user.email &quot;github-actions[bot]@users.noreply.github.com&quot;

          success=0
          quarantined=0
          max_attempts=10   # 필요시 6~10으로 여유

          for i in $(seq 1 $max_attempts); do
            echo &quot;==&gt; Jekyll build attempt #$i&quot;
            if bundle exec jekyll build --trace 2&gt;build.err; then
              success=1
              break
            fi

            offender=$(grep -oE &quot;$GITHUB_WORKSPACE/_posts/[^:]+\.md&quot; build.err | head -n1 || true)
            if [ -z &quot;$offender&quot; ]; then
              echo &quot;No offending post detected. Full error follows:&quot;
              cat build.err
              exit 1
            fi

            reason=$(grep -m1 &quot;Error&quot; build.err || true)
            echo &quot;Error reason: $reason&quot; # 에러 원인 출력

            base=$(basename &quot;$offender&quot;)
            echo &quot;⚠️  Quarantining failing post: $base&quot;
            git mv &quot;$offender&quot; &quot;_quarantine/$base&quot; || mv &quot;$offender&quot; &quot;_quarantine/$base&quot;
            git commit -m &quot;ci: quarantine failing post $base&quot; || true
            git push origin HEAD:main || true

            quarantined=1
            # 바로 다음 루프로 넘어가 재빌드
          done

          # 루프가 끝났는데 마지막이 격리로 종료되었을 수 있으니 한 번 더 시도
          if [ $success -ne 1 ] &amp;&amp; [ $quarantined -eq 1 ]; then
            echo &quot;==&gt; Final build after last quarantine&quot;
            if bundle exec jekyll build --trace 2&gt;build.err; then
              success=1
            fi
          fi

          if [ $success -ne 1 ]; then
            echo &quot;Build failed after quarantine attempts.&quot;
            cat build.err
            exit 1
          fi

      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./_site
</code></pre><br>

<p>그다음 push</p>
<pre><code class="language-bash">git commit -am &quot;[feat] actions setting&quot;
git push</code></pre>
<br>

<h3 id="6-github-pages-설정-변경">6. GitHub Pages 설정 변경</h3>
<ul>
<li>GitHub 저장소 → Settings → Pages 로 이동</li>
<li>Deploy from a branch → gh-pages 브랜치 선택</li>
</ul>
<br>

<h3 id="7-권한-설정-확인">7. 권한 설정 확인</h3>
<ul>
<li>리포지토리 → Settings → Actions → General
Workflow permissions 항목에서 Read and write permissions 체크</li>
<li>Allow GitHub Actions to create and approve pull requests 옵션 활성화</li>
</ul>
<br>

<h3 id="8-완료">8. 완료</h3>
<p>이제 커밋 후 main 브랜치에 푸시하면, GitHub Actions가 자동으로 실행되어 _site 폴더가 gh-pages 브랜치로 배포됩니다.
잠시 후 https://사용자명.github.io 에 접속해 사이트를 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/jaewon-ju/post/1f13657c-406d-4a20-91ea-c5e60d348fc0/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩테스트] Backtracking, Two Pointers, 최단거리]]></title>
            <link>https://velog.io/@jaewon-ju/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-Backtracking</link>
            <guid>https://velog.io/@jaewon-ju/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-Backtracking</guid>
            <pubDate>Sun, 17 Aug 2025 13:42:44 GMT</pubDate>
            <description><![CDATA[<p>이 시리즈의 목표는, 코딩테스트에서 자주 사용되는 핵심 문법들을 템플릿 형태로 정리하고, 이를 빠르게 외워서 실전 문제에 바로 적용할 수 있도록 만드는 것이다.</p>
<h2 id="✏️-백트래킹-backtracking">✏️ 백트래킹 (Backtracking)</h2>
<h3 id="■-핵심-개념">■ 핵심 개념</h3>
<p>모든 경우의 수를 탐색하는 과정에서, 해답이 될 수 없는 경우는 더 이상 탐색하지 않고 되돌아가는 방법.
DFS를 기반으로 하며, 가지치기를 통해 탐색 효율을 높임.</p>
<br>

<h3 id="■-템플릿-요약">■ 템플릿 요약</h3>
<ol>
<li>현재 노드를 탐색</li>
<li>조건 충족 시 다음 단계 DFS 재귀 호출</li>
<li>조건 불충족 또는 끝까지 탐색 시 이전 단계로 되돌아감</li>
</ol>
<br>

<h3 id="■-구현-템플릿">■ 구현 템플릿</h3>
<pre><code class="language-py">def backtrack(depth, path):
    # 종료 조건
    if depth == 목표_깊이 or 조건_만족:
        결과 처리()
        return

    for 다음_선택 in 가능한_선택지:
        path.append(다음_선택)
        dfs(depth+1, path)
        path.pop()  # 백트래킹 (되돌리기)</code></pre>
<br>

<p>dfs를 활용한 예시</p>
<pre><code class="language-py">def backtrack(node, graph, visited):
    # 종료 조건
    if (종료조건):
        return

    for next in graph[node]:
        if visited[next] == 0:
            visited[next] = 1       # 방문 처리
            backtrack(next, graph, visited)
            visited[next] = 0       # 방문 해제 (rollback)</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-투-포인터-two-pointers">✏️ 투 포인터 (Two Pointers)</h2>
<h3 id="■-핵심-개념-1">■ 핵심 개념</h3>
<p>하나의 배열에서 두 개의 인덱스를 움직여 조건을 만족하는 구간을 찾는 기법.</p>
<br>

<h3 id="■-템플릿-요약-1">■ 템플릿 요약</h3>
<ol>
<li>오른쪽 포인터로 확장</li>
<li>조건 위반 시 왼쪽 포인터로 축소</li>
<li>정답 갱신</li>
</ol>
<br>

<h3 id="■-구현-템플릿-1">■ 구현 템플릿</h3>
<pre><code class="language-py">def two_pointers(arr):
    n = len(arr)
    left = 0
    state = {} # 윈도우 상태관리

    # 1. 윈도우 자동 확장
    for right in range(n):

        # 2. 조건 위반 시 왼쪽 축소
        while 조건위반():
            left += 1

        # 3. 정답 갱신
        answer = max(answer, right - left)</code></pre>
<br>

<p>예시: 서로 다른 문자 종류가 최대 K개인 부분문자열의 최대 길이를 구하라.</p>
<pre><code class="language-py">from collections import defaultdict

N = len(string) # 주어진 문자열 길이

freq = defaultdict(int)
left = 0
maximum = 0

for right, element in enumerate(N):
    freq[element] += 1

    while(len(freq) &gt; K):
        freq[string[left]] -= 1
        left += 1

    maximum = max(maximum, right - left + 1)</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-최단거리-알고리즘">✏️ 최단거리 알고리즘</h2>
<h3 id="■-핵심-개념-2">■ 핵심 개념</h3>
<p>그래프에서 두 정점 사이의 최소 거리를 구하는 알고리즘. 
다양한 방법이 있으며, 문제의 조건(음수 가중치 여부, 전체 경로 탐색 필요 여부)에 따라 알고리즘이 달라진다.</p>
<ul>
<li><strong>Dijkstra 알고리즘</strong>: 음수 가중치가 없는 경우, 우선순위 큐(힙)을 이용하여 가장 짧은 경로를 탐색.</li>
<li><strong>Bellman-Ford 알고리즘</strong>: 음수 가중치가 있어도 사용 가능하며, 음수 사이클 탐지 가능.</li>
<li><strong>Floyd-Warshall 알고리즘</strong>: 모든 정점 쌍의 최단거리를 구하는 알고리즘.</li>
</ul>
<br>

<h3 id="■-템플릿-요약-2">■ 템플릿 요약</h3>
<ol>
<li>시작 노드의 거리를 0으로 초기화, 나머지는 무한대(INF)로 설정</li>
<li>최단 거리 후보 중 가장 작은 값을 가진 노드를 선택</li>
<li>선택한 노드를 거쳐가는 경로가 더 짧다면 거리 갱신</li>
<li>모든 노드가 처리될 때까지 반복</li>
</ol>
<br>

<h3 id="■-구현-템플릿-dijkstra">■ 구현 템플릿 (Dijkstra)</h3>
<p>다익스트라는 <span style="color:red">가장 가까운 노드부터 차례대로 확장</span>해 나가는 방식이다.</p>
<ul>
<li>이때, heapq는 탐색할 후보 노드를 저장한다.</li>
</ul>
<pre><code class="language-py">import heapq

def dijkstra(start, graph, n):
    INF = int(1e9)
    distance = [INF] * (n+1)
    distance[start] = 0
    queue = []
    heapq.heappush(queue, (0, start))

    while queue:
        # heap에서 가장 짧은 거리(dist)를 가진 노드(now)를 꺼낸다.
        # heapq는 최소힙이므로, 지금까지 발견된 &quot;가장 가까운 후보&quot;가 나온다.
        dist, now = heapq.heappop(queue)

        # 이미 더 짧은 경로로 방문한 적이 있다면 무시한다.
        if distance[now] &lt; dist:
            continue

        # 현재 노드에 연결된 모든 인접 노드 탐색
        for next, cost in graph[now]:
            # 현재 노드까지의 거리(dist)에다, 간선 비용(cost)을 더한 값
            # 즉, start → ... → now → next 경로의 총 비용
            new_dist = dist + cost

            # 만약 이 경로가 기존에 기록된 next까지의 최단거리보다 짧다면,
            # distance[next]를 갱신하고, 우선순위 큐에 새로운 후보로 추가한다.
            if new_dist &lt; distance[next]:
                distance[next] = new_dist
                # heap에는 &quot;next까지의 최신 최단거리 후보&quot;를 넣는다.
                heapq.heappush(queue, (new_dist, next))

    return distance</code></pre>
<br>

<p>Dijkstra 알고리즘은 <strong>음수 가중치가 없는 경우</strong>에 적합하며, BFS와 유사하게 동작하지만 우선순위 큐를 활용해 효율적으로 최단 경로를 찾는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[7개월간의 스타트업 인턴 회고]]></title>
            <link>https://velog.io/@jaewon-ju/7%EA%B0%9C%EC%9B%94%EA%B0%84%EC%9D%98-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%EC%9D%B8%ED%84%B4-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@jaewon-ju/7%EA%B0%9C%EC%9B%94%EA%B0%84%EC%9D%98-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%EC%9D%B8%ED%84%B4-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 11 Aug 2025 05:23:43 GMT</pubDate>
            <description><![CDATA[<p>작년 12월 말부터 진행한 7개월간의 인턴 생활이 끝났다.
정말 많이 배웠고, 살면서 이렇게까지 코딩만 했던 적이 있었나 싶을 정도로 말 그대로 죽어라 코딩만 했다.
무엇을 배웠는지, 스타트업 개발 인턴은 어떤지, 그리고 다른 사람에게 추천할만한지 등을 정리해보고자 책상 앞에 앉았다.</p>
<hr>
<p>결론부터 말하자면,</p>
<blockquote>
<h3 id="적극-추천한다">적극 추천한다.</h3>
</blockquote>
<ul>
<li>하루 8시간씩 코딩에 집중할 수 있는 경험</li>
<li>프로젝트의 A-Z까지 총괄해보는 경험</li>
<li>협업 경험</li>
<li>다양한 기술을 써보는 경험</li>
</ul>
<p>이 외에도 정말 다양한 경험을 할 수 있었고, 앞으로의 개발 인생에 큰 도움이 될 것이라 생각한다.</p>
<br>

<hr>
<br>

<h2 id="무엇을-배웠는가">무엇을 배웠는가?</h2>
<h3 id="1-프로젝트의-전-과정">1. 프로젝트의 전 과정</h3>
<p>입사 전까진 백엔드와 RDB 외에는 다뤄본 적이 없었다.
배포 경험이 있긴 했지만, 항상 AWS EC2만 사용했기에 기술 스택의 폭이 넓진 않았다.</p>
<p>하지만 이번 인턴 기간 동안 정말 다양한 분야를 접할 수 있었다.</p>
<blockquote>
<p><code>Firestore</code>, <code>Firebase Authentication</code>, <code>Cloudflare workers</code>, <code>R2</code>, <code>inngest</code>, <code>nginx</code>, <code>next.js</code>, <code>hono</code>, <code>authentik</code>, <code>railway</code>....</p>
</blockquote>
<p>프론트부터 Serverless Function, 인증 서버, NoSQL DB, 배포 서비스까지.
프로젝트의 A to Z를 직접 연결하고 구축해보는 경험이었다.</p>
<p>물론 기술의 &#39;깊이&#39;도 자연스레 늘었지만, 무엇보다 기술의 &#39;폭&#39;이 확장되었다는 점을 크게 느꼈다.
이는 스타트업의 장점이라고 생각한다.</p>
<ul>
<li>새롭고 트렌디한 기술을 직접 도입해볼 수 있다는 점.</li>
</ul>
<br>

<p>물론 단점도 있다.
잘 알려지지 않은 기술을 다뤄야 하다 보니, 레퍼런스가 적고 문제 해결이 쉽지 않다는 것.</p>
<p>그럼에도 불구하고, 이제는 나 혼자서도 하나의 서비스를 처음부터 끝까지 만들 수 있겠다는 자신감이 생겼다.</p>
<br>

<h3 id="2-엉덩이-힘">2. 엉덩이 힘</h3>
<p>코딩을 좋아하지만, 하루 8시간씩 주 5일을 몰입했던 적은 없었던 것 같다.
매일 새로운 에러가 터진다.
빠르면 3시간, 길면 3일 동안 에러를 잡는다.
출근해서 어제 에러를 잡고 밥 먹고 돌아오면 또 다른 에러가 터져 있다.</p>
<p>하루 종일 화면만 보다 보니 눈도 뻑뻑해졌다.
그럼에도 불구하고, 이제는 ‘하루 = 코딩’ 이 수식이 너무 익숙해졌다.
오히려 좋아.</p>
<br>


<h3 id="3-백엔드-개발자가-ux를-신경써야-하는-이유">3. 백엔드 개발자가 UX를 신경써야 하는 이유</h3>
<p>예전에는 “백엔드 개발자가 UI/UX까지 고려해야 할까?” 하는 생각이 있었다.
하지만 토스 페이먼츠를 연동하면서, 그 이유를 뼈저리게 깨달았다.</p>
<p>Stripe의 구독 라이프사이클을 참고해서 결제 시스템을 구축하던 중, <code>Incomplete</code>라는 상태를 발견했다.
이는 첫 결제가 실패했을 때 구독이 머무는 상태다.
당시에는 왜 이게 필요한지 이해하지 못했다.
그래서 나는 <code>INACTIVE</code>, <code>ACTIVE</code>, <code>UNPAID(연장 결제 실패)</code> 세 가지 상태만 있는 라이프사이클을 설계했고, 그대로 CTO님께 보고했다.</p>
<p>그런데 실제 구현 과정에서 문제가 발생했다.
사용자 입장에서 생각해보니, 첫 결제가 실패할 경우 다시 결제하려면 상품을 처음부터 다시 선택 → 결제 팝업 작성 → 결제 재시도의 과정을 거쳐야 한다.
이는 상당히 번거롭고, 사용자 경험을 크게 떨어뜨린다.</p>
<p>바로 이 문제를 해결하기 위해 존재하는 상태가 <code>Incomplete</code>였다.
결제 실패 시 해당 상태로 유지하면, 사용자는 이전에 실패한 결제 내역을 그대로 재결제할 수 있어 번거로운 과정을 줄일 수 있다.</p>
<p>이 경험을 통해 깨달았다.
백엔드 개발이라도 사용자 경험을 고려한 설계가 필요하다.
UI/UX는 화면에 보이는 것만이 아니라, 사용자가 서비스를 어떻게 경험하는지 전반을 포함하기 때문이다.</p>
<br>

<h3 id="4-개발자--코더">4. 개발자 != 코더</h3>
<blockquote>
<p>개발자는 단순히 코드를 작성하는 코더가 아니다.</p>
</blockquote>
<p>회사 업무를 하면서 비즈니스 로직을 짜는 것보다 다른 요소들에 훨씬 많은 시간을 투자했다.
서버 관리, 서비스 비교 후 선택(Inngest 등), DB 설계, 환경 구성, 서비스 연결 등…
순수한 코딩보다 이 과정들에 더 많은 시간이 들었다.</p>
<p>회사에서 AI 대신 개발자를 고용해야 할 이유를 묻는다면, 이렇게 대답할 것이다.</p>
<ul>
<li>단순한 비즈니스 로직 짜기? 사실 나보다 GPT가 더 잘한다.</li>
<li>그럼에도 불구하고 회사에서 개발자를 고용하는 이유는 소프트웨어 개발 != 코딩이기 때문이다.</li>
</ul>
<p>소프트웨어 개발은 요구사항 분석부터 서비스 유지보수까지 전 과정을 포함한다.
비즈니스 로직 작성은 그중 일부일 뿐이다.
스택을 선택하고, 서비스들을 연결하며, 그 과정에서 발생하는 오류를 해결하는 것까지 모두 개발자의 역할이다. 이 과정에서 CS 지식이 빛을 발한다.</p>
<br>

<p>서비스의 흐름을 보자.</p>
<ol>
<li>nginx가 요청을 받는다 </li>
<li>프론트로 전달 </li>
<li>프론트가 백엔드에 요청</li>
<li>인증 처리</li>
<li>백엔드는 DB에서 데이터 조회</li>
<li>가공 후 응답</li>
<li>프론트는 응답을 받아 Redux로 상태 관리</li>
<li>필요 시 백엔드가 Inngest로 요청</li>
<li>Inngest가 특정 작업을 실행</li>
</ol>
<br>

<p>이 흐름 속에는 CORS, 버퍼 관리, 동시성 관리, 비동기 요청, DB 쿼리, 인증, 메시지 큐 등 다양한 기술과 개념이 맞물린다. CS 지식은 바로 이런 순간에 힘을 발휘한다.</p>
<p>비즈니스 로직을 잘짜는 것? 물론 중요하다. 
하지만 개발자라면 흐름을 설계하고, 문제가 생겼을 때 어느 서비스의 어느 부분을 수정해야 하는지 파악할 수 있어야 한다.
GPT에게 모든 맥락을 이해시키는 것도 가능하겠지만, 정확도와 맥락 파악은 결국 개발자의 몫이다.</p>
<br>

<hr>
<br>


<h2 id="기억에-남는-경험">기억에 남는 경험</h2>
<h3 id="1-결제-시스템-구축기">1. 결제 시스템 구축기</h3>
<p>처음 입사했을 때, 회사 서비스는 <code>Firebase</code> 기반의 MVP였다.
<code>Firestore</code>, <code>Firebase Authentication</code>, <code>Firebase Function</code>, <code>Firebase Hosting</code> 등 모든 곳에서 Firebase를 사용하고 있었다.</p>
<p>자연스럽게 Firebase에 익숙해질 수 있었고, 특히 초반에는 백엔드 API 개발을 맡았기에 Firebase Function을 심도 있게 다뤄볼 수 있었다.</p>
<p>초반 2개월은 결제 시스템을 구축했다.
그냥 PG사 연동만 하면 되는거 아닌가???</p>
<p>맞다. 백엔드는 그리 어렵지 않았다.
하지만, DB 설계가 굉장히 까다로웠다.</p>
<blockquote>
<p>&#39;돈이 연관된 기능은 그 어떠한 것보다 정확하고 섬세해야 한다&#39;</p>
</blockquote>
<br>

<p>사용자가 구독을 요청했을 때,
구독을 취소했을 때,
invoice가 생성된 뒤에 구독을 취소했을 때,
invoice가 생성되기 전에 구독 요금제를 변경했을 때,
관리자가 사용자의 구독을 임의로 조정할 때....</p>
<p>이 모든 상황을 추적할 수 있는 구조가 필요했다.
그러나 이를 여러 필드의 조합으로 처리하려다 보니 복잡함이 상당했다.</p>
<p>처음 2주간 정말 머리를 싸매며 DB를 설계하고 백엔드 로직까지 완성해 CTO님께 컨펌을 받았다.
물론 요구사항에 맞게 모든 기능을 구현했지만, CTO님이 정곡을 찌르셨다.</p>
<blockquote>
<p>‘확장성이 부족하고, 현 상태를 파악하려면 여러 필드를 조합해야 하는 점이 아쉽다.’</p>
</blockquote>
<p>이 피드백과 함께, Stripe의 구독 관리 예시를 보여주시며 조언을 해주셨다.
항상 작업할 때는 best practice를 참고하는 것이 좋다는 이야기였다.</p>
<p>나보다 먼저 이 길을 걸어간 개발자들이 이미 많은 시행착오를 겪었고,
우리는 그중 검증된 길을 따라가야 효율적이라는 조언이었다.</p>
<p>이 피드백을 바탕으로 DB를 전면 재설계했다.
그 결과, 단일 필드만으로 현재 상태를 즉시 파악할 수 있는 구조를 만들었고, 새로운 상태나 기능이 추가되더라도 쉽게 확장 가능하도록 설계했다.
이 구조 덕분에 이후 Stripe 결제 방식을 추가할 때도 DB 스키마 변경 없이 바로 적용할 수 있었다.</p>
<br>

<h3 id="2-firebase-의존성-제거">2. Firebase 의존성 제거</h3>
<p>입사할 때만 해도 우리 서비스는 Firebase에 올인된 구조였다.
<code>Firestore</code>, <code>Firebase Auth</code>, <code>Firebase Functions</code>, <code>Firebase Storage</code>…
뭐든 Firebase에서 다 해결되는 구조였고, 덕분에 금방 익숙해졌다.</p>
<p>하지만, 편리한 만큼 제약도 많았다.
새로운 기능을 붙이거나 다른 서비스와 연동하려고 할 때, Firebase 구조에 맞춰야 하는 제약이 항상 따라왔다.
이러한 문제 때문에, 서비스에서 Firebase 의존성을 모두 제거하는 업무를 맡게 되었다.</p>
<h4 id="db부터-뜯어고치기">DB부터 뜯어고치기</h4>
<p>DB부터 뜯어고치기
첫 번째 타깃은 Firestore였다.
가장 힘들었던 건 NoSQL에서 RDB로 전환하는 과정이었다.
컬렉션·도큐먼트 기반으로 느슨하게 저장되던 데이터를,
관계형 구조에 맞춰 테이블·PK·FK·조인 테이블로 재설계해야 했다.
데이터 참조 방식이 완전히 달라지다 보니, 기존 로직도 전부 손봐야 했다.</p>
<p>처음엔 ORM(Prisma)로 접근했지만, Cloudflare Workers 환경에서 Node 전용 라이브러리들이 먹히지 않았다. Accelerate도 시도했지만, 이렇게 되면 플랫폼 의존성을 줄이려는 초기 목적과 멀어졌다.
결국 node-postgres로 직접 쿼리를 쓰기로 했고,
그때부터 Repository 레이어를 전부 손으로 다시 쓰는 장대한 작업이 시작됐다.</p>
<br>

<h4 id="프론트-직접-db-접근-제거">프론트 직접 DB 접근 제거</h4>
<p>MVP 시절에 만들어진 코드다 보니, 프론트에서 DB를 직접 호출하는 로직이 여기저기 박혀 있었다.
당시에는 빠르게 기능을 붙이는 게 목적이었으니 가능한 구조였겠지만, 서비스가 커진 지금은 보안·유지보수·확장성 측면에서 전혀 맞지 않았다.</p>
<p>그래서 전부 뜯어냈다.
프론트의 DB 직접 접근 코드를 전부 삭제하고,
대신 백엔드 API를 통해서만 데이터를 주고받도록 변경했다.
이 과정에서 API 스펙을 정리하고, 필요한 엔드포인트를 새로 만들었으며,
응답 포맷도 { code, message?, data? } 형태로 통일했다.</p>
<p>결과적으로, 프론트와 백의 역할이 명확히 분리됐고,
데이터 흐름을 추적·관리하기가 훨씬 쉬워졌다.</p>
<br>

<h4 id="functions를-workers로">Functions를 Workers로</h4>
<p>Firebase Functions도 마찬가지였다.
Cloudflare Workers + Hono로 마이그레이션하면서 라우팅, 인증, 서비스 구조를 싹 재정리했다.
Swagger랑 zod를 붙여서 문서와 검증을 한 번에 돌렸고, 기존 Functions 호출하던 프론트 로직도 전부 Workers API 호출로 바꿨다.</p>
<br>

<h4 id="인증-갈아엎기">인증 갈아엎기</h4>
<p>Firebase Auth는 Authentik으로 교체했다.
결정은 쉬웠지만, 붙이는 과정은 예상보다 길었다. 기록을 보면 거의 하루 단위로 문제를 부딪히고, 원인 찾고, 한 줄씩 고쳐 나갔다.</p>
<ul>
<li>프론트에서는 next-auth로 로그인 플로우를 다시 짰다.</li>
<li>API 테스트 환경도 손봤다. Swagger UI에 Security 스키마를 추가해서 Authorization 헤더로 실제 토큰을 넣고 테스트할 수 있게 했고, 개발 환경에서 Authentik ↔ Workers 연동을 끝까지 확인했다. </li>
<li>추가로 로그인 옵션을 확장했다.<ul>
<li>Google OAuth</li>
<li>Passkey</li>
</ul>
</li>
</ul>
<p>이메일 비밀번호 재설정은 SMTP(SendGrid)로 구현해서 복구 메일을 보낼 수 있게 했다.
<img src="https://velog.velcdn.com/images/jaewon-ju/post/a6edd98c-7b7a-4ab2-a90f-1475ee981614/image.png" alt=""></p>
<br>

<h4 id="inngest로-비동기-작업-처리">Inngest로 비동기 작업 처리</h4>
<p>파일 업로드 후 썸네일 생성, 월간 정산, 활성 유저 기록 같은 비동기 작업은 Inngest로 옮겼다.
원래는 워커 간 상태 공유와 큐 처리를 위해 Redis를 붙이려 했는데, Cloudflare Workers 환경에서는 직접 TCP 연결이 불가능했다.
이 경우에는 Upstash 같은 HTTP 기반 Redis 서비스를 써야 한다.</p>
<p>그런데 외부 SaaS 의존성을 또 늘리고 싶진 않았다.
그래서 한동안 셀프 호스팅 가능한 메시지 큐 서비스들을 조사한 뒤, Inngest라는 서비스를 도입했다.</p>
<p>업로드 이벤트를 받아 후처리를 실행하는 워크플로우, 크론 스케줄을 통한 주기 작업까지 전부 Inngest로 구성했다.</p>
<br>

<h3 id="3-배포">3. 배포</h3>
<p>초기에는 모든 서비스를 Docker로 감싸서 회사 NAS에 올리는 방식을 사용하려 했다.
DB, 백엔드, 프론트까지 전부 컨테이너로 띄우고, NAS에서 돌리는 구조였다.
여기에 서브 도메인을 붙이기 위해 nginx를 프록시로 두었고, 서비스별 라우팅도 전부 직접 관리했다.</p>
<p>이 방식은 회사 내부망에서 모든 걸 제어할 수 있다는 장점이 있었지만, 외부 접근과 확장성 면에서는 불편한 점이 많았다.
특히, 배포 변경이나 확장이 필요할 때마다 NAS에 직접 접속해 docker-compose를 수정하고 nginx 설정까지 건드려야 하는 구조였다.</p>
<p>그래서 지금은 각 서비스를 환경에 맞게 분리 배포하는 방식으로 전환했다.</p>
<ul>
<li>프론트엔드: Next.js를 Railway에 배포</li>
<li>인증 서버: Authentik 역시 Railway에서 구동</li>
<li>비동기 작업(Inngest): Inngest 자체 호스팅 서비스 사용</li>
<li>백엔드 API: Cloudflare Workers에서 동작</li>
<li>DB: Supabase PostgreSQL 사용</li>
</ul>
<p>이렇게 나누니 서비스별 스케일링을 각각 조절할 수 있고, 배포 속도도 훨씬 빨라졌다.
특히 Workers와 Railway를 조합하니,프론트와 백엔드가 서로 다른 환경에서 돌아가면서도 CORS·도메인 라우팅 문제 없이 안정적으로 붙을 수 있었다.</p>
<br>

<hr>
<br>


<h2 id="앞으로의-방향">앞으로의 방향</h2>
<p>실제로 업무를 하다보니, AI가 정말 강력함을 느꼈다.
코드를 짜는 것은 확실히 나보다 잘한다.
감히 예상해보자면, 10년 뒤에는 프론트, 백엔드 같은 직군은 없어질 것 같다..</p>
<blockquote>
<p>그럼에도 불구하고, 프론트와 백엔드 개발은 개발자라면 한번씩 거쳐야 할 관문이라고 생각한다.</p>
</blockquote>
<p>실제로 업무에 투입돼서 개발, 배포, 에러 해결까지 하다보면 프로젝트의 전반적인 흐름을 알게 된다.
그 흐름을 알고 있는 사람만이 AI를 효율적으로 사용할 수 있고, 기업에서도 그런 인재를 원할 것이다.</p>
<br>

<p>이제부터는 취업과 졸업 준비를 병행하려 한다.
웬만하면 IT 통합 직군으로 들어가서 관리하는 역할을 맡고 싶지만, 백엔드 직군으로 들어가서 에러랑 한번 더 뒹굴어 보는 것도 좋은 경험일 것 같다.</p>
<p>현장실습 인턴 고민중이라면 꼭꼭 해보시길 추천드립니다~</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩테스트] BFS, DFS]]></title>
            <link>https://velog.io/@jaewon-ju/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-DFS-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@jaewon-ju/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-DFS-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 06 Aug 2025 02:08:11 GMT</pubDate>
            <description><![CDATA[<p>이 포스팅의 목표는, 코딩테스트에서 자주 사용되는 핵심 문법들을 템플릿 형태로 정리하고, 이를 빠르게 외워서 실전 문제에 바로 적용할 수 있도록 만드는 것이다.</p>
<hr>
<h2 id="✏️-bfs">✏️ BFS</h2>
<h3 id="■-핵심-개념">■ 핵심 개념</h3>
<ul>
<li>시작 노드에서 출발하여 인접한 노드를 너비 우선으로 탐색.</li>
<li>큐(Queue)를 이용하여 구현.</li>
<li>방문 여부를 체크하는 visited 리스트가 필수 (중복 방문 방지).</li>
</ul>
<br>

<h3 id="■-한줄-요약">■ 한줄 요약</h3>
<pre><code class="language-bash">1. 큐에 시작 노드 삽입
2. 큐에서 꺼낸 후 인접 노드 탐색
3. 방문 안했으면 큐에 추가 및 방문 처리</code></pre>
<br>

<h3 id="■-구현">■ 구현</h3>
<h4 id="1-인접-리스트-방식">1. 인접 리스트 방식</h4>
<ul>
<li>그래프를 딕셔너리로 표현</li>
<li>노드마다 연결된 노드의 리스트를 가짐</li>
</ul>
<p>인수: 시작 노드, 그래프, visited 배열</p>
<pre><code class="language-python">from collections import deque

def BFS(start, graph, visited):
    queue = deque([start])
    visited[start] = True

    while queue:
        current = queue.popleft()

        for next in graph[current]:
            if not visited[next]:
                visited[next] = True
                queue.append(next)</code></pre>
<br>

<h4 id="2-인접-행렬-방식">2. 인접 행렬 방식</h4>
<ul>
<li>인접 행렬로 그래프를 표현</li>
<li>노드 수가 적고 간선이 많은 경우 유리</li>
</ul>
<p>인수: 시작 노드, 그래프, visited 배열</p>
<pre><code class="language-python">from collections import deque

def BFS(start, graph, visited):
    queue = deque([start])
    visited[start] = True

    while queue:
        current = queue.popleft()

        for next in range(len(graph)):
            if graph[current][next] == 1 and not visited[next]:
                visited[next] = True
                queue.append(next)</code></pre>
<br>

<h4 id="3-격자-기반-방식">3. 격자 기반 방식</h4>
<ul>
<li>미로 탐색, 최단거리 구하기 등에 자주 등장</li>
<li>2차원 배열로 그래프 표현</li>
</ul>
<p>인수: 시작 x좌표, y좌표</p>
<pre><code class="language-python">from collections import deque

dy = [-1, 1, 0, 0] #상, 하
dx = [0, 0, -1, 1] #좌, 우

def BFS(x, y):
    queue = deque()
    queue.append((x, y))
    visited[y][x] = True

    while queue:
        x, y = queue.popleft()

        for dir in range(4):
            nx = x + dx[dir]
            ny = y + dy[dir]

            if 0 &lt;= nx &lt; M and 0 &lt;= ny &lt; N:
                if not visited[ny][nx] and graph[ny][nx] == 1:
                    visited[ny][nx] = True
                    queue.append((nx, ny))</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-dfs">✏️ DFS</h2>
<h3 id="■-핵심-개념-1">■ 핵심 개념</h3>
<ul>
<li>시작 노드에서 출발하여 자식 노드를 하나씩 깊이 탐색.</li>
<li>재귀 또는 스택을 이용해 구현.</li>
<li>방문 여부를 체크하는 visited 리스트가 필수 (무한루프 방지).</li>
</ul>
<br>

<h3 id="■-한줄-요약-1">■ 한줄 요약</h3>
<pre><code class="language-bash">1. 방문 처리
2. 인접 노드 순회
3. 방문 안했으면 DFS 재귀 호출</code></pre>
<br>

<h3 id="■-구현-1">■ 구현</h3>
<h4 id="1-인접-리스트-방식-1">1. 인접 리스트 방식</h4>
<ul>
<li>그래프를 딕셔너리로 표현</li>
<li>노드마다 연결된 노드의 리스트를 가짐</li>
</ul>
<p>인수: 현재 노드, 그래프, visited 배열</p>
<pre><code class="language-py">def DFS(current, graph, visited):
    visited[current] = True

    for next in graph[current]:
        if(visitied[next] == False):
            DFS(next, graph, visited)</code></pre>
<br>

<h4 id="2-인접-행렬-방식-1">2. 인접 행렬 방식</h4>
<ul>
<li>인접 행렬로 그래프를 표현</li>
<li>노드 수가 적고 간선이 많은 경우 유리</li>
</ul>
<p>인수: 현재 노드, 그래프, visited 배열</p>
<pre><code class="language-py">def DFS(current, graph, visited):
    visited[current] = True

    for next in range(len(graph)):
        if(graph[current][next] == 1 and visited[next] == False):
            DFS(next, graph, visited)</code></pre>
<br>

<h4 id="3-격자-기반-방식-1">3. 격자 기반 방식</h4>
<ul>
<li>미로 탈출, 좌표 기반 이동에서 사용</li>
<li>2차원 배열로 그래프를 표현</li>
</ul>
<p>인수: 현재 x좌표, 현재 y좌표</p>
<pre><code class="language-py">dy = [-1, 1, 0, 0] #상, 하
dx = [0, 0, -1, 1] #좌, 우

def DFS(x, y):
    visited[y][x] = True

    for dir in range(4):
        nx = x + dx[dir]
        ny = y + dy[dir]

        if((0 &lt;= nx &lt; M) and (0 &lt;= ny &lt; N)):
            if(visited[ny][nx] == False and graph[ny][nx] == 1):
                DFS(nx, ny)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 지식] Binary Data 총정리]]></title>
            <link>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Binary-Data-%EC%B4%9D%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Binary-Data-%EC%B4%9D%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 14 Jul 2025 08:56:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>현대 웹과 Node.js 애플리케이션에서는 이미지, 영상, 오디오, 압축 파일 등 다양한 <strong>바이너리 데이터</strong>를 다루는 일이 흔하다. 이러한 데이터는 기본적인 문자열로는 표현할 수 없기 때문에, 자바스크립트는 이를 처리하기 위한 다양한 저수준 타입들을 제공한다.</p>
</blockquote>
<p><code>Buffer</code>, <code>Blob</code>, <code>Uint8Array</code>...
프론트-백 API 연동을 할 때 항상 헷갈리는 것들이다.
Binary Data의 타입은 어떤 것들이 있고, HTTP로 전송할 때는 어떤 방식을 사용해야하는지 정리해보자.</p>
<br>

<hr>
<br>

<h2 id="✏️-바이너리-데이터란">✏️ 바이너리 데이터란?</h2>
<p>바이너리 데이터(Binary Data)란 사람이 읽을 수 있는 텍스트가 아닌 비트와 바이트로 구성된 순수 데이터를 의미한다.</p>
<p>대표적인 예시:</p>
<ul>
<li>이미지: PNG, JPG</li>
<li>오디오: MP3, WAV</li>
<li>영상: MP4, TS</li>
<li>압축 파일: ZIP, RAR</li>
<li>PDF, 바이너리 로그 파일 등</li>
</ul>
<br>

<hr>
<br>


<h2 id="✏️-타입-구조도">✏️ 타입 구조도</h2>
<p>자바스크립트에서 바이너리 데이터를 다루는 주요 타입들은 다음과 같은 계층 구조를 이룬다.</p>
<pre><code class="language-ts">┌────────────┐
│ ArrayBuffer│  ← 원시 메모리
└────┬───────┘
     │     
     │                           
 TypedArray (ex: Uint8Array)   
          │
     ┌────┴────┐
     │         │
  Buffer     Blob/File
(Node.js)   (브라우저)</code></pre>
<br>

<h3 id="◼︎-arraybuffer">◼︎ ArrayBuffer</h3>
<blockquote>
<p>고정 길이의 메모리 덩어리이다.</p>
</blockquote>
<ul>
<li>직접 값을 읽거나 쓸 수 없으며, 보통 Uint8Array와 함께 사용한다.</li>
<li>말 그대로 &quot;빈 메모리 공간&quot;이다.</li>
</ul>
<pre><code class="language-ts">const buffer = new ArrayBuffer(8)</code></pre>
<br>

<h3 id="◼︎-typedarray-예-uint8array">◼︎ TypedArray (예: Uint8Array)</h3>
<blockquote>
<p>ArrayBuffer 위에 구조를 입힌 배열 타입이다.</p>
</blockquote>
<ul>
<li>각 요소는 고정된 크기의 정수 또는 실수이다.</li>
<li>바이너리 데이터를 가장 널리 다룰 수 있는 방식이다.</li>
</ul>
<pre><code class="language-ts">const arr = new Uint8Array([72, 101, 108, 108, 111]) // Hello</code></pre>
<br>

<h3 id="◼︎-buffer-nodejs-전용">◼︎ Buffer (Node.js 전용)</h3>
<p>Uint8Array를 확장한 Node.js 전용 바이너리 타입이다.</p>
<ul>
<li>파일, 네트워크, 스트림 등과의 빠른 IO 처리를 위해 설계되었다.</li>
<li>대부분의 Node API에서 기본적으로 사용된다.</li>
</ul>
<pre><code class="language-ts">const buf = Buffer.from(&#39;Hello&#39;)</code></pre>
<br>


<h3 id="◼︎-blob-브라우저-전용">◼︎ Blob (브라우저 전용)</h3>
<p>바이너리 대용량 데이터를 추상적으로 감싼 컨테이너이다.</p>
<ul>
<li>내부 내용을 직접 수정할 수 없으며, 읽기 전용이다.</li>
<li>type(MIME 타입)을 지정할 수 있다.</li>
</ul>
<pre><code class="language-ts">const blob = new Blob([Uint8Array], { type: &#39;image/png&#39; })</code></pre>
<br>

<h3 id="◼︎-file-브라우저-전용">◼︎ File (브라우저 전용)</h3>
<p>Blob을 확장한 구조로, 파일명과 수정일 등 메타데이터를 포함한다.</p>
<ul>
<li>보통 사용자가 입력한 파일을 다룰 때 사용된다.</li>
</ul>
<pre><code class="language-ts">const file = new File([blob], &#39;image.png&#39;, { type: &#39;image/png&#39; })</code></pre>
<br>

<hr>
<br>


<h2 id="✏️-binary-data의-직렬화">✏️ Binary Data의 직렬화</h2>
<p>⚠️⚠️⚠️ <span style="color:red">바이너리 데이터는 JSON.stringify를 통해 직렬화가 되지 않는다.</span></p>
<pre><code class="language-ts">JSON.stringify(new ArrayBuffer(8)) // 결과: {}</code></pre>
<p>왜냐하면 ArrayBuffer나 Buffer, Uint8Array는 메모리 주소만 가지며, 구조화된 정보를 담지 않기 때문이다.
직렬화를 위해서는 Array&lt;number&gt; 형태로 변환하거나, base64 문자열로 인코딩하는 방법을 사용해야 한다.</p>
<br>

<hr>
<br>


<h2 id="✏️-formdata와-바이너리-전송-시-고려할-점">✏️ FormData와 바이너리 전송 시 고려할 점</h2>
<p>바이너리 데이터를 API로 전송할 때는 일반적으로 두 가지 방식이 사용된다:</p>
<ol>
<li>FormData를 사용하는 방식 (multipart/form-data)</li>
<li>바디에 바이너리 자체를 넣는 방식 (application/octet-stream 혹은 base64/JSON 변환)</li>
</ol>
<br>

<h3 id="◼︎-formdata">◼︎ FormData</h3>
<p>FormData는 파일 업로드에 특화된 구조이며, 여러 필드와 함께 파일을 함께 전송할 수 있다.</p>
<blockquote>
<h4 id="장점">장점</h4>
</blockquote>
<ul>
<li>브라우저 fetch, axios, form 등과 호환성이 높다.</li>
<li>파일 이름, 타입 등 메타데이터를 포함할 수 있다.</li>
<li>문자열, 숫자 등의 부가 정보도 함께 전송 가능하다.</li>
</ul>
<blockquote>
<h4 id="주의할-점">주의할 점</h4>
<p>Node.js에서 FormData를 생성할 경우, form-data, formdata-node, undici, formdata-polyfill 등 환경별 라이브러리 호환 이슈가 발생할 수 있다.</p>
</blockquote>
<br>

<h3 id="◼︎-직접-바이너리-전송-applicationoctet-stream">◼︎ 직접 바이너리 전송 (application/octet-stream)</h3>
<p>파일 하나만 전송하거나, 간단한 API 설계를 원할 경우 request body에 바이너리를 직접 담아 전송할 수 있다.</p>
<blockquote>
<h4 id="장점-1">장점</h4>
</blockquote>
<ul>
<li>간결하다. form 구조 없이 곧바로 버퍼를 전송할 수 있다.</li>
<li>서버 측에서 메모리 사용량을 줄이고 스트리밍 처리하기 좋다.</li>
</ul>
<blockquote>
<h4 id="주의할-점-1">주의할 점</h4>
</blockquote>
<ul>
<li>파일 외의 추가 메타데이터(fileId, userId 등)를 함께 보내기 어렵다.</li>
<li>다중 파일 업로드에는 적합하지 않다.</li>
</ul>
<pre><code class="language-ts">fetch(&#39;/upload&#39;, {
  method: &#39;POST&#39;,
  headers: { &#39;Content-Type&#39;: &#39;application/octet-stream&#39; },
  body: fileBuffer,
})</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] Authentik API 403 Forbidden]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-Authentik-API-403-Forbidden</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-Authentik-API-403-Forbidden</guid>
            <pubDate>Mon, 07 Jul 2025 06:36:08 GMT</pubDate>
            <description><![CDATA[<p><a href="#English">English Version</a></p>
<p>Next.js 프로젝트에서 Authentik 기반 OIDC 인증을 구성한 후, 로그인된 사용자 세션을 기반으로 WebAuthn 장치 정보를 가져오는 API(<code>/api/auth/webauthn</code>)에서 production 환경에서만 <code>403 Forbidden</code> 오류가 발생하는 문제가 있었다.</p>
<br>

<hr>
<br>

<h2 id="✏️-오류-발생-환경">✏️ 오류 발생 환경</h2>
<p>로그인 후, 다음과 같은 API를 통해 현재 사용자의 WebAuthn 장치 보유 여부를 확인하고자 했다:</p>
<pre><code class="language-ts">const authentikRes = await fetch(
  `${process.env.NEXT_PUBLIC_AUTHENTIK_URL}api/v3/authenticators/webauthn/`,
  {
    headers: {
      Authorization: `Bearer ${accessToken}`, // 액세스 토큰으로 인증
    },
  },
);</code></pre>
<p>그러나 production 환경에서 다음과 같은 에러가 발생했다:</p>
<pre><code>GET https://app.wedeo.io/api/auth/webauthn 403 (Forbidden)</code></pre><p>이때 사용한 accessToken은 NextAuth.js의 getToken() 함수로부터 추출한 OAuth2 Access Token이다.</p>
<br>

<hr>
<br>
## ✏️ 원인

<p>Authentik의 공식 문서에서는 다음과 같이 명시되어 있다:</p>
<blockquote>
<h3 id="jwt-token">JWT Token</h3>
<p>OAuth2 clients can request the scope goauthentik.io/api, which allows their OAuth Access token to be used to authenticate to the API.</p>
</blockquote>
<ul>
<li><p>즉, Access Token을 API 인증에 사용하려면 반드시 scope에 goauthentik.io/api가 포함되어 있어야 한다.</p>
</li>
<li><p>그러나 기존 설정에서는 scope 항목에 이 값이 누락되어 있었기 때문에, 발급받은 토큰이 API 접근 권한을 갖지 못했고, 그 결과 403 Forbidden 오류가 발생한 것이다.</p>
</li>
</ul>
<br>

<hr>
<br>


<h2 id="✏️-해결방법">✏️ 해결방법</h2>
<p>OAuth2 인증 요청 시 scope에 goauthentik.io/api를 추가</p>
<p>NextAuth.js의 AuthentikProvider 설정을 다음과 같이 수정하였다:</p>
<pre><code class="language-ts">const authOptions: NextAuthOptions = {
  debug: true,
  providers: [
    AuthentikProvider({
      id: &#39;authentik&#39;,
      name: &#39;authentik&#39;,
      authorization: {
        url: &#39;http://localhost:9000/application/o/authorize/&#39;,
        params: {
          scope: &#39;openid email profile offline_access goauthentik.io/api&#39;,
        },
      },
      clientId: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID,
      clientSecret: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_SECRET,
      issuer: process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER,
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.name,
          email: profile.email,
        };
      },
    }),
  ],
};</code></pre>
<p>해당 설정 이후, 발급된 Access Token은 API 호출 시 Authorization: Bearer 헤더를 통해 인증 토큰으로 정상 인식되었고, 403 Forbidden 오류는 더 이상 발생하지 않았다.</p>
<p><br> <br><br><br><br><br><br> </p>
<div id="English"></div>
Issue: 403 Forbidden on Authentik API Call via OAuth Access Token
In a Next.js project, we used Authentik as the OIDC provider.
After logging in, we attempted to query the current user’s WebAuthn devices via the following API:

<hr>
<pre><code class="language-ts">const authentikRes = await fetch(
  `${process.env.NEXT_PUBLIC_AUTHENTIK_URL}api/v3/authenticators/webauthn/`,
  {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  },
);</code></pre>
<p>However, in the production environment, the following error occurred:</p>
<pre><code>GET https://app.wedeo.io/api/auth/webauthn 403 (Forbidden)</code></pre><br>

<hr>
<br>

<h2 id="✏️-root-cause">✏️ Root Cause</h2>
<p>According to the Authentik documentation, JWT access tokens can only be used to authenticate against the API if the OAuth2 scope includes:</p>
<pre><code class="language-bash">goauthentik.io/api</code></pre>
<p>Without this scope, the access token lacks API privileges and is rejected with a 403 Forbidden response, even if the user is otherwise authenticated.</p>
<br>

<hr>
<br>


<h2 id="✏️-solution">✏️ Solution</h2>
<p>Add goauthentik.io/api to the OAuth2 scope when configuring the provider</p>
<p>We updated the AuthentikProvider configuration in NextAuth.js as follows:</p>
<pre><code class="language-ts">AuthentikProvider({
  id: &#39;authentik&#39;,
  name: &#39;authentik&#39;,
  authorization: {
    url: &#39;http://localhost:9000/application/o/authorize/&#39;,
    params: {
      scope: &#39;openid email profile offline_access goauthentik.io/api&#39;,
    },
  },
  clientId: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID,
  clientSecret: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_SECRET,
  issuer: process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER,
  profile(profile) {
    return {
      id: profile.sub,
      name: profile.name,
      email: profile.email,
    };
  },
})</code></pre>
<p>After including this scope, Authentik issued access tokens that were authorized to access the API.
As a result, the 403 Forbidden error was resolved.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 지식] HyperDrvie]]></title>
            <link>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-HyperDrvie</link>
            <guid>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-HyperDrvie</guid>
            <pubDate>Mon, 23 Jun 2025 08:12:42 GMT</pubDate>
            <description><![CDATA[<h3 id="cloudflare-workers">Cloudflare Workers</h3>
<p>환경에서 PostgreSQL 데이터베이스에 접근할 때 가장 큰 병목 중 하나는 TCP 연결 시간이다. Workers는 요청마다 콜드스타트를 동반하며, 매번 TCP 핸드셰이크를 수행해야 한다. 이로 인해 단순 SELECT 쿼리조차 평균 500ms 이상의 지연이 발생하는 경우가 많다.</p>
<p>이를 해결하기 위해 Cloudflare Hyperdrive를 적용했고, 그 결과 단순 조회는 평균 10ms 내외, INSERT는 약 200ms 수준까지 응답 속도를 줄이는 데 성공했다. 이 글은 Hyperdrive의 개념부터 적용 방법, 그리고 성능 개선 결과까지 정리한 실전 도입 사례다.</p>
<br>

<hr>
<h2 id="✏️-hyperdrive란">✏️ Hyperdrive란?</h2>
<h3 id="◻️-개요">◻️ 개요</h3>
<blockquote>
<h4 id="hyperdrive">HyperDrive</h4>
<p>: Cloudflare에서 제공하는 SQL 연결 최적화 레이어다. </p>
</blockquote>
<ul>
<li>PostgreSQL 또는 MySQL과 같은 전통적인 관계형 데이터베이스는 TCP 소켓을 통해 통신하는데, 이때 매 요청마다 발생하는 연결 지연을 Hyperdrive가 해결한다.</li>
<li>Hyperdrive는 전역에 배포된 Cloudflare 인프라 내에서 연결을 사전 생성 및 풀링하고, 쿼리를 캐싱 및 릴레이하여 요청-응답 시간을 최소화한다. </li>
</ul>
<br>

<h3 id="◻️-기존-구조의-문제점">◻️ 기존 구조의 문제점</h3>
<ul>
<li>Workers는 상태 없는 실행 환경이기 때문에 매번 새로운 TCP 핸드셰이크가 발생</li>
<li>PostgreSQL 드라이버는 TLS 설정 등으로 인해 초기 연결에 수백 ms 소요</li>
<li>하나의 함수에서 두 번 이상 DB 접근 시, 두 번째 요청부터는 커넥션이 재사용되어 속도는 빨라지지만 첫 요청의 지연은 불가피</li>
</ul>
<br>

<h3 id="◻️-hyperdrive는-이-문제를-다음과-같이-해결한다">◻️ Hyperdrive는 이 문제를 다음과 같이 해결한다.</h3>
<ol>
<li>Cloudflare 네트워크 상에 전역으로 배포된 노드에서 DB 연결을 <span style="color:red">풀링</span> 및 유지</li>
<li>자주 수행되는 쿼리에 대해 <span style="color:red">스마트 캐싱</span> 전략을 활용하여 RTT를 제거</li>
</ol>
<br>

<hr>
<br>

<h2 id="✏️-hyperdrive-적용-방법">✏️ Hyperdrive 적용 방법</h2>
<h4 id="1-hyperdrive-설정-생성">1. Hyperdrive 설정 생성</h4>
<pre><code class="language-bash">npx wrangler hyperdrive create hyperdrive-config \
  --connection-string=&quot;postgres://USER:PASSWORD@HOST:5432/DB&quot;</code></pre>
<p>이 명령을 실행하면 Hyperdrive 설정 ID가 출력된다.</p>
<br>


<h4 id="2-wranglertoml-파일에-바인딩-추가">2. wrangler.toml 파일에 바인딩 추가</h4>
<pre><code class="language-json">name = &quot;my-worker&quot;
main = &quot;src/index.ts&quot;
compatibility_date = &quot;2024-08-21&quot;
compatibility_flags = [&quot;nodejs_compat&quot;]

[hyperdrive]
binding = &quot;HYPERDRIVE&quot;
id = &quot;&lt;생성된 Hyperdrive ID&gt;&quot;</code></pre>
<br>

<h4 id="3-타입-선언-파일-생성">3. 타입 선언 파일 생성</h4>
<pre><code class="language-bash">npx wrangler types</code></pre>
<br>

<h4 id="4-worker-코드-수정">4. Worker 코드 수정</h4>
<pre><code class="language-ts">import postgres from &#39;postgres&#39;

export default {
  async fetch(request, env, ctx) {
    const sql = postgres(env.HYPERDRIVE.connectionString)

    const data = await sql`SELECT * FROM users LIMIT 1`;
    return new Response(JSON.stringify(data));
  }
}</code></pre>
<br>

<h4 id="5-배포">5. 배포</h4>
<pre><code class="language-bash">npx wrangler deploy</code></pre>
<br>

]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] NextAuth - Authentik Docker Issue (2)]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-NextAuth-Authentik-Docker-Issue-2</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-NextAuth-Authentik-Docker-Issue-2</guid>
            <pubDate>Mon, 16 Jun 2025 07:23:38 GMT</pubDate>
            <description><![CDATA[<p><a href="#English">English Version</a>
Next.js 프로젝트에서 Authentik을 OIDC Provider로 사용하고자 하였고, docker-compose 기반에서 Authentik을 server:9000 으로 내부 연결하였다.
이 과정에서 NextAuth.js가 wellKnown 메커니즘을 통해 Authentik의 Public URL을 잘못 해석하면서 OAuth 인증 흐름이 정상적으로 동작하지 않는 문제가 발생했다.</p>
<br>

<hr>
<br>


<h2 id="✏️-오류-발생-환경">✏️ 오류 발생 환경</h2>
<p>NextAuth.js의 AuthentikProvider를 다음과 같이 설정:</p>
<pre><code class="language-typescript">wellKnown: &#39;http://server:9000/application/o/wedeo/.well-known/openid-configuration&#39;</code></pre>
<p>이 때 NextAuth.js 라이브러리 내부 코드를 확인한 결과:</p>
<ul>
<li><p>wellKnown 옵션이 존재하면, NextAuth.js는 해당 엔드포인트로 직접 HTTP 요청을 보내어 authorize, token, jwks, userinfo 등의 엔드포인트 URL을 동적으로 가져옴.</p>
</li>
<li><p>이 과정에서 다음과 같은 OIDC 메타데이터가 반환됨:</p>
<pre><code class="language-json">{
&quot;authorization_endpoint&quot;: &quot;http://server:9000/application/o/authorize/&quot;,
&quot;token_endpoint&quot;: &quot;http://server:9000/application/o/token/&quot;,
&quot;userinfo_endpoint&quot;: &quot;http://server:9000/application/o/userinfo/&quot;,
&quot;jwks_uri&quot;: &quot;http://server:9000/application/o/wedeo/jwks/&quot;
}</code></pre>
</li>
</ul>
<br>

<p>그러나 이 URL들은 브라우저 입장에서 접근이 불가능함:</p>
<ul>
<li>브라우저 → server:9000 → Docker 내부 DNS → 접근 불가 (브라우저는 내부 컨테이너 네트워크를 인식하지 못함)</li>
<li><span style = "color:red">브라우저는 반드시 auth.localhost 와 같은 public-facing 도메인을 통해 접근해야 한다.</span></li>
</ul>
<br>

<hr>
<br>

<h2 id="✏️-원인">✏️ 원인</h2>
<p>NextAuth.js는 wellKnown를 통해 OIDC 서버 메타데이터를 자동 수집함.</p>
<ul>
<li>wellKnown 응답에 포함되는 모든 엔드포인트가 상대 경로가 아닌 절대 경로로 반환됨.</li>
<li>Authentik은 이 절대 경로를 PUBLIC_URL 기준으로 계산함 → 현재는 내부 DNS(server:9000) 기준으로 URL을 생성.</li>
</ul>
<p>따라서, Authorization URL → 브라우저 접근 실패
Token URL → docker 내부 호출 시 Connection refused</p>
<p>특히, 만약 wellKnown에 <code>auth.localhost</code>를 넣더라도 문제가 발생하는데:</p>
<p>Authorization URL은 정상작동하나
이후 토큰 교환 과정에서 Next.js 서버가 auth.localhost에 접근하려다 → ECONNREFUSED 발생 (docker 내부에서 외부 도메인으로 연결 불가)</p>
<p>결국 브라우저용 엔드포인트와 서버용 엔드포인트가 충돌하는 문제 발생.</p>
<br>

<hr>
<br>

<h2 id="✏️-해결방법">✏️ 해결방법</h2>
<blockquote>
<p>wellKnown 자동 요청을 완전히 차단</p>
</blockquote>
<ul>
<li>wellKnown: null로 설정하여 NextAuth.js가 OIDC 서버 메타데이터를 자동으로 가져오지 않도록 만듦</li>
<li>모든 OIDC 엔드포인트를 명시적으로 수동 지정</li>
</ul>
<pre><code class="language-typescript">AuthentikProvider({
  id: &#39;authentik&#39;,
  name: &#39;authentik&#39;,
  authorization: {
    url: &#39;http://auth.localhost/application/o/authorize/&#39;,
    params: { scope: &#39;openid email profile offline_access&#39; },
  },
  clientId: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID,
  clientSecret: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_SECRET,
  wellKnown: null, // 자동 wellKnown 사용 차단
  jwks_endpoint: &#39;http://server:9000/application/o/wedeo/jwks/&#39;,
  issuer: &#39;http://server:9000/application/o/wedeo/&#39;,
  token: &#39;http://server:9000/application/o/token/&#39;,
  userinfo: &#39;http://server:9000/application/o/userinfo/&#39;,
  profile(profile) {
    return {
      id: profile.sub,
      name: profile.name,
      email: profile.email,
    };
  },
})</code></pre>
<br>
<br><br><br><br><br><br>

<div id="English"></div>

<h1 id="issue-incorrect-public-url-resolution-when-using-authentik-as-oidc-provider-in-docker-with-nextjs">Issue: Incorrect Public URL Resolution When Using Authentik as OIDC Provider in Docker with Next.js</h1>
<p>In a Next.js project, Authentik was used as the OIDC Provider.
Inside a docker-compose setup, Authentik was internally exposed as <code>server:9000</code>.
During integration with NextAuth.js, Public URL resolution issues occurred due to how NextAuth.js retrieves OIDC metadata using the <code>wellKnown</code> mechanism, ultimately breaking the OAuth flow.</p>
<hr>
<h2 id="✏️-problem-environment">✏️ Problem Environment</h2>
<p>The AuthentikProvider in NextAuth.js was configured as follows:</p>
<pre><code class="language-typescript">wellKnown: &#39;http://server:9000/application/o/wedeo/.well-known/openid-configuration&#39;</code></pre>
<p>By inspecting the NextAuth.js library code directly:</p>
<ul>
<li>If <code>wellKnown</code> is specified, NextAuth.js sends an HTTP request to the provided endpoint.</li>
<li>It dynamically retrieves the OIDC metadata such as <code>authorization</code>, <code>token</code>, <code>userinfo</code>, <code>jwks</code>, etc.</li>
</ul>
<p>The returned metadata looked like this:</p>
<pre><code class="language-json">{
  &quot;authorization_endpoint&quot;: &quot;http://server:9000/application/o/authorize/&quot;,
  &quot;token_endpoint&quot;: &quot;http://server:9000/application/o/token/&quot;,
  &quot;userinfo_endpoint&quot;: &quot;http://server:9000/application/o/userinfo/&quot;,
  &quot;jwks_uri&quot;: &quot;http://server:9000/application/o/wedeo/jwks/&quot;
}</code></pre>
<p>However, these URLs are <strong>inaccessible from the browser</strong>:</p>
<ul>
<li>Browser → <code>server:9000</code> → Docker internal DNS → unreachable (browsers have no knowledge of container-internal DNS)</li>
<li><strong>The browser must always use a public-facing domain such as <code>auth.localhost</code></strong>.</li>
</ul>
<br>

<hr>
<br>

<h2 id="✏️-root-cause">✏️ Root Cause</h2>
<ul>
<li>NextAuth.js automatically fetches OIDC server metadata using <code>wellKnown</code>.</li>
<li>The OIDC server (Authentik) returns <strong>absolute URLs</strong> (not relative paths).</li>
<li>Authentik generates these URLs based on its configured <code>PUBLIC_URL</code>, which in this case is incorrectly resolved to <code>server:9000</code>.</li>
</ul>
<p>This causes:</p>
<ul>
<li><code>authorization_endpoint</code> → Browser cannot reach <code>server:9000</code> → login fails.</li>
<li><code>token_endpoint</code> → When exchanging the token, server-to-server call to <code>server:9000</code> succeeds, but when switched to <code>auth.localhost</code>, it causes <code>ECONNREFUSED</code> inside Docker because containers must use Docker internal DNS.</li>
</ul>
<blockquote>
<p>⚠ Even if <code>wellKnown</code> is pointed to <code>auth.localhost</code>, problems remain:</p>
<ul>
<li>Authorization URL works in browser.</li>
<li>But token exchange fails inside Docker since container cannot resolve external domain <code>auth.localhost</code>.</li>
</ul>
</blockquote>
<br>

<hr>
<br>

<h2 id="✏️-solution">✏️ Solution</h2>
<blockquote>
<p><strong>Fully disable automatic wellKnown discovery</strong></p>
</blockquote>
<ul>
<li>Set <code>wellKnown: null</code> to prevent NextAuth.js from automatically requesting OIDC metadata.</li>
<li>Manually define all OIDC endpoints to fully control internal vs public routing.</li>
</ul>
<pre><code class="language-typescript">AuthentikProvider({
  id: &#39;authentik&#39;,
  name: &#39;authentik&#39;,
  authorization: {
    url: &#39;http://auth.localhost/application/o/authorize/&#39;,
    params: { scope: &#39;openid email profile offline_access&#39; },
  },
  clientId: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID,
  clientSecret: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_SECRET,
  wellKnown: null, // disable automatic metadata fetching
  jwks_endpoint: &#39;http://server:9000/application/o/wedeo/jwks/&#39;,
  issuer: &#39;http://server:9000/application/o/wedeo/&#39;,
  token: &#39;http://server:9000/application/o/token/&#39;,
  userinfo: &#39;http://server:9000/application/o/userinfo/&#39;,
  profile(profile) {
    return {
      id: profile.sub,
      name: profile.name,
      email: profile.email,
    };
  },
})</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] NextAuth - Authentik Docker Issue (1)]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-NextAuth-Authentik-Docker-Issue-1</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-NextAuth-Authentik-Docker-Issue-1</guid>
            <pubDate>Mon, 16 Jun 2025 07:12:24 GMT</pubDate>
            <description><![CDATA[<p><a href="#English">English Version</a>
Next.js 프로젝트에서 Authentik을 OIDC Provider로 사용하기 위해, docker-compose 환경에서 Nginx를 통해 리버스 프록시를 구성하였다.
이 과정에서 /authentik/와 같은 서브패스 방식으로 Authentik을 배포하려 했으나, 정적 파일 로드 실패로 인해 인증 화면 자체가 정상 렌더링되지 않았다.</p>
<br>

<h2 id="✏️-오류-발생-환경">✏️ 오류 발생 환경</h2>
<p>Nginx에서 다음과 같이 서브패스 프록시를 구성했었다:</p>
<pre><code class="language-nginx">location /authentik/ {
    proxy_pass http://server:9000/;
    ...
}</code></pre>
<p>그러나 브라우저에서 Authentik에 접근하면 다음과 같이 정적 파일 요청 실패가 발생:</p>
<pre><code class="language-bash">GET http://app.localhost/static/dist/main.js 404 (Not Found)
GET http://app.localhost/static/dist/theme.css 404 (Not Found)</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-원인">✏️ 원인</h2>
<blockquote>
<p>Authentik은 내부적으로 정적 리소스의 경로를 절대경로<code>(/static/...)</code>로 지정하고 있다.
서브패스를 사용하면 브라우저가 <code>/authentik/static/...</code> 경로를 요청해야 하는데, Authentik은 이를 상대 경로가 아닌 절대경로<code>(/static/...)</code>로 반환하여 충돌 발생.</p>
</blockquote>
<ul>
<li>결과적으로 정적 리소스 요청이 전부 404 에러로 실패.</li>
</ul>
<br>

<hr>
<br>

<h2 id="✏️-해결방법">✏️ 해결방법</h2>
<p>서브도메인 방식으로 분리하여 각각 독립적인 도메인으로 서비스</p>
<ul>
<li>Authentik → auth.localhost</li>
<li>Next.js → app.localhost</li>
</ul>
<p>이를 위해 Nginx에서 다음과 같이 도메인 단위로 리버스 프록시 설정을 변경:</p>
<pre><code class="language-bash">server {
    listen 80;
    server_name auth.localhost;

    location / {
        proxy_pass http://authentik:9000;
        ...
    }
}

server {
    listen 80;
    server_name app.localhost;

    location / {
        proxy_pass http://nextjs-app:3000;
        ...
    }
}</code></pre>
<p>이후 Authentik의 PUBLIC_URL을 <code>http://auth.localhost</code>로 지정하여 절대경로 문제도 동시에 해결됨.
이로써 인증 UI, 정적 파일, OIDC 엔드포인트 모두 정상 동작하게 되었다.</p>
<br>



<br>
<br>
<br>
<br>
<br>
<br>
<br>

<div id = "English"></div>
Subpath Issue When Running Authentik via Docker (Static Files Failing to Load)
In a Next.js project using Authentik as an OIDC provider, Nginx was configured as a reverse proxy in a docker-compose environment.
During this process, Authentik was deployed under a subpath (e.g. /authentik/), but static files failed to load, preventing the authentication UI from rendering properly.


<br>

<hr>
<br>

<h2 id="✏️-error-scenario">✏️ Error Scenario</h2>
<p>Initially, Nginx was configured with the following subpath proxy rule:</p>
<pre><code class="language-bash">location /authentik/ {
    proxy_pass http://authentik:9000/;
    ...
}</code></pre>
<p>However, when accessing Authentik in the browser, static resource requests failed:</p>
<pre><code class="language-bash">GET http://app.localhost/static/dist/main.js 404 (Not Found)
GET http://app.localhost/static/dist/theme.css 404 (Not Found)</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-root-cause">✏️ Root Cause</h2>
<p>Internally, Authentik uses absolute paths (/static/...) for its static resources.
When using a subpath, the browser expects URLs like /authentik/static/....
However, Authentik returns absolute URLs such as /static/..., which leads to incorrect requests.</p>
<p>As a result, all static resource requests fail with 404 Not Found errors.</p>
<br>

<hr>
<br>


<h2 id="✏️-solution">✏️ Solution</h2>
<p>Instead of using subpaths, configure separate subdomains to serve each service independently:</p>
<ul>
<li>Authentik → auth.localhost</li>
<li>Next.js → app.localhost</li>
</ul>
<p>Update Nginx configuration to route by domain:</p>
<pre><code class="language-bash">server {
    listen 80;
    server_name auth.localhost;

    location / {
        proxy_pass http://authentik:9000;
        ...
    }
}

server {
    listen 80;
    server_name app.localhost;

    location / {
        proxy_pass http://nextjs-app:3000;
        ...
    }
}</code></pre>
<p>This resolves the absolute path issue entirely, allowing the authentication UI, static files, and all OIDC endpoints to work correctly.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] Canvas Docker install 오류]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-Canvas-Docker-install-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-Canvas-Docker-install-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Mon, 09 Jun 2025 05:38:40 GMT</pubDate>
            <description><![CDATA[<p>Docker로 Next.js 프로젝트를 빌드하는 과정에서, npm ci 명령어 실행 시 canvas 모듈의 네이티브 바이너리 설치 실패로 인해 빌드가 중단되었다.</p>
<p>에러 메시지는 다음과 같다:</p>
<pre><code class="language-bash">npm error node-pre-gyp ERR! install response status 404 Not Found on https://github.com/Automattic/node-canvas/releases/download/...
npm error Package pangocairo was not found in the pkg-config search path.
npm error gyp ERR! stack Error: `gyp` failed with exit code: 1</code></pre>
<br>

<h2 id="✏️-오류-발생-환경">✏️ 오류 발생 환경</h2>
<p>Next.js 프로젝트의 Dockerfile에서 다음과 같이 npm ci를 실행하는 코드가 포함되어 있었다:</p>
<pre><code class="language-Dockerfile">RUN npm ci --prefer-offline --no-audit</code></pre>
<br>

<p>해당 프로젝트는 canvas 모듈을 사용하고 있었고, 이 모듈은 C++로 구현된 네이티브 확장 모듈이므로 시스템 레벨의 라이브러리와 빌드 도구가 필요하다. 하지만 Docker 이미지에는 이러한 의존성이 포함되어 있지 않아 빌드 실패가 발생하였다.</p>
<br>

<hr>
<h2 id="✏️-원인">✏️ 원인</h2>
<p>canvas는 사전 빌드된 바이너리를 찾지 못하면 직접 소스 빌드 시도</p>
<ul>
<li>이 과정에서 pkg-config, pangocairo, libcairo2-dev 등 필수 시스템 패키지가 누락되어 빌드 실패</li>
<li>기본적으로 사용하는 node 기반 Docker 이미지에는 이러한 라이브러리가 없기 때문에 gyp 빌드가 실패함</li>
</ul>
<br>

<hr>
<h2 id="✏️-해결방법">✏️ 해결방법</h2>
<p>canvas 빌드를 위해 필요한 패키지들을 Dockerfile에 명시적으로 설치한다. 예시는 다음과 같다:</p>
<pre><code class="language-bash">FROM node:20

RUN apt-get update &amp;&amp; apt-get install -y \
  build-essential \
  python3 \
  pkg-config \
  libcairo2-dev \
  libpango1.0-dev \
  libjpeg-dev \
  libgif-dev \
  &amp;&amp; rm -rf /var/lib/apt/lists/*</code></pre>
<p>위 패키지를 설치한 후 다시 npm ci를 실행하면 canvas 모듈도 문제없이 설치된다.
이제 docker-compose up --build 명령으로도 빌드가 정상 완료되며, Next.js 앱이 정상적으로 구동된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] Cloudflare Workers-JOSEError]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-Cloudflare-Workers-JOSEError</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-Cloudflare-Workers-JOSEError</guid>
            <pubDate>Mon, 19 May 2025 06:38:31 GMT</pubDate>
            <description><![CDATA[<p>Cloudflare Workers에서 jose를 사용해 JWT를 검증하는 도중, JWKS 키셋을 불러오는 요청에서 200 OK가 오지 않아 JOSEError가 발생했다.</p>
<p>에러 메시지는 다음과 같다.</p>
<pre><code class="language-bash">&quot;error_message&quot;: &quot;Expected 200 OK from the JSON Web Key Set HTTP response&quot;</code></pre>
<hr>
<br>

<h3 id="✏️-오류-발생-코드">✏️ 오류 발생 코드</h3>
<p>Authentik에서 발급된 JWT를 <code>jose</code> 라이브러리로 검증하기 위해, 아래와 같이 <code>createRemoteJWKSet</code>으로 JWKS URL을 설정하였다.</p>
<pre><code class="language-ts">const AUTHENTIK_JWKS_URL = c.env.AUTHENTIK_JWKS_URL;
const JWKS = createRemoteJWKSet(new URL(AUTHENTIK_JWKS_URL));

const { payload } = await jwtVerify(token, JWKS, {
  issuer: c.env.AUTHENTIK_ISSUER,
});</code></pre>
<br>

<p>하지만...
.dev.vars에 분명 JWKS URL을 올바르게 입력했음에도, 여전히 JOSEError: Expected 200 OK... 에러가 발생했다.</p>
<br>

<h3 id="✏️-원인">✏️ 원인</h3>
<p>결론부터 말하자면, 환경변수 덮어쓰기 이슈였다.</p>
<p>로컬 개발 중 npx wrangler dev를 실행했을 때, 예상과는 다르게 c.env.AUTHENTIK_JWKS_URL 값이 .dev.vars의 로컬 값이 아닌, wrangler.toml의 [vars] 혹은 [env.production.vars]에 설정된 값으로 덮어쓰이고 있었던 것이다.</p>
<p>즉, 로컬 개발인데도 배포용 환경변수가 로드되어 localhost 대신 접근 불가능한 URL로 요청을 보내고 있었던 것이다.</p>
<br>

<h3 id="✏️-해결방법">✏️ 해결방법</h3>
<p>환경별 변수를 명확하게 분리하고, 로컬 개발 환경에서 올바른 환경변수가 로드되도록 설정한다.</p>
<p>wrangler dev 실행 시 반드시 --env 옵션을 명시한다.</p>
<pre><code>npx wrangler dev --env dev</code></pre><p>이후에는 문제없이 JWKS URL에 fetch 요청이 성공했고, jwtVerify도 정상적으로 동작하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Stripe] Stripe로 카드 정보 저장하기 W/O checkout Session
]]></title>
            <link>https://velog.io/@jaewon-ju/Stripe-Stripe%EB%A1%9C-%EC%B9%B4%EB%93%9C-%EC%A0%95%EB%B3%B4-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0-WO-checkout-Session</link>
            <guid>https://velog.io/@jaewon-ju/Stripe-Stripe%EB%A1%9C-%EC%B9%B4%EB%93%9C-%EC%A0%95%EB%B3%B4-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0-WO-checkout-Session</guid>
            <pubDate>Fri, 09 May 2025 08:53:51 GMT</pubDate>
            <description><![CDATA[<h2 id="목표">목표</h2>
<p>Stripe의 <span style = "color:red">Checkout Session을 사용하지 않고</span>, 사용자의 카드 정보를 웹사이트 내에서 직접 수집하고 저장하는 방법을 설명한다.
여기서 핵심은 <code>&quot;결제는 하지 않고 카드 정보만 미리 받아두는 것&quot;</code> 이다. 결제는 이후 필요할 때 따로 진행한다.</p>
<hr>
<br>

<h2 id="✏️-핵심-개념-정리">✏️ 핵심 개념 정리</h2>
<h3 id="1-setupintent">1. SetupIntent</h3>
<p><code>SetupIntent</code>는 Stripe에서 결제 수단을 미리 등록할 때 사용하는 객체다.
결제는 하지 않지만, 사용자 카드가 유효한지 확인하고 Stripe 서버에 안전하게 저장하는 역할을 한다.</p>
<p>즉, <code>SetupIntent</code>는 <strong>&quot;결제 수단을 등록할 준비가 되었다&quot;</strong>를 Stripe에 선언하는 것이다.</p>
<ul>
<li>따라서, <code>setupIntent</code> 생성만으로는 아무것도 등록되지 않는다.</li>
<li>반드시 <code>confirmCardSetup</code>을 호출해야 결제 수단이 등록된다.</li>
</ul>
<br>

<h3 id="2-paymentintent">2. PaymentIntent</h3>
<p><code>PaymentIntent</code>는 실제 결제를 실행할 때 사용하는 객체다.
<code>SetupIntent</code>로 등록해둔 payment_method를 사용해서 PaymentIntent를 만들어 결제할 수 있다.</p>
<br>

<h3 id="3-stripe-elements">3. Stripe Elements</h3>
<p>Stripe가 제공하는 클라이언트 측 카드 입력 <span style="background-color:green">UI 컴포넌트다</span>.
<code>CardElement</code>, <code>PaymentElement</code> 등을 통해 카드 번호, 만료일, CVC 등을 사용자로부터 입력받을 수 있다.</p>
<p>⚠️ 보안상 중요한 점은, 카드 정보가 브라우저에서 Stripe로 직접 전달되므로 서버가 민감 정보를 다루지 않아도 된다는 것이다.</p>
<br>

<hr>
<br>



<h2 id="✏️-코드">✏️ 코드</h2>
<p> 전체 흐름 요약</p>
<blockquote>
<ol>
<li>사용자가 웹사이트의 CardElement에 카드 정보를 입력한다.</li>
<li>제출 시, 백엔드 API(/api/create-setup-intent)를 호출해 SetupIntent를 생성하고 client_secret을 받아온다.</li>
<li>프론트엔드는 stripe.confirmCardSetup(client_secret)으로 카드 정보를 인증한다.</li>
<li>인증이 완료되면 Stripe 서버에 payment_method.id가 생성되고 저장된다.</li>
<li>이 ID를 DB에 저장해두고, 나중에 PaymentIntent를 통해 결제에 사용한다.</li>
</ol>
</blockquote>
<pre><code class="language-tsx">// stripe 초기화 설정
// Stripe 퍼블리시 키로 초기화
const stripePromise = loadStripe(&#39;pk_test_...&#39;);

// Elements 컴포넌트에 전달할 옵션 (mode: setup을 명시적으로 지정)
const options = {
  mode: &#39;setup&#39; as const, // 결제가 아닌 카드 등록 목적
  currency: &#39;usd&#39;,
};

// 최상위 App 컴포넌트에서 Stripe context 구성
export default function App({ children }: { children: React.ReactNode }) {
  return (
    &lt;Elements stripe={stripePromise} options={options}&gt;
      &lt;Provider store={store}&gt;
        &lt;I18nextProvider i18n={i18n}&gt;
          &lt;RecoilRoot&gt;
            &lt;MainWrapper&gt;{children}&lt;/MainWrapper&gt;
          &lt;/RecoilRoot&gt;
        &lt;/I18nextProvider&gt;
      &lt;/Provider&gt;
    &lt;/Elements&gt;
  );
}</code></pre>
<br>

<pre><code class="language-tsx">export default function SetupForm() {
  const stripe = useStripe();        // Stripe 객체
  const elements = useElements();    // Stripe Elements 객체

  const [errorMessage, setErrorMessage] = useState(); // 에러 메시지 상태
  const [loading, setLoading] = useState(false);      // 로딩 상태
  const { data: session } = useSession();             // 로그인 세션
  const token = session?.accessToken;                 // 인증 토큰

  const handleError = (error) =&gt; {
    setLoading(false);
    setErrorMessage(error.message); // 에러 메시지 설정
  };

  const handleSubmit = async (event) =&gt; {
    event.preventDefault();         // 폼 기본 동작 차단 (새로고침 방지)

    if (!stripe) return;            // Stripe.js가 아직 로드되지 않았다면 종료

    setLoading(true);               // 로딩 시작

    const { error: submitError } = await elements.submit(); // 카드 유효성 검사
    if (submitError) return handleError(submitError);

    // 백엔드 API 호출해서 SetupIntent 생성 → clientSecret 반환
    const clientSecret = (await createSetupIntent(undefined, undefined, token)).data;

    // Stripe에 카드 정보 등록 및 인증 요청
    const { error } = await stripe.confirmCardSetup(clientSecret, {
      payment_method: {
        card: elements.getElement(CardElement),
      },
    });

    if (error) {
      // 인증 실패 시 에러 처리
      handleError(error);
    } else {
      // 인증 성공 시 추가 처리 (예: 성공 메시지, DB 저장 등)
    }
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;CardElement
        options={{
          style: { base: { color: &#39;white&#39; } }, // 스타일 설정
        }}
      /&gt;
      &lt;button type=&quot;submit&quot; disabled={!stripe || loading}&gt;
        Submit
      &lt;/button&gt;
      {errorMessage &amp;&amp; &lt;div&gt;{errorMessage}&lt;/div&gt;}
    &lt;/form&gt;
  );
}</code></pre>
<br>

<pre><code class="language-tsx">export const createPaymentMethod = async (
  stripe: Stripe,
  sql: postgres.Sql&lt;{}&gt;,
  userId: string,
): Promise&lt;string&gt; =&gt; {
  try {
    // 사용자 정보 조회 (Stripe customer_id 필요)
    const user: usersRowType = await usersRepository.findById(sql, userId);

    // Stripe SetupIntent 생성 (카드 등록만 수행)
    const setupIntent = await stripe.setupIntents.create({
      customer: user.customer_id!,           // Stripe customer 연결
      automatic_payment_methods: { enabled: true }, // 여러 결제 수단 지원 가능 (카드만 사용해도 문제 없음)
    });

    // client_secret을 프론트에 반환 → 카드 인증 시 사용
    return setupIntent.client_secret!;
  } catch (error: any) {
    // 에러 로깅 및 예외 처리
    logger.error(&#39;Failed to create payment method&#39;, {
      error_message: error.message,
      error_stack: error.stack,
      context: &#39;stripe.payment-method.ts&#39;,
      timestamp: new Date().toISOString(),
    });
    throw new Error(error as string);
  }
}</code></pre>
<hr>
<p>아래는 동일한 내용을 영어로 정리한 것입니다.</p>
<h2 id="goal">Goal</h2>
<p>This guide explains how to collect and save a user&#39;s card information directly on your website <strong>without using Stripe Checkout Session</strong>.
The key point is that you&#39;re <strong>not charging the customer right away</strong>, just saving their payment method for later use.</p>
<hr>
<h2 id="✏️-core-concepts">✏️ Core Concepts</h2>
<h3 id="1-setupintent-1">1. SetupIntent</h3>
<p><code>SetupIntent</code> is a Stripe object used to register a payment method without charging the customer.
It ensures the card is valid and securely saves it to the Stripe server.</p>
<blockquote>
<p>Think of <code>SetupIntent</code> as telling Stripe:
&quot;I&#39;m preparing to register a payment method.&quot;</p>
</blockquote>
<ul>
<li>Creating a <code>SetupIntent</code> does <strong>not</strong> register anything by itself.</li>
<li>You must call <code>confirmCardSetup</code> to actually register the payment method.</li>
</ul>
<br>

<h3 id="2-paymentintent-1">2. PaymentIntent</h3>
<p><code>PaymentIntent</code> is used when you&#39;re ready to charge the customer.
You can use a <code>payment_method</code> saved via <code>SetupIntent</code> to create a <code>PaymentIntent</code> and process the actual payment.</p>
<br>

<h3 id="3-stripe-elements-1">3. Stripe Elements</h3>
<p>Stripe Elements are client-side <span style="background-color:green">UI components</span> for securely collecting payment information.
Examples include <code>CardElement</code>, <code>PaymentElement</code>, etc., which allow users to input their card number, expiry date, CVC, etc.</p>
<p>⚠️ The important security detail: card information is sent directly from the browser to Stripe — your server never touches sensitive data.</p>
<br>

<hr>
<br>

<h2 id="✏️-code">✏️ Code</h2>
<h3 id="overall-flow-summary">Overall Flow Summary</h3>
<blockquote>
<ol>
<li>User inputs card info via <code>CardElement</code> on the website.</li>
<li>On submit, frontend calls a backend API (<code>/api/create-setup-intent</code>) to create a <code>SetupIntent</code> and receive a <code>client_secret</code>.</li>
<li>The frontend calls <code>stripe.confirmCardSetup(client_secret)</code> to authenticate the card info.</li>
<li>On success, a <code>payment_method.id</code> is created and stored on the Stripe server.</li>
<li>Save this ID in your database and use it later with <code>PaymentIntent</code> to charge the user.</li>
</ol>
</blockquote>
<pre><code class="language-tsx">// Stripe initialization
const stripePromise = loadStripe(&#39;pk_test_...&#39;);

// Stripe Elements config: setup mode
const options = {
  mode: &#39;setup&#39; as const, // setup only, not payment
  currency: &#39;usd&#39;,
};

// Global app context setup
export default function App({ children }: { children: React.ReactNode }) {
  return (
    &lt;Elements stripe={stripePromise} options={options}&gt;
      &lt;Provider store={store}&gt;
        &lt;I18nextProvider i18n={i18n}&gt;
          &lt;RecoilRoot&gt;
            &lt;MainWrapper&gt;{children}&lt;/MainWrapper&gt;
          &lt;/RecoilRoot&gt;
        &lt;/I18nextProvider&gt;
      &lt;/Provider&gt;
    &lt;/Elements&gt;
  );
}</code></pre>
<br>

<pre><code class="language-tsx">export default function SetupForm() {
  const stripe = useStripe();
  const elements = useElements();

  const [errorMessage, setErrorMessage] = useState();
  const [loading, setLoading] = useState(false);
  const { data: session } = useSession();
  const token = session?.accessToken;

  const handleError = (error) =&gt; {
    setLoading(false);
    setErrorMessage(error.message);
  };

  const handleSubmit = async (event) =&gt; {
    event.preventDefault();
    if (!stripe) return;

    setLoading(true);
    const { error: submitError } = await elements.submit();
    if (submitError) return handleError(submitError);

    const clientSecret = (await createSetupIntent(undefined, undefined, token)).data;

    const { error } = await stripe.confirmCardSetup(clientSecret, {
      payment_method: {
        card: elements.getElement(CardElement),
      },
    });

    if (error) {
      handleError(error);
    } else {
      // success handling (e.g., store payment_method.id)
    }
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;CardElement
        options={{
          style: { base: { color: &#39;white&#39; } },
        }}
      /&gt;
      &lt;button type=&quot;submit&quot; disabled={!stripe || loading}&gt;
        Submit
      &lt;/button&gt;
      {errorMessage &amp;&amp; &lt;div&gt;{errorMessage}&lt;/div&gt;}
    &lt;/form&gt;
  );
}</code></pre>
<br>

<pre><code class="language-ts">export const createPaymentMethod = async (
  stripe: Stripe,
  sql: postgres.Sql&lt;{}&gt;,
  userId: string,
): Promise&lt;string&gt; =&gt; {
  try {
    // Lookup user to get customer_id
    const user: usersRowType = await usersRepository.findById(sql, userId);

    // Create SetupIntent linked to customer
    const setupIntent = await stripe.setupIntents.create({
      customer: user.customer_id!,
      automatic_payment_methods: { enabled: true },
    });

    // Return client_secret to frontend
    return setupIntent.client_secret!;
  } catch (error: any) {
    logger.error(&#39;Failed to create payment method&#39;, {
      error_message: error.message,
      error_stack: error.stack,
      context: &#39;stripe.payment-method.ts&#39;,
      timestamp: new Date().toISOString(),
    });
    throw new Error(error as string);
  }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TroubleShooting] next-auth getSession null error]]></title>
            <link>https://velog.io/@jaewon-ju/TroubleShooting-next-auth-getSession-null-error</link>
            <guid>https://velog.io/@jaewon-ju/TroubleShooting-next-auth-getSession-null-error</guid>
            <pubDate>Mon, 28 Apr 2025 05:04:25 GMT</pubDate>
            <description><![CDATA[<h2 id="오류-상황">오류 상황</h2>
<p>Next.js 13 App Router 환경 </p>
<blockquote>
<p><code>middleware.ts</code> 파일 안에서 <code>getServerSession(authOptions)</code>을 사용해 세션을 가져오려 했지만, session 값이 <code>null</code>인 문제가 발생했다. </p>
</blockquote>
<br>

<hr>
<br>

<h3 id="✏️-오류-발생-코드">✏️ 오류 발생 코드</h3>
<pre><code class="language-ts">import { getServerSession } from &quot;next-auth/next&quot;;
import { authOptions } from &quot;@/app/api/auth/[...nextauth]/route&quot;;

export async function middleware(request: NextRequest) {
  const session = await getServerSession(authOptions);
  console.log(session); // 항상 null
}</code></pre>
<br>

<hr>
<br>

<h3 id="✏️-원인">✏️ 원인</h3>
<p><a href="https://github.com/nextauthjs/next-auth/issues/4042">Github Issue</a></p>
<blockquote>
<p>NextAuth의 <code>getServerSession()</code>은 내부적으로 <code>req.headers.cookie</code>를 읽으려 한다.
그런데 Next.js 미들웨어의 request.headers는 일반적인 객체가 아니라 <span style="color:red"><code>Headers</code></span> 타입이다.
<code>Headers</code> 객체는 cookie라는 필드로 접근할 수 없고, <code>get(&quot;cookie&quot;)</code> 메서드를 통해서만 쿠키에 접근할 수 있다.</p>
</blockquote>
<p>따라서, 헤더에 담긴 쿠키를 수동으로 읽어와서, 임시 request 객체를 만들어 <code>getSession({ req: 임시객체 })</code>로 넘겨주면 정상적으로 세션을 복원할 수 있다.</p>
<br>

<hr>
<br>

<h3 id="✏️-해결-방법">✏️ 해결 방법</h3>
<p>NextRequest로부터 쿠키를 수동으로 추출해 <code>getSession()</code>에 넘겨줄 수 있는 request 객체를 만들어준다.</p>
<pre><code class="language-ts">import { getSession } from &quot;next-auth/react&quot;;

export async function middleware(request: NextRequest) {
  const requestForNextAuth = {
    headers: {
      cookie: request.headers.get(&quot;cookie&quot;),
    },
  };

  const session = await getSession({ req: requestForNextAuth });
  const token = session?.accessToken;

  console.log(session);
  console.log(token);
}</code></pre>
<ul>
<li>request.headers.get(&quot;cookie&quot;)를 통해 클라이언트로부터 전달된 쿠키를 가져온다.</li>
<li>가져온 쿠키를 포함하는 임시 req 객체를 만들어 getSession({ req })에 넘긴다.</li>
</ul>
<br>

<p>#next-auth getSession null
#next-auth getServerSession null
#next-auth app router getSession
#next-auth app router middleware
#next-auth session not found
#next-auth getSession cookie issue
#next-auth nextrequest getSession
#next-auth next.js 13 app router session
#next-auth middleware token 가져오기
#next-auth getSession edge runtime
#next-auth getSession nextrequest
#next-auth getServerSession request headers
#next-auth session 복구 오류
#next-auth middleware 인증 문제
#next-auth app router 인증 처리
#next-auth session token 직접 전달</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 원 페이지 스크롤 구현]]></title>
            <link>https://velog.io/@jaewon-ju/React-%EC%9B%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@jaewon-ju/React-%EC%9B%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 23 Apr 2025 08:01:27 GMT</pubDate>
            <description><![CDATA[<h1 id="원-페이지-스크롤-구현-가이드">원 페이지 스크롤 구현 가이드</h1>
<p>이 문서는 React와 styled-components를 활용해 &quot;한 화면씩 넘기는 원 페이지 스크롤&quot; 기능을 구현하는 방법을 설명합니다. 사용자는 마우스 휠을 통해 슬라이드를 하나씩 전환하며, 각 슬라이드는 전체 화면(viewport)을 채우는 구조로 구성됩니다.</p>
<hr>
<h2 id="1-시작-스크롤-이벤트-감지">1. 시작: 스크롤 이벤트 감지</h2>
<p>원 페이지 스크롤의 핵심은 사용자의 <strong>휠 스크롤 이벤트를 감지하는 것</strong>입니다. 
브라우저는 기본적으로 사용자가 마우스 휠을 움직일 때마다 <code>wheel</code> 이벤트를 발생시킵니다.</p>
<p>이 이벤트는 <code>deltaY</code>라는 값을 포함하고 있어, 사용자가 스크롤을 위로 움직였는지(음수) 아래로 움직였는지(양수)를 확인할 수 있습니다.</p>
<p>스크롤 이벤트를 감지하려면 다음을 설정합니다:</p>
<ul>
<li>특정 요소(보통 전체 페이지를 감싸는 div)에 이벤트 리스너를 추가</li>
<li><code>passive: false</code> 옵션을 주어 기본 동작(브라우저의 스크롤)을 막을 수 있음</li>
</ul>
<hr>
<h2 id="2-동작-현재-슬라이드-인덱스-업데이트">2. 동작: 현재 슬라이드 인덱스 업데이트</h2>
<p>휠 스크롤이 감지되면 다음 단계는 <strong>현재 사용자가 보고 있는 슬라이드 인덱스를 갱신하는 작업</strong>입니다.</p>
<p>이를 위해 내부적으로 유지되는 <code>currentPage</code> 상태값을 사용하여:</p>
<ul>
<li>아래로 스크롤 시: <code>currentPage</code>를 1 증가</li>
<li>위로 스크롤 시: <code>currentPage</code>를 1 감소</li>
<li>첫 페이지와 마지막 페이지에서는 더 이상 이동하지 않도록 제한</li>
</ul>
<p>이 로직은 사용자 스크롤에 따라 어떤 슬라이드를 보여줄지를 판단하는 핵심 기준이 됩니다.</p>
<hr>
<h2 id="3-결과-화면-이동-스크롤-위치-제어">3. 결과: 화면 이동 (스크롤 위치 제어)</h2>
<p>슬라이드 인덱스가 변경되면, 해당 인덱스에 맞는 화면 위치로 <strong>스크롤을 프로그래밍적으로 이동</strong>시킵니다.</p>
<p>이를 위해 <code>scrollTo</code> 또는 <code>scrollTop</code> 값을 직접 조작하여:</p>
<ul>
<li>예를 들어, <code>2번째 슬라이드 = 2 * 화면 높이 (100vh)</code>로 계산</li>
<li>부드러운 이동을 위해 <code>behavior: &#39;smooth&#39;</code> 옵션 사용</li>
</ul>
<p>이 과정을 통해 마우스 휠 한 번에 정확히 한 슬라이드씩 이동하는 느낌을 줄 수 있습니다.</p>
<hr>
<h2 id="4-부가-처리-스크롤-이벤트-최적화">4. 부가 처리: 스크롤 이벤트 최적화</h2>
<p>스크롤 이벤트는 매우 자주 발생할 수 있으므로, 다음과 같은 처리가 추가로 필요합니다:</p>
<ul>
<li><strong>스크롤 중복 방지</strong>: 스크롤 중에는 추가 입력을 막기 위해 잠깐 상태를 잠그는 방식 사용 (debounce 또는 flag 활용)</li>
<li><strong>페이지 범위 체크</strong>: 0보다 작거나, 슬라이드 총 개수를 초과하지 않도록 제한</li>
<li><strong>모바일 대응</strong>: 터치 이벤트 (<code>touchstart</code>, <code>touchend</code>)도 별도 감지 필요</li>
</ul>
<hr>
<h2 id="5-결론">5. 결론</h2>
<p>원 페이지 스크롤은 마우스 휠을 감지하고, 현재 페이지를 기준으로 다음 화면 위치를 계산해 스크롤하는 구조입니다. 이 과정은 <strong>스크롤 이벤트 감지 → 현재 위치 판단 → 위치 이동</strong>이라는 순서를 반복하면서 동작합니다.</p>
<p>구현은 간단해 보이지만, UX 관점에서 부드럽고 안정적인 전환을 위해 이벤트 제어와 스크롤 상태 관리가 중요합니다.</p>
<hr>
<h2 id="1-구조-개요">1. 구조 개요</h2>
<p>기본 구조는 다음과 같습니다:</p>
<ul>
<li><code>Base</code>: 전체 스크롤을 제어하는 컨테이너 (스크롤 대상)</li>
<li><code>SliderContainer</code>: 모든 슬라이드를 포함하는 요소 (높이 = 슬라이드 개수 x 100vh)</li>
<li>각 <code>Slide</code>: 개별 페이지 (높이 100vh)</li>
</ul>
<pre><code class="language-tsx">&lt;Base ref={containerRef}&gt;
  &lt;SliderContainer&gt;
    &lt;Slide /&gt;
    &lt;Slide /&gt;
    &lt;Slide /&gt;
  &lt;/SliderContainer&gt;
&lt;/Base&gt;</code></pre>
<hr>
<h2 id="2-슬라이드-이동-구현-방법">2. 슬라이드 이동 구현 방법</h2>
<h3 id="21-scrolltopage-함수">2.1 scrollToPage 함수</h3>
<p><code>window.innerHeight</code>를 기준으로 슬라이드 위치 계산:</p>
<pre><code class="language-tsx">const scrollToPage = (index: number) =&gt; {
  if (!containerRef.current) return;
  const targetScrollTop = index * window.innerHeight;
  containerRef.current.scrollTo({
    top: targetScrollTop,
    behavior: &quot;smooth&quot;,
  });
  setCurrentPage(index);
};</code></pre>
<h3 id="22-마우스-휠-이벤트-처리">2.2 마우스 휠 이벤트 처리</h3>
<p>사용자의 휠 스크롤을 감지해 현재 페이지 상태에 따라 <code>scrollToPage</code> 호출:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const handleWheel = (e: WheelEvent) =&gt; {
    e.preventDefault();
    if (e.deltaY &gt; 0 &amp;&amp; currentPage &lt; slides.length - 1) {
      scrollToPage(currentPage + 1);
    } else if (e.deltaY &lt; 0 &amp;&amp; currentPage &gt; 0) {
      scrollToPage(currentPage - 1);
    }
  };

  const container = containerRef.current;
  if (container) {
    container.addEventListener(&quot;wheel&quot;, handleWheel, { passive: false });
  }
  return () =&gt; {
    if (container) {
      container.removeEventListener(&quot;wheel&quot;, handleWheel);
    }
  };
}, [currentPage, slides.length]);</code></pre>
<hr>
<h2 id="3-스타일-구성">3. 스타일 구성</h2>
<pre><code class="language-tsx">const Base = styled.div`
  height: 100vh;
  overflow-y: auto;
  scroll-behavior: smooth;
`;

const SliderContainer = styled.div`
  height: calc(100vh * 슬라이드 개수);
`;

const Slide = styled.div`
  height: 100vh;
  width: 100vw;
  display: flex;
  align-items: center;
  justify-content: center;
`;</code></pre>
<p>슬라이드는 반드시 <code>height: 100vh</code>로 설정되어야 하며, 컨테이너는 슬라이드 개수만큼 높이를 갖도록 해야 스크롤이 제대로 작동합니다.</p>
<hr>
<h2 id="4-주의사항-및-개선-포인트">4. 주의사항 및 개선 포인트</h2>
<ul>
<li>IntersectionObserver를 활용하여 슬라이드 진입 시 애니메이션 트리거 가능</li>
<li>모바일 터치 이벤트 처리 필요시 <code>touchstart</code> / <code>touchend</code> 이벤트 별도 추가</li>
<li>슬라이드 개수가 많을 경우 <code>throttle</code> 또는 <code>debounce</code>를 통해 이벤트 최적화 필요</li>
</ul>
<hr>
<h2 id="5-결론-1">5. 결론</h2>
<p>React 환경에서 원 페이지 스크롤을 구현하기 위해서는 각 섹션을 정확히 <code>100vh</code>로 분할하고, 사용자 스크롤에 맞춰 정확한 위치로 이동시키는 제어가 핵심입니다. 이를 통해 보다 몰입감 있는 인터랙티브 웹페이지를 제작할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] JSX Attributes 정리]]></title>
            <link>https://velog.io/@jaewon-ju/React-JSX-Attributes-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jaewon-ju/React-JSX-Attributes-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 23 Apr 2025 06:21:55 GMT</pubDate>
            <description><![CDATA[<h2 id="react-jsx-속성-정리">React JSX 속성 정리</h2>
<p>React에서 자주 쓰이는 주요 속성들인 <code>ref</code>, <code>key</code>, <code>className</code>, <code>onClick</code>, <code>style</code>, <code>children</code>에 대해 정리한 문서이다.</p>
<hr>
<br>

<h2 id="1-ref">1. ref</h2>
<h3 id="◼︎-역할">◼︎ 역할</h3>
<ul>
<li>특정 <strong>DOM 요소에 직접 접근</strong>할 수 있게 해준다.</li>
<li><code>focus</code>, <code>scroll</code>, <code>style</code> 등을 <strong>직접 조작</strong>할 때 사용</li>
</ul>
<br>

<h3 id="◼︎-사용-예">◼︎ 사용 예</h3>
<pre><code class="language-tsx">import { useRef, useEffect } from &#39;react&#39;;

function FocusInput() {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null);

  useEffect(() =&gt; {
    inputRef.current?.focus();
  }, []);

  return &lt;input ref={inputRef} /&gt;;
}</code></pre>
<blockquote>
<p><code>ref</code>는 일반적인 props와는 달리, 실제 HTML 요소를 참조하는 <strong>특수한 prop</strong>입니다.</p>
</blockquote>
<hr>
<h2 id="2-key">2. <code>key</code></h2>
<h3 id="◼︎-역할-1">◼︎ 역할</h3>
<ul>
<li>React가 리스트를 렌더링할 때, <strong>각 항목을 고유하게 식별</strong>할 수 있게 해줍니다.</li>
<li>어떤 요소가 변경, 추가, 삭제되었는지를 빠르게 파악할 수 있게 하여 <strong>성능 향상</strong>에 중요합니다.</li>
</ul>
<br>

<h3 id="◼︎-어떻게-작동하나요">◼︎ 어떻게 작동하나요?</h3>
<p>React는 리스트 요소들을 다시 그릴 때, <code>key</code>를 기준으로 &quot;이전 요소와 같은지&quot; 판단합니다. <code>key</code>가 같으면 <strong>재사용</strong>, 다르면 <strong>다시 렌더링</strong>합니다.</p>
<br>

<h3 id="◼︎-사용-예-1">◼︎ 사용 예</h3>
<pre><code class="language-tsx">const fruits = [
  { id: &#39;a1&#39;, name: &#39;🍎 사과&#39; },
  { id: &#39;b2&#39;, name: &#39;🍌 바나나&#39; },
  { id: &#39;c3&#39;, name: &#39;🍇 포도&#39; },
];

return (
  &lt;ul&gt;
    {fruits.map((fruit) =&gt; (
      &lt;li key={fruit.id}&gt;{fruit.name}&lt;/li&gt;
    ))}
  &lt;/ul&gt;
);</code></pre>
<blockquote>
<p>❗ 단순히 <code>index</code>를 key로 사용하는 것은 항목의 순서가 바뀌는 경우 <strong>예기치 않은 렌더링 문제가 생길 수 있으므로 주의</strong>해야 합니다.</p>
</blockquote>
<hr>
<h2 id="3-classname">3. <code>className</code></h2>
<h3 id="◼︎-역할-2">◼︎ 역할</h3>
<ul>
<li>HTML의 <code>class</code> 대신 <strong>React에서는 <code>className</code>을 사용</strong>해야 합니다. (JS 예약어 <code>class</code>와 충돌 방지)</li>
</ul>
<br>

<h3 id="◼︎-사용-예-2">◼︎ 사용 예</h3>
<pre><code class="language-tsx">&lt;div className=&quot;card-container&quot;&gt;Hello&lt;/div&gt;</code></pre>
<hr>
<h2 id="4-onclick-onchange-onsubmit-등">4. <code>onClick</code>, <code>onChange</code>, <code>onSubmit</code> 등</h2>
<h3 id="◼︎-역할-3">◼︎ 역할</h3>
<ul>
<li><strong>이벤트 처리 함수</strong>를 등록하는 속성입니다.</li>
<li>반드시 camelCase로 작성해야 합니다 (<code>onclick</code> ❌ → <code>onClick</code> ✅)</li>
</ul>
<br>

<h3 id="◼︎-사용-예-3">◼︎ 사용 예</h3>
<pre><code class="language-tsx">&lt;button onClick={() =&gt; alert(&quot;눌렀어요!&quot;)}&gt;Click Me&lt;/button&gt;
&lt;input onChange={(e) =&gt; console.log(e.target.value)} /&gt;</code></pre>
<hr>
<h2 id="5-style">5. <code>style</code></h2>
<h3 id="◼︎-역할-4">◼︎ 역할</h3>
<ul>
<li>인라인 스타일을 적용할 때 사용합니다.</li>
<li>객체 형태로 작성하며, CSS 속성은 <code>camelCase</code>로 표기합니다.</li>
</ul>
<br>

<h3 id="◼︎-사용-예-4">◼︎ 사용 예</h3>
<pre><code class="language-tsx">&lt;div style={{ backgroundColor: &#39;skyblue&#39;, padding: &#39;20px&#39; }}&gt;
  스타일이 적용된 박스
&lt;/div&gt;</code></pre>
<hr>
<h2 id="6-children">6. <code>children</code></h2>
<h3 id="◼︎-역할-5">◼︎ 역할</h3>
<ul>
<li>컴포넌트 태그 안에 <strong>들어가는 모든 요소</strong>를 의미합니다.</li>
<li>부모 컴포넌트가 자식 콘텐츠를 유연하게 구성할 수 있게 해줍니다.</li>
</ul>
<br>

<h3 id="◼︎-사용-예-5">◼︎ 사용 예</h3>
<pre><code class="language-tsx">function Card({ children }: { children: React.ReactNode }) {
  return &lt;div className=&quot;card&quot;&gt;{children}&lt;/div&gt;;
}

&lt;Card&gt;
  &lt;h2&gt;제목&lt;/h2&gt;
  &lt;p&gt;내용입니다&lt;/p&gt;
&lt;/Card&gt;</code></pre>
<br>

<hr>
<br>

<h2 id="props-vs-attributes-차이점">props vs attributes 차이점</h2>
<h3 id="1-attributes">1. attributes</h3>
<h3 id="정의">정의:</h3>
<p>HTML 태그에서 사용하는 전통적인 속성. 브라우저가 직접 해석해서 동작합니다.</p>
<pre><code class="language-html">&lt;!-- HTML에서의 attributes --&gt;
&lt;input type=&quot;text&quot; placeholder=&quot;이름을 입력하세요&quot; /&gt;</code></pre>
<ul>
<li><code>type</code>, <code>placeholder</code>, <code>disabled</code>, <code>checked</code>, <code>value</code> 등은 HTML이 해석하는 속성입니다.</li>
</ul>
<hr>
<h3 id="2-props-react-속성">2. props (React 속성)</h3>
<h3 id="정의-1">정의:</h3>
<p>컴포넌트에 데이터를 전달하는 수단입니다. JavaScript 객체의 형태로 전달되며, <strong>JS 내부에서 자유롭게 사용</strong>할 수 있습니다.</p>
<pre><code class="language-tsx">function Welcome({ name }: { name: string }) {
  return &lt;h1&gt;Hello, {name}&lt;/h1&gt;;
}

&lt;Welcome name=&quot;민수&quot; /&gt;</code></pre>
<ul>
<li><code>name</code>은 <code>props</code>이며, 함수의 인자로 전달되어 <strong>컴포넌트 내부 로직</strong>에 활용됩니다.</li>
</ul>
<hr>
<h2 id="주요-차이점-비교">주요 차이점 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>attributes</th>
<th>props</th>
</tr>
</thead>
<tbody><tr>
<td>정의 위치</td>
<td>HTML</td>
<td>React 컴포넌트 내부</td>
</tr>
<tr>
<td>사용 목적</td>
<td>HTML 요소 제어</td>
<td>컴포넌트 데이터 전달</td>
</tr>
<tr>
<td>처리 주체</td>
<td>브라우저</td>
<td>React 렌더링 엔진</td>
</tr>
<tr>
<td>데이터 흐름</td>
<td>단방향</td>
<td>단방향 (상위 → 하위)</td>
</tr>
<tr>
<td>커스터마이징 가능 여부</td>
<td>제한적</td>
<td>완전 자유</td>
</tr>
</tbody></table>
<br>

<h2 id="-prefix로-props와-html-attributes-구분하기">$ prefix로 props와 HTML attributes 구분하기</h2>
<h3 id="◼︎-문제-상황">◼︎ 문제 상황</h3>
<p>React에서는 props가 실제 DOM 요소에도 전달될 수 있는데,
HTML 태그가 인식하지 못하는 props가 그대로 전달되면 경고가 발생합니다.</p>
<pre><code class="language-tsx">const Box = styled.div&lt;{ highlight: boolean }&gt;`
  background-color: ${(props) =&gt; (props.highlight ? &quot;yellow&quot; : &quot;white&quot;)};
`;

&lt;Box highlight={true}&gt;강조된 박스&lt;/Box&gt;</code></pre>
<p>⚠️ 경고: React does not recognize the &#39;highlight&#39; prop on a DOM element</p>
<p>highlight는 HTML의 표준 속성이 아니므로, <div highlight="true">처럼 DOM에 남게 되면 React가 경고를 띄웁니다.</p>
<h3 id="◼︎-해결-방법--prefix-사용">◼︎ 해결 방법: $ prefix 사용</h3>
<p>styled-components에서 $로 시작하는 prop은
DOM으로 전달되지 않고 스타일 계산에만 사용됩니다.</p>
<pre><code>const Box = styled.div&lt;{ $highlight: boolean }&gt;`
  background-color: ${(props) =&gt; (props.$highlight ? &quot;yellow&quot; : &quot;white&quot;)};
`;

&lt;Box $highlight={true}&gt;강조된 박스&lt;/Box&gt;</code></pre><p>✅ 결과:</p>
<p>$highlight는 오직 styled-components 내부에서만 사용됨
실제 HTML에는 전달되지 않음 → 경고 없음, DOM 깔끔하게 유지</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] Redux]]></title>
            <link>https://velog.io/@jaewon-ju/React-Redux</link>
            <guid>https://velog.io/@jaewon-ju/React-Redux</guid>
            <pubDate>Wed, 16 Apr 2025 02:09:48 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="redux">Redux</h4>
<p>: <strong>애플리케이션의 상태(state)를 예측 가능하게 관리</strong>하기 위한 자바스크립트 상태 관리 라이브러리이다.<br>주로 React와 함께 사용되며, 전역 상태를 중앙에서 관리함으로써 복잡한 컴포넌트 간의 데이터 전달 문제를 해결한다.</p>
</blockquote>
<br>

<hr>
<br>

<h2 id="✏️-왜-사용하는가">✏️ 왜 사용하는가?</h2>
<h3 id="1-중앙-집중형-상태-관리">1. 중앙 집중형 상태 관리</h3>
<p>React는 컴포넌트 단위로 상태(state)를 관리한다. 
상위 컴포넌트의 상태를 하위 컴포넌트까지 전달하려면 계속 props를 넘겨야 한다.</p>
<p>얘를 들어, App 컴포넌트의 <code>user</code> state를  하위 하위 컴포넌트인 Child에서 사용하려면, <code>user</code>를 props로 계속 전달해야한다.</p>
<pre><code class="language-tsx">// 최상위 App 컴포넌트
function App() {
  const [user, setUser] = useState({ name: &#39;Jin&#39; });
  return &lt;Parent user={user} /&gt;;
}

// App의 하위 컴포넌트 Parent
function Parent({ user }) {
  return &lt;Child user={user} /&gt;;
}

// Parent의 하위 컴포넌트 Child
function Child({ user }) {
  return &lt;p&gt;Hello, {user.name}&lt;/p&gt;;
}</code></pre>
<br>

<blockquote>
<h4 id="redux를-사용한다면">Redux를 사용한다면?</h4>
</blockquote>
<p>Redux는 state를 전역에서 관리하는 기능을 제공한다.
따라서, <code>user</code> state를 하나의 파일에서 관리하고 어떤 컴포넌트든 직접 접근해서 사용할 수 있게 만들어준다.</p>
<br>

<h3 id="2-state-변경-추적">2. State 변경 추적</h3>
<p>React에서 state는 컴포넌트 내부에서 변경되며, 상태 변경이 많아질수록 언제 어떤 이유로 상태가 바뀌었는지 파악하기 어려워진다.</p>
<blockquote>
<h4 id="redux를-사용한다면-1">Redux를 사용한다면?</h4>
</blockquote>
<p>Redux는 모든 상태 변경이 reducer를 거쳐 이루어지기 때문에, 상태 변경의 흐름이 명시적이고 예측 가능하다.</p>
<br>

<hr>
<br>

<h2 id="✏️-redux의-주요-요소">✏️ Redux의 주요 요소</h2>
<p>Redux는 세 가지 핵심 개념을 중심으로 State를 관리한다:</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Store</strong></td>
<td>애플리케이션의 모든 State를 하나로 모아놓은 전역 저장소</td>
</tr>
<tr>
<td><strong>Action</strong></td>
<td>State를 어떻게 바꿀지를 설명하는 객체 (type, payload)</td>
</tr>
<tr>
<td><strong>Reducer</strong></td>
<td>Action에 따라 State를 실제로 바꾸는 함수들의 모음</td>
</tr>
</tbody></table>
<br>


<p>코드를 통해 주요 요소들을 알아보자.</p>
<h3 id="◼︎-store-action-reducer">◼︎ Store, Action, Reducer</h3>
<h4 id="1-store">1. Store</h4>
<p><code>store/index.ts</code> 에 다음과 같이 저장소를 정의한다.</p>
<pre><code class="language-ts">// store/index.ts
import { configureStore } from &#39;@reduxjs/toolkit&#39;;
import authReducer from &#39;./slices/authSlice&#39;;

export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
});</code></pre>
<ul>
<li><code>store</code>는 모든 State가 모여 있는 중앙 저장소이다.</li>
<li><code>authReducer</code>는 사용자 관련 State를 관리하는 reducer이며, 이 저장소에 auth라는 이름으로 등록된다.</li>
</ul>
<br>

<h4 id="2-action">2. Action</h4>
<p>Action은 “State를 어떻게 바꿀 것인지”를 설명하는 객체다.
기본적으로 type 속성이 필수이며, payload를 통해 전달할 데이터를 담을 수 있다.</p>
<pre><code class="language-ts">{
  type: &#39;SET_USER&#39;,
  payload: { name: &#39;Jin&#39; }
}</code></pre>
<br>

<h4 id="3-reducer">3. Reducer</h4>
<p>Reducer는 현재 State와 Action을 받아서 → 새로운 State를 만드는 함수들의 모음이다.</p>
<pre><code class="language-ts">// store/authSlice.ts
const authSlice = createSlice({
  name: &#39;auth&#39;,
  initialState: { user: null },

  // 여기에 함수들을 정의한다.
  reducers: {

    // 이 함수는 dispatch(setUser(payload))가 실행됐을 때 동작한다.
    // user state의 값에 payload의 값을 대입한다.
    setUser(state, action) {
      state.user = action.payload;
    },

    // 이 함수는 dispatch(updateCurrentTeam(payload))가 실행되었을 때 동작한다.
    updateCurrentTeam: (state, action) =&gt; {
      state.user.currentTeam = action.payload;
    },
    ...

  },
});</code></pre>
<br>

<h3 id="◼︎-구현-방법">◼︎ 구현 방법</h3>
<p>예를 들어, <code>user</code> state에 <code>JIM</code> 이라는 값을 대입하는 과정을 생각해보자.
State 중앙 관리소에 <code>user</code> State를 등록해두고, <code>user</code> State를 변경하는 함수를 reducer에 저장한다.
그리고, <code>user</code>에 값을 대입하고자 하는 컴포넌트에서 Action을 통해 State를 변경한다.</p>
<br>

<h4 id="1-authslicets--상태-정의-및-reducer-작성">1. authSlice.ts – 상태 정의 및 reducer 작성</h4>
<pre><code class="language-ts">// store/slices/authSlice.ts
import { createSlice } from &#39;@reduxjs/toolkit&#39;;

const authSlice = createSlice({
  name: &#39;auth&#39;,
  initialState: {
    user: null,
  },
  reducers: {
    // user 상태를 바꾸는 동기 Action
    setUser(state, action) {
      state.user = action.payload;
    },
  },
});

export const { setUser } = authSlice.actions;
export default authSlice.reducer;</code></pre>
<br>

<h4 id="2-storeindexts--store-생성">2. store/index.ts – Store 생성</h4>
<pre><code class="language-ts">import { configureStore } from &#39;@reduxjs/toolkit&#39;;
import authReducer from &#39;./slices/authSlice&#39;;

export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
});

export type RootState = ReturnType&lt;typeof store.getState&gt;;
export type AppDispatch = typeof store.dispatch;</code></pre>
<br>

<h4 id="3-컴포넌트에서-호출">3. 컴포넌트에서 호출</h4>
<pre><code class="language-ts">import { useAppDispatch, useAppSelector } from &#39;@/store/hooks&#39;;
import { setUser } from &#39;@/store/slices/authSlice&#39;;

function Profile() {
  const dispatch = useDispatch();
  const user = useAppSelector((state) =&gt; state.auth.user);

  return (
    &lt;div&gt;
      &lt;p&gt;User: {user}&lt;/p&gt;
      &lt;button onClick={() =&gt; dispatch(setUser(&#39;JIM&#39;))}&gt;Set User&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<ul>
<li>상태를 조회할 때는 useAppSelector() 훅을 사용한다.</li>
<li>상태를 변경할 때는 useDispatch() 훅을 사용한다.</li>
</ul>
<p>아래에서 Redux Hook을 더 자세히 알아보자.</p>
<br>

<hr>
<br>

<h2 id="✏️-redux-hook">✏️ Redux Hook</h2>
<p>Redux에서 컴포넌트와 Store를 연결할 때는 두 가지 핵심 훅을 사용한다:</p>
<h3 id="1-useappselector">1. useAppSelector</h3>
<p>전역 State에서 필요한 값을 가져올 때 사용하는 훅이다.</p>
<pre><code class="language-ts">const user = useAppSelector((state) =&gt; state.auth.user);</code></pre>
<br>

<h3 id="2-useappdispatch">2. useAppDispatch</h3>
<p>reducer에 Action을 전달(=dispatch)하여 State를 변경하고 싶을 때 사용하는 훅이다.</p>
<pre><code class="language-ts">const dispatch = useDispatch();
dispatch(setUser(&#39;JIM&#39;));</code></pre>
<br>

<hr>
<br>

<h2 id="✏️-비동기-액션">✏️ 비동기 액션</h2>
<p>Redux의 Action에는 동기 액션과 비동기 액션이 있다.</p>
<ul>
<li>동기 액션은 createSlice의 reducers 안에 정의하며, 실행되자마자 즉시 State를 변경한다.</li>
<li>반면, 비동기 액션은 네트워크 요청처럼 결과가 바로 나오지 않는 작업을 처리할 때 사용된다.</li>
</ul>
<br>

<h3 id="◼︎-비동기-액션이란">◼︎ 비동기 액션이란?</h3>
<p>비동기 액션은 예를 들어 다음과 같은 작업들을 처리할 때 사용된다:</p>
<ul>
<li>API 호출</li>
<li>일정 시간 후 실행되는 타이머</li>
<li>파일 업로드/다운로드 등</li>
</ul>
<p>이처럼 결과를 기다려야 하는 작업은 Redux 안에서 바로 처리할 수 없기 때문에, 비동기 액션을 사용해야 한다.
비동기 액션은 <code>createAsyncThunk()</code>를 통해 정의할 수 있다.</p>
<pre><code class="language-ts">// 예시: Action 내부에서 백엔드 API 호출 함수인 getUSer()를 사용하는 경우
// 비동기 함수를 정의한다.
export const setUser = createAsyncThunk(
  &#39;auth/setUserStatus&#39;,
  async (currentUser, { rejectWithValue }) =&gt; {
    const result = await getUser(...);
    return result.data.user;
  }
);</code></pre>
<br>


<blockquote>
<h4 id="createasyncthunk를-사용해-만든-비동기-액션은-세-가지-단계의-state를-자동으로-생성한다">createAsyncThunk를 사용해 만든 비동기 액션은 세 가지 단계의 State를 자동으로 생성한다</h4>
</blockquote>
<table>
<thead>
<tr>
<th>상태</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>pending</code></td>
<td>비동기 작업이 시작될 때 자동 실행됨 (로딩 중 상태 표시 등에 사용)</td>
</tr>
<tr>
<td><code>fulfilled</code></td>
<td>비동기 작업이 성공적으로 끝났을 때 실행됨 (데이터 저장, UI 갱신)</td>
</tr>
<tr>
<td><code>rejected</code></td>
<td>비동기 작업이 실패했을 때 실행됨 (에러 처리, 에러 메시지 표시)</td>
</tr>
</tbody></table>
<p>이 세 가지 상태를 통해, 로딩 중인지, 성공했는지, 실패했는지를 컴포넌트에서 명확하게 구분할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Auth] Authentik 비밀번호 복구 기능, email 환경 설정]]></title>
            <link>https://velog.io/@jaewon-ju/Auth-Authentik-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EB%B3%B5%EA%B5%AC-%EA%B8%B0%EB%8A%A5-email-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@jaewon-ju/Auth-Authentik-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EB%B3%B5%EA%B5%AC-%EA%B8%B0%EB%8A%A5-email-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 15 Apr 2025 05:48:19 GMT</pubDate>
            <description><![CDATA[<h2 id="✏️-authentik-password-recovery-설정-가이드">✏️ Authentik Password Recovery 설정 가이드</h2>
<p>이 문서는 <strong>Authentik에서 비밀번호 재설정</strong> 기능을 구성하는 과정을 설명한다. 
비밀번호 재설정을 구현하기 위해 필요한 요소는 다음과 같다:</p>
<br>

<ul>
<li><strong>메일 발송 환경 구성</strong>: 비밀번호 재설정 링크를 사용자에게 이메일로 전송하기 위한 SMTP 설정</li>
<li><strong>Password Policy</strong>: 비밀번호에 대한 최소 요구 조건 (문자 길이, 특수문자 포함 등)</li>
<li><strong>Identification Stage</strong>: 사용자 식별 단계 (이메일 또는 사용자명 입력)</li>
<li><strong>Email Stage</strong>: 비밀번호 재설정 링크를 이메일로 발송하는 단계</li>
<li><strong>Recovery Flow</strong>: 위 스테이지들을 순차적으로 실행하기 위한 흐름 구성</li>
<li><strong>Password Prompt 및 Write Stage</strong>: 사용자가 새 비밀번호를 입력하고 적용하는 단계</li>
<li><strong>Attach To Authentication Flow</strong>: Authentication Flow에 비밀번호 복구 기능 추가</li>
</ul>
<br>

<hr>
<blockquote>
<h3 id="1-메일-전송-설정-env-파일">1. 메일 전송 설정 (.env 파일)</h3>
<p>SendGrid를 사용하는 경우 <code>.env</code> 파일에 아래 항목 추가:</p>
</blockquote>
<pre><code class="language-env">AUTHENTIK_EMAIL__HOST=smtp.sendgrid.net
AUTHENTIK_EMAIL__PORT=587
AUTHENTIK_EMAIL__USERNAME=apikey
AUTHENTIK_EMAIL__PASSWORD=SG.패스워드내용
AUTHENTIK_EMAIL__USE_TLS=true
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__FROM=이메일주소</code></pre>
<hr>
<blockquote>
<h3 id="2-password-policy-생성">2. Password Policy 생성</h3>
</blockquote>
<ol>
<li>왼쪽 메뉴에서 <strong>Policies</strong> 클릭</li>
<li><strong>Create</strong> 버튼 클릭</li>
<li><strong>Type</strong>: <code>Password Policy</code> 선택</li>
<li>특수문자 포함, 길이 제한 등 조건 설정</li>
</ol>
<hr>
<blockquote>
<h3 id="3-email-identification-stage-생성">3. Email Identification Stage 생성</h3>
</blockquote>
<ol>
<li>왼쪽 메뉴에서 <strong>Stages</strong> 클릭</li>
<li><strong>Create</strong> 버튼 클릭</li>
<li><strong>Type</strong>: <code>Identification Stage</code> 선택</li>
<li>User Fields: <code>username</code>, <code>email</code> 지정</li>
</ol>
<hr>
<blockquote>
<h3 id="4-email-recovery-stage-생성">4. Email Recovery Stage 생성</h3>
</blockquote>
<ol>
<li><strong>Stages</strong> 메뉴에서 <strong>Create</strong> 클릭</li>
<li><strong>Type</strong>: <code>Email Stage</code> 선택</li>
<li>Template: <code>Password Reset</code> 선택</li>
</ol>
<hr>
<blockquote>
<h3 id="5-recovery-flow-생성">5. Recovery Flow 생성</h3>
</blockquote>
<ol>
<li><strong>Flows</strong> 메뉴에서 <strong>Create</strong> 클릭</li>
<li>이름: 원하는 이름 지정 (예: <code>password-recovery-flow</code>)</li>
<li>Designation: <code>Recovery</code> 선택</li>
</ol>
<hr>
<blockquote>
<h3 id="6-flow에-스테이지-바인딩">6. Flow에 스테이지 바인딩</h3>
</blockquote>
<ol>
<li>생성한 <code>password-recovery-flow</code> 클릭</li>
<li><strong>Stage Binding</strong> 클릭 → <strong>기존 스테이지 바인딩</strong> 클릭<ul>
<li>Stage: <code>email identification stage</code></li>
<li>Order: <code>10</code></li>
</ul>
</li>
<li>다시 <strong>기존 스테이지 바인딩</strong> 클릭<ul>
<li>Stage: <code>email recovery stage</code></li>
<li>Order: <code>20</code></li>
</ul>
</li>
<li>다시 <strong>기존 스테이지 바인딩</strong> 클릭<ul>
<li>Stage: <code>default-password-change-prompt</code></li>
<li>Order: <code>30</code></li>
</ul>
</li>
<li>다시 <strong>기존 스테이지 바인딩</strong> 클릭<ul>
<li>Stage: <code>default-password-change-write</code></li>
<li>Order: <code>40</code></li>
</ul>
</li>
</ol>
<hr>
<blockquote>
<h3 id="7-password-prompt-stage-설정">7. Password Prompt Stage 설정</h3>
</blockquote>
<ol>
<li><code>Stages</code> 메뉴에서 <code>default-password-change-prompt</code> 편집</li>
<li>Validation 항목에 이전에 생성한 <code>Password Policy</code> 지정</li>
</ol>
<hr>
<blockquote>
<h3 id="8-인증-플로우에-복구-기능-추가">8. 인증 플로우에 복구 기능 추가</h3>
</blockquote>
<ol>
<li><code>Flows</code> 메뉴에서 <code>default-authentication-flow</code> 클릭</li>
<li>스테이지 바인딩 - <code>default-authetication-identification</code> 편집 버튼 클릭</li>
<li>플로우 설정에서 복구 플로우: recovery(방금 생성한 복구 플로우) 지정</li>
</ol>
<hr>
<p>이로써 사용자는 이메일을 통해 비밀번호 재설정 링크를 받고, 정책에 맞춘 새 비밀번호로 변경할 수 있게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Auth] Authentik에 회원가입, Passkey 붙이기]]></title>
            <link>https://velog.io/@jaewon-ju/Auth-Authentik%EC%97%90-Passkey-%EB%B6%99%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jaewon-ju/Auth-Authentik%EC%97%90-Passkey-%EB%B6%99%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Mon, 14 Apr 2025 05:07:39 GMT</pubDate>
            <description><![CDATA[<h2 id="✏️-authentik에-회원가입-붙이는-방법">✏️ Authentik에 회원가입 붙이는 방법</h2>
<p>YouTube 영상 <a href="https://www.youtube.com/watch?v=mGOTpRfulfQ">&quot;Authentik - Enrollment&quot;</a>의 내용을 바탕으로 Authentik에 Passkey를 적용하는 절차를 정리하였다.</p>
<br>

<blockquote>
<h3 id="1-디렉토리---그룹-생성">1. 디렉토리 - 그룹 생성</h3>
</blockquote>
<ol>
<li>왼쪽 메뉴에서 <strong>디렉토리 &gt; 그룹</strong>으로 이동</li>
<li><strong>생성</strong> 버튼 클릭</li>
<li><strong>이름</strong>만 입력하고 생성</li>
</ol>
<hr>
<blockquote>
<h3 id="2-플로우-생성">2. 플로우 생성</h3>
</blockquote>
<ol>
<li>왼쪽 메뉴에서 <strong>플로우</strong> 클릭</li>
<li><strong>생성</strong> 버튼 클릭</li>
<li>아래와 같이 입력<ul>
<li>이름: 원하는 이름 (예: <code>main-page-enrollment</code>)</li>
<li>Title: <code>main-page-enrollment</code></li>
<li>Slug: <code>main-page-enrollment</code></li>
<li>Designation: <code>Enrollment</code></li>
<li>호환성 모드: 체크</li>
</ul>
</li>
</ol>
<hr>
<blockquote>
<h3 id="3-스테이지-바인딩-설정">3. 스테이지 바인딩 설정</h3>
</blockquote>
<ol>
<li>생성한 <code>main-page-enrollment</code> 플로우 클릭</li>
<li><strong>스테이지 바인딩</strong> 클릭 → <strong>기존 스테이지 바인드</strong> 클릭<ul>
<li>스테이지: <code>default-source-enrollment-prompt</code></li>
<li>Order: <code>10</code></li>
</ul>
</li>
<li>방금 바인딩한 스테이지 옆의 편집 아이콘 클릭<ul>
<li>필드에 <code>username</code>, <code>name</code>, <code>password</code>, <code>password_repeat</code> 추가</li>
</ul>
</li>
<li>다시 <strong>기존 스테이지 바인드</strong> 클릭<ul>
<li>스테이지: <code>default-source-enrollment-write</code></li>
<li>Order: <code>20</code></li>
</ul>
</li>
<li>방금 바인딩한 스테이지 편집<ul>
<li><strong>Group</strong> 항목에 앞서 생성한 그룹 선택 및 추가</li>
</ul>
</li>
</ol>
<hr>
<blockquote>
<h3 id="4-기본-인증-플로우에-회원가입-플로우-연결">4. 기본 인증 플로우에 회원가입 플로우 연결</h3>
</blockquote>
<ol>
<li>플로우 메뉴에서 default-authentication-flow 클릭</li>
<li>스테이지 바인딩 클릭</li>
<li>default-authentication-identification 스테이지 편집<ul>
<li>플로우 설정 &gt; Enrollment Flow 항목에 main-page-enrollment 선택 후 저장</li>
</ul>
</li>
</ol>
<br>

<hr>
<br>

<h2 id="✏️-authentik에-passkey-붙이는-방법">✏️ Authentik에 Passkey 붙이는 방법</h2>
<p>YouTube 영상 <a href="https://www.youtube.com/watch?v=aEpT2fYGwLw">&quot;How to enable passwordless login with Passkeys in authentik&quot;</a>의 내용을 바탕으로 Authentik에 Passkey를 적용하는 절차를 정리하였다.</p>
<blockquote>
<h3 id="1-관리자-계정으로-로그인">1. 관리자 계정으로 로그인</h3>
</blockquote>
<ul>
<li>Authentik 관리자 계정으로 로그인합니다.</li>
</ul>
<hr>
<blockquote>
<h3 id="2-인증-flow-생성">2. 인증 Flow 생성</h3>
</blockquote>
<ol>
<li>왼쪽 메뉴에서 <code>Flows</code> 선택</li>
<li>오른쪽 상단의 <strong>[생성]</strong> 버튼 클릭</li>
<li>아래와 같이 설정<ul>
<li><strong>Name</strong>: 원하는 이름 (예: <code>wedeo-passwordless</code>)</li>
<li><strong>Designation</strong>: <code>Authentication</code></li>
<li><strong>Title</strong>: 원하는 제목</li>
<li><strong>Slug</strong>: 자동 생성됨</li>
</ul>
</li>
</ol>
<hr>
<blockquote>
<h3 id="3-스테이지-바인딩-설정-1">3. 스테이지 바인딩 설정</h3>
</blockquote>
<ol>
<li>방금 생성한 Flow를 클릭</li>
<li><strong>[스테이지 바인딩]</strong> 클릭 → 바인드 스테이지 생성<ul>
<li><strong>Name</strong>: <code>wedeo Passkey Validation</code></li>
<li><strong>Stage type</strong>: <code>Authenticator Validation Stage</code></li>
<li><strong>Device class</strong>: <code>WebAuthn 인증기</code></li>
<li><strong>Configuration</strong>: <code>default-webauthn-setup</code></li>
<li><strong>Order</strong>: <code>10</code></li>
</ul>
</li>
</ol>
<hr>
<blockquote>
<h3 id="4-기존-로그인-스테이지-추가-바인딩">4. 기존 로그인 스테이지 추가 바인딩</h3>
</blockquote>
<ul>
<li>같은 Flow에서 다시 <strong>[기존 스테이지 바인딩]</strong> 클릭<ul>
<li><strong>Stage</strong>: <code>default-authentication-login</code></li>
<li><strong>Order</strong>: <code>20</code> (WebAuthn 이후 실행되도록 설정)</li>
</ul>
</li>
</ul>
<hr>
<blockquote>
<h3 id="5-identification-스테이지-연결-변경">5. Identification 스테이지 연결 변경</h3>
</blockquote>
<ol>
<li>좌측 메뉴에서 <code>Stages</code>로 이동</li>
<li><code>default-authentication-identification</code> 스테이지 편집</li>
<li>하단의 <strong>Passwordless Flow 설정</strong>을 다음과 같이 수정:<ul>
<li><strong>Passwordless Flow</strong>: <code>wedeo-passwordless</code> (앞에서 만든 Flow 선택)</li>
</ul>
</li>
</ol>
<br>


<p>이제 사용자는 WebAuthn 기반 Passkey 인증을 통해 비밀번호 없이 로그인할 수 있다.</p>
<blockquote>
<p>단, 이 기능은 사용자의 브라우저 또는 디바이스에 Passkey가 존재할 경우에만 작동한다다.</p>
</blockquote>
<br>

<hr>
<br>

<h2 id="✏️-passkey-등록-방법-2가지">✏️ Passkey 등록 방법 (2가지)</h2>
<h3 id="1-authentik-ui에서-직접-등록">1. Authentik UI에서 직접 등록</h3>
<p>사용자가 <a href="https://auth.example.com/if/user-settings/">https://auth.example.com/if/user-settings/</a> 페이지에서 로그인</p>
<p>MFA 디바이스 &gt; Passkey 등록 메뉴 진입
디바이스 기반 생체 인증 (지문/Face ID 등)을 통해 Passkey 생성</p>
<br>

<h3 id="2-우리-서비스의-프론트엔드에서-등록-유도">2. 우리 서비스의 프론트엔드에서 등록 유도</h3>
<p>사용자가 Generate Passkey 버튼을 클릭하면 Authentik의 Passkey 등록 Flow로 리디렉션</p>
<pre><code>window.location.href = http://localhost:9000/if/flow/default-authenticator-webauthn-setup;</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 지식] Inngest]]></title>
            <link>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Inngest</link>
            <guid>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Inngest</guid>
            <pubDate>Wed, 02 Apr 2025 07:39:45 GMT</pubDate>
            <description><![CDATA[<h4 id="cloudflare-workers--r2-환경에서-inngest-기반-워크플로우-구축기">Cloudflare Workers + R2 환경에서 Inngest 기반 워크플로우 구축기</h4>
<p>이번 글에서는 Firebase Functions 환경에서 사용하던 Storage Trigger 기반 워크플로우를 <code>Cloudflare Workers + R2 + Inngest</code> 조합으로 마이그레이션한 과정을 공유한다. </p>
<p>특히 <strong>Inngest의 이벤트 기반 워크플로우 시스템</strong>을 활용해, Cloudflare R2에 파일이 업로드되었을 때 후처리 작업을 자동으로 실행하는 구조를 구현하였다.</p>
<br>

<hr>
<h2 id="✏️-inngest란">✏️ Inngest란?</h2>
<blockquote>
<p><a href="https://www.inngest.com">Inngest</a>
: 이벤트 기반 서버리스 워크플로우 플랫폼</p>
</blockquote>
<p>간단히 설명하자면, 트리거가 발생했을때 특정 로직을 실행시켜주는 도구이다.</p>
<p>주요 기능은 아래와 같다.</p>
<ul>
<li>트리거 기반 비동기 함수 실행</li>
<li>이벤트 발송 → 워크플로우 자동 실행</li>
<li>재시도, 스텝 단위 실행, 의존성 관리 등 내장</li>
<li>Dev 서버 또는 셀프 호스팅 가능</li>
</ul>
<br>


<h3 id="◼︎-구현한-워크플로우-시나리오">◼︎ 구현한 워크플로우 시나리오</h3>
<ol>
<li>사용자가 파일 업로드를 요청</li>
<li>Cloudflare Worker가 R2에 파일을 저장</li>
<li>업로드 완료 후 Inngest 서버에 <code>file.uploaded</code> 이벤트 전송</li>
<li>프론트의 <code>/api/inngest</code>에 정의된 워크플로우가 실행됨</li>
<li>워크플로우 내부에서 백엔드의 후처리 API(<code>/storage/post-upload</code>) 호출</li>
<li>썸네일 생성, 메타데이터 추출 등 후처리 수행</li>
</ol>
<br>

<hr>
<h3 id="◼︎-프론트엔드-구조-nextjs-app-router">◼︎ 프론트엔드 구조 (Next.js App Router)</h3>
<h4 id="appapiinngestclientts"><code>/app/api/inngest/client.ts</code></h4>
<pre><code class="language-ts">import { Inngest } from &quot;inngest&quot;;

export const inngest = new Inngest({
  id: &quot;wedeo-inngest&quot;,
  baseUrl: process.env.INNGEST_BASE_URL,
  eventKey: process.env.INNGEST_EVENT_KEY,
});</code></pre>
<h4 id="appapiinngestroutets"><code>/app/api/inngest/route.ts</code></h4>
<pre><code class="language-ts">import { serve } from &quot;inngest/next&quot;;
import { inngest } from &quot;./client&quot;;
import { onFileInBucketCreate } from &quot;./workflow&quot;;

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [onFileInBucketCreate],
});</code></pre>
<h4 id="appapiinngestworkflowts"><code>/app/api/inngest/workflow.ts</code></h4>
<pre><code class="language-ts">import { inngest } from &quot;./client&quot;;

export const onFileInBucketCreate = inngest.createFunction(
  { id: &quot;on-file-in-bucket-create&quot; },
  { event: &quot;file.uploaded&quot; },
  async ({ event, step }) =&gt; {
    const { userId, fileId } = event.data;

    await step.run(&quot;Call worker&#39;s post-upload handler&quot;, async () =&gt; {
      const res = await fetch(&quot;http://localhost:8787/storage/post-upload&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ userId, fileId }),
      });

      if (!res.ok) {
        throw new Error(`Worker 처리 실패: ${await res.text()}`);
      }
    });

    return { status: &quot;ok&quot; };
  }
);</code></pre>
<br>

<hr>
<h3 id="◼︎-백엔드-구조-cloudflare-worker">◼︎ 백엔드 구조 (Cloudflare Worker)</h3>
<h4 id="uploadfiletostorage"><code>uploadFileToStorage</code></h4>
<pre><code class="language-ts">export const uploadFileToStorage = async (
  bucket: R2Bucket,
  inngestBaseUrl: string,
  inngestEventKey: string,
  filePath: string,
  userId: string,
  fileId: string,
  file: Buffer,
) =&gt; {
  await bucket.put(filePath, file);

  const response = await sendInngestEvent(inngestBaseUrl, inngestEventKey, &quot;file.uploaded&quot;, {
    userId,
    fileId,
  });

  return filePath;
};</code></pre>
<h4 id="sendinngestevent"><code>sendInngestEvent</code></h4>
<pre><code class="language-ts">export const sendInngestEvent = async (
  inngestBaseUrl: string,
  inngestEventKey: string,
  name: string,
  data: any
) =&gt; {
  return await fetch(`${inngestBaseUrl}/e/${inngestEventKey}`, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify({ name, data }),
  });
};</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS 지식] Redis]]></title>
            <link>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Redis</link>
            <guid>https://velog.io/@jaewon-ju/CS-%EC%A7%80%EC%8B%9D-Redis</guid>
            <pubDate>Tue, 01 Apr 2025 06:01:00 GMT</pubDate>
            <description><![CDATA[<h2 id="✏️-redis">✏️ Redis</h2>
<blockquote>
<p>Redis(REmote DIctionary Server)
: 인메모리 기반의 Key-Value 저장소로, 빠른 데이터 처리와 다양한 자료구조 지원을 특징으로 하는 <strong>비관계형 데이터베이스(NoSQL)</strong>이다. </p>
</blockquote>
<br>

<h3 id="◼︎-redis의-주요-특징">◼︎ Redis의 주요 특징</h3>
<ul>
<li><p>In-memory 저장소
→ 모든 데이터를 메모리에 저장하여 디스크 기반 DB보다 빠른 성능 제공</p>
</li>
<li><p>Key-Value 구조
→ 문자열뿐 아니라 리스트, 해시, 셋, 정렬된 셋 등 다양한 자료구조 지원</p>
</li>
<li><p>Persistence(지속성) 지원
→ RDB 스냅샷, AOF(Append Only File) 방식으로 디스크 저장 가능</p>
</li>
<li><p>Pub/Sub 시스템
→ 메시지 브로커처럼 채널 기반 메시징 기능 제공</p>
</li>
<li><p>Atomic 연산
→ Redis 명령어는 기본적으로 원자적으로 실행되어 데이터 정합성 보장</p>
</li>
</ul>
<br>

<h3 id="◼︎-redis는-어디에-쓰이나">◼︎ Redis는 어디에 쓰이나</h3>
<ol>
<li><p>웹 애플리케이션 세션 저장소</p>
</li>
<li><p>캐시 서버</p>
</li>
<li><p>Queue</p>
</li>
<li><p>Pub/Sub 이벤트 브로커</p>
</li>
</ol>
<br>

<hr>
<br>

<h2 id="✏️-redis-queue">✏️ Redis Queue</h2>
<blockquote>
<p>Redis Queue는 Redis의 List 자료 구조를 이용하여 구현한 작업 큐 시스템이다.</p>
</blockquote>
<br>

<h3 id="◼︎-구성요소">◼︎ 구성요소</h3>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Producer (생산자)</strong></td>
<td>작업을 생성해서 큐에 넣는 쪽. 보통 웹 서버나 API 요청 처리기</td>
</tr>
<tr>
<td><strong>Queue (큐 자체)</strong></td>
<td>Redis의 <code>List</code>, <code>Stream</code>, <code>Sorted Set</code> 등을 사용하여 작업을 저장</td>
</tr>
<tr>
<td><strong>Consumer (소비자)</strong></td>
<td>큐에서 작업을 꺼내 처리하는 백엔드 프로세스 (worker)</td>
</tr>
<tr>
<td><strong>Redis 서버</strong></td>
<td>중간 저장소로, 작업을 안전하게 저장하고 관리함</td>
</tr>
<tr>
<td><strong>Job</strong></td>
<td>큐에 들어가는 단위 작업 데이터. JSON 객체 등으로 표현됨</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/jaewon-ju/post/0590fe93-b524-4911-b722-4c9e325cb572/image.png" alt=""></p>
<br>

<h3 id="◼︎-구현-wjs">◼︎ 구현 W/JS</h3>
<p><a href="https://github.com/OptimalBits/bull"><code>bull</code></a>은 Redis를 기반으로 한 <strong>작업 큐(queue)</strong> 처리 라이브러리이다.</p>
<h4 id="1-설치">1. 설치</h4>
<pre><code class="language-bash">npm install bull</code></pre>
<h4 id="2-기본-예제">2. 기본 예제</h4>
<pre><code class="language-ts">// queue.js
import Queue from &#39;bull&#39;

// Redis에 연결된 큐 생성
const myQueue = new Queue(&#39;my-job-queue&#39;, {
  redis: {
    host: &#39;localhost&#39;,
    port: 6379,
  },
})

// 작업 추가
myQueue.add({
  type: &#39;sendEmail&#39;,
  email: &#39;user@example.com&#39;,
})</code></pre>
<br>

<h4 id="3-작업-처리-consumer">3. 작업 처리 (Consumer)</h4>
<pre><code class="language-ts">// worker.js
import Queue from &#39;bull&#39;

const myQueue = new Queue(&#39;my-job-queue&#39;, {
  redis: {
    host: &#39;localhost&#39;,
    port: 6379,
  },
})

myQueue.process(async (job) =&gt; {
  const { type, email } = job.data
  if (type === &#39;sendEmail&#39;) {
    // 이메일 전송 로직
    console.log(`Sending email to ${email}`)
  }
})</code></pre>
]]></description>
        </item>
    </channel>
</rss>