평점이 NaN인 상품 하나 때문에 상품 목록 전체가 안 뜬 이유
문제 발생
상품 목록 API에 평균 평점을 붙이는 작업이었습니다. 리뷰를 상품별로 묶어 평균을 내고, 상품 목록에 붙여서 JSON으로 내려 주는 코드였습니다.
avg = reviews.groupby("product_id")["score"].mean()
products["avg_rating"] = products["id"].map(avg)
body = json.dumps(products.to_dict(orient="records"), ensure_ascii=False)배포하고 며칠 지나서 상품 목록 화면이 통째로 하얗게 나온다는 제보가 왔습니다. 서버 로그는 깨끗했고 응답도 200이었습니다. 브라우저 콘솔에만 이런 에러가 찍혀 있었습니다.
SyntaxError: Unexpected token 'N', ..."_rating": NaN}" is not valid JSON응답 본문을 열어 보니 그날 새로 등록된 상품 하나가 "avg_rating": NaN을 달고 있었습니다. 리뷰가 아직 하나도 없는 상품이었습니다. 상품 하나의 평점 칸 때문에 나머지 수백 개 상품까지 같이 안 보인 겁니다. 더 당황스러웠던 건 서버 쪽 테스트가 전부 통과하고 있었다는 점입니다. 테스트는 Python으로 응답을 받아 json.loads()로 확인했는데, 거기서는 아무 문제가 없었습니다.
원인 분석
JSON에는 NaN이 없습니다. JSON 표준인 RFC 8259는 숫자 문법으로 표현할 수 없는 Infinity와 NaN 같은 숫자 값은 허용하지 않는다고 적어 둡니다. JSON의 숫자는 부호, 정수부, 소수부, 지수부로만 이뤄집니다. NaN은 숫자도 문자열도 null도 아닌, JSON 입장에서는 그냥 모르는 글자입니다.
그런데 Python의 json은 기본값으로 NaN을 내보냅니다. json 모듈 문서의 "Infinite and NaN Number Values" 항목이 이걸 직접 설명합니다. RFC는 무한대와 NaN을 허용하지 않지만, 이 모듈은 기본적으로 Infinity, -Infinity, NaN을 유효한 JSON 숫자 리터럴인 것처럼 받아들이고 내보낸다고요. json.dumps(float('nan'))는 에러 없이 'NaN'을 돌려줍니다. 읽을 때도 마찬가지라서 json.loads('NaN')은 nan이 됩니다. Python끼리 주고받으면 왕복이 멀쩡하니, Python으로 짠 테스트에서 걸릴 리가 없었습니다.
받는 쪽이 Python이 아니면 바로 깨집니다. 브라우저의 JSON.parse는 표준대로 NaN을 거부합니다. 위의 에러 메시지는 Node 22에서 똑같이 재현됩니다. Go의 encoding/json도 마찬가지라서, 직접 돌려 보니 NaN이 든 JSON을 읽으면 invalid character 'N' looking for beginning of value가 났고, 반대로 NaN을 쓰려고 하면 문서대로 UnsupportedValueError(json: unsupported value: NaN)가 났습니다. 재밌는 건 JavaScript 쪽은 애초에 NaN을 JSON에 쓰지 않는다는 점입니다. MDN의 JSON.stringify() 문서에 따르면 Infinity와 NaN은 null로 직렬화됩니다. JSON.stringify({a: NaN})은 {"a":null}입니다. 비표준 출력을 만드는 건 Python 쪽뿐이었습니다.
NaN이 생긴 곳은 pandas였습니다. 리뷰가 없는 상품은 groupby 결과에 아예 없으니, map(avg)에서 짝을 못 찾아 빈 값이 됩니다. pandas는 숫자 열의 빈 값을 NaN으로 채우고, to_dict()는 이걸 그대로 Python float의 nan으로 꺼내 줍니다. 그다음은 위에서 본 대로 json.dumps()가 NaN을 그대로 썼습니다.
해결 방안
- 서버에서
allow_nan=False로 먼저 터지게 합니다.json문서대로allow_nan이 거짓이면 이런 float를 인코딩할 때ValueError가 납니다. 잘못된 JSON이 클라이언트까지 가서 화면을 하얗게 만드는 것보다, 서버에서 에러로 잡히는 쪽이 훨씬 빨리 보입니다.
>>> json.dumps({"avg_rating": float("nan")}, allow_nan=False)
ValueError: Out of range float values are not JSON compliant: nan참고로 FastAPI가 쓰는 Starlette의 JSONResponse는 소스를 열어 보면 이미 allow_nan=False로 직렬화합니다. 그래서 같은 데이터를 FastAPI로 내려 보냈다면 200 대신 500이 나서 바로 눈에 띄었을 겁니다. json.dumps()를 직접 부르는 코드라면 이 옵션을 스스로 챙겨야 합니다.
- 직렬화하기 전에
NaN과 무한대를None으로 바꿉니다. 에러를 내는 것만으로는 화면이 나오지 않으니, 값을 정리하는 단계도 필요합니다. 여기서default인자로 해결하려고 하면 안 됩니다. 문서대로default는 원래 직렬화할 수 없는 객체에만 불리는데,nan은 엄연한float라서 한 번도 불리지 않습니다. 직접 해 보니default함수는 호출조차 되지 않고NaN이 그대로 나왔습니다. 그래서 값을 미리 훑어서 바꿔 줍니다.
import math
def clean(o):
if isinstance(o, float) and not math.isfinite(o):
return None
if isinstance(o, dict):
return {k: clean(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [clean(v) for v in o]
return o
body = json.dumps(clean(rows), ensure_ascii=False, allow_nan=False)math.isfinite()는 NaN, inf, -inf를 한 번에 걸러 줍니다. 1번의 allow_nan=False는 남겨 둡니다. 정리 함수가 놓친 경로가 생기면 거기서 잡히게 하려는 안전장치입니다.
-
pandas라면 변환 단계에서 처리합니다.
DataFrame.to_json()문서에는NaN과None이null로 변환된다고 적혀 있습니다. 실제로to_dict()뒤json.dumps()는NaN을 썼고,to_json()은 같은 행을null로 썼습니다. 딕셔너리가 필요하면df.astype(object).where(df.notna(), None)로 빈 값을None으로 바꾼 뒤to_dict()를 부릅니다. 이때 "리뷰 없음"을 API 계약에서 어떻게 표현할지도 같이 정합니다. 저희는avg_rating: null이면 화면에 "아직 리뷰가 없어요"를 띄우기로 했습니다.NaN을 "값 없음" 표시로 쓰던 게 애초에 문제의 시작이었습니다. -
테스트에서는 Python이 아니라 표준 기준으로 검사합니다. 이번에 테스트가 통과한 이유는
json.loads()가 너그러웠기 때문입니다. 문서에 따르면parse_constant는'-Infinity','Infinity','NaN'을 만났을 때 불리는 함수이고, 잘못된 JSON 숫자에서 예외를 내는 데 쓸 수 있습니다. API 응답을 검사하는 테스트에는 이걸 붙여 둡니다.
def reject(constant):
raise ValueError(f"표준 JSON이 아닌 값: {constant}")
json.loads(response.text, parse_constant=reject)이제 Python 테스트도 브라우저와 같은 기준으로 응답을 거부합니다.
- 나눗셈으로 만든 지표도 같이 확인합니다. 평균만 문제가 아닙니다. 전환율처럼
df["orders"] / df["views"]로 계산한 값은 조회수가 0인 상품에서NaN(0/0)이나inf(2/0)가 됩니다. 직접 돌려 보니[0.05, nan, inf]가 나왔고,json.dumps()는 이걸[0.05, NaN, Infinity]로 썼습니다. 이번 사고 뒤로 비율이나 평균을 새로 내려 줄 때는 "분모가 0이거나 데이터가 없으면 무엇을 보낼지"를 먼저 정합니다. 그 답이 정해져 있으면 2번의 정리 함수는 거의 일을 하지 않습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.