웹서비스 출시 전 보안 점검표: 배포 환경에서 놓치는 10가지
출시 전 보안 점검을 코드 밖 배포 환경에 집중해 정리했습니다. 디버그 모드, .git, 백업 파일, 스테이징, 기본 계정 등 10가지를 curl로 확인하고 고치는 방법을 얻을 수 있습니다.
서비스 오픈 직전이라면 기능 코드보다 운영 도메인에서 실제로 무엇이 응답하는지부터 확인하는 편이 효율적입니다. 디버그 모드, 오류 페이지, .git, .env, 백업 파일, 소스맵, 공개된 스테이징, 기본 계정, 운영 도구 엔드포인트, 열린 포트. 이 10가지는 대부분 터미널에서 curl 한 줄로 확인할 수 있고, 고치는 데도 설정 몇 줄이면 되는 경우가 많습니다.
이 항목들은 코드 리뷰에 잘 걸리지 않습니다. 저장소에는 없고 서버 설정, 컨테이너 이미지, 배포 스크립트에 있기 때문입니다. OWASP Top 10:2025는 보안 설정 오류를 A02로 두고, 기본 계정 방치, 스택 트레이스 노출, 불필요한 기능 활성화를 대표적인 조건으로 꼽습니다.
로그인이나 관리자 기능의 설계 점검은 관리자 페이지 보안 체크리스트 같은 기능별 점검으로 다루고, 이 글은 배포 환경만 봅니다. 아래 명령은 본인이 운영하거나 점검 권한을 받은 서비스에만 실행하세요. 예시의 example.com은 자기 도메인으로 바꾸면 됩니다.
먼저 1분 점검: 경로를 한 번에 훑기
항목별로 보기 전에 민감 경로의 응답을 한 번에 확인합니다. 운영 도메인뿐 아니라 스테이징과 API 도메인에도 각각 돌립니다.
#!/usr/bin/env bash
BASE="https://example.com"
for p in /.git/HEAD /.git/config /.env /.env.production \
/backup.zip /db.sql /actuator /actuator/env \
/swagger-ui/index.html /v3/api-docs /server-status; do
result=$(curl -s -o /dev/null -m 5 \
-w '%{http_code} %{size_download} %{content_type}' "$BASE$p")
echo "$result $p"
done
결과를 읽을 때 주의할 점이 있습니다. SPA는 없는 경로에도 index.html을 200으로 돌려주는 경우가 많아서 상태 코드만 보면 오탐이 납니다. 응답 크기와 Content-Type이 메인 페이지와 같다면 대개 폴백입니다. 반대로 403은 파일이 있다는 뜻일 수 있으니 안심하지 말고, 차단이 어느 계층에서 일어났는지 확인합니다.
항목 1~2: 디버그 모드와 오류 페이지
1. 디버그 모드가 꺼져 있는가
없는 경로를 요청해 프레임워크 디버그 화면이 나오는지 봅니다.
curl -s https://example.com/no-such-path-check \
| grep -iE 'traceback|stack trace|DEBUG = True|URLconf'
아무것도 출력되지 않아야 정상입니다. Django 공식 문서는 운영에서 DEBUG를 켜면 소스 코드 일부, 지역 변수, 설정, 사용 라이브러리가 새어 나간다고 설명합니다. Laravel 문서도 운영의 APP_DEBUG는 항상 false여야 한다고 명시합니다. Django라면 운영 설정 파일로 python manage.py check --deploy까지 돌려 둡니다.
2. 오류 응답과 헤더가 내부를 말해주는가
일부러 깨진 JSON을 보내 오류 경로를 건드립니다.
curl -s -X POST https://example.com/api/orders \
-H 'Content-Type: application/json' -d '{bad'
파일 경로, SQL 문, 스택 트레이스가 보이면 수정 대상입니다. Express 공식 문서에 따르면 기본 오류 처리기는 NODE_ENV가 production일 때만 스택 트레이스를 뺍니다. 환경 변수 하나가 빠지면 그대로 노출된다는 뜻입니다. 직접 만든 처리기는 이 설정의 보호도 받지 못합니다.
// 취약: 오류 내용을 그대로 응답
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message, stack: err.stack });
});
// 수정: 상세 내용은 서버 로그로, 사용자에겐 추적 ID만
app.use((err, req, res, next) => {
logger.error({ err, requestId: req.id });
res.status(500).json({ message: '일시적인 오류입니다.', requestId: req.id });
});
OWASP 오류 처리 치트시트의 권고도 같습니다. 사용자에게는 구현 정보가 없는 일반 메시지를, 서버에는 상세 로그를 남깁니다.
헤더도 함께 봅니다.
curl -sI https://example.com | grep -iE '^(server|x-powered-by|strict-transport-security|x-content-type-options):'
nginx의 server_tokens 기본값은 on이라 끄지 않으면 버전이 오류 페이지와 Server 헤더에 나옵니다. OWASP HTTP 헤더 치트시트는 X-Powered-By 제거와 X-Content-Type-Options: nosniff를 권합니다. 다만 버전을 숨겨도 다른 방법으로 기술 스택을 알아낼 수 있어서 패치를 대신하지는 못합니다. HSTS는 긴 max-age를 잘못 걸면 정상 사용자가 접속하지 못할 수 있으니 짧은 값부터 시작합니다.
항목 3~6: 웹에서 받을 수 있으면 안 되는 파일
3. .git 디렉터리
curl -s https://example.com/.git/HEAD
ref: refs/heads/main 같은 문자열이 나오면 저장소가 공개된 상태입니다. 커밋 이력까지 받아질 수 있으므로 지금 코드뿐 아니라 과거에 커밋했던 비밀 값도 노출 범위에 들어갑니다. 원인은 대개 웹 루트가 프로젝트 루트를 가리키거나, 빌드 산출물이 아닌 저장소 전체를 배포한 경우입니다.
# 취약: 프로젝트 전체가 웹 루트
root /var/www/app;
# 수정: 빌드 결과물만 웹 루트로, 숨김 파일은 차단
root /var/www/app/public;
location ~ /\.(?!well-known) {
return 404;
}
컨테이너를 쓴다면 .dockerignore에 .git과 .env*를 넣고, COPY . . 대신 필요한 산출물만 복사합니다. 차단 규칙은 2차 방어이고, 근본 조치는 파일이 서버에 올라가지 않게 하는 것입니다.
4. .env와 설정 파일
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://example.com/.env
본문 대신 상태 코드와 크기만 보는 이유는 비밀 값을 터미널 기록과 로그에 남기지 않기 위해서입니다. 200이고 메인 페이지와 크기가 다르면 내용을 확인합니다.
실제로 내용이 받아졌다면 파일 삭제로 끝나지 않습니다. 그 안의 키와 비밀번호는 유출된 것으로 보고 전부 교체하고, 접근 로그에서 해당 경로 요청을 확인합니다. 키 교체 절차는 API 키 유출 방지 체크리스트를 참고하세요.
5. 백업 파일과 디렉터리 목록
OWASP WSTG는 편집기가 만든 ~, .swp, 수동 복사본 .old, .bak, 운영 서버에서 묶은 압축 파일을 대표적인 위험으로 듭니다. 서버가 실행하지 않는 확장자는 원문 그대로 내려받힐 수 있기 때문입니다.
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://example.com/config.php.bak
curl -s https://example.com/uploads/ | grep -i 'index of'
서버에서 직접 찾는 편이 추측보다 정확합니다.
find /var/www/app/public -type f \
\( -name '*.bak' -o -name '*.old' -o -name '*.orig' -o -name '*.swp' \
-o -name '*.sql' -o -name '*.zip' -o -name '*~' \)
WSTG의 권고대로 운영 서버에서 직접 편집하거나 압축하지 않고, 로그와 데이터 파일은 웹 루트 밖에 둡니다.
6. 프런트엔드 소스맵
curl -s https://example.com/assets/main.abc123.js | grep -o 'sourceMappingURL=.*'
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/assets/main.abc123.js.map
Next.js는 프로덕션 브라우저 소스맵이 기본적으로 꺼져 있고, 켜면 JS 파일과 같은 위치에서 제공됩니다. Vite도 build.sourcemap 기본값이 false입니다. 주의할 값은 hidden입니다. 번들의 참조 주석만 없앨 뿐 맵 파일은 생성되므로, 오류 추적 도구에 올린 뒤 배포물에서 삭제해야 합니다.
프런트엔드 코드는 어차피 공개되는 것이라 소스맵 자체는 치명적인 문제가 아닐 때가 많습니다. 다만 주석이나 내부 API 경로가 함께 드러나므로 의도적으로 공개한 것이 아니라면 끕니다.
항목 7~8: 스테이징과 기본 계정
7. 인증 없이 열린 스테이징
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/
로그인 프롬프트 없이 200이 나오면 수정 대상입니다. 공인 인증서를 발급받은 호스트 이름은 인증서 투명성 로그에 기록되므로, 주소를 어렵게 짓는 것은 보호 수단이 되지 못합니다. robots.txt도 마찬가지입니다. RFC 9309는 이 규칙이 접근 권한 부여 수단이 아니라고 밝히며, 경로를 적어 두면 오히려 위치를 알려주는 셈이 됩니다.
server {
listen 443 ssl;
server_name staging.example.com;
# 수정: 앱 앞단에서 인증 요구, 가능하면 VPN이나 IP 제한을 우선
auth_basic "Staging";
auth_basic_user_file /etc/nginx/.htpasswd;
add_header X-Robots-Tag "noindex" always;
}
함께 확인할 것은 두 가지입니다. 스테이징이 운영 DB나 운영 데이터 복사본에 붙어 있지 않은지, 디버그 설정이나 테스트 계정이 켜져 있지 않은지입니다. 스테이징은 운영보다 느슨한데 데이터는 같은 구성이 가장 위험합니다.
8. 기본 계정과 시드 계정
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"admin1234"}'
팀이 시드 스크립트에 넣었던 계정으로만 시도합니다. 비밀번호 대입이 목적이 아니므로 많이 반복하지 않습니다. OWASP WSTG도 계정 잠금 정책 때문에 관리자가 잠길 수 있다고 주의를 줍니다. 앱뿐 아니라 Grafana, DB 관리 도구, 모니터링 대시보드처럼 함께 띄운 도구의 초기 비밀번호도 목록을 만들어 확인합니다.
// 취약: 저장소에 고정된 관리자 계정
await createUser({ email: '[email protected]', password: 'admin1234', role: 'admin' });
// 수정: 비밀 값은 환경에서 주입, 없으면 실패, 첫 로그인 시 변경
const password = process.env.INITIAL_ADMIN_PASSWORD;
if (!password) throw new Error('INITIAL_ADMIN_PASSWORD가 없습니다');
await createUser({
email: process.env.INITIAL_ADMIN_EMAIL,
password,
role: 'admin',
mustChangePassword: true,
});
OWASP가 권하는 대로 환경마다 자격 증명을 다르게 둡니다. 테스트용 시드 스크립트는 운영 배포 파이프라인에서 실행되지 않게 분리합니다.
항목 9~10: 운영 도구와 네트워크 경계
9. 운영 도구 엔드포인트
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/actuator/env
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/swagger-ui/index.html
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/metrics
Spring Boot의 Actuator 문서에 따르면 HTTP로는 기본적으로 health만 노출됩니다. 문제는 개발 중 편의로 넣은 *가 운영까지 가는 경우입니다.
# 취약
management:
endpoints:
web:
exposure:
include: "*"
# 수정: 필요한 것만 노출하고 값은 권한자에게만
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
env:
show-values: when-authorized
roles: "admin"
현재 문서 기준 shutdown과 heapdump는 기본 접근이 제한되지만 버전마다 다르므로, 문서가 아니라 실제 응답으로 확인합니다. 직접 정의한 SecurityFilterChain이 있으면 Actuator 접근 규칙도 직접 챙겨야 합니다. API 문서는 공개 API가 아니라면 운영 빌드에서 끄거나 인증 뒤에 둡니다.
10. 열린 포트와 오리진 직접 접속
사내망이 아니라 외부 네트워크(휴대폰 테더링 등)에서 서버 공인 IP로 확인합니다. 203.0.113.10은 예시 주소입니다.
# HTTP 관리 포트
curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.10:8080/
# DB, 캐시 포트
nc -vz -w 3 203.0.113.10 5432
nc -vz -w 3 203.0.113.10 6379
# CDN이나 WAF를 건너뛰고 오리진에 바로 닿는지
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/
WSTG도 관리 인터페이스가 메인 서비스와 다른 포트에 있을 수 있다고 설명합니다. 80, 443 외에 연결되는 포트가 있으면 이유를 설명할 수 있어야 합니다. 흔한 원인 중 하나는 Docker 포트 공개입니다.
# 취약: 모든 인터페이스에 공개
ports:
- "6379:6379"
# 수정: 로컬에만 바인딩(같은 네트워크의 컨테이너끼리는 ports 없이 통신 가능)
ports:
- "127.0.0.1:6379:6379"
Docker가 공개한 포트는 호스트 방화벽 도구의 규칙과 별개로 열릴 수 있으므로, 설정 파일이 아니라 외부에서의 접속 결과로 판단합니다. 오리진에 바로 닿는다면 보안 그룹에서 CDN 대역만 허용합니다.
결과를 어떻게 판단할까
발견한 항목을 모두 같은 무게로 볼 필요는 없습니다. 오픈 일정이 빠듯할 때는 아래 기준으로 나눕니다.
| 구분 | 해당 항목 | 조치 |
|---|---|---|
| 출시 보류 | .git, .env, DB 백업 다운로드 가능, 기본 관리자 로그인 성공, DB 포트 외부 접속 | 고치고 노출된 비밀 값 교체 후 출시 |
| 출시 전 수정 | 디버그 모드, 스택 트레이스, 인증 없는 스테이징, Actuator 민감 엔드포인트 | 대부분 설정 변경이므로 당일 처리 |
| 일정 등록 | 버전 헤더, 의도치 않은 소스맵, 내용이 민감하지 않은 API 문서 | 담당자와 기한을 정해 후속 처리 |
자주 묻는 질문
Vercel이나 S3 같은 관리형 호스팅이면 이 점검이 필요 없나요?
서버 포트 항목은 줄어들지만 나머지는 남습니다. 저장소 전체를 업로드하면 파일은 그대로 공개되고, 미리보기 배포는 공개된 스테이징과 같은 문제를 낳습니다. 디버그 설정과 기본 계정도 호스팅과 무관합니다.
스테이징을 외부 협력사도 봐야 하면 어떻게 하나요?
IP 제한이나 VPN이 가장 깔끔하고, 어렵다면 앱 앞단에 Basic 인증이나 SSO를 둡니다. 어떤 경우든 운영 데이터는 쓰지 않습니다. 협력사 계정은 기간을 정해 만들고 프로젝트가 끝나면 회수합니다.
언제, 얼마나 자주 돌려야 하나요?
출시 직전 한 번으로는 부족합니다. 설정은 이후 배포에서 다시 바뀌기 때문입니다. 1분 점검 스크립트를 배포 후 단계에 넣어 민감 경로가 차단 응답이 아니면 알림을 보내게 하고, 포트와 기본 계정은 인프라를 바꿀 때마다 확인합니다. OWASP도 설정 검증의 자동화를 권합니다.
정리
- 출시 전 보안 점검은 저장소가 아니라 운영 도메인과 서버가 실제로 돌려주는 응답을 기준으로 합니다.
.git,.env, 백업 파일, 기본 계정, 외부에 열린 DB 포트는 출시를 멈출 만한 항목이며, 비밀 값이 노출됐다면 삭제가 아니라 교체까지 해야 끝납니다.- 디버그 모드, 오류 페이지, 스테이징, 운영 도구는 대부분 설정 변경으로 해결되므로 오픈 당일에도 처리할 수 있습니다.
- 점검을 배포 파이프라인에 넣으면 한 번의 확인이 지속되는 안전장치가 되고, 직접 점검이 어렵다면 무료 분석 신청으로 확인을 받아볼 수 있습니다.
참고 자료
- OWASP Top 10:2025 A02 Security Misconfiguration
- OWASP WSTG v4.2: Review Old Backup and Unreferenced Files for Sensitive Information
- OWASP WSTG v4.2: Enumerate Infrastructure and Application Admin Interfaces
- OWASP WSTG v4.2: Testing for Default Credentials
- OWASP Error Handling Cheat Sheet
- OWASP HTTP Headers Cheat Sheet
- Django Deployment checklist
- Laravel 13.x Configuration
- Express Error Handling
- Spring Boot Actuator Endpoints
함께 읽으면 좋은 글
Express 보안 설정 가이드: 배포 전 고칠 미들웨어 실수 7가지
Express 보안 설정에서 자주 틀리는 trust proxy, 미들웨어 순서, 본문 크기 제한, 에러 스택 노출 등 7가지를 잘못된 코드와 고친 코드로 비교해 배포 전 점검 기준을 제시합니다.
CSRF 공격이란? SameSite 시대에도 뚫리는 구성과 방어법
CSRF 공격의 원리와 XSS와의 차이, SameSite=Lax만 믿으면 방어가 빠지는 구성을 짚고, 인증 방식별로 토큰·Origin 검증 중 무엇이 필요한지 코드와 체크리스트로 정리합니다.
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.