Minbook
EN

Solo SaaS 보안 — 최소한 이것만은 하자

· 6 min read

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 InjectionParameterized query / ORMPrisma, Drizzle
XSS출력 이스케이프, CSPDOMPurify, 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프로덕션 환경변수가 호스팅 서비스의 환경변수 설정에 있는가 (파일이 아닌)
4API 키에 최소 권한 원칙이 적용되어 있는가
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.jsNEXT_PUBLIC_*나머지 전부
ViteVITE_*나머지 전부
AstroPUBLIC_*나머지 전부

클라이언트에 노출되는 환경변수에 API secret을 넣는 실수가 빈번합니다. NEXT_PUBLIC_STRIPE_SECRET_KEY 같은 이름은 절대 사용하면 안 됩니다. 클라이언트 환경변수는 브라우저 소스에 그대로 노출됩니다.


의존성 감사: Dependabot과 Snyk

1인 개발자의 코드는 안전해도, 사용하는 패키지가 취약할 수 있습니다. npm 생태계에서 의존성 취약점은 상시 발생합니다.

도구 비교

도구가격자동 PRCI 통합특징
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"

이 파일 하나를 추가하면:

  1. 매주 의존성 취약점을 자동 스캔
  2. 취약한 패키지에 대해 자동 PR 생성
  3. 머지하면 바로 수정 완료

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회/시간남용 방지
일반 API100회/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
설정개발 환경프로덕션 환경
originlocalhost:3000실제 도메인만
credentialstrue (쿠키 사용 시)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-srcJavaScript 출처'self' (인라인 스크립트 금지)
style-srcCSS 출처'self' + 필요 시 'unsafe-inline'
img-src이미지 출처'self' + CDN
connect-srcAPI/WebSocket 출처'self' + API 도메인
object-srcFlash/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개가 난이도 “낮음” — 잊지 않으면 됨
Share

Related Posts

Comments