LabHub
배우기 러닝패스 코스

FastAPI — 타입이 곧 계약이다 · 목록을 통째로 쌓지 않는 JSON Lines 내보내기 · 이론

목록을 통째로 쌓지 않는 JSON Lines 내보내기의 설계 원리

LabHub 에서 이어서 보기

한 줄 요약

지연 생성·줄 경계·공개 필드·출력 상한을 HTTP 스트림과 연결합니다.

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

왜 이게 필요했나

관리자가 모든 주문을 내려받자 서버 메모리가 급증했다. export 함수가 JSON을 만들기 전에 모든 행을 리스트에 모았기 때문이다. 줄 단위 출력으로 바꿨지만 본문 속 개행이 실제 줄 경계를 깨고 내부 원가도 그대로 나갔다. 스트리밍은 반환 타입 하나를 바꾸는 일이 아니라 지연 평가와 표현 계약을 함께 정하는 일이다.

어떻게 동작하나

각 행을 검증하고 공개 필드만 투영한 뒤 JSON으로 인코딩하고 줄 끝에 개행 하나를 붙인다. 생성기는 호출만으로 입력을 소비하지 않으며 한 항목을 꺼내면 한 행만 진행한다. 출력 상한은 bool을 제외한 정수로 검증한다. 마지막에는 FastAPI StreamingResponse로 연결하고 Content-Type과 내려받은 줄들을 다시 파싱한다.

입력 iterator → 한 행 검증 → 공개 투영 → JSON + 개행 → StreamingResponse

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

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

1. 행 계약을 검증한다

validate_row(row)는 dict이며 id가 bool 제외 양의 int, name이 비어 있지 않은 str일 때 row를 반환합니다. 나머지는 ValueError입니다. 추가 내부 필드는 허용합니다.

판단의 근거: bool과 숫자를 구분하고 빈 이름을 오류로 처리합니다.

리뷰할 잘못된 변경 조각:

not isinstance(row.get("id"), int)

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

2. 공개 행만 만든다

project(row)는 validate_row 후 id와 name만 가진 새 dict를 반환합니다. 원본 내부 필드는 그대로 보존합니다.

판단의 근거: 내보내기 경로도 일반 API와 같은 공개 필드 정책을 적용해야 합니다.

리뷰할 잘못된 변경 조각:

"name":row["name"], "internal_cost":row.get("internal_cost")}

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

3. 줄 경계를 보존하며 인코딩한다

encode_line(row)는 project 결과를 ensure_ascii=False, separators=(',',':'), sort_keys=True로 JSON 인코딩하고 마지막에 '
' 한 개를 붙인 str입니다. name 안 개행은 JSON 이스케이프여야 합니다.

판단의 근거: 문자열 덧붙이기로 JSON을 만들면 따옴표와 개행에서 형식이 깨집니다.

리뷰할 잘못된 변경 조각:

ensure_ascii=True

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

4. 출력 개수 상한을 검증한다

validate_max(value)는 bool 제외 int 1~1000만 그대로 반환하고 그 외 ValueError입니다.

판단의 근거: 무한 입력을 실수로 끝까지 읽지 않도록 호출자에게 상한을 요구합니다.

리뷰할 잘못된 변경 조각:

<= 1001

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

5. 필요한 행만 소비한다

take_rows(rows, maximum)는 islice 등으로 최대 maximum개만 지연 반환하는 iterator입니다. 호출 시 maximum을 검증하며 한 번 next하면 입력을 한 번만 소비합니다.

판단의 근거: list(rows)로 바꾸는 순간 무한 입력과 대용량 입력을 처리할 수 없습니다.

리뷰할 잘못된 변경 조각:

islice(list(rows), validate_max(maximum))

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

6. 행을 지연 직렬화한다

json_lines(rows, maximum=100)는 take_rows에서 받은 행마다 encode_line을 yield합니다. 전부 합친 문자열이나 리스트를 반환하지 않습니다.

판단의 근거: 객체 선택과 표현 변환을 각각 지연 단계로 유지합니다.

리뷰할 잘못된 변경 조각:

for row in list(take_rows(rows, maximum)):

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

7. 내려받은 줄을 다시 검증한다

decode_lines(text)는 splitlines의 각 비어 있지 않은 줄을 json.loads 후 validate_row하고 리스트로 반환합니다. 빈 문자열은 [], 비어 있는 중간 줄은 ValueError입니다.

판단의 근거: 빈 파일과 형식이 깨진 빈 레코드를 구분합니다.

리뷰할 잘못된 변경 조각:

continue

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

8. HTTP 다운로드를 완성한다

create_app(rows)는 GET /export에서 json_lines(rows, 100)을 application/x-ndjson StreamingResponse로 반환합니다. rows는 다시 순회 가능한 리스트입니다. 내부 필드가 없고 각 행의 내용과 순서를 보존해야 합니다.

판단의 근거: Content-Type만 스트리밍으로 적고 내부에서는 전체를 모으지 않았는지 생성기 시험과 함께 확인합니다.

리뷰할 잘못된 변경 조각:

media_type="application/json"

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

현장에서 만나는 모습

TestClient는 응답을 버퍼링하므로 네트워크 첫 바이트 지연이나 메모리 상한 전체를 증명하지 않는다. 지연 평가 여부는 별도의 계수 생성기로 검사한다. 스트림 시작 이후 잘못된 행을 만나면 정상 오류 JSON으로 상태를 바꾸기 어렵다. 실서비스는 사전 검증·행별 오류 형식·중단 정책 중 무엇을 택할지 정해야 한다.

다음 실습에서 할 것

여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 행 계약을 검증한다 → 공개 행만 만든다 → 줄 경계를 보존하며 인코딩한다 → 출력 개수 상한을 검증한다 → 필요한 행만 소비한다 → 행을 지연 직렬화한다 → 내려받은 줄을 다시 검증한다 → HTTP 다운로드를 완성한다.

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