서버는 시작됐는데 글 페이지는 열리지 않았습니다
Fresh 프로젝트의 production 실행 명령에는 다음과 같은 옵션이 있습니다.
deno serve -A _fresh/server.js9편에서도 이 명령을 기준으로 배포 구성을 설명했습니다..
여기서 -A는 --allow-all의 짧은 표현입니다. 애플리케이션에 모든 Deno 권한을 허용합니다.
그렇다면 Markdown을 읽어 HTML을 만드는 지금 블로그에도 모든 권한이 필요할까요?
이를 확인하기 위해 기존 production 결과물에서 -A를 제거하고 실행했습니다.
서버는 정상적으로 시작됐습니다.
하지만 요청을 보내자 결과가 달라졌습니다.
홈 500
글 상세 404
검색 500
About 404
robots.txt 200
favicon 500서버 시작 메시지만으로는 애플리케이션에 필요한 권한을 확인할 수 없었습니다.
이번 글에서는 이 결과를 출발점으로 콘텐츠와 정적 파일의 읽기 권한을 나누어 추가하고, 환경 변수와 외부 요청에 필요한 권한도 작은 예제로 확인하겠습니다.
먼저 무엇을 제한하는지 구분합니다
Deno의 권한은 실행 중인 코드가 어떤 시스템 자원에 접근할 수 있는지를 제어합니다.
대표적인 구분은 다음과 같습니다.
| 작업 | 관련 옵션 |
|---|---|
| 파일 읽기 | --allow-read |
| 파일 쓰기 | --allow-write |
| 네트워크 접근 | --allow-net |
| 환경 변수 읽기 | --allow-env |
| 하위 프로세스 실행 | --allow-run |
이 옵션들은 종류 전체를 허용하거나 특정 자원으로 범위를 좁힐 수 있습니다. Deno Permissions
예를 들어 다음 옵션은 파일 읽기 전체를 허용합니다.
--allow-read반면 다음 옵션은 지정한 디렉터리와 그 아래로 범위를 제한합니다.
--allow-read=./content이 권한은 애플리케이션 코드뿐 아니라 같은 실행 스레드에서 사용하는 의존성에도 적용됩니다. 패키지마다 별도의 권한을 주는 방식은 아닙니다. Deno Security and permissions
따라서 이번 작업에서는 “Fresh가 어떤 권한을 요구하는가”만 보는 것으로 충분하지 않습니다.
콘텐츠 로더와 정적 파일 처리까지 포함해 실제 요청이 실행하는 작업을 확인해야 합니다.
실험은 production 결과물에서 진행합니다
개발 서버와 production 서버는 실행하는 작업이 다릅니다.
개발 중에는 Vite가 변경을 감지하고 모듈을 변환합니다. 이번에 확인하려는 것은 빌드가 끝난 애플리케이션을 실행할 때 필요한 권한입니다.
먼저 평소 방식으로 빌드 결과물을 준비합니다.
deno task build그다음부터는 같은 결과물을 두고 실행 옵션만 변경합니다.
검증에 사용한 환경은 다음과 같습니다.
Deno 2.8.3
macOS / Apple Silicon
Fresh 2 계열
Vite 7.3.6
기존 Fresh Notes production 결과물이번 실험은 로컬에서 Deno CLI로 직접 실행한 결과입니다. 특정 호스팅 플랫폼의 실행 설정을 변경하거나 그 환경까지 검증한 것은 아닙니다.
작업 디렉터리는 프로젝트 루트로 고정했습니다.
project/
├── _fresh/
│ ├── server.js
│ ├── server/
│ └── client/
├── content/
│ ├── posts/
│ └── pages/
└── deno.json-A를 제거하고 요청별로 확인합니다
먼저 별도의 애플리케이션 권한 없이 실행합니다.
deno serve \
--no-prompt \
--host=127.0.0.1 \
--port=18110 \
_fresh/server.js--no-prompt는 실행 중 부족한 권한을 대화형으로 승인하지 않도록 합니다. 같은 명령을 반복했을 때 권한 질문에 대한 응답이 결과를 바꾸지 않게 하기 위한 설정입니다.
서버는 다음 메시지를 출력했습니다.
deno serve: Listening on http://127.0.0.1:18110/여기서 한 가지 중요한 차이가 있습니다.
deno serve는 서버 수신에 필요한 권한을 자체적으로 처리하므로, 수신을 시작하기 위해 별도의 --allow-net을 요구하지 않습니다. 추가 파일 읽기 같은 작업에는 별도 권한이 필요합니다. Deno serve
따라서 위 메시지가 나왔다고 파일 읽기나 외부 API 요청까지 허용된 것은 아닙니다.
다른 터미널에서 홈을 요청합니다.
curl -i http://127.0.0.1:18110/응답은 HTTP 500이었습니다.
서버 로그에는 다음 오류가 나타났습니다.
NotCapable: Requires read access to "./content/posts",
run again with the --allow-read flag홈에서 글 목록을 만들기 위해 getPosts()가 콘텐츠 디렉터리를 읽으려다 거부된 것입니다.
검색도 같은 글 목록을 사용하므로 HTTP 500을 반환했습니다.
왜 글 상세는 500이 아니라 404였을까요?
같은 실행 조건에서 존재하는 글을 요청했습니다.
curl -i \
http://127.0.0.1:18110/posts/fresh-vite-deno-roles이번에는 HTTP 404였습니다.
파일이 없어졌기 때문은 아닙니다.
현재 getPost()의 오류 처리가 모든 예외를 null로 바꾸고 있었기 때문입니다.
try {
// 파일 읽기와 Markdown 변환
} catch {
return null;
}route는 null을 받으면 글이 없는 것으로 판단합니다.
if (!post) {
return new Response(
"Post not found",
{ status: 404 },
);
}결과적으로 다음 흐름이 만들어졌습니다.
존재하는 글 요청
↓
파일 읽기 권한 거부
↓
catch에서 null 반환
↓
route가 404 반환About 페이지도 같은 종류의 처리로 404를 반환했습니다.
반면 robots.txt는 Markdown 파일을 읽지 않고 설정값으로 응답 문자열을 만들기 때문에 HTTP 200이었습니다.
이 결과는 권한 오류를 확인할 때 HTTP 상태 코드만으로 원인을 단정하면 안 된다는 것을 보여줍니다.
9편에서 설명한 것처럼, 파일이 없는 경우만 따로 처리하면 이런 차이를 드러낼 수 있습니다.
catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;
}이번 비교에서는 기존 응답을 관찰하기 위해 로더를 수정하지 않았습니다. 위 코드는 오류 처리를 보완할 때 적용할 변경입니다.
콘텐츠 읽기를 허용하자 글이 열렸습니다
첫 번째 서버를 종료하고 콘텐츠 디렉터리 읽기를 허용합니다.
deno serve \
--no-prompt \
--host=127.0.0.1 \
--port=18111 \
--allow-read=./content \
_fresh/server.js다시 요청한 결과는 다음과 같습니다.
| 요청 | HTTP 상태 |
|---|---|
/ |
200 |
/posts/fresh-vite-deno-roles |
200 |
/search?q=fresh |
200 |
/about |
200 |
/robots.txt |
200 |
/sitemap.xml |
200 |
content/posts와 content/pages를 읽을 수 있게 되면서 글과 고정 페이지가 정상적으로 응답했습니다.
sitemap도 공개 글 목록을 읽으므로 같은 권한에 영향을 받습니다.
현재 블로그에서는 content 전체를 허용하면 두 콘텐츠 디렉터리를 함께 포함합니다.
필요하다면 경로를 더 나누어 지정할 수도 있습니다.
--allow-read=./content/posts,./content/pages그러나 여기서 검증을 마치면 안 됩니다.
HTML이 200이라고 CSS와 JavaScript, 이미지도 정상이라는 뜻은 아니기 때문입니다.
CSS와 이미지에는 다른 경로의 읽기 권한이 필요했습니다
콘텐츠 읽기만 허용한 상태에서 정적 파일을 요청했습니다.
curl -i \
http://127.0.0.1:18111/favicon.ico응답은 HTTP 500이었습니다.
서버 로그의 경로를 줄여 표시하면 다음과 같습니다.
NotCapable: Requires read access to
".../_fresh/client/favicon.ico"production 서버는 빌드된 클라이언트 파일을 _fresh/client에서 읽고 있었습니다.
이번 결과물에서는 다음 항목들이 모두 영향을 받았습니다.
favicon
OG 이미지
CSS
클라이언트 시작 모듈
ThemeToggle Island 모듈브라우저에서는 HTML만 먼저 보여서 정상처럼 느껴질 수 있습니다. 하지만 스타일이 적용되지 않거나 버튼이 동작하지 않을 수 있습니다.
개발자 도구의 Network에서 문서뿐 아니라 CSS·JavaScript·이미지 요청을 함께 확인해야 하는 이유입니다.
콘텐츠와 빌드된 정적 파일을 함께 허용합니다
이번에는 읽기 경로 두 개를 지정합니다.
deno serve \
--no-prompt \
--host=127.0.0.1 \
--port=18112 \
--allow-read=./content,./_fresh/client \
_fresh/server.js같은 요청을 비교한 결과입니다.
| 대상 | 권한 추가 없음 | content 읽기 |
content와 _fresh/client 읽기 |
|---|---|---|---|
| 홈 | 500 | 200 | 200 |
| 글 상세 | 404 | 200 | 200 |
| 검색 | 500 | 200 | 200 |
| About | 404 | 200 | 200 |
| robots.txt | 200 | 200 | 200 |
| favicon | 500 | 500 | 200 |
| OG 이미지 | 확인하지 않음 | 500 | 200 |
| 빌드된 CSS·JS | 확인하지 않음 | 500 | 200 |
CSS와 JavaScript는 HTML에 실제로 포함된 빌드 파일 경로로 요청했습니다. 파일 이름의 해시는 빌드마다 달라질 수 있으므로 고정된 예제 이름을 그대로 사용하지 않습니다.
이번 요청 범위에서는 다음 두 경로의 읽기 권한으로 동작했습니다.
./content
./_fresh/client이것이 모든 Fresh 애플리케이션에 통용되는 최소 권한 목록이라는 뜻은 아닙니다.
다른 디렉터리를 읽는 기능이나 외부 API 호출을 추가하면 필요한 권한도 달라집니다.
서버 모듈은 읽기 권한 없이 어떻게 로드됐을까요?
여기까지 보면 의문이 생깁니다.
_fresh/client는 읽기 허용이 필요한데
_fresh/server.js는 왜 바로 실행됐을까?Deno가 코드를 모듈로 로드하는 것과, 실행 중인 코드가 파일 API로 데이터를 읽는 것은 다르게 취급되기 때문입니다.
초기 모듈 그래프의 코드 로딩에는 별도의 규칙이 적용됩니다. 반면 애플리케이션이 실행 중 Deno.readTextFile(), Deno.stat(), Deno.open() 등으로 파일에 접근하면 권한 검사를 받습니다. Deno Security and permissions
이번 경우에는 다음 두 작업을 구분해야 합니다.
서버 모듈 로드
→ 실행할 코드 준비
Markdown과 정적 파일 읽기
→ 요청 처리 중 데이터 접근_fresh/server.js가 실행됐다는 사실을 “이 프로세스는 프로젝트 전체를 읽을 수 있다”로 해석하면 안 됩니다.
동적 import도 형태와 실행 시점에 따라 검사가 달라질 수 있으므로, 코드 로딩 전체가 무조건 예외라고 일반화하지 않습니다.
환경 변수는 이름을 지정해서 허용합니다
현재 확인한 production 결과물은 별도의 환경 변수 읽기 권한 없이 앞선 요청을 처리했습니다.
하지만 9편에서 제시한 SITE_URL 환경 변수 구성을 적용한다면 조건이 달라집니다.
차이를 작은 파일로 확인해보겠습니다.
scripts/read-site-url.ts:
const siteUrl = Deno.env.get("SITE_URL");
console.log(
siteUrl === undefined
? "SITE_URL is unset"
: "SITE_URL is set",
);권한 없이 실행합니다.
deno run \
--no-prompt \
scripts/read-site-url.ts실험에서는 다음 오류가 발생했습니다.
NotCapable: Requires env access to "SITE_URL",
run again with the --allow-env flag이번에는 해당 이름만 허용합니다.
deno run \
--no-prompt \
--allow-env=SITE_URL \
scripts/read-site-url.ts변수를 설정하지 않은 환경에서는 다음 결과가 나옵니다.
SITE_URL is unset여기에는 서로 다른 조건이 있습니다.
읽기 권한 없음
→ 권한 오류
읽기 권한 있음 + 값 없음
→ undefined--allow-env=SITE_URL은 값을 만들어주는 옵션이 아닙니다. 그 이름을 읽을 권한만 허용합니다.
환경 변수를 사용하는 서버라면 앞선 실행 명령에 같은 옵션을 추가할 수 있습니다.
deno serve \
--no-prompt \
--allow-read=./content,./_fresh/client \
--allow-env=SITE_URL \
_fresh/server.js다른 환경 변수에 대한 오류가 발생한다면 이름과 호출 위치를 확인한 뒤 필요한 항목을 추가합니다.
서버 수신과 외부 API 호출도 구분합니다
앞에서 deno serve는 별도의 --allow-net 없이 수신을 시작했습니다.
그렇다고 실행 중인 코드가 외부 API에 요청할 수 있는 것은 아닙니다.
다음 코드를 별도 파일로 실행해 확인할 수 있습니다.
const response = await fetch(
"https://example.com",
);
console.log(response.status);
await response.body?.cancel();네트워크 권한 없이 실행하면 외부 응답을 받기 전에 거부됩니다.
NotCapable: Requires net access to "example.com:443",
run again with the --allow-net flag외부 연동이 필요할 때는 목적지를 지정할 수 있습니다.
deno run \
--no-prompt \
--allow-net=example.com:443 \
scripts/fetch-example.ts이 명령은 해당 목적지의 네트워크 접근을 허용하는 예제입니다. 실제 연결 성공 여부는 DNS·TLS·상대 서버 상태에도 영향을 받습니다.
서버의 역할을 나누어 보면 다음과 같습니다.
deno serve의 HTTP 수신
→ CLI가 수신 처리
애플리케이션의 외부 fetch
→ 별도 네트워크 권한 확인현재 Markdown 검색은 서버의 로컬 파일만 사용합니다. 검색을 위해 외부 네트워크 권한을 추가할 이유는 없었습니다.
또한 이 권한은 Deno 서버 프로세스에 적용됩니다. Island가 브라우저에서 실행하는 요청이나 DOM 조작은 브라우저의 보안 규칙을 따릅니다.
실행 옵션은 별도 task로 남겨 비교할 수 있습니다
검증한 구성을 유지하려면 기존 start를 즉시 교체하기보다 비교용 task를 추가할 수 있습니다.
{
"tasks": {
"start": "deno serve -A _fresh/server.js",
"start:restricted": "deno serve --no-prompt --allow-read=./content,./_fresh/client _fresh/server.js"
}
}그리고 프로젝트 루트에서 실행합니다.
deno task start:restricted경로가 상대 경로이므로 실행 위치도 검증 조건에 포함됩니다.
이 설정은 이번 글에서 확인한 production 결과물을 기준으로 합니다. 개발 서버나 빌드 작업에 그대로 적용하는 설정은 아닙니다.
특히 빌드에는 파일 쓰기와 도구 실행 등 production 요청 처리와 다른 작업이 포함됩니다. 이번에는 실행 권한을 확인했으므로 설치·빌드 단계의 권한까지 줄였다고 주장하지 않습니다.
권한을 줄인 뒤에는 요청 목록을 남깁니다
권한 검증은 시작 명령 한 줄로 끝나지 않습니다.
이번 실험에서도 서버 시작은 세 구성 모두 성공했지만 실제 응답은 달랐습니다.
따라서 권한 설정과 함께 확인한 요청을 기록해두는 편이 좋습니다.
홈
공개 글 상세
검색 결과
About
robots.txt
sitemap.xml
favicon
OG 이미지
실제 CSS
실제 Island JavaScript기능이 늘어나면 이 목록도 늘어납니다.
파일 업로드를 추가했다면 쓰기 작업을, 외부 서비스를 연결했다면 외부 요청을 확인해야 합니다.
원인을 찾기 어려운 경우에는 Deno가 제공하는 권한 감사 로그를 사용할 수도 있습니다.
DENO_AUDIT_PERMISSIONS=/tmp/fresh-permissions.jsonl \
deno serve \
--no-prompt \
--allow-read=./content,./_fresh/client \
_fresh/server.js대표 요청을 수행한 뒤 로그에서 어떤 자원에 접근했는지 확인합니다. 이 로그도 실행한 경로에서 관찰한 기록이므로, 아직 호출하지 않은 기능의 요구사항까지 증명하지는 않습니다. Deno Permission audit
필요한 권한은 애플리케이션의 데이터 흐름을 따라갑니다
이번 Fresh Notes 결과물에서는 콘텐츠와 빌드된 클라이언트 파일의 읽기를 허용하자 확인한 페이지와 정적 파일 요청이 정상화됐습니다.
글과 검색
→ content 읽기
CSS·JavaScript·이미지
→ _fresh/client 읽기
환경 변수 설정을 사용하는 코드
→ 해당 변수 읽기
외부 API 연동
→ 해당 목적지 접근권한을 줄이는 과정은 옵션을 짧게 만드는 작업이 아니었습니다. 요청이 어느 파일과 설정, 외부 서비스에 의존하는지 확인하는 작업이었습니다.
특히 글 상세의 404는 중요한 단서였습니다. 파일은 존재했지만 읽기 권한 오류가 로더의 catch 안에서 숨겨졌습니다.
이처럼 권한 실험은 실행 범위를 제한하는 데 그치지 않고, 기존 오류 처리가 어떤 문제를 감추는지도 드러낼 수 있습니다.
다음 글에서는 콘텐츠를 읽은 뒤의 문제로 넘어가겠습니다. TypeScript 인터페이스를 선언해도 잘못된 Front Matter가 들어올 수 있으므로, 발행 전에 메타데이터를 검증하는 과정을 만들어보겠습니다.