Grab이 AI Agent로 생산성 향상: 상세 설명과 예제

이 글에는 짧은 영상이 있습니다:TikTok

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는 읽기는 많이, 쓰기는 적게 할 수 있도록 하고, 큰 영향을 미치는 작업은 항상 검토자가 마지막 단계에 남겨두어야 합니다.

AI AgentSpring AIProductivityLLMAutomation