OpenAI 연동과 미니프로젝트 시작 준비.
트랜스포머는 인코더와 디코더로 이루어져 있고, GPT(Generative Pre-trained Transformer)는 그중 디코더를 쌓아서 만든 모델이라고 배웠다.
동작 방식은 패턴 학습이다. 문장 뒷부분을 지우고 다음에 올 확률이 가장 높은 단어를 고르는 식으로 답을 만든다. 그래서 모르는 것도 아는 것처럼 답을 만들어내는 환각(Hallucination) 문제가 생긴다.
좋은 프롬프트의 조건은 명확성, 구체성, 맥락 제공 세 가지다.
여기서 회사별 전략들이 파생되는데 오늘 나온 것만 4개이다.
이 중 CO-STAR가 실무에서 가장 많이 쓰인다고 배웠다. 5W1H로 접근하는 방법도 있고, 기획 문서 자체를 프롬프트에 녹이는 PRD(Product Requirements Document) 방식도 있다.
GPT 공식 문서가 권장하는 프롬프트 엔지니어링 4대 전략은 이렇다.
1. 명확한 지침 작성
2. 참조 텍스트 제공
3. 하위 작업으로 분할하기
4. 생각할 시간을 부여하기
프롬프트를 쓰는 순서도 정리했다.
페르소나 → 명확한 지침 → 상세 지침 → 작성 예시 → 참고 문맥
백엔드(inspire_jpa)에 spring-ai-starter-model-openai 의존성이 추가됐고, application-dev.yml에 아래 설정이 붙었다.
spring:
ai:
openai:
api-key: ${OPEN_AI_KEY}
chat:
options:
model: ${OPEN_AI_MODEL}
OpenAiService에는 두 가지 방식이 같은 파일에 남아 있다. 하나는 주석으로 처리된 예전 방식, 다른 하나는 실제로 동작하는 방식이다.
직접 호출 (주석 처리됨) — Map으로 역할(role)과 내용(content)을 담은 메시지를 만들고, ObjectMapper로 JSON 문자열로 바꿔서 OkHttpClient로 https://api.openai.com/v1/chat/completions에 직접 POST 요청을 보낸다. 응답이 오면 objectMapper.readTree()로 트리를 만들고 node.at("/choices/0/message/content")로 실제 답변 텍스트를 꺼낸다. 응답 본문 자체는 JSON 형태의 문자열이고, 그 안의 content 자리에는 AI가 만든 또 다른 JSON 문자열이 값으로 들어가 있다. 바깥쪽을 한 번 열어 안쪽 문자열을 꺼내고, 그 안쪽 문자열을 다시 objectMapper.readValue()로 열어야 DTO에 담을 수 있는 실제 데이터가 나온다.
ChatClient (실제로 쓰는 방식)
RecommandResponseDTO result = chatClient
.prompt()
.system("전처리된 json 형태로만 반환해줘.")
.user("""
너는 날씨 전문가이고 맛있는 음식을 즐겨하는 인공지능전문가야.
<조건>
- 날씨 : "%s"
- 위치 : "%s"
</조건>
<출력예시>
{ "weather" : "날씨", "location" : "위치", "restaurants" : [...] }
</출력예시>
""".formatted(weather, location))
.call()
.entity(RecommandResponseDTO.class);
OpenAiConfig는 ChatClient.Builder를 받아서 .build() 결과를 빈으로 등록해두는 세 줄이 전부다. 이 덕분에 다른 클래스에서는 생성자 주입만으로 chatClient를 바로 쓸 수 있다. 엔드포인트 주소를 적는 코드가 안 보이는 이유는, yml에 적어둔 키·모델 정보를 ChatClient.Builder가 읽어서 이미 채워서 내려주기 때문이다. 직접 호출에서 요청을 조립하고 보내고 파싱해서 DTO로 바꾸던 코드 네 덩어리가, .prompt().user().call().entity() 한 줄 안으로 들어가 안 보이게 됐다.
같은 chatClient.prompt()...call() 뒤에 두 가지 마무리 방법이 있다.
.entity(DTO.class) — 항목이 나뉘어 있는 결과(맛집 추천, 퀴즈)를 자바 타입으로 바로 받는다..content() — 정해진 구조가 없는 결과(블로그 본문처럼 그냥 글)를 문자열로 받는다.QuizResponseDTO, RecommandResponseDTO는 둘 다 @Builder, @NoArgsConstructor, @AllArgsConstructor, @JsonIgnoreProperties(ignoreUnknown = true)를 붙였고, 배열 안에 들어가는 항목(RestaurantDTO, Quiz)은 이너 스태틱 클래스로 표현했다. @JsonIgnoreProperties(ignoreUnknown = true)는 AI 응답에 DTO가 모르는 필드가 섞여 있어도 무시하고 담으라는 설정이고, 생성자 어노테이션 중 하나가 비면 Cannot construct instance ... no Creators 에러가 나면서 변환이 멈춘다.
BlogController에 /ai/agent 엔드포인트가 새로 생겼고, BlogService에 contentGenerate()가 추가됐다.
public String contentGenerate(Map<String, Object> map) {
String result = chatClient
.prompt()
.user("""
넌 국문학과박사 수료한 블로그작성 전문가야.
주어진 카테고리와 키워드를 기반으로 차분한 톤의 블로그를 작성해줘.
글자수는 200자 이내로 작성해줘.
<조건>
- 카테고리 : "%s"
- 키워드 : "%s"
</조건>
""".formatted((String)(map.get("category")), (String)(map.get("keyword"))))
.call()
.content() ;
return result ;
}
system() 없이 user()만 있고, 출력 예시도 없다. 결과가 JSON이 아니라 그냥 글이라 .entity()가 아니라 .content()로 받는다. SecurityConfig의 화이트리스트에는 /openai/**만 추가됐고 /blogs/ai/agent는 /blogs로 시작하는 경로라 여전히 토큰이 필요하다. 같은 AI 호출인데 어느 경로에 붙였는지에 따라 인증 여부가 갈린 것이다.
Controller → Service → ChatClient → OpenAI)에서는 어느 서비스를 부를지 컨트롤러 코드가 미리 정해두는데, 에이전트를 두면 그 판단 자체를 AI에게 맡긴다. 에이전트는 판단만 하고 실제 일은 툴이 한다.프론트(BlogWritePage.jsx)에 키워드 입력창과 "키워드 전송" 버튼을 추가해서, /blogs/ai/agent를 호출해 본문을 자동으로 채우는 걸 실습했다.
#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP #SpringAI #OpenAI