SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
배경
개인 블로그와 별도로 멀티 유저 SaaS 블로그 플랫폼을 만들고 있습니다. 백엔드는 NestJS(MongoDB + MySQL), 프론트엔드는 Next.js 15로 구성되어 있고, 배포 환경은 Coolify를 사용하고 있습니다.
이미 개인 블로그와 백엔드는 Coolify에서 운영 중이었기 때문에, SaaS 프론트엔드도 같은 방식으로 올리면 금방 끝날 거라 생각했습니다. 하지만 막상 배포를 진행하니 예상치 못한 문제들이 하나씩 튀어나왔습니다.
1. Docker 메모리 설정 — 빌드 타임이 아니라 런타임 문제
Dockerfile에서 NODE_OPTIONS="--max-old-space-size=512"를 설정하고 있었는데, 처음에 이 값을 512MB로 잡아두었습니다. 그런데 실제로 Grafana에서 백엔드 메모리를 확인해보니 RSS 173~192MB, 힙 106~117MB 정도만 사용하고 있었습니다.
프론트엔드는 Prometheus 메트릭이 없어서 정확한 측정은 어려웠지만, 백엔드보다 가벼울 것이 분명했습니다. 결국 SaaS 프론트엔드는 384MB, 개인 블로그 웹은 512MB로 줄였습니다.
# SaaS 프론트엔드
ENV NODE_OPTIONS="--max-old-space-size=384"
# 개인 블로그 웹
ENV NODE_OPTIONS="--max-old-space-size=512"
여기서 한 가지 짚고 넘어갈 부분이 있습니다. --max-old-space-size는 V8 힙 메모리 제한이지 빌드 타임 설정이 아닙니다. Dockerfile의 ENV로 설정하면 프로덕션 런타임(Stage 3: runner)에만 적용됩니다. 빌드 단계(Stage 2: builder)는 별도 스테이지이므로 이 제한에 영향을 받지 않습니다.
즉, OOM이 걱정된다면 빌드 타임보다는 런타임에서의 메모리 누수나 급증을 신경 써야 합니다.
2. OG 이미지가 localhost를 가리키는 문제
배포 후 OG 이미지 태그를 확인해보니 이런 상황이었습니다.
og:image → http://localhost:3000/images/og-image.png
Next.js의 metadataBase가 process.env.NEXT_PUBLIC_SITE_URL을 참조하는데, 이 값이 빌드 시점에 제대로 주입되지 않았던 것입니다.
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'
),
// ...
};
NEXT_PUBLIC_* 환경변수는 빌드 타임에 번들에 하드코딩됩니다. 런타임에 바꿀 수 없습니다. Coolify에서 Build Arguments로 NEXT_PUBLIC_SITE_URL을 설정하고 재빌드해야 합니다.
이건 Next.js를 Docker로 배포할 때 자주 놓치는 부분인데, NEXT_PUBLIC_ 접두사가 붙은 변수는 반드시 빌드 단계에서 주입되어야 한다는 점을 다시 한번 확인하게 되었습니다.
3. 봇 스캔이 에러 로그를 도배하는 문제
배포하자마자 백엔드 로그가 이런 에러로 가득 찼습니다.
GET /public/favicon.ico ERROR: 사용자를 찾을 수 없습니다.
GET /public/.env ERROR: 사용자를 찾을 수 없습니다.
GET /public/.git/config ERROR: 사용자를 찾을 수 없습니다.
GET /public/swagger-ui.html ERROR: 사용자를 찾을 수 없습니다.
공개 IP에 도메인을 연결하자마자 봇들이 취약점 스캔을 시작한 것입니다. 문제는 /public/:username 라우트가 모든 경로를 username 파라미터로 받아들여서, favicon.ico든 .env든 전부 DB 조회를 시도했다는 점입니다.
해결 1: username 형식 검증
컨트롤러에서 DB 조회 전에 username 형식을 먼저 검증하도록 했습니다.
const VALID_USERNAME = /^[a-z0-9_]{3,20}$/;
private validateUsername(username: string): void {
if (!VALID_USERNAME.test(username)) {
throw new NotFoundException();
}
}
favicon.ico나 .env는 이 정규식을 통과하지 못하므로 DB 조회 없이 즉시 404를 반환합니다.
해결 2: 로그 레벨 분리
기존 로깅 인터셉터는 모든 에러를 ERROR 레벨로 기록하고 있었습니다. 404는 클라이언트 잘못이지 서버 문제가 아닌데, 같은 레벨로 기록되니 실제 서버 에러를 찾기 어려웠습니다.
// 변경 전: 모든 에러를 ERROR로
this.logger.error(`${method} ${path} ERROR: ${errorMessage}`);
// 변경 후: HTTP 상태 코드 기반으로 분리
const statusCode = error instanceof HttpException
? error.getStatus()
: 500;
if (statusCode >= 500) {
this.logger.error(logMessage, error, logMeta);
} else {
this.logger.warn(logMessage, logMeta);
}
이제 봇 스캔으로 인한 404는 WARN으로, 실제 서버 에러만 ERROR로 기록됩니다.
4. Redis 연결 타임아웃 — 회원가입이 안 되는 원인
가장 당황했던 문제입니다. 배포 후 회원가입을 시도하면 500 에러가 발생했습니다. 로그를 확인하니 원인이 명확했습니다.
POST /auth/signup ERROR (5263ms): Redis connection timeout
CacheConnectionError: Redis connection timeout
SaaS 플랫폼은 Refresh Token을 Redis에 저장합니다. 로컬에서는 Docker Compose로 Redis를 띄우지만, 운영 환경에서는 REDIS_URL이 올바른 값으로 설정되어 있지 않았습니다.
Coolify에 이미 Redis 서비스가 있었기 때문에, 내부 네트워크 URL로 REDIS_URL을 업데이트하고 백엔드를 재시작하는 것으로 해결했습니다. 같은 Coolify Docker 네트워크 안에 있으므로 외부 노출 없이 컨테이너 간 직접 통신이 가능합니다.
5. CORS 에러 — curl은 되는데 브라우저는 안 되는 이유
Redis 문제를 해결한 뒤에도 브라우저에서 회원가입이 되지 않았습니다. 개발자 도구를 열어보니 CORS 에러가 발생하고 있었습니다.
그런데 이상한 점이 있었습니다. curl로 같은 API를 호출하면 정상적으로 201 응답이 왔습니다. 왜 curl은 되고 브라우저는 안 될까?
CORS는 브라우저가 강제하는 보안 정책이기 때문입니다. 브라우저는 프론트엔드 도메인에서 로드된 JavaScript가 API 서버로 요청을 보낼 때, 응답 헤더의 Access-Control-Allow-Origin에 해당 도메인이 포함되어 있는지 확인합니다. 없으면 응답 자체를 차단합니다. curl이나 Postman 같은 도구는 이 검사를 하지 않으므로 항상 성공합니다.
백엔드의 CORS 설정을 확인해보니, 프로덕션 환경에 기존 서비스 도메인만 등록되어 있고 새로 배포한 SaaS 프론트엔드 도메인이 빠져 있었습니다.
// 변경 전: SaaS 프론트엔드 도메인 누락
const corsOrigins = process.env.NODE_ENV === 'production'
? [
process.env.WEB_URL, // 기존 서비스만
'http://localhost:5173',
'http://localhost:3003',
]
: [ /* ... */ ];
// 변경 후: SaaS 프론트엔드 도메인 추가
const corsOrigins = process.env.NODE_ENV === 'production'
? [
process.env.WEB_URL,
process.env.SAAS_FRONTEND_URL, // SaaS 프론트엔드 추가
'http://localhost:5173',
'http://localhost:3003',
]
: [ /* ... */ ];
로컬 개발에서는 http://localhost:3003이 이미 등록되어 있어서 문제가 없었기 때문에, 프로덕션 배포 전까지 이 문제를 발견할 수 없었습니다.
정리
하나의 SaaS 앱을 배포하는 과정에서 겪은 문제들을 정리하면 이렇습니다.
| 문제 | 원인 | 해결 |
|---|---|---|
| 메모리 과다 할당 | 실 사용량 미측정 | Grafana 확인 후 384~512MB로 조정 |
| OG 이미지 localhost | 빌드 타임 환경변수 미주입 | Coolify Build Arguments 설정 |
| 봇 스캔 에러 도배 | username 라우트가 모든 경로 수용 | 정규식 검증 + 로그 레벨 분리 |
| 회원가입 500 에러 | Redis 연결 정보 누락 | Coolify 내부 Redis URL 설정 |
| CORS 에러 | 프로덕션 origin에 SaaS 도메인 누락 | 환경변수로 도메인 추가 |
돌이켜 생각해보면, 대부분 로컬 개발 환경과 운영 환경의 차이에서 비롯된 문제들이었습니다. 로컬에서는 Redis가 Docker Compose로 바로 뜨고, 환경변수도 .env 파일로 관리되고, CORS도 localhost로 이미 등록되어 있으니 문제가 없었습니다. 하지만 운영에서는 각각의 서비스가 독립적인 컨테이너로 분리되어 있고, 도메인도 다르다는 점을 꼼꼼히 챙겨야 했습니다.
특히 CORS 문제는 curl로 테스트하면 발견할 수 없다는 점이 인상적이었습니다. 배포 후에는 반드시 실제 브라우저에서 테스트해야 합니다. curl이 성공했다고 안심하면 안 됩니다.
NEXT_PUBLIC_* 환경변수의 빌드 타임 주입도 Next.js + Docker 조합에서 매번 실수하기 쉬운 부분이라, 배포 체크리스트에 넣어두는 것이 좋겠다는 생각이 들었습니다.
관련 글
작은 서비스에도 모니터링이 필요한 이유 - NestJS + Prometheus + Grafana Cloud 구축기
작은 서비스도 리소스 제약 때문에 모니터링이 필수입니다. NestJS에 Prometheus와 Grafana Cloud를 연동하여 효율적인 리소스 관리 시스템을 구축하는 방법을 알아보세요.
PM2 vs Coolify: 상황에 맞는 Node.js 배포 전략 선택하기
Node.js 배포 도구인 PM2와 Coolify의 차이점을 분석하고, 프로젝트 특성에 따른 선택 기준을 제시합니다.
Next.js 배포 시 빈 캐시 문제 해결: 런타임 워밍에서 빌드 타임 정적 생성으로
Next.js + Coolify 환경에서 배포 직후 빈 캐시와 no available server 에러가 발생하는 문제를 해결한 경험. 런타임 캐시 워밍의 한계를 겪고, 빌드 타임 정적 생성으로 전환하여 근본적으로 해결했습니다.