1인 개발자와 보안의 현실
1인 SaaS를 만들면 보안은 항상 “나중에” 목록에 들어갑니다. 기능 하나 더 만드는 게 더 급하고, 사용자가 10명인데 보안이 뭐가 중요하냐는 생각이 듭니다.
문제는, 보안 사고가 터지면 1인 개발자에게는 복구할 여력이 없다는 것입니다.
| 상황 | 팀이 있을 때 | 혼자일 때 |
|---|---|---|
| DB 유출 | 보안팀이 대응 + PR팀이 공지 | 혼자 원인 파악 + 사과 + 복구 |
| API key 노출 | 즉시 rotate + 영향 분석 | 어디서 쓰이는지 파악부터 |
| DDoS | 인프라팀이 WAF 조정 | Cloudflare 무료 티어로 버티기 |
| 법적 이슈 (GDPR 등) | 법무팀 대응 | 직접 조사 + 대응 |
보안은 “안 해도 되는 것”이 아니라 “혼자이기 때문에 더 해야 하는 것”입니다. 사고 발생 시 대응할 사람이 나밖에 없기 때문입니다.
graph TD
A["1인 SaaS 개발자"] --> B{"보안 사고\n발생"}
B -->|"팀 있음"| C["역할 분담\n빠른 대응"]
B -->|"혼자"| D["원인 파악 +\n고객 대응 +\n복구 = 전부 혼자"]
D --> E["서비스 중단\n시간 ↑"]
D --> F["신뢰 손실"]
D --> G["법적 리스크"]
style D fill:#ffebee,stroke:#f44336
style C fill:#e8f5e9,stroke:#4caf50
OWASP Top 10에서 1인 SaaS에 해당하는 5가지
OWASP Top 10은 웹 애플리케이션의 가장 흔한 보안 취약점 목록입니다. 10개 중 1인 SaaS에서 실제로 자주 발생하는 5가지를 골랐습니다.
graph LR
subgraph RELEVANT["1인 SaaS에 해당"]
A01["A01\nBroken Access Control"]
A02["A02\nCryptographic Failures"]
A03["A03\nInjection"]
A05["A05\nSecurity Misconfiguration"]
A07["A07\nIdentification &\nAuthentication Failures"]
end
subgraph LESS["상대적으로 덜 해당"]
A04["A04\nInsecure Design"]
A06["A06\nVulnerable Components"]
A08["A08\nData Integrity Failures"]
A09["A09\nLogging Failures"]
A10["A10\nSSRF"]
end
style RELEVANT fill:#fff3e0,stroke:#ff9800
style LESS fill:#f5f5f5,stroke:#bdbdbd
A01. Broken Access Control (접근 제어 실패)
무엇인가: 다른 사용자의 데이터에 접근할 수 있는 취약점.
| 예시 | 원인 | 결과 |
|---|---|---|
/api/users/123/data에서 123을 456으로 바꾸면 다른 사용자 데이터 노출 | URL 파라미터만으로 권한 확인 | 전체 사용자 데이터 유출 |
| Admin 페이지가 로그인만 하면 접근 가능 | 역할(role) 검증 없음 | 일반 사용자가 관리자 기능 사용 |
| DELETE API에 인증 없음 | 엔드포인트별 인증 누락 | 누구나 데이터 삭제 가능 |
최소 대응:
// BAD — URL 파라미터만으로 데이터 조회
app.get('/api/users/:id/reports', async (req, res) => {
const reports = await db.getReports(req.params.id);
return res.json(reports);
});
// GOOD — 세션의 사용자 ID와 비교
app.get('/api/users/:id/reports', auth, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const reports = await db.getReports(req.params.id);
return res.json(reports);
});
WICHI에서 초기에 report API가 UUID만 알면 누구나 접근 가능했습니다. UUID가 추측 불가능하다고 생각했지만, URL이 공유되면 인증 없이 접근되는 문제가 있었습니다.
A02. Cryptographic Failures (암호화 실패)
무엇인가: 민감한 데이터가 암호화 없이 저장되거나 전송되는 취약점.
| 항목 | 해야 하는 것 | 하지 말아야 하는 것 |
|---|---|---|
| 비밀번호 | bcrypt/argon2 해싱 | 평문 저장, MD5/SHA1 |
| API 통신 | HTTPS 강제 | HTTP 허용 |
| DB 연결 | SSL/TLS | 평문 연결 |
| 토큰 저장 | httpOnly + secure 쿠키 | localStorage |
A03. Injection (인젝션)
무엇인가: 사용자 입력이 쿼리/명령어의 일부로 실행되는 취약점. SQL Injection, XSS(Cross-Site Scripting), Command Injection이 대표적입니다.
// BAD — SQL Injection 취약
const query = `SELECT * FROM users WHERE email = '${email}'`;
// GOOD — Parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);
| 인젝션 유형 | 방어 | 도구 |
|---|---|---|
| SQL Injection | Parameterized query / ORM | Prisma, Drizzle |
| XSS | 출력 이스케이프, CSP | DOMPurify, helmet |
| Command Injection | 사용자 입력을 shell에 넘기지 않음 | — |
A05. Security Misconfiguration (보안 설정 오류)
무엇인가: 기본값을 그대로 사용하거나, 불필요한 기능이 켜져 있는 취약점.
graph TD
A["기본 설정 그대로 배포"] --> B["디버그 모드 ON"]
A --> C["에러 상세 메시지 노출"]
A --> D["기본 비밀번호 미변경"]
A --> E["불필요한 포트 열림"]
B --> F["내부 구조 노출"]
C --> F
D --> G["무단 접근"]
E --> G
style F fill:#ffebee,stroke:#f44336
style G fill:#ffebee,stroke:#f44336
1인 개발자가 자주 빠뜨리는 것들:
| 항목 | 위험 | 대응 |
|---|---|---|
DEBUG=true로 프로덕션 배포 | 스택 트레이스 노출 | 환경별 설정 분리 |
CORS * (모든 origin 허용) | 다른 사이트에서 API 호출 가능 | origin 화이트리스트 |
| 기본 에러 페이지 | 프레임워크/버전 노출 | 커스텀 에러 핸들러 |
| 불필요한 HTTP 메서드 | PUT/DELETE 노출 | 필요한 메서드만 허용 |
A07. Identification & Authentication Failures (인증 실패)
무엇인가: 로그인, 세션 관리, 비밀번호 정책이 약한 취약점.
| 항목 | 최소 기준 |
|---|---|
| 세션 만료 | 최대 24시간, 비활동 시 30분 |
| 비밀번호 정책 | 최소 8자, 복잡도 요구 |
| 로그인 시도 제한 | 5회 실패 시 15분 잠금 |
| 비밀번호 재설정 | 토큰 기반, 1시간 만료 |
| 소셜 로그인 | state 파라미터 검증 |
환경변수 관리
환경변수는 1인 SaaS에서 가장 흔한 보안 사고의 원인입니다. API 키가 GitHub에 올라가는 사고는 매일 발생합니다.
환경변수 보안 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | .env가 .gitignore에 포함되어 있는가 | □ |
| 2 | .env.example에 실제 값 대신 placeholder가 있는가 | □ |
| 3 | 프로덕션 환경변수가 호스팅 서비스의 환경변수 설정에 있는가 (파일이 아닌) | □ |
| 4 | API 키에 최소 권한 원칙이 적용되어 있는가 | □ |
| 5 | 키 로테이션 주기가 정해져 있는가 (최소 90일) | □ |
| 6 | 클라이언트에 노출되는 환경변수와 서버 전용 환경변수가 분리되어 있는가 | □ |
graph LR
A["환경변수"] --> B{"어디에 저장?"}
B -->|".env 파일"| C["로컬 개발 전용\n.gitignore 필수"]
B -->|"호스팅 설정"| D["Vercel/Railway\n환경변수 패널"]
B -->|"시크릿 매니저"| E["AWS SSM,\nVault 등"]
C --> F["절대 커밋 금지"]
D --> G["안전"]
E --> G
style F fill:#ffebee,stroke:#f44336
style G fill:#e8f5e9,stroke:#4caf50
클라이언트 vs 서버 환경변수
| 프레임워크 | 클라이언트 노출 | 서버 전용 |
|---|---|---|
| Next.js | NEXT_PUBLIC_* | 나머지 전부 |
| Vite | VITE_* | 나머지 전부 |
| Astro | PUBLIC_* | 나머지 전부 |
클라이언트에 노출되는 환경변수에 API secret을 넣는 실수가 빈번합니다.
NEXT_PUBLIC_STRIPE_SECRET_KEY같은 이름은 절대 사용하면 안 됩니다. 클라이언트 환경변수는 브라우저 소스에 그대로 노출됩니다.
의존성 감사: Dependabot과 Snyk
1인 개발자의 코드는 안전해도, 사용하는 패키지가 취약할 수 있습니다. npm 생태계에서 의존성 취약점은 상시 발생합니다.
도구 비교
| 도구 | 가격 | 자동 PR | CI 통합 | 특징 |
|---|---|---|---|---|
| GitHub Dependabot | 무료 | ✅ | ✅ | GitHub 기본 내장 |
| Snyk | 무료 티어 있음 | ✅ | ✅ | 더 넓은 DB, 수정 제안 |
npm audit | 무료 | ❌ | 수동 | CLI에서 바로 확인 |
| Socket.dev | 무료 티어 있음 | ✅ | ✅ | 공급망 공격 탐지 특화 |
최소 설정 (GitHub Dependabot)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
이 파일 하나를 추가하면:
- 매주 의존성 취약점을 자동 스캔
- 취약한 패키지에 대해 자동 PR 생성
- 머지하면 바로 수정 완료
5분이면 설정 가능. 설정 안 하면 3개월 뒤
npm audit에 취약점 30개가 쌓여 있는 걸 발견하게 됩니다.
Rate Limiting
API에 rate limiting이 없으면 다음이 발생합니다:
| 공격 유형 | 결과 | rate limiting 효과 |
|---|---|---|
| Brute-force 로그인 | 비밀번호 탈취 | 시도 횟수 제한 |
| API 남용 | 서버 비용 폭증 | 요청량 제한 |
| 스크래핑 | 데이터 무단 수집 | 속도 제한 |
| DDoS (경량) | 서비스 중단 | 부하 분산 |
구현 예시
// express-rate-limit
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // IP당 100회
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, try again later' },
});
// 로그인은 더 엄격하게
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // IP당 5회
skipSuccessfulRequests: true,
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
엔드포인트별 권장 제한
| 엔드포인트 유형 | 제한 | 이유 |
|---|---|---|
| 로그인/회원가입 | 5회/15분 | brute-force 방지 |
| 비밀번호 재설정 | 3회/시간 | 남용 방지 |
| 일반 API | 100회/15분 | 정상 사용 허용 범위 |
| 파일 업로드 | 10회/시간 | 스토리지 남용 방지 |
| Webhook 수신 | 1000회/분 | 정상 트래픽 허용 |
CORS 설정
CORS(Cross-Origin Resource Sharing)는 브라우저가 다른 도메인의 API를 호출할 때 적용되는 보안 정책입니다.
흔한 실수
// BAD — 모든 origin 허용
app.use(cors({ origin: '*' }));
// BAD — credentials와 * 조합 (실제로 작동하지 않음)
app.use(cors({ origin: '*', credentials: true }));
// GOOD — 화이트리스트
app.use(cors({
origin: ['https://myapp.com', 'https://www.myapp.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
graph LR
A["브라우저\n(myapp.com)"] -->|"API 요청"| B["서버\n(api.myapp.com)"]
B -->|"CORS 헤더\nAccess-Control-Allow-Origin"| A
C["공격자\n(evil.com)"] -->|"API 요청"| B
B -->|"origin 불일치\n→ 거부"| C
style C fill:#ffebee,stroke:#f44336
style A fill:#e8f5e9,stroke:#4caf50
| 설정 | 개발 환경 | 프로덕션 환경 |
|---|---|---|
| origin | localhost:3000 | 실제 도메인만 |
| credentials | true (쿠키 사용 시) | true (쿠키 사용 시) |
| methods | 전체 허용 가능 | 필요한 것만 |
| headers | 전체 허용 가능 | 필요한 것만 |
CSP (Content Security Policy)
CSP는 브라우저에게 “이 페이지에서 허용하는 리소스 출처”를 알려주는 HTTP 헤더입니다. XSS 공격의 영향을 줄이는 데 효과적입니다.
기본 CSP 설정
// helmet을 사용한 CSP 설정
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // CSS-in-JS 사용 시
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.myapp.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
| 디렉티브 | 설명 | 권장 |
|---|---|---|
default-src | 기본 정책 | 'self' |
script-src | JavaScript 출처 | 'self' (인라인 스크립트 금지) |
style-src | CSS 출처 | 'self' + 필요 시 'unsafe-inline' |
img-src | 이미지 출처 | 'self' + CDN |
connect-src | API/WebSocket 출처 | 'self' + API 도메인 |
object-src | Flash/Java 등 | 'none' |
CSP를 처음 적용하면 사이트가 깨질 수 있습니다.
Content-Security-Policy-Report-Only헤더로 먼저 테스트한 후, 문제 없으면 실제Content-Security-Policy로 전환하는 것을 권장합니다.
WICHI에서 빠뜨렸다가 고친 것들
MMU의 534개 체크리스트 중 보안 관련 항목은 45개입니다. 이 항목들은 WICHI를 만들면서 실제로 빠뜨렸다가 고친 경험에서 나왔습니다.
빠뜨린 항목과 발견 시점
| 항목 | 빠뜨린 기간 | 발견 계기 | 영향 |
|---|---|---|---|
| API rate limiting | 론칭 후 2주 | 비정상 트래픽 감지 | API 비용 2배 |
| CORS origin 제한 | 론칭 후 1주 | 보안 체크리스트 작성 중 발견 | 잠재적 취약점 |
| 환경변수 분리 (client/server) | 개발 중기 | 코드 리뷰 | 키 노출 위험 |
| CSP 헤더 | 론칭 후 3주 | MMU 체크리스트 항목 정리 중 | XSS 방어 부재 |
| Webhook 서명 검증 | 론칭 후 1주 | Stripe/LemonSqueezy 문서 재확인 | 위조 결제 이벤트 위험 |
graph TD
A["WICHI 론칭"] --> B["기능은 작동"]
B --> C["보안 점검 시작"]
C --> D["빠뜨린 항목 발견"]
D --> E["하나씩 수정"]
E --> F["패턴 발견:\n같은 항목을\n매번 빠뜨림"]
F --> G["체크리스트화\n= MMU"]
style F fill:#fff3e0,stroke:#ff9800
style G fill:#e8f5e9,stroke:#4caf50
보안 항목을 빠뜨린 원인은 “몰라서”가 아닙니다. “급해서”입니다. 기능 개발에 집중하면 보안은 자연스럽게 밀립니다. 체크리스트가 필요한 이유입니다.
1인 SaaS 보안 최소 체크리스트
아래 20개 항목은 론칭 전에 최소한 확인해야 하는 보안 항목입니다.
| # | 카테고리 | 항목 | 난이도 |
|---|---|---|---|
| 1 | 인증 | 비밀번호 해싱 (bcrypt/argon2) | 낮음 |
| 2 | 인증 | 세션 만료 설정 | 낮음 |
| 3 | 인증 | 로그인 시도 제한 (rate limit) | 낮음 |
| 4 | 접근제어 | API 엔드포인트별 인증 확인 | 중간 |
| 5 | 접근제어 | 사용자 데이터 격리 (다른 사용자 데이터 접근 불가) | 중간 |
| 6 | 환경변수 | .env가 .gitignore에 포함 | 낮음 |
| 7 | 환경변수 | 클라이언트/서버 환경변수 분리 | 낮음 |
| 8 | 환경변수 | 프로덕션 키가 코드에 하드코딩되지 않음 | 낮음 |
| 9 | 통신 | HTTPS 강제 (HTTP → HTTPS 리다이렉트) | 낮음 |
| 10 | 통신 | CORS origin 화이트리스트 | 낮음 |
| 11 | 통신 | API rate limiting | 낮음 |
| 12 | 헤더 | CSP 헤더 설정 | 중간 |
| 13 | 헤더 | X-Frame-Options (클릭재킹 방지) | 낮음 |
| 14 | 헤더 | X-Content-Type-Options: nosniff | 낮음 |
| 15 | 의존성 | Dependabot 또는 Snyk 설정 | 낮음 |
| 16 | 의존성 | npm audit 정기 실행 (주 1회) | 낮음 |
| 17 | 데이터 | SQL Injection 방지 (parameterized query) | 낮음 |
| 18 | 데이터 | XSS 방지 (출력 이스케이프) | 낮음 |
| 19 | 결제 | Webhook 서명 검증 | 중간 |
| 20 | 모니터링 | 비정상 트래픽 알림 | 중간 |
20개 중 14개가 “낮음” 난이도입니다. 대부분은 설정 한 줄, 패키지 하나로 해결됩니다. 어려운 게 아니라 잊어버리는 겁니다.
보안 vs 사용성 트레이드오프
보안을 강화하면 사용성이 떨어지는 지점이 있습니다. 1인 SaaS에서의 균형점:
graph LR
subgraph MUST["반드시 (사용성 무관)"]
M1["HTTPS 강제"]
M2["비밀번호 해싱"]
M3["SQL Injection 방지"]
M4["환경변수 보호"]
end
subgraph BALANCE["균형 필요"]
B1["세션 만료 시간"]
B2["비밀번호 복잡도"]
B3["rate limiting 임계값"]
B4["CSP 강도"]
end
subgraph DEFER["후순위 가능"]
D1["2FA"]
D2["IP 화이트리스트"]
D3["감사 로그"]
D4["침투 테스트"]
end
style MUST fill:#ffebee,stroke:#f44336
style BALANCE fill:#fff3e0,stroke:#ff9800
style DEFER fill:#e8f5e9,stroke:#4caf50
| 판단 기준 | 질문 |
|---|---|
| 사용자 수 | 10명이면 2FA는 과잉, 10,000명이면 필수 |
| 데이터 민감도 | 결제 정보 → 높은 보안, 블로그 → 기본 보안 |
| 규제 | GDPR 대상이면 감사 로그 필수 |
| 비용 | WAF 월 $20 vs 잠재적 사고 비용 |
정리
| 핵심 | 내용 |
|---|---|
| 1인이라 보안 더 중요 | 사고 시 대응할 사람이 나뿐 |
| OWASP 5가지 | 접근제어, 암호화, 인젝션, 설정오류, 인증 |
| 환경변수 | .gitignore + 클라이언트/서버 분리 |
| 자동화 | Dependabot 5분 설정으로 의존성 감사 |
| Rate limiting | 엔드포인트별 차등 제한 |
| CORS + CSP | 허용 origin + 리소스 출처 제한 |
| 20개 최소 체크리스트 | 14개가 난이도 “낮음” — 잊지 않으면 됨 |
Related Posts
해커톤 탈락 후 — 독립 SaaS로 전환한 과정
해커톤 탈락 직후 24시간 내에 WICHI를 독립 SaaS로 피벗하며 결정한 i18n 도입, SEO 설정, 수익화 로드맵 재구성 등 실행 항목과 의사결정 기록
Lovable에서 Vercel로 — 프론트엔드 마이그레이션 기록
Lovable($25/mo) → Vercel($0) 마이그레이션하고 빌드 파이프라인을 재구성, 프론트엔드 호스팅 전환 기록
Build, Document, Share
AI 툴에 대한 FOMO로 시작한 비전공자 빌더가 '딸깍' 그 이상의 현실적인 운영 문제를 해결하며 남기는 개인적인 실행 노트와 운영 철학.