LabHub
배우기 러닝패스 코스

FastAPI — 타입이 곧 계약이다 · 요청 제한의 경계 시각을 검증한다 · 이론

요청 제한의 경계 시각을 검증한다의 설계 원리

LabHub 에서 이어서 보기

한 줄 요약

가상 시계로 슬라이딩 윈도와 Retry-After를 검사하고 사용자별 한도를 분리합니다.

흐름 지도: 한 줄 요약 · 왜 이게 필요했나 · 어떻게 동작하나 · 계약을 읽고 실패를 예측하는 워크시트

왜 이게 필요했나

트래픽이 늘자 서버는 모든 요청을 같은 목록에 기록하기 시작했다. 한 사용자의 연속 요청이 다른 사용자의 정상 요청까지 막았다. 창의 마지막 시각에서 항목을 지우는 비교 연산도 틀려 제한이 1초 더 유지됐다. 실제로 수십 초 기다리는 테스트는 이런 경계를 느리고 불안정하게 만든다.

어떻게 동작하나

시계를 함수 인자로 받으면 대기하지 않고 정확한 시점으로 이동할 수 있다. 유효 창은 now-window보다 큰 타임스탬프만 포함한다. 허용한 요청만 기록하며 거절 요청은 창을 늘리지 않는다. 꽉 찼을 때 가장 오래된 허용 요청이 만료될 때까지의 시간을 올림해 Retry-After로 보낸다. 마지막에는 같은 사용자의 세 번째 요청과 다른 사용자의 첫 요청을 비교한다.

client id → 해당 키의 기록 → 만료 제거 → 여유 있음: 기록+200                                      └→ 꽉 참: 기록 보존+429

계약을 읽고 실패를 예측하는 워크시트

다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.

1. 설정을 검증한다

validate_limit(limit, window)는 bool 제외 양의 int limit와 양의 유한 int/float window만 허용해 (limit, float(window))를 반환합니다. 나머지는 ValueError입니다.

판단의 근거: bool은 int의 하위 타입입니다. NaN과 무한대도 따로 거절해야 합니다.

리뷰할 잘못된 변경 조각:

not isinstance(limit, int)

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

2. 창의 왼쪽 경계를 제외한다

active(history, now, window)는 now-window보다 큰 시각만 원래 순서의 새 리스트로 반환합니다. history는 정렬된 비감소 시각입니다.

판단의 근거: 정확히 만료된 시각을 남기는 >=와 >의 차이를 확인합니다.

리뷰할 잘못된 변경 조각:

stamp >= now - window

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

3. 대기 시간을 올림한다

retry_after(history, now, window)는 이미 정리된 비어 있지 않은 history의 첫 시각+window-now를 ceil한 값과 0 중 큰 정수입니다. 빈 리스트는 0입니다.

판단의 근거: 0.2초 남았다고 Retry-After를 0으로 내면 클라이언트가 즉시 재요청합니다.

리뷰할 잘못된 변경 조각:

int(history[0] + window - now)

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

4. 키마다 기록을 나눈다

history_for(state, key)는 없는 키면 빈 리스트, 있으면 그 기록의 사본을 반환합니다. 조회만으로 state를 수정하지 않습니다.

판단의 근거: 공유 리스트를 반환하면 한 요청의 정리가 다른 요청의 기록을 바꿀 수 있습니다.

리뷰할 잘못된 변경 조각:

state.get(key, [])

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

5. 허용 요청만 기록한다

admit(state, key, now, limit, window)는 설정 검증 후 해당 키의 만료 기록을 정리합니다. 여유가 있으면 now를 추가하고 (True,0), 꽉 찼으면 추가하지 않고 (False,retry_after)를 반환합니다.

판단의 근거: 거절된 요청을 추가하면 재시도할 때마다 만료 시각이 밀립니다.

리뷰할 잘못된 변경 조각:

len(history) > limit

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

6. 클라이언트 키를 검증한다

client_key(value)는 1~40자 ASCII 영문·숫자·하이픈 문자열을 그대로 반환하고 나머지는 ValueError입니다.

판단의 근거: 무제한 키 크기로 상태 메모리를 압박하지 않게 입력의 범위를 제한합니다.

리뷰할 잘못된 변경 조각:

<= 80

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

7. 거절 응답을 만든다

limited_response(wait)는 상태 429, 본문 {error:'rate_limited'}, Retry-After 헤더는 wait를 문자열로 만든 JSONResponse입니다.

판단의 근거: 클라이언트가 재시도 시간을 알 수 있게 상태와 헤더를 함께 보냅니다.

리뷰할 잘못된 변경 조각:

status_code=503

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

8. 가상 시간으로 요청 흐름을 끝낸다

create_app(clock, limit=2, window=10)는 GET /work에서 X-Client-ID를 검사해 잘못된 키는 400 {error:'invalid_client'}, 허용은 200 {ok:True}, 초과는 limited_response입니다. state는 앱별로 분리합니다.

판단의 근거: 실제로 sleep하지 말고 리스트에 담은 현재 시각을 clock 함수로 전달합니다.

리뷰할 잘못된 변경 조각:

admit(state, "shared", clock(), limit, window)

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

현장에서 만나는 모습

프로세스 메모리에 있는 단일 워커용 예제다. 여러 파드가 공유하는 전역 한도나 악의적인 클라이언트의 신원을 보장하지 않는다. X-Client-ID는 테스트용 키이므로 운영에서는 인증된 주체에서 키를 얻어야 한다. 지속적인 시계 역행은 단조 시계 사용으로 피해야 하며 이 실습의 clock은 비감소한다.

다음 실습에서 할 것

여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 설정을 검증한다 → 창의 왼쪽 경계를 제외한다 → 대기 시간을 올림한다 → 키마다 기록을 나눈다 → 허용 요청만 기록한다 → 클라이언트 키를 검증한다 → 거절 응답을 만든다 → 가상 시간으로 요청 흐름을 끝낸다.

각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.