발단: "혹시 우리 앱, 뚫리는 거 아냐?"

교회 재정 관리 앱을 만들면서 기능에만 집중했다. 헌금 OCR, Excel 자동 생성, 예산 대비 실적 리포트... 기능은 30개 Phase를 넘기며 야금야금 쌓였다. 그런데 문득 불안해졌다.

"로그인, 비밀번호, 데이터 삭제... 보안은 괜찮은 거야?"

사이드 프로젝트라서 "나중에 하지 뭐" 하고 미뤄뒀던 그 '나중'이 드디어 찾아왔다.

전개: 감사 결과는 충격적이었다

전면 보안 감사를 돌렸다. 인증/API, 데이터 조작, 프론트엔드/설정 — 세 갈래로 동시에 파고들었다. 결과?

22개 취약점. CRITICAL 3개, HIGH 9개.

솔직히 좀 멘붕이었다. 그중 가장 소름 돋았던 3가지:

1. DB 비밀번호가 코드에 박혀 있었다

# Before: 누구나 볼 수 있는 기본값
DATABASE_URL = os.environ.get("DATABASE_URL",
    "postgresql://user:password@localhost:5432/mydb")  # 이게 Git에...

환경변수가 없으면 하드코딩된 자격증명으로 연결되는 구조. .env.example에도 실제 비밀번호가 들어있었다. 오픈소스였으면 대참사였을 것이다.

2. SQL Injection이 대놓고 가능했다

# Before: f-string으로 SQL 조립 (교과서적 안티패턴)
query = f"SELECT * FROM income WHERE date LIKE '{month}%'"

query.py의 거의 모든 함수가 이 패턴이었다. 사용자 입력이 그대로 SQL에 들어가는, OWASP Top 10의 1번 항목. 부끄러웠다.

3. 백업 파일을 아무거나 다운로드할 수 있었다

GET /api/backup/download?filename=../../../etc/passwd

Path Traversal. 파일명 파라미터를 검증하지 않아서, 서버의 어떤 파일이든 읽을 수 있었다. 백업 기능을 급하게 만들면서 생긴 전형적인 실수.

위기: 고치다 보니 테스트가 와장창

22개를 순서대로 고쳐나갔다. Rate Limiting 추가, 비밀번호 정책 8자 이상, OAuth CSRF 방어, 보안 헤더 미들웨어, Soft Delete 전환...

그런데 고칠 때마다 기존 테스트가 깨졌다.

비밀번호 정책 추가 → 테스트에서 쓰던 "pass123"(7자)이 전부 거부됨
Rate Limiter 추가 → 테스트가 연달아 돌면서 429 Too Many Requests
Soft Delete 전환is_deleted 컬럼이 테스트 DB에 없어서 쿼리 실패
DB 검증 강화 → import 시점에 환경변수 체크해서 테스트 수집 자체가 실패

보안 수정 하나 할 때마다 테스트 수정 2~3개가 따라붙었다. "테스트 없이 이걸 했으면..." 하는 생각이 스쳤다. 453개의 테스트가 있어서 뭐가 깨졌는지 정확히 알 수 있었고, 그래서 자신 있게 고칠 수 있었다.

절정: Hard DELETE를 Soft DELETE로 바꾸는 순간

가장 임팩트가 컸던 변경은 삭제 방식 전환이었다.

# Before: 진짜 삭제 (복구 불가)
DELETE FROM income WHERE batch_id = ?

# After: 논리 삭제 (복구 가능)
UPDATE income SET is_deleted = TRUE, deleted_at = NOW()
WHERE batch_id = ? AND is_deleted = FALSE

단순해 보이지만, 이 변경이 연쇄적으로 퍼졌다:

  • income_repository.py, expense_repository.py 둘 다 수정
  • 모든 SELECT 쿼리에 WHERE is_deleted = FALSE 조건 추가 필요
  • 테스트 DB 스키마에 is_deleted, deleted_at 컬럼 추가
  • 단위 테스트에서 DB 의존성 분리 (DB_AVAILABLE = False)

한 줄 바꿨을 뿐인데 12개 파일에 영향. 이게 보안 수정의 현실이다.

결말: 453개 테스트, 전부 통과

============================= 453 passed in 52.59s =============================

이 한 줄을 보는 순간의 쾌감. 22개 취약점을 전부 막고, 기존 기능 하나도 안 깨뜨리고, 테스트까지 전부 통과.

에필로그: 진짜 사고는 배포 후에 터진다

"끝났다!" 싶었는데, 브라우저에서 로그인을 누르자 **"요청 실패"**가 떴다.

curl -s -X POST http://localhost:8000/auth/login → 500 Internal Server Error

453개 테스트 올 그린이었는데? 뭐가 잘못된 거지?

범인은 내가 고친 1번 취약점이었다. DB 자격증명을 코드에서 제거한 건 좋았다. 그런데 실제 서버 환경의 .envDATABASE_URL을 추가하는 걸 잊었다. 이전에는 코드에 기본값이 하드코딩되어 있어서 .env 없이도 동작했지만, 보안 수정 후에는 환경변수가 필수가 되었다.

# Before: 환경변수 없어도 동작 (보안 취약)
DATABASE_URL = os.environ.get("DATABASE_URL",
    "postgresql://user:password@localhost:5432/mydb")

# After: 환경변수 없으면 명시적 에러 (보안 강화)
DATABASE_URL = os.environ.get("DATABASE_URL", "")
# → get_connection() 호출 시: "DATABASE_URL 환경변수가 설정되지 않았습니다"

테스트는 conftest.py에서 자체적으로 DATABASE_URL을 설정하기 때문에 전부 통과했다. 하지만 실제 서버는 .env에서 읽는데, 거기엔 SMTP 설정만 있었다. 테스트와 운영 환경의 설정 차이 — 전형적인 함정이었다.

.envDATABASE_URL 한 줄 추가하고, 서버 재시작. 그제서야 진짜 끝이었다... 고 생각했다.

사고 2: 비밀번호 초기화 메일이 안 온다

배포 후 비밀번호 초기화 기능을 테스트했다. "초기화 메일이 발송됩니다" — 화면에는 성공 메시지가 떴다. 그런데 메일이 안 왔다. 5분, 10분... 안 온다.

로그를 뒤져봤다. 초기화 요청 자체가 로그에 없다. 운영 .env를 열어보니:

DATABASE_URL=postgresql://...
ENV=production
CORS_ORIGINS=http://localhost:8003,...
# SMTP? 없다.

SMTP 설정이 통째로 빠져있었다. 코드에서는 SMTP_USER가 빈 문자열이면 예외를 던지지만, 그 예외를 except Exception으로 조용히 삼켜버리고 있었다:

try:
    _send_reset_email(email, token)
except Exception:
    logger.error("비밀번호 초기화 이메일 발송 실패: %s", email)
# → 사용자에게는 "발송됩니다" 성공 응답 반환

사용자 열거 방지를 위해 의도적으로 동일한 응답을 반환하는 패턴이었는데, 이게 SMTP 설정 누락까지 숨겨버린 것이다. 운영 .env에 SMTP 설정 추가하고, 재시작. 메일이 왔다.

사고 3: 로그인해도 화면이 안 바뀐다

"이번엔 진짜 끝이겠지..." 로그인 버튼을 눌렀다. Network 탭에서 POST /auth/login → 200 OK. /auth/me → 200 OK. 그런데 화면이 로그인 페이지 그대로다.

API는 정상인데 화면이 안 바뀐다? 쿠키를 확인했다. Application 탭 → Cookies → 비어있다. 서버가 Set-Cookie를 보내는데 브라우저가 저장을 안 하고 있었다.

범인은 내가 고친 보안 코드였다:

env = os.environ.get("ENV", "development")
use_secure = env == "production"  # → True

ENV=production이라 쿠키에 Secure 플래그가 붙었다. 그런데 사이트는 http://로 서빙 중이었다. HTTP 환경에서 Secure 쿠키는 브라우저가 무시한다. RFC 6265의 명확한 규칙인데, "운영 환경이면 당연히 HTTPS겠지"라는 가정이 깔려 있었다.

# Before: 운영이면 무조건 Secure (HTTPS 가정)
use_secure = env == "production"

# After: COOKIE_SECURE 환경변수로 명시적 제어
cookie_secure_env = os.environ.get("COOKIE_SECURE", "")
if cookie_secure_env:
    use_secure = cookie_secure_env.lower() == "true"
else:
    use_secure = os.environ.get("ENV", "development") == "production"

운영 .envCOOKIE_SECURE=false 추가. 코드 수정, 빌드, 배포, 재시작. 로그인이 됐다.

교훈: 보안 강화의 아이러니

보안을 강화하면서 3번의 사고가 연쇄적으로 터졌다. 공통점은 하나다:

코드에서 제거한 "편의성 기본값"을 운영 환경에서 명시적으로 채워넣지 않았다.

사고 코드 변경 누락된 환경변수
DB 접속 불가 하드코딩 제거 DATABASE_URL
메일 안 옴 SMTP 기능 추가 SMTP_USER, SMTP_PASSWORD
로그인 안 됨 Secure 쿠키 강제 COOKIE_SECURE=false

테스트가 다 통과해도 배포가 되는 건 아니다. 테스트 환경은 conftest.py가 다 채워주지만, 운영 환경은 .env 파일이 전부다. 보안을 강화할 때는 반드시 "이 변경이 운영 .env에 어떤 영향을 주는지"를 체크리스트에 넣어야 한다.

이것이 진짜 마지막 교훈이다: 보안은 코드만 고치는 게 아니라, 코드가 살아가는 환경 전체를 업데이트하는 것이다.

배운 것들

사이드 프로젝트도 보안은 처음부터

"나만 쓰니까 괜찮아"는 위험한 착각이다. 특히 재정 데이터를 다루는 앱은 더더욱. 처음부터 파라미터 바인딩, 입력 검증, 인증을 넣는 게 나중에 22개를 한꺼번에 고치는 것보다 훨씬 쉽다.

테스트가 보안 수정의 안전망

453개 테스트가 없었으면 이 작업은 불가능에 가까웠을 것이다. 수정할 때마다 "다른 데 안 깨졌나?"를 수동으로 확인하는 건 현실적으로 무리다. 테스트가 있으니 과감하게 고칠 수 있었다.

보안 수정의 파급력을 과소평가하지 말 것

"DELETE를 UPDATE로 바꾸면 끝이지" — 절대 아니다. 한 곳의 보안 수정이 5~10개 파일에 연쇄 효과를 만든다. 시간 여유를 넉넉히 잡아야 한다.

실전 체크리스트 (FastAPI 기준)

  • DB 자격증명: 환경변수 전용, 코드에 절대 하드코딩 금지
  • SQL: f-string 금지, 파라미터 바인딩(%s, ?) 필수
  • 파일 경로: .resolve() 후 상위 디렉토리 벗어남 체크
  • Rate Limiting: 로그인/가입/비밀번호 리셋에 필수
  • 삭제: Soft Delete (is_deleted + deleted_at) 기본
  • 세션: secrets.token_urlsafe(32) (uuid4 대신)
  • 쿠키: secure=True (HTTPS), httponly=True 기본, HTTP 환경이면 명시적 오버라이드
  • CORS: ["*"] 금지, 명시적 origin/method/header
  • 보안 헤더: X-Content-Type-Options, X-Frame-Options, HSTS
  • Docker: non-root user 실행
  • 에러 핸들링: 보안용 except가 설정 오류까지 숨기지 않는지 확인
  • 배포 환경: 하드코딩 제거 후 .env에 값 추가 확인 (테스트 통과 != 배포 성공)
  • 환경 매트릭스: 보안 변경 시 운영 .env에 미치는 영향 체크리스트 작성