공유 호스팅과 Render(무료)를 이용한 풀스택 LMS 배포 — 어려운 방식
요약
VPS 없이 공유 호스팅, Render, Supabase를 조합하여 Node.js, React, PostgreSQL 기반의 풀스택 LMS를 배포한 실전 경험담입니다. 모노레포 구조와 Windows 환경에서의 빌드 이슈 등 배포 과정에서 겪은 기술적 난관과 해결책을 다룹니다.
핵심 포인트
- 공유 호스팅, Render, Supabase를 활용한 분산 아키텍처 구축
- Windows 환경에서 PowerShell을 사용한 환경 변수 빌드 문제 해결
- esbuild를 이용한 모노레포 패키지의 단일 번들링 전략
- 정적 파일 배포 및 API 서버 분리 운영 노하우
VPS 없이 Node.js + React + PostgreSQL 앱을 프로덕션 환경에 배포한 과정과 그 과정에서 겪은 모든 고통스러운 교훈에 대하여.
상황 (The Situation)
우리는 G3HUB를 위한 완전한 학습 관리 시스템 (LMS)을 구축했습니다 — React 프론트엔드, Node.js/Express API, 그리고 PostgreSQL 데이터베이스로 구성되었습니다. 우리가 가진 유일한 인프라는 이미 WordPress 사이트가 실행 중인 cPanel 공유 호스팅 계정 (Truehost Nigeria)뿐이었습니다.
대부분의 배포 튜토리얼은 사용자가 VPS나 클라우드 VM을 보유하고 있다고 가정합니다. 우리는 그렇지 않았습니다. 우리에게는 공유 호스팅과 마감 기한, 그리고 해결해야 할 문제만이 있었습니다.
이것은 우리가 어떻게든 이를 배포해낸 이야기입니다 — 우리가 마주한 모든 장벽, 찾아낸 모든 우회 방법, 그리고 다음에 한다면 다르게 할 일들에 대한 기록입니다.
기술 스택 (The Stack)
| 계층 (Layer) | 기술 (Technology) |
|---|---|
| 프론트엔드 (Frontend) | React 18 + Vite 7 + TailwindCSS |
| ... |
코드베이스는 프론트엔드, API 서버, 데이터베이스 스키마, 그리고 생성된 API 클라이언트를 위한 별도의 패키지를 가진 **pnpm 모노레포 (monorepo)**입니다. 깔끔한 아키텍처이지만, 이로 인해 배포가 단일 레포지토리 프로젝트보다 훨씬 더 복잡해졌습니다.
최종적으로 구축된 아키텍처 (The Architecture We Ended Up With)
사용자 (브라우저)
│ HTTPS
▼
...
핵심 통찰: Node.js와 PostgreSQL을 동일한 서버에서 실행할 수 없었기 때문에, 우리는 이를 세 가지 무료/기존 서비스로 나누었습니다. 프론트엔드는 기존 호스팅에 유지하고, API는 Render에서 실행하며, 데이터베이스는 Supabase에 위치합니다.
1단계 — 프론트엔드 구축 (Step 1 — Building the Frontend)
Vite 설정에는 빌드 시점에 두 개의 환경 변수(environment variables)인 PORT와 BASE_PATH가 필요했습니다. 충분히 간단해 보였지만, 문제는 우리가 Windows 환경에서 Git Bash를 사용하여 빌드하고 있었다는 점입니다. 첫 번째 빌드 결과로 생성된 index.html의 경로가 /assets/...가 아닌 /Program Files/Git/assets/...를 가리키고 있었습니다.
배운 교훈: Windows에서는 항상 Git Bash가 아닌 PowerShell에서 빌드하세요. 환경 변수 해석 방식이 다릅니다.
# PowerShell — 이것이 올바르게 작동합니다
cd artifacts/lms
$env:PORT = "3000"
...
빌드 결과물은 dist/public/로 이동하며 — 이것들이 웹 서버에 배포할 정적 파일 (static files)입니다.
Step 2 — API 서버 빌드 (Building the API Server)
API 서버는 커스텀 build.mjs 스크립트와 함께 esbuild를 사용합니다. 이는 모든 워크스페이스 의존성 (@workspace/db, @workspace/api-zod)을 포함한 모든 것을 단일 dist/index.mjs 파일(6.4MB)로 번들링 (bundling) 합니다.
cd artifacts/api-server
pnpm exec node build.mjs
이 부분이 중요합니다: esbuild가 컴파일 타임 (compile time)에 워크스페이스 패키지들을 번들링하기 때문에, 결과물인 dist/index.mjs는 완전히 독립적이며 실행 시 모노레포 (monorepo)가 필요하지 않습니다. 유일한 실제 외부 런타임 의존성 (runtime dependency)은 번들링되지 않은 pdfkit (인증서 생성용)뿐입니다.
Step 3 — cPanel에 프론트엔드 배포 (Deploying the Frontend to cPanel)
이 단계는 간단했습니다:
public_html/learn/을 가리키는learn.g3hub.com.ng서브도메인을 cPanel에서 생성dist/public/콘텐츠를 업로드 및 압축 해제- SPA 라우팅 (routing)을 위한
.htaccess파일 추가
.htaccess에는 JavaScript 모듈을 위한 MIME 타입 선언이 필요했습니다. 그렇지 않으면 Chrome에서 엄격한 MIME 타입 오류 (strict MIME type error)와 함께 .js 파일을 거부했기 때문입니다:
<IfModule mod_mime.c>
AddType application/javascript .js .mjs
AddType text/css .css
...
첫 번째 장벽 (Wall #1) — 공유 호스팅의 외부 PostgreSQL 접속 차단
우리의 첫 번째 계획은 cPanel의 내장 Node.js App 기능(Truehost에서 지원함)을 사용하여 Node.js API를 실행하고 이를 Supabase에 연결하는 것이었습니다.
Node.js 앱을 설정하고, 컴파일된 dist/index.mjs를 업로드하고, 모든 환경 변수 (environment variables)를 구성한 뒤 실행했습니다. 하지만 즉시 충돌(crash)이 발생했습니다:
AggregateError [ECONNREFUSED]: connection refused
서버 터미널에서 직접 연결을 테스트해 보았습니다:
timeout 5 bash -c "echo > /dev/tcp/aws-1-us-west-2.pooler.supabase.com/5432" \
&& echo "Connected" || echo "Failed"
# → Failed
5432 포트와 6543 포트 모두 차단되어 있었습니다. Truehost에 지원 티켓 (support ticket)을 보냈고, 그들의 답변은 명확했습니다:
"애플리케이션이 외부 PostgreSQL 데이터베이스에 대한 직접적인 연결을 필요로 하는 경우, VPS 또는 전용 서버 (dedicated server) 환경으로 이전하는 것을 권장합니다."
공유 호스팅 환경에서는 아웃바운드 PostgreSQL 연결이 차단됩니다. 끝입니다.
해결책: API를 Render(무료 티어)로 이전합니다. Render는 그러한 제한이 없습니다.
벽 #2 — pnpm Workspace 의존성은 모노레포 외부에서 존재하지 않음
api-server 폴더를 GitHub에 푸시하고 Render에 배포를 시도했을 때, 빌드가 즉시 실패했습니다:
error: workspace:* — Not found
package.json에는 `
RewriteCond %{REQUEST_URI} ^/api/(.*)$
RewriteRule ^api/(.*)$ /proxy.php?path=$1 [QSA,L]
프론트엔드가 /api/auth/login을 호출하면 → Apache가 /proxy.php?path=auth/login으로 재작성(rewrite)하고 → PHP가 Render로 전달(forward)하여 → 응답이 돌아옵니다. 완벽하게 작동합니다.
장벽 #4 — Supabase SSL 인증서 체인 오류
Render에서 API가 처음 Supabase에 연결되었을 때, 모든 데이터베이스 쿼리가 다음과 같은 오류와 함께 실패했습니다:
Error: self-signed certificate in certificate chain
이는 Supabase의 커넥션 풀러(connection pooler)에서 발생하는 알려진 특이 사항입니다. SSL 인증서 체인이 Node.js의 기본 인증서 저장소(certificate store)에 의해 완전히 신뢰되지 않기 때문입니다.
임시 해결책 (연결이 여전히 암호화되므로 이 사용 사례에서는 허용 가능함):
NODE_TLS_REJECT_UNAUTHORIZED=0
이를 Render의 환경 변수(environment variable)로 추가하세요. Node.js는 연결을 위해 여전히 SSL/TLS를 사용하지만, 인증서 체인을 검증하지는 않게 됩니다.
장벽 #5 — Supabase 세션 풀러(Session Pooler) 사용자 이름 형식
Supabase 세션 풀러의 DATABASE_URL은 특수한 사용자 이름 형식을 사용합니다:
postgresql://postgres.<PROJECT_REF>:<PASSWORD>@aws-1-us-west-2.pooler.supabase.com:5432/postgres
postgres.<PROJECT_REF> 사용자 이름에 주목하세요. 이는 단순히 postgres를 사용하는 직접 연결(direct connection)과는 다릅니다. 잘못된 형식을 사용하면 다음과 같은 오류가 발생합니다:
error: (ENOTFOUND) tenant/user postgres.hnzrl... not found
또한, 비밀번호에 @나 !와 같은 특수 문자가 포함되어 있다면 URL 인코딩(URL-encode)을 해야 합니다. @는 %40이 되고, !는 %21이 됩니다. 더 좋은 방법은 인코딩 문제를 완전히 피하기 위해 특수 문자가 없는 비밀번호를 사용하는 것입니다.
장벽 #6 — Windows에서의 Drizzle Kit Schema Push
Windows에서 drizzle-kit push를 실행하여 Supabase로 스키마(schema)를 푸시하려고 했을 때 다음과 같은 오류가 발생하며 실패했습니다:
Error: No schema files found for path config ['C:\Users\...\lib\db\src\schema\index.ts']
기존의 drizzle.config.ts는 __dirname을 사용하는데, 이는 Windows의 ESM 모듈(ESM modules)에서 작동하지 않습니다. 해결 방법은 하드코딩된 상대 경로를 사용하는 임시 설정 파일을 만드는 것이었습니다:
// drizzle.temp.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
...
그 다음:
DATABASE_URL="<your-url>" pnpm exec drizzle-kit push --config ./drizzle.temp.config.ts
Wall #7 — 서비스 워커(Service Worker)의 공격적인 캐싱
수정 사항을 반영하여 프론트엔드를 재배포한 후에도, 브라우저에는 계속해서 이전의 고장 난 버전이 표시되었습니다. PWA 서비스 워커(sw.js)가 모든 요청을 가로채고 캐시된 응답을 반환하고 있었기 때문입니다. 여기에는 API 호출에 대해 {"offline": true}를 반환하는 것도 포함되었습니다.
해결 방법: Chrome DevTools → Application → Service Workers에서:
- **"Bypass for network"**를 체크합니다.
- Unregister를 클릭합니다.
- Storage → Clear site data로 이동합니다.
서비스 워커 캐시가 적절히 무효화(invalidate)되지 않는 경우, 중요한 프론트엔드 재배포가 있을 때마다 사용자가 이 작업을 수행해야 할 수도 있습니다.
최종 아키텍처 — 작동 원리
이 설정의 영리한 부분은 **PHP 프록시(PHP proxy)**입니다. 프론트엔드와 PHP 프록시가 동일한 도메인(learn.g3hub.com.ng)에 존재하기 때문에 CORS 문제가 발생하지 않습니다. 브라우저는 동일한 서버와 통신하고 있다고 인식합니다. PHP 스크립트는 백그라운드에서 Render로 요청을 조용히 전달합니다.
공유 호스팅에서 PHP 프록시를 사용하여 외부 API로 전달하는 이 패턴은 단순한 편법(hack)이 아니라, 정당한 프로덕션 기술입니다. 그 이유는 다음과 같습니다:
- PHP의 cURL은 Apache mod_proxy와 동일한 방화벽 제한을 받지 않습니다.
- 프록시가 서버 측(server-side)에서 실행되므로, 프론트엔드와 API 사이에 CORS 헤더가 필요하지 않습니다.
- cPanel에서 Render로의 연결은 443 포트의 표준 HTTPS이므로, 공유 호스팅에서 차단하는 경우가 거의 없습니다.
비용 요약
| 서비스 | 플랜 | 비용 |
|---|---|---|
| Truehost cPanel | 기존 | $0 (이미 결제됨) |
| ... |
유일한 주의 사항: Render의 무료 티어(free tier)는 15분 동안 활동이 없으면 휴면 상태(sleep)로 전환되어, 첫 번째 요청 시 50초의 콜드 스타트(cold start)가 발생합니다. UptimeRobot을 사용하여 이를 무료로 해결할 수 있습니다. 5분마다 /api/healthz를 핑(ping)하도록 설정하세요.
우리가 다르게 했을 방식
만약 처음부터 다시 시작할 수 있다면:
-
첫날부터 VPS를 사용하세요. 월 5달러 정도의 DigitalOcean 또는 Hetzner VPS를 사용했다면 이 글에서 언급된 모든 장벽이 사라졌을 것입니다. 방화벽 제한도, 프록시 편법(proxy hacks)도 필요 없이 완전한 제어권을 가질 수 있습니다. 실제 운영용(production) LMS를 구축한다면 그만한 가치가 있습니다.
-
모노레포(monorepo) 배포 설정을 코드와 분리하세요. API의
package.json에@workspace/*의존성을 포함시킨 것이 불필요한 고통을 초래했습니다. 빌드 타임의 모노레포 의존성은 번들링(bundled)되어야 하며, 배포 아티팩트(deployment artifact)는 자기 완결적(self-contained)이어야 합니다. -
데이터베이스 비밀번호에 특수 문자를 사용하지 마세요. 연결 문자열(connection strings)에서
@를%40으로 URL 인코딩(URL-encoding)하는 과정에서 몇 시간 동안 디버깅을 해야 했습니다.G3hub@DB2026!보다는G3hubDB2026Secure가 훨씬 낫습니다. -
프록시 아키텍처(proxy architecture)를 조기에 테스트하세요. PHP 프록시 패턴을 발견하기 전까지 cPanel의 Node.js App을 작동시키려고 며칠을 허비했습니다. 서버의 아웃바운드 연결성(outbound connectivity)을 초기에 테스트했다면 시간을 절약할 수 있었을 것입니다.
다른 엔지니어들을 위한 교훈
- 공유 호스팅(Shared hosting)은 막다른 길이 아닙니다 — 단지 창의적인 라우팅(routing)이 필요할 뿐입니다.
- PHP는 여전히 유용합니다 — 프록시 계층(proxy layer)으로서, Apache가 해결할 수 없는 문제들을 해결해 줍니다.
- esbuild 번들은 강력합니다 — 6.4MB 크기의 자기 완결적인 번들은 전체 모노레포를 배포하는 것보다 훨씬 쉽습니다.
- 무료 티어(Free tiers)도 운영 환경에서 사용 가능합니다 — 적절한 아키텍처만 있다면 월 0달러의 인프라로도 실제 애플리케이션을 실행할 수 있습니다.
- 항상 아웃바운드 연결성을 먼저 테스트하세요 — 배포 파이프라인(deployment pipeline)을 구축하기 전에, 서버가 데이터베이스 및 외부 서비스에 도달할 수 있는지 확인하십시오.
리소스
- Render Free Tier
- Supabase Free Tier
- Drizzle ORM
- UptimeRobot — Render를 깨어 있게 유지하기 위한 무료 업타임 모니터링(uptime monitoring)
- pnpm Workspaces
이 글이 유용했거나 공유 호스팅 배포 중 비슷한 장벽에 부딪혔다면 댓글을 남겨주세요. 모든 고통스러운 배포는 미래의 블로그 포스트가 됩니다.
Tags: node react postgresql devops webdev
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기