Grab이 AI Agent로 생산성 향상: 상세 설명과 예제
1. 개요
Grab 같은 규모 있는 IT 회사들이 “챗봇 질의응답”에서 “스스로 행동하는 AI Agent 사용”으로 전환하고 있습니다. 기술 생산성을 높이기 위해: 자동 코드 리뷰, PR 자동 요약, 인시던트 자동 트리아주. 이 글은 AI Agent가 순수 LLM 호출과 어떻게 다른지, 널리 사용되는 Planner–Tool–Executor 아키텍처, 그리고 Java 21 + Spring AI를 사용해 비슷한 agent를 직접 구축하는 방법을 설명합니다. 이 글을 읽은 후, 실제 tool을 호출하는 작은 agent를 직접 작성할 수 있고(파일 읽기, 내부 API 호출), agent를 프로덕션에 적용하지 말아야 할 경우를 알 수 있습니다.
2. AI Agent와 일반적인 LLM 호출의 차이점
순수 LLM 호출은 프롬프트를 받고 텍스트를 반환할 뿐 — 상태가 없고, 행동도 없습니다. AI Agent는 다음 세 가지 특징을 가집니다:
- 자체 계획(planning): 큰 요청을 작은 단계로 나눕니다.
- Tool 호출(tool calling / function calling): agent는 API를 호출하고, 파일을 읽고, 쿼리를 실행할 수 있습니다 — 텍스트만 생성하는 것이 아닙니다.
- 관찰–행동 루프(observe–act loop): agent는 tool이 반환한 결과를 보고, 다음 단계를 결정하고, 작업이 완료될 때까지 반복합니다.
대규모 엔지니어링 팀에서의 실제 예: agent는 pull request의 diff를 읽고, “팀의 코딩 컨벤션 가져오기” tool을 호출하고, “정적 분석 실행” tool을 호출한 후 리뷰 댓글로 종합합니다 — 모두 사람이 각 단계를 기다릴 필요 없이 진행됩니다.
주의: 작업이 단순히 “텍스트 요약” 또는 “언어 번역”이라면 순수 LLM 호출만으로 충분합니다. OpenAI API를 호출하는 모든 것에 “agent”라는 꼬리표를 붙이지 마세요 — 불필요한 복잡화는 시스템을 디버깅하기 어렵게 만듭니다.
3. 기본 agent 아키텍처: Planner–Tool–Executor
가장 일반적인 모델은 세 부분으로 구성됩니다:
User request
│
▼
[Planner] -- 수행할 단계 목록 생성 또는 다음 tool 선택
│
▼
[Tool Executor] -- 실제 tool 호출: API, DB, 파일 시스템
│
▼
[Observation] -- tool 결과를 Planner에 반환
│
└── Planner이 "완료"를 보고할 때까지 반복
Planner은 일반적으로 tool “메뉴”(이름, 설명, 파라미터 스키마)가 주어진 LLM입니다. LLM은 직접 코드를 실행하지 않습니다 — 어떤 tool을 어떤 파라미터로 호출할지 결정하기만 합니다. 당신의 코드가 실제 실행을 담당합니다.
3.1. 필수 종속성
Spring AI는 이 패턴에 대해 꽤 깔끔한 추상화를 제공합니다:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
4. 실습 예제: Code Review Agent
Order 서비스의 diff를 받아 코딩 컨벤션을 자동으로 확인하고 수정을 제안하는 agent가 필요하다고 가정합시다. 먼저 agent가 호출할 수 있는 tool을 선언합니다 — 여기서는 내부 파일에서 코딩 컨벤션을 읽는 tool입니다.
@Component
public class ConventionTool {
@Tool(description = "특정 언어에 대한 팀의 현재 코딩 컨벤션 가져오기")
public String getConvention(String language) {
return switch (language.toLowerCase()) {
case "java" -> "DTO에는 record 사용; @Autowired 필드 금지; SLF4J로 로깅.";
default -> "이 언어에 대한 컨벤션이 없습니다.";
};
}
}
@Tool은 이 메서드가 LLM이 호출할 수 있는 함수임을 표시하는 Spring AI 어노테이션입니다.description은 LLM이 “이 tool을 호출해야 할까?“를 결정하기 위해 읽는 것입니다 — 명확하게 작성해야 LLM이 올바르게 선택합니다.
다음은 서비스 오케스트레이션입니다 — Planner + Executor의 역할을 합니다:
@Service
public class CodeReviewAgent {
private final ChatClient chatClient;
public CodeReviewAgent(ChatClient.Builder builder, ConventionTool conventionTool) {
this.chatClient = builder
.defaultTools(conventionTool)
.build();
}
public String review(String diff) {
return chatClient.prompt()
.system("당신은 리뷰어입니다. 의견을 제시하기 전 필요하면 tool을 사용해 컨벤션을 확인하세요.")
.user("다음 diff를 리뷰해주세요:\n" + diff)
.call()
.content();
}
}
defaultTools(conventionTool)은chatClient의 모든 요청에 tool을 등록합니다.review(diff)를 호출하면 Spring AI는 “tool 메뉴”를 프롬프트와 함께 모델에 자동으로 전송합니다. 모델이getConvention("java")를 호출하기로 결정하면 프레임워크는 해당 Java 메서드를 자동으로 실행하고, 결과를 가져와서 모델에 다시 보내 모델이 최종 답변을 생성하도록 합니다 — 이 모든 루프는call()명령어 하나에서 일어납니다.
시도해봅시다:
@RestController
@RequestMapping("/api/review")
public class ReviewController {
private final CodeReviewAgent agent;
public ReviewController(CodeReviewAgent agent) {
this.agent = agent;
}
@PostMapping
public String review(@RequestBody String diff) {
return agent.review(diff);
}
}
일반적인 출력:
팀의 컨벤션에서는 DTO에 record를 사용하고 로깅에는 SLF4J를 사용하도록 요구합니다.
이 diff에서 OrderDto 클래스는 일반 클래스이며 getter/setter가 있습니다 —
record로 변경해야 합니다. 42번 줄은 logger 대신 System.out.println을 사용하고 있으므로
컨벤션에 따라 수정이 필요합니다.
핵심 포인트: “일반 클래스를 보면 record를 제안한다”는 로직을 직접 작성하지 않습니다 — 모델이 tool에서 가져온 컨벤션을 기반으로 자체적으로 추론합니다. 이것이 agent가 “생산성을 향상시키는” 이유입니다: 리뷰가 더 이상 컨벤션을 수동으로 기억하는 사람에게 의존하지 않습니다.
5. 여러 tool을 호출하는 Agent: PR Summary Agent
실제로 대규모 팀은 일반적으로 agent가 여러 tool을 연속으로 호출하기를 원합니다 — 예를 들어: 변경된 파일 목록 가져오기, 테스트 커버리지 가져오기, 그 다음 PR을 리뷰어에게 요약하기.
@Component
public class PullRequestTools {
private final GitService gitService;
private final CoverageService coverageService;
public PullRequestTools(GitService gitService, CoverageService coverageService) {
this.gitService = gitService;
this.coverageService = coverageService;
}
@Tool(description = "PR id에 따른 변경된 파일 목록 가져오기")
public List<String> listChangedFiles(long prId) {
return gitService.changedFiles(prId);
}
@Tool(description = "파일의 테스트 커버리지 백분율 가져오기")
public double getCoverage(String filePath) {
return coverageService.coverageOf(filePath);
}
}
- 두 tool은 독립적이며, 각 tool은 작은 작업을 수행하고 개별적으로 테스트하기 쉽습니다.
- 모델은 호출 순서를 자동으로 결정합니다: 일반적으로 먼저
listChangedFiles를 호출한 다음, 반환된 각 파일에 대해getCoverage를 호출합니다 — 이것이 3번 항목에서 언급한 “observe–act” 루프입니다.
@Service
public class PullRequestSummaryAgent {
private final ChatClient chatClient;
public PullRequestSummaryAgent(ChatClient.Builder builder, PullRequestTools tools) {
this.chatClient = builder.defaultTools(tools).build();
}
public String summarize(long prId) {
return chatClient.prompt()
.system("PR 요약에는 다음이 포함되어야 합니다: 변경된 파일 수, 커버리지가 70% 미만인 파일 주의.")
.user("PR id " + prId + " 요약")
.call()
.content();
}
}
이런 유형의 agent는 생산성 문제를 올바르게 해결합니다: 리뷰어가 각 파일을 열어 커버리지를 확인할 필요가 없습니다 — agent가 이미 한 요약에 모두 종합했습니다.
6. Agent 테스트
agent를 실제로 적용할 때 가장 어려운 부분은 LLM을 호출하는 것이 아니라 입력이 변경될 때 tool이 올바르게 호출되는지 보장하는 것입니다. 따라서 tool에 대한 단위 테스트(결정적)와 agent에 대한 통합 테스트(ChatClient를 mock할 수 있음)를 분리해야 합니다.
class ConventionToolTest {
private final ConventionTool tool = new ConventionTool();
@Test
void shouldReturnJavaConvention() {
String result = tool.getConvention("Java");
assertThat(result)
.contains("record")
.contains("SLF4J");
}
@Test
void shouldReturnDefaultMessageForUnknownLanguage() {
String result = tool.getConvention("cobol");
assertThat(result).isEqualTo("이 언어에 대한 컨벤션이 없습니다.");
}
}
- Tool 테스트는 순수 메서드 테스트와 같으며 LLM을 mock할 필요가 없습니다 — 이것이 당신이 100% 제어할 수 있는 부분입니다.
- 실제 LLM을 호출하는 agent 부분의 경우 “계약” 수준에서 테스트를 작성해야 합니다: 응답이 필수 키워드(예: “record”, “coverage”)를 포함하는지 assert하고, LLM 출력이 호출 간에 완전히 안정적이지 않으므로 정확한 문자열 assert는 피하세요.
@Test
void reviewShouldMentionConventionKeyword() {
String diff = "class OrderDto { private String id; }";
String result = codeReviewAgent.review(diff);
assertThat(result.toLowerCase()).containsAnyOf("record", "convention");
}
주의: LLM을 실제로 호출하는 테스트는 비용이 많이 들고 느립니다 — 별도의 테스트 스위트로 분리하고(예:
@Tag("llm-integration")사용) 별도의 파이프라인에서만 실행하고 모든 commit에서 실행하지 마세요.
7. Agent 배포 시 흔한 오류
- 모호한 Tool description: tool 설명이 불명확하면 모델이 잘못된 tool을 선택하거나 필요할 때 tool을 호출하지 않습니다. description은 처음 읽는 낯선 사람을 위해 Javadoc을 작성하듯이 작성하세요.
- 루프 반복 횟수 제한 없음: logic에서 종료 지점이 명확하지 않으면 agent는 tool을 지속적으로 호출하는 루프에 빠질 수 있습니다. 항상 오케스트레이션 계층에서
maxIterations또는 timeout을 설정하세요. - 민감한 데이터 쓰기 권한 부여: “프로덕션 배포” 또는 “데이터 삭제” tool이 사람의 확인 단계 없이 agent에 제공되도록 하지 마세요. Agent는 읽기 권한은 넓지만 쓰기 권한은 좁아야 합니다.
- Tool 호출 프로세스 로깅 부족: agent가 잘못된 답변을 제공할 때, 디버깅을 위해 어떤 tool을 호출했는지, 어떤 파라미터로 호출했는지 알아야 합니다. 이 로그가 없으면 오류 추적이 거의 불가능합니다.
- 간단한 규칙으로 해결할 수 있는 작업에 agent 사용: “X이면 Y” 로직이 고정되어 있다면 일반적인 코드를 작성하는 것이 agent보다 빠르고, 저렴하고, 테스트하기 쉽습니다.
8. AI Agent를 사용하지 말아야 할 때
Agent는 많은 비정형 단계가 있고, 여러 소스에서 정보를 종합해야 하며, 작은 오류를 허용할 수 있는 경우(리뷰 제안, 요약)에 적합합니다. Agent는 다음 경우에 적합하지 않습니다:
- 절대적인 정확도가 필요한 작업(금액 계산, 금융 거래 처리).
- 프로세스가 고정적이고 거의 변하지 않는 작업 — rule engine 또는 workflow engine(Camunda, Temporal)이 더 안정적이고 감사하기 쉽습니다.
- 낮은 지연 시간이 필요한 시스템. 각 tool-calling 루프는 LLM으로의 추가 왕복을 소비합니다.
9. 결론
AI Agent는 올바른 곳에 적용할 때 명확한 생산성 가치를 가져옵니다: 반복되는 엔지니어링 작업, 여러 단계, 분산된 정보 종합이 필요한 경우 — 코드 리뷰, PR 요약, 버그 트리아주. “모든 것을 할 수 있는” agent를 작성하는 대신, 작고 명확한 몇 가지 tool로 시작하고, 각각에 대해 테스트를 작성한 다음, Planner(LLM)가 조율 방식을 결정하도록 해야 합니다. 가장 중요한 것은 권한을 구분하는 것입니다: agent는 읽기는 많이, 쓰기는 적게 할 수 있도록 하고, 큰 영향을 미치는 작업은 항상 검토자가 마지막 단계에 남겨두어야 합니다.