이전 회사 재직 중 있었던 일이다.
열심히 개발해서 배포한 지 얼마 되지 않은 API가 있었는데, KISA 취약점 결과에서 생각지도 못한 지적을 받게 되었다. 바로 ‘불충분한 반복 요청 제한’ 항목에서 취약점이 발견된 것이다.
"짧은 시간 내에 동일한 사용자가 API를 중복 호출할 경우, 서버가 이를 적절히 차단하지 않고 모두 수행함.”
우리는 이미 Ingress나 API Gateway 레이어에서 IP 기반으로 초당 요청 수를 제한하고 있었다. 하지만 KISA의 지적은 그보다 더 깊은 애플리케이션 레벨의 문제였다. 공격자가 IP를 변조하거나, 혹은 로그인한 정상 사용자가 의도적으로 비즈니스 로직을 악용하기 위해 반복 요청을 보낼 때, 앞단의 IP 차단만으로는 방어가 부족하다는 것이었다.
결국 특정 사용자가, 특정 기능을, 짧은 시간 내에 중복해서 호출하는 것을 막아야 했다.
왜 Redis인가?
가장 먼저 고민한 것은 ‘상태를 어디에 저장할 것인가’였다.
단일 서버라면 간단하다. 메모리 상의 변수나 세마포어를 사용하면 된다. 하지만 우리 환경은 수십 개의 Pod가 떠 있는 분산 환경이었다.
- A 요청은 1번 Pod로, B 요청은 2번 Pod로 들어갈 수 있다.
- 각 Pod의 로컬 메모리는 서로 공유되지 않는다.
- 따라서 모든 Pod가 공통으로 바라볼 수 있는 ‘단일 진실 공급원(Single Source of Truth)’이 필요했다.
여기서 한 가지 기술적 질문이 생길 수 있다. “단순히 키가 있는지 없는지만 확인할 거라면, 더 가벼운 Memcached를 쓸 수도 있지 않을까?”
물론 Memcached의 add 명령어 또한 원자성을 보장하며, 성능도 훌륭하다. 하지만 나의 선택은 Redis였다. 이유는 ‘운영의 효율성’ 때문이다.
- 관리 포인트의 최소화: 우리는 이미 세션 저장소 및 캐시 용도로 Redis를 사용하고 있었다. 고작 락 하나 구현하겠다고 Memcached 데몬을 새로 띄우고 모니터링을 붙이는 것은 오버 엔지니어링이자 불필요한 관리 비용 증가다.
- 확장성: 지금은 단순 락이지만, 추후 Rate Limiting 정책이 복잡해질 경우, Redis의
Sorted Set등 다양한 자료구조가 필요해질 수 있다.
결국 “이미 검증되었고, 우리가 잘 쓰고 있는 도구를 활용하자”는 것이 엔지니어링 관점에서 가장 합리적인 판단이었다.
왜 SETNX인가?
Redis를 사용하기로 결정했지만, 단순히 KEY를 저장한다고 끝나는 게 아니었다. 여기서 가장 중요한 기술적 포인트는 ‘원자성(Atomicity)’이다.
잘못된 접근: Check-then-Act
흔히 범하기 쉬운 실수는 로직을 두 단계로 나누는 것이다.
GET key(이미 요청했는지 확인)- 없다면?
SET key(요청 기록)
이 방식은 동시성 이슈에 취약하다. 0.001초 차이로 두 개의 요청이 들어오면, 두 요청이 모두 1단계에서 “없네?”라고 판단하고 로직을 실행해버린다.
올바른 접근: SETNX(Set if Not Exists)
그래서 사용한 것이 SETNX 명령어다.
- SETNX: “키가 없을 때만 값을 저장해라.”
- 특징: 확인(Check)과 저장(Act)이 하나의 명령어 안에서 원자적으로 이루어진다.
Redis는 싱글 스레드로 동작하기 때문에, 아무리 많은 요청이 동시에 몰려와도 SETNX 명령 순서에 따라 단 하나의 요청만 성공하고, 나머지는 실패하게 된다.
여기에 EX(Expire) 옵션을 더하면, “n초 동안은 내가 찜했으니 아무도 못 들어와”라는 로직이 완성된다.
작업이 끝나면 락을 풀어줘야지
처음에는 SETNX에 EX만 걸어서 “1초 동안 무조건 차단”하는 방식을 생각했다. 하지만 이는 작업이 0.1초 만에 끝나도 사용자를 0.9초 동안 기다리게 하는 문제가 있었다. 따라서 작업 시작 시 락을 걸고, 작업이 끝나면 즉시 락을 해제하는 구조로 고도화했다. 하지만 여기서 분산 락의 치명적인 함정이 있었다.
위험한 시나리오
- 서버 A가 락을 획득했다. (TTL 5초)
- 서버 A가 모종의 이유(GC, 네트워크 지연)로 5초 이상 멈췄다. → Redis에서 락 자동 만료.
- 그 사이 서버 B가 들어와서 락을 획득했다.
- 정신 차린 서버 A가 작업을 마치고
DEL key를 날린다. - 참사: 서버 A가 서버 B의 락을 지워버렸다!
이 문제를 해결하려면 지울 때, 값을 확인해서 내가 만든 것일 때만 지워야 한다. 이를 위해 두 가지 장치를 추가했다.
- UUID: 락을 걸 때 값으로 고유한 식별자를 넣는다.
- Lua Script: 값을 확인하고(GET), 맞으면 지우는(DEL) 과정을 원자적으로 실행한다.
FastAPI Dependency로 구현하기
지금까지 설명한 로직을 FastAPI의 Depends와 yield를 사용해 Context Manager 패턴으로 구현했다. 간단하게 라우터에 추가만 해주면 분산 락이 적용된다.
import logging
import uuid
from typing import Annotated
import redis.asyncio as redis
from fastapi import Depends, HTTPException, Request
from redis.exceptions import RedisError
from starlette import status
from app.constants import RATE_LIMITER_REDIS_ENGINE_NAME
from app.infrastructure.db.redis import redis_client_dependency
from app.interfaces.http.dependencies.auth import UserIdHeaderDep
# [핵심] Lua Script: 키의 값이 내가 생성한 UUID와 같을 때만 삭제한다.
# 이를 통해 남의 락을 지우는 사고를 원자적으로 방지한다.
UNLOCK_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
class DistributedRequestLimiter:
"""
[KISA 취약점 조치] Redis 기반의 안전한 분산 락
작업 시작 시 Lock을 획득(SETNX)하고, 작업 종료 시 Lock을 안전하게 해제(Lua Script)합니다.
"""
def __init__(self, timeout: int = 5):
self.timeout = timeout
self.logger = logging.getLogger(__name__)
async def __call__(
self,
request: Request,
redis_client: Annotated[redis.Redis, Depends(redis_client_dependency(RATE_LIMITER_REDIS_ENGINE_NAME))],
x_sg_userid: UserIdHeaderDep = None,
):
# 1. 식별자 및 키 생성
identifier = self._get_identifier(request, x_sg_userid)
key = f"lock:{identifier}:{request.method}:{request.url.path}"
# 2. 고유한 Lock Value 생성 (UUID) - "내가 건 락이다"라는 증표
lock_value = str(uuid.uuid4())
try:
# 3. Lock 획득 시도 (SETNX)
# nx=True: 키가 없을 때만 설정 (원자성 보장)
# ex=timeout: 작업이 비정상 종료되어도 언젠가는 락이 풀리도록 안전장치(TTL)
is_acquired = await redis_client.set(name=key, value=lock_value, ex=self.timeout, nx=True)
if not is_acquired:
self.logger.warning(f"[Security] Locked request blocked: {key}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="이전 요청을 처리 중입니다. 잠시 후 다시 시도해주세요.",
)
# 4. Lock 획득 성공 -> 비즈니스 로직 실행
try:
yield # 여기서 실제 API 로직이 실행됩니다.
finally:
# 5. Lock 해제 (Safe Release)
# 비즈니스 로직이 끝나면(성공하든 실패하든) Lua Script를 통해 안전하게 삭제
await redis_client.eval(UNLOCK_SCRIPT, 1, key, lock_value)
except RedisError as e:
# Redis 장애 시 Fail Open (서비스 가용성 우선)
# *금융 등 치명적인 데이터 정합성이 필요하다면 Fail Closed(에러 발생)로 변경 고려
self.logger.error(f"RedisError in DistributedRequestLimiter: {e}")
yield
def _get_identifier(self, request: Request, user_id: int | None) -> str:
"""User ID 우선, 없으면 IP 사용 (Fallback)"""
if user_id:
return f"user:{user_id}"
# k8s Ingress 등을 거쳐 들어오는 실제 Client IP 파싱
if x_forwarded_for := request.headers.get("x-forwarded-for"):
ip = x_forwarded_for.split(",")[0].strip()
elif x_real_ip := request.headers.get("x-real-ip"):
ip = x_real_ip
else:
ip = request.client.host if request.client else "unknown"
return f"ip:{ip}"
적용 및 결과
이제 취약점이 지적된 API에 의존성을 주입하면 된다.
@router.post(
"posts",
summary="게시글 생성",
description="게시글을 생성합니다.",
status_code=status.HTTP_201_CREATED,
response_model=BaseResponse[PublicPostInfo],
# 5초의 타임아웃을 가지지만, 작업이 0.5초 만에 끝나면 즉시 락을 해제한다.
**dependencies=[Depends(DistributedRequestLimiter())],**
)
async def create_post(...):
# ... 게시글 생성 로직 ...
return BaseResponse(result=result)
이 코드를 적용한 후, 테스트를 위해 임의로 동일한 요청을 1초에 여러 번 보내봤다. 결과적으로, 첫 번째 요청만 정상 처리 되었고, 나머지 49개의 요청은 429 Too Many Requests로 차단되었다.
KISA 취약점 점검 항목을 완벽하게 통과함은 물론, 실제 서비스에서 발생하던 간헐적인 데이터 중복 생성 이슈도 함께 해결되었다.
새로운 도구 도입보다 중요한 것
이번 작업의 핵심은 SETNX의 원자성, 그리고 Lua Script를 이용한 안전한 락 해제였다. 이 두 가지 개념을 조합함으로써 추가적인 리소스 투입 없이 MSA 환경에서의 동시성 제어와 보안 취약점 해결이라는 두 마리 토끼를 잡을 수 있었다.
물론 Redis만이 유일한 정답은 아니다. 하지만 이미 우리 시스템에서 사용하고 있는 인프라를 두고, 굳이 익숙하지 않거나 관리가 필요한 새로운 도구를 추가할 이유는 없었다.
중요한 건 ‘현재 우리에게 주어진 자원을 왜, 그리고 어떻게 써야 하는지’ 깊이 있게 이해하고 활용하는 태도였다. 결과적으로 관리 복잡도는 낮추면서 시스템의 안정성은 높일 수 있었고, 개인적으로도 인프라를 바라보는 시야를 넓히는 값진 경험이 되었다. 이것이 바로 실용적인 엔지니어링이 아닐까.
'기록' 카테고리의 다른 글
| 데이터베이스 격리 수준(Isolation Level) 알아보기 (0) | 2025.12.10 |
|---|---|
| MVCC(Multi-Version Concurrency Control) 알아보기 (0) | 2025.12.08 |
| 2024년 9월 회고 (10) | 2024.10.11 |
| 2024년 8월 회고 (6) | 2024.09.08 |
| 2024년 7월 회고 (0) | 2024.08.18 |