Published on

OpenAI Responses API 400 에러 10분 해결

Authors

서버에서 OpenAI Responses API를 붙이다 보면 가장 빨리(그리고 가장 자주) 마주치는 게 400 Bad Request 입니다. 500처럼 서버가 터진 것도 아니고, 401처럼 키가 틀린 것도 아닌데 계속 400이 나면 디버깅 시간이 길어지기 쉽습니다.

이 글은 “10분 안에” 400을 끝내기 위한 실전 체크리스트 형태로 구성했습니다. 핵심은 요청 바디를 최소 단위로 줄여서 어떤 필드가 400을 유발하는지 빠르게 찾는 것입니다.

아래 예제는 Node.js 기준이지만, 원리 자체는 언어와 무관합니다.

0. 10분 진단 루틴(가장 빠른 방법)

  1. 요청을 최소화: modelinput 만 남기고 호출
  2. 성공하면: tools, response_format, input 구조(배열/멀티모달) 순으로 하나씩 추가
  3. 실패하면: 응답의 error.message, error.type, error.param 을 그대로 로그
  4. 최종적으로: 서버에서 실제로 전송된 JSON을 그대로 덤프(직렬화 결과 확인)

운영에서 이런 류의 “즉시 실패” 에러는 애플리케이션 문제인 경우가 대부분입니다. 반면 재시도 루프나 프로세스가 꼬이는 문제는 시스템 레벨 진단이 필요할 때가 많습니다. 그런 케이스는 systemd 서비스 자동 재시작 무한루프 진단 가이드 같은 접근이 더 맞습니다.

1. 가장 작은 정상 요청부터 확인

400을 잡는 첫 단계는 “정상 요청”을 확보하는 것입니다.

Node.js 최소 예제

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const res = await client.responses.create({
  model: "gpt-4.1-mini",
  input: "ping",
});

console.log(res.output_text);

이게 400이면, 아래를 우선 확인하세요.

  • OPENAI_API_KEY 가 비었거나 공백이 섞여 있지는 않은지(보통은 401이지만, 프록시/게이트웨이에 따라 400으로 뭉개질 때가 있습니다)
  • 사내 프록시가 요청 바디를 변형하지 않는지
  • 실제로 전송된 JSON이 깨지지 않았는지

2. 400 원인 Top 7과 “바로 고치는 법”

2.1 model 오타 또는 사용 불가 모델

가장 단순하지만 실제로 자주 발생합니다.

  • 모델 문자열 오타
  • 프로젝트/조직 정책상 특정 모델 사용 불가
  • SDK/문서 예제의 모델명을 그대로 복붙했는데 계정에서 접근 불가

해결:

  • 모델명을 하드코딩했다면 우선 가장 널리 쓰이는 모델로 바꿔 테스트
  • 모델을 환경변수로 받는다면 배포 환경에서 값이 바뀌지 않았는지 확인
await client.responses.create({
  model: "gpt-4.1-mini", // 우선 검증용으로 고정
  input: "hello",
});

2.2 input 타입/구조가 잘못됨(문자열 vs 배열)

Responses API의 input 은 단순 문자열도 가능하지만, 멀티턴/멀티모달로 가면 구조가 달라집니다. 여기서 구조를 한 단계라도 틀리면 400이 납니다.

단일 텍스트(정상)

{ model: "gpt-4.1-mini", input: "요약해줘" }

멀티턴(예시)

await client.responses.create({
  model: "gpt-4.1-mini",
  input: [
    {
      role: "user",
      content: [
        { type: "input_text", text: "아래 문장을 요약해줘" },
        { type: "input_text", text: "문장: ..." },
      ],
    },
  ],
});

실수 포인트:

  • content 를 문자열로 넣어야 하는데 배열로 넣거나(반대도 마찬가지)
  • type 값 오타 (input_text 대신 다른 문자열)
  • text 필드 누락

진단 팁: 성공하는 최소 요청에서 input 만 점진적으로 복잡하게 바꾸세요.

2.3 멀티모달에서 image_url 형식 오류

이미지 입력을 넣는 순간 400이 나는 경우가 많습니다.

자주 있는 실수:

  • URL 스킴 누락(예: https:// 빠짐)
  • 접근 불가한 사설 네트워크 URL
  • base64 데이터 URL 포맷 오류

이미지 URL 예시

await client.responses.create({
  model: "gpt-4.1-mini",
  input: [
    {
      role: "user",
      content: [
        { type: "input_text", text: "이 이미지 설명해줘" },
        {
          type: "input_image",
          image_url: "https://example.com/cat.png",
        },
      ],
    },
  ],
});

운영 팁:

  • 서버에서 외부 URL을 넘길 때는, 해당 URL이 OpenAI 측에서 접근 가능한 공개 URL인지 확인하세요.
  • 사내망 이미지라면 먼저 서버가 이미지를 다운로드한 뒤, 허용되는 방식으로 전달(정책/SDK 지원 범위 내)하는 구조를 고려해야 합니다.

2.4 response_format JSON 모드 설정 실수

JSON으로만 답을 받고 싶어 response_format 을 넣었는데 400이 나는 케이스입니다.

실수 유형:

  • JSON 스키마 형식이 잘못됨
  • 스키마에서 필수 필드 정의가 누락되었거나 타입이 틀림
  • 모델/기능 조합이 맞지 않음

안전한 시작: JSON 스키마를 최소로

await client.responses.create({
  model: "gpt-4.1-mini",
  input: "사용자 정보를 JSON으로 만들어줘: 이름=Kim, 나이=33",
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "user_profile",
      schema: {
        type: "object",
        properties: {
          name: { type: "string" },
          age: { type: "integer" },
        },
        required: ["name", "age"],
        additionalProperties: false,
      },
    },
  },
});

진단 팁:

  • 400이 나면 스키마를 절반씩 덜어내며 어떤 제약이 문제인지 찾습니다.
  • additionalProperties 같은 강한 제약은 마지막에 추가하세요.

2.5 tools 정의/호출 규격 불일치

툴 호출에서 400이 나는 원인은 크게 두 가지입니다.

  • 툴 스키마 자체가 잘못됨
  • 모델이 생성한 호출을 서버가 후처리하는 과정에서 요청 바디가 깨짐(특히 “이전 응답의 tool call 결과를 다음 요청에 붙이는 로직”에서 흔함)

툴 정의 예시

await client.responses.create({
  model: "gpt-4.1-mini",
  input: "서울의 위도를 알려줘",
  tools: [
    {
      type: "function",
      function: {
        name: "get_lat",
        description: "도시의 위도를 반환",
        parameters: {
          type: "object",
          properties: {
            city: { type: "string" },
          },
          required: ["city"],
          additionalProperties: false,
        },
      },
    },
  ],
});

실수 포인트:

  • parameters 가 JSON Schema가 아닌 임의 객체
  • required 누락/오타
  • name 에 공백/특수문자

운영 팁:

  • 툴을 여러 개 쓰는 경우, 우선 1개만 남겨서 400 여부를 확인하세요.
  • 툴 결과를 다음 요청에 “합성”할 때는, 원문을 그대로 붙이지 말고 서버에서 생성하는 JSON 구조를 엄격히 통제하세요.

2.6 문자열 인코딩/이스케이프 문제(특히 로그/템플릿)

의외로 많이 터집니다.

  • JSON을 문자열로 직접 만들다가 따옴표가 깨짐
  • 로그/템플릿에서 역슬래시가 중복 이스케이프됨
  • 사용자 입력을 그대로 붙여 넣어 JSON이 깨짐

해결 원칙:

  • 요청 바디를 절대 문자열로 만들지 말고, 객체를 만든 뒤 JSON.stringify 는 전송 직전에만 사용
  • 서버 로그에는 “전송 객체”와 “직렬화 문자열”을 둘 다 남기되, 개인정보는 마스킹
const payload = {
  model: "gpt-4.1-mini",
  input: "따옴표(\")가 있어도 안전해야 함",
};

console.log("payload(object)=", payload);
console.log("payload(json)=", JSON.stringify(payload));

await client.responses.create(payload);

2.7 SDK 버전/런타임 충돌로 잘못된 필드가 전송됨

SDK가 오래되었거나, 프로젝트에서 ESM/CJS가 섞여 이상하게 import 되면 요청 형태가 꼬여 400이 날 수 있습니다.

  • Node 런타임과 패키지 타입 불일치
  • 번들러가 트리 쉐이킹하면서 폴리필/전송 로직이 깨짐

이런 경우는 에러 메시지가 애매하게 나오는 편이라, 런타임/모듈 시스템부터 정리하는 게 빠릅니다. 관련해서 Node 모듈 충돌을 다룬 글인 Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기도 함께 참고하면 좋습니다.

3. 400을 “바로 읽히게” 만드는 로깅 패턴

400을 10분 안에 끝내려면, 에러가 났을 때 다음 3가지를 반드시 확보해야 합니다.

  • 전송한 요청 바디(민감정보 마스킹)
  • OpenAI 응답의 error 전문
  • 요청을 만든 코드 경로(어떤 입력이 어떤 필드로 들어갔는지)

Node.js 에러 로깅 예시

try {
  const payload = {
    model: "gpt-4.1-mini",
    input: "hello",
  };

  const res = await client.responses.create(payload);
  console.log(res.output_text);
} catch (e) {
  // SDK 에러 객체 구조는 버전에 따라 다를 수 있어 방어적으로 처리
  const status = e?.status || e?.response?.status;
  const data = e?.error || e?.response?.data;

  console.error("OpenAI error status=", status);
  console.error("OpenAI error data=", JSON.stringify(data, null, 2));
  console.error("OpenAI raw message=", e?.message);

  throw e;
}

운영 팁:

  • 400은 재시도로 해결되지 않는 경우가 대부분이므로, 무한 재시도는 피하세요.
  • 재시도 정책을 잘못 잡으면 장애가 증폭될 수 있습니다. 이런 “루프형 장애”는 애플리케이션 레벨에서도 systemd 레벨에서도 동시에 나타날 수 있으니, 프로세스가 계속 재기동되는 상황이라면 systemd 서비스 자동 재시작 무한루프 진단 가이드처럼 루프를 먼저 끊고 원인을 파는 게 안전합니다.

4. 실전 체크리스트(이대로만 보면 대부분 끝남)

아래 순서대로 확인하면 400의 대부분은 빠르게 해결됩니다.

  1. model 을 검증된 값으로 고정하고 input 을 문자열로 최소화
  2. 성공하면 input 을 배열 구조로 확장(멀티턴/멀티모달은 단계적으로)
  3. 이미지가 있으면 image_url 이 공개 접근 가능한지, 스킴이 있는지 확인
  4. tools 를 쓰면 1개만 남겨 스키마부터 검증
  5. response_format 은 최소 스키마로 시작해 점진적으로 강화
  6. 요청 바디를 문자열로 만들지 말고 객체로 구성(이스케이프 문제 제거)
  7. SDK/모듈 시스템(ESM/CJS) 충돌 여부 확인

5. 마무리: 400은 “요청 바디를 줄이면” 이깁니다

OpenAI Responses API의 400은 대부분 요청 JSON의 스키마 불일치에서 발생합니다. 그래서 가장 강력한 해결책은 디버거보다도 “요청을 최소로 만들고, 하나씩 추가하면서 깨지는 지점을 찾는 방식”입니다.

이 글의 최소 요청 예제가 성공하도록 만든 뒤, 멀티모달, 툴, JSON 스키마를 순서대로 얹어가면 10분 안에 원인을 특정할 확률이 크게 올라갑니다.