제 3 장

첫 호출과 신뢰할 수 있는 응답 처리

시범 운영으로 되돌린 뒤 개발팀이 처음 손댄 곳은 모델을 호출하는 코드였는데, 데모에서는 모델이 돌려준 문자열을 그대로 화면에 뿌리고 있었다. 그러던 어느 날 답변이 문장 한가운데서 끊긴 채로 나왔다. 개발팀은 호출이 어디선가 실패했으려니 하고 로그부터 열어 봤다. 응답 코드는 200이었다. 어디서 끊겼는지 보려고 열어본 로그에는 정작 끊긴 흔적이 없었다.

요청은 성공했다. 그런데 돌아온 답변은 중간에서 멈춰 있었다. 응답이 중간에 끊겼을 수도, 모델이 답을 거절했을 수도, 문장 대신 도구 호출 요청이 들어 있을 수도 있었다. 200이라는 숫자 하나로는 그중 어느 쪽인지 가려낼 수 없으니 돌아온 것을 먼저 읽고 나서 화면에 뿌린다.

그래서 개발팀은 봇에서 모델을 호출하는 부분만 떼어 명령줄 프로그램으로 다시 만들었다. 검색도 티켓 도구도 아직 붙이기 전이라, 교육비 안내 문서 한 장을 고정해 두고 질문만 바꿔 넘겨 보는 것이 전부였다.

전송·생성·업무

개발팀은 답이 어디서 멈췄는지 알아보려고 호출 한 번에서 성공을 세 군데로 갈라 놓고, 각각 무엇을 보장하는지부터 적었다.

세 단계의 성공① 전송 성공HTTP 응답을 받았다실패: 인증 오류 · 시간 초과 · 429status == "ok"② 생성 성공completed + 완료된 텍스트실패: 미완료 · 거절 · 도구 요청answered③ 업무 성공유효한 정책 · 근거 · 상태실패: 20만 원이라고 답한다오른쪽은 왼쪽을 전제한다. 어댑터가 보증하는 것은 ②까지이고 ③은 평가가 정한다

요청에는 지시문과 문서, 질문이 함께 들어간다. 개발팀이 넣은 문서는 이 한 장이다.

[교육용 가상 문서 edu-v2; 시행일 2026-09-01]
한울연구소의 직원 교육비 한도는 분기당 30만 원이다.
구매 전에 팀장의 승인을 받고 지원 포털에서 신청한다.

개발팀은 문서를 붙여 놓고 출력 토큰의 상한부터 정했다. 모델이 그 한도에 닿으면 문장을 다 못 쓰고 멈추는데, 그때 무엇을 돌려주는지 보고 한도를 조정한다. 응답을 제공자 쪽에 남길지도 요청마다 정할 수 있다. 다만 그 설정 하나가 데이터 처리 정책까지 정하지는 않으니 정책은 따로 확인한다. 돌아온 출력이 늘 “첫 메시지 하나”라고 가정하지 말고 항목의 타입부터 본다. Responses API 생성 참조

생성 상태가 ok면 문장이 끝까지 만들어졌다는 뜻이고, 거기까지다. 모델이 문서의 한도를 잘못 읽어도 문법적으로 온전한 문장은 나오므로, ok를 answered로 읽으면 안 된다.

결과 상태

성공을 셋으로 갈라 놓고 보니 돌아온 것을 그대로 화면에 넘길 수가 없었다. 그래서 개발팀은 모델의 응답을 받는 대로 정해진 자리에 나눠 담기로 했다.

담는 자리무엇이 들어가나
상태아래 그림의 여섯 가지 중 하나
본문완결된 텍스트. 미완료나 거절이면 비운다
응답 식별자생성된 응답을 가리키는 값
요청 식별자요청을 추적하는 값. 스트리밍의 최종 객체에는 안 실릴 수 있다
입력·출력 토큰 수모르면 null로 둔다
오류 코드자체 분류한 실패 종류

없는 값을 0으로 바꾸지 않는다. 연결이 끊긴 요청은 서버가 계산을 했는지 알 수 없는데, 그 자리를 0으로 채우면 실패가 많을수록 비용이 적게 기록된다.

normalize가 응답을 나누는 순서HTTP 응답을 받았나아니오upstream_error전송·인증·요청·서버 실패. 분류 코드만 남긴다예응답 상태가 completed인가아니오incomplete텍스트가 있어도 비운다예거절(refusal) 콘텐츠 없이 답했나아니오refused일반 답변과 분리한다예완료된 assistant 텍스트가 있나아니오unexpected_output출력 없음 · 도구 요청 · 모르는 형태 · 잘못된 JSON예ok텍스트 · 사용량 · 식별자를 돌려준다스트림이 완료 이벤트 없이 끝나면 interrupted

모델이 문장 대신 도구를 부르라고 할 때도 있다. 그런 응답은 지원하지 않는 출력으로 본다. 도구 이름을 보고 함수를 실행하지 않고, 모델의 내부 추론 항목도 사용자 답변으로 내보내지 않는다. 모델이 거절한 것과 서버가 권한으로 막은 것은 로그에 따로 남긴다. 모델이 거절했다고 조직이 그 사용자를 거부한 것은 아니고, 거꾸로 서버가 금지한 요청은 모델이 허용해도 실행되지 않는다.

시간 초과와 재시도

돌아온 것을 나누고 나니 아무것도 안 돌아오는 경우가 남았는데, 기다리는 데도 단계가 있다.

시간 초과 설정이 재는 구간연결connect 5초요청 쓰기첫 바이트 대기read 30초청크대기read 30초청크완료전체 경과 시간에는 한도가 없다. 마감은 상위 요청 처리에서 관리한다

처음에는 연결에 5초, 응답을 읽는 데 30초를 주는데 교육용으로 잡은 값이다. 읽기 한도는 기다림 한 번마다 다시 재므로 스트리밍이 작은 조각을 계속 보내면 전체 시간을 제한하지 못한다. 전체 마감이 필요하면 상위 요청 처리에서 마감 시각을 관리하고 취소를 전달해야 한다.

기다리다 실패하면 다시 보내게 되는데, 그 재시도에도 고를 것이 있다. SDK는 기본으로 실패한 요청을 두 번 더 보낸다. 처음에는 그 자동 재시도를 꺼 두는 쪽이 낫다. 요청을 한 번만 보내면 호출 횟수와 결과를 연결해서 볼 수 있기 때문이다. 재시도를 넣을 때는 다시 시도하면 회복되는 실패인지, 서버가 이미 처리했을 가능성이 있는지, 추가 비용과 대기가 허용되는지를 본다. 키가 틀렸다면 같은 키로 몇 번을 더 보내도 같은 오류가 돌아온다. 반면 연결이 잠깐 끊긴 것이라면 횟수를 제한해 몇 번 더 보내 보는 쪽이 낫다.

관측먼저 확인할 것기본 처리
인증 실패키와 프로젝트 설정자동 반복 없이 오류 반환
권한 실패모델·프로젝트 접근 권한설정 확인
잘못된 요청인자·지원 기능·입력요청 수정
429요청 속도 제한인지 사용 한도 문제인지원인을 구분할 기록을 남기고 중단
서버 오류일시적인 실패인지실패 반환, 정책을 정한 뒤 재시도 도입
시간 초과어느 단계에서 기다렸는지완료 여부를 추정하지 않음

429에는 요청 속도 제한과 사용 한도 초과가 섞여 있고 둘은 손쓸 자리가 다르므로, 상태 코드 하나로 묶어 재시도하지 않는다. 재시도를 넣는다면 횟수를 제한하고 간격을 늘리고 지터를 섞되, SDK와 애플리케이션 양쪽에 두면 총 호출 횟수가 곱해지니 한 계층에만 둔다. 서버가 생성을 마친 뒤 응답만 돌아오지 않았다면 다시 생성하는 비용이 든다. OpenAI 오류 안내

다시 보내 봐야 소용없는 실패가 하나 더 있다. API 키나 모델 식별자를 아예 안 넣은 경우다. 설정이 없는 상태는 몇 번을 다시 보내도 같으니 네트워크 오류와 섞지 않고, 바깥으로 요청을 보내기 전에 끝낸다. 키를 소스나 로그에 남기지 않고 안내 문구에는 환경 변수 이름만 적는다.

원본 예외 메시지에는 요청 내용이나 내부 경로가 들어 있을 수 있어 사용자에게 돌려주지 않는다. timeout, authentication, rate_or_quota처럼 자체 분류한 코드만 반환하고 상세 기록은 접근을 제한한 진단 로그에 둔다.

스트리밍의 완료와 중단

기다리는 시간을 줄이는 길도 하나 있었다. 스트리밍을 켜면 답이 다 만들어지기를 기다리지 않고 생성 중인 결과를 이벤트로 나눠 받는다. 이렇게 조각으로 오는 텍스트를 델타라고 부른다. 조각이 왔다고 답이 다 온 것은 아니라서, 완료 이벤트를 받은 뒤에만 최종 응답을 정규화한다. 스트리밍 응답 안내

스트림이 끝나는 네 가지 방식연결이벤트 수신텍스트 델타: 계속 받는다response.completedok · refused ·unexpected_outputresponse.incompleteincompleteresponse.failed · errorupstream_error완료 이벤트 없이 종료읽기 오류interrupted어떻게 끝났나결과 상태

델타를 화면에 바로 흘리더라도 최종 결과는 따로 확정한다. 그래야 다음 단계가 임시 문장과 최종 결과를 섞어 읽지 않는다. ‘생성 중’이라고 알리는 화면은 따로 만든다.

연결이 정상적으로 닫혔더라도 완료 이벤트를 못 봤으면 성공했다는 증거가 없다. 그런 응답과 전송 라이브러리의 읽기 오류는 따로 기록하고 둘 다 불완전한 텍스트를 돌려주지 않는다. 끊긴 부분에 새 요청의 결과를 이어 붙이지도 않는다. 새로 생성하면 앞선 텍스트와 다른 방향으로 갈 수 있기 때문이다. 스트림과 클라이언트는 쓰고 나서 닫는다. 단발 호출에서는 티가 안 나지만 반복 호출하는 서버의 연결 풀에서는 새는 연결이 쌓인다.

실패는 일부러 만들어야 본다

실제 모델에 아무리 질문을 반복해도 시간 초과나 스트림 중단을 원할 때 일으킬 수는 없다. 그래서 진짜 SDK에 가짜 전송 계층을 끼우고 응답을 손으로 만들어 넣는다. 바깥으로 나가는 요청이 없으니 외부 사용량도 생기지 않는다.

모의 전송 테스트가 실행하는 경로실제generate요청을 만든다실제SDK요청 직렬화가짜모의 전송미리 정한 응답실제SDK응답 파싱실제normalize상태 분류✓SDK가 요청을 직렬화하고 응답을 파싱하는 경로는 실제로 실행된다✕모델이 계산한 응답이 아니다. 한국어 품질·비용·지연은 여기서 못 본다

개발팀이 넣어 본 상황은 이렇다.

넣어 보는 상황기대하는 결과
완료 텍스트와 정상 사용량성공. 텍스트와 사용량 보존
텍스트가 있지만 미완료미완료로 분류하고 텍스트를 비운다
거절 콘텐츠거절로 분류
출력 없음 · 미지원 도구 요청지원하지 않는 출력으로 분류
인증·권한·제한·서버 오류분류 코드만. 원본 오류 문구는 감춘다
연결 실패 · 시간 초과실패 분류
완료 이벤트가 있는 스트림최종 응답 검증
델타만 받은 뒤 스트림 종료중단으로 분류
델타 뒤 읽기 오류중단으로 분류
사용량 없음미상인 값을 null로 유지
잘못된 JSON지원하지 않는 출력으로 분류

재시도를 끈 조건에서 호출이 정말 한 번만 일어나는지도 함께 본다. 이만큼을 다 통과해도 실제 모델이 한국어로 제대로 답하는지, 계정에 그 모델을 호출할 권한이 있는지, 비용과 외부 네트워크 지연이 얼마나 되는지는 여전히 모른다. 그래서 접근할 수 있는 모델로 같은 질문을 실제로 한 번 보내 보고 모델·설정·질문·응답·시간·사용량을 따로 기록한다.

끊긴 답변을 처음 봤을 때 개발팀이 열어본 로그에는 200만 적혀 있었다. 이제 개발팀은 같은 로그에서 끊긴 이유까지 본다.

모델 호출을 애플리케이션으로 만들기3 / 21