<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>caffeine_coder.log</title>
        <link>https://velog.io/</link>
        <description>꾸준히 기록 중 입니다.</description>
        <lastBuildDate>Wed, 12 Aug 2026 05:11:38 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>caffeine_coder.log</title>
            <url>https://velog.velcdn.com/images/caffeine_coder/profile/315c3ab8-a96e-4a40-b8d0-beb037d3e378/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. caffeine_coder.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/caffeine_coder" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Codex] 서브에이전트를 실제 개발에 어떻게 써야 할까]]></title>
            <link>https://velog.io/@caffeine_coder/codex-subagents-real-development-workflow</link>
            <guid>https://velog.io/@caffeine_coder/codex-subagents-real-development-workflow</guid>
            <pubDate>Wed, 12 Aug 2026 05:11:38 GMT</pubDate>
            <description><![CDATA[<p>최근 Codex로 개발하면서 작업 방식을 계속 바꿔보고 있습니다.</p>
<p>처음에는 한 채팅에서</p>
<p><code>구현 → 테스트 → 자체 리뷰 → 수정 → 다시 테스트 → PR</code></p>
<p>까지 전부 처리하게 했습니다.</p>
<p>그런데 작업이 길어질수록 문제가 생겼습니다.</p>
<p>코드 탐색 내용, 테스트 로그, 오류 로그, 수정 내역이 계속 쌓이면서 <strong>채팅 컨텍스트가 너무 무거워졌습니다.</strong></p>
<p>특히 몇 시간씩 이어지는 작업에서는 초반보다 Codex가 둔해지는 느낌도 있었습니다.</p>
<p>그래서 최근에는 작업 단계가 길어지면 아예 새 채팅을 만들어 필요한 현재 상태만 다시 읽게 하는 방식을 사용하고 있었습니다.</p>
<p>그러던 중 계속 눈에 들어오던 기능이 있었습니다.</p>
<p>바로 <strong>Subagent, 서브에이전트</strong>입니다.</p>
<p>처음에는 저도 개념이 조금 애매했습니다.</p>
<blockquote>
<p>에이전트를 여러 개 띄우는 건 알겠는데<br>그래서 실제 개발에서는 어디에 쓰는 거지?</p>
</blockquote>
<p>이번에 실제 개발 사례까지 찾아보면서 이 개념이 꽤 명확해졌습니다.</p>
<p>결론부터 이야기하면,</p>
<blockquote>
<p><strong>서브에이전트는 여러 AI에게 무작정 코드를 동시에 작성시키는 기능이 아니었습니다.</strong></p>
<p><strong>복잡한 개발 작업을 역할별로 분리하고, 메인 에이전트는 판단과 조율에 집중하게 만드는 방식에 더 가깝습니다.</strong></p>
</blockquote>
<hr>
<h2 id="1-codex-서브에이전트란">1. Codex 서브에이전트란?</h2>
<p>일반적으로 Codex에게 작업 하나를 요청하면 하나의 에이전트가 모든 일을 수행합니다.</p>
<pre><code class="language-text">Main Agent

코드 탐색
  ↓
설계
  ↓
구현
  ↓
테스트
  ↓
로그 분석
  ↓
리뷰
  ↓
수정</code></pre>
<p>작은 작업이라면 이 방식이 가장 단순하고 효율적입니다.</p>
<p>문제는 작업 규모가 커졌을 때입니다.</p>
<p>예를 들어 인증 기능 하나를 수정한다고 해보겠습니다.</p>
<p>메인 에이전트가 확인해야 할 것이 많습니다.</p>
<pre><code class="language-text">Controller
Service
DB
Migration
Security
Session
Frontend
기존 Test
Regression</code></pre>
<p>여기에 테스트까지 돌리면 수백 줄, 수천 줄의 로그가 컨텍스트에 들어옵니다.</p>
<p>서브에이전트를 사용하면 이를 나눌 수 있습니다.</p>
<pre><code class="language-text">                   Main Agent
                Orchestrator
                      │
       ┌──────────────┼──────────────┐
       ↓              ↓              ↓
   Explorer        Reviewer      Test Analyzer
   코드 탐색       위험 분석       테스트 분석
       │              │              │
       └──────────────┼──────────────┘
                      ↓
                 결과만 요약
                      ↓
                  Main Agent</code></pre>
<p>각 서브에이전트는 독립된 Agent Thread에서 작업하고, 필요한 결과만 메인 에이전트에게 전달합니다.</p>
<p>OpenAI 공식 문서에서는 이렇게 메인 대화에 탐색 기록, 테스트 로그, stack trace 같은 중간 결과가 계속 쌓이는 문제를 <strong>Context Pollution</strong>, 시간이 갈수록 관련성이 낮은 정보가 늘어 성능이 떨어지는 현상을 <strong>Context Rot</strong>이라고 설명합니다.</p>
<p>제가 기존 Codex 작업에서 느꼈던 문제가 정확히 이 부분이었습니다.</p>
<hr>
<h2 id="2-그렇다면-실제-개발에서는-어떻게-사용할까">2. 그렇다면 실제 개발에서는 어떻게 사용할까?</h2>
<p>여기서부터가 가장 궁금했습니다.</p>
<p>실제로 사용되는 사례를 찾아보니 몇 가지 패턴이 반복해서 나타났습니다.</p>
<hr>
<h2 id="3-실제-사례-1---여러-관점으로-pr을-동시에-리뷰한다">3. 실제 사례 1 - 여러 관점으로 PR을 동시에 리뷰한다</h2>
<p>가장 이해하기 쉬운 활용법입니다.</p>
<p>PR 하나를 리뷰한다고 해보겠습니다.</p>
<p>기존에는 Reviewer 하나에게 이렇게 요청할 수 있습니다.</p>
<pre><code class="language-text">이 PR을 리뷰해.
버그, 보안 문제, 테스트 누락까지 모두 찾아줘.</code></pre>
<p>서브에이전트를 사용하면 역할을 분리할 수 있습니다.</p>
<pre><code class="language-text">                    Main Agent
                        │
        ┌───────────────┼───────────────┐
        ↓               ↓               ↓
 Security Reviewer   Test Reviewer   Code Reviewer
        │               │               │
        └───────────────┼───────────────┘
                        ↓
                   결과 통합</code></pre>
<p>프롬프트도 어렵지 않습니다.</p>
<pre><code class="language-text">현재 브랜치를 서브에이전트를 이용해 리뷰해.

Agent 1
보안 취약점과 인증/권한 문제를 검토한다.

Agent 2
누락된 테스트와 회귀 가능성을 검토한다.

Agent 3
코드 correctness와 유지보수성을 검토한다.

세 에이전트는 코드를 수정하지 않는다.

모두 완료될 때까지 기다린 뒤,
발견 사항을 심각도순으로 통합해서 보고한다.</code></pre>
<p>OpenAI 공식 문서에서도 거의 같은 형태로 <strong>Security / Test Gap / Maintainability를 각각 별도의 Agent에게 맡기는 PR 리뷰 예제</strong>를 제공하고 있습니다.</p>
<p>이 방식의 핵심은 단순한 병렬 처리보다 <strong>독립적인 시각</strong>에 있습니다.</p>
<p>한 Agent에게</p>
<blockquote>
<p>구현도 네가 하고 리뷰도 네가 해.</p>
</blockquote>
<p>라고 하는 것과,</p>
<blockquote>
<p>A가 구현하고<br>B는 구현 과정을 모른 채 결과만 리뷰해.</p>
</blockquote>
<p>라고 하는 것은 꽤 다릅니다.</p>
<p>후자가 처음 구현한 Agent의 판단에 덜 끌려갈 가능성이 있습니다.</p>
<hr>
<h2 id="4-실제-사례-2---openai도-agent가-agent를-리뷰한다">4. 실제 사례 2 - OpenAI도 Agent가 Agent를 리뷰한다</h2>
<p>흥미로운 사례가 OpenAI 내부 개발 방식입니다.</p>
<p>OpenAI가 공개한 Harness Engineering 사례를 보면 PR을 완료시키는 과정에서 Codex에게 자체 변경사항을 검토하게 한 뒤, <strong>추가적인 전문 Agent Review를 요청하고 피드백을 다시 반영하는 반복 구조</strong>를 사용한다고 설명합니다.</p>
<p>대략 이런 흐름입니다.</p>
<pre><code class="language-text">Codex 구현
   ↓
자체 리뷰
   ↓
추가 Agent Review
   ↓
문제 발견
   ↓
수정
   ↓
다시 Agent Review
   ↓
PASS</code></pre>
<p>즉,</p>
<pre><code class="language-text">Worker
   ↓
Reviewer
   ↓
Worker
   ↓
Reviewer</code></pre>
<p>구조입니다.</p>
<p>특히 OpenAI는 시간이 지나면서 상당한 리뷰 작업을 <strong>Agent-to-Agent 방식으로 이동시켰다</strong>고 설명합니다.</p>
<p>이 사례를 보고 서브에이전트의 용도를 좀 더 명확하게 이해할 수 있었습니다.</p>
<p><strong>사람 한 명을 AI 한 명으로 대체하는 것이 아니라, 개발팀 안에서 역할을 나누듯 Agent 역할도 나누는 것입니다.</strong></p>
<hr>
<h2 id="5-실제-사례-3---설계부터-구현-테스트-리뷰까지-역할을-나눈다">5. 실제 사례 3 - 설계부터 구현, 테스트, 리뷰까지 역할을 나눈다</h2>
<p>GitHub의 <code>codex-workflows</code> 프로젝트는 이 구조를 조금 더 적극적으로 사용합니다.</p>
<p>메인 Codex가 전체 흐름을 관리하고, 필요할 때 전문 Agent를 호출합니다.</p>
<p>예를 들어 다음과 같은 역할이 존재합니다.</p>
<pre><code class="language-text">requirement-analyzer
        ↓
codebase-analyzer
        ↓
technical-designer
        ↓
work-planner
        ↓
task-executor
        ↓
reviewer</code></pre>
<p>단순히</p>
<pre><code class="language-text">Agent A
Agent B
Agent C</code></pre>
<p>가 아니라 실제 개발팀처럼 역할을 구분한 것입니다.</p>
<p>이 프로젝트에서 공개한 실제 workflow 사례도 재미있었습니다.</p>
<p>BytePlus Seedream provider를 추가하는 작업에서</p>
<ul>
<li>18개 파일 변경</li>
<li>8개의 계획된 작업</li>
<li>외부 API 연동</li>
<li>Live Evaluation</li>
<li>최종 303개 테스트</li>
</ul>
<p>를 수행했습니다.</p>
<p>그리고 독립된 리뷰 과정에서</p>
<ul>
<li>제한 없는 파일 읽기</li>
<li>Validation 우회</li>
<li>Blocking FIFO 경로</li>
<li>API Key 정규화 불일치</li>
</ul>
<p>등의 문제가 발견되어 수정됐습니다.</p>
<p>여기서 제가 특히 눈여겨본 부분은 <strong>Agent 간 Context 전달 방법</strong>이었습니다.</p>
<p>긴 대화를 그대로 다음 Agent에게 넘기지 않습니다.</p>
<p>대신,</p>
<pre><code class="language-text">Requirement
Design Doc
Work Plan
Task File
Git Diff</code></pre>
<p>같은 저장소의 결과물을 읽게 합니다.</p>
<pre><code class="language-text">Agent A의 긴 대화 전체
        ↓
Agent B</code></pre>
<p>가 아니라</p>
<pre><code class="language-text">Agent A
   ↓
Design / Task Artifact
   ↓
Agent B</code></pre>
<p>형태입니다.</p>
<p>이 방식은 제가 기존에 사용하던 <strong>&quot;새 Codex 채팅을 만들어 현재 상태만 다시 읽게 하는 방법&quot;</strong>과도 비슷한 철학이라고 느꼈습니다.</p>
<hr>
<h2 id="6-실제-사례-4---장애-원인-분석을-서로-다른-agent가-검증한다">6. 실제 사례 4 - 장애 원인 분석을 서로 다른 Agent가 검증한다</h2>
<p>서브에이전트가 특히 잘 맞는 분야가 장애 분석입니다.</p>
<p>예를 들어 장애가 발생했을 때 하나의 Agent에게 이렇게 요청할 수도 있습니다.</p>
<pre><code class="language-text">장애 원인을 찾아서 해결해줘.</code></pre>
<p>문제는 Agent가 초반에 잘못된 가설을 잡으면 계속 그 가설을 기준으로 분석할 가능성이 있다는 것입니다.</p>
<p>그래서 역할을 이렇게 나눌 수 있습니다.</p>
<pre><code class="language-text">                 Main
                  │
        ┌─────────┼─────────┐
        ↓         ↓         ↓
 Investigator  Verifier   Solver</code></pre>
<h3 id="investigator">Investigator</h3>
<pre><code class="language-text">로그 분석
호출 경로 추적
문제 발생 지점 조사</code></pre>
<h3 id="verifier">Verifier</h3>
<pre><code class="language-text">Investigator의 결론을 독립적으로 검증
반대 가설 탐색
다른 Failure Path 확인</code></pre>
<h3 id="solver">Solver</h3>
<pre><code class="language-text">검증된 사실만 기준으로
해결 방법과 Trade-off 제시</code></pre>
<p>이 패턴의 장점은 명확합니다.</p>
<pre><code class="language-text">A : 원인이 무엇인지 찾아라.

B : A의 결론이 틀렸다고 가정하고 검증해라.

C : 검증된 사실만 가지고 해결책을 만들어라.</code></pre>
<p>이렇게 역할을 분리하면 하나의 Agent가 자신의 결론을 스스로 검증할 때 발생할 수 있는 편향을 줄일 수 있습니다.</p>
<hr>
<h2 id="7-실제-사례-5---병렬-구현은-git-worktree와-함께-사용한다">7. 실제 사례 5 - 병렬 구현은 Git Worktree와 함께 사용한다</h2>
<p>여기서 한 단계 더 나가면 <strong>코드 구현 자체도 병렬화</strong>할 수 있습니다.</p>
<pre><code class="language-text">                   Coordinator
                        │
       ┌────────────────┼────────────────┐
       ↓                ↓                ↓
   Worktree A        Worktree B       Worktree C
       │                │                │
   Worker A          Worker B         Worker C</code></pre>
<p>각 Agent에게 별도의 Git Worktree와 Branch를 주는 방식입니다.</p>
<p>OpenAI 내부 개발환경 역시 <strong>변경사항마다 독립적인 Git Worktree에서 애플리케이션을 실행할 수 있도록 구성</strong>했고, Codex가 각 변경 환경에서 앱 실행, 버그 재현, 로그 및 metric 확인 등을 할 수 있도록 만들었습니다.</p>
<p>실제 Codex GitHub Issue에서도 한 사용자가</p>
<pre><code class="language-text">Coordinator
    ↓
Task별 Worktree 생성
    ↓
각 Worker Agent 배정
    ↓
독립 구현
    ↓
Coordinator가 결과 검토 및 통합</code></pre>
<p>방식으로 Codex를 사용한다고 설명했습니다.</p>
<p>다만 재미있는 문제가 하나 있습니다.</p>
<p>병렬 Worker가 자신의 Worktree가 아닌 Coordinator의 Worktree에서 실수로 파일을 찾거나 수정하는 문제가 발생할 수 있다는 것입니다.</p>
<p>실제로 해당 사용자는 그래서 <code>spawn_agent</code> 단계에서 Worker의 작업 디렉터리를 강제로 지정할 수 있는 기능을 요청했습니다.</p>
<p>이 사례가 <strong>병렬 구현을 무조건 사용하면 안 되는 이유</strong>도 잘 보여줍니다.</p>
<hr>
<h2 id="8-서브에이전트의-장점">8. 서브에이전트의 장점</h2>
<p>실제 사례를 보고 나니 장점도 훨씬 명확해졌습니다.</p>
<h3 id="1-메인-context를-깨끗하게-유지할-수-있다">1) 메인 Context를 깨끗하게 유지할 수 있다</h3>
<p>개인적으로 가장 큰 장점이라고 생각합니다.</p>
<p>예를 들어 테스트 Agent가 이런 로그를 분석했다고 해보겠습니다.</p>
<pre><code class="language-text">10,000 lines test logs
        ↓
Test Agent
        ↓
&quot;3 tests failed.
원인은 A와 B이며 영향 범위는 C입니다.&quot;
        ↓
Main Agent</code></pre>
<p>메인 Agent에게 필요한 것은 대부분 <strong>10,000줄의 로그가 아니라 결론</strong>입니다.</p>
<p>OpenAI도 서브에이전트 활용의 주요 이유로 이런 Context Pollution과 Context Rot 감소를 설명합니다.</p>
<hr>
<h3 id="2-서로-독립적인-작업을-동시에-처리할-수-있다">2) 서로 독립적인 작업을 동시에 처리할 수 있다</h3>
<p>예를 들어:</p>
<pre><code class="language-text">DB 영향 분석      ─┐
Security Review  ─┼─ 동시에 실행
Test 분석        ─┤
Frontend 영향    ─┘</code></pre>
<p>이 네 작업 사이에 의존성이 없다면 순차 실행할 이유가 없습니다.</p>
<p>OpenAI 역시 Codebase Exploration, Test, Triage, Summarization 같은 <strong>read-heavy 작업을 병렬 Agent 활용의 좋은 시작점</strong>으로 권장합니다.</p>
<hr>
<h3 id="3-리뷰-관점을-분리할-수-있다">3) 리뷰 관점을 분리할 수 있다</h3>
<pre><code class="language-text">Security Reviewer
Test Reviewer
Regression Reviewer
Architecture Reviewer</code></pre>
<p>각 Agent에게 보는 관점을 제한할 수 있습니다.</p>
<p>하나의 Agent에게</p>
<pre><code class="language-text">전체적으로 잘 봐줘.</code></pre>
<p>라고 하는 것보다 책임 범위가 명확합니다.</p>
<hr>
<h3 id="4-긴-작업을-작은-문제로-나눌-수-있다">4) 긴 작업을 작은 문제로 나눌 수 있다</h3>
<p>복잡한 작업 하나를 통째로 해결하려 하지 않고,</p>
<pre><code class="language-text">큰 Issue
   ↓
분석
설계
DB
Backend
Frontend
Test
Review</code></pre>
<p>처럼 bounded task로 나눌 수 있습니다.</p>
<p>결과적으로 메인 Agent는 모든 세부사항을 직접 처리하는 Worker라기보다 <strong>Orchestrator에 가까워집니다.</strong></p>
<hr>
<h2 id="9-서브에이전트의-단점">9. 서브에이전트의 단점</h2>
<p>물론 무조건 좋은 것은 아닙니다.</p>
<h3 id="1-토큰을-절약하는-기능은-아니다">1) 토큰을 절약하는 기능은 아니다</h3>
<p>이 부분은 처음 생각했던 것과 반대였습니다.</p>
<p>각 서브에이전트가 별도의 모델 작업과 Tool 작업을 수행하기 때문에 <strong>동일한 작업을 단일 Agent가 처리하는 것보다 더 많은 Token을 사용할 수 있습니다.</strong></p>
<p>즉,</p>
<blockquote>
<p>비용을 아끼기 위해 Agent를 나눈다.</p>
</blockquote>
<p>가 아니라,</p>
<blockquote>
<p><strong>AI 연산을 더 사용해서 사람의 시간과 메인 Context를 아낀다.</strong></p>
</blockquote>
<p>에 가깝습니다.</p>
<hr>
<h3 id="2-작업을-잘못-나누면-오히려-비효율적이다">2) 작업을 잘못 나누면 오히려 비효율적이다</h3>
<p>예를 들어 이런 작업은 굳이 Agent 세 명이 필요하지 않습니다.</p>
<pre><code class="language-text">버튼 이름 하나 변경</code></pre>
<p>그런데</p>
<pre><code class="language-text">Agent 1 : 영향도 분석
Agent 2 : 구현
Agent 3 : 테스트
Agent 4 : 리뷰</code></pre>
<p>까지 만든다면 배보다 배꼽이 더 커집니다.</p>
<p>잘 정의된 작은 수정이나 일회성 작업에서는 그냥 단일 Codex가 더 빠르고 저렴할 수 있습니다.</p>
<hr>
<h3 id="3-여러-agent가-동시에-코드를-수정하면-충돌할-수-있다">3) 여러 Agent가 동시에 코드를 수정하면 충돌할 수 있다</h3>
<p>이것도 중요한 부분입니다.</p>
<pre><code class="language-text">Worker A ─┐
Worker B ─┼─ 같은 파일 수정
Worker C ─┘</code></pre>
<p>이렇게 되면 코드 충돌뿐 아니라 Agent 간 조율 비용도 커집니다.</p>
<p>그래서 OpenAI 공식 가이드도 처음에는</p>
<pre><code class="language-text">Exploration
Test
Triage
Summarization</code></pre>
<p>같은 <strong>Read-heavy 작업부터 병렬화하고, Write-heavy 병렬 작업은 더 신중하게 사용하라</strong>고 안내합니다.</p>
<hr>
<h2 id="10-그래서-실제-개발에는-어떻게-적용할까">10. 그래서 실제 개발에는 어떻게 적용할까?</h2>
<p>여러 사례를 보고 제가 가장 괜찮다고 느낀 구조는 이것입니다.</p>
<h3 id="병렬-분석--단일-구현--독립-리뷰">병렬 분석 + 단일 구현 + 독립 리뷰</h3>
<pre><code class="language-text">                     Main Codex
                    Orchestrator
                         │
        ┌────────────────┼────────────────┐
        ↓                ↓                ↓
   Code Explorer     Test Analyzer     Risk Reviewer
    READ ONLY         READ ONLY         READ ONLY
        │                │                │
        └────────────────┼────────────────┘
                         ↓
                    결과 통합
                         ↓
                       Worker
                       WRITE
                         ↓
                     Full Test
                         ↓
             ┌───────────┴───────────┐
             ↓                       ↓
        Formal Reviewer       Security Reviewer
           READ ONLY             READ ONLY
             │                       │
             └───────────┬───────────┘
                         ↓
                       수정
                         ↓
                        PR</code></pre>
<p>이 방식의 가장 큰 장점은 <strong>코드를 실제로 수정하는 Agent는 한 명으로 유지하면서 가장 Context를 많이 소모하는 탐색과 검증 작업을 분리할 수 있다는 점</strong>입니다.</p>
<hr>
<h2 id="11-예를-들어-실제-java-프로젝트라면">11. 예를 들어 실제 Java 프로젝트라면</h2>
<p>DB Migration, Backend, Frontend가 함께 변경되는 Issue가 있다고 가정해보겠습니다.</p>
<p>메인 Codex에게 이렇게 요청할 수 있습니다.</p>
<pre><code class="language-text">이번 작업은 서브에이전트를 사용한다.

먼저 구현하지 말고 다음 분석을 병렬 수행해.

Agent 1 - DB Analyzer
- Flyway migration
- schema 영향
- 기존 데이터 호환성
- rollback 위험
을 분석한다.
코드는 수정하지 않는다.

Agent 2 - Backend Explorer
- Controller
- Service
- Transaction
- 호출 경로
- 기존 Contract
영향을 분석한다.
코드는 수정하지 않는다.

Agent 3 - Test Analyzer
- 관련 기존 테스트
- 회귀 가능성
- 누락된 테스트
를 분석한다.
코드는 수정하지 않는다.

Agent 4 - Risk Reviewer
- security
- concurrency
- transaction
- compatibility
위험을 분석한다.
코드는 수정하지 않는다.

모든 Agent가 완료될 때까지 기다린다.

결과를 통합한 뒤 구현 계획을 확정하고,
실제 코드 수정은 Worker 한 명만 수행한다.

구현 및 전체 테스트가 끝나면
새로운 Reviewer Agent를 사용해 독립 리뷰한다.

P0/P1 문제가 발견되면 Worker가 수정하고
다시 Reviewer 검증을 수행한다.</code></pre>
<p>이 정도만으로도 기존의</p>
<pre><code class="language-text">한 Agent가 모든 것을 탐색하고
한 Agent가 모든 것을 기억하고
한 Agent가 자기 코드를 다시 리뷰하는 구조</code></pre>
<p>와 꽤 달라집니다.</p>
<hr>
<h2 id="12-새-채팅과-서브에이전트는-경쟁-관계가-아니었다">12. 새 채팅과 서브에이전트는 경쟁 관계가 아니었다</h2>
<p>이 부분도 이번에 정리가 됐습니다.</p>
<p>저는 기존에 작업이 길어지면 Codex 새 채팅을 만들었습니다.</p>
<p>그런데 서브에이전트를 사용한다고 이 방법을 버릴 필요는 없습니다.</p>
<p>둘의 역할이 다릅니다.</p>
<h3 id="새-채팅">새 채팅</h3>
<p><strong>개발 단계 자체를 분리하는 용도</strong></p>
<pre><code class="language-text">Chat A
Issue 구현
    ↓
Chat B
PR Formal Review
    ↓
Chat C
후속 수정</code></pre>
<h3 id="서브에이전트">서브에이전트</h3>
<p><strong>한 개발 단계 안에서 작업을 분리하는 용도</strong></p>
<pre><code class="language-text">Chat A
Issue 구현

 ├ Explorer
 ├ DB Analyzer
 ├ Test Analyzer
 ├ Reviewer
 └ Worker</code></pre>
<p>그래서 오히려 두 가지를 함께 사용하는 것이 좋아 보입니다.</p>
<hr>
<h2 id="13-제가-앞으로-적용해보려는-방식">13. 제가 앞으로 적용해보려는 방식</h2>
<p>기존에는 대략 이렇게 작업했습니다.</p>
<pre><code class="language-text">ChatGPT
   ↓
Codex 구현
   ↓
Codex 자체 리뷰
   ↓
PR
   ↓
ChatGPT 리뷰
   ↓
Codex 수정
   ↓
다시 리뷰</code></pre>
<p>완벽하게 검증하려다 보니 제가 중간 단계에 계속 개입해야 했습니다.</p>
<p>앞으로는 조금 다르게 해볼 생각입니다.</p>
<pre><code class="language-text">ChatGPT
Issue / Acceptance Criteria 설계
              ↓
          Codex Main
              ↓
    ┌─────────┼─────────┐
    ↓         ↓         ↓
 Explorer   Tester    Reviewer
    └─────────┼─────────┘
              ↓
            Worker
              ↓
          Full Test
              ↓
    ┌─────────┴─────────┐
    ↓                   ↓
Formal Reviewer    Security Reviewer
    └─────────┬─────────┘
              ↓
         자동 수정 반복
              ↓
             PR
              ↓
        최종 인간 검토</code></pre>
<p>사람이 모든 중간 과정을 관리하는 대신,</p>
<p><strong>Codex 내부에서 역할을 나누고 정말 판단이 필요한 지점만 사람이 확인하는 구조</strong>입니다.</p>
<p>이 방식이 잘 자리 잡으면 제가 직접 해야 할 일이 꽤 줄어들 것 같습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>서브에이전트를 처음 봤을 때는</p>
<blockquote>
<p>AI를 여러 개 동시에 돌리는 기능</p>
</blockquote>
<p>정도로 생각했습니다.</p>
<p>그래서 실제 개발에서 어떻게 사용해야 하는지 조금 두루뭉술했습니다.</p>
<p>이번에 실제 사례들을 찾아보고 나니 생각이 바뀌었습니다.</p>
<p>서브에이전트의 핵심은 <strong>Agent의 숫자가 아니라 역할 분리</strong>였습니다.</p>
<pre><code class="language-text">탐색하는 Agent
검증하는 Agent
구현하는 Agent
테스트하는 Agent
리뷰하는 Agent</code></pre>
<p>를 분리하고,</p>
<p>메인 Agent는</p>
<pre><code class="language-text">요구사항
의사결정
작업 분배
결과 통합</code></pre>
<p>에 집중하게 만드는 것입니다.</p>
<p>특히 제가 가장 중요하게 보는 포인트는 이것입니다.</p>
<blockquote>
<p><strong>서브에이전트는 Token을 아끼는 기능이 아니라, Token을 더 사용해서 사람의 시간과 메인 Context를 아끼는 기능이다.</strong></p>
</blockquote>
<p>그리고 실제 개발에서는 처음부터 여러 Worker에게 코드를 병렬로 작성시키기보다,</p>
<blockquote>
<p><strong>Read-heavy 작업은 병렬화하고, Write는 통제한다.</strong></p>
</blockquote>
<p>는 방식부터 시작하는 것이 가장 안전해 보입니다.</p>
<p>저도 다음 개발 작업부터</p>
<p><strong>Explorer → Test Analyzer → Worker → Independent Reviewer</strong></p>
<p>구조를 실제 프로젝트에 적용해볼 생각입니다.</p>
<p>사용해보고 기존 단일 Codex 방식과 작업 속도, 사용량, 리뷰 품질이 어떻게 달라지는지도 따로 기록해보겠습니다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://developers.openai.com/codex/multi-agent">OpenAI Codex - Multi-agent</a></li>
<li><a href="https://openai.com/index/harness-engineering/">OpenAI - Harness engineering: leveraging Codex in an agent-first world</a></li>
<li><a href="https://github.com/shinpr/codex-workflows">GitHub - shinpr/codex-workflows</a></li>
<li><a href="https://github.com/openai/codex/issues/23095">GitHub OpenAI/Codex - Worktree 기반 Subagent 활용 사례</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[집 서버를 포트포워딩 없이 공개하는 방법: Cloudflare Tunnel과 Access 쉽게 이해하기]]></title>
            <link>https://velog.io/@caffeine_coder/cloudflare-tunnel-access-home-server</link>
            <guid>https://velog.io/@caffeine_coder/cloudflare-tunnel-access-home-server</guid>
            <pubDate>Wed, 22 Jul 2026 07:28:59 GMT</pubDate>
            <description><![CDATA[<h1 id="집-서버를-포트포워딩-없이-공개하는-방법">집 서버를 포트포워딩 없이 공개하는 방법</h1>
<p>집에 남는 데스크톱을 활용해 개인 프로젝트 서버를 운영하려고 했습니다.</p>
<p>Ubuntu를 설치하고 웹 애플리케이션까지 실행한 뒤, 휴대폰과 외부 노트북에서도 관리자 화면을 확인하고 싶었습니다.</p>
<p>예를 들어 주소는 다음과 같습니다.</p>
<pre><code class="language-text">dashboard.example.com</code></pre>
<p>그런데 외부 접속 방법을 알아보다 보니 공유기 포트포워딩이 먼저 나왔습니다.</p>
<pre><code class="language-text">공유기 443 포트
→ 집 서버 Nginx

공유기 8080 포트
→ 애플리케이션</code></pre>
<p>기술적으로는 가능하지만 마음에 걸리는 부분이 있었습니다.</p>
<blockquote>
<p>인터넷에 포트를 직접 열면<br>주소나 IP를 알아낸 누구나 서버에 접속을 시도할 수 있는 것 아닌가?</p>
</blockquote>
<p>이 고민을 하다가 알게 된 것이 <strong>Cloudflare Tunnel과 Cloudflare Access</strong>였습니다.</p>
<hr>
<h2 id="cloudflare-access는-온라인-출입문-경비원이다">Cloudflare Access는 온라인 출입문 경비원이다</h2>
<p>Cloudflare Access를 가장 쉽게 표현하면 다음과 같습니다.</p>
<blockquote>
<p><strong>집 서버 앞에 서 있는 온라인 출입문 경비원</strong></p>
</blockquote>
<p>Cloudflare Access가 없다면 사용자는 바로 애플리케이션 로그인 화면이나 서버로 접근을 시도합니다.</p>
<pre><code class="language-text">사용자
↓
집 서버
↓
관리자 화면</code></pre>
<p>Access를 적용하면 중간에 신원 확인 과정이 추가됩니다.</p>
<pre><code class="language-text">사용자
↓
Cloudflare 로그인
↓
허용된 사용자인지 확인
↓
집 서버
↓
관리자 화면</code></pre>
<p>허용한 이메일이면 관리자 화면으로 연결하고, 허용되지 않은 사용자는 집 서버에 도달하기 전에 차단합니다.</p>
<p>이메일, 로그인 제공자, 사용자 그룹, 기기 조건 등을 이용해 애플리케이션별 접근 정책을 만들 수 있습니다.</p>
<p>혼자 사용하는 서버라면 본인의 이메일 주소 하나만 허용하는 방식으로도 시작할 수 있습니다.</p>
<hr>
<h2 id="tunnel과-access는-역할이-다르다">Tunnel과 Access는 역할이 다르다</h2>
<p>처음에는 Tunnel과 Access가 비슷한 서비스라고 생각했습니다.</p>
<p>하지만 둘은 역할이 완전히 다릅니다.</p>
<h3 id="cloudflare-tunnel">Cloudflare Tunnel</h3>
<blockquote>
<p><strong>집 서버까지 연결되는 전용 지하 통로</strong></p>
</blockquote>
<p>집 서버에서 실행되는 <code>cloudflared</code>가 Cloudflare 쪽으로 연결을 만듭니다.</p>
<p>외부 사용자가 집 서버의 공개 IP와 포트로 직접 들어오는 구조가 아닙니다.</p>
<pre><code class="language-text">집 서버
↓ 외부 방향 연결
Cloudflare</code></pre>
<p>따라서 일반적인 웹 애플리케이션 공개를 위해 공유기에 80·443 포트포워딩을 설정하거나, 애플리케이션의 8080 포트를 직접 열 필요가 없습니다.</p>
<h3 id="cloudflare-access">Cloudflare Access</h3>
<blockquote>
<p><strong>지하 통로 입구에 있는 신분증 검사대</strong></p>
</blockquote>
<p>Tunnel이 연결 경로를 만든다면 Access는 누가 그 경로를 이용할 수 있는지 판단합니다.</p>
<pre><code class="language-text">사용자
↓
Cloudflare Access
↓
Cloudflare Tunnel
↓
집 서버</code></pre>
<p>보통 두 서비스를 함께 사용합니다.</p>
<hr>
<h2 id="개인-프로젝트-관리자-화면에-적용하면">개인 프로젝트 관리자 화면에 적용하면</h2>
<p>제가 이해한 구조를 개인 프로젝트 관리자 화면에 적용하면 다음과 같습니다.</p>
<pre><code class="language-text">휴대폰·노트북
↓
https://dashboard.example.com
↓
Cloudflare Access
  허용한 이메일만 통과
↓
Cloudflare Tunnel
↓
집 Ubuntu 서버
↓
Nginx
├─ 웹 관리자 화면
└─ /api → 백엔드 애플리케이션</code></pre>
<p>사용자가 <code>dashboard.example.com</code>에 접속하면 바로 집 서버에 연결되지 않습니다.</p>
<p>먼저 Cloudflare 로그인 화면이 나타납니다.</p>
<p>이메일 일회용 인증번호 방식을 사용한다면 대략 다음 순서로 진행됩니다.</p>
<pre><code class="language-text">1. dashboard.example.com 접속
2. Cloudflare 로그인 화면 표시
3. 허용된 이메일 입력
4. 이메일로 일회용 인증번호 수신
5. 인증번호 입력
6. 관리자 화면 접속</code></pre>
<p>별도의 VPN 애플리케이션 없이 브라우저에서 인증할 수 있다는 점이 개인용 웹 관리자 화면에 잘 맞았습니다.</p>
<hr>
<h2 id="공유기-포트포워딩을-열지-않아도-된다">공유기 포트포워딩을 열지 않아도 된다</h2>
<p>Cloudflare Tunnel을 사용하는 핵심 이유 중 하나는 외부에서 집 서버로 직접 들어오는 포트를 열지 않아도 된다는 점입니다.</p>
<p>예를 들어 집 서버에 다음 서비스가 실행되고 있다고 가정해보겠습니다.</p>
<pre><code class="language-text">Nginx        80·443
Backend      8080
PostgreSQL   5432
SSH          22</code></pre>
<p>이 포트들을 공유기에서 모두 외부로 공개해서는 안 됩니다.</p>
<p>특히 데이터베이스와 백엔드 애플리케이션 포트는 인터넷에서 직접 접근할 수 없도록 구성하는 것이 좋습니다.</p>
<pre><code class="language-text">PostgreSQL
→ localhost 또는 Docker 내부 네트워크만 허용

Backend
→ Nginx 또는 내부 네트워크에서만 접근

웹 접점
→ Cloudflare Tunnel을 통한 주소만 사용</code></pre>
<p>Cloudflare를 통한 주소를 보호해놓고 <code>공인 IP:8080</code>으로도 접근할 수 있게 열어두면 Access를 우회할 수 있습니다.</p>
<p>따라서 Tunnel을 사용한다면 기존 포트포워딩도 함께 닫아야 합니다.</p>
<hr>
<h2 id="access가-해결하는-것">Access가 해결하는 것</h2>
<p>Cloudflare Access와 Tunnel을 함께 사용하면 다음 부분을 해결할 수 있습니다.</p>
<h3 id="1-허용된-사람만-접근">1. 허용된 사람만 접근</h3>
<p>등록한 이메일이나 로그인 정책에 맞는 사람만 관리자 화면에 들어올 수 있습니다.</p>
<h3 id="2-원본-서버를-직접-공개하지-않음">2. 원본 서버를 직접 공개하지 않음</h3>
<p>사용자가 집 서버의 공인 IP와 애플리케이션 포트로 직접 접근하지 않고 Cloudflare를 통해 접속합니다.</p>
<h3 id="3-애플리케이션별-접근-정책">3. 애플리케이션별 접근 정책</h3>
<p>관리자 화면, 테스트 화면, 내부 도구마다 다른 접근 정책을 적용할 수 있습니다.</p>
<h3 id="4-인증-기록-확인">4. 인증 기록 확인</h3>
<p>누가 인증을 시도했고 허용 또는 차단됐는지 확인할 수 있습니다.</p>
<hr>
<h2 id="access가-해결하지-않는-것">Access가 해결하지 않는 것</h2>
<p>Cloudflare Access를 붙였다고 애플리케이션 보안이 모두 끝나는 것은 아닙니다.</p>
<p>Access는 외부 출입문 역할입니다.</p>
<p>집 안에 들어온 사용자가 어떤 데이터에 접근할 수 있는지는 애플리케이션이 계속 확인해야 합니다.</p>
<pre><code class="language-text">Cloudflare Access
→ 서버에 접근할 사용자 확인

애플리케이션 인증·권한
→ 사용자별 기능과 데이터 접근 통제

암호화
→ 비밀번호, 토큰, API Key 보호

데이터베이스 권한
→ 애플리케이션에 필요한 최소 권한만 제공</code></pre>
<p>예를 들어 관리자와 일반 사용자가 함께 사용하는 서비스라면 Access 인증을 통과한 뒤에도 애플리케이션 내부 권한 검사가 필요합니다.</p>
<p>또한 서버가 Cloudflare를 우회해 직접 노출되지 않았는지도 계속 확인해야 합니다.</p>
<hr>
<h2 id="이메일-otp-설정-시-주의할-점">이메일 OTP 설정 시 주의할 점</h2>
<p>이메일 일회용 인증번호는 혼자 사용하는 서버에서 간단하게 시작하기 좋습니다.</p>
<p>다만 정책을 잘못 설정하면 누구나 자기 이메일로 인증번호를 받아 접속할 수 있습니다.</p>
<p>따라서 다음처럼 설정해야 합니다.</p>
<pre><code class="language-text">잘못된 예
→ 이메일 OTP를 사용할 수 있는 모든 사용자 허용

권장 예
→ 지정한 이메일 주소만 허용</code></pre>
<p>예를 들면 다음과 같습니다.</p>
<pre><code class="language-text">Allow
→ Email
→ owner@example.com</code></pre>
<p>가족이나 팀원에게 접근을 열어야 할 때만 허용 목록을 추가합니다.</p>
<hr>
<h2 id="제가-개인-서버에-적용한다면">제가 개인 서버에 적용한다면</h2>
<p>개인 프로젝트 관리자 화면이라면 처음부터 복잡하게 구성하지 않을 생각입니다.</p>
<pre><code class="language-text">대상 주소
dashboard.example.com/*

접근 허용
내 이메일 주소 1개

인증 방식
이메일 일회용 인증번호

서버 연결
Cloudflare Tunnel

공유기 포트포워딩
없음

cloudflared 실행
Ubuntu systemd 서비스

웹 서버
Nginx

백엔드
내부 포트에서만 실행

데이터베이스
localhost 또는 Docker 내부망만 허용</code></pre>
<p>추가로 외부 네트워크에서 다음 두 가지를 테스트해야 합니다.</p>
<pre><code class="language-text">허용된 이메일
→ 정상 접속

허용되지 않은 이메일
→ 서버 도달 전 차단</code></pre>
<p>공인 IP와 애플리케이션 포트를 이용한 직접 접근도 불가능한지 확인합니다.</p>
<hr>
<h2 id="cloudflare-access도-애플리케이션-검증이-필요하다">Cloudflare Access도 애플리케이션 검증이 필요하다</h2>
<p>조금 더 안전하게 운영하려면 애플리케이션이 Cloudflare Access에서 전달한 인증 정보도 검증하는 것이 좋습니다.</p>
<p>Cloudflare Access는 인증된 요청에 토큰을 전달할 수 있습니다.</p>
<p>백엔드에서는 해당 토큰이 실제 Cloudflare에서 발급됐는지 확인해, 우회 요청을 한 번 더 차단할 수 있습니다.</p>
<p>특히 서버가 다른 경로로 노출될 가능성이 있거나 중요한 관리자 기능을 제공한다면 방어 단계를 하나 더 두는 편이 안전합니다.</p>
<hr>
<h2 id="비용은-어떻게-될까">비용은 어떻게 될까?</h2>
<p>현재 Cloudflare 문서에서는 Zero Trust 무료 플랜을 소규모 사용자 환경에서 시작할 수 있도록 제공하고 있습니다.</p>
<p>혼자 사용하거나 소수 인원만 접근하는 개인 서버라면 우선 무료 범위에서 기능을 검증한 뒤, 필요한 기능이 생겼을 때 유료 플랜을 검토하는 방식이 적합해 보입니다.</p>
<p>다만 플랜과 제공 범위는 변경될 수 있으므로 실제 적용 시점에는 공식 요금제와 제한을 다시 확인해야 합니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Cloudflare Access가 처음에는 복잡한 기업용 보안 서비스처럼 느껴졌습니다.</p>
<p>하지만 집 서버에 대입해서 이해하니 역할이 명확했습니다.</p>
<pre><code class="language-text">Cloudflare Tunnel
= 집 서버까지 이어지는 전용 통로

Cloudflare Access
= 통로 입구에서 사용자를 확인하는 경비원</code></pre>
<p>두 서비스를 함께 사용하면 집 서버를 인터넷에 직접 내놓지 않고, 허용한 사람만 웹 관리자 화면에 접근하도록 구성할 수 있습니다.</p>
<p>물론 Access 하나만 붙인다고 모든 보안이 끝나지는 않습니다.</p>
<p>애플리케이션 내부 권한, 중요 정보 암호화, 데이터베이스 접근 통제도 계속 필요합니다.</p>
<p>그래도 외부 접점을 줄이고 서버에 도달하기 전에 사용자를 확인하는 구조만으로도, 공유기 포트를 직접 여는 방식보다 훨씬 마음 편하게 개인 서버를 운영할 수 있을 것 같습니다.</p>
<p>집 서버 보안은 로그인 화면부터 시작하는 것이 아니라,</p>
<blockquote>
<p><strong>서버까지 누가 도달할 수 있는지 제한하는 것부터 시작했습니다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[혼자 개발하는데 PR 작성자와 리뷰어가 같았다: Codex 전용 GitHub 계정 분리하기]]></title>
            <link>https://velog.io/@caffeine_coder/separate-codex-github-bot-account</link>
            <guid>https://velog.io/@caffeine_coder/separate-codex-github-bot-account</guid>
            <pubDate>Wed, 15 Jul 2026 07:53:09 GMT</pubDate>
            <description><![CDATA[<p>ChatGPT와 Codex를 함께 사용하면서 생각하지 못했던 불편함이 하나 생겼습니다.</p>
<p>둘 다 같은 GitHub 계정을 사용하다 보니 PR 화면에서 역할 구분이 애매했습니다.</p>
<ul>
<li>PR을 만든 계정도 나</li>
<li>리뷰 코멘트를 작성한 계정도 나</li>
<li>최종 승인하는 계정도 나</li>
</ul>
<p>혼자 개발하는 사이드 프로젝트이기는 하지만, PR 타임라인만 보면 누가 구현했고 누가 리뷰했는지 전혀 구분되지 않았습니다.</p>
<p>말 그대로 혼자 북 치고 장구 치는 모습이었습니다ㅎㅎ</p>
<p>그래서 이번에 Codex 전용 GitHub 계정을 하나 만들었습니다.</p>
<blockquote>
<p>이 글에 나오는 계정명, 저장소명, 경로는 모두 설명을 위한 샘플입니다.</p>
</blockquote>
<pre><code class="language-text">저장소 소유 계정
dev-harbor

Codex 전용 계정
dev-harbor-codex-bot

샘플 저장소
dev-harbor/order-service-lab</code></pre>
<p>설정을 마친 뒤에는 역할이 이렇게 나뉘었습니다.</p>
<pre><code class="language-text">Codex
→ dev-harbor-codex-bot
→ 코드 구현, Commit, Push, PR 생성

ChatGPT 및 저장소 소유자
→ dev-harbor
→ PR 리뷰, 코멘트, 최종 승인</code></pre>
<p>계정 하나만 추가하면 끝날 줄 알았는데, 설정하면서 알게 된 사실이 하나 있었습니다.</p>
<p>GitHub에서 사용하는 계정 정보는 한 곳에서 결정되지 않았습니다.</p>
<hr>
<h2 id="github-계정은-세-군데에서-따로-결정된다">GitHub 계정은 세 군데에서 따로 결정된다</h2>
<p>Codex가 GitHub에서 작업할 때는 크게 세 가지 계정 정보가 사용됩니다.</p>
<table>
<thead>
<tr>
<th>작업</th>
<th>계정을 결정하는 설정</th>
</tr>
</thead>
<tbody><tr>
<td>Commit 작성자</td>
<td><code>git config user.name</code>, <code>user.email</code></td>
</tr>
<tr>
<td>브랜치 Push 계정</td>
<td>Git Remote와 SSH 키</td>
</tr>
<tr>
<td>PR 생성 및 코멘트</td>
<td><code>gh</code> CLI에 로그인된 계정</td>
</tr>
</tbody></table>
<p>예를 들어 Codex를 봇 계정으로 GitHub에 연결했더라도, 로컬 Git 설정이 기존 사용자로 되어 있으면 Commit 작성자는 여전히 기존 계정으로 표시될 수 있습니다.</p>
<p>반대로 Commit 작성자만 봇으로 바꿔도 Remote가 기존 SSH 키를 사용하면 Push는 기존 사용자 인증으로 처리됩니다.</p>
<p>그래서 아래 세 가지를 모두 봇 계정으로 맞춰야 했습니다.</p>
<pre><code class="language-text">Commit 작성자
→ dev-harbor-codex-bot

Git Push 인증
→ dev-harbor-codex-bot 전용 SSH 키

PR 생성 및 코멘트
→ dev-harbor-codex-bot의 gh 인증</code></pre>
<p>이 글은 <strong>Mac에서 로컬 Codex 앱 또는 CLI를 사용하는 환경</strong>을 기준으로 작성했습니다.</p>
<hr>
<h2 id="최종적으로-만들-구조">최종적으로 만들 구조</h2>
<p>제가 구성한 형태는 다음과 같습니다.</p>
<pre><code class="language-text">Codex 앱 또는 CLI
├─ OpenAI 로그인: 기존 ChatGPT 계정
├─ Git Commit: dev-harbor-codex-bot
├─ Git Push: 봇 전용 SSH 키
└─ PR 생성·코멘트: 봇 계정의 gh 인증

ChatGPT
├─ GitHub 연결: dev-harbor
└─ 역할: PR 리뷰 및 코멘트

저장소
├─ 소유자: dev-harbor
├─ Collaborator: dev-harbor-codex-bot
├─ Codex는 작업 브랜치만 Push
├─ Codex는 Draft PR까지만 생성
└─ 최종 Merge는 저장소 소유자가 진행</code></pre>
<p>Codex의 OpenAI 로그인 계정까지 바꿀 필요는 없었습니다.</p>
<p>Codex가 로컬에서 실행하는 <code>git</code>, <code>ssh</code>, <code>gh</code> 명령의 인증 주체만 봇 계정으로 분리하면 됩니다.</p>
<hr>
<h2 id="1-codex-전용-github-계정-만들기">1. Codex 전용 GitHub 계정 만들기</h2>
<p>먼저 Codex 전용 GitHub 계정을 새로 만들었습니다.</p>
<p>기존 GitHub 계정과 다른 이메일 주소가 필요합니다.</p>
<p>저는 다음과 같은 형태로 구성했습니다.</p>
<pre><code class="language-text">기존 사용자 계정
dev-harbor

Codex 전용 계정
dev-harbor-codex-bot</code></pre>
<p>봇 계정에는 다음 설정도 함께 적용했습니다.</p>
<ul>
<li>별도 이메일 사용</li>
<li>이메일 인증</li>
<li>2단계 인증 설정</li>
<li>복구 코드 보관</li>
<li>프로필에 자동화 전용 계정임을 표시</li>
</ul>
<p>계정을 만든 뒤 저장소에 Collaborator로 초대합니다.</p>
<pre><code class="language-text">Repository
→ Settings
→ Collaborators
→ Add people</code></pre>
<p>샘플 저장소를 기준으로 하면 다음 계정을 초대합니다.</p>
<pre><code class="language-text">dev-harbor-codex-bot</code></pre>
<p>Codex가 작업 브랜치를 Push하고 PR을 생성해야 하므로 <code>Write</code> 권한을 부여했습니다.</p>
<p>봇 계정에는 필요한 최소 권한만 주는 편이 좋습니다.</p>
<hr>
<h2 id="2-github-cli-설치-확인">2. GitHub CLI 설치 확인</h2>
<p>PR 생성과 코멘트 작성에는 GitHub CLI인 <code>gh</code>를 사용합니다.</p>
<p>설치 여부를 확인합니다.</p>
<pre><code class="language-bash">gh --version</code></pre>
<p>설치되어 있지 않다면 Homebrew로 설치할 수 있습니다.</p>
<pre><code class="language-bash">brew install gh</code></pre>
<hr>
<h2 id="3-gh에-codex-봇-계정-로그인하기">3. <code>gh</code>에 Codex 봇 계정 로그인하기</h2>
<p>다음 명령으로 GitHub CLI 인증을 시작합니다.</p>
<pre><code class="language-bash">gh auth login \
  --hostname github.com \
  --git-protocol https \
  --web</code></pre>
<p>브라우저가 열리면 반드시 봇 계정으로 로그인되어 있는지 확인합니다.</p>
<pre><code class="language-text">dev-harbor-codex-bot</code></pre>
<p>기존 사용자 계정으로 로그인되어 있다면 GitHub 계정을 전환하거나, 별도의 브라우저 프로필에서 봇 계정으로 로그인한 뒤 인증합니다.</p>
<p>여러 계정이 등록되어 있다면 봇 계정을 활성화합니다.</p>
<pre><code class="language-bash">gh auth switch \
  --hostname github.com \
  --user dev-harbor-codex-bot</code></pre>
<p>현재 활성화된 계정을 확인합니다.</p>
<pre><code class="language-bash">gh auth status --hostname github.com</code></pre>
<p>조금 더 확실하게 확인하려면 다음 명령을 사용합니다.</p>
<pre><code class="language-bash">gh api user --jq &#39;.login&#39;</code></pre>
<p>결과는 반드시 다음과 같아야 합니다.</p>
<pre><code class="language-text">dev-harbor-codex-bot</code></pre>
<p>저는 Codex가 GitHub 작업을 시작하기 전에 이 명령을 항상 실행하도록 설정했습니다.</p>
<p>계정 하나 잘못 선택한 상태로 PR을 만들면 처음 문제로 다시 돌아가니까요ㅎㅎ</p>
<hr>
<h2 id="4-봇-계정-전용-ssh-키-만들기">4. 봇 계정 전용 SSH 키 만들기</h2>
<p>기존 사용자 계정의 SSH 키와 분리하기 위해 봇 전용 키를 생성합니다.</p>
<pre><code class="language-bash">mkdir -p ~/.ssh
chmod 700 ~/.ssh</code></pre>
<p>SSH 키를 생성합니다.</p>
<pre><code class="language-bash">ssh-keygen \
  -t ed25519 \
  -C &quot;dev-harbor-codex-bot&quot; \
  -f ~/.ssh/id_ed25519_dev_harbor_codex_bot</code></pre>
<p>Passphrase 입력 화면이 나오면 비밀번호를 설정할 수 있습니다.</p>
<p>Mac Keychain에 키를 등록합니다.</p>
<pre><code class="language-bash">ssh-add --apple-use-keychain \
  ~/.ssh/id_ed25519_dev_harbor_codex_bot</code></pre>
<p>다음 오류가 발생한다면 SSH Agent부터 실행합니다.</p>
<pre><code class="language-text">Could not open a connection to your authentication agent</code></pre>
<pre><code class="language-bash">eval &quot;$(ssh-agent -s)&quot;

ssh-add --apple-use-keychain \
  ~/.ssh/id_ed25519_dev_harbor_codex_bot</code></pre>
<p>생성되는 파일은 두 개입니다.</p>
<pre><code class="language-text">개인 키
~/.ssh/id_ed25519_dev_harbor_codex_bot

공개 키
~/.ssh/id_ed25519_dev_harbor_codex_bot.pub</code></pre>
<p>GitHub에 등록하는 것은 반드시 <code>.pub</code>이 붙은 공개 키입니다.</p>
<p>개인 키 파일은 외부에 공유하거나 GitHub에 등록하면 안 됩니다.</p>
<hr>
<h2 id="5-공개-키를-codex-봇-계정에-등록하기">5. 공개 키를 Codex 봇 계정에 등록하기</h2>
<p>먼저 현재 <code>gh</code> 계정이 봇인지 다시 확인합니다.</p>
<pre><code class="language-bash">gh api user --jq &#39;.login&#39;</code></pre>
<p>결과:</p>
<pre><code class="language-text">dev-harbor-codex-bot</code></pre>
<p>정상이라면 다음 명령으로 공개 키를 등록합니다.</p>
<pre><code class="language-bash">gh ssh-key add \
  ~/.ssh/id_ed25519_dev_harbor_codex_bot.pub \
  --type authentication \
  --title &quot;MacBook - Codex Bot&quot;</code></pre>
<p>GitHub 웹 화면에서 직접 등록할 수도 있습니다.</p>
<pre><code class="language-text">Profile
→ Settings
→ SSH and GPG keys
→ New SSH key</code></pre>
<p>공개 키 내용은 다음 명령으로 확인할 수 있습니다.</p>
<pre><code class="language-bash">cat ~/.ssh/id_ed25519_dev_harbor_codex_bot.pub</code></pre>
<hr>
<h2 id="6-ssh-계정-별칭-만들기">6. SSH 계정 별칭 만들기</h2>
<p>같은 <code>github.com</code>에 여러 계정을 사용하려면 SSH Host 별칭을 만들어두는 것이 편합니다.</p>
<p>SSH 설정 파일을 엽니다.</p>
<pre><code class="language-bash">nano ~/.ssh/config</code></pre>
<p>기존 내용을 지우지 않고 아래 설정을 추가합니다.</p>
<pre><code class="language-sshconfig">Host github-dev-harbor-codex-bot
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_dev_harbor_codex_bot
  IdentitiesOnly yes
  AddKeysToAgent yes
  UseKeychain yes</code></pre>
<p>설정 파일의 권한도 확인합니다.</p>
<pre><code class="language-bash">chmod 600 ~/.ssh/config</code></pre>
<p>SSH 연결을 테스트합니다.</p>
<pre><code class="language-bash">ssh -T git@github-dev-harbor-codex-bot</code></pre>
<p>정상적으로 연결되면 메시지에 봇 계정명이 표시됩니다.</p>
<pre><code class="language-text">Hi dev-harbor-codex-bot!</code></pre>
<p>이 별칭 덕분에 일반 GitHub 작업은 기존 SSH 키를 사용하고, 특정 저장소에서만 Codex 봇 계정을 사용할 수 있습니다.</p>
<pre><code class="language-text">github.com
→ 기존 사용자 계정

github-dev-harbor-codex-bot
→ Codex 봇 계정</code></pre>
<hr>
<h2 id="7-해당-저장소의-push-계정을-봇으로-고정하기">7. 해당 저장소의 Push 계정을 봇으로 고정하기</h2>
<p>프로젝트 저장소로 이동합니다.</p>
<pre><code class="language-bash">cd ~/workspace/order-service-lab</code></pre>
<p>현재 Remote를 확인합니다.</p>
<pre><code class="language-bash">git remote -v</code></pre>
<p><code>origin</code>을 앞에서 만든 봇 전용 SSH 별칭으로 변경합니다.</p>
<pre><code class="language-bash">git remote set-url origin \
  git@github-dev-harbor-codex-bot:dev-harbor/order-service-lab.git</code></pre>
<p>변경 결과를 확인합니다.</p>
<pre><code class="language-bash">git remote -v</code></pre>
<p>정상적인 결과는 다음과 비슷합니다.</p>
<pre><code class="language-text">origin  git@github-dev-harbor-codex-bot:dev-harbor/order-service-lab.git (fetch)
origin  git@github-dev-harbor-codex-bot:dev-harbor/order-service-lab.git (push)</code></pre>
<p>여기서 헷갈렸던 부분이 하나 있습니다.</p>
<p>저장소의 소유자는 여전히 기존 계정입니다.</p>
<pre><code class="language-text">dev-harbor/order-service-lab</code></pre>
<p>다만 해당 저장소에 접근할 때 사용하는 SSH 인증 계정만 봇으로 변경된 것입니다.</p>
<pre><code class="language-text">dev-harbor-codex-bot</code></pre>
<p>봇 계정이 Collaborator로 등록되어 있기 때문에 작업 브랜치를 Push할 수 있습니다.</p>
<hr>
<h2 id="8-commit-작성자를-봇-계정으로-고정하기">8. Commit 작성자를 봇 계정으로 고정하기</h2>
<p>Push 계정을 바꿨다고 Commit 작성자까지 자동으로 바뀌지는 않습니다.</p>
<p>Git의 Commit 작성자는 다음 설정으로 결정됩니다.</p>
<pre><code class="language-text">user.name
user.email</code></pre>
<p>먼저 봇 계정의 GitHub 설정에서 전용 <code>noreply</code> 이메일을 확인합니다.</p>
<pre><code class="language-text">GitHub
→ Settings
→ Emails
→ Keep my email addresses private</code></pre>
<p>보통 다음과 비슷한 형태입니다.</p>
<pre><code class="language-text">12345678+dev-harbor-codex-bot@users.noreply.github.com</code></pre>
<p>숫자와 이메일 형식은 계정마다 다르므로 GitHub 화면에 표시된 정확한 값을 사용해야 합니다.</p>
<p>저장소 디렉터리에서 다음 명령을 실행합니다.</p>
<pre><code class="language-bash">cd ~/workspace/order-service-lab

git config --local user.name &quot;dev-harbor-codex-bot&quot;

git config --local user.email \
  &quot;12345678+dev-harbor-codex-bot@users.noreply.github.com&quot;</code></pre>
<p>여기서 중요한 것은 <code>--global</code>이 아니라 <code>--local</code>을 사용한다는 점입니다.</p>
<pre><code class="language-bash">git config --local</code></pre>
<p>그래야 이 저장소에서만 봇 계정이 Commit 작성자로 사용되고, 다른 저장소에서는 기존 사용자 정보를 그대로 유지할 수 있습니다.</p>
<p>설정 출처를 확인합니다.</p>
<pre><code class="language-bash">git config --show-origin --get user.name
git config --show-origin --get user.email</code></pre>
<p>정상이라면 <code>.git/config</code>에 저장된 값이 표시됩니다.</p>
<pre><code class="language-text">file:.git/config  dev-harbor-codex-bot
file:.git/config  12345678+dev-harbor-codex-bot@users.noreply.github.com</code></pre>
<hr>
<h2 id="9-전체-인증-상태-한-번에-확인하기">9. 전체 인증 상태 한 번에 확인하기</h2>
<p>설정이 끝난 뒤에는 Commit, Push, PR 계정이 모두 제대로 분리됐는지 확인해야 합니다.</p>
<pre><code class="language-bash">cd ~/workspace/order-service-lab

echo &quot;=== Commit identity ===&quot;
git config user.name
git config user.email

echo &quot;=== Git remote ===&quot;
git remote get-url origin

echo &quot;=== GitHub CLI identity ===&quot;
gh api user --jq &#39;.login&#39;

echo &quot;=== Repository permission ===&quot;
gh repo view dev-harbor/order-service-lab \
  --json nameWithOwner,viewerPermission

echo &quot;=== SSH identity ===&quot;
ssh -T git@github-dev-harbor-codex-bot</code></pre>
<p>기대하는 결과는 다음과 같습니다.</p>
<pre><code class="language-text">Commit identity
→ dev-harbor-codex-bot

Git remote
→ git@github-dev-harbor-codex-bot:dev-harbor/order-service-lab.git

GitHub CLI identity
→ dev-harbor-codex-bot

Repository permission
→ WRITE 또는 그 이상의 권한

SSH identity
→ Hi dev-harbor-codex-bot!</code></pre>
<p>실제 브랜치를 원격에 만들지 않고 Push 권한만 검사하려면 <code>--dry-run</code>을 사용할 수 있습니다.</p>
<pre><code class="language-bash">git push --dry-run \
  origin \
  HEAD:refs/heads/codex/auth-check</code></pre>
<p><code>--dry-run</code>이기 때문에 실제 원격 브랜치는 생성되지 않습니다.</p>
<hr>
<h2 id="10-commit-push-pr-생성-테스트하기">10. Commit, Push, PR 생성 테스트하기</h2>
<p>설정만 믿고 끝내기보다 실제 Draft PR 하나를 만들어 확인하는 편이 안전합니다.</p>
<p>테스트 브랜치를 만듭니다.</p>
<pre><code class="language-bash">git switch -c codex/github-account-test</code></pre>
<p>문서 파일 하나를 추가합니다.</p>
<pre><code class="language-bash">mkdir -p docs

cat &gt; docs/codex-account-test.md &lt;&lt;&#39;EOF&#39;
# Codex GitHub Account Test

Codex bot account authentication test.
EOF</code></pre>
<p>변경 내용을 확인합니다.</p>
<pre><code class="language-bash">git status --short
git diff --check</code></pre>
<p>Commit을 생성합니다.</p>
<pre><code class="language-bash">git add docs/codex-account-test.md

git commit -m &quot;chore: verify Codex bot account&quot;</code></pre>
<p>원격 브랜치로 Push합니다.</p>
<pre><code class="language-bash">git push -u origin HEAD</code></pre>
<p>Draft PR을 생성합니다.</p>
<p>기본 브랜치가 <code>main</code>인 저장소라면 다음처럼 실행합니다.</p>
<pre><code class="language-bash">gh pr create \
  --draft \
  --base main \
  --head &quot;$(git branch --show-current)&quot; \
  --title &quot;chore: verify Codex bot account&quot; \
  --body &quot;Codex 전용 GitHub 계정의 Commit, Push, PR 인증을 확인합니다.&quot;</code></pre>
<p>기본 브랜치가 <code>develop</code>이라면 <code>--base develop</code>로 바꾸면 됩니다.</p>
<p>PR 화면에서 다음 세 가지를 확인했습니다.</p>
<pre><code class="language-text">Commit 작성자
→ dev-harbor-codex-bot

PR 작성자
→ dev-harbor-codex-bot

리뷰 코멘트 작성자
→ dev-harbor</code></pre>
<p>여기까지 확인되면 계정 분리가 완료된 것입니다.</p>
<hr>
<h2 id="11-codex-작업지시문에-계정-검증-절차-넣기">11. Codex 작업지시문에 계정 검증 절차 넣기</h2>
<p>설정 후 가장 걱정됐던 건 어느 날 인증이 바뀐 상태로 Codex가 작업하는 것이었습니다.</p>
<p>그래서 Codex 작업지시문 첫 부분에 인증 확인 절차를 추가했습니다.</p>
<pre><code class="language-text">GitHub 작업 전에 아래 계정 검증을 먼저 수행한다.

1. `gh api user --jq &#39;.login&#39;`
   - 반드시 `dev-harbor-codex-bot`이어야 한다.

2. `git config user.name`
   - 반드시 `dev-harbor-codex-bot`이어야 한다.

3. `git config user.email`
   - 반드시 dev-harbor-codex-bot 계정에 등록된 이메일이어야 한다.

4. `git remote get-url origin`
   - 반드시 `github-dev-harbor-codex-bot` SSH 별칭을 사용해야 한다.

하나라도 일치하지 않으면 Commit, Push, PR 생성을 중지하고 보고한다.

허용하는 GitHub 작업:
- 작업 브랜치 생성
- Commit
- 작업 브랜치 Push
- Draft PR 생성
- PR 코멘트 작성

금지하는 GitHub 작업:
- main 직접 Push
- develop 직접 Push
- 강제 Push
- PR Merge
- `gh pr merge` 실행</code></pre>
<p>설정이 잘 되어 있다고 가정하지 않고, 매번 실제 계정을 확인하도록 한 것입니다.</p>
<p>조금 번거로워 보여도 자동화가 많아질수록 이런 안전장치가 더 중요하다고 느꼈습니다.</p>
<hr>
<h2 id="12-브랜치-보호-규칙도-함께-설정하기">12. 브랜치 보호 규칙도 함께 설정하기</h2>
<p>봇 계정에 <code>Write</code> 권한을 부여했기 때문에 저장소 자체에도 안전장치를 추가했습니다.</p>
<pre><code class="language-text">Repository
→ Settings
→ Branches 또는 Rules
→ Branch protection rule</code></pre>
<p>제가 적용한 기본 원칙은 다음과 같습니다.</p>
<pre><code class="language-text">main 직접 Push 금지
develop 직접 Push 금지
PR을 통해서만 변경
Codex는 Draft PR까지만 생성
최종 Merge는 저장소 소유 계정만 진행</code></pre>
<p>Codex에게 기능 구현과 PR 생성까지 맡기더라도 최종 Merge 권한은 분리했습니다.</p>
<p>AI가 코드를 만드는 속도와 최종 승인 권한은 별개의 문제라고 생각했기 때문입니다.</p>
<hr>
<h2 id="codex-cloud를-사용한다면-설정이-다르다">Codex Cloud를 사용한다면 설정이 다르다</h2>
<p>지금까지 설명한 방법은 Mac의 로컬 Codex 앱 또는 CLI를 기준으로 합니다.</p>
<p>Codex Cloud는 로컬의 다음 설정을 그대로 사용하지 않습니다.</p>
<pre><code class="language-text">로컬 gh 인증
로컬 SSH 키
로컬 ~/.ssh/config
저장소별 git config</code></pre>
<p>Codex Cloud에서는 별도의 GitHub 연결 화면에서 봇 계정을 연결해야 합니다.</p>
<pre><code class="language-text">1. 기존 GitHub 연결 해제
2. 브라우저에서 dev-harbor-codex-bot 로그인
3. GitHub 연결 재설정
4. dev-harbor/order-service-lab 저장소 선택
5. 임시 Draft PR로 실제 작성자 확인</code></pre>
<p>Cloud 연결 후에도 실제 PR 작성자가 원하는 계정으로 표시되는지는 테스트 PR 한 건으로 직접 확인하는 편이 안전합니다.</p>
<hr>
<h2 id="계정을-분리하고-달라진-점">계정을 분리하고 달라진 점</h2>
<p>설정을 끝내고 나니 PR 타임라인이 훨씬 자연스러워졌습니다.</p>
<p>Codex가 구현하고 PR을 생성합니다.</p>
<pre><code class="language-text">dev-harbor-codex-bot
→ 코드 수정
→ Commit
→ Push
→ Draft PR 생성</code></pre>
<p>그다음 ChatGPT와 제가 기존 소유 계정으로 리뷰합니다.</p>
<pre><code class="language-text">dev-harbor
→ PR 리뷰
→ 수정 코멘트
→ 최종 승인</code></pre>
<p>수정사항이 생기면 다시 Codex가 반영합니다.</p>
<pre><code class="language-text">dev-harbor
→ 리뷰 코멘트 작성

dev-harbor-codex-bot
→ 수정 반영
→ Commit
→ Push

dev-harbor
→ 재리뷰</code></pre>
<p>같은 사람이 운영하는 계정들이지만, 역할이 분리되니 작업 기록이 훨씬 명확해졌습니다.</p>
<p>누가 코드를 만들었고, 누가 리뷰했으며, 어떤 피드백으로 코드가 바뀌었는지가 PR 안에 그대로 남았습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>처음에는 단순히 PR 작성자와 리뷰 코멘트 작성자를 구분하고 싶었습니다.</p>
<p>그런데 설정하다 보니 GitHub에서 사용하는 계정이 생각보다 여러 곳에서 결정된다는 것도 알게 됐습니다.</p>
<pre><code class="language-text">Commit 작성자
→ git config

Push 계정
→ SSH 키와 Remote

PR 작성자
→ gh CLI 인증</code></pre>
<p>세 부분을 모두 분리하고 나니 제가 원했던 작업 흐름이 만들어졌습니다.</p>
<pre><code class="language-text">Codex는 구현한다.
ChatGPT는 리뷰한다.
마지막 승인은 내가 한다.</code></pre>
<p>혼자 진행하는 사이드 프로젝트인데 계정을 하나 분리했을 뿐인데도 작은 개발팀이 생긴 기분이었습니다ㅎㅎ</p>
<p>요즘은 기능을 하나씩 구현하는 것도 재밌지만, 역할을 나누고 작업 흐름을 정리하고 반복되는 과정을 자동화하는 재미가 더 커지고 있습니다.</p>
<p>코드를 만드는 사이드 프로젝트에서, 조금씩 <strong>개발팀을 설계하는 사이드 프로젝트</strong>로 바뀌고 있는 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Codex 한도를 아끼려다 개발 프로세스가 생겼다: ChatGPT와 Codex를 나눠 쓰는 방법]]></title>
            <link>https://velog.io/@caffeine_coder/chatgpt-codex-development-workflow</link>
            <guid>https://velog.io/@caffeine_coder/chatgpt-codex-development-workflow</guid>
            <pubDate>Mon, 13 Jul 2026 16:19:34 GMT</pubDate>
            <description><![CDATA[<h1 id="codex-한도를-아끼려다-개발-프로세스가-생겼다">Codex 한도를 아끼려다 개발 프로세스가 생겼다</h1>
<p>최근 Threads에 제가 ChatGPT와 Codex를 나눠 쓰는 방법을 올렸는데, 생각보다 많은 분이 공감해주셨습니다.</p>
<p>저만 이렇게 사용하는 줄 알았는데 비슷한 고민을 하는 분들이 꽤 많더라고요.</p>
<p>사실 처음부터 거창한 개발 프로세스를 만들려고 했던 건 아닙니다.</p>
<p>시작은 정말 단순했습니다.</p>
<blockquote>
<p><strong>Codex 사용량을 조금이라도 아끼고 싶었습니다.</strong></p>
</blockquote>
<hr>
<h2 id="plus에서는-한도가-항상-아쉬웠다">Plus에서는 한도가 항상 아쉬웠다</h2>
<p>Codex Plus를 사용할 때는 한도가 생각보다 빠르게 줄었습니다.</p>
<p>프로젝트 전체를 읽고, 코드를 수정하고, 테스트하고, 다시 리뷰하다 보면 체감상 사용량이 금방 쌓였습니다.</p>
<p>그래서 처음에는 이렇게 생각했습니다.</p>
<blockquote>
<p>설계나 대화까지 굳이 Codex에서 할 필요가 있을까?<br>실제 저장소를 봐야 하는 작업만 Codex에 맡기면 되지 않을까?</p>
</blockquote>
<p>그때부터 ChatGPT와 Codex의 역할을 나누기 시작했습니다.</p>
<p>처음 목적은 정말 하나였습니다.</p>
<p><strong>양쪽을 최대한 알뜰하게 써보자.</strong></p>
<p>그런데 이 방식을 계속 사용하다 보니, 단순한 절약법이 아니라 하나의 개발 프로세스처럼 자리 잡기 시작했습니다.</p>
<hr>
<h2 id="현재-사용하고-있는-전체-개발-흐름">현재 사용하고 있는 전체 개발 흐름</h2>
<p>제가 현재 사용하는 흐름은 대략 이렇습니다.</p>
<pre><code class="language-text">1. ChatGPT에서 요구사항과 설계 방향 정리
2. 작업지시문과 프로젝트 현황 문서를 Codex에 전달
3. Codex에서 기존 코드 검토 및 구현
4. 구현이 끝나면 Codex에서 자체 리뷰 진행
5. 자체 리뷰가 통과하면 Commit / Push / PR 생성
6. ChatGPT에서 PR 전체 리뷰
7. 수정사항이 있으면 Codex에 다시 전달
8. Codex 수정 → PR 갱신 → ChatGPT 재리뷰
9. 최종 승인 시 PR에 결과를 남기고 작업 종료</code></pre>
<p>글로 적으면 조금 복잡해 보이지만, 실제로 반복하다 보면 흐름은 꽤 단순합니다.</p>
<ul>
<li><strong>ChatGPT는 설계와 판단</strong></li>
<li><strong>Codex는 실제 프로젝트 안에서 실행</strong></li>
<li><strong>개발자는 최종 방향과 승인</strong></li>
</ul>
<p>이렇게 역할을 나눠서 사용하고 있습니다.</p>
<hr>
<h2 id="chatgpt에서-하는-일">ChatGPT에서 하는 일</h2>
<p>저는 주로 ChatGPT에서 아래 작업을 진행합니다.</p>
<ul>
<li>요구사항 정리</li>
<li>구현 범위 결정</li>
<li>여러 설계안 비교</li>
<li>작업 순서 나누기</li>
<li>Codex에 전달할 작업지시문 작성</li>
<li>PR 전체 리뷰</li>
<li>다음 작업 방향 결정</li>
</ul>
<p>기능을 바로 만들어달라고 요청하기 전에 먼저 이런 내용을 정리합니다.</p>
<pre><code class="language-text">이번 작업의 목적은 무엇인가?
어디까지 구현해야 하는가?
기존 기능에 어떤 영향을 줄 수 있는가?
테스트 통과 기준은 무엇인가?
이번 작업에서 하지 말아야 할 것은 무엇인가?</code></pre>
<p>이 단계에서 방향을 최대한 정리해두면 Codex가 실제 구현을 시작했을 때 불필요하게 넓은 범위를 수정하는 일이 줄어듭니다.</p>
<p>제가 느끼기에는 Codex에게 바로 구현부터 맡기는 것보다, ChatGPT에서 작업 범위를 먼저 정리하고 넘기는 편이 결과도 훨씬 안정적이었습니다.</p>
<hr>
<h2 id="codex에서-하는-일">Codex에서 하는 일</h2>
<p>Codex에서는 실제 저장소를 확인해야 하는 작업을 맡깁니다.</p>
<ul>
<li>프로젝트 구조 확인</li>
<li>기존 코드 분석</li>
<li>기능 구현</li>
<li>테스트 실행</li>
<li>문서 갱신</li>
<li>Commit과 Push</li>
<li>PR 생성</li>
<li>구현 코드 자체 리뷰</li>
</ul>
<p>Codex의 가장 큰 장점은 실제 프로젝트 안에서 관련 파일을 찾고, 수정하고, 테스트까지 이어서 진행할 수 있다는 점입니다.</p>
<p>반대로 설계 단계부터 모든 내용을 Codex에 맡기면 프로젝트를 읽는 과정까지 포함되어 사용량이 더 빠르게 줄 수 있습니다.</p>
<p>그래서 저는 저장소를 직접 봐야 하는 순간부터 Codex를 사용합니다.</p>
<hr>
<h2 id="프로젝트-현황-문서를-함께-관리한다">프로젝트 현황 문서를 함께 관리한다</h2>
<p>Codex를 사용하면서 중요하다고 느낀 것 중 하나가 <strong>프로젝트 현황 문서</strong>입니다.</p>
<p>대화가 길어지거나 새로운 세션에서 작업을 이어가면 이전 결정사항이 누락될 수 있습니다.</p>
<p>그래서 프로젝트 안에 현재 상황을 정리한 문서를 함께 둡니다.</p>
<p>문서 이름은 프로젝트마다 다를 수 있지만 대략 이런 내용을 관리합니다.</p>
<pre><code class="language-text">현재 프로젝트 상태
이번 작업의 목표
구현이 완료된 범위
남아 있는 작업
변경하면 안 되는 기존 정책
테스트 및 품질 기준
최근 결정사항</code></pre>
<p>예를 들면 작업을 시작할 때 Codex에 이렇게 전달합니다.</p>
<pre><code class="language-text">프로젝트 현황 문서를 먼저 확인해주세요.

이번 작업은 주문 복구 기능까지만 진행합니다.
기존 주문 생성 정책은 변경하지 않습니다.

구현 후 관련 테스트를 실행하고 결과를 정리해주세요.
Commit과 PR 생성은 자체 리뷰가 통과한 뒤 진행해주세요.</code></pre>
<p>처음에는 컨텍스트를 아끼려고 문서를 만들었습니다.</p>
<p>그런데 계속 사용하다 보니 이 문서가 Codex뿐 아니라 저에게도 꽤 유용했습니다.</p>
<p>한동안 프로젝트를 보지 않다가 다시 시작해도 현재 상황을 빠르게 파악할 수 있었고, 작업 범위가 옆으로 퍼지는 것도 막아줬습니다.</p>
<p>결국 AI를 위해 만든 문서가 프로젝트 운영 문서가 된 셈입니다.</p>
<hr>
<h2 id="구현이-끝나도-바로-pr을-만들지-않는다">구현이 끝나도 바로 PR을 만들지 않는다</h2>
<p>Codex가 구현을 완료했다고 바로 PR을 만들지는 않습니다.</p>
<p>먼저 자신이 수정한 코드를 다시 리뷰하도록 합니다.</p>
<p>보통 이런 식으로 요청합니다.</p>
<pre><code class="language-text">현재 구현 내용을 PR 리뷰어 관점에서 자체 리뷰해주세요.

다음 항목을 중점적으로 확인해주세요.

- 요구사항 누락
- 기존 기능 영향
- 예외 처리
- 테스트 부족
- 불필요하게 넓어진 변경 범위
- 성능 문제
- 보안 문제
- 문서와 실제 구현의 불일치

문제가 있다면 바로 수정하지 말고,
중요도와 근거를 먼저 정리해주세요.</code></pre>
<p>같은 Codex가 구현하고 리뷰하는 것이 의미가 있나 싶을 수도 있습니다.</p>
<p>그런데 실제로는 구현할 때와 리뷰할 때 보는 관점이 꽤 다릅니다.</p>
<p>구현할 때는 주로</p>
<blockquote>
<p>어떻게 동작하게 만들 것인가?</p>
</blockquote>
<p>를 중심으로 봅니다.</p>
<p>반면 리뷰를 요청하면</p>
<blockquote>
<p>기존 기능에 영향을 주지는 않는가?<br>테스트가 빠지지는 않았는가?<br>불필요한 변경이 포함되지는 않았는가?</p>
</blockquote>
<p>같은 부분을 다시 확인합니다.</p>
<p>물론 자체 리뷰를 통과했다고 완전히 믿고 끝내지는 않습니다.</p>
<p>다만 PR을 만들기 전에 한 번 걸러주는 역할은 충분히 합니다.</p>
<hr>
<h2 id="pr은-다시-chatgpt에서-리뷰한다">PR은 다시 ChatGPT에서 리뷰한다</h2>
<p>Codex 자체 리뷰가 끝나면 Commit, Push, PR 생성을 요청합니다.</p>
<p>그리고 만들어진 PR은 ChatGPT에서 다시 리뷰합니다.</p>
<p>이 단계에서는 단순히 코드 한 줄씩만 보는 게 아니라 전체 방향을 확인합니다.</p>
<pre><code class="language-text">요구사항을 제대로 해결했는가?
변경 범위가 과도하지 않은가?
기존 정책을 깨뜨리지는 않았는가?
테스트가 실제 위험을 충분히 검증하는가?
이번 PR을 Merge해도 되는가?</code></pre>
<p>수정사항이 나오면 리뷰 내용을 다시 Codex에 전달합니다.</p>
<pre><code class="language-text">PR 리뷰 결과를 확인해주세요.

각 리뷰 항목이 실제 문제인지 먼저 검토하고,
타당한 항목만 최소 범위로 수정해주세요.

수정 후 테스트를 실행하고
기존 PR에 반영해주세요.</code></pre>
<p>Codex가 수정하고 PR을 갱신하면 다시 ChatGPT에서 리뷰합니다.</p>
<p>이 과정을 반복해서 문제가 모두 해결되면 최종 결과를 PR에 남기고 작업을 종료합니다.</p>
<hr>
<h2 id="처음에는-절약이-목적이었는데-품질-관리가-됐다">처음에는 절약이 목적이었는데 품질 관리가 됐다</h2>
<p>처음에는 Codex 한도를 아끼기 위해 역할을 나눴습니다.</p>
<p>그런데 계속 사용하다 보니 오히려 다른 장점이 더 크게 느껴졌습니다.</p>
<p>설계와 구현을 분리하니 작업 범위가 선명해졌고, 구현과 리뷰를 분리하니 놓치는 부분이 줄었습니다.</p>
<p>또 모든 결정을 AI에게 맡기지 않고 각 단계에서 제가 방향을 확인하게 됐습니다.</p>
<p>결국 이렇게 정리할 수 있습니다.</p>
<pre><code class="language-text">ChatGPT: 생각하고 정리하는 역할
Codex: 프로젝트 안에서 실행하는 역할
개발자: 최종 방향과 승인 책임</code></pre>
<p>모든 작업을 하나의 도구에 몰아넣는 것보다 역할을 나눠 사용하는 방식이 저에게는 더 잘 맞았습니다.</p>
<hr>
<h2 id="pro-5x로-올리고-나서도-같은-방식을-유지하는-이유">Pro 5x로 올리고 나서도 같은 방식을 유지하는 이유</h2>
<p>최근에는 Codex 요금제를 Plus에서 Pro 5x로 변경했습니다.</p>
<p>Plus에서는 한도가 아쉬워서 컨텍스트를 최대한 아끼는 방향으로 작업했습니다.</p>
<p>그 습관을 그대로 유지한 채 한도가 늘어나니 이제는 오히려 사용량이 남습니다.</p>
<p>처음에는 한도를 아끼기 위한 방식이었지만 지금은 굳이 바꿀 필요를 느끼지 못하고 있습니다.</p>
<p>역할을 나눠두니 작업 흐름도 깔끔하고, 문제 발생 시 어느 단계에서 잘못됐는지도 찾기 쉽기 때문입니다.</p>
<p>다만 한도가 넉넉해지면서 생긴 부작용도 있습니다.</p>
<p>예전에는 문서 하나를 읽힐 때도</p>
<blockquote>
<p>이 문서까지 정말 필요한가?</p>
</blockquote>
<p>를 고민했는데, 요즘은 일단 시켜보는 경우가 많아졌습니다ㅎㅎ</p>
<p>한도가 여유로워진 만큼 컨텍스트 관리가 조금 느슨해진 건 사실입니다.</p>
<p>Plus에서 만들어둔 절약 습관 덕분인지 Pro 5x에서는 아직 꽤 여유롭게 사용하고 있습니다.</p>
<hr>
<h2 id="다음-목표는-반복-작업-자동화">다음 목표는 반복 작업 자동화</h2>
<p>이제 다음으로 해보고 싶은 건 자동화입니다.</p>
<p>매 PR마다 반복하는 작업들이 있습니다.</p>
<ul>
<li>구현 완료 후 자체 리뷰</li>
<li>테스트 결과 정리</li>
<li>PR 생성</li>
<li>리뷰 결과 전달</li>
<li>수정 후 재검토</li>
<li>프로젝트 현황 문서 갱신</li>
<li>최종 PR 코멘트 작성</li>
</ul>
<p>이런 작업들을 조금씩 자동화해보고 싶습니다.</p>
<p>예를 들면 Codex가 구현을 끝내면 자동으로 자체 리뷰를 수행하고, 품질 기준을 통과한 경우에만 PR을 만들게 하는 방식입니다.</p>
<p>PR 리뷰 결과가 등록되면 수정사항을 다시 분류하고, 필요한 변경만 반영하게 만들 수도 있을 것 같습니다.</p>
<p>다만 열심히 자동화를 세팅해놓고 실행했다가 주간 한도가 아이스크림처럼 녹아버릴까 봐 아직은 조금 걱정됩니다…ㅎㅎ</p>
<p>한도가 많아지니 이제는 아끼는 방법이 아니라 어디까지 자동화할 수 있을지를 고민하게 되네요.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>처음에는 Codex 사용량을 아끼려고 ChatGPT와 역할을 나눴습니다.</p>
<p>그런데 계속 사용하다 보니 이 방식이 단순한 절약법이 아니라, 설계와 구현, 리뷰를 분리하는 하나의 개발 프로세스가 됐습니다.</p>
<p>물론 이 방식이 모든 개발자에게 정답은 아닙니다.</p>
<p>ChatGPT에서도 코드를 분석할 수 있고, Codex에서도 설계와 리뷰를 진행할 수 있습니다.</p>
<p>다만 저처럼 사이드 프로젝트를 혼자 진행하면서 AI를 적극적으로 사용한다면, 도구마다 역할을 나눠보는 것도 꽤 괜찮은 방법이라고 생각합니다.</p>
<p>앞으로는 이 반복 과정을 조금씩 자동화해보려고 합니다.</p>
<p>어디까지 자동화할 수 있을지, 실제로 생산성이 얼마나 달라질지, 사용량은 얼마나 줄거나 늘어날지 직접 해보면서 기록해볼 생각입니다.</p>
<p>그리고 첫 번째 목표도 하나 정했습니다.</p>
<blockquote>
<p><strong>더도 말고 Codex 구독료부터 벌어보기.</strong></p>
</blockquote>
<p>무엇으로 수익화할지는 아직 정하지 못했지만, 이것저것 시도하는 과정도 함께 기록해보겠습니다ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ChatGPT, Codex 웹, 데스크탑 앱, CLI, IDE 확장은 뭐가 다를까? ]]></title>
            <link>https://velog.io/@caffeine_coder/chatgpt-codex-web-app-cli-ide-difference</link>
            <guid>https://velog.io/@caffeine_coder/chatgpt-codex-web-app-cli-ide-difference</guid>
            <pubDate>Sat, 27 Jun 2026 17:01:17 GMT</pubDate>
            <description><![CDATA[<p>AI로 개발을 시작하다 보면 생각보다 헷갈리는 지점이 있다.</p>
<p>ChatGPT에 코드를 물어보는 것과<br>Codex를 사용하는 것은 무엇이 다를까?</p>
<p>또 Codex도 사용 방식이 여러 가지다.</p>
<ul>
<li>ChatGPT</li>
<li>Codex 웹</li>
<li>Codex 데스크탑 앱</li>
<li>Codex CLI</li>
<li>Codex IDE 확장</li>
</ul>
<p>처음 접하는 사람 입장에서는 전부 비슷해 보일 수 있다.</p>
<p>모두 “AI로 개발을 도와주는 도구”처럼 보이기 때문이다.</p>
<p>하지만 실제로 써보면 각각의 역할이 조금씩 다르다.<br>이번 글에서는 각 방식의 차이를 입문자 기준으로 정리해보려고 한다.</p>
<hr>
<h2 id="1-chatgpt">1. ChatGPT</h2>
<p>가장 익숙한 방식은 ChatGPT에 직접 질문하는 것이다.</p>
<p>코드를 붙여넣고 물어보거나,<br>에러 메시지를 설명해달라고 하거나,<br>구현 방향을 상담할 때 사용하기 좋다.</p>
<p>예를 들면 이런 상황이다.</p>
<ul>
<li>이 코드가 무슨 뜻인지 알고 싶을 때</li>
<li>에러 메시지를 해석하고 싶을 때</li>
<li>함수 하나를 리팩토링하고 싶을 때</li>
<li>라이브러리 사용법을 이해하고 싶을 때</li>
<li>구현 전에 구조를 먼저 상담하고 싶을 때</li>
</ul>
<p>ChatGPT는 대화형 조언자에 가깝다.</p>
<p>내가 필요한 정보를 직접 제공하면,<br>그 정보를 바탕으로 설명하거나 코드를 제안해준다.</p>
<p>다만 일반적인 사용 방식에서는<br>내 로컬 프로젝트 전체를 직접 읽고, 파일을 수정하고, 테스트까지 실행하는 도구라고 보기는 어렵다.</p>
<p>즉, ChatGPT는 이런 상황에 잘 맞는다.</p>
<pre><code class="language-text">“이 개념을 설명해줘”
“이 에러가 왜 나는지 알려줘”
“이 코드를 더 깔끔하게 바꿀 수 있을까?”
“이 기능을 만들려면 어떤 구조가 좋을까?”</code></pre>
<p>개발 초반의 학습, 설계, 코드 이해에는 매우 유용하다.</p>
<hr>
<h2 id="2-codex-웹">2. Codex 웹</h2>
<p>Codex 웹은 브라우저에서 Codex를 사용하는 방식이다.</p>
<p>GitHub 저장소를 연결해두고,<br>Codex에게 작업을 맡기거나<br>변경사항을 PR 형태로 검토할 때 유용하다.</p>
<p>ChatGPT가 “질문하고 답변받는 도구”에 가깝다면,<br>Codex 웹은 “작업을 맡기는 도구”에 더 가깝다.</p>
<p>예를 들면 이런 상황이다.</p>
<ul>
<li>GitHub 이슈 하나를 맡기고 싶을 때</li>
<li>버그 수정을 별도 작업으로 돌리고 싶을 때</li>
<li>여러 작업을 병렬로 진행하고 싶을 때</li>
<li>결과물을 PR 형태로 검토하고 싶을 때</li>
<li>내 로컬 환경을 계속 켜두지 않고 작업을 맡기고 싶을 때</li>
</ul>
<p>Codex 웹은 클라우드에서 작업을 진행하는 흐름에 가깝다.</p>
<p>그래서 로컬 터미널에서 직접 명령어를 치는 방식보다는<br>“저장소를 보고, 작업을 수행하고, 결과를 검토하는 흐름”에 어울린다.</p>
<p>이런 사람에게 잘 맞는다.</p>
<pre><code class="language-text">“이슈 단위로 일을 맡기고 싶다”
“작업 결과를 PR처럼 보고 싶다”
“여러 작업을 동시에 진행하고 싶다”
“내 컴퓨터가 아니라 클라우드에서 돌리고 싶다”</code></pre>
<hr>
<h2 id="3-codex-데스크탑-앱">3. Codex 데스크탑 앱</h2>
<p>Codex 데스크탑 앱은 로컬 프로젝트를 열어두고<br>Codex와 대화하면서 작업하기 좋은 방식이다.</p>
<p>프로젝트 폴더를 선택하면<br>Codex가 해당 프로젝트를 기준으로 파일을 읽고,<br>필요한 경우 수정하고,<br>명령어도 실행할 수 있다.</p>
<p>개인적으로는 이 방식이 “AI 개발 작업실”에 가깝다고 느껴진다.</p>
<p>여러 작업을 스레드처럼 나눠 관리할 수 있고,<br>변경사항을 확인하면서 대화하기도 좋다.</p>
<p>예를 들면 이런 상황에 잘 맞는다.</p>
<ul>
<li>기능 하나를 끝까지 구현하고 싶을 때</li>
<li>기존 코드베이스를 분석하고 싶을 때</li>
<li>여러 작업을 나눠서 동시에 진행하고 싶을 때</li>
<li>수정된 파일과 실행 결과를 보면서 작업하고 싶을 때</li>
<li>코드 작성, 테스트, 검토를 한 흐름에서 처리하고 싶을 때</li>
</ul>
<p>ChatGPT가 “질문 중심”이라면,<br>Codex 데스크탑 앱은 “프로젝트 작업 중심”에 가깝다.</p>
<pre><code class="language-text">“이 프로젝트 구조를 파악해줘”
“이 기능을 실제 코드에 반영해줘”
“테스트까지 돌려보고 문제를 고쳐줘”
“이 변경사항을 검토해줘”</code></pre>
<p>이런 식의 작업에 적합하다.</p>
<hr>
<h2 id="4-codex-cli">4. Codex CLI</h2>
<p>Codex CLI는 터미널에서 Codex를 실행하는 방식이다.</p>
<pre><code class="language-bash">codex</code></pre>
<p>이렇게 실행하면 현재 디렉토리를 기준으로<br>Codex가 프로젝트를 읽고,<br>파일을 수정하고,<br>테스트나 빌드 명령을 실행할 수 있다.</p>
<p>터미널 중심으로 개발하는 사람에게 잘 맞는다.</p>
<p>예를 들면 이런 상황이다.</p>
<ul>
<li>터미널에서 바로 Codex를 실행하고 싶을 때</li>
<li>코드 수정과 테스트를 빠르게 반복하고 싶을 때</li>
<li>tmux와 함께 장시간 작업을 유지하고 싶을 때</li>
<li>스크립트나 자동화 흐름에 Codex를 붙이고 싶을 때</li>
<li>GUI보다 터미널 환경이 더 편한 사람</li>
</ul>
<p>Codex CLI는 익숙해지면 강력하다.</p>
<p>하지만 처음에는 몇 가지 개념이 같이 필요하다.</p>
<ul>
<li>현재 작업 디렉토리</li>
<li>터미널 명령어</li>
<li>파일 권한</li>
<li>승인 모드</li>
<li>실행되는 명령어의 범위</li>
<li>tmux 같은 세션 관리 도구</li>
</ul>
<p>그래서 터미널이 익숙하지 않은 사람에게는 처음 진입 장벽이 있을 수 있다.</p>
<p>반대로 터미널이 익숙한 사람에게는<br>가장 빠르고 직접적인 사용 방식이 될 수 있다.</p>
<pre><code class="language-text">“현재 프로젝트에서 바로 실행하고 싶다”
“명령어 실행 결과를 보면서 고치고 싶다”
“tmux로 세션을 유지하면서 작업시키고 싶다”
“반복 작업을 CLI 흐름에 넣고 싶다”</code></pre>
<p>이런 경우에 특히 잘 맞는다.</p>
<hr>
<h2 id="5-codex-ide-확장">5. Codex IDE 확장</h2>
<p>Codex IDE 확장은 VS Code, Cursor 같은 IDE 안에서 Codex를 사용하는 방식이다.</p>
<p>에디터 안에서 열려 있는 파일,<br>선택한 코드,<br>현재 보고 있는 문맥을 활용하기 좋다.</p>
<p>개발자가 이미 IDE에서 코드를 보고 있다면<br>가장 자연스럽게 느껴질 수 있는 방식이다.</p>
<p>예를 들면 이런 상황이다.</p>
<ul>
<li>선택한 코드만 설명받고 싶을 때</li>
<li>현재 파일을 기준으로 수정하고 싶을 때</li>
<li>TODO 주석을 구현하고 싶을 때</li>
<li>에디터를 벗어나지 않고 AI를 쓰고 싶을 때</li>
<li>작은 단위의 수정과 질문을 빠르게 반복하고 싶을 때</li>
</ul>
<p>IDE 확장의 장점은 문맥 전환이 적다는 것이다.</p>
<p>코드를 보다가 바로 질문하고,<br>필요하면 수정 요청을 하고,<br>결과를 에디터 안에서 확인할 수 있다.</p>
<pre><code class="language-text">“이 파일 기준으로 고쳐줘”
“선택한 코드 설명해줘”
“이 TODO 구현해줘”
“현재 컴포넌트를 리팩토링해줘”</code></pre>
<p>이런 작업에는 IDE 확장이 편하다.</p>
<hr>
<h2 id="한눈에-정리하기">한눈에 정리하기</h2>
<p>각 방식을 간단히 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>어울리는 사용 상황</th>
</tr>
</thead>
<tbody><tr>
<td>ChatGPT</td>
<td>코드 질문, 개념 설명, 에러 해석, 설계 상담</td>
</tr>
<tr>
<td>Codex 웹</td>
<td>GitHub 기반 작업 위임, PR 흐름, 클라우드 작업</td>
</tr>
<tr>
<td>Codex 데스크탑 앱</td>
<td>로컬 프로젝트 기반의 본격적인 작업 관리</td>
</tr>
<tr>
<td>Codex CLI</td>
<td>터미널 중심 작업, 명령 실행, 자동화, tmux 활용</td>
</tr>
<tr>
<td>Codex IDE 확장</td>
<td>에디터 안에서 현재 코드 문맥을 보며 작업</td>
</tr>
</tbody></table>
<p>중요한 건 어떤 도구가 무조건 더 좋다는 것이 아니다.</p>
<p>각각 잘 맞는 작업 흐름이 다르다.</p>
<hr>
<h2 id="처음-시작한다면-어떤-걸-쓰면-좋을까">처음 시작한다면 어떤 걸 쓰면 좋을까?</h2>
<p>처음부터 모든 방식을 사용할 필요는 없다.</p>
<p>목적에 따라 하나씩 시작하면 된다.</p>
<h3 id="코드-질문이-많다면">코드 질문이 많다면</h3>
<p>ChatGPT부터 시작하는 것이 좋다.</p>
<p>개념 설명, 에러 해석, 간단한 코드 수정 방향을 잡기에 좋다.</p>
<h3 id="실제-프로젝트를-맡기고-싶다면">실제 프로젝트를 맡기고 싶다면</h3>
<p>Codex 데스크탑 앱이나 IDE 확장이 좋다.</p>
<p>내 프로젝트 파일을 기준으로<br>실제 수정 작업을 진행하기 좋기 때문이다.</p>
<h3 id="터미널이-익숙하다면">터미널이 익숙하다면</h3>
<p>Codex CLI를 써보는 것도 좋다.</p>
<p>명령어 실행, 테스트, 빌드, 자동화 흐름과 잘 맞는다.</p>
<h3 id="github-이슈나-pr-흐름으로-관리하고-싶다면">GitHub 이슈나 PR 흐름으로 관리하고 싶다면</h3>
<p>Codex 웹이 잘 맞는다.</p>
<p>작업을 클라우드에 맡기고 결과를 검토하는 방식으로 사용할 수 있다.</p>
<hr>
<h2 id="개인적인-추천-흐름">개인적인 추천 흐름</h2>
<p>입문자라면 아래 순서가 무난하다고 생각한다.</p>
<pre><code class="language-text">1. ChatGPT로 개념과 코드 질문에 익숙해지기
2. IDE 확장으로 현재 파일 기준 작업을 해보기
3. Codex 데스크탑 앱으로 프로젝트 단위 작업을 맡겨보기
4. 터미널에 익숙해지면 Codex CLI를 사용해보기
5. 작업이 많아지면 Codex 웹이나 클라우드 흐름을 활용하기</code></pre>
<p>처음부터 CLI, tmux, 자동화까지 한 번에 익히려고 하면 부담이 클 수 있다.</p>
<p>먼저 “AI에게 어떤 일을 맡길 수 있는지”를 경험하고,<br>그 다음 내 작업 방식에 맞는 도구를 선택하면 된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>ChatGPT, Codex 웹, Codex 데스크탑 앱, Codex CLI, Codex IDE 확장은<br>모두 AI 개발을 도와주는 도구이지만 사용 방식은 다르다.</p>
<p>ChatGPT는 질문과 설명에 강하고,<br>Codex 웹은 작업 위임과 PR 흐름에 어울리고,<br>Codex 데스크탑 앱은 로컬 프로젝트 작업 관리에 좋고,<br>Codex CLI는 터미널 중심의 직접적인 작업에 강하고,<br>Codex IDE 확장은 에디터 안에서 현재 코드 문맥을 활용하기 좋다.</p>
<p>결국 중요한 건 하나다.</p>
<pre><code class="language-text">내가 어떤 방식으로 개발하고 있는가?</code></pre>
<p>그 흐름에 맞는 도구를 고르면 된다.</p>
<p>AI 개발 도구가 많아질수록<br>도구 자체보다 “언제 무엇을 쓰면 좋은지”를 아는 것이 더 중요해지는 것 같다.
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI로 만든 웹앱, 외부에서 어떻게 보이는지 무료로 확인해드립니다]]></title>
            <link>https://velog.io/@caffeine_coder/free-ai-webapp-security-check</link>
            <guid>https://velog.io/@caffeine_coder/free-ai-webapp-security-check</guid>
            <pubDate>Sat, 27 Jun 2026 01:56:14 GMT</pubDate>
            <description><![CDATA[<h1 id="ai로-만든-웹앱-외부에서-어떻게-보이는지-무료로-확인해드립니다">AI로 만든 웹앱, 외부에서 어떻게 보이는지 무료로 확인해드립니다</h1>
<p>AI 개발 도구를 사용하면 웹앱을 만드는 속도가 정말 빨라졌습니다.</p>
<p>기획만 어느 정도 명확하면 로그인, 예약폼, 문의폼, 관리자 페이지, 결제 페이지까지도 예전보다 훨씬 빠르게 만들 수 있습니다.</p>
<p>그런데 배포까지 끝내고 나면 질문이 하나 남습니다.</p>
<pre><code class="language-text">내 서비스는 외부에서 어떻게 보이고 있을까?</code></pre>
<p>기능이 잘 동작하는 것과<br>인터넷에 안전하게 공개되어 있는 것은 조금 다른 문제입니다.</p>
<p>예를 들어 이런 항목들은 소스코드나 관리자 계정 없이도 외부에서 먼저 확인할 수 있습니다.</p>
<ul>
<li>HTTPS가 정상 적용되어 있는지</li>
<li>인증서 오류가 없는지</li>
<li>HTTP 접속 시 HTTPS로 이동하는지</li>
<li>기본 보안 헤더가 설정되어 있는지</li>
<li>공개되면 안 되는 파일이나 경로가 열려 있지 않은지</li>
<li>관리자나 테스트 경로가 인증 없이 열리지 않는지</li>
<li><code>robots.txt</code>, <code>sitemap.xml</code>에 의도치 않은 URL이 들어가 있지 않은지</li>
<li>브라우저 개발자도구에서 민감한 값이 보이지 않는지</li>
</ul>
<p>물론 이것만으로 완전한 보안 진단이 되지는 않습니다.</p>
<p>DB 권한 설정, 인증/인가 로직, 결제 검증, 서버 내부 구현, 관리자 기능 보호 같은 부분은<br>소스코드와 설정을 함께 봐야 더 정확하게 판단할 수 있습니다.</p>
<p>하지만 배포 직후에 외부에서 보이는 기본 상태를 확인하는 것만으로도<br>생각보다 많은 실수를 먼저 발견할 수 있습니다.</p>
<hr>
<h2 id="무료-기본-외부-보안-점검을-받아봅니다">무료 기본 외부 보안 점검을 받아봅니다</h2>
<p>현재 Vercel, Supabase, Firebase 등으로 만든 웹앱을 대상으로<br>무료 기본 외부 보안 점검을 소수로 받아보고 있습니다.</p>
<p>대상은 아래와 같습니다.</p>
<ul>
<li>AI 개발 도구로 웹앱을 만들어 배포한 분</li>
<li>Vercel, Netlify, Cloudflare Pages 등에 서비스를 배포한 분</li>
<li>Supabase나 Firebase를 사용해 MVP를 만든 분</li>
<li>문의폼, 예약폼, 회원가입, 로그인 기능이 있는 서비스를 만든 분</li>
<li>배포는 했지만 외부에서 어떻게 보이는지 확인해보고 싶은 분</li>
</ul>
<hr>
<h2 id="점검-범위">점검 범위</h2>
<p>이번 무료 점검에서는 공개된 URL을 기준으로<br>외부에서 확인 가능한 항목만 봅니다.</p>
<p>확인하는 항목은 다음과 같습니다.</p>
<ul>
<li>HTTPS 및 인증서 상태</li>
<li>HTTP에서 HTTPS로 이동되는지 여부</li>
<li>기본 보안 헤더 설정 여부</li>
<li>공개 파일 및 흔한 노출 경로 확인</li>
<li>관리자/테스트 경로 노출 여부</li>
<li><code>robots.txt</code>, <code>sitemap.xml</code> 확인</li>
<li>브라우저 기준 민감정보 노출 가능성</li>
<li>간단한 외부 노출 요약</li>
</ul>
<p>소스코드, 관리자 계정, 비밀키는 요청하지 않습니다.</p>
<hr>
<h2 id="점검-후-제공되는-것">점검 후 제공되는 것</h2>
<p>점검 후에는 간단한 요약 리포트를 전달드립니다.</p>
<p>리포트에는 아래 내용이 포함됩니다.</p>
<ul>
<li>전체 기본 점검 결과</li>
<li>확인된 주요 항목</li>
<li>우선적으로 확인하면 좋은 부분</li>
<li>개선이 필요한 경우 간단한 조치 방향</li>
</ul>
<p>이번 무료 점검은 정식 보안 진단이나 모의해킹이 아닙니다.<br>서비스 외부에서 보이는 기본 상태를 확인하는 가벼운 점검에 가깝습니다.</p>
<hr>
<h2 id="신청-전-확인해주세요">신청 전 확인해주세요</h2>
<p>반드시 본인이 운영하거나 점검 권한이 있는 서비스만 신청해주세요.</p>
<p>다른 사람의 서비스, 회사 내부 서비스, 허가받지 않은 도메인은 점검하지 않습니다.</p>
<p>또한 이번 점검은 공격성 테스트를 하지 않습니다.<br>과도한 요청, 취약점 악용, 로그인 우회 시도, 데이터 접근 시도는 하지 않습니다.</p>
<p>공개된 URL 기준으로 안전하게 확인 가능한 항목만 봅니다.</p>
<hr>
<h2 id="신청-방법">신청 방법</h2>
<p>아래 페이지에서 신청할 수 있습니다.</p>
<p><a href="https://ai-web-security-check.vercel.app/">무료 기본 외부 보안 점검 신청하기</a></p>
<p>우선은 소수만 받아보면서<br>점검 범위와 리포트 형식을 다듬어보려고 합니다.</p>
<p>AI로 만든 웹앱을 배포했는데<br>내 서비스가 외부에서 어떻게 보이는지 궁금했다면 한 번 신청해보셔도 좋습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vercel로 배포한 웹앱, 브라우저에서 바로 확인해볼 수 있는 7가지]]></title>
            <link>https://velog.io/@caffeine_coder/Vercel%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%9C-%EC%9B%B9%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90%EC%84%9C-%EB%B0%94%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B3%BC-%EC%88%98-%EC%9E%88%EB%8A%94-7%EA%B0%80%EC%A7%80</link>
            <guid>https://velog.io/@caffeine_coder/Vercel%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%9C-%EC%9B%B9%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90%EC%84%9C-%EB%B0%94%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B3%BC-%EC%88%98-%EC%9E%88%EB%8A%94-7%EA%B0%80%EC%A7%80</guid>
            <pubDate>Sat, 27 Jun 2026 01:46:29 GMT</pubDate>
            <description><![CDATA[<h1 id="vercel로-배포한-웹앱-브라우저에서-바로-확인해볼-수-있는-7가지">Vercel로 배포한 웹앱, 브라우저에서 바로 확인해볼 수 있는 7가지</h1>
<p>Vercel로 웹앱을 배포하면 생각보다 빠르게 인터넷에 서비스를 공개할 수 있다.</p>
<p>특히 AI 개발 도구를 사용하면 기획부터 구현, 배포까지 걸리는 시간이 훨씬 짧아진다.<br>그런데 배포가 쉬워진 만큼, 배포 후 확인해야 할 기본 항목을 놓치기도 쉽다.</p>
<p>이번 글에서는 소스코드나 관리자 계정 없이도<br>브라우저에서 직접 확인해볼 수 있는 항목들을 정리해보려고 한다.</p>
<p>전문 보안 진단은 아니지만,<br>배포 직후 기본적인 실수를 발견하는 데는 꽤 도움이 된다.</p>
<hr>
<h2 id="1-http로-접속하면-https로-이동하는가">1. http로 접속하면 https로 이동하는가</h2>
<p>먼저 브라우저 주소창에 아래처럼 직접 입력해본다.</p>
<pre><code class="language-text">http://내도메인.com</code></pre>
<p>정상이라면 보통 아래처럼 <code>https</code> 주소로 이동해야 한다.</p>
<pre><code class="language-text">https://내도메인.com</code></pre>
<p>HTTPS는 브라우저와 서버 사이의 통신을 암호화하는 기본적인 보호 장치다.</p>
<p>Vercel은 도메인이 프로젝트에 연결되면 SSL 인증서 발급을 자동으로 시도하지만,<br>커스텀 도메인을 연결한 경우에는 DNS 설정과 인증서 적용 상태를 직접 확인해보는 것이 좋다.</p>
<p>확인할 것들은 다음과 같다.</p>
<ul>
<li><code>http://</code>로 접속했을 때 <code>https://</code>로 이동하는가</li>
<li>브라우저 주소창에 자물쇠 표시가 정상적으로 보이는가</li>
<li>인증서 오류나 경고가 뜨지 않는가</li>
</ul>
<p>HTTPS가 적용되어 있다고 해서 서비스 전체가 안전한 것은 아니다.<br>하지만 HTTPS가 제대로 적용되지 않은 상태라면 기본 출발선부터 점검이 필요하다.</p>
<hr>
<h2 id="2-인증서-오류가-뜨지-않는가">2. 인증서 오류가 뜨지 않는가</h2>
<p>HTTPS로 접속했을 때 브라우저가 인증서 경고를 띄우지 않는지 확인한다.</p>
<p>예를 들면 이런 상황이다.</p>
<ul>
<li>인증서가 만료됨</li>
<li>접속한 도메인과 인증서의 도메인이 다름</li>
<li>인증서 발급이나 연결이 아직 완료되지 않음</li>
<li>DNS 설정이 잘못되어 인증서 검증이 실패함</li>
</ul>
<p>Vercel 기본 도메인을 사용할 때는 대부분 자동으로 처리되지만,<br>내가 구매한 커스텀 도메인을 연결했다면 배포 직후 반드시 확인하는 것이 좋다.</p>
<p>특히 도메인을 막 연결한 직후에는 DNS 전파 시간이 필요할 수 있다.<br>이때는 잠시 기다린 뒤 다시 확인해야 한다.</p>
<hr>
<h2 id="3-없는-페이지에서-내부-에러가-노출되지-않는가">3. 없는 페이지에서 내부 에러가 노출되지 않는가</h2>
<p>일부러 존재하지 않는 주소로 접속해본다.</p>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">https://내도메인.com/asdf-test-404</code></pre>
<p>이때 사용자에게는 깔끔한 404 페이지가 보이는 것이 좋다.</p>
<p>반대로 아래와 같은 정보가 노출된다면 좋지 않다.</p>
<ul>
<li>서버 내부 경로</li>
<li>프레임워크 디버그 정보</li>
<li>스택 트레이스</li>
<li>데이터베이스 관련 오류</li>
<li>환경변수 이름</li>
<li>내부 API 주소</li>
</ul>
<p>없는 페이지에 접속했을 때도 서비스의 일부가 노출될 수 있다.<br>그래서 정상 동작하는 화면뿐 아니라, 실패하는 화면도 확인해야 한다.</p>
<hr>
<h2 id="4-admin-dashboard-같은-경로가-그냥-열리지-않는가">4. /admin, /dashboard 같은 경로가 그냥 열리지 않는가</h2>
<p>브라우저 주소창에 아래와 같은 경로를 직접 입력해본다.</p>
<pre><code class="language-text">/admin
/dashboard
/manager
/test
/dev
/cms</code></pre>
<p>관리자 페이지가 존재하지 않는다면 404가 나와야 한다.<br>관리자 페이지가 존재한다면 로그인이나 권한 확인이 먼저 나와야 한다.</p>
<p>문제는 이런 경우다.</p>
<ul>
<li>로그인 없이 관리자 화면이 열림</li>
<li>테스트 페이지가 그대로 남아 있음</li>
<li>내부 데이터가 보이는 대시보드가 열림</li>
<li>개발 중 만든 임시 페이지가 배포되어 있음</li>
</ul>
<p>“주소를 모르면 괜찮겠지”는 보안 장치가 아니다.</p>
<p>웹에 공개된 서비스라면 누군가 직접 경로를 입력해볼 수 있다.<br>관리자나 내부용 경로는 반드시 인증과 권한 확인이 필요하다.</p>
<hr>
<h2 id="5-robotstxt와-sitemapxml이-의도한-대로-보이는가">5. robots.txt와 sitemap.xml이 의도한 대로 보이는가</h2>
<p>아래 주소도 확인해보는 것이 좋다.</p>
<pre><code class="language-text">/robots.txt
/sitemap.xml</code></pre>
<p><code>robots.txt</code>는 검색엔진 크롤러에게 어떤 URL을 접근할 수 있는지 알려주는 파일이다.<br><code>sitemap.xml</code>은 검색엔진에게 사이트의 주요 페이지 정보를 알려주는 파일이다.</p>
<p>여기서 확인할 점은 두 가지다.</p>
<p>첫째, 검색되어야 할 페이지가 빠져 있지 않은가.<br>둘째, 검색되면 안 되는 페이지나 테스트 페이지가 들어가 있지 않은가.</p>
<p>예를 들어 이런 URL이 <code>sitemap.xml</code>에 들어가 있다면 확인이 필요하다.</p>
<pre><code class="language-text">/test
/dev
/admin
/staging
/sample-user</code></pre>
<p>주의할 점도 있다.</p>
<p><code>robots.txt</code>는 보안 기능이 아니다.<br>Google 공식 문서에서도 <code>robots.txt</code>는 페이지를 숨기는 보안 수단이 아니라고 설명한다.</p>
<p>즉, 중요한 페이지를 숨기고 싶다면 <code>robots.txt</code>에만 의존하면 안 된다.<br>인증, 권한 확인, 비공개 처리 같은 별도의 보호가 필요하다.</p>
<hr>
<h2 id="6-개발용-문구나-테스트-데이터가-남아-있지-않은가">6. 개발용 문구나 테스트 데이터가 남아 있지 않은가</h2>
<p>배포된 화면을 실제 사용자 입장에서 살펴본다.</p>
<p>생각보다 이런 것들이 자주 남아 있다.</p>
<ul>
<li>테스트 계정</li>
<li>더미 데이터</li>
<li>임시 버튼</li>
<li>개발 중 표시</li>
<li>관리자용 안내 문구</li>
<li>console.log용 문구</li>
<li>샘플 이미지</li>
<li>Lorem ipsum 텍스트</li>
</ul>
<p>이런 항목은 직접적인 보안 취약점이 아닐 수도 있다.</p>
<p>하지만 사용자가 보기에는 서비스가 덜 준비된 것처럼 보일 수 있다.<br>또한 내부 구조나 운영 방식에 대한 힌트를 줄 수도 있다.</p>
<p>AI로 빠르게 만든 MVP일수록 이런 흔적이 남기 쉽다.<br>배포 직후에는 기능 테스트뿐 아니라 화면 문구와 데이터도 함께 확인하는 것이 좋다.</p>
<hr>
<h2 id="7-브라우저-개발자도구에서-민감한-값이-보이지-않는가">7. 브라우저 개발자도구에서 민감한 값이 보이지 않는가</h2>
<p>브라우저 개발자도구를 열고 <code>Network</code>, <code>Sources</code>, <code>Console</code> 탭을 확인해본다.</p>
<p>여기서 아래와 같은 값이 보이면 위험하다.</p>
<ul>
<li>DB 비밀번호</li>
<li>관리자 토큰</li>
<li>secret key</li>
<li>Supabase service_role key</li>
<li>Firebase Admin SDK 키</li>
<li>JWT secret</li>
<li>개인 정보가 담긴 API 응답</li>
<li>내부 관리자 API 주소</li>
</ul>
<p>프론트엔드로 전달된 값은 사용자가 볼 수 있다고 생각하는 편이 안전하다.</p>
<p>환경변수에 넣었다고 해서 무조건 안전한 것도 아니다.<br>Vercel 환경변수는 저장 시 암호화되지만, 코드에서 브라우저로 전달하면 결국 사용자에게 노출될 수 있다.</p>
<p>특히 Next.js에서는 브라우저에서 사용되는 값과 서버에서만 사용해야 하는 값을 구분해야 한다.</p>
<p>예를 들어 공개되어도 되는 API Key와<br>절대 공개되면 안 되는 Secret Key를 구분하지 않으면 문제가 생길 수 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>Vercel 배포가 끝났다는 것은<br>내 서비스가 인터넷에 공개되었다는 뜻이다.</p>
<p>배포 자체는 빠르고 편리해졌지만,<br>공개된 이후의 확인까지 자동으로 끝나는 것은 아니다.</p>
<p>브라우저에서 먼저 확인해볼 수 있는 항목은 다음과 같다.</p>
<ol>
<li>http 접속 시 https로 이동하는가</li>
<li>인증서 오류가 뜨지 않는가</li>
<li>없는 페이지에서 내부 에러가 노출되지 않는가</li>
<li>관리자나 테스트 경로가 인증 없이 열리지 않는가</li>
<li>robots.txt와 sitemap.xml이 의도한 대로 보이는가</li>
<li>개발용 문구나 테스트 데이터가 남아 있지 않은가</li>
<li>개발자도구에서 민감한 값이 보이지 않는가</li>
</ol>
<p>물론 이것만으로 보안 점검이 끝나는 것은 아니다.</p>
<p>하지만 AI로 빠르게 만든 웹앱이라면,<br>배포 직후 이 정도만 확인해도 기본적인 실수를 줄이는 데 도움이 된다.</p>
<p>서비스를 만드는 장벽은 낮아졌지만,<br>안전하게 공개하는 과정은 여전히 필요하다.</p>
<p>```</p>
<p>참고한 문서: 
<a href="https://vercel.com/docs/domains/working-with-ssl">Vercel SSL Certificates</a>, 
<a href="https://vercel.com/docs/environment-variables">Vercel Environment Variables</a>, 
<a href="https://developers.google.com/search/docs/crawling-indexing/robots/intro">Google robots.txt</a>, 
<a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview">Google Sitemaps</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vercel·Supabase로 만든 웹앱, 보안 점검은 어디까지 확인해야 할까?]]></title>
            <link>https://velog.io/@caffeine_coder/web-security-assessment-three-levels</link>
            <guid>https://velog.io/@caffeine_coder/web-security-assessment-three-levels</guid>
            <pubDate>Tue, 23 Jun 2026 00:57:12 GMT</pubDate>
            <description><![CDATA[<p>Vercel이나 Supabase로 웹앱을 만들고 보안 체크리스트를 확인하다 보면 마지막에 한 가지 질문이 남습니다.</p>
<blockquote>
<p>그래서 내 웹앱은 실제로 괜찮은 걸까?</p>
</blockquote>
<p>이 질문에 도메인 주소만 보고 바로 답하기는 어렵습니다.</p>
<p>보안 점검은 확인할 수 있는 자료와 접근 범위에 따라 결과가 달라지기 때문입니다.</p>
<p>이번 글에서는 웹앱 보안 점검을 다음 세 단계로 나누어 살펴보겠습니다.</p>
<ol>
<li>도메인으로 확인하는 외부 점검</li>
<li>관리자 설정을 확인하는 설정 점검</li>
<li>소스코드를 확인하는 코드 점검</li>
</ol>
<hr>
<h2 id="보안-점검은-모두-같은-점검이-아니다">보안 점검은 모두 같은 점검이 아니다</h2>
<p>세 점검의 차이를 먼저 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>필요한 자료</th>
<th>확인 가능한 내용</th>
<th>주요 한계</th>
</tr>
</thead>
<tbody><tr>
<td>외부 점검</td>
<td>도메인 주소</td>
<td>외부에 노출된 설정과 정보</td>
<td>내부 권한과 코드는 확인 불가</td>
</tr>
<tr>
<td>설정 점검</td>
<td>관리자 설정 자료</td>
<td>Vercel·Supabase 등의 구성</td>
<td>실제 코드 흐름은 확인 불가</td>
</tr>
<tr>
<td>코드 점검</td>
<td>소스코드와 테스트 환경</td>
<td>인증, 권한, 입력값 처리</td>
<td>운영 환경과 다를 수 있음</td>
</tr>
</tbody></table>
<p>어느 한 단계가 다른 단계를 완전히 대체하지는 않습니다.</p>
<p>외부 점검에서 문제가 발견되지 않았다고 내부 설정과 코드까지 안전하다는 뜻은 아닙니다.</p>
<p>반대로 보안 헤더 하나가 없다고 해서 서비스 전체가 즉시 위험하다고 단정할 수도 없습니다.</p>
<p>점검 결과를 해석하려면 <strong>무엇을 확인했고, 무엇은 확인하지 못했는지</strong> 함께 설명해야 합니다.</p>
<hr>
<h2 id="1단계-도메인으로-확인하는-외부-점검">1단계: 도메인으로 확인하는 외부 점검</h2>
<p>외부 점검은 소스코드나 관리자 권한 없이 웹앱 주소에서 확인되는 정보를 살펴보는 방식입니다.</p>
<p>실제 외부 방문자와 비슷한 위치에서 서비스가 무엇을 노출하고 있는지 확인합니다.</p>
<h3 id="https와-인증서">HTTPS와 인증서</h3>
<p>다음 항목을 확인할 수 있습니다.</p>
<ul>
<li>HTTP 접속이 HTTPS로 전환되는가</li>
<li>인증서가 유효한가</li>
<li>인증서의 도메인이 일치하는가</li>
<li>브라우저에서 인증서 오류가 발생하지 않는가</li>
<li>HTTPS 페이지에서 HTTP 리소스를 불러오지 않는가</li>
</ul>
<p>HTTPS가 적용되어 있어도 리디렉션이나 혼합 콘텐츠 설정은 별도로 확인할 필요가 있습니다.</p>
<h3 id="보안-응답-헤더">보안 응답 헤더</h3>
<p>서버 응답에서 다음과 같은 헤더를 확인할 수 있습니다.</p>
<ul>
<li><code>Strict-Transport-Security</code></li>
<li><code>Content-Security-Policy</code></li>
<li><code>X-Content-Type-Options</code></li>
<li><code>X-Frame-Options</code></li>
<li><code>Referrer-Policy</code></li>
<li><code>Permissions-Policy</code></li>
</ul>
<p>보안 헤더는 브라우저에 적용할 보안 정책을 전달합니다.</p>
<p>다만 특정 헤더가 없다는 사실만으로 취약점이 확정되는 것은 아닙니다. 서비스의 기능과 응답 종류를 함께 살펴봐야 합니다.</p>
<p>예를 들어 <code>Content-Security-Policy</code>는 서비스에서 사용하는 스크립트와 외부 리소스에 맞게 구성해야 합니다.</p>
<h3 id="외부에-공개된-파일과-경로">외부에 공개된 파일과 경로</h3>
<p>서비스 소유자의 동의를 받은 범위에서 낮은 강도의 요청으로 다음 항목을 확인할 수 있습니다.</p>
<ul>
<li>환경설정 파일</li>
<li>백업 파일</li>
<li>로그 파일</li>
<li>테스트 페이지</li>
<li>API 문서</li>
<li>관리 기능으로 연결되는 공개 경로</li>
<li>불필요하게 공개된 디렉터리</li>
</ul>
<p>경로가 존재한다는 사실 자체가 취약점은 아닙니다.</p>
<p>관리자 페이지가 공개된 경로에 있더라도 적절한 인증과 권한 검사가 적용되어 있다면 접근은 제한될 수 있습니다.</p>
<h3 id="브라우저에-전달된-javascript">브라우저에 전달된 JavaScript</h3>
<p>웹앱은 실행에 필요한 JavaScript를 브라우저로 전송합니다.</p>
<p>이 파일에서 다음과 같은 값을 확인할 수 있습니다.</p>
<ul>
<li>외부 서비스 API 주소</li>
<li>Supabase 프로젝트 주소</li>
<li>공개용 API Key</li>
<li>비밀 값으로 의심되는 문자열</li>
<li>내부 경로와 기능 이름</li>
<li>빌드 환경 정보</li>
</ul>
<p>여기서 주의할 점은 <strong>브라우저에 보이는 API Key가 모두 비밀 Key는 아니라는 것</strong>입니다.</p>
<p>Supabase의 Publishable Key와 기존 <code>anon key</code>처럼 공개를 전제로 만들어진 값도 있습니다. 키가 보인다는 이유만으로 유출이라고 판단해서는 안 됩니다.</p>
<p>값의 종류와 해당 키로 가능한 작업을 함께 확인해야 합니다.</p>
<h3 id="source-map">Source Map</h3>
<p>Source Map이 외부에 공개되어 있으면 압축된 JavaScript와 원본 소스의 관계를 파악하는 데 도움이 될 수 있습니다.</p>
<p>Source Map 공개가 항상 보안 취약점인 것은 아닙니다.</p>
<p>하지만 다음 정보가 불필요하게 포함되어 있다면 검토가 필요합니다.</p>
<ul>
<li>원본 파일명과 디렉터리 구조</li>
<li>내부 API 경로</li>
<li>개발 중 남긴 주석</li>
<li>사용하지 않는 관리자 기능</li>
<li>오류 처리 과정의 내부 정보</li>
</ul>
<h3 id="dns와-메일-보안-설정">DNS와 메일 보안 설정</h3>
<p>도메인의 공개 DNS 레코드에서는 다음 항목도 확인할 수 있습니다.</p>
<ul>
<li>사용 중인 네임서버</li>
<li>불필요하게 남은 서브도메인</li>
<li>SPF</li>
<li>DKIM</li>
<li>DMARC</li>
<li>CAA</li>
<li>DNSSEC 적용 여부</li>
</ul>
<p>메일을 사용하지 않는 서비스라면 모든 메일 보안 항목이 동일하게 필요한 것은 아닙니다. 실제 도메인 사용 목적을 기준으로 판단해야 합니다.</p>
<hr>
<h2 id="외부-점검만으로-확인할-수-없는-것">외부 점검만으로 확인할 수 없는 것</h2>
<p>도메인 기반 외부 점검에는 명확한 한계가 있습니다.</p>
<p>다음 항목은 일반적으로 도메인만으로 정확히 판단하기 어렵습니다.</p>
<ul>
<li>Supabase 테이블별 RLS 정책</li>
<li>사용자 간 데이터 접근 권한</li>
<li>Vercel 환경변수의 적용 범위</li>
<li>Preview Deployment 보호 설정</li>
<li><code>service_role</code> 또는 Secret Key의 서버 사용 방식</li>
<li>API 내부의 인증과 권한 검사</li>
<li>서버 입력값 검증</li>
<li>로그에 저장되는 개인정보</li>
<li>관리자 기능의 내부 접근 제어</li>
<li>백업과 복구 설정</li>
</ul>
<p>따라서 외부 점검 결과는 다음과 같이 표현해야 합니다.</p>
<blockquote>
<p>외부에서 확인한 범위에서는 해당 문제가 발견되지 않았습니다.</p>
</blockquote>
<p>다음과 같이 단정해서는 안 됩니다.</p>
<blockquote>
<p>이 웹앱은 안전합니다.</p>
</blockquote>
<p>무료 외부 점검은 전체 보안을 보증하는 검사가 아니라, 다음 점검이 필요한 위치를 찾는 1차 진단에 가깝습니다.</p>
<hr>
<h2 id="2단계-관리자-설정을-확인하는-설정-점검">2단계: 관리자 설정을 확인하는 설정 점검</h2>
<p>Vercel과 Supabase의 내부 설정은 프로젝트 소유자만 확인할 수 있습니다.</p>
<p>이 단계에서는 관리자 계정의 비밀번호나 Secret Key를 전달할 필요가 없습니다.</p>
<p>다음과 같은 방법을 사용할 수 있습니다.</p>
<ul>
<li>민감정보를 제거한 설정 화면</li>
<li>설정값 내보내기 자료</li>
<li>Migration 파일</li>
<li>화면 공유</li>
<li>점검자가 제공한 체크리스트를 소유자가 직접 실행</li>
</ul>
<h3 id="vercel에서-확인할-수-있는-항목">Vercel에서 확인할 수 있는 항목</h3>
<ul>
<li>Production과 Preview 환경변수 구분</li>
<li>Preview Deployment 보호 설정</li>
<li>환경변수 변경 후 재배포 여부</li>
<li>프로젝트와 팀의 접근 권한</li>
<li>도메인과 Redirect 설정</li>
<li>사용량과 비용 관리</li>
<li>Runtime Logs 접근 범위</li>
</ul>
<p>Vercel 공식 문서도 애플리케이션 코드, 환경변수 노출, 인증과 사용자 접근 관리는 고객의 책임으로 구분합니다.</p>
<p><a href="https://vercel.com/docs/security/shared-responsibility">Vercel 공동 책임 모델</a></p>
<h3 id="supabase에서-확인할-수-있는-항목">Supabase에서 확인할 수 있는 항목</h3>
<ul>
<li>API에 노출된 테이블의 RLS 활성화</li>
<li><code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 정책</li>
<li><code>anon</code>과 <code>authenticated</code> 역할의 접근 범위</li>
<li>Storage 버킷의 공개·비공개 설정</li>
<li><code>storage.objects</code> 접근 정책</li>
<li>인증 Redirect URL</li>
<li>백업과 복구 설정</li>
<li>Security Advisor 결과</li>
</ul>
<p>Supabase RLS는 테이블 행에 대한 접근을 제한하는 기능입니다. 실제 정책을 확인하지 않고 외부 응답만으로 전체 권한 구조를 판단하기는 어렵습니다.</p>
<p><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS 공식 문서</a></p>
<p><a href="https://supabase.com/docs/guides/api/securing-your-api">Supabase API 보안 공식 문서</a></p>
<hr>
<h2 id="3단계-소스코드를-확인하는-코드-점검">3단계: 소스코드를 확인하는 코드 점검</h2>
<p>설정이 올바르더라도 애플리케이션 코드에서 권한을 잘못 처리하면 문제가 발생할 수 있습니다.</p>
<p>코드 점검에서는 기능이 동작하는지만 보는 것이 아니라 데이터가 이동하고 처리되는 흐름을 확인합니다.</p>
<h3 id="인증과-권한-검사">인증과 권한 검사</h3>
<p>다음 항목을 살펴볼 수 있습니다.</p>
<ul>
<li>API가 로그인 여부를 확인하는가</li>
<li>사용자가 자신의 데이터에만 접근하는가</li>
<li>관리자 기능이 역할을 다시 확인하는가</li>
<li>URL의 ID만 변경해서 다른 사용자 데이터에 접근할 수 있는가</li>
<li>조회·등록·수정·삭제마다 권한을 검사하는가</li>
</ul>
<p>화면에서 버튼을 숨기는 것은 접근 통제가 아닙니다.</p>
<p>사용자는 화면을 거치지 않고 API에 직접 요청할 수 있으므로 서버와 데이터베이스에서 권한을 확인해야 합니다.</p>
<h3 id="서버-입력값-검증">서버 입력값 검증</h3>
<p>프론트엔드의 입력값 검사는 사용자 경험에는 도움이 되지만 우회할 수 있습니다.</p>
<p>서버에서도 다음 항목을 검증해야 합니다.</p>
<ul>
<li>필수 값</li>
<li>문자열 길이</li>
<li>숫자와 날짜의 범위</li>
<li>허용된 선택지</li>
<li>파일 형식과 크기</li>
<li>요청한 사용자의 변경 권한</li>
</ul>
<h3 id="로그와-오류-처리">로그와 오류 처리</h3>
<p>코드에서는 어떤 정보를 로그로 남기는지도 확인해야 합니다.</p>
<ul>
<li>비밀번호</li>
<li>Access Token</li>
<li>Session ID</li>
<li>Cookie</li>
<li>Authorization 헤더</li>
<li>API Secret</li>
<li>개인정보</li>
<li>문의와 상담 내용</li>
</ul>
<p>오류 객체나 요청 객체 전체를 출력하면 예상보다 많은 정보가 로그에 포함될 수 있습니다.</p>
<h3 id="secret-관리">Secret 관리</h3>
<p>다음 위치에 비밀 값이 하드코딩되어 있지 않은지 확인할 수 있습니다.</p>
<ul>
<li>프론트엔드 코드</li>
<li>설정 파일</li>
<li>테스트 코드</li>
<li>Git 커밋 기록</li>
<li>배포 스크립트</li>
<li>로그 출력 코드</li>
</ul>
<p>코드에서 Secret을 제거했더라도 Git 기록이나 이전 배포에 남아 있다면 해당 키를 교체해야 할 수 있습니다.</p>
<hr>
<h2 id="세-단계가-모두-필요한-이유">세 단계가 모두 필요한 이유</h2>
<p>Vercel과 Supabase 같은 플랫폼은 개발 속도를 크게 높여줍니다.</p>
<p>하지만 플랫폼이 제공하는 보안과 개발자가 구현해야 하는 보안은 구분해야 합니다.</p>
<p>예를 들어 다음 상황이 동시에 존재할 수 있습니다.</p>
<ul>
<li>HTTPS와 보안 헤더는 정상적으로 설정됨</li>
<li>Supabase RLS 정책은 지나치게 넓게 설정됨</li>
<li>API에서는 다른 사용자의 데이터를 수정할 수 있음</li>
</ul>
<p>외부 점검만 했다면 첫 번째 상태만 확인할 수 있습니다.</p>
<p>설정 점검을 추가하면 두 번째 상태를 확인할 수 있고, 코드와 실제 사용자 테스트까지 진행해야 세 번째 상태를 확인할 수 있습니다.</p>
<p>OWASP ASVS도 웹 애플리케이션의 기술적 보안 통제를 검증하기 위한 요구사항과 기준을 제공합니다.</p>
<p><a href="https://owasp.org/www-project-application-security-verification-standard/">OWASP ASVS 공식 페이지</a></p>
<hr>
<h2 id="무료-외부-보안-점검을-준비하고-있습니다">무료 외부 보안 점검을 준비하고 있습니다</h2>
<p>2026년 6월 24일, Vercel 또는 Supabase로 만든 웹앱을 대상으로 소수의 무료 외부 보안 점검을 모집할 예정입니다.</p>
<p>이번 무료 점검은 첫 번째 단계인 <strong>도메인 기반 외부 점검</strong>만 진행합니다.</p>
<p><img src="https://velog.velcdn.com/images/caffeine_coder/post/f59a8e27-6db2-4cf8-8f41-7d145a11d7ed/image.jpg" alt=""></p>
<h3 id="무료-점검에서-확인하는-범위">무료 점검에서 확인하는 범위</h3>
<ul>
<li>HTTPS와 인증서</li>
<li>보안 응답 헤더</li>
<li>외부에 공개된 파일과 정보</li>
<li>브라우저에 전달된 코드의 노출 정보</li>
<li>Source Map 공개 여부</li>
<li>기본적인 DNS와 메일 보안 설정</li>
</ul>
<h3 id="무료-점검에서-확인하지-않는-범위">무료 점검에서 확인하지 않는 범위</h3>
<ul>
<li>소스코드</li>
<li>Vercel과 Supabase 관리자 설정</li>
<li>RLS 정책</li>
<li>API 내부 권한</li>
<li>공격적인 침투 테스트</li>
<li>서비스 장애를 유발할 수 있는 요청</li>
</ul>
<p>비밀번호, 관리자 권한, Secret Key와 <code>service_role</code> Key는 받지 않습니다.</p>
<p>서비스 소유자이거나 점검 권한을 가진 사람만 신청할 수 있으며, 발견된 내용은 신청자의 동의 없이 공개하지 않습니다.</p>
<p>무료 점검은 서비스가 안전하다는 인증이 아니라, 외부에서 확인되는 상태와 다음 점검이 필요한 항목을 정리하는 1차 진단입니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>보안 점검을 받을 때는 결과뿐 아니라 점검 범위를 함께 확인해야 합니다.</p>
<p>도메인만 확인한 점검으로 소스코드와 데이터 권한까지 안전하다고 판단할 수는 없습니다.</p>
<p>반대로 간단한 외부 점검도 현재 외부에 무엇이 노출되어 있는지 파악하고 다음 조치를 결정하는 출발점이 될 수 있습니다.</p>
<p>보안 점검을 다음 세 질문으로 나누어 생각해보세요.</p>
<ol>
<li>외부에서는 무엇이 보이는가?</li>
<li>관리자 설정은 어떻게 되어 있는가?</li>
<li>소스코드는 권한과 데이터를 어떻게 처리하는가?</li>
</ol>
<p>이번 무료 점검에서는 첫 번째 질문부터 확인해보려고 합니다.</p>
<hr>
<h2 id="참고한-공식-문서">참고한 공식 문서</h2>
<ul>
<li><a href="https://owasp.org/www-project-application-security-verification-standard/">OWASP Application Security Verification Standard</a></li>
<li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li>
<li><a href="https://vercel.com/docs/security/shared-responsibility">Vercel Shared Responsibility Model</a></li>
<li><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase Row Level Security</a></li>
<li><a href="https://supabase.com/docs/guides/api/securing-your-api">Supabase Securing Your API</a></li>
<li><a href="https://supabase.com/docs/guides/storage/security/access-control">Supabase Storage Access Control</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vercel 자주 하는 실수 10가지]]></title>
            <link>https://velog.io/@caffeine_coder/vercel-common-mistakes-before-deploy</link>
            <guid>https://velog.io/@caffeine_coder/vercel-common-mistakes-before-deploy</guid>
            <pubDate>Mon, 22 Jun 2026 01:37:22 GMT</pubDate>
            <description><![CDATA[<p>AI 개발 도구를 이용해 만든 웹앱을 배포할 때 Vercel을 사용하는 경우가 많습니다.</p>
<p>GitHub 저장소를 연결하면 자동으로 빌드되고 URL까지 생성되기 때문에 배포 과정은 간단하게 느껴집니다.</p>
<p>하지만 <strong>배포에 성공했다는 것과 안전하게 운영할 준비가 끝났다는 것은 다릅니다.</strong></p>
<p>Vercel은 컴퓨팅, 네트워크 같은 플랫폼 인프라를 보호하지만 애플리케이션 코드, 사용자 인증, 데이터 접근 권한, 환경변수 사용은 개발자가 관리해야 합니다.</p>
<p>이번 글에서는 Vercel로 웹앱을 배포할 때 자주 하는 실수 10가지와 확인 방법을 정리해보겠습니다.</p>
<blockquote>
<p>이 글은 2026년 6월 22일 기준 Vercel, Next.js, OWASP 공식 문서를 바탕으로 작성했습니다. 요금제와 플랫폼 기능은 변경될 수 있으므로 글 하단의 공식 문서도 함께 확인해주세요.</p>
</blockquote>
<hr>
<h2 id="1-환경변수에-넣으면-무조건-비밀이라고-생각한다">1. 환경변수에 넣으면 무조건 비밀이라고 생각한다</h2>
<p>Vercel에 등록한 환경변수는 저장 시 암호화됩니다.</p>
<p>일반 환경변수는 프로젝트 접근 권한이 있는 사용자가 확인할 수 있으며, Production과 Preview에서는 값을 다시 읽을 수 없도록 <code>Sensitive</code> 옵션을 사용할 수도 있습니다.</p>
<p>하지만 환경변수를 안전하게 저장했다는 사실이 애플리케이션에서의 노출까지 막아주지는 않습니다.</p>
<p>다음과 같은 경우에는 값이 외부로 노출될 수 있습니다.</p>
<ul>
<li>브라우저용 JavaScript에 포함한 경우</li>
<li>API 응답에 값을 넣은 경우</li>
<li><code>console.log</code>로 출력한 경우</li>
<li>오류 메시지에 환경변수를 포함한 경우</li>
<li>URL이나 쿼리 파라미터에 넣은 경우</li>
</ul>
<blockquote>
<p>환경변수는 비밀을 저장하는 방법이지, 어디에서 사용해도 안전하게 만들어주는 기능은 아닙니다.</p>
</blockquote>
<h3 id="이렇게-확인해보세요">이렇게 확인해보세요</h3>
<ul>
<li>환경변수를 서버 코드에서만 사용하는가</li>
<li>프로젝트 접근 권한을 가진 사람이 누구인지 알고 있는가</li>
<li>중요한 값에 Sensitive 옵션을 적용했는가</li>
<li>환경변수를 응답이나 로그에 출력하고 있지 않은가</li>
</ul>
<p><a href="https://vercel.com/docs/environment-variables">Vercel 환경변수 공식 문서</a></p>
<hr>
<h2 id="2-secret을-next_public_-변수에-넣는다">2. Secret을 <code>NEXT_PUBLIC_</code> 변수에 넣는다</h2>
<p>Next.js는 <code>NEXT_PUBLIC_</code> 접두사가 붙은 환경변수를 브라우저에서 사용할 수 있도록 빌드 결과에 포함합니다.</p>
<pre><code class="language-env">NEXT_PUBLIC_ANALYTICS_ID=public-value</code></pre>
<p>위와 같이 브라우저에서 사용하도록 만들어진 공개 값에는 사용할 수 있습니다.</p>
<p>하지만 다음과 같은 값에는 사용하면 안 됩니다.</p>
<pre><code class="language-env">NEXT_PUBLIC_DATABASE_URL=...
NEXT_PUBLIC_SERVICE_ROLE_KEY=...
NEXT_PUBLIC_STRIPE_SECRET_KEY=...
NEXT_PUBLIC_JWT_SECRET=...</code></pre>
<p><code>NEXT_PUBLIC_</code> 변수의 값은 빌드 시 브라우저에 전달되는 JavaScript에 포함될 수 있습니다. 사용자가 개발자 도구나 빌드 파일을 통해 값을 확인할 수 있다는 뜻입니다.</p>
<h3 id="프로젝트에서-검색할-항목">프로젝트에서 검색할 항목</h3>
<pre><code class="language-text">NEXT_PUBLIC_
DATABASE_URL
SERVICE_ROLE
SECRET_KEY
PRIVATE_KEY
JWT_SECRET</code></pre>
<p>공개되어도 되는 값과 서버에서만 사용해야 하는 값을 구분해야 합니다.</p>
<p><a href="https://nextjs.org/docs/app/guides/environment-variables">Next.js 환경변수 공식 문서</a></p>
<hr>
<h2 id="3-환경변수를-변경하고-다시-배포하지-않는다">3. 환경변수를 변경하고 다시 배포하지 않는다</h2>
<p>Vercel에서 환경변수를 수정해도 이전에 생성된 배포에는 새로운 값이 적용되지 않습니다.</p>
<p>변경된 환경변수는 이후 만들어지는 새로운 배포부터 적용됩니다.</p>
<p>예를 들어 노출된 API Key를 교체하기 위해 Vercel 설정만 변경하고 다시 배포하지 않았다면 현재 실행 중인 배포는 기존 값을 계속 사용할 수 있습니다.</p>
<h3 id="환경변수를-변경했다면-확인할-항목">환경변수를 변경했다면 확인할 항목</h3>
<ol>
<li>어떤 환경에 적용했는지 확인한다.</li>
<li>해당 환경을 다시 배포한다.</li>
<li>새 배포가 변경된 값으로 동작하는지 확인한다.</li>
<li>필요한 경우 기존 키를 폐기한다.</li>
<li>Preview 배포도 교체가 필요한지 확인한다.</li>
</ol>
<p>Secret을 교체할 때는 새 값을 적용할 배포와 기존 키의 폐기 시점을 함께 계획해야 서비스 중단을 줄일 수 있습니다.</p>
<hr>
<h2 id="4-production과-preview-환경을-구분하지-않는다">4. Production과 Preview 환경을 구분하지 않는다</h2>
<p>Vercel은 기본적으로 다음 환경을 구분합니다.</p>
<ul>
<li>Production</li>
<li>Preview</li>
<li>Development</li>
</ul>
<p>Production이 아닌 Git 브랜치를 배포하면 일반적으로 Preview 배포가 생성됩니다. Preview 환경변수는 전체 비운영 브랜치 또는 특정 브랜치에 적용할 수 있습니다.</p>
<p>문제는 Preview가 운영 환경과 같은 비밀 값과 데이터베이스를 사용할 때 발생합니다.</p>
<h3 id="발생할-수-있는-문제">발생할 수 있는 문제</h3>
<ul>
<li>테스트 코드가 운영 데이터를 수정함</li>
<li>개발 중인 화면에서 실제 개인정보가 노출됨</li>
<li>외부에 공유된 Preview가 운영 DB에 접근함</li>
<li>테스트 요청이 실제 결제·메일·문자 발송으로 이어짐</li>
</ul>
<h3 id="이렇게-구분해보세요">이렇게 구분해보세요</h3>
<table>
<thead>
<tr>
<th>환경</th>
<th>연결 대상 예시</th>
</tr>
</thead>
<tbody><tr>
<td>Development</td>
<td>로컬 또는 개발용 DB</td>
</tr>
<tr>
<td>Preview</td>
<td>테스트 DB, 테스트 API Key</td>
</tr>
<tr>
<td>Production</td>
<td>운영 DB, 운영 API Key</td>
</tr>
</tbody></table>
<p>모든 서비스에서 반드시 별도의 DB를 사용해야 하는 것은 아닙니다. 하지만 Preview가 운영 데이터에 접근한다면 접근 권한과 테스트 데이터 처리 방식을 명확히 정해야 합니다.</p>
<p><a href="https://vercel.com/docs/deployments/production-env">Vercel 환경 구분 공식 문서</a></p>
<hr>
<h2 id="5-preview-배포의-접근-설정을-확인하지-않는다">5. Preview 배포의 접근 설정을 확인하지 않는다</h2>
<p>Vercel은 브랜치나 커밋마다 고유한 Preview URL을 생성할 수 있습니다.</p>
<p>Preview 응답에는 검색엔진 수집을 막기 위한 <code>x-robots-tag: noindex</code>가 자동으로 추가될 수 있지만, <strong>검색되지 않는 것과 비공개인 것은 다릅니다.</strong></p>
<p>Deployment Protection이 적용되지 않은 배포는 URL을 가진 사람이 접근할 수 있습니다.</p>
<h3 id="deployment-protection에서-확인할-항목">Deployment Protection에서 확인할 항목</h3>
<ul>
<li>현재 프로젝트에 보호 기능이 활성화되어 있는가</li>
<li>Preview와 생성된 배포 URL이 보호 대상인가</li>
<li>외부 협업자에게 어떤 방식으로 접근을 허용하는가</li>
<li>공유 링크가 필요한 사람에게만 전달되었는가</li>
<li>Preview에서 실제 개인정보를 사용하고 있지 않은가</li>
</ul>
<p>Vercel의 Standard Protection은 Production 도메인을 제외한 배포를 보호하는 용도로 사용할 수 있습니다. 적용 가능한 보호 방식과 범위는 요금제 및 프로젝트 설정에 따라 달라질 수 있습니다.</p>
<blockquote>
<p>Preview URL을 추측하기 어렵게 만드는 것보다 접근 권한을 설정하는 것이 중요합니다.</p>
</blockquote>
<p><a href="https://vercel.com/docs/deployment-protection">Vercel Deployment Protection 공식 문서</a></p>
<hr>
<h2 id="6-api-route에-인증과-권한-검사를-넣지-않는다">6. API Route에 인증과 권한 검사를 넣지 않는다</h2>
<p>Vercel에 배포된 Next.js Route Handler와 API Route는 외부에서 요청할 수 있는 HTTP 엔드포인트입니다.</p>
<p>Vercel이 서버를 실행해준다고 해서 애플리케이션의 API에 사용자 인증과 권한 검사가 자동으로 추가되는 것은 아닙니다.</p>
<p>다음 두 가지를 구분해야 합니다.</p>
<ul>
<li><strong>인증</strong>: 요청한 사용자가 누구인지 확인</li>
<li><strong>인가</strong>: 해당 사용자가 이 데이터에 접근할 수 있는지 확인</li>
</ul>
<p>예를 들어 로그인한 사용자라 하더라도 URL의 예약 ID만 변경해서 다른 사용자의 예약을 조회할 수 있다면 권한 검사가 부족한 것입니다.</p>
<h3 id="api마다-확인할-항목">API마다 확인할 항목</h3>
<ul>
<li>유효한 세션이 있는가</li>
<li>해당 데이터의 소유자가 요청한 사용자인가</li>
<li>일반 사용자와 관리자의 권한을 구분했는가</li>
<li>등록·조회·수정·삭제 작업마다 권한을 확인하는가</li>
<li>화면에서 버튼을 숨기는 것에만 의존하지 않는가</li>
</ul>
<p>Next.js의 Server Action도 데이터 변경 작업을 수행한다면 호출 사용자의 권한을 확인해야 합니다.</p>
<p>Vercel의 공식 공동 책임 모델에서도 애플리케이션의 인증과 사용자 접근 관리는 고객 책임으로 구분합니다.</p>
<p><a href="https://vercel.com/docs/security/shared-responsibility">Vercel 공동 책임 모델</a></p>
<p><a href="https://nextjs.org/docs/app/guides/backend-for-frontend">Next.js Backend for Frontend 공식 문서</a></p>
<hr>
<h2 id="7-프론트엔드의-입력값-검사만-믿는다">7. 프론트엔드의 입력값 검사만 믿는다</h2>
<p>브라우저 화면에서 필수 항목과 글자 수를 검사해도 사용자는 API를 직접 호출할 수 있습니다.</p>
<p>따라서 프론트엔드 검사는 사용자 편의를 위한 기능으로 보고, 서버에서 입력값을 다시 검증해야 합니다.</p>
<h3 id="서버에서-확인할-항목">서버에서 확인할 항목</h3>
<ul>
<li>필수 값이 존재하는가</li>
<li>문자열의 최소·최대 길이가 적절한가</li>
<li>숫자의 범위가 유효한가</li>
<li>날짜의 순서가 올바른가</li>
<li>정해진 선택지 중 하나인지 확인했는가</li>
<li>요청한 사용자가 해당 값을 변경할 권한이 있는가</li>
<li>예상하지 않은 필드가 포함되지 않았는가</li>
</ul>
<p>예를 들어 화면에서 예약 인원을 1명 이상으로 제한했더라도 API에 <code>-100</code>을 직접 전달할 수 있습니다.</p>
<p>클라이언트와 서버 검증은 목적이 다릅니다.</p>
<ul>
<li>클라이언트 검증: 빠른 안내와 사용자 경험</li>
<li>서버 검증: 신뢰할 수 없는 요청 차단</li>
</ul>
<p>OWASP도 클라이언트 검증은 우회할 수 있으므로 서버 측 검증을 함께 구현하도록 안내합니다.</p>
<p><a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html">OWASP Input Validation Cheat Sheet</a></p>
<hr>
<h2 id="8-로그에-개인정보와-secret을-남긴다">8. 로그에 개인정보와 Secret을 남긴다</h2>
<p>Vercel Functions에서 <code>console.log</code>, <code>console.error</code> 등으로 출력한 값은 Runtime Logs에서 확인할 수 있습니다.</p>
<p>Runtime Logs에는 Preview와 Production 함수에서 출력한 내용이 포함될 수 있으며, 프로젝트에서 로그를 볼 수 있는 권한을 가진 사용자에게 노출될 수 있습니다.</p>
<p>디버깅을 위해 요청 전체를 출력하면 다음 정보가 함께 기록될 수 있습니다.</p>
<ul>
<li>비밀번호</li>
<li>Access Token</li>
<li>Session ID</li>
<li>Cookie</li>
<li>Authorization 헤더</li>
<li>데이터베이스 연결 문자열</li>
<li>API Secret</li>
<li>이메일, 전화번호, 주소</li>
<li>문의와 상담 내용</li>
</ul>
<h3 id="피해야-할-코드">피해야 할 코드</h3>
<pre><code class="language-ts">console.log(request.headers);
console.log(await request.json());
console.log(process.env);
console.log(user);</code></pre>
<p>객체 전체를 출력하기보다 문제 확인에 필요한 값만 선택해서 기록해야 합니다.</p>
<pre><code class="language-ts">console.log({
  event: &quot;reservation_created&quot;,
  requestId,
  userId,
  success: true,
});</code></pre>
<p>사용자 ID도 서비스 상황에 따라 마스킹하거나 내부 식별자로 대체할 수 있습니다.</p>
<p><a href="https://vercel.com/docs/functions/logs">Vercel Runtime Logs 공식 문서</a></p>
<p><a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html">OWASP Logging Cheat Sheet</a></p>
<hr>
<h2 id="9-보안-헤더가-모두-자동-설정된다고-생각한다">9. 보안 헤더가 모두 자동 설정된다고 생각한다</h2>
<p>Vercel은 배포 응답에 HSTS와 같은 일부 헤더를 기본으로 제공합니다.</p>
<p>하지만 다음과 같은 보안 정책까지 모든 서비스에 맞게 자동으로 만들어주는 것은 아닙니다.</p>
<ul>
<li>Content-Security-Policy</li>
<li>Referrer-Policy</li>
<li>X-Content-Type-Options</li>
<li>iframe 삽입 제한</li>
<li>서비스별 Cache-Control</li>
</ul>
<p>특히 CSP는 서비스가 사용하는 스크립트, 이미지, API, 외부 서비스에 따라 허용 범위가 달라지므로 애플리케이션에 맞춰 구성해야 합니다.</p>
<p>Vercel에서는 프레임워크 설정이나 <code>vercel.json</code>을 이용해 응답 헤더를 추가할 수 있습니다.</p>
<pre><code class="language-json">{
  &quot;headers&quot;: [
    {
      &quot;source&quot;: &quot;/(.*)&quot;,
      &quot;headers&quot;: [
        {
          &quot;key&quot;: &quot;X-Content-Type-Options&quot;,
          &quot;value&quot;: &quot;nosniff&quot;
        },
        {
          &quot;key&quot;: &quot;Referrer-Policy&quot;,
          &quot;value&quot;: &quot;strict-origin-when-cross-origin&quot;
        }
      ]
    }
  ]
}</code></pre>
<p>보안 헤더는 무조건 많이 넣는 것이 목적이 아닙니다. 서비스 기능을 깨뜨리지 않는지 Preview에서 테스트한 뒤 적용해야 합니다.</p>
<p><a href="https://vercel.com/docs/headers/response-headers">Vercel Response Headers 공식 문서</a></p>
<p><a href="https://vercel.com/docs/project-configuration">Vercel 프로젝트 설정 공식 문서</a></p>
<p><a href="https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html">OWASP HTTP Headers Cheat Sheet</a></p>
<hr>
<h2 id="10-사용량과-비용-제한을-확인하지-않는다">10. 사용량과 비용 제한을 확인하지 않는다</h2>
<p>Vercel은 요청, 데이터 전송, 빌드, Functions 실행 등 여러 자원의 사용량을 측정합니다.</p>
<p>기능 오류, 반복 요청, 자동화된 공격 또는 갑작스러운 트래픽으로 사용량이 증가할 수 있습니다.</p>
<p>Vercel Dashboard의 Usage에서 프로젝트별 사용량과 예상 비용을 확인할 수 있습니다.</p>
<h3 id="pro-요금제의-spend-management">Pro 요금제의 Spend Management</h3>
<p>Pro 팀은 Spend Management를 이용해 설정 금액에 도달했을 때 다음 작업을 구성할 수 있습니다.</p>
<ul>
<li>알림 전송</li>
<li>Webhook 호출</li>
<li>Production 프로젝트 일시 중지</li>
</ul>
<p>주의할 점은 <strong>금액을 설정하는 것만으로 프로젝트가 자동 중지되는 것은 아니라는 것</strong>입니다.</p>
<p>프로젝트 중지를 원한다면 해당 옵션을 별도로 활성화해야 합니다. 중지되면 정상 사용자도 서비스에 접근할 수 없으므로 운영 영향을 함께 고려해야 합니다.</p>
<h3 id="함께-확인할-항목">함께 확인할 항목</h3>
<ul>
<li>최근 30일간 프로젝트별 사용량</li>
<li>예상보다 많이 호출되는 API</li>
<li>반복 요청을 제한할 필요가 있는 기능</li>
<li>이미지 최적화와 데이터 전송 사용량</li>
<li>Functions 실행 시간과 오류</li>
<li>비용 알림 수신자</li>
<li>사용량 급증 시 대응 방법</li>
</ul>
<p>플랫폼 비용 설정만 믿기보다 로그인, 문의, 예약, AI API처럼 반복 호출될 수 있는 기능에도 적절한 요청 제한을 두는 것이 좋습니다.</p>
<p><a href="https://vercel.com/docs/platform/usage/">Vercel Usage 공식 문서</a></p>
<p><a href="https://vercel.com/docs/pricing/spend-management">Vercel Spend Management 공식 문서</a></p>
<hr>
<h2 id="배포-전-체크리스트">배포 전 체크리스트</h2>
<ul>
<li><input disabled="" type="checkbox"> Secret을 브라우저 코드에서 사용하지 않는다</li>
<li><input disabled="" type="checkbox"> <code>NEXT_PUBLIC_</code> 변수에는 공개 가능한 값만 넣었다</li>
<li><input disabled="" type="checkbox"> <code>.env</code> 파일이 Git 저장소에 포함되지 않았다</li>
<li><input disabled="" type="checkbox"> 환경변수 변경 후 해당 환경을 다시 배포했다</li>
<li><input disabled="" type="checkbox"> Production과 Preview 환경변수를 구분했다</li>
<li><input disabled="" type="checkbox"> Preview가 운영 데이터에 접근하는지 확인했다</li>
<li><input disabled="" type="checkbox"> Deployment Protection 설정을 확인했다</li>
<li><input disabled="" type="checkbox"> API마다 인증과 데이터 권한을 검사한다</li>
<li><input disabled="" type="checkbox"> 서버에서도 입력값을 검증한다</li>
<li><input disabled="" type="checkbox"> 로그에 Token, Cookie, 개인정보를 남기지 않는다</li>
<li><input disabled="" type="checkbox"> 실제 응답의 보안 헤더를 확인했다</li>
<li><input disabled="" type="checkbox"> Usage와 비용 알림을 확인했다</li>
<li><input disabled="" type="checkbox"> 반복 호출이 가능한 기능에 요청 제한을 검토했다</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>Vercel은 웹앱을 빠르게 배포하고 운영할 수 있게 도와주는 플랫폼입니다.</p>
<p>하지만 플랫폼에 배포했다는 이유만으로 애플리케이션의 인증, 권한, 입력값, 환경변수까지 자동으로 보호되는 것은 아닙니다.</p>
<p>처음부터 모든 항목을 확인하기 어렵다면 다음 세 가지부터 점검해보세요.</p>
<ol>
<li>프론트엔드에 Secret이 포함되어 있지 않은가</li>
<li>Preview 배포가 누구에게 공개되어 있는가</li>
<li>API가 사용자 인증과 데이터 권한을 확인하는가</li>
</ol>
<p>이 세 가지는 소스코드와 Vercel 프로젝트 설정에서 바로 확인할 수 있습니다.</p>
<p>배포 성공 화면을 확인한 뒤에는 실제 사용자가 접근하는 경로와 권한도 함께 점검해야 합니다.</p>
<hr>
<h2 id="참고한-공식-문서">참고한 공식 문서</h2>
<ul>
<li><a href="https://vercel.com/docs/environment-variables">Vercel Environment Variables</a></li>
<li><a href="https://vercel.com/docs/environment-variables/sensitive-environment-variables">Vercel Sensitive Environment Variables</a></li>
<li><a href="https://vercel.com/docs/deployment-protection">Vercel Deployment Protection</a></li>
<li><a href="https://vercel.com/docs/security/shared-responsibility">Vercel Shared Responsibility Model</a></li>
<li><a href="https://vercel.com/docs/functions/logs">Vercel Runtime Logs</a></li>
<li><a href="https://vercel.com/docs/headers/response-headers">Vercel Response Headers</a></li>
<li><a href="https://vercel.com/docs/platform/usage/">Vercel Usage</a></li>
<li><a href="https://vercel.com/docs/pricing/spend-management">Vercel Spend Management</a></li>
<li><a href="https://nextjs.org/docs/app/guides/environment-variables">Next.js Environment Variables</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html">OWASP Input Validation Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html">OWASP Logging Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html">OWASP HTTP Headers Cheat Sheet</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase RLS 설정에서 자주 하는 실수 10가지]]></title>
            <link>https://velog.io/@caffeine_coder/supabase-rls-common-mistakes</link>
            <guid>https://velog.io/@caffeine_coder/supabase-rls-common-mistakes</guid>
            <pubDate>Sat, 20 Jun 2026 15:20:39 GMT</pubDate>
            <description><![CDATA[<p>Supabase로 회원가입과 데이터 저장 기능을 구현했다면 <code>RLS</code>라는 용어를 한 번쯤 보셨을 겁니다.</p>
<p>RLS를 활성화하라는 안내에 따라 설정은 했지만, 다음과 같은 의문이 남을 수 있습니다.</p>
<ul>
<li>RLS만 켜면 데이터가 보호되는가?</li>
<li>로그인한 사용자는 자신의 데이터만 볼 수 있는가?</li>
<li><code>USING</code>과 <code>WITH CHECK</code>는 무엇이 다른가?</li>
<li>RLS가 제대로 동작하는지는 어떻게 확인하는가?</li>
</ul>
<p>RLS는 강력한 기능이지만 <strong>활성화 여부보다 정책의 내용과 테스트가 더 중요합니다.</strong></p>
<p>이번 글에서는 Supabase RLS를 설정할 때 자주 하는 실수 10가지와 확인 방법을 정리해보겠습니다.</p>
<blockquote>
<p>이 글은 2026년 6월 21일 기준 Supabase 및 PostgreSQL 공식 문서를 바탕으로 작성했습니다. 설정 화면과 기능은 변경될 수 있으므로 글 하단의 공식 문서도 함께 확인해주세요.</p>
</blockquote>
<hr>
<h2 id="먼저-rls란-무엇일까">먼저 RLS란 무엇일까?</h2>
<p>RLS(Row Level Security)는 PostgreSQL에서 <strong>사용자별로 접근할 수 있는 행을 제한하는 기능</strong>입니다.</p>
<p>예를 들어 <code>bookings</code> 테이블에 여러 사용자의 예약 정보가 함께 저장되어 있더라도, RLS 정책을 이용하면 각 사용자가 자신의 예약만 조회하도록 제한할 수 있습니다.</p>
<pre><code class="language-sql">create policy &quot;Users can view their own bookings&quot;
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);</code></pre>
<p>이 정책은 조회 쿼리에 다음과 같은 조건이 추가되는 것과 비슷하게 동작합니다.</p>
<pre><code class="language-sql">where auth.uid() = user_id</code></pre>
<p>Supabase에서 기본적으로 API에 노출되는 스키마는 <code>public</code>입니다. 브라우저에서 데이터베이스에 직접 접근하는 구조라면 해당 스키마의 테이블에 적절한 RLS 설정이 필요합니다.</p>
<p>다만 RLS는 PostgreSQL의 테이블 권한을 대신하는 기능이 아닙니다. 요청이 허용되려면 기본 테이블 권한과 RLS 정책을 모두 통과해야 합니다.</p>
<p><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS 공식 문서</a></p>
<hr>
<h2 id="1-모든-테이블에-rls가-켜졌다고-생각한다">1. 모든 테이블에 RLS가 켜졌다고 생각한다</h2>
<p>Supabase Dashboard의 Table Editor로 생성한 테이블은 RLS가 기본적으로 활성화됩니다.</p>
<p>하지만 SQL Editor, Migration 또는 외부 도구를 이용해 만든 테이블은 RLS를 직접 활성화해야 합니다.</p>
<pre><code class="language-sql">alter table public.bookings enable row level security;</code></pre>
<p>특히 AI가 작성한 SQL을 그대로 실행했다면 테이블 생성문만 있고 RLS 활성화 구문은 빠져 있을 수 있습니다.</p>
<h3 id="이렇게-확인해보세요">이렇게 확인해보세요</h3>
<p>Supabase Dashboard에서 API에 노출된 테이블을 하나씩 확인합니다.</p>
<ul>
<li>RLS가 활성화되어 있는가</li>
<li>정책이 필요한 테이블인데 빠져 있지 않은가</li>
<li>테스트용 테이블이 운영 환경에 남아 있지 않은가</li>
<li><code>public</code> 이외에 별도로 노출한 스키마가 있는가</li>
</ul>
<p>Security Advisor에서도 <code>public</code> 스키마의 RLS 비활성화 등 일부 문제를 확인할 수 있습니다.</p>
<blockquote>
<p>Table Editor로 만든 테이블이 안전하다는 뜻은 아닙니다. RLS가 켜져 있더라도 정책이 적절한지는 별도로 확인해야 합니다.</p>
</blockquote>
<hr>
<h2 id="2-rls를-켜면-필요한-정책도-자동으로-만들어진다고-생각한다">2. RLS를 켜면 필요한 정책도 자동으로 만들어진다고 생각한다</h2>
<p>RLS를 활성화해도 서비스에 필요한 접근 정책이 자동으로 생성되지는 않습니다.</p>
<p>RLS가 활성화된 테이블에 적용 가능한 정책이 없다면 일반 사용자의 접근에는 기본 거부가 적용됩니다. Supabase 공식 문서도 공개용 키를 사용한 API 요청은 정책이 생성되기 전까지 데이터에 접근할 수 없다고 설명합니다.</p>
<p>하지만 다음과 같은 높은 권한의 요청은 별도로 봐야 합니다.</p>
<ul>
<li>새로운 <code>secret key</code>를 사용하는 서버 요청</li>
<li>기존 <code>service_role</code> 키를 사용하는 서버 요청</li>
<li>PostgreSQL의 <code>BYPASSRLS</code> 권한을 가진 역할</li>
<li>일반적으로 RLS를 우회하는 테이블 소유자</li>
</ul>
<p>따라서 관리자용 키로 기능이 동작한다고 해서 일반 사용자의 RLS 정책까지 정상이라는 뜻은 아닙니다.</p>
<h3 id="흔히-발생하는-흐름">흔히 발생하는 흐름</h3>
<ol>
<li>RLS를 활성화한다.</li>
<li>정책을 만들지 않아 기능이 작동하지 않는다.</li>
<li>원인을 찾지 못하고 <code>service_role</code> 키를 사용한다.</li>
<li>기능은 동작하지만 RLS를 우회하는 구조가 된다.</li>
</ol>
<p>RLS 때문에 요청이 실패한다면 높은 권한의 키로 우회하기 전에 필요한 정책이 무엇인지 먼저 확인해야 합니다.</p>
<p><a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL Row Security 공식 문서</a></p>
<hr>
<h2 id="3-select-정책-하나로-모든-작업이-보호된다고-생각한다">3. SELECT 정책 하나로 모든 작업이 보호된다고 생각한다</h2>
<p>RLS 정책은 작업별로 나누어 생각해야 합니다.</p>
<table>
<thead>
<tr>
<th>작업</th>
<th>주로 사용하는 조건</th>
</tr>
</thead>
<tbody><tr>
<td><code>SELECT</code></td>
<td><code>USING</code></td>
</tr>
<tr>
<td><code>INSERT</code></td>
<td><code>WITH CHECK</code></td>
</tr>
<tr>
<td><code>UPDATE</code></td>
<td><code>USING</code>, <code>WITH CHECK</code></td>
</tr>
<tr>
<td><code>DELETE</code></td>
<td><code>USING</code></td>
</tr>
</tbody></table>
<p>예를 들어 자신의 예약만 조회하도록 설정했다고 해서 등록·수정·삭제 정책까지 자동으로 생기지는 않습니다.</p>
<h3 id="select-정책">SELECT 정책</h3>
<pre><code class="language-sql">create policy &quot;Users can view their own bookings&quot;
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);</code></pre>
<h3 id="insert-정책">INSERT 정책</h3>
<pre><code class="language-sql">create policy &quot;Users can create their own bookings&quot;
on public.bookings
for insert
to authenticated
with check ((select auth.uid()) = user_id);</code></pre>
<h3 id="update-정책">UPDATE 정책</h3>
<pre><code class="language-sql">create policy &quot;Users can update their own bookings&quot;
on public.bookings
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);</code></pre>
<h3 id="delete-정책">DELETE 정책</h3>
<pre><code class="language-sql">create policy &quot;Users can delete their own bookings&quot;
on public.bookings
for delete
to authenticated
using ((select auth.uid()) = user_id);</code></pre>
<p>Supabase 공식 문서에 따르면 <code>UPDATE</code>가 정상적으로 동작하려면 해당 행에 대한 <code>SELECT</code> 정책도 필요합니다.</p>
<p>위 코드는 사용자가 자신의 데이터만 관리하는 단순한 예시입니다. 실제 서비스에서는 관리자, 팀, 공유 데이터 등 서비스의 권한 구조에 맞게 정책을 설계해야 합니다.</p>
<hr>
<h2 id="4-using과-with-check를-구분하지-않는다">4. USING과 WITH CHECK를 구분하지 않는다</h2>
<p>두 조건은 검사하는 대상이 다릅니다.</p>
<h3 id="using"><code>USING</code></h3>
<p>현재 저장되어 있는 행 중 어떤 행에 접근할 수 있는지를 판단합니다.</p>
<p>주로 다음 작업에 사용됩니다.</p>
<ul>
<li><code>SELECT</code></li>
<li><code>UPDATE</code>할 기존 행 선택</li>
<li><code>DELETE</code>할 기존 행 선택</li>
</ul>
<h3 id="with-check"><code>WITH CHECK</code></h3>
<p>새로 저장되거나 수정된 결과가 정책 조건을 만족하는지 판단합니다.</p>
<p>주로 다음 작업에 사용됩니다.</p>
<ul>
<li><code>INSERT</code></li>
<li><code>UPDATE</code> 후의 새로운 행</li>
</ul>
<p>예를 들어 사용자가 자신의 예약을 다른 사용자의 소유로 변경하지 못하게 하려면 <code>UPDATE</code> 정책에 두 조건을 모두 적용할 수 있습니다.</p>
<pre><code class="language-sql">using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id)</code></pre>
<ul>
<li><code>USING</code>: 수정하려는 기존 예약이 자신의 것인지 확인</li>
<li><code>WITH CHECK</code>: 수정 후에도 예약 소유자가 자신인지 확인</li>
</ul>
<p><code>UPDATE</code> 정책에서 <code>WITH CHECK</code>를 생략하면 PostgreSQL은 적용 가능한 <code>USING</code> 조건을 새 행 검사에도 사용할 수 있습니다. 하지만 정책의 의도를 명확히 표현하고 검토하기 위해 두 조건을 구분해서 작성하는 편이 이해하기 쉽습니다.</p>
<hr>
<h2 id="5-로그인-사용자에게-모든-데이터를-허용한다">5. 로그인 사용자에게 모든 데이터를 허용한다</h2>
<p>다음 정책은 간단하지만 적용 범위를 정확히 이해해야 합니다.</p>
<pre><code class="language-sql">create policy &quot;Authenticated users can view bookings&quot;
on public.bookings
for select
to authenticated
using (true);</code></pre>
<p>이 정책은 <strong>로그인한 사용자라면 대상 테이블의 모든 행을 조회하도록 허용</strong>할 수 있습니다.</p>
<p>로그인 여부만 확인할 뿐, 데이터의 소유자는 확인하지 않기 때문입니다.</p>
<p>사용자별로 데이터를 분리해야 한다면 다음과 같이 사용자 ID를 비교해야 합니다.</p>
<pre><code class="language-sql">using ((select auth.uid()) = user_id);</code></pre>
<h3 id="여러-정책이-있다면-더-주의해야-한다">여러 정책이 있다면 더 주의해야 한다</h3>
<p>PostgreSQL의 RLS 정책은 기본적으로 <code>PERMISSIVE</code> 정책이며, 같은 작업에 적용되는 여러 기본 정책은 <code>OR</code> 조건으로 결합됩니다.</p>
<p>예를 들어 다음 두 정책이 함께 있다면:</p>
<ul>
<li>자신의 데이터만 허용하는 정책</li>
<li>로그인 사용자에게 모든 데이터를 허용하는 <code>using (true)</code> 정책</li>
</ul>
<p>두 번째 정책 때문에 첫 번째 정책의 제한이 사실상 넓어질 수 있습니다.</p>
<p>정책 하나만 확인하지 말고 같은 테이블과 작업에 적용되는 모든 정책을 함께 확인해야 합니다.</p>
<hr>
<h2 id="6-계정-하나로만-권한을-테스트한다">6. 계정 하나로만 권한을 테스트한다</h2>
<p>자신의 계정으로 데이터가 정상적으로 보인다는 사실만으로는 권한이 안전한지 판단할 수 없습니다.</p>
<p>RLS 테스트에는 최소한 다음 상태가 필요합니다.</p>
<ul>
<li>로그아웃 상태</li>
<li>사용자 A</li>
<li>사용자 B</li>
<li>필요한 경우 관리자 역할</li>
</ul>
<h3 id="사용자-간-권한-테스트">사용자 간 권한 테스트</h3>
<ol>
<li>사용자 A로 예약 데이터를 생성합니다.</li>
<li>사용자 B로 로그인합니다.</li>
<li>B가 A의 예약을 조회할 수 있는지 확인합니다.</li>
<li>B가 A의 예약을 수정할 수 있는지 확인합니다.</li>
<li>B가 A의 예약을 삭제할 수 있는지 확인합니다.</li>
<li>B가 A의 <code>user_id</code>로 데이터를 등록할 수 있는지 확인합니다.</li>
</ol>
<p>화면에서 버튼을 숨기는 것만으로는 권한 테스트가 되지 않습니다. 자신의 프로젝트에서 실제 Supabase 클라이언트 요청을 보내 결과를 확인해야 합니다.</p>
<p>반복적으로 점검해야 하는 서비스라면 Supabase CLI와 테스트 도구를 이용해 RLS 정책 테스트를 자동화할 수도 있습니다.</p>
<p><a href="https://supabase.com/docs/guides/database/testing">Supabase 데이터베이스 테스트 공식 문서</a></p>
<hr>
<h2 id="7-user_metadata로-중요한-권한을-판단한다">7. user_metadata로 중요한 권한을 판단한다</h2>
<p>Supabase Auth의 사용자 정보에는 다음과 같은 메타데이터가 있습니다.</p>
<ul>
<li><code>raw_user_meta_data</code></li>
<li><code>raw_app_meta_data</code></li>
</ul>
<p>두 값은 이름이 비슷하지만 권한 관리에서는 중요한 차이가 있습니다.</p>
<h3 id="raw_user_meta_data"><code>raw_user_meta_data</code></h3>
<p>인증된 사용자가 사용자 정보 업데이트 기능을 통해 변경할 수 있습니다.</p>
<p>따라서 다음과 같은 중요한 권한을 저장하고 신뢰하면 안 됩니다.</p>
<pre><code class="language-json">{
  &quot;role&quot;: &quot;admin&quot;
}</code></pre>
<p>사용자가 수정할 수 있는 값을 기준으로 관리자 권한을 판단하면 권한 상승 문제가 발생할 수 있습니다.</p>
<h3 id="raw_app_meta_data"><code>raw_app_meta_data</code></h3>
<p>일반 사용자가 직접 수정할 수 없으므로 권한 정보에 사용할 수 있습니다.</p>
<p>다만 메타데이터를 JWT에서 읽는 경우 변경된 값이 기존 토큰에 즉시 반영된다고 가정해서는 안 됩니다. 기존 JWT는 갱신되기 전까지 이전 정보를 포함할 수 있습니다.</p>
<p>서비스 구조에 따라 다음과 같은 방법을 검토할 수 있습니다.</p>
<ul>
<li>서버에서만 관리하는 <code>app_metadata</code></li>
<li>별도의 역할·권한 테이블</li>
<li>관리자 서버를 통한 권한 변경</li>
<li>권한 변경 후 세션과 토큰 갱신 고려</li>
</ul>
<blockquote>
<p>사용자 프로필 정보와 서비스 권한 정보는 분리해서 생각하는 것이 좋습니다.</p>
</blockquote>
<hr>
<h2 id="8-service_role로-rls가-동작하는지-테스트한다">8. service_role로 RLS가 동작하는지 테스트한다</h2>
<p>기존 <code>service_role</code> 키와 새로운 <code>secret key</code>는 높은 권한을 가진 서버용 키입니다.</p>
<p>이 키를 이용한 요청은 <code>service_role</code> 역할을 사용하며 RLS를 우회할 수 있습니다. 따라서 해당 키로 데이터를 조회해보고 RLS가 정상이라고 판단하면 안 됩니다.</p>
<h3 id="올바른-테스트-조건">올바른 테스트 조건</h3>
<ul>
<li>비로그인 요청: 공개용 Key, 사용자 JWT 없음</li>
<li>사용자 요청: 공개용 Key와 해당 사용자의 JWT</li>
<li>관리자 서버 요청: 필요한 경우에만 Secret 또는 <code>service_role</code> Key</li>
</ul>
<p>RLS 점검은 실제 사용자가 사용하는 공개용 Key와 사용자 세션으로 진행해야 합니다.</p>
<p><code>service_role</code> 또는 Secret key는 다음 위치에 포함하면 안 됩니다.</p>
<ul>
<li>브라우저 JavaScript</li>
<li>모바일 앱 코드</li>
<li>공개 저장소</li>
<li>URL과 쿼리 파라미터</li>
<li>사용자에게 배포되는 실행 파일</li>
</ul>
<p><a href="https://supabase.com/docs/guides/api/api-keys/">Supabase API Key 공식 문서</a></p>
<hr>
<h2 id="9-일반-테이블의-rls가-storage-파일도-보호한다고-생각한다">9. 일반 테이블의 RLS가 Storage 파일도 보호한다고 생각한다</h2>
<p>Supabase Storage의 접근 권한은 일반 서비스 테이블의 정책과 별도로 관리됩니다.</p>
<p>Storage 객체 접근 정책은 주로 <code>storage.objects</code> 테이블에 설정합니다.</p>
<pre><code class="language-sql">create policy &quot;Users can view their own files&quot;
on storage.objects
for select
to authenticated
using ((select auth.uid())::text = owner_id);</code></pre>
<p>실제 정책은 버킷, 폴더 구조, 객체 소유 방식에 맞게 작성해야 합니다.</p>
<h3 id="private-버킷">Private 버킷</h3>
<p>Private 버킷은 파일 다운로드를 포함한 작업에 접근 제어가 적용됩니다. 인증된 다운로드 또는 제한된 시간 동안 유효한 Signed URL을 사용할 수 있습니다.</p>
<h3 id="public-버킷">Public 버킷</h3>
<p>Public 버킷은 파일 조회와 제공 과정에서 접근 제어를 우회합니다. 파일 URL을 가진 사용자가 파일에 접근할 수 있습니다.</p>
<p>다만 Public 버킷이라고 해서 업로드, 삭제, 이동, 복사까지 모두 공개되는 것은 아닙니다. 이러한 작업에는 여전히 접근 정책이 적용됩니다.</p>
<p>또한 파일을 <code>upsert</code> 방식으로 덮어쓰려면 <code>INSERT</code>뿐 아니라 <code>SELECT</code>, <code>UPDATE</code> 권한도 필요할 수 있습니다.</p>
<p><a href="https://supabase.com/docs/guides/storage/security/access-control">Supabase Storage 접근 제어 공식 문서</a></p>
<p><a href="https://supabase.com/docs/guides/storage/buckets/fundamentals">Supabase Storage 버킷 공식 문서</a></p>
<hr>
<h2 id="10-로그인-상태에서만-테스트한다">10. 로그인 상태에서만 테스트한다</h2>
<p>Supabase는 요청 상태에 따라 PostgreSQL 역할을 구분합니다.</p>
<ul>
<li>로그인하지 않은 요청: <code>anon</code></li>
<li>로그인한 요청: <code>authenticated</code></li>
</ul>
<p>로그인 상태에서만 테스트하면 <code>anon</code> 역할에 잘못 허용된 정책을 발견하지 못할 수 있습니다.</p>
<h3 id="익명-로그인은-별도로-구분해야-한다">익명 로그인은 별도로 구분해야 한다</h3>
<p>Supabase Auth의 <code>signInAnonymously()</code>로 생성한 익명 사용자는 로그인하지 않은 <code>anon</code> 역할과 다릅니다.</p>
<p>익명 로그인 사용자도 사용자 ID와 JWT를 가지며 데이터베이스에서는 <code>authenticated</code> 역할을 사용합니다.</p>
<p>익명 사용자와 일반 가입자를 구분해야 한다면 JWT의 <code>is_anonymous</code> 값을 확인하는 정책이 필요합니다.</p>
<h3 id="다음-상태를-나누어-테스트하세요">다음 상태를 나누어 테스트하세요</h3>
<ol>
<li>로그인하지 않은 방문자</li>
<li>익명 로그인 사용자</li>
<li>일반 사용자 A</li>
<li>일반 사용자 B</li>
<li>관리자 또는 서버 역할</li>
<li>세션이 만료된 사용자</li>
</ol>
<p>인증 정보가 없거나 세션이 만료된 요청에서 <code>auth.uid()</code>는 <code>null</code>을 반환합니다.</p>
<p>정책의 의도를 명확히 하려면 적용 역할을 <code>TO</code> 절로 지정하고, 필요한 경우 인증 상태도 명시적으로 확인하는 것이 좋습니다.</p>
<p><a href="https://supabase.com/docs/guides/auth/auth-anonymous">Supabase 익명 로그인 공식 문서</a></p>
<hr>
<h2 id="실제-프로젝트에-적용할-정책-예시">실제 프로젝트에 적용할 정책 예시</h2>
<p>다음 예시는 <code>bookings</code> 테이블에서 사용자가 자신의 예약만 관리하도록 제한하는 기본 형태입니다.</p>
<pre><code class="language-sql">alter table public.bookings enable row level security;

create policy &quot;Users can view their own bookings&quot;
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);

create policy &quot;Users can create their own bookings&quot;
on public.bookings
for insert
to authenticated
with check ((select auth.uid()) = user_id);

create policy &quot;Users can update their own bookings&quot;
on public.bookings
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

create policy &quot;Users can delete their own bookings&quot;
on public.bookings
for delete
to authenticated
using ((select auth.uid()) = user_id);</code></pre>
<p>이 코드를 그대로 모든 서비스에 적용해서는 안 됩니다.</p>
<p>다음 조건에 따라 필요한 정책이 달라집니다.</p>
<ul>
<li>관리자가 모든 예약을 관리하는가</li>
<li>팀원이 데이터를 공유하는가</li>
<li>일부 데이터는 공개되어야 하는가</li>
<li>익명 사용자의 등록을 허용하는가</li>
<li>사용자가 삭제할 수 없는 기록이 있는가</li>
</ul>
<p>먼저 서비스의 권한 구조를 문장으로 정리한 뒤 정책으로 옮기는 것이 좋습니다.</p>
<pre><code class="language-text">사용자는 자신의 예약만 조회할 수 있다.
사용자는 자신의 예약만 등록할 수 있다.
사용자는 예약 확정 후에는 내용을 수정할 수 없다.
관리자는 모든 예약을 조회하고 상태를 변경할 수 있다.</code></pre>
<hr>
<h2 id="배포-전-rls-체크리스트">배포 전 RLS 체크리스트</h2>
<ul>
<li><input disabled="" type="checkbox"> API에 노출된 모든 테이블의 RLS 상태를 확인했다</li>
<li><input disabled="" type="checkbox"> SQL이나 Migration으로 만든 테이블도 확인했다</li>
<li><input disabled="" type="checkbox"> <code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 정책을 각각 검토했다</li>
<li><input disabled="" type="checkbox"> <code>USING</code>과 <code>WITH CHECK</code>의 목적을 구분했다</li>
<li><input disabled="" type="checkbox"> <code>using (true)</code>처럼 범위가 넓은 정책을 확인했다</li>
<li><input disabled="" type="checkbox"> 같은 작업에 적용되는 여러 정책을 함께 검토했다</li>
<li><input disabled="" type="checkbox"> <code>user_metadata</code>를 중요한 권한 판단에 사용하지 않았다</li>
<li><input disabled="" type="checkbox"> 공개용 Key와 실제 사용자 JWT로 테스트했다</li>
<li><input disabled="" type="checkbox"> 사용자 A와 B 사이의 교차 접근을 테스트했다</li>
<li><input disabled="" type="checkbox"> 로그아웃 상태의 접근을 테스트했다</li>
<li><input disabled="" type="checkbox"> 익명 로그인을 사용한다면 일반 사용자와 구분했다</li>
<li><input disabled="" type="checkbox"> Storage의 버킷 유형과 별도 정책을 확인했다</li>
<li><input disabled="" type="checkbox"> Security Advisor의 경고를 검토했다</li>
<li><input disabled="" type="checkbox"> 중요한 정책은 자동화된 테스트로 검증할 수 있다</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>RLS는 활성화 버튼을 누르는 것으로 끝나는 설정이 아닙니다.</p>
<p>다음 질문에 명확하게 답할 수 있어야 합니다.</p>
<blockquote>
<p>누가, 어떤 데이터에, 어떤 작업을 할 수 있는가?</p>
</blockquote>
<p>정책을 작성한 뒤에는 설정 화면만 확인하지 말고 비로그인 상태와 서로 다른 사용자 계정으로 실제 요청을 테스트해야 합니다.</p>
<p>처음부터 모든 정책을 검토하기 어렵다면 다음 세 가지부터 확인해보세요.</p>
<ol>
<li>사용자 B가 사용자 A의 데이터를 조회할 수 있는가</li>
<li>사용자 B가 사용자 A의 데이터를 수정하거나 삭제할 수 있는가</li>
<li>로그인하지 않은 상태에서 사용자 데이터에 접근할 수 있는가</li>
</ol>
<p>세 질문 중 하나라도 의도와 다른 결과가 나온다면 배포 전에 해당 테이블의 RLS 정책을 다시 확인해야 합니다.</p>
<hr>
<h2 id="참고한-공식-문서">참고한 공식 문서</h2>
<ul>
<li><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase Row Level Security</a></li>
<li><a href="https://supabase.com/docs/guides/api/api-keys/">Supabase API Keys</a></li>
<li><a href="https://supabase.com/docs/guides/storage/security/access-control">Supabase Storage Access Control</a></li>
<li><a href="https://supabase.com/docs/guides/storage/buckets/fundamentals">Supabase Storage Buckets</a></li>
<li><a href="https://supabase.com/docs/guides/auth/auth-anonymous">Supabase Anonymous Sign-Ins</a></li>
<li><a href="https://supabase.com/docs/guides/database/testing">Supabase Database Testing</a></li>
<li><a href="https://supabase.com/docs/guides/database/database-advisors">Supabase Performance and Security Advisors</a></li>
<li><a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL Row Security Policies</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase 자주 하는 실수 10가지]]></title>
            <link>https://velog.io/@caffeine_coder/supabase-common-mistakes-before-deploy</link>
            <guid>https://velog.io/@caffeine_coder/supabase-common-mistakes-before-deploy</guid>
            <pubDate>Fri, 19 Jun 2026 00:34:59 GMT</pubDate>
            <description><![CDATA[<p>Supabase는 데이터베이스, 인증, 파일 저장소, API를 한곳에서 사용할 수 있어 빠르게 웹앱을 만들 때 유용합니다.</p>
<p>특히 AI 개발 도구와 함께 사용하면 백엔드를 처음 다루는 사람도 회원가입, 문의폼, 예약 기능을 비교적 빠르게 구현할 수 있습니다.</p>
<p>하지만 <strong>기능이 동작하는 것과 안전하게 운영할 수 있는 것은 다른 문제</strong>입니다.</p>
<p>Supabase 자체가 위험한 것은 아닙니다. 대부분의 문제는 API 키, 권한, Storage, 인증 설정을 정확히 이해하지 못한 상태에서 배포할 때 발생합니다.</p>
<p>이번 글에서는 Supabase로 웹앱을 만들 때 자주 하는 실수 10가지를 정리해보겠습니다.</p>
<hr>
<h2 id="1-공개용-api-키가-보인다는-이유만으로-유출이라고-생각한다">1. 공개용 API 키가 보인다는 이유만으로 유출이라고 생각한다</h2>
<p>Supabase 프로젝트를 프론트엔드에 연결하면 API 키가 브라우저 코드나 네트워크 요청에서 보일 수 있습니다.</p>
<p>처음 발견하면 키가 유출됐다고 생각하기 쉽지만, Supabase의 <code>publishable key</code>는 웹과 모바일 앱에서 공개되는 것을 전제로 만들어진 키입니다. 기존 프로젝트의 <code>anon key</code>도 같은 용도로 사용됩니다.</p>
<p>중요한 것은 키가 보이느냐가 아니라 <strong>그 키로 어떤 데이터에 접근할 수 있느냐</strong>입니다.</p>
<h3 id="이렇게-확인해보세요">이렇게 확인해보세요</h3>
<ul>
<li>프론트엔드에는 <code>publishable key</code> 또는 기존 <code>anon key</code>만 사용했는가</li>
<li>비로그인 사용자가 조회하거나 수정할 수 있는 데이터는 무엇인가</li>
<li><code>anon</code>, <code>authenticated</code> 역할에 필요한 권한만 허용했는가</li>
<li>RLS 정책이 실제 사용자 권한에 맞게 설정되어 있는가</li>
</ul>
<blockquote>
<p>공개용 키는 숨기는 것보다 권한을 제한하는 것이 핵심입니다.</p>
</blockquote>
<p><a href="https://supabase.com/docs/guides/api/api-keys/">Supabase API 키 공식 문서</a></p>
<hr>
<h2 id="2-비밀-키를-프론트엔드-환경변수에-넣는다">2. 비밀 키를 프론트엔드 환경변수에 넣는다</h2>
<p><code>secret key</code>와 기존 <code>service_role key</code>는 공개용 키와 다릅니다.</p>
<p>이 키들은 높은 권한을 가지며 RLS를 우회할 수 있으므로, 브라우저나 모바일 앱에 포함되면 안 됩니다.</p>
<p>환경변수에 넣었다고 모두 비밀이 되는 것도 아닙니다. 프론트엔드 빌드에 포함되는 공개 환경변수는 최종 JavaScript 파일에서 확인될 수 있습니다.</p>
<h3 id="프론트엔드에-있으면-안-되는-값">프론트엔드에 있으면 안 되는 값</h3>
<ul>
<li><code>sb_secret_...</code> 형식의 Secret key</li>
<li>기존 <code>service_role</code> key</li>
<li>데이터베이스 비밀번호</li>
<li>JWT Secret</li>
<li>외부 서비스의 관리자용 API 키</li>
</ul>
<p>이 값들은 서버, Edge Function 또는 접근이 통제된 백엔드에서만 사용해야 합니다.</p>
<h3 id="이렇게-확인해보세요-1">이렇게 확인해보세요</h3>
<p>프로젝트 전체에서 다음 문자열을 검색합니다.</p>
<pre><code class="language-text">service_role
sb_secret_
DATABASE_URL
DB_PASSWORD
JWT_SECRET</code></pre>
<p>Git 저장소에 커밋된 적이 있다면 코드에서 지우는 것만으로 끝나지 않습니다. 해당 키를 교체하고 커밋 기록과 배포 환경도 함께 확인해야 합니다.</p>
<hr>
<h2 id="3-로그인-기능을-만들면-데이터-권한도-해결됐다고-생각한다">3. 로그인 기능을 만들면 데이터 권한도 해결됐다고 생각한다</h2>
<p>로그인과 권한은 서로 다른 문제입니다.</p>
<ul>
<li><strong>인증(Authentication)</strong>: 이 사용자가 누구인지 확인</li>
<li><strong>인가(Authorization)</strong>: 이 사용자가 어떤 데이터에 접근할 수 있는지 결정</li>
</ul>
<p>Supabase Auth로 로그인에 성공했다고 해서 사용자가 자신의 데이터만 볼 수 있도록 자동 설정되는 것은 아닙니다.</p>
<p>예를 들어 예약 목록을 조회하는 기능에서 사용자 조건이 빠지면, 로그인한 사용자가 다른 사용자의 예약까지 조회할 수 있습니다.</p>
<h3 id="이렇게-확인해보세요-2">이렇게 확인해보세요</h3>
<ol>
<li>A 계정으로 데이터를 생성한다.</li>
<li>B 계정으로 로그인한다.</li>
<li>B 계정이 A의 데이터를 조회할 수 있는지 확인한다.</li>
<li>조회뿐 아니라 수정과 삭제도 각각 확인한다.</li>
</ol>
<blockquote>
<p>로그인 성공 여부보다 다른 사용자의 데이터에 접근할 수 없는지를 확인해야 합니다.</p>
</blockquote>
<p><a href="https://supabase.com/docs/guides/auth/">Supabase Auth 공식 문서</a></p>
<hr>
<h2 id="4-rls를-켜기만-하면-안전하다고-생각한다">4. RLS를 켜기만 하면 안전하다고 생각한다</h2>
<p>RLS(Row Level Security)는 사용자마다 접근할 수 있는 행을 제한하는 PostgreSQL 기능입니다.</p>
<p>Supabase의 <code>public</code> 스키마처럼 API에 노출된 테이블은 RLS 설정이 중요합니다.</p>
<p>Dashboard의 Table Editor로 만든 테이블은 RLS가 기본 활성화될 수 있지만, SQL Editor나 직접 작성한 SQL로 만든 테이블은 별도로 활성화해야 할 수 있습니다.</p>
<p>RLS를 켰더라도 정책을 지나치게 넓게 작성하면 데이터가 그대로 노출될 수 있습니다.</p>
<pre><code class="language-sql">using (true)</code></pre>
<p>이 조건은 적용 대상 역할과 작업에 따라 모든 행을 허용할 수 있습니다.</p>
<h3 id="테이블마다-확인할-항목">테이블마다 확인할 항목</h3>
<ul>
<li>RLS가 활성화되어 있는가</li>
<li><code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 정책을 구분했는가</li>
<li>정책 대상이 <code>anon</code>인지 <code>authenticated</code>인지 명확한가</li>
<li>사용자의 <code>id</code>와 데이터의 소유자 컬럼을 비교하는가</li>
<li>비로그인, 사용자 A, 사용자 B 상태에서 각각 테스트했는가</li>
</ul>
<p>권한 판단에 사용자가 직접 수정할 수 있는 <code>user_metadata</code>를 사용하는 것도 주의해야 합니다. 역할과 같은 중요한 권한 정보는 사용자가 임의로 바꿀 수 없는 데이터를 기준으로 판단해야 합니다.</p>
<p><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS 공식 문서</a></p>
<hr>
<h2 id="5-storage-버킷을-공개로-만들고-파일명만-숨긴다">5. Storage 버킷을 공개로 만들고 파일명만 숨긴다</h2>
<p>Supabase Storage에는 Public 버킷과 Private 버킷이 있습니다.</p>
<p>Public 버킷의 파일은 URL을 알고 있다면 누구나 조회할 수 있습니다. 파일명을 복잡하게 만들었다고 접근 권한이 생기는 것은 아닙니다.</p>
<h3 id="public으로-사용할-수-있는-예">Public으로 사용할 수 있는 예</h3>
<ul>
<li>공개 프로필 이미지</li>
<li>블로그 대표 이미지</li>
<li>누구나 볼 수 있는 서비스 이미지</li>
</ul>
<h3 id="private으로-관리해야-하는-예">Private으로 관리해야 하는 예</h3>
<ul>
<li>계약서</li>
<li>상담 첨부파일</li>
<li>신분증이나 증빙자료</li>
<li>사용자 개인 문서</li>
<li>외부에 공개하면 안 되는 이미지</li>
</ul>
<p>Private 버킷은 RLS 정책과 인증 정보를 이용해 접근을 통제할 수 있으며, 일정 시간만 유효한 Signed URL을 사용할 수도 있습니다.</p>
<h3 id="이렇게-확인해보세요-3">이렇게 확인해보세요</h3>
<ul>
<li>로그아웃 상태에서 파일 URL이 열리는가</li>
<li>다른 사용자 계정으로 같은 파일을 조회할 수 있는가</li>
<li>업로드뿐 아니라 조회, 수정, 삭제 정책도 설정했는가</li>
<li>파일 크기와 허용할 파일 형식을 제한했는가</li>
</ul>
<p><a href="https://supabase.com/docs/guides/storage/buckets/fundamentals">Supabase Storage 버킷 공식 문서</a></p>
<hr>
<h2 id="6-화면의-입력값-검사만-믿는다">6. 화면의 입력값 검사만 믿는다</h2>
<p>회원가입이나 문의폼에서 글자 수와 이메일 형식을 검사했다고 해도 브라우저의 검증만으로는 충분하지 않습니다.</p>
<p>사용자는 화면을 거치지 않고 Supabase API에 직접 요청할 수 있기 때문입니다.</p>
<h3 id="함께-적용할-수-있는-방법">함께 적용할 수 있는 방법</h3>
<ul>
<li>데이터베이스의 <code>NOT NULL</code>, <code>UNIQUE</code>, <code>CHECK</code> 제약조건</li>
<li>RLS의 <code>WITH CHECK</code> 정책</li>
<li>서버 또는 Edge Function에서의 입력값 검증</li>
<li>허용할 길이, 형식, 값의 범위 제한</li>
<li>중복 요청과 과도한 요청 제한</li>
</ul>
<p>예를 들어 예약 인원을 화면에서 1명 이상으로 제한했다면 데이터베이스에도 같은 제약조건을 둘 수 있습니다.</p>
<pre><code class="language-sql">check (guest_count &gt;= 1)</code></pre>
<blockquote>
<p>화면 검증은 사용자의 실수를 줄이고, 서버와 데이터베이스 검증은 잘못된 요청이 저장되는 것을 막습니다.</p>
</blockquote>
<hr>
<h2 id="7-인증-리디렉션과-요청-제한을-기본값으로-둔다">7. 인증 리디렉션과 요청 제한을 기본값으로 둔다</h2>
<p>이메일 인증, 비밀번호 재설정, 소셜 로그인을 사용하면 인증 후 사용자를 돌려보낼 URL을 설정하게 됩니다.</p>
<p>개발 중 사용한 localhost 주소나 임시 배포 주소가 운영 설정에 남아 있으면 인증 링크가 잘못된 화면으로 연결될 수 있습니다.</p>
<h3 id="확인할-항목">확인할 항목</h3>
<ul>
<li>운영 환경의 <code>Site URL</code>이 정확한가</li>
<li>허용된 Redirect URL만 등록되어 있는가</li>
<li>사용하지 않는 개발·미리보기 주소가 남아 있지 않은가</li>
<li>비밀번호 재설정 링크가 올바른 화면으로 이동하는가</li>
<li>이메일과 OTP 요청 제한이 서비스 상황에 맞는가</li>
</ul>
<p>Supabase Auth에는 인증 API에 대한 기본 Rate Limit이 있지만, 이것만으로 서비스의 모든 API와 폼이 보호되는 것은 아닙니다.</p>
<p>문의 등록이나 예약 생성처럼 직접 만든 기능은 별도의 요청 제한이 필요할 수 있습니다.</p>
<p><a href="https://supabase.com/docs/guides/auth/redirect-urls">Redirect URL 공식 문서</a></p>
<p><a href="https://supabase.com/docs/guides/auth/rate-limits">Auth Rate Limit 공식 문서</a></p>
<hr>
<h2 id="8-운영-db를-dashboard에서-직접-수정하고-기록을-남기지-않는다">8. 운영 DB를 Dashboard에서 직접 수정하고 기록을 남기지 않는다</h2>
<p>초기 개발에서는 Dashboard에서 테이블과 컬럼을 직접 수정하는 것이 편리합니다.</p>
<p>하지만 서비스가 운영되기 시작하면 누가 어떤 구조를 변경했는지 추적하기 어려워집니다. 개발 환경과 운영 환경의 구조가 달라지는 문제도 생길 수 있습니다.</p>
<p>Supabase는 데이터베이스 변경사항을 Migration 파일로 관리할 수 있습니다.</p>
<pre><code class="language-text">supabase/migrations/</code></pre>
<h3 id="migration으로-관리하면-좋은-항목">Migration으로 관리하면 좋은 항목</h3>
<ul>
<li>테이블과 컬럼 생성</li>
<li>인덱스와 제약조건</li>
<li>RLS 활성화</li>
<li>접근 정책</li>
<li>데이터베이스 함수와 트리거</li>
</ul>
<p>Dashboard에서 작업하더라도 변경 내용을 Migration으로 남겨 Git에서 관리하면 동일한 환경을 다시 만들고 변경 이력을 검토하기 쉬워집니다.</p>
<p><a href="https://supabase.com/docs/guides/local-development/overview">Supabase Migration 공식 문서</a></p>
<hr>
<h2 id="9-자동-백업이-모든-데이터를-복구해준다고-생각한다">9. 자동 백업이 모든 데이터를 복구해준다고 생각한다</h2>
<p>백업은 요금제와 설정에 따라 제공 범위와 보관 기간이 다릅니다. 따라서 프로젝트의 현재 백업 정책을 직접 확인해야 합니다.</p>
<p>특히 주의할 점은 <strong>데이터베이스 백업에 Storage의 실제 파일이 포함되지 않는다는 것</strong>입니다.</p>
<p>데이터베이스에는 Storage 객체의 메타데이터가 저장되지만, 삭제된 파일 자체가 데이터베이스 복원으로 되살아나는 것은 아닙니다.</p>
<h3 id="이렇게-확인해보세요-4">이렇게 확인해보세요</h3>
<ul>
<li>현재 요금제에서 제공되는 백업 범위와 보관 기간</li>
<li>마지막 백업이 정상적으로 생성된 시점</li>
<li>복원 시 손실될 수 있는 데이터의 범위</li>
<li>Storage 파일의 별도 백업 필요 여부</li>
<li>실제 복원 절차와 예상 중단 시간</li>
<li>중요한 데이터의 외부 백업 여부</li>
</ul>
<p>무료 프로젝트라면 필요한 데이터를 정기적으로 내보내는 방법도 검토해야 합니다.</p>
<blockquote>
<p>백업이 존재하는 것과 필요한 데이터를 실제로 복구할 수 있는 것은 다릅니다.</p>
</blockquote>
<p><a href="https://supabase.com/docs/guides/platform/backups">Supabase Database Backups 공식 문서</a></p>
<hr>
<h2 id="10-기능이-동작하면-운영-준비도-끝났다고-생각한다">10. 기능이 동작하면 운영 준비도 끝났다고 생각한다</h2>
<p>개발 환경에서 회원가입과 데이터 저장이 정상적으로 동작해도 운영 환경에서는 트래픽, 비용, 장애 대응까지 확인해야 합니다.</p>
<h3 id="배포-전에-확인할-항목">배포 전에 확인할 항목</h3>
<ul>
<li>Security Advisor에 해결하지 않은 항목이 있는가</li>
<li>데이터베이스 사용량과 응답 속도를 확인하고 있는가</li>
<li>Auth, Storage, Edge Function 로그를 확인할 수 있는가</li>
<li>사용량 한도와 초과 비용을 알고 있는가</li>
<li>장애나 데이터 손실이 발생했을 때 대응 방법이 있는가</li>
<li>프로젝트 소유자가 한 사람에게만 의존하고 있지 않은가</li>
</ul>
<p>Security Advisor는 흔한 설정 문제를 찾는 데 도움이 되지만, 애플리케이션 전체의 보안을 보장하는 도구는 아닙니다. 실제 사용자 권한과 서비스 흐름은 별도로 테스트해야 합니다.</p>
<p><a href="https://supabase.com/docs/guides/platform/going-into-prod/">Supabase 운영 전 체크리스트</a></p>
<hr>
<h2 id="배포-전-최종-체크리스트">배포 전 최종 체크리스트</h2>
<p>아래 항목만이라도 자신의 프로젝트에서 직접 확인해보세요.</p>
<ul>
<li><input disabled="" type="checkbox"> 프론트엔드에는 공개용 키만 들어 있다</li>
<li><input disabled="" type="checkbox"> Secret key와 <code>service_role</code> key는 서버에서만 사용한다</li>
<li><input disabled="" type="checkbox"> 로그인과 데이터 권한을 별도로 구현했다</li>
<li><input disabled="" type="checkbox"> API에 노출된 모든 테이블의 RLS를 확인했다</li>
<li><input disabled="" type="checkbox"> 두 개 이상의 사용자 계정으로 권한을 테스트했다</li>
<li><input disabled="" type="checkbox"> Storage의 공개·비공개 범위를 구분했다</li>
<li><input disabled="" type="checkbox"> 입력값을 데이터베이스나 서버에서도 검증한다</li>
<li><input disabled="" type="checkbox"> 인증 Redirect URL과 요청 제한을 확인했다</li>
<li><input disabled="" type="checkbox"> 데이터베이스 변경사항을 Migration으로 관리한다</li>
<li><input disabled="" type="checkbox"> DB와 Storage의 백업 범위를 알고 있다</li>
<li><input disabled="" type="checkbox"> Security Advisor와 사용량을 주기적으로 확인한다</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>Supabase를 사용하면 백엔드 개발 시간을 크게 줄일 수 있습니다.</p>
<p>하지만 빠르게 구현할 수 있다는 것이 권한과 운영 설정까지 자동으로 해결된다는 뜻은 아닙니다.</p>
<p>처음부터 모든 것을 완벽하게 점검하기 어렵다면 우선 다음 세 가지부터 확인해보세요.</p>
<ol>
<li>프론트엔드에 비밀 키가 포함되어 있지 않은가</li>
<li>다른 사용자의 데이터를 조회하거나 수정할 수 없는가</li>
<li>공개되면 안 되는 Storage 파일이 로그아웃 상태에서도 열리지 않는가</li>
</ol>
<p>이 세 가지는 실제 프로젝트에 테스트 계정을 만들어 직접 확인할 수 있습니다.</p>
<p>설정 화면만 보고 안전하다고 판단하기보다, <strong>권한이 다른 사용자의 입장에서 직접 요청해보는 과정</strong>이 필요합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[문의폼, 예약폼, 회원가입이 있다면 배포 전에 꼭 확인해야 할 보안 체크리스트]]></title>
            <link>https://velog.io/@caffeine_coder/form-signup-security-checklist-before-deploy</link>
            <guid>https://velog.io/@caffeine_coder/form-signup-security-checklist-before-deploy</guid>
            <pubDate>Thu, 18 Jun 2026 01:14:26 GMT</pubDate>
            <description><![CDATA[<p>AI로 웹앱을 만들다 보면 가장 먼저 붙는 기능 중 하나가 문의폼, 예약폼, 회원가입이다.</p>
<p>이 기능들은 얼핏 보면 단순 입력창처럼 보인다.<br>이름, 이메일, 전화번호, 날짜, 요청사항 정도만 받으니까 가볍게 느껴질 수도 있다.</p>
<p>하지만 실제로는 그렇지 않다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>문의폼이나 회원가입이 붙는 순간부터, 내 서비스는 개인정보를 다루는 서비스가 된다.</strong></span></p>
</blockquote>
<p>이번 글에서는 AI로 만든 웹앱에 문의폼, 예약폼, 회원가입 기능이 있다면 배포 전에 무엇을 먼저 확인해야 하는지 정리해보려고 한다.</p>
<hr>
<h2 id="1-어떤-정보를-왜-받고-있는지-먼저-정리한다"><strong>1. 어떤 정보를 왜 받고 있는지 먼저 정리한다</strong></h2>
<p>가장 먼저 해야 할 일은 기술 설정이 아니라 <strong>수집 항목 정리</strong>다.</p>
<p>예를 들어 문의폼이라면 흔히 이런 항목이 들어간다.</p>
<ul>
<li>이름</li>
<li>이메일</li>
<li>전화번호</li>
<li>회사명</li>
<li>문의 내용</li>
</ul>
<p>예약폼은 여기에 더해질 수 있다.</p>
<ul>
<li>예약 날짜</li>
<li>예약 시간</li>
<li>인원 수</li>
<li>방문 목적</li>
<li>요청사항</li>
</ul>
<p>회원가입은 보통 이렇다.</p>
<ul>
<li>이메일</li>
<li>비밀번호</li>
<li>이름 또는 닉네임</li>
<li>전화번호</li>
<li>주소</li>
<li>프로필 정보</li>
</ul>
<p>여기서 먼저 물어야 할 질문은 간단하다.</p>
<pre><code class="language-text">이 정보가 정말 필요한가?</code></pre>
<p>예를 들어:</p>
<ul>
<li>답변을 이메일로만 보낼 거라면 전화번호가 꼭 필요한가?</li>
<li>예약 확정 문자 발송이 없다면 휴대전화 번호를 굳이 받는가?</li>
<li>회원가입에 주소 정보가 정말 필요한가?</li>
</ul>
<p>많이 받는다고 좋은 것이 아니다.<br>불필요한 정보가 많을수록 저장, 접근권한, 삭제, 노출 리스크가 같이 커진다.</p>
<hr>
<h2 id="2-브라우저-검증만-믿지-않는지-확인한다"><strong>2. 브라우저 검증만 믿지 않는지 확인한다</strong></h2>
<p>폼을 만들 때 프론트엔드에서 흔히 이런 검증을 한다.</p>
<ul>
<li>필수 입력</li>
<li>최소 길이</li>
<li>최대 길이</li>
<li>이메일 형식</li>
<li>전화번호 형식</li>
</ul>
<p>이건 UX에는 좋다.<br>하지만 보안 검증으로는 부족하다.</p>
<p>OWASP도 입력값 검증은 반드시 <strong>서버 측에서</strong> 수행되어야 한다고 설명한다. 브라우저 측 JavaScript 검증은 우회될 수 있기 때문이다.</p>
<p>즉 이런 구조면 부족하다.</p>
<pre><code class="language-text">프론트엔드에서만 required, minLength, pattern 체크
서버는 그대로 저장</code></pre>
<p>최소한 서버에서도 확인해야 한다.</p>
<ul>
<li>입력 길이 제한</li>
<li>허용 문자/형식 검증</li>
<li>날짜/시간/인원 수 범위 검증</li>
<li>선택값 allowlist 검증</li>
<li>비정상적으로 큰 payload 차단</li>
</ul>
<p>예를 들어 예약폼이라면 이런 식의 의미 검증도 필요하다.</p>
<pre><code class="language-text">예약 날짜가 과거 날짜는 아닌가?
인원 수가 0명 또는 10000명은 아닌가?
선택 가능한 시간대만 허용하는가?</code></pre>
<p>기술적으로 동작하는 것과, 이상한 입력을 막는 것은 다르다.</p>
<hr>
<h2 id="3-입력한-정보가-어디에-저장되는지-알아야-한다"><strong>3. 입력한 정보가 어디에 저장되는지 알아야 한다</strong></h2>
<p>문의폼이나 예약폼을 만들 때 실제 저장 위치는 생각보다 다양하다.</p>
<ul>
<li>DB 테이블</li>
<li>Supabase</li>
<li>Firebase</li>
<li>Google Sheets</li>
<li>Notion</li>
<li>이메일 수신함</li>
<li>Slack/Discord 알림</li>
<li>Airtable</li>
<li>CRM</li>
</ul>
<p>문제는 UI를 만든 사람과 데이터를 저장하는 사람이 다를 수 있다는 점이다.<br>특히 AI로 빠르게 만든 프로젝트에서는 더 자주 그렇다.</p>
<p>최소한 이 질문은 답할 수 있어야 한다.</p>
<pre><code class="language-text">이 폼 데이터는 어디에 저장되는가?
누가 그 데이터를 볼 수 있는가?
운영자 외 다른 사용자가 접근할 수 있는가?</code></pre>
<p>Supabase나 Firebase를 쓴다면 이전 글에서 다룬 <code>RLS</code>, <code>Security Rules</code>가 다시 중요해진다.  <a href="https://velog.io/@caffeine_coder/supabase-firebase-auth-rules-checklist">[지난글-Supabase/Firebase를 쓴다면 꼭 봐야 할 권한 설정 문제]</a>
문의 데이터나 예약 데이터는 보통 공개 데이터가 아니다.</p>
<p>다음 상황은 다시 봐야 한다.</p>
<ul>
<li>문의 테이블이 로그인 사용자 전체에게 열려 있음</li>
<li>예약 데이터가 인증만 되면 누구나 읽힘</li>
<li>관리자 페이지 없이도 DB API로 데이터 조회 가능</li>
<li>저장소는 private라고 생각했는데 실제로는 public bucket임</li>
</ul>
<hr>
<h2 id="4-관리자-알림이-의도치-않게-개인정보를-퍼뜨리지-않는지-본다"><strong>4. 관리자 알림이 의도치 않게 개인정보를 퍼뜨리지 않는지 본다</strong></h2>
<p>폼 데이터는 DB에만 저장되지 않는 경우가 많다.</p>
<p>실무에서는 보통 이런 흐름이 붙는다.</p>
<pre><code class="language-text">사용자 제출
→ 이메일 발송
→ Slack 알림
→ Notion 기록
→ Google Sheets 저장</code></pre>
<p>문제는 이 흐름이 편한 만큼 개인정보가 넓게 퍼질 수 있다는 점이다.</p>
<p>예를 들어:</p>
<ul>
<li>문의 내용 전체가 Slack 공개 채널에 올라감</li>
<li>예약자 전화번호가 팀 전체 메일로 전송됨</li>
<li>회원가입 정보가 여러 외부 툴에 복제됨</li>
<li>관리자 알림 메일에 토큰이나 내부 링크가 포함됨</li>
</ul>
<p>특히 이메일은 전달, 포워딩, 검색, 아카이빙이 쉽기 때문에 더 조심해야 한다.</p>
<p>질문은 이것이다.</p>
<pre><code class="language-text">이 알림을 꼭 이렇게 상세하게 보내야 하는가?
모든 운영자가 이 전체 정보를 봐야 하는가?</code></pre>
<hr>
<h2 id="5-스팸과-자동-제출을-막는-장치가-있는지-확인한다"><strong>5. 스팸과 자동 제출을 막는 장치가 있는지 확인한다</strong></h2>
<p>문의폼과 예약폼은 기능상 공개되는 경우가 많다.<br>그래서 <strong>자동화 공격</strong>에 매우 취약할 수 있다.</p>
<p>대표적인 문제는 이런 것들이다.</p>
<ul>
<li>봇이 무한 제출</li>
<li>스팸 홍보 메시지 대량 발송</li>
<li>예약 시간대 점유 시도</li>
<li>회원가입 대량 생성</li>
<li>비밀번호 추측/credential stuffing</li>
<li>이메일 인증 메일 폭탄</li>
</ul>
<p>회원가입이 있다면 OWASP Authentication Cheat Sheet에서 말하는 자동화 공격 방어도 같이 봐야 한다.</p>
<p>최소한 아래 중 일부는 있어야 한다.</p>
<ul>
<li>rate limiting</li>
<li>CAPTCHA 또는 bot protection</li>
<li>이메일 인증</li>
<li>로그인/회원가입 시도 제한</li>
<li>비정상 요청 패턴 탐지</li>
<li>동일 IP/디바이스 과다 요청 차단</li>
</ul>
<p>문의폼은 “로그인 없이 누구나 써야 하니까” 더 위험할 수 있다.<br>예약폼은 “자리가 한정되어 있으니까” 비즈니스 영향이 더 클 수 있다.<br>회원가입은 “계정이 생기니까” 이후 보안 문제가 더 커질 수 있다.</p>
<hr>
<h2 id="6-로그인된-사용자의-폼이라면-csrf도-봐야-한다"><strong>6. 로그인된 사용자의 폼이라면 CSRF도 봐야 한다</strong></h2>
<p>모든 폼에 CSRF가 필요한 것은 아니다.<br>하지만 로그인된 상태에서 서버 데이터를 바꾸는 요청이라면 확인해야 한다.</p>
<p>예를 들어 이런 경우다.</p>
<ul>
<li>로그인 사용자의 프로필 수정</li>
<li>회원 정보 변경</li>
<li>예약 변경/취소</li>
<li>문의 내역 수정</li>
<li>관리자 답변 등록</li>
</ul>
<p>OWASP는 CSRF 방어에 대해 synchronizer token pattern 같은 서버 측 토큰 기반 방식을 권장한다.</p>
<p>핵심은 이거다.</p>
<pre><code class="language-text">사용자가 로그인된 상태일 때,
다른 사이트가 사용자의 브라우저를 악용해서
내 서비스에 원치 않는 요청을 보내지 못하게 해야 한다.</code></pre>
<p>즉 “폼이 있으니 제출된다”가 아니라,<br>“이 요청이 정말 우리 서비스에서 의도된 사용자 행동인가”를 확인해야 한다.</p>
<hr>
<h2 id="7-로그와-에러-메시지에-개인정보를-남기지-않는지-본다"><strong>7. 로그와 에러 메시지에 개인정보를 남기지 않는지 본다</strong></h2>
<p>폼 기능을 붙이면 디버깅을 위해 로그를 남기고 싶어진다.</p>
<p>예를 들어 이런 코드가 쉽게 들어간다.</p>
<pre><code class="language-js">console.log(&quot;signup payload&quot;, req.body)
console.log(&quot;inquiry submitted&quot;, formData)</code></pre>
<p>개발 중에는 편하다.<br>운영에서는 위험할 수 있다.</p>
<p>OWASP Logging Cheat Sheet는 로그에 남기면 안 되는 정보로 인증 비밀번호, 토큰, DB 연결 문자열, 일부 민감한 개인정보 등을 명시한다. 이메일 주소나 전화번호 같은 비민감 개인정보도 필요 이상 남기지 말고, 필요 시 비식별화나 축약을 고려하라고 안내한다.</p>
<p>다음은 피하는 게 좋다.</p>
<ul>
<li>비밀번호 전체 로그</li>
<li>이메일/전화번호 원문 전체 로그</li>
<li>문의 내용 전체 로그</li>
<li>access token, reset token 로그</li>
<li>내부 관리자 URL이나 DB 정보가 섞인 에러 메시지</li>
<li>사용자가 입력한 내용을 그대로 에러 화면에 출력</li>
</ul>
<p>특히 회원가입 기능에서는 더 민감하다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>운영 환경 로그는 디버깅 도구이지, 개인정보 보관소가 아니다.</strong></span></p>
</blockquote>
<hr>
<h2 id="8-회원가입-기능이-있다면-추가로-확인할-것"><strong>8. 회원가입 기능이 있다면 추가로 확인할 것</strong></h2>
<p>문의폼, 예약폼보다 회원가입은 한 단계 더 무겁다.</p>
<p>이유는 단순하다.<br>사용자 계정과 인증 정보가 생기기 때문이다.</p>
<p>최소한 이것들은 봐야 한다.</p>
<h3 id="비밀번호를-직접-저장하지-않는가"><strong>비밀번호를 직접 저장하지 않는가</strong></h3>
<p>직접 인증을 구현한다면 평문 저장은 물론 안 되고, 적절한 해시 방식이 필요하다.<br>가능하면 Supabase Auth, Firebase Authentication 같은 검증된 제품을 우선 검토하는 편이 낫다.</p>
<h3 id="이메일-인증-또는-본인-확인-흐름이-있는가"><strong>이메일 인증 또는 본인 확인 흐름이 있는가</strong></h3>
<p>가입 직후 아무 검증 없이 바로 중요한 기능을 다 열어두는 구조라면 악용 가능성이 커질 수 있다.</p>
<h3 id="중복-계정-확인-응답이-과하게-친절하지-않은가"><strong>중복 계정 확인 응답이 과하게 친절하지 않은가</strong></h3>
<p>예를 들어 “이 이메일은 이미 가입되어 있습니다” 같은 메시지는 UX에는 좋지만, 경우에 따라 계정 존재 여부를 추측하는 데 쓰일 수 있다. 서비스 성격에 따라 균형을 봐야 한다.</p>
<h3 id="비밀번호-재설정-링크가-안전한가"><strong>비밀번호 재설정 링크가 안전한가</strong></h3>
<p>토큰이 짧거나, 너무 오래 유효하거나, URL이 로그에 남는 구조면 다시 봐야 한다.</p>
<hr>
<h2 id="9-삭제와-보관-기준도-미리-생각해둔다"><strong>9. 삭제와 보관 기준도 미리 생각해둔다</strong></h2>
<p>이건 초반 MVP에서 자주 놓치는 부분이다.</p>
<p>폼을 만들고 저장은 했는데, 그다음이 없다.</p>
<ul>
<li>언제 지우는가?</li>
<li>오래된 문의는 어떻게 처리하는가?</li>
<li>탈퇴한 회원 데이터는 어떻게 다루는가?</li>
<li>테스트 데이터는 언제 정리하는가?</li>
<li>스프레드시트/노션/슬랙에 흩어진 데이터는 어떻게 회수하는가?</li>
</ul>
<p>기능이 잘 되는 것만으로는 끝나지 않는다.<br>특히 문의폼과 예약폼은 생각보다 빨리 데이터가 쌓인다.</p>
<p>최소한 이런 기준은 필요하다.</p>
<pre><code class="language-text">무엇을 저장하는가
왜 저장하는가
누가 보는가
얼마나 보관하는가
언제 삭제하는가</code></pre>
<hr>
<h2 id="배포-전에-확인할-체크리스트"><strong>배포 전에 확인할 체크리스트</strong></h2>
<p>문의폼, 예약폼, 회원가입 기능이 있다면 배포 전에 아래 항목은 한 번씩 체크해보는 것이 좋다.</p>
<ul>
<li>꼭 필요한 정보만 받고 있는가</li>
<li>입력값 검증을 서버에서도 하고 있는가</li>
<li>데이터 저장 위치를 정확히 알고 있는가</li>
<li>문의/예약/회원 데이터가 관리자 외 다른 사용자에게 보이지 않는가</li>
<li>이메일/Slack/Notion/Sheets로 개인정보가 과하게 퍼지지 않는가</li>
<li>rate limiting, CAPTCHA, 이메일 인증 같은 자동화 방어가 있는가</li>
<li>로그인된 상태에서 데이터 변경이 일어나는 폼이라면 CSRF 방어가 있는가</li>
<li>비밀번호, 토큰, 문의 내용, 이메일, 전화번호가 로그에 과하게 남지 않는가</li>
<li>회원가입이라면 인증/비밀번호 재설정 흐름이 안전한가</li>
<li>보관/삭제 기준이 정리되어 있는가</li>
</ul>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>문의폼, 예약폼, 회원가입은 기능적으로는 단순해 보인다.</p>
<p>하지만 보안 관점에서는 이런 문제들이 한 번에 붙는다.</p>
<ul>
<li>개인정보 수집</li>
<li>접근 권한</li>
<li>스팸/자동화 공격</li>
<li>인증</li>
<li>로그 노출</li>
<li>운영자 알림 확산</li>
<li>삭제와 보관 기준</li>
</ul>
<p>즉 이런 기능을 붙이는 순간부터 내 서비스는 그냥 “화면 몇 개 있는 웹앱”이 아니라, <strong>사용자 데이터를 책임지는 서비스</strong>가 된다.</p>
<p>개인적으로는 AI로 만든 웹앱일수록 이 지점을 더 의식해야 한다고 생각한다.<br>기능은 빠르게 붙일 수 있지만, 데이터 책임은 자동으로 해결되지 않기 때문이다.</p>
<p>다음 글에서는 관리자 페이지를 만들었다면 배포 전에 꼭 확인해야 할 보안 포인트를 정리해보려고 한다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html">OWASP Input Validation Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html">OWASP Authentication Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP Cross-Site Request Forgery Prevention Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html">OWASP Logging Cheat Sheet</a></li>
<li><a href="https://www.privacy.go.kr">개인정보 포털</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI로 만든 웹앱은 실제로 어디에 배포될까? 배포 플랫폼별 보안 체크포인트]]></title>
            <link>https://velog.io/@caffeine_coder/ai-webapp-deployment-security-checkpoints</link>
            <guid>https://velog.io/@caffeine_coder/ai-webapp-deployment-security-checkpoints</guid>
            <pubDate>Tue, 16 Jun 2026 15:01:03 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@caffeine_coder/supabase-firebase-auth-rules-checklist">지난 글</a>에서는 Supabase와 Firebase를 사용할 때 특히 조심해야 할 권한 설정 문제를 정리했다.</p>
<p>이번에는 한 단계 더 넓게 보려고 한다.</p>
<p>AI로 만든 웹앱은 실제로 어디에 배포될까?<br>그리고 배포 플랫폼이 달라지면 무엇을 먼저 확인해야 할까?</p>
<p>개인적으로 이 질문이 중요한 이유는, 많은 사람들이 “클라우드 보안”이라고 하면 곧바로 AWS, IAM, VPC, Security Group 같은 키워드를 떠올리기 때문이다.</p>
<p>물론 그것도 중요하다.<br>하지만 AI로 만든 초기 MVP나 사이드 프로젝트는 생각보다 그렇게 배포되지 않는 경우가 많다.</p>
<p>오히려 실제 흐름은 이런 경우가 더 많다.</p>
<ul>
<li><code>v0</code>로 만들고 <code>Vercel</code>에 배포</li>
<li><code>Replit</code>에서 만들고 <code>Replit Publishing</code>으로 공개</li>
<li><code>Lovable</code>로 만들고 <code>Lovable Cloud</code>에 배포</li>
<li>백엔드는 <code>Supabase</code>나 <code>Firebase</code>에 연결</li>
</ul>
<p>즉, <strong>AI로 만든 웹앱의 초기 보안 포인트는 raw AWS보다 상위 플랫폼 설정에 더 가까운 경우가 많다.</strong></p>
<hr>
<h2 id="ai로-만든-웹앱은-왜-상위-플랫폼에-많이-배포될까"><strong>AI로 만든 웹앱은 왜 상위 플랫폼에 많이 배포될까</strong></h2>
<p>이유는 단순하다.</p>
<p>AI로 빠르게 만든 프로젝트는 보통 이런 요구가 있다.</p>
<ul>
<li>빨리 배포하고 싶다</li>
<li>인프라 설정은 최소화하고 싶다</li>
<li>로그인, DB, 파일 업로드를 바로 붙이고 싶다</li>
<li>커스텀 도메인을 빠르게 연결하고 싶다</li>
<li>서버 운영보다 기능 구현에 집중하고 싶다</li>
</ul>
<p>이 요구에 잘 맞는 게 <code>Vercel</code>, <code>Replit</code>, <code>Lovable Cloud</code>, <code>Supabase</code>, <code>Firebase</code> 같은 관리형 플랫폼이다.</p>
<p>예를 들어:</p>
<ul>
<li><code>v0</code>는 공식 페이지에서 바로 <code>Deploy to Vercel</code> 흐름을 전면에 두고 있다.</li>
<li><code>Replit</code>은 <code>Publishing</code> 기능으로 앱을 공개하고, 공식 문서에서 배포 인프라가 <code>Google Cloud Platform</code> 기반이라고 설명한다.</li>
<li><code>Lovable</code>은 공식 문서에서 <code>Publish your Lovable project</code>, <code>Lovable Cloud</code>, <code>Supabase</code>, <code>Custom domain</code>을 별도 기능으로 안내한다.</li>
<li><code>Supabase</code>는 <code>Database</code>, <code>Auth</code>, <code>Storage</code>, <code>Edge Functions</code>를 기본 제품으로 제공한다.</li>
<li><code>Firebase</code>는 <code>Authentication</code>, <code>Security Rules</code>, <code>Firestore</code>, <code>Storage</code>, <code>Hosting</code>, <code>App Hosting</code>을 한 묶음으로 제공한다.</li>
</ul>
<p>즉, AI로 만든 웹앱은 “클라우드를 안 쓴다”가 아니라, <strong>클라우드를 더 높은 추상화 레벨에서 사용한다</strong>고 보는 게 맞다.</p>
<hr>
<h2 id="배포-플랫폼이-달라지면-보안-체크포인트도-달라진다"><strong>배포 플랫폼이 달라지면 보안 체크포인트도 달라진다</strong></h2>
<p>중요한 건 “어디에 배포했는가”에 따라 먼저 봐야 하는 포인트가 달라진다는 점이다.</p>
<p>예를 들어 <code>AWS EC2</code>에 직접 올린 앱이라면 이런 걸 먼저 볼 수 있다.</p>
<ul>
<li>보안 그룹</li>
<li>SSH 노출</li>
<li>IAM 권한</li>
<li>S3 bucket 정책</li>
<li>ALB/WAF 설정</li>
</ul>
<p>반면 <code>Vercel</code>, <code>Replit</code>, <code>Lovable</code>, <code>Supabase</code>, <code>Firebase</code> 같은 플랫폼에서는 사용자가 직접 이런 레벨을 만지지 않는 경우가 많다.</p>
<p>대신 아래 같은 질문이 더 중요해진다.</p>
<ul>
<li>환경변수가 클라이언트로 노출되지는 않는가?</li>
<li>Preview URL이나 임시 배포 URL이 외부에 열려 있지는 않은가?</li>
<li>로그인만 붙이고 권한은 비어 있지 않은가?</li>
<li>파일 업로드가 public으로 열려 있지는 않은가?</li>
<li>도메인, 인증서, 배포 환경 분리가 제대로 되어 있는가?</li>
<li>개발용 설정이 운영으로 그대로 나가지는 않았는가?</li>
</ul>
<p>이게 초기 AI 웹앱 보안의 실제 출발점에 더 가깝다.</p>
<hr>
<h2 id="1-vercel에-배포했다면-먼저-볼-것"><strong>1. Vercel에 배포했다면 먼저 볼 것</strong></h2>
<p><code>v0</code>를 쓰는 사람이라면 자연스럽게 <code>Vercel</code>로 이어지는 경우가 많다.</p>
<p>공식 페이지에서도 <code>Deploy to Vercel</code>을 직접 안내한다.</p>
<p>Vercel 계열에서 먼저 봐야 할 것은 이런 것들이다.</p>
<h3 id="환경변수-관리"><strong>환경변수 관리</strong></h3>
<p>다음 값을 <code>NEXT_PUBLIC_</code> 같은 public 환경변수로 넣고 있지 않은지 확인해야 한다.</p>
<ul>
<li>DB password</li>
<li>API secret</li>
<li>JWT secret</li>
<li>Supabase <code>service_role</code> key</li>
<li>Stripe secret key</li>
<li>관리자 토큰</li>
</ul>
<p>브라우저에 전달되는 환경변수는 사용자에게 공개된다고 봐야 한다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>Vercel에 올렸다고 안전해지는 것이 아니라, 어떤 값이 클라이언트 번들에 들어가느냐가 더 중요하다.</strong></span></p>
</blockquote>
<h3 id="preview-deployment-노출"><strong>Preview Deployment 노출</strong></h3>
<p>Vercel은 미리보기 배포가 편하다.<br>문제는 편한 만큼, 운영 전 상태의 URL이 외부에 공유되거나 노출될 수 있다는 점이다.</p>
<p>특히 아래 상황은 다시 확인할 필요가 있다.</p>
<ul>
<li>임시 URL인데 로그인 없이 접근 가능</li>
<li>테스트 데이터가 그대로 들어 있음</li>
<li>운영용 API에 연결되어 있음</li>
<li>관리자 기능이 preview에서도 살아 있음</li>
</ul>
<h3 id="source-map과-디버그-정보"><strong>Source map과 디버그 정보</strong></h3>
<p>운영 배포에 source map이나 과한 debug log가 남아 있으면, 내부 구조가 지나치게 노출될 수 있다.</p>
<p>source map이 곧바로 취약점은 아니지만, 운영 정책상 공개가 적절한지는 따로 판단해야 한다.</p>
<hr>
<h2 id="2-replit에-배포했다면-먼저-볼-것"><strong>2. Replit에 배포했다면 먼저 볼 것</strong></h2>
<p>Replit은 “만들고 바로 공개”가 쉬운 플랫폼이다.<br>공식 문서에서도 <code>Publish</code> 기능과 <code>Custom domains</code>, <code>Access controls</code>, <code>Analytics</code>를 함께 제공한다고 설명한다.</p>
<p>Replit 계열에서 먼저 봐야 할 것은 이런 것들이다.</p>
<h3 id="앱-공개-범위"><strong>앱 공개 범위</strong></h3>
<p>Replit에서는 배포를 누르는 순간 인터넷에 공개되는 흐름이 빠르다.</p>
<p>그래서 먼저 확인해야 한다.</p>
<ul>
<li>지금 이 앱이 공개 상태인가?</li>
<li>누구나 접근 가능한가?</li>
<li>테스트용 앱인데 이미 외부 URL이 살아 있는가?</li>
</ul>
<h3 id="secrets-관리"><strong>Secrets 관리</strong></h3>
<p>Replit은 secret 관리 기능을 제공하지만, 코드 안에 직접 key를 박아두는 실수는 여전히 발생할 수 있다.</p>
<p>특히 확인해야 할 것:</p>
<ul>
<li><code>.env</code>를 코드 저장소에 커밋했는가</li>
<li>Secret을 브라우저로 직접 내려주고 있지는 않은가</li>
<li>테스트용 third-party key를 그대로 운영에 쓰고 있지는 않은가</li>
</ul>
<h3 id="파일시스템에-데이터-저장"><strong>파일시스템에 데이터 저장</strong></h3>
<p>Replit 공식 문서도 배포된 앱의 파일시스템에 쓴 데이터를 영속 저장소처럼 믿지 말라고 안내한다.</p>
<p>즉 이런 구조는 다시 봐야 한다.</p>
<pre><code class="language-text">업로드 파일을 로컬 파일시스템에 저장
문의 데이터를 앱 내부 파일에 저장
세션 상태를 파일에만 기록</code></pre>
<p>이런 건 보안 문제이기도 하고 운영 안정성 문제이기도 하다.</p>
<hr>
<h2 id="3-lovable로-배포했다면-먼저-볼-것"><strong>3. Lovable로 배포했다면 먼저 볼 것</strong></h2>
<p>Lovable은 단순 코드 생성기를 넘어서, <code>Lovable Cloud</code>, <code>Publish</code>, <code>Custom domain</code>, <code>Security overview</code>, <code>Test and Live environments</code> 같은 기능을 문서에서 직접 제공한다.</p>
<p>이건 좋은 신호이기도 하다.<br>즉, 보안과 배포를 기능으로 다루고 있다는 뜻이다.</p>
<p>다만 그만큼 사용자가 직접 확인할 것도 있다.</p>
<h3 id="테스트-환경과-운영-환경-분리"><strong>테스트 환경과 운영 환경 분리</strong></h3>
<p>Lovable 문서에는 <code>Test and Live environments</code> 기능이 있다.</p>
<p>이게 중요한 이유는, 많은 초기 프로젝트가 아래처럼 꼬이기 쉽기 때문이다.</p>
<ul>
<li>테스트 중인데 운영 데이터에 붙어 있음</li>
<li>내부 검토용 앱인데 이미 실서비스 도메인에 연결됨</li>
<li>실험용 기능이 라이브 환경에 반영됨</li>
</ul>
<p>AI로 빠르게 만드는 흐름에서는 특히 더 자주 생길 수 있다.</p>
<h3 id="security-overview를-실제로-보는가"><strong>Security overview를 실제로 보는가</strong></h3>
<p>Lovable 문서에는 <code>Security overview</code>, <code>Security center</code>, <code>Sensitive data scanning</code>, <code>Project security view</code>가 있다.</p>
<p>문제는 기능이 존재하는 것과 실제로 확인하는 것은 다르다는 점이다.</p>
<p>배포 전에 최소한 이런 질문은 해야 한다.</p>
<ul>
<li>민감정보 스캔 결과를 봤는가?</li>
<li>보안 관련 경고를 무시하고 배포하지 않았는가?</li>
<li>외부 공유 설정이 과하게 열려 있지 않은가?</li>
<li>프로젝트 접근 범위가 적절한가?</li>
</ul>
<h3 id="supabase-연동-상태"><strong>Supabase 연동 상태</strong></h3>
<p>Lovable 프로젝트는 Supabase와 연결되는 경우가 많다.<br>그렇다면 결국 보안 핵심은 다시 <code>RLS</code>, <code>Storage</code>, <code>key 권한</code>으로 돌아온다.</p>
<p>즉 Lovable 보안은 Lovable 자체만 보면 끝나는 게 아니라, <strong>연결된 Supabase 설정까지 같이 봐야 한다.</strong></p>
<hr>
<h2 id="4-supabase를-백엔드로-쓴다면-먼저-볼-것"><strong>4. Supabase를 백엔드로 쓴다면 먼저 볼 것</strong></h2>
<p>Supabase는 AI로 만든 웹앱에서 가장 자주 붙는 백엔드 중 하나다.</p>
<p>이 경우 핵심은 명확하다.</p>
<h3 id="rls가-필요한-테이블에-켜져-있는가"><strong>RLS가 필요한 테이블에 켜져 있는가</strong></h3>
<ul>
<li>사용자 데이터가 들어가는 테이블</li>
<li>주문, 예약, 문의, 결제, 업로드 메타데이터</li>
<li>관리자 외에는 보면 안 되는 데이터</li>
</ul>
<h3 id="정책이-너무-넓지-않은가"><strong>정책이 너무 넓지 않은가</strong></h3>
<ul>
<li><code>using (true)</code></li>
<li><code>to authenticated using (true)</code></li>
</ul>
<h3 id="서버-전용-key가-클라이언트에-있지-않은가"><strong>서버 전용 key가 클라이언트에 있지 않은가</strong></h3>
<ul>
<li><code>service_role</code></li>
<li>secret key</li>
</ul>
<h3 id="storage-bucket이-public이어도-되는가"><strong>Storage bucket이 public이어도 되는가</strong></h3>
<ul>
<li>프로필 이미지는 공개 가능할 수 있음</li>
<li>계약서, 상담 파일, 증빙자료는 별도 판단 필요</li>
</ul>
<p>Supabase에서는 결국 질문이 이거다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>이 사용자가 이 row와 이 파일에 접근할 수 있어야 하는가?</strong></span></p>
</blockquote>
<hr>
<h2 id="5-firebase를-백엔드로-쓴다면-먼저-볼-것"><strong>5. Firebase를 백엔드로 쓴다면 먼저 볼 것</strong></h2>
<p>Firebase 역시 AI와 함께 빠르게 MVP를 만들 때 자주 선택된다.</p>
<p>이 경우 먼저 보는 건 <code>Security Rules</code>다.</p>
<h3 id="개발용-open-rule이-남아-있지-않은가"><strong>개발용 open rule이 남아 있지 않은가</strong></h3>
<pre><code class="language-js">allow read, write: if true;</code></pre>
<h3 id="로그인만-확인하고-끝내지-않았는가"><strong>로그인만 확인하고 끝내지 않았는가</strong></h3>
<pre><code class="language-js">allow read, write: if request.auth != null;</code></pre>
<p>이건 “로그인하면 다 됨”에 가까울 수 있다.</p>
<h3 id="사용자별-데이터에-uid-조건이-있는가"><strong>사용자별 데이터에 uid 조건이 있는가</strong></h3>
<pre><code class="language-js">request.auth.uid == userId</code></pre>
<h3 id="storage-rules도-따로-확인했는가"><strong>Storage Rules도 따로 확인했는가</strong></h3>
<p>Firestore만 보고 끝내면 안 된다.<br>파일 업로드는 또 다른 권한 경계다.</p>
<h3 id="클라이언트가-보내는-role-값을-믿고-있지-않은가"><strong>클라이언트가 보내는 role 값을 믿고 있지 않은가</strong></h3>
<ul>
<li><code>role: &quot;admin&quot;</code>을 프론트에서 보내면 관리자 처리</li>
<li>버튼만 숨겨두고 실제 권한 검증은 없음</li>
</ul>
<p>Firebase는 제품 자체가 위험한 것이 아니라, <strong>Rules를 비워둔 상태로도 기능이 일단 되는 것처럼 보일 수 있다는 점</strong>이 함정이다.</p>
<hr>
<h2 id="aws-보안-글은-나중에-쓰는-게-더-나은-이유"><strong>AWS 보안 글은 나중에 쓰는 게 더 나은 이유</strong></h2>
<p>여기서 오해하면 안 되는 점이 있다.</p>
<p>AWS 보안이 덜 중요하다는 뜻은 아니다.<br>오히려 나중에 서비스가 커지면 더 중요해진다.</p>
<p>다만 지금 이 시점, 즉:</p>
<ul>
<li>AI로 웹앱을 만든 비개발자</li>
<li>빠르게 MVP를 만든 기획자/창업자</li>
<li>Supabase/Firebase/Vercel/Replit/Lovable을 쓰는 사용자</li>
</ul>
<p>이 사람들에게는 <code>IAM</code>, <code>VPC</code>, <code>NACL</code>, <code>ALB</code>, <code>Private Subnet</code>보다 먼저 봐야 하는 것이 있다.</p>
<p>그게 바로:</p>
<ul>
<li>공개 URL</li>
<li>배포 환경</li>
<li>secrets 노출</li>
<li>권한 설정</li>
<li>Storage 공개 범위</li>
<li>테스트/운영 분리</li>
</ul>
<p>즉, 초기 AI 웹앱 보안의 출발점은 <strong>플랫폼 보안 설정</strong>에 더 가깝다.</p>
<hr>
<h2 id="배포-전에-확인할-체크리스트"><strong>배포 전에 확인할 체크리스트</strong></h2>
<p>AI로 만든 웹앱이라면 배포 전에 최소한 아래 질문은 해보는 것이 좋다.</p>
<h3 id="공통-체크"><strong>공통 체크</strong></h3>
<ul>
<li>공개 URL이 이미 외부에 열려 있지 않은가?</li>
<li>테스트용 앱이 실데이터와 연결되어 있지 않은가?</li>
<li>커스텀 도메인이 올바른 앱에 연결되어 있는가?</li>
<li>HTTPS는 정상인가?</li>
<li>운영 환경 변수와 테스트 환경 변수가 분리되어 있는가?</li>
</ul>
<h3 id="프론트엔드배포-플랫폼"><strong>프론트엔드/배포 플랫폼</strong></h3>
<ul>
<li>public 환경변수에 secret이 들어가 있지 않은가?</li>
<li>preview deployment가 과도하게 공유되고 있지 않은가?</li>
<li>debug 로그와 source map 정책은 괜찮은가?</li>
</ul>
<h3 id="관리형-백엔드"><strong>관리형 백엔드</strong></h3>
<ul>
<li>Supabase RLS 또는 Firebase Security Rules가 있는가?</li>
<li>사용자별 데이터 접근 제한이 있는가?</li>
<li>업로드 파일이 public이어도 되는가?</li>
<li>service key나 admin 권한이 클라이언트에 없나?</li>
</ul>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>AI로 만든 웹앱이 모두 AWS를 직접 다루는 것은 아니다.</p>
<p>오히려 많은 초기 프로젝트는 <code>Vercel</code>, <code>Replit</code>, <code>Lovable Cloud</code>, <code>Supabase</code>, <code>Firebase</code> 같은 상위 플랫폼 위에서 만들어지고 배포된다.</p>
<p>그래서 보안도 그 현실에 맞게 봐야 한다.</p>
<p>초기 단계에서 진짜 먼저 확인해야 하는 것은 이런 것들이다.</p>
<ul>
<li>어디에 배포되었는가</li>
<li>누가 그 URL에 접근할 수 있는가</li>
<li>어떤 데이터가 public으로 열려 있는가</li>
<li>권한 설정이 실제로 존재하는가</li>
<li>secret이 브라우저로 내려가지 않는가</li>
</ul>
<p>즉 질문은 더 이상 이것만이 아니다.</p>
<pre><code class="language-text">어떤 클라우드를 쓰고 있는가?</code></pre>
<p>이제는 이것도 같이 물어야 한다.</p>
<pre><code class="language-text">이 플랫폼에서 내가 직접 확인해야 할 보안 경계는 어디인가?</code></pre>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://v0.app/">v0</a></li>
<li><a href="https://docs.replit.com/learn/projects-and-artifacts/replit-deployments">Replit Publishing</a></li>
<li><a href="https://docs.lovable.dev/llms.txt">Lovable Documentation Index</a></li>
<li><a href="https://supabase.com/docs">Supabase Docs</a></li>
<li><a href="https://firebase.google.com/docs">Firebase Docs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase/Firebase를 쓴다면 꼭 봐야 할 권한 설정 문제]]></title>
            <link>https://velog.io/@caffeine_coder/supabase-firebase-auth-rules-checklist</link>
            <guid>https://velog.io/@caffeine_coder/supabase-firebase-auth-rules-checklist</guid>
            <pubDate>Mon, 15 Jun 2026 15:17:58 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@caffeine_coder/external-web-security-checklist">지난 글</a>에서는 웹앱 배포 후 외부에서 확인할 수 있는 보안 점검 항목들을 정리했다.</p>
<p>이번 글에서는 Supabase나 Firebase처럼 <strong>클라이언트에서 직접 DB/API를 사용하는 구조</strong>에서 특히 조심해야 할 권한 설정 문제를 정리해보려고 한다.</p>
<p>먼저 Supabase와 Firebase가 무엇인지부터 간단히 짚고 넘어가자.</p>
<hr>
<h2 id="supabase와-firebase는-무엇인가"><strong>Supabase와 Firebase는 무엇인가</strong></h2>
<p>웹앱을 만들다 보면 보통 이런 기능들이 필요하다.</p>
<ul>
<li>회원가입/로그인</li>
<li>데이터베이스</li>
<li>파일 업로드</li>
<li>실시간 데이터</li>
<li>서버리스 함수</li>
<li>인증/권한 처리</li>
</ul>
<p>예전에는 이런 기능을 직접 백엔드 서버에서 많이 구현했다.</p>
<pre><code class="language-text">브라우저 → 내 백엔드 서버 → DB</code></pre>
<p>이 구조에서는 보통 백엔드 서버가 중간에서 사용자의 요청을 검사한다.</p>
<p>예를 들어 사용자가 <code>/api/orders/123</code>을 요청하면 서버가 확인한다.</p>
<pre><code class="language-text">이 사용자가 123번 주문을 볼 수 있는 사람인가?</code></pre>
<p>그런데 Supabase나 Firebase를 쓰면 구조가 조금 달라질 수 있다.</p>
<pre><code class="language-text">브라우저 → Supabase/Firebase → DB 또는 Storage</code></pre>
<p>프론트엔드 코드에서 직접 Supabase나 Firebase SDK를 호출해서 데이터를 읽고 쓰는 방식이다.</p>
<p>이 방식은 정말 편하다.<br>AI 개발 도구와도 잘 맞는다.<br>로그인, DB, 파일 업로드까지 빠르게 붙일 수 있다.</p>
<p>하지만 편한 만큼 중요한 질문이 생긴다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>누가 어떤 데이터에 접근할 수 있는지는 어디서 막고 있는가?</strong></span></p>
</blockquote>
<p>이 질문에 제대로 답하지 못하면, 기능은 잘 동작하지만 권한은 뚫려 있는 서비스가 될 수 있다.</p>
<hr>
<h2 id="로그인과-권한은-다르다"><strong>로그인과 권한은 다르다</strong></h2>
<p>가장 먼저 구분해야 할 것은 <strong>인증(Authentication)</strong> 과 <strong>인가/권한(Authorization)</strong> 이다.</p>
<p>인증은 “누구인지 확인하는 것”이다.</p>
<pre><code class="language-text">이 사용자가 로그인한 사용자인가?</code></pre>
<p>권한은 “무엇을 할 수 있는지 확인하는 것”이다.</p>
<pre><code class="language-text">이 사용자가 이 데이터를 볼 수 있는가?
이 사용자가 이 주문을 수정할 수 있는가?
이 사용자가 관리자 기능을 실행할 수 있는가?</code></pre>
<p>로그인이 되어 있다고 해서 모든 데이터에 접근할 수 있어야 하는 것은 아니다.</p>
<p>예를 들어 A 사용자가 로그인했다고 해서 B 사용자의 주문 내역을 볼 수 있으면 안 된다.</p>
<pre><code class="language-text">로그인 여부 확인: O
소유자/권한 확인: X</code></pre>
<p>이 상태가 꽤 위험하다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>“로그인한 사용자만 접근 가능”은 충분한 권한 설계가 아니다.</strong></span></p>
</blockquote>
<hr>
<h2 id="supabase에서-조심해야-할-것-rls"><strong>Supabase에서 조심해야 할 것: RLS</strong></h2>
<p>Supabase는 PostgreSQL 기반의 백엔드 플랫폼이다.<br>DB, Auth, Storage, Edge Functions 등을 제공하고, 클라이언트에서 Supabase API를 직접 호출할 수 있다.</p>
<p>Supabase에서 권한 설정의 핵심은 <strong>RLS(Row Level Security)</strong> 다.</p>
<p>RLS는 말 그대로 “행 단위 보안”이다.<br>테이블 안의 각 row에 대해, 어떤 사용자가 읽고 쓸 수 있는지 정책을 정하는 방식이다.</p>
<p>예를 들어 <code>todos</code> 테이블이 있다고 하자.</p>
<pre><code class="language-text">id | user_id | title</code></pre>
<p>이때 사용자는 자기 <code>user_id</code>에 해당하는 todo만 볼 수 있어야 한다.</p>
<p>Supabase에서는 이런 정책을 작성할 수 있다.</p>
<pre><code class="language-sql">create policy &quot;Users can view their own todos&quot;
on todos
for select
to authenticated
using ((select auth.uid()) = user_id);</code></pre>
<p>의미는 대략 이렇다.</p>
<pre><code class="language-text">로그인한 사용자만 select 가능하다.
단, auth.uid()가 row의 user_id와 같을 때만 가능하다.</code></pre>
<p>즉 단순히 “로그인했는가”가 아니라, <strong>이 row의 소유자인가</strong>를 확인한다.</p>
<p>Supabase 공식 문서에서도 브라우저에서 안전하게 데이터에 접근하려면 RLS를 활성화해야 한다고 설명한다.<br>특히 외부에 노출된 schema의 테이블에는 RLS를 항상 활성화해야 한다고 안내한다.</p>
<hr>
<h2 id="supabase에서-흔한-위험-패턴"><strong>Supabase에서 흔한 위험 패턴</strong></h2>
<h3 id="1-rls가-꺼져-있는-테이블"><strong>1. RLS가 꺼져 있는 테이블</strong></h3>
<p>Supabase Table Editor로 만든 테이블은 RLS가 기본 활성화될 수 있지만, SQL Editor나 raw SQL로 만든 테이블은 직접 확인해야 한다.</p>
<p>위험한 상태는 이런 것이다.</p>
<pre><code class="language-text">테이블은 public schema에 있음
클라이언트에서 접근 가능함
그런데 RLS가 꺼져 있음</code></pre>
<p>이 경우 권한 정책이 제대로 적용되지 않을 수 있다.</p>
<p>배포 전에 최소한 이런 질문을 해야 한다.</p>
<pre><code class="language-text">사용자 데이터가 들어가는 모든 테이블에 RLS가 켜져 있는가?</code></pre>
<hr>
<h3 id="2-using-true-정책을-대충-넣어둔-경우"><strong>2. <code>using (true)</code> 정책을 대충 넣어둔 경우</strong></h3>
<p>개발 중에는 빨리 테스트하려고 이런 식의 정책을 넣을 수 있다.</p>
<pre><code class="language-sql">using (true)</code></pre>
<p>이 정책은 말 그대로 조건이 항상 참이다.</p>
<p>물론 공개 프로필이나 공개 게시글처럼 모두가 읽어도 되는 데이터에는 의도적으로 사용할 수 있다.</p>
<p>하지만 사용자별 개인 데이터, 주문, 문의, 예약, 결제 정보에 이런 정책이 들어가 있으면 위험하다.</p>
<pre><code class="language-text">이 데이터는 정말 모두가 읽어도 되는가?</code></pre>
<p>이 질문에 바로 답하지 못하면 <code>using (true)</code>는 의심해봐야 한다.</p>
<hr>
<h3 id="3-authenticated면-모두-허용하는-경우"><strong>3. <code>authenticated</code>면 모두 허용하는 경우</strong></h3>
<p>이런 정책도 조심해야 한다.</p>
<pre><code class="language-sql">to authenticated
using (true)</code></pre>
<p>이건 “로그인한 사용자라면 모두 허용”에 가깝다.</p>
<p>게시글 목록처럼 로그인한 사용자 전체가 봐도 되는 데이터라면 괜찮을 수 있다.<br>하지만 개인 데이터라면 부족하다.</p>
<p>예를 들어 다음 데이터에는 보통 소유자 조건이 필요하다.</p>
<ul>
<li>내 주문</li>
<li>내 예약</li>
<li>내 문의</li>
<li>내 결제 내역</li>
<li>내 프로필</li>
<li>내 업로드 파일</li>
<li>내 프로젝트</li>
</ul>
<p>이런 데이터는 보통 아래처럼 사용자 식별자와 연결되어야 한다.</p>
<pre><code class="language-sql">using ((select auth.uid()) = user_id)</code></pre>
<p>핵심은 이것이다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>로그인 여부가 아니라, row의 소유자인지를 확인해야 한다.</strong></span></p>
</blockquote>
<hr>
<h3 id="4-service_role-key를-클라이언트에-넣는-경우"><strong>4. <code>service_role</code> key를 클라이언트에 넣는 경우</strong></h3>
<p>Supabase에는 클라이언트에서 써도 되는 publishable key 또는 legacy <code>anon</code> key가 있다.<br>이 키들은 공개 환경에서 쓰는 것을 전제로 한다.</p>
<p>반대로 secret key나 legacy <code>service_role</code> key는 서버에서만 써야 한다.</p>
<p>특히 <code>service_role</code>은 RLS를 우회할 수 있는 강한 권한을 가진다.</p>
<pre><code class="language-text">anon / publishable key: 클라이언트 사용 가능
service_role / secret key: 서버 전용</code></pre>
<p>프론트엔드 코드, <code>.env</code>의 public 환경변수, GitHub 저장소, 브라우저 번들 안에 <code>service_role</code> key가 들어가면 안 된다.</p>
<p>Next.js라면 <code>NEXT_PUBLIC_</code>에 넣으면 브라우저로 노출될 수 있다.<br>Vite라면 <code>VITE_</code>에 넣으면 클라이언트 번들에 포함될 수 있다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>service_role key는 브라우저에서 쓰는 키가 아니다.</strong></span></p>
</blockquote>
<hr>
<h2 id="firebase에서-조심해야-할-것-security-rules"><strong>Firebase에서 조심해야 할 것: Security Rules</strong></h2>
<p>Firebase는 Google의 앱 개발 플랫폼이다.<br>Authentication, Firestore, Realtime Database, Storage, Hosting, Cloud Functions 등을 제공한다.</p>
<p>Firebase에서 클라이언트가 Firestore나 Storage에 직접 접근하는 경우, 핵심은 <strong>Firebase Security Rules</strong> 다.</p>
<p>Security Rules는 클라이언트 요청이 데이터에 접근해도 되는지 판단하는 규칙이다.</p>
<p>Firestore 예시는 이런 형태다.</p>
<pre><code class="language-js">rules_version = &#39;2&#39;;

service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, update, delete: if request.auth != null
                                  &amp;&amp; request.auth.uid == userId;
      allow create: if request.auth != null;
    }
  }
}</code></pre>
<p>여기서 중요한 부분은 이 조건이다.</p>
<pre><code class="language-js">request.auth.uid == userId</code></pre>
<p>로그인한 사용자이면서, 요청한 문서의 <code>userId</code>와 본인의 uid가 같을 때만 허용한다.</p>
<hr>
<h2 id="firebase에서-흔한-위험-패턴"><strong>Firebase에서 흔한 위험 패턴</strong></h2>
<h3 id="1-개발용-open-rule을-그대로-배포하는-경우"><strong>1. 개발용 open rule을 그대로 배포하는 경우</strong></h3>
<p>가장 위험한 패턴은 이런 규칙이다.</p>
<pre><code class="language-js">allow read, write: if true;</code></pre>
<p>이건 사실상 누구나 읽고 쓸 수 있는 상태다.</p>
<p>개발 중에는 편할 수 있지만, 운영 환경에서는 매우 위험하다.</p>
<p>Firebase 공식 문서에서도 개발 중 open access로 설정한 규칙을 그대로 배포하면, 프로젝트 ID를 아는 사람이 데이터를 훔치거나 수정하거나 삭제할 수 있다고 경고한다.</p>
<hr>
<h3 id="2-로그인만-확인하는-경우"><strong>2. 로그인만 확인하는 경우</strong></h3>
<p>이런 규칙도 조심해야 한다.</p>
<pre><code class="language-js">allow read, write: if request.auth != null;</code></pre>
<p>이 규칙은 “로그인한 사용자면 가능”이라는 뜻이다.</p>
<p>문제는 모든 로그인 사용자가 모든 데이터를 읽거나 쓸 수 있는 구조가 될 수 있다는 점이다.</p>
<p>예를 들어 <code>/orders/{orderId}</code>에 이런 규칙이 있다면, A 사용자가 B 사용자의 주문도 볼 수 있는지 확인해야 한다.</p>
<p>대부분의 개인 데이터에는 이런 조건이 필요하다.</p>
<pre><code class="language-js">request.auth.uid == resource.data.userId</code></pre>
<p>또는 경로 자체를 사용자 기준으로 설계할 수도 있다.</p>
<pre><code class="language-js">match /users/{userId}/orders/{orderId} {
  allow read, write: if request.auth != null
                     &amp;&amp; request.auth.uid == userId;
}</code></pre>
<hr>
<h3 id="3-클라이언트가-보내는-role-값을-믿는-경우"><strong>3. 클라이언트가 보내는 role 값을 믿는 경우</strong></h3>
<p>관리자 기능을 만들 때 이런 실수를 할 수 있다.</p>
<pre><code class="language-text">클라이언트가 role: &quot;admin&quot;을 보내면 관리자 처리</code></pre>
<p>이건 위험하다.</p>
<p>클라이언트에서 보내는 값은 사용자가 조작할 수 있다고 봐야 한다.<br>관리자 여부는 서버에서 검증하거나, Firebase Custom Claims, 별도 관리자 문서, Cloud Functions 같은 구조로 신중하게 다뤄야 한다.</p>
<pre><code class="language-text">프론트엔드에서 버튼을 숨기는 것 ≠ 관리자 권한 보호</code></pre>
<p>버튼이 안 보여도 API나 DB 요청은 직접 보낼 수 있기 때문이다.</p>
<hr>
<h3 id="4-rules를-필터처럼-착각하는-경우"><strong>4. Rules를 필터처럼 착각하는 경우</strong></h3>
<p>Firestore Security Rules에서 중요한 개념 중 하나는 <strong>Rules are not filters</strong>다.</p>
<p>보안 규칙은 “허용된 데이터만 알아서 걸러서 반환하는 필터”가 아니다.</p>
<p>쿼리 자체가 규칙을 만족할 수 있어야 한다.</p>
<p>예를 들어 공개 문서만 읽을 수 있는 규칙이 있다면, 클라이언트 쿼리도 공개 문서만 요청하도록 작성되어야 한다.</p>
<pre><code class="language-js">// 예시
where(&quot;visibility&quot;, &quot;==&quot;, &quot;public&quot;)</code></pre>
<p>규칙이 알아서 전체 컬렉션 중 허용된 문서만 골라주는 방식으로 기대하면 안 된다.</p>
<hr>
<h2 id="파일-업로드도-권한이-필요하다"><strong>파일 업로드도 권한이 필요하다</strong></h2>
<p>Supabase Storage나 Firebase Storage를 쓰면 이미지, 첨부파일, 프로필 사진 같은 기능을 빠르게 만들 수 있다.</p>
<p>하지만 파일도 데이터다.</p>
<p>확인해야 할 질문은 다음과 같다.</p>
<ul>
<li>누구나 업로드할 수 있는가?</li>
<li>로그인한 사용자만 업로드할 수 있는가?</li>
<li>사용자가 자기 폴더에만 업로드할 수 있는가?</li>
<li>다른 사용자의 파일을 읽을 수 있는가?</li>
<li>public bucket이어도 괜찮은 파일인가?</li>
<li>파일 URL만 알면 누구나 볼 수 있는가?</li>
</ul>
<p>프로필 이미지처럼 공개되어도 되는 파일과, 계약서/증빙자료/상담 첨부파일처럼 공개되면 안 되는 파일은 다르게 다뤄야 한다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>Storage를 public으로 열기 전에, 그 파일이 정말 공개 파일인지 확인해야 한다.</strong></span></p>
</blockquote>
<hr>
<h2 id="배포-전에-확인할-체크리스트"><strong>배포 전에 확인할 체크리스트</strong></h2>
<p>Supabase나 Firebase를 쓰는 프로젝트라면 배포 전에 최소한 아래를 확인하는 것이 좋다.</p>
<h3 id="supabase"><strong>Supabase</strong></h3>
<ul>
<li>사용자 데이터가 들어가는 테이블에 RLS가 켜져 있는가?</li>
<li><code>using (true)</code> 정책이 민감 데이터에 적용되어 있지 않은가?</li>
<li><code>authenticated</code> 전체 허용 정책이 과하게 넓지 않은가?</li>
<li><code>auth.uid() = user_id</code> 같은 소유자 조건이 필요한 곳에 들어가 있는가?</li>
<li>insert/update 정책에 <code>with check</code>가 필요한지 확인했는가?</li>
<li><code>service_role</code> key가 클라이언트 코드에 들어가 있지 않은가?</li>
<li>Storage bucket이 public이어도 되는지 확인했는가?</li>
<li>Storage object에도 필요한 RLS 정책이 있는가?</li>
</ul>
<h3 id="firebase"><strong>Firebase</strong></h3>
<ul>
<li><code>allow read, write: if true</code>가 운영 규칙에 남아 있지 않은가?</li>
<li><code>request.auth != null</code>만으로 모든 데이터를 허용하고 있지 않은가?</li>
<li>사용자별 데이터에 <code>request.auth.uid == userId</code> 같은 조건이 있는가?</li>
<li>관리자 권한을 클라이언트 값만 믿고 있지 않은가?</li>
<li>Firestore Rules와 Storage Rules를 각각 확인했는가?</li>
<li>Rules Simulator나 Emulator로 비로그인/다른 사용자 접근을 테스트했는가?</li>
<li>Firebase API key를 비밀키처럼 오해하지 않고, Security Rules/App Check/IAM 관점에서 보고 있는가?</li>
</ul>
<hr>
<h2 id="ai에게-점검을-시킬-때-쓸-수-있는-프롬프트"><strong>AI에게 점검을 시킬 때 쓸 수 있는 프롬프트</strong></h2>
<p>Codex, Claude, Cursor처럼 프로젝트 저장소를 읽을 수 있는 AI를 쓰고 있다면 아래처럼 물어볼 수 있다.</p>
<pre><code class="language-text">이 프로젝트가 Supabase 또는 Firebase를 사용하고 있는지 확인해줘.

사용하고 있다면 배포 전 권한 설정 관점에서 아래 항목을 점검해줘.

1. Supabase 사용 시
- RLS가 필요한 테이블을 추정해줘.
- 사용자별 데이터인데 RLS 정책이 없거나 과하게 넓은 곳이 있는지 봐줘.
- service_role 또는 secret key가 클라이언트 코드에 노출될 가능성이 있는지 확인해줘.
- Storage bucket이나 파일 접근 권한에서 위험 후보가 있는지 봐줘.

2. Firebase 사용 시
- Firestore/Realtime Database/Storage Security Rules 파일을 찾아줘.
- allow read, write: if true 같은 개발용 규칙이 남아 있는지 확인해줘.
- request.auth != null만으로 과하게 넓게 허용하는 규칙이 있는지 봐줘.
- 사용자별 데이터에 request.auth.uid 기반 조건이 있는지 확인해줘.
- 관리자 권한을 클라이언트 값만 믿는 코드가 있는지 찾아줘.

출력은 다음 형식으로 해줘.

- 위험도: 높음 / 보통 / 낮음
- 위치: 파일명과 코드 위치
- 이유:
- 확인 방법:
- 수정 방향:

실제 API key, 비밀번호, 토큰 값은 출력하지 말고 존재 여부와 위치만 알려줘.
확실하지 않은 내용은 취약점이라고 단정하지 말고 &quot;위험 후보&quot;라고 표현해줘.</code></pre>
<p>이 프롬프트가 전문 보안 진단을 대신할 수는 없다.<br>그래도 배포 전 기본적인 권한 실수를 찾는 데는 도움이 될 수 있다.</p>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>Supabase와 Firebase는 빠르게 웹앱을 만들 수 있게 해준다.</p>
<p>특히 AI 개발 도구와 함께 쓰면 로그인, DB, 파일 업로드까지 정말 빠르게 붙일 수 있다.</p>
<p>하지만 구조가 편해질수록 권한 경계는 더 분명하게 봐야 한다.</p>
<p>전통적인 백엔드 구조에서는 서버 코드가 권한을 막아주는 경우가 많았다.<br>반면 Supabase/Firebase처럼 클라이언트가 직접 DB/API에 접근하는 구조에서는 RLS나 Security Rules가 중요한 방어선이 된다.</p>
<p>핵심은 이것이다.</p>
<blockquote>
<p><span style="color:#2563EB"><strong>로그인 기능을 붙였다고 권한 처리가 끝난 것은 아니다.</strong></span></p>
</blockquote>
<p>배포 전에 최소한 아래 질문은 해봐야 한다.</p>
<pre><code class="language-text">A 사용자가 B 사용자의 데이터를 볼 수 없는가?
A 사용자가 B 사용자의 데이터를 수정할 수 없는가?
일반 사용자가 관리자 기능을 실행할 수 없는가?
공개되면 안 되는 파일이 public으로 열려 있지 않은가?
서버 전용 key가 클라이언트에 들어가 있지 않은가?</code></pre>
<p>AI로 만든 웹앱일수록 기능이 동작하는지만 보지 말고, 데이터가 누구에게 보이는지도 같이 확인해야 한다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase Row Level Security</a></li>
<li><a href="https://supabase.com/docs/guides/getting-started/api-keys">Supabase API Keys</a></li>
<li><a href="https://supabase.com/docs/guides/storage/security/access-control">Supabase Storage Access Control</a></li>
<li><a href="https://firebase.google.com/docs/rules">Firebase Security Rules</a></li>
<li><a href="https://firebase.google.com/docs/rules/insecure-rules">Firebase - Avoid insecure rules</a></li>
<li><a href="https://firebase.google.com/docs/firestore/security/get-started">Cloud Firestore Security Rules - Get started</a></li>
<li><a href="https://firebase.google.com/docs/firestore/security/rules-conditions">Cloud Firestore Security Rules - Writing conditions</a></li>
<li><a href="https://firebase.google.com/docs/projects/api-keys">Firebase API Keys</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹앱 배포 후 외부에서 확인할 수 있는 보안 점검 항목들]]></title>
            <link>https://velog.io/@caffeine_coder/external-web-security-checklist</link>
            <guid>https://velog.io/@caffeine_coder/external-web-security-checklist</guid>
            <pubDate>Sun, 14 Jun 2026 23:44:42 GMT</pubDate>
            <description><![CDATA[<p>지난 글인 <a href="https://velog.io/@caffeine_coder/ai-webapp-security-checklist-before-deploy">AI로 만든 웹앱, 배포 전에 최소한 확인해야 할 보안 체크리스트</a>에서는 배포 전에 확인해야 할 기본 보안 항목들을 정리했다.</p>
<p>이번 글에서는 그중에서도 <strong>소스코드나 서버 내부 설정을 보지 않고, 외부에서 확인할 수 있는 항목들</strong>을 정리해보려고 한다.</p>
<p>다만 먼저 전제부터 분명히 해야 한다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>보안 점검은 본인 소유이거나 명시적으로 허가받은 사이트에만 해야 한다.</strong></span></p>
</blockquote>
<p>이 글에서 말하는 점검은 공격적인 침투 테스트가 아니라, 배포된 웹앱의 외부 노출 상태를 확인하는 수준이다.</p>
<hr>
<h2 id="1-https가-제대로-적용되어-있는가"><strong>1. HTTPS가 제대로 적용되어 있는가</strong></h2>
<p>가장 먼저 확인할 것은 HTTPS다.</p>
<p>브라우저 주소창에서 자물쇠 표시가 보이는지 확인할 수 있고, 터미널에서는 아래처럼 헤더를 확인할 수 있다.</p>
<pre><code class="language-bash">curl -I https://example.com</code></pre>
<p>확인할 포인트는 다음과 같다.</p>
<ul>
<li>인증서가 유효한가</li>
<li>HTTPS 접속이 정상 동작하는가</li>
<li>HTTP로 접속했을 때 HTTPS로 리다이렉트되는가</li>
<li>오래된 TLS 설정이나 취약한 암호군을 쓰고 있지는 않은가</li>
</ul>
<p>HTTP에서 HTTPS로 리다이렉트되는지도 확인할 수 있다.</p>
<pre><code class="language-bash">curl -I http://example.com</code></pre>
<p><code>301</code>, <code>308</code> 같은 응답과 함께 <code>Location: https://...</code> 형태로 이동한다면 기본 방향은 맞다.</p>
<p>더 자세한 TLS 설정은 SSL Labs 같은 도구로도 확인할 수 있다.</p>
<hr>
<h2 id="2-보안-헤더가-설정되어-있는가"><strong>2. 보안 헤더가 설정되어 있는가</strong></h2>
<p>보안 헤더는 브라우저에게 “이 사이트의 콘텐츠를 어떻게 다뤄야 하는지” 알려주는 설정이다.</p>
<p>확인은 간단하다.</p>
<pre><code class="language-bash">curl -I https://example.com</code></pre>
<p>대표적으로 볼 수 있는 헤더는 다음과 같다.</p>
<ul>
<li><code>Strict-Transport-Security</code></li>
<li><code>Content-Security-Policy</code></li>
<li><code>X-Frame-Options</code></li>
<li><code>X-Content-Type-Options</code></li>
<li><code>Referrer-Policy</code></li>
<li><code>Permissions-Policy</code></li>
</ul>
<p>각 헤더의 의미는 조금씩 다르다.</p>
<p><code>Strict-Transport-Security</code>는 브라우저가 이후 접속에서 HTTPS를 우선 사용하도록 알려준다.<br>단, 이 헤더는 HTTPS 응답에서 전달되어야 의미가 있다.</p>
<p><code>Content-Security-Policy</code>는 스크립트, 이미지, iframe 같은 리소스를 어떤 출처에서 불러올 수 있는지 제한한다.<br>XSS를 완전히 막아주는 것은 아니지만, 공격 영향을 줄이는 데 도움이 된다.</p>
<p><code>X-Frame-Options</code>나 CSP의 <code>frame-ancestors</code>는 다른 사이트가 내 페이지를 iframe으로 끼워 넣는 것을 제한할 때 사용한다.</p>
<p><code>X-Content-Type-Options: nosniff</code>는 브라우저의 MIME sniffing을 제한한다.</p>
<p><code>Referrer-Policy</code>는 다른 사이트로 이동할 때 referrer 정보를 얼마나 보낼지 제어한다.</p>
<p>보안 헤더가 없다고 해서 곧바로 취약점이라고 단정할 수는 없다.<br>하지만 브라우저가 제공하는 방어 계층을 쓰지 않는 상태일 수는 있다.</p>
<p>MDN HTTP Observatory 같은 도구를 사용하면 이런 항목들을 한 번에 확인할 수 있다.</p>
<hr>
<h2 id="3-공개되면-안-되는-파일이-노출되어-있지-않은가"><strong>3. 공개되면 안 되는 파일이 노출되어 있지 않은가</strong></h2>
<p>배포 과정에서 실수로 파일이 노출되는 경우가 있다.</p>
<p>예를 들면 이런 것들이다.</p>
<ul>
<li><code>.env</code></li>
<li><code>.git/config</code></li>
<li><code>backup.zip</code></li>
<li><code>db.sql</code></li>
<li><code>config.json</code></li>
<li><code>server.log</code></li>
<li><code>admin-backup</code></li>
<li><code>old.zip</code></li>
</ul>
<p>이런 파일에는 DB 접속 정보, API secret, 내부 경로, 과거 소스코드가 포함될 수 있다.</p>
<p>확인은 본인 사이트에서만 해야 한다.</p>
<pre><code class="language-bash">curl -I https://example.com/.env
curl -I https://example.com/.git/config</code></pre>
<p><code>404 Not Found</code>라면 보통 해당 경로에 파일이 없다는 뜻이다.<br><code>403 Forbidden</code>은 접근이 차단된 상태일 수 있다.<br>반대로 <code>200 OK</code>와 함께 파일 내용이 내려온다면 즉시 조치가 필요하다.</p>
<p><code>robots.txt</code>도 확인할 수 있다.</p>
<pre><code class="language-bash">curl https://example.com/robots.txt</code></pre>
<p>주의할 점은 <code>robots.txt</code>가 보안 장치가 아니라는 것이다.<br>검색엔진에게 크롤링 정책을 알려주는 파일일 뿐이고, 비밀 경로를 숨겨주는 기능은 아니다.</p>
<hr>
<h2 id="4-관리자나-테스트-경로가-인증-없이-열려-있지-않은가"><strong>4. 관리자나 테스트 경로가 인증 없이 열려 있지 않은가</strong></h2>
<p>관리자 페이지가 존재하는 것 자체가 문제는 아니다.<br>문제는 인증 없이 접근 가능한 경우다.</p>
<p>예를 들면 이런 경로다.</p>
<ul>
<li><code>/admin</code></li>
<li><code>/dashboard</code></li>
<li><code>/manage</code></li>
<li><code>/test</code></li>
<li><code>/dev</code></li>
<li><code>/cms</code></li>
</ul>
<p>직접 만든 서비스라면 내가 만든 관리자/테스트 경로가 로그인 없이 열리는지 확인해야 한다.</p>
<p>정상적인 경우라면 다음 중 하나가 되어야 한다.</p>
<ul>
<li>로그인 페이지로 이동한다</li>
<li>인증 실패 응답이 온다</li>
<li>권한 없음 응답이 온다</li>
</ul>
<p>반대로 로그인하지 않았는데 관리자 화면이나 관리자 API 결과가 보인다면 위험하다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>URL을 모르면 접근할 수 없을 것이라는 기대는 보안이 아니다.</strong></span></p>
</blockquote>
<p>프론트엔드에서 버튼을 숨기는 것만으로도 부족하다.<br>서버/API 레벨에서 권한 검사가 필요하다.</p>
<hr>
<h2 id="5-dns와-이메일-보안-설정을-확인한다"><strong>5. DNS와 이메일 보안 설정을 확인한다</strong></h2>
<p>도메인을 운영한다면 DNS 설정도 기본 점검 대상이다.</p>
<pre><code class="language-bash">dig example.com
dig TXT example.com
dig TXT _dmarc.example.com</code></pre>
<p>웹앱 보안과 직접 연결되는 항목도 있고, 운영 신뢰도와 연결되는 항목도 있다.</p>
<p>예를 들어 도메인으로 이메일을 보낸다면 SPF, DKIM, DMARC 설정을 확인하는 것이 좋다.</p>
<p>SPF는 이 도메인으로 메일을 보낼 수 있는 서버를 지정하는 방식이다.<br>DMARC는 SPF/DKIM 검사 결과를 바탕으로 수신 서버가 메일을 어떻게 처리할지 정책을 알려준다.</p>
<p>이 설정들이 없다고 웹앱이 바로 뚫리는 것은 아니다.<br>하지만 내 도메인을 사칭한 이메일이 악용될 가능성을 줄이는 데 도움이 된다.</p>
<p>또 하나 확인할 것은 오래된 DNS 레코드다.</p>
<p>사용하지 않는 서브도메인이 예전 클라우드 서비스나 호스팅 주소를 가리키고 있다면, 일부 환경에서는 서브도메인 탈취 위험으로 이어질 수 있다.</p>
<hr>
<h2 id="6-프론트엔드-번들에-불필요한-정보가-들어가-있지-않은가"><strong>6. 프론트엔드 번들에 불필요한 정보가 들어가 있지 않은가</strong></h2>
<p>브라우저에 내려가는 JavaScript 번들은 사용자에게 공개된다고 봐야 한다.</p>
<p>프론트엔드 코드 안에 이런 값이 들어가 있으면 안 된다.</p>
<ul>
<li>서버용 API secret</li>
<li>DB password</li>
<li>service role key</li>
<li>JWT secret</li>
<li>관리자 토큰</li>
</ul>
<p>반대로 API endpoint, public key, 화면 라우트명 같은 정보가 보이는 것 자체가 항상 취약점은 아니다.<br>프론트엔드 애플리케이션에서는 어느 정도 공개될 수밖에 없다.</p>
<p>중요한 기준은 이것이다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>클라이언트에 공개되어도 되는 값인가?</strong></span></p>
</blockquote>
<p>브라우저 개발자 도구의 Network, Sources 탭을 보면 어떤 파일과 값이 내려가는지 확인할 수 있다.</p>
<p>소스맵(<code>.map</code>)도 운영 환경에서 공개할지 여부를 고민해야 한다.<br>소스맵 자체가 곧바로 취약점은 아니지만, 내부 코드 구조나 주석이 노출될 수 있다.</p>
<hr>
<h2 id="7-securitytxt가-있는지-확인한다"><strong>7. security.txt가 있는지 확인한다</strong></h2>
<p><code>security.txt</code>는 보안 취약점을 발견한 사람이 어디로 연락해야 하는지 알려주는 파일이다.</p>
<p>일반적으로 다음 경로에 둔다.</p>
<pre><code class="language-text">/.well-known/security.txt</code></pre>
<p>예시는 이런 형태다.</p>
<pre><code class="language-text">Contact: mailto:security@example.com
Policy: https://example.com/security-policy</code></pre>
<p>작은 서비스라면 꼭 필수는 아니지만, 보안 제보를 받을 의지가 있다면 준비해둘 만하다.</p>
<p>다만 <code>security.txt</code>를 만들어둔다고 서비스가 안전해지는 것은 아니다.<br>취약점 신고 채널을 명확히 하는 운영 장치에 가깝다.</p>
<hr>
<h2 id="외부-점검의-한계"><strong>외부 점검의 한계</strong></h2>
<p>여기까지의 항목들은 외부에서 어느 정도 확인할 수 있다.</p>
<p>하지만 이것만으로 “안전하다”고 말할 수는 없다.</p>
<p>외부 점검만으로 확인하기 어려운 것들이 있다.</p>
<ul>
<li>로그인 이후 권한 문제</li>
<li>A 사용자가 B 사용자의 데이터를 볼 수 있는 문제</li>
<li>Supabase RLS 설정 누락</li>
<li>Firebase Security Rules 과개방</li>
<li>결제/예약/주문 같은 비즈니스 로직 문제</li>
<li>관리자 권한 상승 문제</li>
<li>서버 내부 의존성 취약점</li>
</ul>
<p>이런 항목들은 테스트 계정, 소스코드, 서버 설정, DB 권한을 함께 봐야 한다.</p>
<p>그래서 외부 점검은 최종 보안 진단이라기보다 <strong>첫 번째 필터</strong>에 가깝다.</p>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>AI 개발 도구 덕분에 웹앱을 만드는 속도는 빨라졌다.</p>
<p>하지만 배포된 웹앱이 외부에서 어떻게 보이는지는 여전히 직접 확인해야 한다.</p>
<p>처음부터 완벽한 보안 진단을 하기는 어렵다.<br>그래도 아래 항목부터 확인하면 위험한 실수를 꽤 줄일 수 있다.</p>
<ul>
<li>HTTPS</li>
<li>보안 헤더</li>
<li>공개 파일</li>
<li>관리자/테스트 경로</li>
<li>DNS와 이메일 보안 설정</li>
<li>프론트엔드 번들 노출 정보</li>
</ul>
<p>개인적으로는 AI로 만든 웹앱일수록 배포 직후 이런 질문을 해봐야 한다고 생각한다.</p>
<blockquote>
<p><span style="color:#2563EB"><strong>이 서비스는 기능적으로 동작하는가?</strong></span><br><span style="color:#2563EB"><strong>그리고 외부에 공개돼도 괜찮은 상태인가?</strong></span></p>
</blockquote>
<p>다음 글에서는 Supabase나 Firebase처럼 클라이언트에서 직접 DB/API를 사용하는 구조에서 특히 조심해야 할 권한 설정 문제를 정리해보려고 한다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/observatory">MDN HTTP Observatory</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security">MDN Strict-Transport-Security</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy">MDN Content-Security-Policy</a></li>
<li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc9116">RFC 9116 - security.txt</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc7208">RFC 7208 - SPF</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc7489">RFC 7489 - DMARC</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI로 만든 웹앱, 배포 전에 최소한 확인해야 할 보안 체크리스트]]></title>
            <link>https://velog.io/@caffeine_coder/ai-webapp-security-checklist-before-deploy</link>
            <guid>https://velog.io/@caffeine_coder/ai-webapp-security-checklist-before-deploy</guid>
            <pubDate>Sun, 14 Jun 2026 08:15:44 GMT</pubDate>
            <description><![CDATA[<p>요즘 AI 개발 도구를 보면 웹앱을 만드는 장벽이 정말 많이 낮아졌다는 생각이 든다.</p>
<p>예전에는 로그인, DB, 관리자 페이지, 결제, 배포까지 붙이려면 어느 정도 개발 경험이 필요했다.<br>그런데 이제는 기획만 어느 정도 명확하면 AI 도구의 도움을 받아 꽤 빠르게 MVP를 만들 수 있다.</p>
<p>이건 좋은 변화다.</p>
<p>하지만 문제는 그 다음이다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>웹앱을 만드는 속도는 빨라졌는데, 안전하게 배포하는 난이도는 그대로다.</strong></span></p>
</blockquote>
<p>기능은 눈에 보인다.<br>버튼이 안 눌리면 바로 알 수 있고, 페이지가 깨지면 바로 고칠 수 있다.</p>
<p>하지만 보안 문제는 다르다.<br>대부분은 사고가 나기 전까지 잘 보이지 않는다.</p>
<p>그래서 AI로 만든 웹앱을 배포하기 전에 최소한 어떤 것들을 확인해야 할지 정리해봤다.</p>
<hr>
<h2 id="1-환경변수와-secret이-노출되지-않았는가"><strong>1. 환경변수와 Secret이 노출되지 않았는가</strong></h2>
<p>가장 먼저 봐야 할 것은 <strong>민감정보 노출</strong>이다.</p>
<p>예를 들면 이런 것들이다.</p>
<ul>
<li><code>.env</code> 파일</li>
<li>DB 접속 정보</li>
<li>JWT Secret</li>
<li>관리자 계정 정보</li>
<li>서버용 API Secret</li>
<li>외부 서비스 Secret Key</li>
<li>테스트용 토큰</li>
</ul>
<p>여기서 주의할 점이 있다.</p>
<p><span style="color:#E11D48"><strong>모든 API Key가 곧바로 비밀값인 것은 아니다.</strong></span></p>
<p>예를 들어 Firebase config나 Supabase anon key처럼 클라이언트에 공개되는 것을 전제로 설계된 값도 있다.<br>다만 이런 공개용 키는 <strong>Security Rules, RLS, API 권한 설정이 제대로 되어 있다는 전제</strong>에서만 안전하다.</p>
<p>반대로 서버에서만 써야 하는 secret key, service role key, DB password, JWT secret 같은 값은 프론트엔드 번들에 들어가면 안 된다.</p>
<p>Next.js에서는 <code>NEXT_PUBLIC_</code> prefix가 붙은 환경변수가 브라우저로 전달될 수 있다.<br>Vite에서는 <code>VITE_</code> prefix가 붙은 환경변수가 클라이언트 코드에 포함될 수 있다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>클라이언트로 전달되는 환경변수에는 secret을 넣으면 안 된다.</strong></span></p>
</blockquote>
<hr>
<h2 id="2-db-권한이-너무-열려-있지-않은가"><strong>2. DB 권한이 너무 열려 있지 않은가</strong></h2>
<p>AI로 빠르게 만든 서비스에서 자주 걱정되는 부분은 <strong>DB 권한</strong>이다.</p>
<p>예를 들어 Supabase를 쓴다면 <strong>RLS(Row Level Security)</strong> 가 필요한 테이블에 제대로 적용되어 있는지 확인해야 한다.<br>Supabase 공식 문서에서도 노출된 schema의 테이블에는 RLS를 활성화해야 한다고 설명한다.</p>
<p>Firebase를 쓴다면 <strong>Security Rules</strong> 가 과하게 열려 있지 않은지 봐야 한다.<br>개발 중 편의를 위해 <code>allow read, write: if true</code> 같은 규칙을 쓰고 그대로 배포하면 누구나 데이터를 읽거나 수정할 수 있다.</p>
<p>문제는 기능 테스트만 하면 이런 권한 문제가 잘 드러나지 않는다는 점이다.</p>
<p>내 계정으로 로그인해서 내 데이터가 보이는 것은 정상이다.<br>하지만 다른 사람의 데이터까지 볼 수 있는지는 별도의 관점으로 확인해야 한다.</p>
<p>확인해야 할 질문은 단순하다.</p>
<ul>
<li>로그인하지 않아도 데이터를 읽을 수 있는가?</li>
<li>A 사용자가 B 사용자의 데이터를 볼 수 있는가?</li>
<li>클라이언트에서 임의의 <code>userId</code>를 바꿔 요청하면 데이터가 조회되는가?</li>
<li>로그인만 하면 모든 사용자의 데이터를 볼 수 있는가?</li>
<li>관리자가 아닌 사용자가 관리자용 데이터를 볼 수 있는가?</li>
</ul>
<p>이런 문제는 기능 버그라기보다 <strong>권한 설계 문제</strong>에 가깝다.</p>
<hr>
<h2 id="3-관리자-페이지가-인증-없이-접근-가능하지-않은가"><strong>3. 관리자 페이지가 인증 없이 접근 가능하지 않은가</strong></h2>
<p>생각보다 흔한 실수는 관리자 페이지를 만든 뒤 접근 제어를 약하게 둔 채 배포하는 것이다.</p>
<p>예를 들면 이런 경로다.</p>
<ul>
<li><code>/admin</code></li>
<li><code>/dashboard</code></li>
<li><code>/manage</code></li>
<li><code>/test</code></li>
<li><code>/dev</code></li>
<li><code>/cms</code></li>
</ul>
<p>관리자 페이지 경로가 존재하는 것 자체가 문제는 아니다.<br>문제는 <strong>인증과 권한 검사가 약한 경우</strong>다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>단순히 화면에서 버튼을 숨기는 것은 보안이 아니다.</strong></span></p>
</blockquote>
<p>서버/API 레벨에서 권한을 확인해야 한다.</p>
<p>예를 들어 일반 사용자가 관리자 화면 버튼을 볼 수 없더라도, 직접 API를 호출했을 때 관리자 기능이 실행된다면 문제가 된다.</p>
<hr>
<h2 id="4-테스트-데이터와-샘플-계정이-남아-있지-않은가"><strong>4. 테스트 데이터와 샘플 계정이 남아 있지 않은가</strong></h2>
<p>MVP를 만들다 보면 테스트용 계정이나 샘플 데이터가 남아 있을 수 있다.</p>
<p>예를 들면 이런 것들이다.</p>
<ul>
<li><code>test@test.com</code></li>
<li><code>admin/admin</code></li>
<li><code>password123</code></li>
<li>샘플 고객 정보</li>
<li>테스트 주문 데이터</li>
<li>임시 관리자 계정</li>
<li>개발 중 만든 공개 링크</li>
</ul>
<p>배포 전에 테스트 계정과 샘플 데이터는 정리하는 게 좋다.</p>
<p>특히 개인정보처럼 보이는 더미 데이터도 외부에서는 실제 데이터처럼 보일 수 있다.</p>
<hr>
<h2 id="5-https와-보안-헤더가-설정되어-있는가"><strong>5. HTTPS와 보안 헤더가 설정되어 있는가</strong></h2>
<p>최소한 HTTPS는 기본이다.</p>
<p>그 다음으로는 보안 헤더도 확인할 수 있다.</p>
<p>대표적으로 이런 항목들이 있다.</p>
<ul>
<li><code>Content-Security-Policy</code></li>
<li><code>Strict-Transport-Security</code></li>
<li><code>X-Frame-Options</code></li>
<li><code>X-Content-Type-Options</code></li>
<li><code>Referrer-Policy</code></li>
<li><code>Permissions-Policy</code></li>
</ul>
<p>보안 헤더가 없다고 해서 바로 해킹당하는 것은 아니다.</p>
<p>하지만 브라우저가 완화해줄 수 있는 일부 공격을 굳이 열어둘 필요는 없다.</p>
<p>특히 <code>Content-Security-Policy</code>는 스크립트, 이미지, iframe 등 리소스 로딩 출처를 제한해 XSS 같은 공격의 영향을 줄이는 데 도움을 줄 수 있다.</p>
<p>간단한 점검은 MDN HTTP Observatory 같은 도구로도 해볼 수 있다.</p>
<hr>
<h2 id="6-파일-업로드-기능이-안전한가"><strong>6. 파일 업로드 기능이 안전한가</strong></h2>
<p>파일 업로드는 생각보다 위험한 기능이다.</p>
<p>이미지 업로드 하나만 넣어도 확인할 것이 많다.</p>
<ul>
<li>허용 확장자 allowlist</li>
<li>파일 크기 제한</li>
<li>서버 측 파일 타입 확인</li>
<li>파일 시그니처 검증</li>
<li>파일명 재생성</li>
<li>업로드 파일 저장 위치</li>
<li>공개 URL 접근 범위</li>
<li>사용자별 파일 접근 권한</li>
<li>악성 스크립트나 실행 가능한 파일 업로드 가능성</li>
</ul>
<p>주의할 점은 <code>Content-Type</code>이나 MIME type만 믿으면 안 된다는 것이다.<br>이 값은 요청자가 조작할 수 있기 때문에, 보안 검증의 보조 수단으로 봐야 한다.</p>
<p>특히 스토리지 버킷을 public으로 열어놓은 경우, 의도하지 않은 파일이 외부에 노출될 수 있다.</p>
<blockquote>
<p><span style="color:#E11D48"><strong>파일 업로드는 단순 편의 기능이 아니라 보안 검토가 필요한 기능이다.</strong></span></p>
</blockquote>
<hr>
<h2 id="7-에러-메시지가-너무-자세하지-않은가"><strong>7. 에러 메시지가 너무 자세하지 않은가</strong></h2>
<p>개발 중에는 자세한 에러 메시지가 편하다.</p>
<p>하지만 운영 환경에서는 다르다.</p>
<p>에러 메시지에 이런 정보가 노출되면 좋지 않다.</p>
<ul>
<li>DB 테이블명</li>
<li>SQL 쿼리</li>
<li>서버 경로</li>
<li>내부 API 주소</li>
<li>Stack trace</li>
<li>사용 중인 프레임워크 버전</li>
<li>인증 처리 방식</li>
</ul>
<p>운영 환경에서는 사용자에게 필요한 수준의 메시지만 보여주고, 자세한 에러는 서버 로그에서 확인하는 방식이 안전하다.</p>
<hr>
<h2 id="8-개인정보-입력-폼이-있다면-더-조심해야-한다"><strong>8. 개인정보 입력 폼이 있다면 더 조심해야 한다</strong></h2>
<p>문의폼, 회원가입, 예약, 결제, 상담 신청 같은 기능이 있다면 보안의 중요도는 올라간다.</p>
<p>단순 랜딩 페이지와 개인정보를 수집하는 웹앱은 다르게 봐야 한다.</p>
<p>최소한 이런 질문은 해봐야 한다.</p>
<ul>
<li>어떤 개인정보를 수집하는가?</li>
<li>꼭 필요한 정보만 받고 있는가?</li>
<li>수집한 정보는 어디에 저장되는가?</li>
<li>관리자만 볼 수 있는가?</li>
<li>삭제 요청이 들어오면 지울 수 있는가?</li>
<li>개인정보처리방침은 준비되어 있는가?</li>
</ul>
<p>기술적인 보안뿐 아니라 운영 관점의 보안도 필요하다.</p>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>AI 개발 도구 덕분에 웹앱을 만드는 속도는 훨씬 빨라졌다.</p>
<p>하지만 배포 전에 확인해야 할 보안 항목은 여전히 남아 있다.</p>
<p>오히려 이제는 개발 경험이 적은 사람도 로그인, DB, 결제, 관리자 페이지가 포함된 서비스를 만들 수 있기 때문에 <strong>“작은 서비스니까 괜찮겠지”</strong> 라고 넘기기 어려워졌다고 생각한다.</p>
<p>개인적으로는 앞으로 이런 질문이 더 중요해질 것 같다.</p>
<blockquote>
<p><span style="color:#2563EB"><strong>이 웹앱은 기능적으로 동작하는가?</strong></span><br><span style="color:#2563EB"><strong>그리고 외부에 공개돼도 괜찮은 상태인가?</strong></span></p>
</blockquote>
<p>요즘 이 주제에 관심이 생겨서, AI로 만든 웹앱이나 사이드 프로젝트를 배포하기 전에 확인할 수 있는 보안 점검 체크리스트를 더 정리해보려고 한다.</p>
<p>다음 글에서는 실제로 외부에서 확인할 수 있는 항목들, 예를 들면 HTTPS, 보안 헤더, 공개 파일, 관리자 경로, DNS 설정 같은 것들을 어떻게 점검할 수 있는지 정리해볼 예정이다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://nextjs.org/docs/app/guides/environment-variables">Next.js Environment Variables</a></li>
<li><a href="https://vite.dev/guide/env-and-mode.html">Vite Env Variables and Modes</a></li>
<li><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase Row Level Security</a></li>
<li><a href="https://firebase.google.com/docs/rules">Firebase Security Rules</a></li>
<li><a href="https://firebase.google.com/docs/rules/insecure-rules">Firebase - Avoid insecure rules</a></li>
<li><a href="https://developer.mozilla.org/en-US/observatory">MDN HTTP Observatory</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a></li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html">OWASP Error Handling Cheat Sheet</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>