OpenAI 'you must provide a model parameter' 400 에러 원인 파악 (feat. application/json)

지니·2025년 8월 3일
0

1년 넘게 잘 동작하던 RestTemplate을 통해 OpenAI API를 call 하는 로직에서 아래와 같은 400 에러가 갑자기 뜨게 되었다.

BadRequestError: Error code: 400 - {'error': {'message': 'you must provide a model parameter', 'type': 'invalid_request_error', 'param': null, 'code': null}}

처음엔 외부 API 서버 측 일시적인 문제겠거니... 하고 우선 모니터링에 더 집중했다. (건드린 사람이 없었고 코드에서도 이상이 없어보였으니까!!!)
그런데 잠깐... OpenAI status도 특이사항 없었고 여러 가지 상황을 가정해보았는데 모두 말이 안 되었다. 400이면 클라이언트(=나) 잘못인데... 다시 분석해보았다.


1. 언제 you must provide a model parameter가 뜨는가?

1. body에 model 값을 보내주지 않았을 때

메시지 그대로다. 정말로 model을 null로 보내주거나 아예 보내주지 않으면 해당 메시지가 뜨게 된다. OpenAI에서 지원하지 않는 모델명을 설정하여 보내줄 경우 다른 메시지가 뜬다.

2. header에 Content-Type을 application/json으로 설정하지 않았을 때

header에 Content-Type을 아예 누락시키거나 application/json으로 정확히 설정하지 않으면 해당 메시지가 뜨게 된다 (오타도 조심하자).

리서치 및 실험 결과 위 두 가지 케이스를 발견하였는데 더 있을 수도...


2. RestTemplate 객체 생성 및 초기화 부분 확인

여기! 를 검색하면 원인 코드를 좀 더 빠르게 확인할 수 있을 것이다.

model을 명시적으로 보내주지 않는 케이스가 아니라면 Content-Type을 header에 설정하는 부분을 살펴볼 필요가 있다. (Spring Boot 기준 보통 RestTemplate을 bean으로 관리하거나 서비스 생성자 레벨에서 초기화하여 사용할테니 그 부분을 유심히 살펴보면 될 것 같다.) 본인은 아래와 같이 RestTemplate를 초기화하여 사용했었다.

HttpHeaders headers = new HttpHeaders();
       headers.setContentType(MediaType.APPLICATION_JSON);
       headers.setBearerAuth(API_KEY);

this.restTemplate = restTemplateBuilder
       .rootUri("https://api.openai.com")
       .interceptors((request, body, execution) -> {
               request.getHeaders().addAll(headers);
               return execution.execute(request, body);
       })
                
                ...
                
       .build();

대충 봤을 때는 이상 없어보이는데 결론부터 이야기하자면 Content-Type application/json이 두 번 설정된다. 그 이유가 궁금하여 내부 코드를 살짝 열어보았다.


RestTemplateBuilder.java

public RestTemplate build() {
	return configure(new RestTemplate());
}

RestTemplateBuilder를 통해 RestTemplate()을 만들게 되는 경우 RestTemplate() 기본 생성자를 호출한다.


RestTemplate.java

public RestTemplate() {
		...
		if (jackson2Present) {
			this.messageConverters.add(new MappingJackson2HttpMessageConverter());
		}
		...
	}

RestTemplate 생성자의 일부다. jackson2Present가 true인 조건은 ObjectMapper 클래스의 존재 여부인데 본인 프로젝트에서는 사용하고 있었기 때문에 messageConvertersMappingJackson2HttpMessageConverter가 추가된다.


MappingJackson2HttpMessageConverter.java

/**
 * Construct a new {@link MappingJackson2HttpMessageConverter} with a custom {@link ObjectMapper}.
* You can use {@link Jackson2ObjectMapperBuilder} to build it easily.
* @see Jackson2ObjectMapperBuilder#json()
*/
public MappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
	super(objectMapper, MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
}
    
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) {
	this(objectMapper);
	setSupportedMediaTypes(Arrays.asList(supportedMediaTypes));
}

MappingJackson2HttpMessageConverter 생성자를 따라가보면 supportedMediaTypes에 application/json, application.*+json이 추가되는 걸 볼 수 있다. 이제 이 값들이 어떻게 사용되는지 알아보자.


RestTemplate.java

@Override
@SuppressWarnings("unchecked")
public void doWithRequest(ClientHttpRequest httpRequest) throws IOException {
    super.doWithRequest(httpRequest);
    Object requestBody = this.requestEntity.getBody();
    if (requestBody == null) {
        HttpHeaders httpHeaders = httpRequest.getHeaders();
        HttpHeaders requestHeaders = this.requestEntity.getHeaders();
        if (!requestHeaders.isEmpty()) {
            requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
        }
        if (httpHeaders.getContentLength() < 0) {
            httpHeaders.setContentLength(0L);
        }
    }
    else {
        Class<?> requestBodyClass = requestBody.getClass();
        Type requestBodyType = (this.requestEntity instanceof RequestEntity ?
                ((RequestEntity<?>)this.requestEntity).getType() : requestBodyClass);
        HttpHeaders httpHeaders = httpRequest.getHeaders();
        HttpHeaders requestHeaders = this.requestEntity.getHeaders();
        MediaType requestContentType = requestHeaders.getContentType();
        for (HttpMessageConverter<?> messageConverter : getMessageConverters()) {
            if (messageConverter instanceof GenericHttpMessageConverter) {
                GenericHttpMessageConverter<Object> genericConverter =
                        (GenericHttpMessageConverter<Object>) messageConverter;
                if (genericConverter.canWrite(requestBodyType, requestBodyClass, requestContentType)) {
                    if (!requestHeaders.isEmpty()) {
                        requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
                    }
                    logBody(requestBody, requestContentType, genericConverter);
                    genericConverter.write(requestBody, requestBodyType, requestContentType, httpRequest);
                    return;
                }
            }
            else if (messageConverter.canWrite(requestBodyClass, requestContentType)) {
                if (!requestHeaders.isEmpty()) {
                    requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
                }
                logBody(requestBody, requestContentType, messageConverter);
                ((HttpMessageConverter<Object>) messageConverter).write(
                        requestBody, requestContentType, httpRequest); // 여기!
                return;
            }
        }
        String message = "No HttpMessageConverter for " + requestBodyClass.getName();
        if (requestContentType != null) {
            message += " and content type \"" + requestContentType + "\"";
        }
        throw new RestClientException(message);
    }
}

REST API 요청을 하게 되면 결국 위 메소드를 호출하게 되는데 여기서 messageConverter의 write 메소드에 집중해보자.


AbstractHttpMessageConverter.java

@Override
public final void write(final T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
        throws IOException, HttpMessageNotWritableException {

    final HttpHeaders headers = outputMessage.getHeaders();
    addDefaultHeaders(headers, t, contentType); // 여기!

    if (outputMessage instanceof StreamingHttpOutputMessage) {
        StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
        streamingOutputMessage.setBody(outputStream -> writeInternal(t, new HttpOutputMessage() {
            @Override
            public OutputStream getBody() {
                return outputStream;
            }
            @Override
            public HttpHeaders getHeaders() {
                return headers;
            }
        }));
    }
    else {
        writeInternal(t, outputMessage);
        outputMessage.getBody().flush();
    }
}

/**
 * Add default headers to the output message.
 * <p>This implementation delegates to {@link #getDefaultContentType(Object)} if a
 * content type was not provided, set if necessary the default character set, calls
 * {@link #getContentLength}, and sets the corresponding headers.
 * @since 4.2
 */
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
    if (headers.getContentType() == null) {
        MediaType contentTypeToUse = contentType;
        if (contentType == null || !contentType.isConcrete()) {
            contentTypeToUse = getDefaultContentType(t); // 여기!
        }
        else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
            MediaType mediaType = getDefaultContentType(t);
            contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
        }
        if (contentTypeToUse != null) {
            if (contentTypeToUse.getCharset() == null) {
                Charset defaultCharset = getDefaultCharset();
                if (defaultCharset != null) {
                    contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
                }
            }
            headers.setContentType(contentTypeToUse); // 여기!
        }
    }
    if (headers.getContentLength() < 0 && !headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) {
        Long contentLength = getContentLength(t, headers.getContentType());
        if (contentLength != null) {
            headers.setContentLength(contentLength);
        }
    }
}

getDefaultContentType() 를 통해 contentType을 가져와서 헤더에 Content-Type을 설정해준다.


HttpHeaders.java

/**
 * Set the {@linkplain MediaType media type} of the body,
 * as specified by the {@code Content-Type} header.
 */
public void setContentType(@Nullable MediaType mediaType) {
    if (mediaType != null) {
        Assert.isTrue(!mediaType.isWildcardType(), "Content-Type cannot contain wildcard type '*'");
        Assert.isTrue(!mediaType.isWildcardSubtype(), "Content-Type cannot contain wildcard subtype '*'");
        set(CONTENT_TYPE, mediaType.toString());
    }
    else {
        remove(CONTENT_TYPE);
    }
}

@Override
public void set(String headerName, @Nullable String headerValue) {
	this.headers.set(headerName, headerValue);
}

final MultiValueMap<String, String> headers; // 여기!
    

그리고 headers는 MultiValueMap이었다. 따라서 중복 값이 들어갈 수 있었던 것이다. 실제로 postman에서도 Content-Type을 중복으로 요청하였더니 진짜 동일한 메시지가 뜨는 것을 확인할 수 있었다!!!


3. 정리를 마치며

오랫동안 정상 동작했던 코드라도 너무 믿지 말고 외부 Call이 끼어있다면 다시 한 번 확인해보자. 400 에러면 본인 잘못일 확률이 매우 높다...
오류 찾기 전까지는 매우 답답했지만 원인을 알아내고 RestTemplate 내부 코드를 열어보는 과정은 재미있었다 🙂

profile
Coding Duck

0개의 댓글