우선 API 키를 발급받는다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Feedback {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, name = "FEEDBACK_ID")
private Long id;
private String contents;
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "VIDEO_ID")
private Video video;
}
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnswerDTO {
private String question;
private String answer;
}
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class FeedbackDTO {
private String contents;
}
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/feedback")
public class FeedbackController {
private final FeedbackService feedbackService;
@PostMapping("/create")
public FeedbackDTO createFeedback(AnswerDTO answerDTO) throws Exception {
FeedbackDTO feedbackDTO = null;
try {
feedbackDTO = feedbackService.getFeedback(answerDTO);
} catch (Exception e) {
log.error(e);
}
return feedbackDTO;
}
}
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
@Log4j2
@Service
@RequiredArgsConstructor
public class FeedbackService {
@Value("${claude.api.key}")
private static String API_KEY;
public FeedbackDTO getFeedback(AnswerDTO answerDTO) throws Exception {
String question = answerDTO.getQuestion();
String answer = answerDTO.getAnswer();
String cmd = answer + "은 CS 면접 질문 \"" + question + "\"에 대한 답변이야. 이 답변을 내용 측면과 전달력 측면에서 피드백해줘. 결과는 한국어로 전달해줘.";
// Claude API 요청 URL
URL url = new URL("https://api.anthropic.com/v1/messages");
// HTTP 연결 설정
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("x-api-key", API_KEY); // 실제 API 키로 교체
connection.setRequestProperty("anthropic-version", "2023-06-01");
connection.setRequestProperty("Content-Type", "application/json");
connection.setDoOutput(true);
// JSON 요청 본문 구성
String jsonInputString = "{"
+ "\"model\": \"claude-3-5-sonnet-20241022\","
+ "\"max_tokens\": 1024,"
+ "\"messages\": ["
+ " {\"role\": \"user\", \"content\": \"" + cmd + "\"}"
+ "]"
+ "}";
// 요청 본문 전송
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes("utf-8");
os.write(input, 0, input.length);
}
// 응답 처리
try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"))) {
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// 응답을 FeedbackDTO로 변환하는 부분
String responseString = response.toString();
FeedbackDTO feedbackDTO = new FeedbackDTO(responseString);
return feedbackDTO;
}
}
}
배포 후 api 테스트를 해준다.

비정상 종료가 된 것 같다. 로그를 확인해보자.
Unable to create key store: Error reading certificate or key from file
https 접근에 필요한 letsencrypt 키에 접근할 수 없다는 에러인 것 같다.
인스턴스 재부팅 후에 letsencrypt 키를 재발급 받았기 때문에 경로나 키 파일 접근 권한에 문제가 있는 것 같다.
우선
ls -l /etc/letsencrypt/live/[인스턴스 퍼블릭 ip 주소].nip.io/
를 통해 해당 경로에 실제로 파일이 존재하는지 확인한다.
/etc/letsencrypt/live/[인스턴스 퍼블릭 ip 주소].nip.io/privkey.pem은 심볼릭 링크로 되어 있고, 실제 파일은 /etc/letsencrypt/archive/[인스턴스 퍼블릭 ip 주소].nip.io/privkey1.pem
인 것으로 나온다.
실제 파일도 정상적으로 존재하므로 인증서가 갱신되지 않았을 가능성이 있는 것 같다.
sudo certbot certificates
명령어를 통해 서버에 설치된 인증서의 상태를 확인한다.
Certificate Name: [인스턴스 퍼블릭 ip 주소].nip.io
Serial Number: 31deb011afd2c8981e5c36a882074a78938
Key Type: ECDSA
Domains: [인스턴스 퍼블릭 ip 주소].nip.io
Expiry Date: 2025-06-10 01:27:40+00:00 (VALID: 85 days)
Certificate Path: /etc/letsencrypt/live/[인스턴스 퍼블릭 ip 주소].nip.io/fullchain.pem
Private Key Path: /etc/letsencrypt/live/[인스턴스 퍼블릭 ip 주소].nip.io/privkey.pem
정상적으로 발급된 것으로 보인다.
키를 재발급 받아보자.
여전히 실행이 되지 않는다.
내 Spring Boot 애플리케이션은 root 사용자에게만 실행이 허용되어 있는데 실행할 때 sudo (root 사용자 권한) 명령어를 붙여주지 않아서 그런 것 같다.
sudo로 다시 실행해보자. 실행이 잘 된다.
이제 postman으로 claude의 피드백을 받는 api를 테스트 해보자.

Forbidden이라고 뜬다.
아마도 /feedback/create uri를 permitAllUrls에 추가해주지 않아서 그런 것 같다. 수정 후에 다시 테스트 해 보자.


static 변수에 값 주입을 지원하지 않기 때문에 요청 헤더의 api key 자리에 null값을 넣어서 401 unauthorized가 발생한 것 같다.
static 키워드를 제거해주자.

400에러가 뜬다.
요청 본문의 모델 버전이 맞지 않아 수정해준다.
catch (IOException e) {
// 에러 응답 처리
if (connection.getResponseCode() == 400) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getErrorStream(), "utf-8"))) {
StringBuilder errorResponse = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
errorResponse.append(responseLine.trim());
}
log.error("API 400 Error: " + errorResponse.toString());
}
}
throw e;
}
다음과 같이 에러를 로깅하는 코드도 넣어준다.
API 400 Error: {"type":"error","error":{"type":"invalid_request_error","message":"The request body is not valid JSON: unexpected control character in string: line 1 column 187 (char 186)"}}
문자열 연결 방식으로 JSON을 만들면 이런 문제가 자주 발생한다고 한다. 이를 해결하기 위해 Jackson이나 Gson 같은 JSON 라이브러리를 사용하는 것이 좋다고 한다.
package com.site.xidong.feedback;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
@Log4j2
@Service
@RequiredArgsConstructor
public class FeedbackService {
@Value("${claude.api.key}")
private String API_KEY;
public FeedbackDTO getFeedback(AnswerDTO answerDTO) throws Exception {
String question = answerDTO.getQuestion();
String answer = answerDTO.getAnswer();
String cmd = answer + "은 CS 면접 질문 [" + question + "]에 대한 답변이야. 이 답변을 내용 측면과 전달력 측면에서 피드백해줘. 결과는 한국어로 전달해줘.";
// Jackson 객체 매퍼 생성
ObjectMapper mapper = new ObjectMapper();
ObjectNode rootNode = mapper.createObjectNode();
// JSON 구조 설정
rootNode.put("model", "claude-3-7-sonnet-20250219");
rootNode.put("max_tokens", 1024);
ArrayNode messagesNode = mapper.createArrayNode();
ObjectNode messageNode = mapper.createObjectNode();
messageNode.put("role", "user");
messageNode.put("content", cmd);
messagesNode.add(messageNode);
rootNode.set("messages", messagesNode);
// JSON 문자열로 변환
String jsonInputString = mapper.writeValueAsString(rootNode);
log.info("Request JSON: " + jsonInputString);
// Claude API 요청 URL
URL url = new URL("https://api.anthropic.com/v1/messages");
// HTTP 연결 설정
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("x-api-key", API_KEY); // 실제 API 키로 교체
log.info("API KEY: " + API_KEY);
connection.setRequestProperty("anthropic-version", "2023-06-01");
connection.setRequestProperty("Content-Type", "application/json");
connection.setDoOutput(true);
// 요청 본문 전송
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes("utf-8");
os.write(input, 0, input.length);
}
// 응답 처리
try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"))) {
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// 응답을 FeedbackDTO로 변환하는 부분
String responseString = response.toString();
log.info("response: " + responseString);
// Jackson을 사용하여 JSON 응답 파싱
ObjectNode responseNode = mapper.readValue(responseString, ObjectNode.class);
String content = responseNode.path("content").path(0).path("text").asText();
FeedbackDTO feedbackDTO = new FeedbackDTO(content);
return feedbackDTO;
} catch (IOException e) {
// 에러 응답 처리
if (connection.getResponseCode() >= 400) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getErrorStream(), "utf-8"))) {
StringBuilder errorResponse = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
errorResponse.append(responseLine.trim());
}
log.error("API Error ({}): {}", connection.getResponseCode(), errorResponse.toString());
}
}
throw e;
}
}
}
다음과 같이 수정해준다.

피드백을 받아오는 데 성공했다.
이제 FeedbackRepository 클래스도 마저 작성하고 db에 feedback 객체를 저장하도록 FeedbackService 클래스도 수정해주자.

Feedback 클래스의 contents 칼럼에 들어가기에 길이가 너무 길어서 오류가 발생했다.
@Lob // 길이 제한 없이 저장 가능
@Column(columnDefinition = "TEXT") // MySQL 기준 TEXT 타입으로 지정
두 어노테이션을 추가해주자.

feedback 객체가 잘 생성되었다.