LabHub
배우기 러닝패스 코스

테스트 도구 실전 · sleep 없이 시간 경계를 재현한다 · 이론

sleep 없이 시간 경계를 재현한다의 설계 원리

LabHub 에서 이어서 보기

한 줄 요약

가짜 시계로 제한 창·재시도 시각·사용자별 격리를 검증합니다.

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

왜 이게 필요했나

요청 제한 테스트에 sleep을 넣었더니 개발 PC에서는 통과하고 CI에서는 실패했다. 느린 실행기가 시간 경계를 바꾸고 테스트 자체도 오래 걸렸다. 시간은 프로그램의 입력이므로 호출자가 제어할 수 있게 만들고 경계를 정확히 밟아야 한다.

어떻게 동작하나

clock 콜백이 반환하는 값을 리스트로 제어한다. 왼쪽 경계와 그 안쪽의 요청을 나누고 Retry-After의 올림을 검증한다. 거절 후 기록이 늘지 않는지, 다른 키의 할당량을 쓰지 않는지 상태를 직접 읽어 검사한다.

학생 테스트 → 정상 구현: 실제 시험 모두 통과           └→ 계약 위반 구현: 해당 동작에서 실패수집 실패·0개 실행·강제 종료 ≠ 결함 검출

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

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

1. 설정을 검증한다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: validate_limit(limit, window)는 bool 제외 양의 int limit와 양의 유한 int/float window만 허용해 (limit, float(window))를 반환합니다. 나머지는 ValueError입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: bool은 int의 하위 타입입니다. NaN과 무한대도 따로 거절해야 합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

not isinstance(limit, int)

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

2. 창의 왼쪽 경계를 제외한다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: active(history, now, window)는 now-window보다 큰 시각만 원래 순서의 새 리스트로 반환합니다. history는 정렬된 비감소 시각입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 정확히 만료된 시각을 남기는 >=와 >의 차이를 확인합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

stamp >= now - window

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

3. 대기 시간을 올림한다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: retry_after(history, now, window)는 이미 정리된 비어 있지 않은 history의 첫 시각+window-now를 ceil한 값과 0 중 큰 정수입니다. 빈 리스트는 0입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 0.2초 남았다고 Retry-After를 0으로 내면 클라이언트가 즉시 재요청합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

int(history[0] + window - now)

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

4. 키마다 기록을 나눈다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: history_for(state, key)는 없는 키면 빈 리스트, 있으면 그 기록의 사본을 반환합니다. 조회만으로 state를 수정하지 않습니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 공유 리스트를 반환하면 한 요청의 정리가 다른 요청의 기록을 바꿀 수 있습니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

state.get(key, [])

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

5. 허용 요청만 기록한다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: admit(state, key, now, limit, window)는 설정 검증 후 해당 키의 만료 기록을 정리합니다. 여유가 있으면 now를 추가하고 (True,0), 꽉 찼으면 추가하지 않고 (False,retry_after)를 반환합니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 거절된 요청을 추가하면 재시도할 때마다 만료 시각이 밀립니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

len(history) > limit

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

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

제공된 service.py의 다음 공개 계약을 테스트하세요: client_key(value)는 1~40자 ASCII 영문·숫자·하이픈 문자열을 그대로 반환하고 나머지는 ValueError입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 무제한 키 크기로 상태 메모리를 압박하지 않게 입력의 범위를 제한합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

<= 80

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

7. 거절 응답을 만든다 — 테스트

제공된 service.py의 다음 공개 계약을 테스트하세요: limited_response(wait)는 상태 429, 본문 {error:'rate_limited'}, Retry-After 헤더는 wait를 문자열로 만든 JSONResponse입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 클라이언트가 재시도 시간을 알 수 있게 상태와 헤더를 함께 보냅니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

status_code=503

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

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

제공된 service.py의 다음 공개 계약을 테스트하세요: create_app(clock, limit=2, window=10)는 GET /work에서 X-Client-ID를 검사해 잘못된 키는 400 {error:'invalid_client'}, 허용은 200 {ok:True}, 초과는 limited_response입니다. state는 앱별로 분리합니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.

판단의 근거: 실제로 sleep하지 말고 리스트에 담은 현재 시각을 clock 함수로 전달합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.

리뷰할 잘못된 변경 조각:

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

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

현장에서 만나는 모습

프로세스 메모리에 있는 단일 워커용 예제다. 여러 파드가 공유하는 전역 한도나 악의적인 클라이언트의 신원을 보장하지 않는다. X-Client-ID는 테스트용 키이므로 운영에서는 인증된 주체에서 키를 얻어야 한다. 지속적인 시계 역행은 단조 시계 사용으로 피해야 하며 이 실습의 clock은 비감소한다. 제공 구현은 읽어도 되지만 채점은 별도 사본을 사용한다. 소스 문구 검사나 파일 수정으로 결함을 우회하지 말고 공개 인터페이스의 실행 결과를 검사한다.

다음 실습에서 할 것

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

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