[LG CNS 6기] 본 과정 34일차 TIL / [Spring AI] - AI Agent, Tool Calling, 공공데이터 Open API

김승진·2026년 9월 16일

LG CNS AM 6기 TIL

목록 보기
43/46

1. 오늘의 한 줄 요약

Agent+Tool 구조, LLM , 공공데이터 Open API!

2. 오늘 배운 것

2.1 어제와 오늘의 구조 차이

어제는 Controller가 Service를 부르고, Service 안에서 바로 ChatClient에 프롬프트를 보내는 구조였다.

Controller → Service → ChatClient → OpenAI

오늘은 Controller와 Service 사이에 Agent와 Tool이 끼어든다.

Controller → BlogAIAgent → (blogChatClient에 등록된 Tool 중 LLM이 스스로 판단해서 호출)
           → BlogAITool → BlogService → BlogRepository → DB

실제 DB 처리는 여전히 BlogService와 BlogRepository가 맡고 있고, 그 앞단에 AI가 판단해서 통과할 수 있는 관문(Tool)이 하나 추가된 구조로 보면 된다.

2.2 @Tool — LLM이 스스로 판단해서 호출

BlogAITool에 검색/저장 메서드 두 개가 @Tool로 등록됐다.

@Component
@RequiredArgsConstructor
public class BlogAITool {
    private final BlogService blogService ;

    // LLM이 스스로 판단해서 호출
    @Tool(description = "주어진 카테고리와 키워드로 이미 작성된 블로그 게시글이 있는지 검색한다.")
    public List<BlogResponseDTO> searchBlogKeyword(Map<String, Object> map) {
        return blogService.searchByKeyword(map);
    }

    @Tool(description = "사용자가 작성한 블로그 글을 실제 테이블에 저장한다.")
    public BlogResponseDTO saveBlog(BlogRequestDTO request) {
        return blogService.insert(request);
    }
}

BlogAIAgent.java 안에는 blogAITool.searchBlogKeyword(...)나 .saveBlog(...) 같은 호출문이 존재하지 않는다. 대신 @Tool에 붙인 description 문구가 그 자리를 대신한다 — LLM이 그 설명을 읽고 필요하다고 판단하면 Spring AI가 해당 메서드를 대신 실행하고, 그 결과를 다시 LLM에게 넘겨서 최종 답을 만들게 한다.

이 도구들을 실제로 쓸 수 있게 등록하는 곳이 BlogAIAgentConfig다.

@Bean
public ChatClient blogChatClient(ChatClient.Builder builder,
                                  BlogAITool blogAITool,
                                  ObjectProvider<ToolCallbackProvider> objectProvider) {
    builder = builder.defaultTools(blogAITool);
    // 외부 mcp server 와 연결
    // ToolCallbackProvider mcpTools = objectProvider.getIfAvailable();
    // if(mcpTools != null) {
    //     builder = builder.defaultToolCallbacks(mcpTools.getToolCallbacks());
    // }
    return builder.build() ;
}

builder.defaultTools(blogAITool) 한 줄이 이 blogChatClient가 쓸 수 있는 도구를 등록하는 자리다. ObjectProvider<ToolCallbackProvider>는 외부 MCP 서버에 연결할 때 쓰는 자리인데, 지금은 주석 처리되어 있어 실제로는 로컬 Tool만 쓰는 상태다.

여기서 만든 blogChatClient는 어제 OpenAiConfig에서 만든 일반 chatClient와는 별개의 빈이다.
같은 ChatClient 타입인데 이름으로 구분해서 주입받는 것이다.

2.3 기존 글 검색 — JPQL로 조건 만들기

시스템 프롬프트에 중복 여부를 확인하라고 적어놔도, 그 판단의 근거가 될 DB 조회 자체가 없으면 AI는 아무것도 확인할 수 없다. 그래서 이 검색을 실제로 수행할 쿼리를 만들었다.

@Query("""
    SELECT      b
    FROM        BlogEntity b
    WHERE       LOWER(b.content) LIKE LOWER(CONCAT('%', :content , '%'))
    AND         b.category = :category
""")
public List<BlogEntity> findByContentAndCategory(@Param("content") String content,
                                                  @Param("category") String category);

본문(content)에 키워드가 포함되어 있는지를 대소문자 구분 없이 LIKE로 찾고, 카테고리는 정확히 일치하는 것만 찾는다. content에 그 글자가 그대로 들어 있어야 걸리는 방식이라서, 단어를 바꿔 쓴 비슷한 내용의 글까지 찾아내는 수준은 아니다. BlogService.searchByKeyword()가 이 쿼리 결과를 BlogResponseDTO로 변환해서 Tool에 돌려준다.

2.4 공공데이터 Open API — 기상청 해수욕장 날씨 조회

오후엔 공공데이터포털의 "기상청_전국 해수욕장 날씨 조회서비스"를 백엔드가 직접 호출하는 기능을 만들었다.

public List<ForcastResponseDTO> connection(ForcastRequestDTO request) {
    String requestUrl = UriComponentsBuilder
        .fromUriString(endPoint)
        .queryParam("serviceKey", key)
        .queryParam("beach_num", request.getBeach_num())
        .queryParam("base_date", request.getBase_date())
        .queryParam("base_time", request.getBase_time())
        .queryParam("dataType", type)
        .toUriString() ;

    HttpURLConnection http = (HttpURLConnection) new URL(requestUrl).openConnection();
    int status = http.getResponseCode();
    if(status == 200) {
        return readString(http.getInputStream());
    }
    return null ;
}

프론트가 우리 서버로 보내는 요청(POST /openapi/fcst)과, 우리 서버가 공공데이터 서버로 다시 보내는 요청(GET + 쿼리스트링)은 완전히 다른 통신이다. 한쪽이 POST라고 다른 쪽도 POST여야 하는 건 아니다. UriComponentsBuilder를 쓰면 ?나 &가 몇 개 들어가는지 신경 쓰지 않고, .queryParam(키, 값)을 필요한 만큼 이어붙이기만 하면 된다.

응답을 읽는 과정은 세 단계로 나뉜다.
1. readString() — InputStream을 InputStreamReader(UTF-8) → BufferedReader로 감싸서 한 줄씩 읽어 StringBuilder에 누적한다.
2. parseJson() — objectMapper.readTree()로 트리를 만들고, node.findValue("item")으로 필요한 배열을 바로 찾는다. 어제 OpenAI 응답에서는 node.at("/choices/0/message/content")처럼 정확한 경로를 지정했는데, 오늘은 경로를 모를 때 이름으로 바로 찾는 findValue()를 썼다.
3. objectMapper.treeToValue(item, ForcastResponseDTO[].class)로 JSON 배열을 DTO 배열로, Arrays.asList()로 리스트로 바꾼다.

응답 DTO는 필요한 필드만 받는다.

@JsonIgnoreProperties(ignoreUnknown = true)
public class ForcastResponseDTO {
    @JsonProperty("category")
    private String category ;
    @JsonProperty("fcstValue")
    private String fcstValue ;
    private String categoryName ;
}

외부 API가 더 많은 값을 내려줘도 DTO에 없는 필드는 @JsonIgnoreProperties(ignoreUnknown = true)로 무시한다.
TMP, SKY 같은 코드값은 그 자체로는 의미를 알 수 없어서, CategoryCode라는 enum에 코드-이름-단위를 미리 정리해뒀다. SKY나 PTY처럼 숫자로 내려오는 값을 "맑음", "비" 같은 문자열로 바꿔주는 getCodeValue() 메서드도 같이 있다.

application-dev.yml에는 공공데이터 인증키/엔드포인트/응답 형식이 환경변수로 분리되어 있다.

openapi:
  serviceKey: ${OPENAPI_SERVICE_KEY}
  callBackUrl: ${OPENAPI_CALLBACKURL}
  dataType: ${OPENAPI_TYPE}

SecurityConfig의 화이트리스트에도 /openapi/**가 추가돼서, 이 API는 토큰 없이 테스트할 수 있다.

2.5 자투리 배움

  • 배포와 형상관리: 배포를 하려면 형상관리(버전관리)가 먼저 되어 있어야 한다는 이야기가 나왔다.
  • LangChain: 에이전트 구조를 LangChain(LLM+RAG를 묶은 프레임워크)과 비교하는 설명이 있었다.
    우리가 만든 Agent+Tool 구조가 그 대신 로컬 DB를 데이터 소스로 쓰는 축소판이라는 맥락이었다.
  • 바이브 코딩 이야기 중 Supabase: 바이브 코딩(AI와 대화하며 개발하는 방식)에는 데이터베이스 개념이 따로 없어서, 그런 환경에서는 Supabase 같은 클라우드 DB 서비스를 대신 붙여 쓴다는 여담이 있었다.
    Vercel(프론트엔드 배포 플랫폼)도 같이 언급됐다.

3. 트러블슈팅

프롬프트 글자수 vs DB 컬럼 길이

프롬프트에는 "본문은 300자 이내로 작성한다"고 적어뒀는데, 실제 blogs 테이블의 content 컬럼은 VARCHAR(255)였다.
AI가 300자에 가깝게 글을 쓰면 DB에 들어가지 못하고 에러가 난다. 프롬프트가 요구하는 형식과 DB 스키마의 제약은 서로를 모르기 때문에, 둘 중 하나를 사람이 직접 맞춰줘야 한다는 걸 보여준 사례다. 오늘 수업에서는 컬럼을 늘리는 대신 프롬프트 쪽 글자 수를 줄이는 방향으로 맞췄다.

4. 실습 / 적용

  • 공공데이터 Open API 코드(ForcastController ~ CategoryCode)를 연결-읽기-파싱 3단계로 나눠서 흐름을 따라갔다.

5. 오늘의 회고

  • 느낀 점 : 미니 프로젝트가 다음주부터 본격적으로 시작인데, 팀원들과 열심히 준비해서 좋은 결과가 있으면 좋겠다.
  • 다음에 할 것 : 강의 내용 복습

#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP #SpringAI #AIAgent

profile
이것저것

0개의 댓글