FastAPI — 타입이 곧 계약이다 · CORS는 로그인 검사가 아니다 · 讲解
CORS는 로그인 검사가 아니다의 설계 원리
한 줄 요약
출처·메서드·헤더의 허용 행렬을 실제 프리플라이트로 검증합니다.
왜 이게 필요했나
프런트엔드에서 응답을 읽지 못하자 모든 출처에 별표를 허용했다. 쿠키를 보내는 요청에서는 정책이 더 복잡해졌고, 개발자는 CORS만 켜면 외부 요청이 차단된다고 오해했다. 이 실습에서는 브라우저의 읽기 정책과 서버 인증을 분리한다. 인증 기능을 대신 만들지는 않는다.
어떻게 동작하나
출처는 scheme·host·port의 조합이다. 입력 설정에 경로나 자격 정보가 들어가면 거절한다. 허용 출처를 정확히 비교하고 자격 증명을 허용할 때는 와일드카드를 금지한다. CORSMiddleware에 허용 메서드와 요청 헤더를 명시한다. OPTIONS 프리플라이트가 성공하는 경우와 출처·메서드·헤더 때문에 실패하는 경우를 각각 재현한다.
Origin + 요청 메서드 + 요청 헤더 → OPTIONS 정책 확인허용: 출처/자격 헤더 제공 → 브라우저가 실제 요청거절: 읽기 권한 없음 ≠ 서버 인증계약을 읽고 실패를 예측하는 워크시트
다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.
1. 출처 형식을 검증한다
origin(value)는 http 또는 https URL이며 host가 있고 path·query·fragment·사용자 정보가 없으면 입력 문자열을 반환합니다. 그 외 ValueError입니다. 끝의 /도 path이므로 거절합니다.
판단의 근거: URL 전체를 출처로 허용하면 경로나 사용자 정보를 혼동할 수 있습니다.
리뷰할 잘못된 변경 조각:
or url.query이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
2. 중복 출처를 제거한다
origins(values)는 각 항목을 origin으로 검증한 뒤 처음 나온 순서대로 중복을 제거한 새 리스트입니다.
판단의 근거: 허용 목록은 문자열 부분 일치가 아니라 정확한 출처 목록입니다.
리뷰할 잘못된 변경 조각:
[origin(value) for value in values]이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
3. 메서드를 허용 목록으로 제한한다
methods(values)는 GET·POST·PUT·DELETE·OPTIONS만 허용하고 대문자로 바꿔 중복을 제거합니다. 빈 목록이나 그 외 값은 ValueError입니다.
판단의 근거: 허용하지 않은 PATCH와 임의 메서드를 조용히 추가하지 않습니다.
리뷰할 잘못된 변경 조각:
"DELETE","OPTIONS","PATCH"이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
4. 자격 증명과 별표를 함께 허용하지 않는다
policy(allowed, credentials)는 credentials가 bool인지 확인합니다. allowed에 '*'가 있으면 ValueError이고, {allow_origins:origins(allowed), allow_credentials:credentials}를 반환합니다.
판단의 근거: 이 실습의 명시적인 정책은 자격 증명 여부와 관계없이 별표를 받지 않습니다.
리뷰할 잘못된 변경 조각:
not isinstance(credentials, (bool, int))이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
5. 실제 CORS 미들웨어를 단다
create_app(allowed, credentials=True)는 policy를 검증하고 CORSMiddleware를 설정한 앱입니다. GET/POST만 허용하고 Content-Type·X-Request-ID 요청 헤더를 허용하며 X-Trace 응답 헤더를 expose합니다. GET /data는 {ok:True}, X-Trace='trace-1'을 반환합니다.
판단의 근거: preflight와 실제 응답에 헤더를 손으로 따로 붙이면 두 정책이 쉽게 어긋납니다.
리뷰할 잘못된 변경 조각:
expose_headers=[]이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
6. 프리플라이트 요청을 만든다
preflight_headers(source, method, requested='X-Request-ID')는 Origin, Access-Control-Request-Method, Access-Control-Request-Headers 세 키를 가진 딕셔너리입니다. method는 대문자입니다.
판단의 근거: 실제 요청 메서드는 OPTIONS이며 검사하려는 메서드는 별도 헤더에 있습니다.
리뷰할 잘못된 변경 조각:
method.lower()이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
7. 거절 행렬을 계산한다
preflight_status(app, source, method, requested='X-Request-ID')는 TestClient로 /data에 OPTIONS 요청을 보내 HTTP 상태를 반환합니다. 다른 출처·DELETE·X-Secret 헤더는 400이어야 합니다.
판단의 근거: 거절 사유 세 종류를 한 요청에 섞지 않아야 빠진 정책을 찾을 수 있습니다.
리뷰할 잘못된 변경 조각:
client.get("/data",이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
8. CORS와 인증의 차이를 관찰한다
cors_observation(app, source)는 GET /data를 보내 (상태, Access-Control-Allow-Origin 값 또는 None, JSON 본문)을 반환합니다. 허용되지 않은 출처여도 200 본문은 실행되지만 허용 출처 헤더는 없어야 합니다.
판단의 근거: curl이나 서버 간 요청은 브라우저의 CORS 읽기 제한을 따르지 않습니다.
리뷰할 잘못된 변경 조각:
source이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
현장에서 만나는 모습
TestClient는 브라우저가 아니다. CORS 응답 헤더와 프리플라이트를 검사하지만 브라우저 자체의 읽기 차단까지 구현하지는 않는다. 허용되지 않은 Origin을 보낸 일반 GET도 서버에서 실행될 수 있다. 민감한 동작은 별도의 인증·권한·CSRF 정책으로 보호해야 한다.
다음 실습에서 할 것
여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 출처 형식을 검증한다 → 중복 출처를 제거한다 → 메서드를 허용 목록으로 제한한다 → 자격 증명과 별표를 함께 허용하지 않는다 → 실제 CORS 미들웨어를 단다 → 프리플라이트 요청을 만든다 → 거절 행렬을 계산한다 → CORS와 인증의 차이를 관찰한다.
각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.