Render로 서비스 배포하기: Web Service, Static Site, PostgreSQL, Cron Jobs, render.yaml

이 글의 핵심

Render에 웹 서비스와 정적 사이트를 올리고, PostgreSQL과 환경 변수를 연결하며, Cron Job과 커스텀 도메인을 설정하고 render.yaml로 구성을 관리하는 방법을 다룹니다.

이 글의 핵심

Render로 웹 서비스와 정적 사이트를 배포하고, PostgreSQL·환경 변수·Cron Job·커스텀 도메인을 연결한 뒤 render.yaml로 구성을 코드로 관리하는 방법을 다룹니다. 각 단계에서 처음 배포할 때 자주 실패하는 지점(포트 바인딩, 빌드 시 devDependencies, 무료 DB 만료)도 함께 설명합니다.

Render를 찾게 되는 상황

Heroku 무료 플랜이 없어졌어요

2022년 11월 Heroku가 무료 dyno와 무료 Postgres를 종료하면서 사이드 프로젝트와 교육용 앱이 옮겨 갈 곳을 찾게 되었습니다. Render는 Git 저장소를 연결하면 빌드와 배포가 자동으로 이어지는 Heroku와 비슷한 사용 경험에, 제약이 있는 무료 인스턴스를 제공해 대표적인 이전 대상이 되었습니다.

정적 사이트와 API 서버를 한곳에서 관리하고 싶어요

프런트엔드는 Netlify, 백엔드는 다른 PaaS에 두면 환경 변수와 도메인 설정이 두 곳으로 나뉩니다. Render는 정적 사이트, 웹 서비스, 백그라운드 워커, Cron Job, PostgreSQL을 한 대시보드와 하나의 render.yaml로 묶을 수 있습니다.

정기 작업을 서버 없이 돌리고 싶어요

매일 오래된 로그를 지우거나 리포트를 보내는 작업을 위해 서버에 crontab을 설정하면 그 서버를 계속 관리해야 합니다. Render의 Cron Job은 정해진 시각에 컨테이너를 띄워 명령 하나를 실행하고 종료하는 방식이라 관리할 서버가 없습니다.


Render란?

Heroku 대체재로 부상한 배경

Render는 Stripe 출신 Anurag Goel이 2019년 무렵 서비스를 시작한 클라우드 플랫폼입니다. 당시 PaaS 시장은 Heroku가 사실상 지배하고 있었는데, 2022년 무료 플랜 종료 발표 이후 많은 개인·교육 프로젝트가 대안을 찾으면서 Render가 빠르게 알려졌습니다. Render는 “Heroku만큼 쉽지만 더 현대적인” 플랫폼을 내세웠습니다.

차별점:

  • 무료 인스턴스: Web Service(월 750 인스턴스 시간), Static Site, 기간 제한이 있는 PostgreSQL
  • 컨테이너 지원: 네이티브 런타임(Node, Python, Go 등)과 Dockerfile 빌드를 모두 지원
  • Infrastructure as Code: render.yaml(Blueprint)로 서비스 구성을 저장소에 함께 관리
  • 낮은 종속성: 특수 SDK 없이 PORT 환경 변수를 읽는 표준 앱이 그대로 동작

Render vs Heroku vs Railway

측면RenderHerokuRailway
무료 플랜✅ 제약 있는 무료 인스턴스❌ (2022 종료)체험 크레딧 후 유료
DB 무료PostgreSQL (만료 기한 있음)❌크레딧 안에서 사용
컨테이너네이티브 런타임 + DockerBuildpacks + 컨테이너 레지스트리Nixpacks + Docker
Region미국(Oregon·Ohio·Virginia), Frankfurt, Singapore미국·유럽 등미국·유럽·아시아
유휴 후 첫 응답무료 인스턴스는 수십 초~1분유료 dyno는 슬립 없음기본적으로 슬립 없음
가격$7/월~$57/월사용량 기반

가격과 무료 한도는 세 서비스 모두 자주 바뀌므로 표는 성격 비교로만 보고, 실제 결정 전에는 각 요금 페이지를 확인하는 것이 좋습니다. 선택에서 더 중요한 차이는 과금 방식입니다. Render와 Heroku는 인스턴스 크기별 고정 월 요금이라 비용 예측이 쉽고, Railway는 CPU·메모리 사용량에 따라 과금되어 트래픽이 적은 앱은 싸지만 사용량이 튀면 청구액도 튑니다.

무료 플랜 제약:

  • Sleep: 15분 동안 요청이 없으면 인스턴스가 내려가고, 다음 요청 때 다시 켜지며 수십 초에서 1분 가까이 걸림
  • 대역폭·빌드 시간: 월 대역폭과 빌드 시간에 한도가 있음(약 100GB, 500분 수준)
  • PostgreSQL: 무료 DB는 생성 후 일정 기간(현재 30일)이 지나면 만료되고, 유예 기간 뒤 삭제됨

슬립은 무료 플랜을 쓸 때 가장 먼저 체감하는 제약입니다. 데모 링크를 보냈는데 상대방이 첫 화면에서 한참 기다리거나 타임아웃을 보는 일이 흔합니다. 외부 모니터링 서비스로 몇 분마다 핑을 보내 슬립을 막는 방법이 알려져 있지만, 이렇게 하면 월 750시간을 한 서비스가 거의 다 소모하고 무료 플랜의 취지와도 맞지 않습니다. 항상 응답해야 하는 서비스라면 가장 작은 유료 인스턴스로 올리는 것이 정석입니다.


Web Service 배포

Node.js 앱

  1. New → Web Service
  2. GitHub 저장소 연결
  3. 설정 입력
# Build Command
npm install && npm run build
# Start Command
npm start
# Environment
Node

Render는 저장소를 클론한 뒤 Build Command를 실행하고, 성공하면 Start Command로 프로세스를 띄웁니다. 기본적으로 연결한 브랜치에 push할 때마다 자동으로 다시 배포됩니다(Auto-Deploy). 빌드가 끝난 새 인스턴스가 포트에 응답하기 시작하면 트래픽을 옮기고 이전 인스턴스를 내리므로, 유료 인스턴스에서는 배포 중 중단이 거의 없습니다.

npm install 대신 npm ci를 쓰는 것을 권합니다. npm ci는 package-lock.json에 적힌 버전을 그대로 설치하고 lock 파일과 package.json이 맞지 않으면 실패하므로, 로컬에서 테스트한 의존성과 배포된 의존성이 달라지는 문제를 막아 줍니다. 또 하나 흔한 실패는 NODE_ENV=production을 환경 변수에 넣어 두었을 때입니다. 이 값이 빌드 단계에도 적용되면 npm이 devDependencies를 설치하지 않아 tsc: not found나 Cannot find module 'vite' 같은 에러로 빌드가 멈춥니다. TypeScript나 번들러가 devDependencies에 있다면 빌드 명령을 npm ci --include=dev && npm run build로 바꾸거나, NODE_ENV를 Start Command 쪽에서만 지정하는 방식으로 해결합니다.

Express 예제

// server.ts
import express from 'express';
const app = express();
app.get('/', (req, res) => {
  res.send('Hello Render!');
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

핵심은 process.env.PORT를 읽는 한 줄입니다. Render는 컨테이너에 PORT 환경 변수(기본 10000)를 넣어 주고, 그 포트로 들어오는 요청을 서비스로 연결합니다. 포트를 3000으로 고정하거나 app.listen(port, 'localhost')처럼 루프백 주소에만 바인딩하면 외부 요청이 도달하지 못해 배포 로그에 No open ports detected가 뜨고 결국 배포가 실패합니다. app.listen(port)처럼 호스트를 생략하면 모든 인터페이스에 바인딩되므로 문제없습니다. 헬스 체크 경로(/healthz 등)를 설정해 두면 앱이 뜨긴 했지만 DB 연결에 실패한 상태 같은 경우를 배포 단계에서 걸러 낼 수 있습니다.


Static Site 배포

설정

# Build Command
npm run build
# Publish Directory
dist

Astro 예제

// astro.config.mjs
export default {
  output: 'static',
  build: {
    format: 'directory',
  },
};

Static Site는 빌드 결과 폴더를 CDN에 올려 서빙하는 방식이라 서버 프로세스가 없고, 슬립도 없으며 무료로 쓸 수 있습니다. Astro의 format: 'directory'는 /about 페이지를 about/index.html로 만들어 트레일링 슬래시 URL이 자연스럽게 동작하게 합니다. React·Vue 같은 SPA를 올릴 때는 클라이언트 라우터가 처리하는 /dashboard 같은 경로를 새로고침하면 해당 파일이 없어 404가 납니다. 대시보드의 Redirects/Rewrites 설정에서 /*를 /index.html로 Rewrite(Redirect가 아님)하도록 규칙을 추가해야 합니다. Publish Directory를 잘못 지정하면(dist 대신 build 등) 빌드는 성공하는데 사이트는 비어 있는 상태가 되므로, 사용하는 프레임워크의 출력 폴더를 먼저 확인하세요.


PostgreSQL

추가

  1. New → PostgreSQL
  2. 생성되면 Internal/External Database URL이 발급됨
  3. 웹 서비스의 환경 변수 DATABASE_URL에 연결

연결

import { Pool } from 'pg';
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: {
    rejectUnauthorized: false,
  },
});
export default pool;

Render PostgreSQL은 두 가지 접속 주소를 줍니다. Internal URL은 같은 리전의 Render 서비스끼리 사설 네트워크로 연결되는 주소로 지연이 짧고 외부에 노출되지 않습니다. External URL은 로컬 PC나 다른 클라우드에서 접속할 때 쓰는 주소로 TLS가 필수입니다. 웹 서비스와 DB가 같은 리전에 있다면 반드시 Internal URL을 쓰는 것이 좋습니다. 서로 다른 리전에 만들면 사설 네트워크로 연결되지 않아 External URL을 써야 하고 쿼리마다 지연이 늘어나므로, 서비스와 DB를 만들 때 리전을 같게 고르는 것이 중요합니다.

rejectUnauthorized: false는 TLS 연결은 하되 서버 인증서를 검증하지 않겠다는 설정입니다. External URL로 접속할 때 self signed certificate in certificate chain 에러를 피하려고 흔히 넣지만, 인증서를 검증하지 않으면 중간자 공격에 취약해진다는 트레이드오프가 있습니다. Internal URL을 쓰면 SSL 설정 없이도 연결되므로, 환경에 따라 설정을 나누는 편이 깔끔합니다. 무료 DB는 만료 기한이 있으므로 중요한 데이터라면 처음부터 pg_dump로 정기 백업을 받거나 유료 플랜으로 시작해야 합니다. 만료 알림 메일을 놓쳐 데이터가 삭제되는 사례가 적지 않습니다.


환경 변수

설정

# Render Dashboard → Environment
DATABASE_URL=postgresql://...
API_KEY=secret123
NODE_ENV=production

.env 파일

# 로컬 개발용
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=dev-key

환경 변수는 대시보드의 Environment 탭에서 서비스별로 넣거나, 여러 서비스가 함께 쓰는 값은 Environment Group으로 묶어 공유할 수 있습니다. 환경 변수를 바꾸면 서비스가 다시 배포되어야 새 값이 적용됩니다. .env 파일은 로컬 개발용이므로 반드시 .gitignore에 넣어야 합니다. Render는 .env 파일을 자동으로 읽지 않으며, 저장소에 올라간 .env가 배포에 쓰인다고 착각해 운영 비밀 값을 커밋하는 실수가 종종 있습니다. 인증서나 설정 파일처럼 파일 형태가 필요한 비밀은 Secret Files 기능으로 넣으면 /etc/secrets/<파일명> 경로에서 읽을 수 있습니다.


Cron Jobs

설정

  1. New → Cron Job
  2. 스케줄 설정 (cron 문법)
  3. 명령어 입력
# 매일 자정 (UTC 기준)
0 0 * * *
# 명령어
npm run cleanup

스크립트

// scripts/cleanup.ts
import pool from './lib/db';
async function cleanup() {
  await pool.query('DELETE FROM logs WHERE created_at < NOW() - INTERVAL \'30 days\'');
  console.log('Cleanup completed');
}
cleanup();

Cron Job의 스케줄은 UTC 기준입니다. 0 0 * * *은 한국 시간으로 오전 9시에 실행되므로, 한국 자정에 돌리려면 0 15 * * *로 써야 합니다. 이 차이를 모르고 설정해 “왜 아침에 돌지?” 하는 일이 흔합니다. 또 Cron Job은 무료 인스턴스 대상이 아니고 실행 시간 기준으로 과금되는 유료 기능이라는 점도 알아 두어야 합니다(최소 월 요금이 있음).

위 스크립트는 두 가지를 보강하는 것이 좋습니다. 하나는 작업이 끝나면 await pool.end()로 연결을 닫는 것입니다. 커넥션 풀이 열려 있으면 Node 프로세스가 유휴 타임아웃까지 종료되지 않아 실행 시간(=비용)이 늘어납니다. 다른 하나는 실패를 종료 코드로 알리는 것입니다. cleanup().catch((e) => { console.error(e); process.exit(1); })처럼 처리하면 Render 대시보드에 실패로 기록되어 알림을 받을 수 있습니다. Cron Job은 실행할 때마다 새 컨테이너에서 시작하므로 파일 시스템에 상태를 남겨 다음 실행에서 읽는 방식은 동작하지 않고, 상태가 필요하면 DB에 저장해야 합니다.


도메인 설정

Render 도메인

your-app.onrender.com

커스텀 도메인

  1. Settings → Custom Domain
  2. 도메인 추가
  3. DNS 설정
Type  Name  Value
CNAME www   your-app.onrender.com

www 같은 서브도메인은 CNAME으로 연결하면 되지만, example.com 같은 루트(apex) 도메인에는 DNS 규칙상 CNAME을 둘 수 없습니다. 이 경우 DNS 공급자가 ALIAS/ANAME/CNAME flattening을 지원하면 그것을 쓰고, 지원하지 않으면 Render가 대시보드에서 안내하는 A 레코드 IP를 등록합니다. DNS가 확인되면 Render가 Let’s Encrypt 인증서를 자동으로 발급하고 갱신합니다. Cloudflare를 DNS로 쓰면서 프록시(주황색 구름)를 켜 두면 Render가 도메인 소유를 확인하지 못해 인증서 발급이 멈추는 경우가 있으니, 인증서가 발급될 때까지는 DNS only로 두는 것이 안전합니다.


render.yaml

services:
  - type: web
    name: my-app
    runtime: node
    buildCommand: npm install && npm run build
    startCommand: npm start
    envVars:
      - key: NODE_ENV
        value: production
      - key: DATABASE_URL
        fromDatabase:
          name: my-db
          property: connectionString
  - type: redis        # Redis 호환 Key Value 인스턴스는 services에 정의
    name: my-redis
    plan: free
    ipAllowList: []    # 외부 접속 차단 (내부 서비스만 접근)
databases:
  - name: my-db
    databaseName: mydb
    user: myuser
    plan: free

render.yaml은 Render가 Blueprint라고 부르는 구성 파일입니다. 저장소 루트에 두고 대시보드에서 New → Blueprint로 연결하면 파일에 정의된 서비스와 DB를 한 번에 만들고, 이후 파일을 수정해 push하면 변경 사항이 반영됩니다. 예전 예제에 나오는 env: node는 현재 runtime: node로 이름이 바뀌었습니다. 또 Redis는 databases가 아니라 services 아래에 type: redis로 정의해야 합니다. databases는 PostgreSQL 전용 항목이라 Redis를 여기에 넣으면 Blueprint 검증에서 실패합니다.

fromDatabase는 DB의 접속 문자열을 환경 변수로 자동 연결하는 기능입니다. 비밀번호를 YAML에 적지 않아도 되고, DB를 다시 만들어도 값이 따라옵니다. 대시보드와 render.yaml을 함께 쓸 때는 둘 중 하나를 기준으로 정해야 합니다. 대시보드에서 바꾼 설정이 다음 Blueprint 동기화 때 YAML 값으로 덮어써지는 일이 생기기 때문입니다. 비밀 값은 sync: false로 선언해 두면 YAML에는 키만 남고 값은 대시보드에서 입력하게 되어, 저장소에 비밀을 커밋하지 않으면서 구성은 코드로 관리할 수 있습니다.


첫 배포에서 막히는 다섯 지점

Render에서 처음 배포할 때 막히는 지점은 대부분 정해져 있습니다. 앱이 PORT 환경 변수를 읽고 0.0.0.0에 바인딩하는지, 빌드에 필요한 devDependencies가 실제로 설치되는지, DB를 서비스와 같은 리전에 만들고 Internal URL로 접속하는지, Cron 스케줄을 UTC 기준으로 적었는지, 무료 DB의 만료 기한을 알고 있는지입니다. 배포 로그에 에러가 없는데 서비스가 뜨지 않는다면 첫 번째 항목부터 순서대로 확인해 보시기 바랍니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Heroku와 비교하면 어떤가요?

A. 배포 경험(Git push → 자동 배포)은 비슷하고, Render는 제약 있는 무료 인스턴스와 정적 사이트 무료 호스팅을 제공한다는 점이 다릅니다. Heroku는 애드온 생태계가 넓고 오래된 만큼 문서와 사례가 많습니다.

Q. Railway와 비교하면 어떤가요?

A. Render는 인스턴스 크기별 고정 요금이라 비용 예측이 쉽고 정적 사이트 호스팅이 통합되어 있습니다. Railway는 사용량 기반 과금이라 트래픽이 적은 앱에서 저렴할 수 있고, 여러 서비스를 캔버스에서 연결하는 UI가 편합니다.

Q. 무료 플랜의 제한은?

A. 웹 서비스는 월 750 인스턴스 시간, 15분 비활성 시 슬립, 대역폭·빌드 시간 한도가 있습니다. PostgreSQL 무료 DB는 생성 후 일정 기간이 지나면 만료됩니다. 세부 수치는 바뀔 수 있으니 요금 페이지를 확인하세요.

Q. Render 무료 인스턴스로 프로덕션을 운영해도 되나요?

A. 권하지 않습니다. 무료 인스턴스는 슬립과 DB 만료 때문에 프로덕션에는 맞지 않고, 유료로 전환할 때 DB 백업 정책과 리전 선택을 함께 검토하는 것이 좋습니다.