Jsoup(JAVA HTML parser)
jsoup 는 실제 HTML 및 XML 작업을 간소화하는 Java 라이브러리입니다. DOM API 메서드, CSS 및 xpath 선택자를 사용하여 URL 가져오기, 데이터 파싱, 추출 및 조작을 위한 사용하기 쉬운 API를 제공합니다. [링크]
public NewsResponse parse(String url) {
try {
if (!url.startsWith("https://n.news.naver.com/")) throw new RuntimeException("네이버 뉴스가 아닙니다.");
Document doc = Jsoup.connect(toPrintUrl(url)).get();
String title = doc.select("#title_area").text();
String content = doc.select("#dic_area").text();
return new NewsResponse(title, content);
} catch (IOException e) {
log.error("[Error]", e);
}
return null;
}
private String toPrintUrl(String originalUrl) {
if (originalUrl.contains("/article/print/")) {
return originalUrl;
}
return originalUrl.replaceFirst("/article/", "/article/print/");
}
1. 네이버 뉴스 제한
뉴스 사이트마다 파싱하는 법이 달라서 네이버 뉴스로 제한했다. 가져온 HTML 자체를 GPT에 넘겨서 파싱하는 법도 있으나 그러면 토큰 사용량이 너무 많아지기 때문에 컨텐츠 영역만 GPT로 넘기는 방법을 선택했다.
2. /article/print 강제 리다이렉트
/article/[num]은 실제 사용자가 보는 페이지로 기사 외에 메뉴나 다른 기사 컨텐츠들이 많다.
/article/print/[num]은 인쇄 페이지로 기사만 페이지에 표시된다. 읽어오는 비용을 줄이기 위해 인쇄 페이지로 리다이렉트 했다.


OpenAI 토큰

ChatGPT를 유료 결제해서 쓴다고 해도 OpenAI와는 별개이기 때문에 별도의 비용이 발생한다.
카드를 등록해서 Monthly Limit 을 걸어두면 그 이상으로 사용이 안되지만, 계속해서 비용이 청구될 걸 염려해서 토큰 5달러만 충전하고 연결된 카드를 해지했다.
그래도 괜히 청구될까 겁나서 budget 을 $5로 제한했다. [budget 페이지]
모델 선택
✅ OpenAI GPT API 가격 (2025년 기준)
| 모델 | 입력 (Prompt) | 출력 (Completion) |
| GPT-4o | $0.005 / 1,000 tokens | $0.015 / 1,000 tokens |
| GPT-4-turbo | $0.01 / 1,000 tokens | $0.03 / 1,000 tokens |
| GPT-3.5-turbo | $0.0005 / 1,000 tokens | $0.0015 / 1,000 tokens |
✅ 비용 차이 요약
| 항목 | GPT-3.5-turbo | GPT-4o | GPT-4-turbo |
| 정확도 | 중간 | 높음 | 가장 높음 |
| 속도 | 빠름 | 매우 빠름 | 느림 |
| 비용 (전체) | $0.002 / 1,000 tokens | $0.02 / 1,000 tokens | $0.04 / 1,000 tokens |
✅ 예시: 500자 요약 (약 750 tokens 입력 + 250 tokens 출력)
| 모델 | 총 토큰 | 예상 비용 (1회 호출당) |
| GPT-3.5-turbo | 1,000 | $0.002 (약 2.7원) |
| GPT-4o | 1,000 | $0.02 (약 27원) |
| GPT-4-turbo | 1,000 | $0.04 (약 54원) |
최신 모델로 하면 당연히 성능이 좋지만, OpenAI는 입력과 출력 별도로 비용이 발생한다. 기사가 길수록 비용이 크고 출력 글자수 제한을 늘리면 그만큼 비용이 증가한다. 그래서 가장 저렴한 GPT-3.5-turbo를 선택했다.
현재 18번 요청했는데 $0.02가 청구됐으니 1회 청구비용은 $0.0011정도다. 평균 500~600 tokens으로 짧은 기사들만 찾아서 요청했기 때문에 입력 토큰에서 얼마 안나간게 아닐까 싶다.
이정도 토큰을 유지한다면 $5는 450회 정도의 요청이 가능할 것 같다.
OpenAI 호출
OpenAIOkHttpClient openAIClient = OpenAIOkHttpClient.builder()
.apiKey(API_KEY)
.build();
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
.addSystemMessage(systemPrompt)
.addUserMessage(articleText)
.model(ChatModel.GPT_3_5_TURBO)
.temperature(temperature)
.maxCompletionTokens(maxTokens)
.build();
ChatCompletion chatCompletion = openAIClient.chat().completions().create(params);
String json = chatCompletion.choices().get(0).message().content().get();
return objectMapper.readValue(json, GptResponse.class);
보통 HTTP 요청은 okHttp 라이브러리를 사용한다. 처음에 그렇게 구성했으나 [OpenAI 라이브러리]가 있길래 수정했다.
Message
| 구분 | 설명 | 예시 |
| addSystemMessage(...) | GPT의 역할과 성격을 정의하는 프롬프트. "너는 누구고, 어떤 응답을 해야 해"라는 지침 |
"You are a helpful assistant.", "You are a news summarization expert." |
| addUserMessage(...) | 사용자가 GPT에게 던지는 질문이나 명령문 |
addSystemMessage는 프롬프트가 학습할 내용, addUserMessage는 학습된 프롬프트에 사용할 내용이라고 보면 된다.
You are a news summarizer.
Given a news article, return a JSON response in **Korean only**, with:
- "summary": 3–5 concise sentences covering key facts, causes, people, and background.
- "topic": One main topic (e.g., Economy, Politics, Society).
- "keywords": 2–3 important keywords as a string array.
Use this exact JSON format and no other output:
{
"summary": "...",
"topic": "...",
"keywords": ["...", "...", "..."]
}
프롬프트는 위와같이 작성했는데, 한글이 토큰 비용이 조금 더 나가기 때문에 영문으로 변경했다. 또한 프롬프트를 효율적으로 관리할 수 있게끔 별도 파일을 불러와서 사용할 수 있도록 설계했다.
API_KEY 는 유출 우려가 있어서 .env에 별도 저장하여 사용하고 temperature, maxTokens는 application.yml에서 불러와서 사용했다.
ChatCompletaionCreateParams
| 파라미터 | 타입 | 역할 | 추천 범위 |
| temperature | float | 응답의 창의성(무작위성) 조절 | 0.0 ~ 1.0 |
| top_p (nucleus sampling) | float | 확률 상위 몇 % 단어만 고려 | 0.5 ~ 1.0 |
| frequency_penalty | float | 반복되는 단어 감점 | 0.0 ~ 2.0 |
| presence_penalty | float | 이미 등장한 단어 다시 쓰지 않도록 유도 | 0.0 ~ 2.0 |
| max_tokens | int | 응답 최대 길이 설정 | 요청에 따라 조절 |
- temperature와 top_p는 같이 쓰지 말기: 예측 제어 방식이 충돌할 수 있음
- 요약이 너무 길면 max_tokens를 줄이기
- 요약이 이상하게 반복된다면 frequency_penalty를 올리기
요약만 하면 되기 때문에 temperature만 0.3으로 고정시키고, max_tokens도 너무 길면 비용이 크기도 하고 너무 길면 요약이 아니기 때문에 400으로 제한했다.
ResponseFormat(DTO.class)
StructuredChatCompletionCreateParams<GptResponseDto> params = ChatCompletionCreateParams.builder()
.addUserMessage("Summarize the following news article using the format defined in the system prompt: \n" + articleText)
.addSystemMessage(systemPrompt)
.model(ChatModel.GPT_3_5_TURBO)
.temperature(temperature)
.maxCompletionTokens(maxTokens)
.responseFormat(GptResponseDto.class) // DTO mapper
.build();
com.openai.errors.BadRequestException: 400: Invalid parameter: 'response_format' of type 'json_schema' is not supported with this model. Learn more about supported models at the Structured Outputs guide: https://platform.openai.com/docs/guides/structured-outputs at com.openai.errors.BadRequestException$Builder.build(BadRequestException.kt:88) ~[openai-java-core-2.8.0.jar:2.8.0] at com.openai.core.handlers.ErrorHandler$withErrorHandler$1.handle(ErrorHandler.kt:48) ~[openai-java-core-2.8.0.jar:2.8.0] at com.openai.services.blocking.chat.ChatCompletionServiceImpl$WithRawResponseImpl$create$1.invoke(ChatCompletionServiceImpl.kt:134) ~[openai-java-core-2.8.0.jar:2.8.0] at com.openai.services.blocking.chat.ChatCompletionServiceImpl$WithRawResponseImpl$create$1.invoke(ChatCompletionServiceImpl.kt:132) ~[openai-java-core-2.8.0.jar:2.8.0] at com.openai.core.http.HttpResponseForKt$parseable$1$parsed$2.invoke(HttpResponseFor.kt:14) ~[openai-java-core-2.8.0.jar:2.8.0] at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74) ~[kotlin-stdlib-1.9.25.jar:1.9.25-release-852] at com.openai.core.http.HttpResponseForKt$parseable$1.getParsed(HttpResponseFor.kt:14) ~[openai-java-core-2.8.0.jar:2.8.0] at com.openai.core.http.HttpResponseForKt$parseable$1.parse(HttpResponseFor.kt:16) ~[openai-java-core-2.8.0.jar:2.8.0]
DTO로 반환하려고 ResponseFormat 을 사용했으나 위와같은 에러가 떠서 확인해봤더니, GPT 4이상부터 사용 가능하다고 한다.ㅠ.. 모델을 변경하면 비용이 증가하기 때문에 ObjectMapper로 변환했다.

JSON 에러 Unrecognized field "refusal"
om.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "refusal" (class io.github.haeun.newsgptback.dto.GptMessageDto), not marked as ignorable (2 known properties: "content", "role"]) at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); line: 12, column: 24] (through reference chain: io.github.haeun.newsgptback.dto.GptResponseDto["choices"]->java.util.ArrayList[0]->io.github.haeun.newsgptback.dto.GptResponseDto$Choice["message"]->io.github.haeun.newsgptback.dto.GptMessageDto["refusal"]) at
JSON으로 변환하는 부분에서 이 에러가 뜰 수 있다. (필드명은 다를 수 있음)
"refusal"이라는 필드는 JSON에 존재하지만, GptMessageDto 클래스에는 해당 필드가 정의되어 있지 않다는 뜻이다.
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
관련된 DTO에 @JsonIgnoreProperties(ignoreUnknown = true) 를 추가해서 이를 해결할 수 있으나, 작업당시에 관련된 DTO가 더 있어서 비효율적이라고 판단해 JsonConfig 를 만들어서 처리했다.
OkHttpClinet 비교
// 기존 HTTP 요청 코드
OkHttpClient client = new OkHttpClient();
List<GptMessageDto> messages = List.of(
new GptMessageDto("system", systemPrompt),
new GptMessageDto("user", articleText)
);
GptRequestDto gptRequest = new GptRequestDto(model, messages, maxTokens, temperature);
RequestBody body = RequestBody.create(
mapper.writeValueAsString(gptRequest),
MediaType.get("application/json")
);
Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) throw new IOException("GPT 요청 실패: " + response.code());
String responseBody = response.body().string();
log.info(responseBody);
GptResponseDto gptResponse = mapper.readValue(responseBody, GptResponseDto.class);
return gptResponse.getChoices().get(0).getMessage().getContent();
}
이게 OkHttpClient를 사용한 코드인데 OpenAI에 최적화된 OpenAIClient에 비해 코드가 복잡하다.
파라미터 받는 방식은 당연히 다르고, 큰 차이를 본다면 API URL을 넣는 부분이 없어졌다는 점이다.
"https://api.openai.com/v1/chat/completions" 이렇게 원하는 API URL을 넣어야 했으나, OpenAIClient에서 바로 메서드 호출해서 사용할 수 있다. (예: openAIClient.chat().completions())
결과

[CompletionUsage] 3.415s, prompt_tokens: 1040, completion_tokens: 255, total_tokens: 1295
프롬프트에 요청한대로 JSON 형태로 받아진 걸 확인할 수 있다.
'프로젝트 > 뉴스 요약 (AI)' 카테고리의 다른 글
| [뉴스 요약] REST API 예외 응답 처리 (1) | 2025.06.25 |
|---|---|
| [뉴스 요약] AI 중복 요청 분기 처리 (Database 구성) (0) | 2025.06.23 |
| [뉴스 요약] Swagger, REST Docs 적용 (4) | 2025.06.21 |
| [뉴스 요약] 프론트 (Vite + React) (1) | 2025.06.20 |
| [뉴스 요약] AI API를 이용한 뉴스 요약 프로젝트 기획 (1) | 2025.06.18 |