Rate Limiting을 걷어낸 날: 폐쇄망 배포에서 보안은 더하는 것이 아니다

"보안은 항상 더하는 것"이라는 통념의 반례. 폐쇄망 + nginx 프록시 배포에서 Flask-Limiter가 부과하던 의존성/운영 혼란/인지 부담을 가치-비용으로 저울질하고 제거한 의사결정 기록입니다. Next.js 구현 편의 자매 글입니다.

Rate Limiting을 걷어낸 날: 폐쇄망 배포에서 보안은 더하는 것이 아니다

1. 의도적 역전: 6주 전의 나에게 반박하기

이 블로그에는 이미 Next.js API에 Rate Limiting 구현하기 (메모리 기반)라는 글이 있습니다. 여섯 주 전, 저는 그 글에서 "모든 공개 API에는 Rate Limiting이 있어야 한다"고 썼습니다. 그리고 그 교훈을 이 이미지 라벨링 플랫폼에도 적용해서, Flask-Limiter를 설치하고 /api/auth/login5 per minute, /api/auth/register3 per hour 같은 규칙을 붙였습니다.

오늘 그걸 전부 걷어냈습니다.

이 결정은 "Rate Limiting이 나빴다"가 아닙니다. Rate Limiting은 여전히 대부분의 공개 웹 서비스에서 필수 방어 계층입니다. 다만 이 프로젝트의 배포 맥락에서는 가치가 없는 복잡도였다는 이야기입니다. 이번 편은 그 결정의 논거와 판단 방법을 정리합니다.

2. 배포 맥락의 변화

이 프로젝트를 인수인계 받았을 때는 "어떤 환경에 배포될지"가 확정되지 않은 상태였습니다. 공개 인터넷에 나갈 수도 있고, 사내 네트워크에만 머물 수도 있었죠. 불확실한 상황에서 저는 "더 엄격한 환경을 전제"로 Rate Limiting을 추가했습니다.

몇 주 뒤, 배포 환경이 확정됐습니다.

  • 폐쇄망(air-gapped) 내부 배포 — 외부 인터넷에서 접근 불가
  • nginx 프록시 뒤 — 사내 도메인 경계에서 먼저 차단
  • 고정된 사내 사용자만 접근 — 인증된 임직원, 내부 IP 대역만 허용

이 세 가지 조건 아래에서 "외부 공격자의 무차별 대입 공격"이라는 Rate Limiting의 전형적 위협 모델은 적용 대상이 아닙니다.

3. 위협 모델을 다시 그리기

보안 의사결정에서 가장 기본적이지만 자주 잊히는 원칙이 있습니다.

모든 보안 제어는 특정 위협 모델을 가정한다. 가정이 달라지면 제어의 가치도 달라진다.

Rate Limiting은 어떤 위협을 방어할까요? 크게 세 가지입니다.

위협 방어 방식
Credential stuffing 로그인 엔드포인트에 분당 요청 제한
대규모 스팸/무차별 등록 회원가입 엔드포인트에 시간당 제한
자동화된 scraping / enumeration 일반 API 경로에 초당 제한

이 프로젝트의 새로운 위협 모델에서 셋을 다시 점검하면:

위협 폐쇄망 맥락 평가
Credential stuffing 공격자가 네트워크에 진입하지 못함. 내부망에서 오더라도 이미 사내 인증 계층(VPN, SSO 등)을 통과한 상태
대규모 스팸 등록 등록은 내부 사용자에 한정. 등록 요청이 폭증할 시나리오 자체가 없음
Scraping / enumeration 내부 사용자는 scraping 동기가 없음. 있다 해도 사내 감사로 추적 가능

셋 모두 "제거해도 실질 위험이 증가하지 않는" 위협이었습니다. 남은 것은 "Rate Limiting이 무슨 비용을 부과하고 있었나"뿐입니다.

4. Rate Limiting이 부과하고 있던 비용

Rate Limiting이 "공짜"라면 남겨 두는 것이 합리적입니다. 하지만 현실의 방어 계층은 모두 비용을 부과합니다.

① 의존성 추가

# requirements.txt (Before)
flask>=3.0,<4.0
flask-cors>=4.0,<5.0
flask-limiter>=3.0,<4.0    # ← 제거 대상
python-dotenv>=1.0,<2.0
gunicorn>=22.0,<23.0
ultralytics>=8.0,<9.0

Flask-Limiter는 3.x 버전부터 상당한 기능 확장이 있었고, 의존성도 비교적 가볍습니다. 하지만 폐쇄망 배포에서는 PyPI 접근이 없기 때문에 모든 의존성을 사내 미러나 오프라인 번들로 관리해야 합니다. 의존성 하나가 줄면 번들 크기 + 유지 관리 부담이 줄어듭니다.

② 사용자 혼란

# Before: admin 비밀번호가 서버 재시작마다 바뀜
admin_pw = os.environ.get("ADMIN_PASSWORD")
if not admin_pw:
    admin_pw = secrets.token_urlsafe(16)
    logger.warning(f"Generated admin password: {admin_pw}")

이 프로젝트는 Rate Limiting과 함께 "기본 admin 비밀번호를 서버 재시작마다 랜덤 생성"하는 보안 강화 조치도 함께 적용했었습니다. 공개 인터넷 배포를 전제로 한 보호였죠. 하지만 폐쇄망에서는 이 동작이 운영상 혼란을 만들었습니다.

  • 서버를 재시작할 때마다 admin 비밀번호가 바뀜
  • 재시작 후 첫 로그인 전까지 로그를 뒤져서 새 비밀번호를 찾아야 함
  • 로컬 개발 시 docker compose restart 한 번에 기존 작업 흐름이 끊김

사용자 피드백은 명확했습니다. "개발 서버에 로그인하는 것조차 번거롭다." 강화된 보안이 실제로는 사용자가 더 약한 우회 경로(비밀번호를 공유 문서에 메모하는 등)를 만들게 합니다.

③ 인지 부담

# Before: 로그인 엔드포인트
@app.route("/api/auth/login", methods=["POST"])
@limiter.limit("5 per minute")  # ← 이 줄이 무엇을 의미하는지 팀원이 알아야 함
def api_login():
    ...

팀에 새로 합류한 엔지니어가 이 코드를 보면 "5 per minute이 왜 여기 있지? 바꿔도 되나?"를 고민하게 됩니다. 디버그 상황에서 429 Too Many Requests 를 만나면 진짜 버그가 있는지, 단순히 테스트 속도가 빠른 건지를 판단하는 추가 추론이 필요합니다.

이것이 "인지 부담"입니다. 코드에 있는 모든 줄은 읽는 사람에게 "왜 여기 있는가?"를 설명해야 할 빚을 집니다. 가치 없는 방어 계층은 이 빚을 갚지 못합니다.

④ 테스트 복잡도

# Rate Limiting이 있을 때 로그인 통합 테스트 작성하기
def test_login_success():
    # 첫 번째 테스트
    response = client.post("/api/auth/login", json={...})
    assert response.status_code == 200

def test_login_wrong_password():
    # 두 번째, 세 번째, 네 번째 테스트도 같은 엔드포인트를 호출
    # → 5번째부터 429가 튀어나옴
    # → 테스트마다 limiter 상태를 리셋해야 함

Rate Limiting은 세션 간 상태를 가지므로 테스트 격리를 어렵게 만듭니다. 단위 테스트에서는 limiter를 mock 하거나 설정으로 비활성화해야 하고, 통합 테스트에서는 각 테스트 사이에 상태 리셋이 필요합니다. 이 프로젝트는 아직 테스트가 거의 없지만, 앞으로 테스트를 쓸 때 이 복잡도가 장애물이 될 것 이라는 점도 고려했습니다.

5. After: Flask-Limiter 완전 제거

결정이 서면 실행은 단순합니다.

requirements.txt

 flask>=3.0,<4.0
 flask-cors>=4.0,<5.0
-flask-limiter>=3.0,<4.0
 python-dotenv>=1.0,<2.0
 gunicorn>=22.0,<23.0
 ultralytics>=8.0,<9.0

app.py의 Limiter 초기화 블록 제거

 CORS(app, supports_credentials=True, origins=_allowed_origins)

-# ── Rate Limiting ─────────────────────────────────────────
-from flask_limiter import Limiter
-from flask_limiter.util import get_remote_address
-
-limiter = Limiter(
-    get_remote_address,
-    app=app,
-    default_limits=[],  # No global limit
-    storage_uri="memory://",
-)
-
 # ── CSRF Protection ───────────────────────────────────────

개별 엔드포인트의 @limiter.limit(...) 데코레이터 제거

 @app.route("/api/auth/login", methods=["POST"])
[email protected]("5 per minute")
 def api_login():
     ...

 @app.route("/api/auth/register", methods=["POST"])
[email protected]("3 per hour")
 def api_register():
     ...

429 핸들러 제거

[email protected](429)
-def ratelimit_handler(e):
-    return jsonify(
-        error="요청이 너무 많습니다. 잠시 후 다시 시도하세요.",
-        retry_after=e.description,
-    ), 429

Rate Limiting의 모든 흔적이 사라졌습니다. 총 24줄 제거, 1줄 추가. requirements.txt에서 의존성도 함께 빠졌습니다.

6. Admin 비밀번호 단순화도 함께

Rate Limiting을 제거하면서 admin 비밀번호 관련 로직도 단순화했습니다.

Before

# Before: 랜덤 생성, 로그 경고, 재시작마다 변경
users = _load_json(USERS_FILE, {})
if not users:
    admin_id = "admin"
    admin_pw = os.environ.get("ADMIN_PASSWORD")
    if not admin_pw:
        admin_pw = secrets.token_urlsafe(16)
        logger.warning("=" * 50)
        logger.warning(f"  Generated admin password: {admin_pw}")
        logger.warning("  Set ADMIN_PASSWORD env var to use a fixed password")
        logger.warning("=" * 50)
    users[admin_id] = {
        "id": admin_id, "username": "admin", "displayName": "관리자",
        "email": "[email protected]",
        "password": _hash(admin_pw),
        "role": "admin", "createdAt": _now(), "lastLogin": "",
    }

After

# After: 고정 기본값, 환경변수 오버라이드
users = _load_json(USERS_FILE, {})
if not users:
    admin_id = "admin"
    admin_pw = os.environ.get("ADMIN_PASSWORD", "admin1234")
    users[admin_id] = {
        "id": admin_id, "username": "admin", "displayName": "관리자",
        "email": "[email protected]",
        "password": _hash(admin_pw),
        "role": "admin", "createdAt": _now(), "lastLogin": "",
    }

"admin1234"가 폐쇄망 환경 + 첫 로그인 후 사용자가 변경하는 흐름을 가정한 기본값입니다. 이후 PR #40 리뷰 라운드 2에서 "ADMIN_PASSWORD 미설정 시 경고 로그" 가 추가로 복귀했습니다.

admin_pw = os.environ.get("ADMIN_PASSWORD", "admin1234")
if not os.environ.get("ADMIN_PASSWORD"):
    logger.warning(
        "ADMIN_PASSWORD 미설정 — 기본 비밀번호(admin1234) 사용 중. "
        "환경변수로 변경하세요."
    )

랜덤 생성과 로그 경고 사이의 중간 지점입니다. 기본값은 존재하지만, "지금 기본값을 쓰고 있다"는 사실이 로그에 명시적으로 남습니다. 강제하지 않지만 알려 주는 방식입니다.

7. 맥락 기반 보안 의사결정 프레임워크

이 결정을 일반화한 질문 세트를 정리해 두면, 비슷한 판단이 필요한 순간 재활용할 수 있습니다.

Q1. 이 보안 제어는 어떤 위협을 방어하는가?

리스트로 써 보세요. 세 개 이상 나오지 않으면 "왜 있는지 잘 모르는" 방어일 가능성이 있습니다.

Q2. 그 위협들이 현재 배포 환경에서 실제로 존재하는가?

  • 폐쇄망이라면 외부 공격자는 목록에서 제외
  • 인증된 내부 사용자만 접근한다면 익명 공격자는 제외
  • 프록시/게이트웨이가 앞단에 있다면 그곳에서 처리되는 위협은 제외

Q3. 남은 위협에 대해 이 제어가 유일한 방어인가?

  • 네 → 유지
  • 아니오(다른 방어가 이미 있음) → 중복 가치를 측정

Q4. 이 제어가 부과하는 비용은 얼마인가?

  • 의존성, 인지 부담, 테스트 복잡도, 사용자 마찰, 운영 부담
  • 각 항목을 구체적으로 나열

Q5. Q3의 가치 > Q4의 비용인가?

  • 네 → 유지
  • 아니오 → 제거 검토

이 프로젝트에 적용한 결과

질문
방어 대상 위협 Credential stuffing, 스팸 등록, scraping
현재 환경에 실재하는 위협 세 가지 모두 해당 없음 (폐쇄망 + 내부 인증)
유일한 방어 여부 X (네트워크 경계와 사내 인증이 이미 차단)
비용 의존성 1개, 운영 혼란(admin 비밀번호), 테스트 복잡도, 인지 부담
결론 제거

8. "Security by simplification"의 철학

"보안을 위해 기능을 추가한다"가 기본 직관이지만, 이것은 한 방향일 뿐입니다. 반대 방향도 있습니다.

더 단순한 시스템은 공격 표면이 작고, 이해하기 쉽고, 검증 가능성이 높다.

이 원칙을 "Security by simplification"이라고 부릅니다. 세 가지 하위 원칙이 있습니다.

① 가치 없는 방어는 진짜 방어를 흐린다

보안 레이어가 많을수록 "어느 것이 진짜 방어인지" 흐려집니다. 배포 직전에 "이 계층을 비활성화해야 다음 단계 진행이 가능한데, 그래도 괜찮나?"를 판단하는 사람의 인지 부담을 상상해 보세요. 가치 없는 방어가 섞여 있으면 그 판단이 훨씬 어려워집니다.

② 운영 혼란은 우회를 만든다

보안이 사용자를 불편하게 만들면, 사용자는 우회합니다. admin 비밀번호가 재시작마다 바뀌면 사람들은 비밀번호를 공유 문서에 메모합니다. 보안 강화 조치가 오히려 더 위험한 행동을 유도하는 것입니다.

③ 방어는 위협 모델과 함께 검토되어야 한다

보안 리뷰 때 "이 코드가 안전한가?" 대신 "이 코드가 어떤 위협 모델 아래에서 안전한가?"를 물어야 합니다. 위협 모델이 달라지면 같은 코드가 과잉일 수도, 부족할 수도 있습니다. Rate Limiting은 공개 API에서는 필수지만, 폐쇄망 내부 API에서는 과잉입니다.

9. 이 결정이 "틀릴" 수 있는 시나리오

맥락 기반 결정은 맥락이 바뀌면 무효화됩니다. Rate Limiting 제거가 잘못된 결정이 되는 시나리오를 미리 적어 두었습니다.

시나리오 A: 배포 환경이 변경되는 경우

폐쇄망에서 DMZ나 공개 인터넷으로 배포 환경이 바뀌면, 즉시 Rate Limiting을 복원해야 합니다. 이 판단을 놓치지 않으려면 배포 체크리스트에 "환경 분류(폐쇄/DMZ/공개)"를 명시하고, 분류가 바뀔 때 보안 제어 세트를 재검토하는 절차가 필요합니다.

시나리오 B: 내부자 위협이 구체화되는 경우

폐쇄망이라도 악의적 내부자는 가능한 위협 모델입니다. 일반적으로 이것은 기술적 방어가 아니라 감사 로그, 권한 분리, 사내 감사 절차로 대응합니다. 그런데 특정 엔드포인트에 대한 무차별 공격이 실제로 감지된다면, Rate Limiting 복원을 검토해야 합니다.

시나리오 C: 프록시 신뢰가 깨지는 경우

nginx 프록시가 장애로 오프라인이 되거나, 설정 실수로 외부 접근을 허용하는 경우를 상정하면 두 가지 대응이 가능합니다.

  • "프록시가 항상 정상"을 전제로 설계: 이 프로젝트의 현재 선택
  • "프록시 장애 시에도 최소 방어"를 전제로 설계: 애플리케이션 레벨 Rate Limiting 복원

두 접근 모두 합리적이며, 어느 쪽이 적절한지는 조직의 위험 수용도에 따라 다릅니다. 이 프로젝트는 전자를 선택했지만, 이 결정이 "영원한" 것은 아닙니다.

10. 커밋 결과

diff 요약입니다.

 app.py           | 24 +-----------------------
 requirements.txt |  1 -
 2 files changed, 1 insertion(+), 24 deletions(-)
  • app.py: 24줄 삭제 (Limiter 초기화, 데코레이터 2개, 429 핸들러, admin 랜덤 비밀번호 로직)
  • requirements.txt: 의존성 1개 제거

삭제된 코드의 양이 많다는 것이 이번 커밋의 가치입니다. 좋은 보안 커밋이 항상 "추가"는 아닙니다. 어떤 것은 "제거"가 정답입니다.

11. 핵심 개념 정리

개념 역할
위협 모델 재검토 배포 환경 변경 시 보안 제어를 다시 평가
Security by simplification 단순한 시스템이 검증 가능하고 오용 가능성이 낮음
인지 부담 코드에 남은 방어 계층이 팀원에게 부과하는 "왜 여기 있는가" 이해 비용
Loud vs Silent trade-off 강한 보안이 운영 혼란을 부르면 사용자 우회가 생김
폐쇄망 + 프록시 전제 애플리케이션 레벨 방어 일부를 경계 레이어로 위임
중간 지점의 기본값 고정 기본값 + 로그 경고 (랜덤 생성과 무경고 기본값 사이)

12. 베스트 프랙티스 체크리스트

  • [ ] 각 보안 제어가 어떤 위협을 방어하는지 문서화돼 있나요?
  • [ ] 위협 모델이 현재 배포 환경에서 실제로 존재하는지 검증했나요?
  • [ ] 해당 제어가 유일한 방어인지, 다른 계층과 중복인지 확인했나요?
  • [ ] 제어가 부과하는 비용(의존성, 인지, 테스트, 운영)을 나열했나요?
  • [ ] 배포 환경 변경 시 보안 제어를 재검토하는 절차가 있나요?
  • [ ] 방어가 사용자 우회를 유발하고 있지 않나요?
  • [ ] "제거가 답인 경우"를 고려할 마음의 여유가 있나요?

13. FAQ

Q1. 내부 사용자도 비밀번호를 공격할 수 있지 않나요?
A. 가능합니다. 다만 내부 사용자는 이미 사내 네트워크 인증(VPN/SSO 등)을 통과한 상태이고, 계정 기반 감사 로그로 추적 가능합니다. 이 프로젝트는 감사 로그를 Activity 테이블에 저장하고 있어, 로그인 시도가 비정상적으로 많은 사용자는 사후 감지 가능합니다. "애플리케이션 레벨 Rate Limiting" 대신 "감사 로그 + 사후 대응" 조합을 선택한 것입니다.

Q2. nginx 레벨에서 Rate Limiting을 설정하는 건 어떤가요?
A. 그것이 가장 합리적인 대안입니다. 애플리케이션이 아니라 경계(nginx)에서 rate limit을 걸면, 코드에는 방어 계층이 남지 않으면서도 악의적 요청이 들어왔을 때 차단할 수 있습니다. 다만 이 프로젝트의 nginx 설정은 운영 팀이 관리하는 영역이라 애플리케이션 코드에서는 결정권이 없습니다. "관심 분리"가 맞는 선택이었다고 봅니다.

Q3. 테스트용으로 Limiter를 설정에서 on/off할 수 있게 남겨 두는 게 좋지 않나요?
A. 설정 기반 on/off는 "Rate Limiting이 필요할 수 있다"는 신호를 코드에 남깁니다. 이것이 재도입 비용을 낮추는 장점은 있지만, 동시에 현재는 꺼진 상태라는 이해를 팀에게 요구합니다. 이 프로젝트는 "필요하면 커밋 하나로 복원"이 가능한 수준이므로(git revert), 아예 제거하는 편이 코드 명료성이 높다고 판단했습니다.

Q4. admin 비밀번호에 "admin1234" 같은 기본값이 진짜 괜찮나요?
A. 폐쇄망 + 첫 기동 시 즉시 변경이 전제면 괜찮습니다. 다만 사용자가 변경을 잊을 가능성이 현실적이므로, README.md에 경고를 적고 로그에 "기본 비밀번호 사용 중" 메시지를 남기는 것이 권장됩니다. 이 프로젝트는 둘 다 적용돼 있습니다. 완전한 방어는 첫 로그인 시 비밀번호 변경 강제 인데, 이것은 별도 기능이라 후속 과제로 남겨 두었습니다.

Q5. "Security by simplification"이 게으름의 변명 아닌가요?
A. 경계가 중요합니다. 게으름은 "귀찮아서 방어를 제거하는 것"이고, 이 원칙은 "가치와 비용을 저울질해 제거 결정을 정당화하는 것"입니다. 구분 기준은 "이 결정의 논거를 문서화할 수 있는가"입니다. 이번 편이 그 문서화의 예시입니다. 논거를 글로 쓸 수 없다면 그것은 게으름일 가능성이 큽니다.

Q6. 구현 편과 제거 편이 같은 블로그에 있으면 독자가 혼란스럽지 않을까요?
A. 오히려 반대입니다. 맥락이 다르면 결정도 다르다는 것을 동시에 보여 주는 것이 더 교육적입니다. 어떤 기술 블로그도 "이 방식만이 정답이다"라고 말해서는 안 되고, 특히 보안 영역은 더 그렇습니다. 두 글이 상호 링크돼 있으면 독자는 "아, 이 저자는 맥락 기반 판단을 한다"라는 신호를 받습니다.

14. 참고 자료

  • Next.js API에 Rate Limiting 구현하기 (메모리 기반)반대 관점의 자매 글
  • Flask-Limiter 공식 문서: 검색 키워드 flask-limiter python api
  • OWASP Threat Modeling: 검색 키워드 owasp threat modeling cheat sheet
  • nginx limit_req 모듈: 검색 키워드 nginx limit_req rate limiting module
  • Security Engineering (Ross Anderson): 검색 키워드 ross anderson security engineering book

15. 다음 단계

이번 편까지 8편에 걸쳐 구조 → 데이터 → 실행 → 성능 → ML → 기능 → 보안 강화 → 보안 단순화 순서로 프로젝트를 리팩토링한 기록을 정리했습니다. 다음 편은 마지막으로, 자잘하지만 기억해 둘 가치가 있는 네 가지 팁을 묶어서 다룹니다.

  • 빈 Alembic migration이 생기는 이유와 정리 방법
  • 모델 다운로드 엔드포인트가 JSON 메타만 반환하던 시뮬레이션 버그
  • ruff F841 ignore를 벗겨내면서 드러난 쓰지 않는 subprocess 반환값
  • 프론트엔드 'done'과 백엔드 'success' 상태 문자열 불일치로 생긴 무한 로딩 버그

🐍 Flask 백엔드 실전 시리즈 (9부작)

  1. 모놀리식 app.py를 Blueprint로 분해하기
  2. JSON 파일 DB에서 PostgreSQL로 마이그레이션
  3. ML 학습 백그라운드 실행: ProcessPoolExecutor
  4. Python/SQLAlchemy N+1 쿼리 잡기
  5. train과 val이 같은 폴더일 때의 조용한 ML 버그
  6. 하나의 백엔드로 Detection/Classification/Segmentation
  7. Fail-fast 설정 검증과 Path Traversal 방어
  8. Rate Limiting을 걷어낸 날: 폐쇄망 보안 (현재 글)
  9. 작지만 기억할 만한 네 가지 교훈

관련 자매 글: