🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Function Calling으로 도구 사용 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 12. 24. · 3 Views

Function Calling으로 도구 사용 완벽 가이드

LLM이 외부 도구를 사용하는 Function Calling의 핵심 개념부터 실무 활용까지, 파일 시스템, 터미널, Git 도구를 직접 만들어보며 배우는 실전 가이드입니다. 안전한 샌드박스 구축까지 다룹니다.


목차

  1. File_System_도구
  2. Terminal_실행_도구
  3. Git_도구
  4. 도구_스키마_정의와_실행
  5. 실습_도구_세트_구축
  6. 실습_안전한_도구_실행_샌드박스
  7. 도구_실행_흐름과_에러_핸들링
  8. LLM과_도구_통합하기
  9. 병렬_도구_호출과_성능_최적화
  10. 실전_프로젝트_코드_분석_에이전트

1. File System 도구

어느 날 김개발 씨는 AI 챗봇 프로젝트를 맡게 되었습니다. 고객이 "README.md 파일 내용을 요약해줘"라고 요청하면 챗봇이 직접 파일을 읽어야 하는데, 어떻게 구현해야 할지 막막했습니다.

File System 도구는 LLM이 파일을 읽고, 쓰고, 목록을 조회할 수 있게 해주는 도구입니다. 마치 비서가 서류를 찾아주는 것처럼, LLM이 파일 시스템에 접근할 수 있게 합니다.

read, write, list 세 가지 핵심 기능으로 구성됩니다.

다음 코드를 살펴봅시다.

def read_file(path: str) -> str:
    """파일 내용을 읽어서 반환합니다"""
    with open(path, 'r', encoding='utf-8') as f:
        return f.read()

def write_file(path: str, content: str) -> str:
    """파일에 내용을 씁니다"""
    with open(path, 'w', encoding='utf-8') as f:
        f.write(content)
    return f"파일 저장 완료: {path}"

def list_files(directory: str) -> list:
    """디렉토리의 파일 목록을 반환합니다"""
    import os
    return os.listdir(directory)

김개발 씨는 입사 3개월 차 주니어 개발자입니다. 오늘도 열심히 AI 챗봇 개발을 하던 중, 팀장님으로부터 새로운 요구사항을 받았습니다.

"사용자가 파일 관련 질문을 하면 챗봇이 직접 파일을 읽고 답변할 수 있게 해주세요." 김개발 씨는 고민에 빠졌습니다. LLM은 텍스트만 처리할 수 있는데, 어떻게 파일 시스템에 접근할 수 있을까요?

선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다. "아, Function Calling을 사용하면 됩니다.

LLM이 필요할 때 우리가 만든 함수를 호출할 수 있게 하는 거죠." 그렇다면 File System 도구란 정확히 무엇일까요? 쉽게 비유하자면, File System 도구는 마치 도서관 사서와 같습니다.

당신이 "파이썬 입문서를 찾아주세요"라고 요청하면, 사서가 서가를 뒤져서 책을 찾아줍니다. 당신은 직접 서가를 뒤지지 않아도 됩니다.

이처럼 LLM도 직접 파일 시스템에 접근하는 대신, 우리가 만든 도구를 통해 파일 작업을 수행합니다. Function Calling이 없던 시절에는 어땠을까요?

개발자들은 LLM의 응답을 파싱해서 "아, 사용자가 파일을 읽고 싶어 하는구나"를 추론해야 했습니다. 정규표현식으로 파일 경로를 찾아내고, 조건문으로 분기 처리를 했습니다.

코드가 길어지고, 실수하기도 쉬웠습니다. 더 큰 문제는 새로운 도구를 추가할 때마다 파싱 로직을 다시 작성해야 한다는 점이었습니다.

프로젝트가 커질수록 이런 문제는 눈덩이처럼 불어났습니다. 바로 이런 문제를 해결하기 위해 Function Calling이 등장했습니다.

Function Calling을 사용하면 LLM이 구조화된 형태로 함수 호출 요청을 보냅니다. 또한 도구를 자동으로 선택하는 능력도 얻을 수 있습니다.

무엇보다 타입 안전성과 검증이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 read_file 함수를 보면 파일 경로를 받아서 내용을 문자열로 반환하는 것을 알 수 있습니다. encoding='utf-8'로 한글도 정상적으로 읽을 수 있습니다.

다음으로 write_file 함수에서는 파일에 내용을 쓰고 완료 메시지를 반환합니다. 마지막으로 list_files는 os.listdir로 디렉토리의 파일 목록을 리스트로 반환합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 코드 리뷰 자동화 서비스를 개발한다고 가정해봅시다.

사용자가 "main.py 파일의 코드 품질을 검토해줘"라고 요청하면, LLM이 read_file을 호출해서 코드를 읽고, 분석 결과를 write_file로 report.md에 저장할 수 있습니다. 많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 경로 검증 없이 모든 파일 접근을 허용하는 것입니다.

이렇게 하면 보안 문제가 발생할 수 있습니다. 사용자가 "/etc/passwd"를 읽으려고 시도할 수도 있습니다.

따라서 허용된 디렉토리 내에서만 작동하도록 제한해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 Function Calling이 필요했군요!" File System 도구를 제대로 이해하면 LLM이 파일을 자유롭게 다룰 수 있는 강력한 에이전트를 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 파일 읽기 전에 존재 여부를 os.path.exists()로 확인하세요

  • 대용량 파일은 청크 단위로 읽어서 메모리 오버플로우를 방지하세요
  • 절대 경로 대신 상대 경로를 사용하고, ".."을 필터링해서 디렉토리 탈출을 막으세요

2. Terminal 실행 도구

김개발 씨의 챗봇은 이제 파일을 읽을 수 있게 되었습니다. 그런데 팀장님이 또 다른 요구사항을 제시했습니다.

"npm install 같은 명령어도 챗봇이 실행할 수 있으면 좋겠어요."

Terminal 실행 도구는 LLM이 시스템 명령어를 실행할 수 있게 해주는 도구입니다. 마치 비서가 전화를 대신 걸어주는 것처럼, LLM이 셸 명령어를 대신 실행합니다.

명령어 실행, 출력 캡처, 에러 핸들링이 핵심입니다.

다음 코드를 살펴봅시다.

import subprocess

def run_command(command: str, timeout: int = 30) -> dict:
    """셸 명령어를 실행하고 결과를 반환합니다"""
    try:
        # 명령어를 실행하고 출력을 캡처합니다
        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=timeout
        )
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "returncode": result.returncode
        }
    except subprocess.TimeoutExpired:
        return {"error": "명령어 실행 시간 초과"}

김개발 씨는 File System 도구를 성공적으로 구현했습니다. 이제 챗봇은 파일을 읽고 쓸 수 있게 되었습니다.

하지만 새로운 도전이 기다리고 있었습니다. 어느 날 김개발 씨는 팀장님으로부터 이런 요청을 받았습니다.

"사용자가 '현재 Git 상태를 확인해줘'라고 하면 git status를 실행해서 보여주면 좋겠어요." 김개발 씨는 또다시 고민에 빠졌습니다. 박시니어 씨가 커피를 한 잔 건네며 말했습니다.

"이번엔 Terminal 실행 도구가 필요하겠네요. 파일 읽기만으로는 부족하니까요." Terminal 실행 도구란 무엇일까요?

쉽게 비유하자면, Terminal 실행 도구는 마치 심부름 센터와 같습니다. 당신이 "편의점에 가서 우유 사다 줘"라고 부탁하면, 심부름 기사가 대신 다녀옵니다.

당신은 직접 나가지 않아도 결과를 받을 수 있습니다. 이처럼 LLM도 직접 터미널 명령어를 실행하는 대신, 우리가 만든 도구를 통해 명령어를 실행하고 결과를 받습니다.

명령어 실행 기능이 없던 시절에는 어땠을까요? 개발자들은 특정 명령어에 대해 하드코딩된 함수를 만들어야 했습니다.

git_status(), npm_install(), pytest_run() 같은 함수들을 일일이 만들었습니다. 새로운 명령어가 필요할 때마다 함수를 추가해야 했습니다.

코드가 비대해지고, 유지보수가 어려워졌습니다. 바로 이런 문제를 해결하기 위해 범용 Terminal 실행 도구가 등장했습니다.

run_command 함수 하나로 모든 셸 명령어를 실행할 수 있습니다. 또한 표준 출력과 에러를 분리해서 캡처하는 능력도 얻을 수 있습니다.

무엇보다 타임아웃 설정으로 무한 대기를 방지한다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 subprocess.run을 보면 셸 명령어를 실행하는 것을 알 수 있습니다. shell=True로 파이프나 리다이렉션 같은 셸 기능을 사용할 수 있습니다.

capture_output=True는 stdout과 stderr를 캡처합니다. text=True는 바이트 대신 문자열로 결과를 받습니다.

timeout 매개변수로 최대 실행 시간을 제한합니다. 마지막으로 returncode, stdout, stderr를 딕셔너리로 반환해서 성공/실패 여부와 출력을 모두 확인할 수 있습니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 DevOps 자동화 챗봇을 개발한다고 가정해봅시다.

사용자가 "프로덕션 서버의 디스크 사용량을 확인해줘"라고 요청하면, LLM이 "df -h" 명령어를 실행해서 결과를 보여줄 수 있습니다. 또는 "테스트를 실행해줘"라고 하면 "pytest tests/"를 실행하고 결과를 요약해서 알려줄 수 있습니다.

많은 스타트업에서 이런 패턴으로 내부 도구를 만들고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 사용자 입력을 검증 없이 명령어에 포함시키는 것입니다. 이렇게 하면 command injection 공격에 취약해집니다.

사용자가 "file.txt; rm -rf /"를 입력하면 끔찍한 일이 벌어질 수 있습니다. 따라서 허용된 명령어 목록을 화이트리스트로 관리하거나, 위험한 문자를 필터링해야 합니다.

또 다른 주의사항은 타임아웃 설정입니다. 무한 루프에 빠진 스크립트를 실행하면 챗봇이 멈춰버릴 수 있습니다.

반드시 적절한 timeout 값을 설정하세요. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언을 듣고 김개발 씨는 Terminal 실행 도구를 구현했습니다. "이제 챗봇이 정말 많은 일을 할 수 있겠네요!" Terminal 실행 도구를 제대로 이해하면 LLM이 시스템 레벨 작업을 수행할 수 있는 강력한 에이전트를 만들 수 있습니다.

하지만 강력한 만큼 보안에 각별히 신경 써야 합니다.

실전 팁

💡 - 위험한 명령어(rm, sudo, chmod 등)는 명시적으로 차단하세요

  • 명령어 실행 전에 사용자에게 확인을 받는 것도 좋은 방법입니다
  • 실행 로그를 남겨서 나중에 문제를 추적할 수 있게 하세요

3. Git 도구

김개발 씨의 챗봇은 이제 파일도 읽고 명령어도 실행할 수 있게 되었습니다. 그런데 동료 개발자가 이런 요청을 했습니다.

"마지막 커밋이 뭐였는지 물어보면 자동으로 git log를 보여주면 좋겠어요."

Git 도구는 LLM이 버전 관리 작업을 수행할 수 있게 해주는 도구입니다. commit, diff, log 등 자주 사용하는 Git 명령어를 래핑해서 제공합니다.

코드 변경 이력을 추적하고 관리하는 핵심 기능입니다.

다음 코드를 살펴봅시다.

def git_log(max_count: int = 5) -> str:
    """최근 커밋 로그를 가져옵니다"""
    result = run_command(f"git log --oneline -n {max_count}")
    return result["stdout"]

def git_diff(file_path: str = None) -> str:
    """변경 사항을 확인합니다"""
    cmd = f"git diff {file_path}" if file_path else "git diff"
    result = run_command(cmd)
    return result["stdout"]

def git_commit(message: str, files: list = None) -> str:
    """파일을 스테이징하고 커밋합니다"""
    if files:
        for file in files:
            run_command(f"git add {file}")
    result = run_command(f'git commit -m "{message}"')
    return result["stdout"] + result["stderr"]

김개발 씨의 챗봇은 점점 똑똑해지고 있었습니다. 파일을 읽고, 명령어를 실행하는 것은 물론이고, 이제는 Git 작업도 도와줄 수 있게 되었으면 좋겠다는 요청이 들어왔습니다.

코드 리뷰 중이던 김개발 씨는 동료 개발자 이주니어 씨로부터 이런 말을 들었습니다. "챗봇한테 '마지막 5개 커밋 보여줘'라고 물어보면 자동으로 보여주면 편할 것 같아요." 김개발 씨는 이번에는 자신감이 생겼습니다.

"아, 이것도 도구로 만들면 되겠구나!" Git 도구란 정확히 무엇일까요? 쉽게 비유하자면, Git 도구는 마치 역사 기록 관리자와 같습니다.

박물관 큐레이터가 유물의 변천사를 추적하고 설명해주는 것처럼, Git 도구는 코드의 변경 이력을 추적하고 관리합니다. 언제, 누가, 무엇을, 왜 바꿨는지 모두 기록되어 있습니다.

Git 도구가 없던 시절에는 어땠을까요? 개발자들은 "git log를 실행해줘"라는 사용자 질문을 받으면, 직접 터미널에서 명령어를 실행해서 결과를 복사해야 했습니다.

또는 챗봇이 일반적인 Terminal 실행 도구를 사용했지만, Git 명령어의 복잡한 옵션을 매번 지정하기 어려웠습니다. git log --pretty=format 같은 복잡한 포맷 옵션을 기억하기도 힘들었습니다.

바로 이런 문제를 해결하기 위해 Git 전용 도구가 등장했습니다. Git 도구를 사용하면 자주 사용하는 Git 명령어를 추상화할 수 있습니다.

또한 매개변수를 단순화해서 사용하기 쉽게 만들 수 있습니다. 무엇보다 안전한 기본값 설정이라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 git_log 함수를 보면 최근 N개의 커밋을 한 줄로 보여주는 것을 알 수 있습니다.

max_count 매개변수로 개수를 조절할 수 있습니다. 다음으로 git_diff 함수에서는 특정 파일의 변경 사항을 확인하거나, 파일을 지정하지 않으면 전체 변경 사항을 보여줍니다.

마지막으로 git_commit 함수는 파일을 스테이징하고 커밋까지 한 번에 처리합니다. files 리스트를 순회하며 각 파일을 git add하고, 마지막에 commit 메시지와 함께 커밋합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 코드 리뷰 자동화 봇을 개발한다고 가정해봅시다.

사용자가 "PR의 변경 사항을 요약해줘"라고 요청하면, LLM이 git_diff를 호출해서 변경된 코드를 읽고, 자연어로 요약해서 설명할 수 있습니다. "database.py에서 쿼리 최적화를 했고, api.py에서 새로운 엔드포인트를 추가했습니다" 같은 식으로 말이죠.

또는 "최근 버그 수정 커밋들을 보여줘"라고 하면 git_log로 커밋 이력을 가져와서 필터링할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 커밋 메시지에 따옴표 처리를 안 하는 것입니다. 사용자가 입력한 커밋 메시지에 따옴표가 포함되어 있으면 셸 명령어가 깨질 수 있습니다.

따라서 메시지를 이스케이프 처리하거나, subprocess의 리스트 형태 명령어를 사용해야 합니다. 또 다른 주의사항은 강제 푸시나 리베이스 같은 위험한 작업입니다.

git push --force나 git reset --hard 같은 명령어는 데이터 손실을 일으킬 수 있으므로, 별도의 확인 절차를 거치도록 해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

Git 도구를 구현한 김개발 씨는 이주니어 씨에게 시연을 했습니다. "챗봇아, 최근 커밋 3개 보여줘." 챗봇이 즉시 git_log(3)을 호출해서 결과를 보여줬습니다.

"우와, 정말 편하네요!" Git 도구를 제대로 이해하면 버전 관리 작업을 자동화하고, 개발자의 생산성을 크게 높일 수 있습니다. 코드 변경 이력을 자연어로 질문하고 답을 얻는 것은 정말 강력한 경험입니다.

실전 팁

💡 - git log에 --pretty=format 옵션을 추가하면 커밋 정보를 더 상세하게 가져올 수 있습니다

  • git diff에 --stat 옵션을 추가하면 변경된 파일 통계를 간단히 볼 수 있습니다
  • 위험한 Git 명령어(reset, push --force 등)는 별도의 확인 도구로 분리하세요

4. 도구 스키마 정의와 실행

김개발 씨는 이제 여러 도구를 만들었습니다. 그런데 문제가 생겼습니다.

LLM이 어떤 도구를 언제 호출해야 할지 어떻게 알 수 있을까요? 박시니어 씨가 말했습니다.

"도구 스키마를 정의해야 합니다."

도구 스키마 정의는 LLM에게 도구의 사용법을 알려주는 명세서입니다. 함수 이름, 매개변수 타입, 설명 등을 JSON 형태로 정의합니다.

OpenAI Function Calling API는 이 스키마를 보고 적절한 도구를 선택합니다.

다음 코드를 살펴봅시다.

tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "파일의 내용을 읽어서 반환합니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "읽을 파일의 경로"
                    }
                },
                "required": ["path"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "run_command",
            "description": "셸 명령어를 실행하고 결과를 반환합니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "실행할 셸 명령어"
                    },
                    "timeout": {
                        "type": "integer",
                        "description": "최대 실행 시간(초)",
                        "default": 30
                    }
                },
                "required": ["command"]
            }
        }
    }
]

김개발 씨는 지금까지 read_file, write_file, run_command, git_log 등 여러 도구를 만들었습니다. 이제 이 도구들을 LLM이 사용할 수 있게 연결해야 합니다.

그런데 막상 OpenAI API를 호출하려고 하니 막막했습니다. "LLM이 어떻게 내가 만든 함수들을 알 수 있지?" 박시니어 씨가 설명했습니다.

"LLM은 당신의 코드를 직접 볼 수 없습니다. 대신 도구 스키마라는 명세서를 제공해야 합니다.

마치 API 문서처럼요." 도구 스키마란 정확히 무엇일까요? 쉽게 비유하자면, 도구 스키마는 마치 레스토랑 메뉴판과 같습니다.

메뉴판에는 음식 이름, 설명, 가격, 주재료가 적혀 있습니다. 손님은 메뉴판을 보고 무엇을 주문할지 결정합니다.

이처럼 LLM도 도구 스키마를 보고 어떤 도구를 사용할지, 어떤 매개변수를 전달할지 결정합니다. 도구 스키마가 없던 시절에는 어땠을까요?

개발자들은 프롬프트에 도구 설명을 텍스트로 적었습니다. "read_file(path) 함수는 파일을 읽습니다"처럼 말이죠.

그러면 LLM이 이 텍스트를 읽고, 함수 호출을 텍스트로 생성했습니다. "read_file('README.md')"처럼 말이죠.

개발자는 이 텍스트를 파싱해서 실제 함수를 호출해야 했습니다. 에러가 많이 발생했고, 타입 검증도 어려웠습니다.

바로 이런 문제를 해결하기 위해 구조화된 도구 스키마가 등장했습니다. 도구 스키마를 사용하면 타입 안전성이 보장됩니다.

또한 자동 검증도 가능해집니다. 무엇보다 표준화된 형식이라는 큰 이점이 있습니다.

OpenAI, Anthropic, Google 등 주요 LLM 제공자들이 모두 비슷한 스키마 형식을 지원합니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 tools 배열을 보면 여러 도구를 정의할 수 있다는 것을 알 수 있습니다. 각 도구는 type이 "function"이고, function 객체를 포함합니다.

name은 함수 이름, description은 언제 이 도구를 사용해야 하는지 설명합니다. parameters는 JSON Schema 형식으로 매개변수를 정의합니다.

properties에는 각 매개변수의 이름, 타입, 설명이 들어갑니다. required 배열에는 필수 매개변수를 명시합니다.

timeout처럼 선택적 매개변수는 default 값을 지정할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 슬랙봇을 개발한다고 가정해봅시다. 사용자가 "어제 커밋한 파일 중 tests 폴더의 것만 보여줘"라고 요청합니다.

LLM은 도구 스키마를 보고 git_log와 read_file 중 어떤 것을 사용할지 판단합니다. git_log(max_count=20)를 호출해서 커밋 목록을 가져오고, 각 커밋의 파일 경로를 파싱해서 tests 폴더에 있는 것만 필터링할 수 있습니다.

스키마가 명확하기 때문에 LLM이 올바른 도구를 선택할 확률이 높아집니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 description을 너무 간단하게 쓰는 것입니다. "파일을 읽습니다"보다는 "지정된 경로의 파일 내용을 UTF-8로 읽어서 문자열로 반환합니다.

파일이 없으면 에러를 반환합니다"처럼 구체적으로 써야 합니다. LLM이 description을 보고 도구를 선택하기 때문에, 설명이 명확할수록 정확도가 높아집니다.

또 다른 주의사항은 매개변수 타입입니다. JSON Schema의 타입은 string, integer, number, boolean, array, object로 제한됩니다.

파이썬의 Path 같은 커스텀 타입은 사용할 수 없습니다. 문자열로 받아서 내부에서 변환해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 듣고 김개발 씨는 도구 스키마를 작성했습니다.

OpenAI API에 tools 매개변수로 전달하니, LLM이 자동으로 적절한 도구를 선택해서 호출하기 시작했습니다. "드디어 완성이다!" 도구 스키마를 제대로 이해하면 LLM이 여러분의 함수들을 똑똑하게 사용할 수 있습니다.

명확한 명세서가 곧 좋은 Function Calling의 시작입니다.

실전 팁

💡 - description은 최대한 구체적으로 작성하세요. 예시를 포함하면 더 좋습니다

  • enum을 사용해서 선택지를 제한하면 LLM이 잘못된 값을 전달하는 것을 방지할 수 있습니다
  • 복잡한 객체는 중첩된 properties로 표현할 수 있습니다

5. 실습 도구 세트 구축

김개발 씨는 이제 기본 도구들을 만들 수 있게 되었습니다. 팀장님이 말했습니다.

"실무에서 정말 유용한 도구 세트를 만들어봐요. 최소 10개는 있어야 제대로 된 에이전트라고 할 수 있죠."

실전에서 사용할 수 있는 다양한 도구를 모아서 도구 세트를 구축합니다. 파일 검색, 코드 분석, 데이터 처리, API 호출 등 다양한 카테고리의 도구를 만들어서 LLM의 능력을 확장합니다.

다음 코드를 살펴봅시다.

# 파일 검색 도구
def search_files(pattern: str, directory: str = ".") -> list:
    """패턴에 맞는 파일을 검색합니다"""
    import glob
    return glob.glob(f"{directory}/**/{pattern}", recursive=True)

# 코드 분석 도구
def count_lines(file_path: str) -> dict:
    """코드 줄 수를 세고 통계를 반환합니다"""
    with open(file_path, 'r') as f:
        lines = f.readlines()
        return {
            "total": len(lines),
            "non_empty": len([l for l in lines if l.strip()]),
            "comments": len([l for l in lines if l.strip().startswith('#')])
        }

# 데이터 처리 도구
def parse_json(file_path: str) -> dict:
    """JSON 파일을 파싱합니다"""
    import json
    with open(file_path, 'r') as f:
        return json.load(f)

# HTTP 요청 도구
def fetch_url(url: str, method: str = "GET") -> dict:
    """HTTP 요청을 보내고 응답을 반환합니다"""
    import requests
    response = requests.request(method, url)
    return {"status": response.status_code, "body": response.text}

# 환경 변수 도구
def get_env(key: str) -> str:
    """환경 변수 값을 가져옵니다"""
    import os
    return os.getenv(key, "")

김개발 씨의 챗봇은 이제 기본적인 파일 읽기, 명령어 실행, Git 작업이 가능해졌습니다. 하지만 실제 업무에서는 더 다양한 작업이 필요했습니다.

팀장님이 회의실로 김개발 씨를 불렀습니다. "좋은 시작이에요.

하지만 실무에서 정말 유용하려면 더 많은 도구가 필요합니다. 파일 검색, 코드 분석, API 호출 같은 것들이요." 김개발 씨는 고개를 끄덕였습니다.

"네, 어떤 도구들이 필요할까요?" 도구 세트 구축이란 무엇일까요? 쉽게 비유하자면, 도구 세트는 마치 만능 공구함과 같습니다.

집을 수리할 때 드라이버만 있으면 나사는 조이지만, 못은 박을 수 없습니다. 망치, 렌치, 드릴 등 다양한 도구가 모여야 어떤 작업이든 할 수 있습니다.

이처럼 LLM 에이전트도 다양한 도구가 모여야 복잡한 실무 작업을 처리할 수 있습니다. 도구가 부족하던 시절에는 어땠을까요?

초기 LLM 에이전트들은 3-4개의 기본 도구만 가지고 있었습니다. 사용자가 "프로젝트에서 TODO 주석이 있는 파일을 찾아줘"라고 요청하면, 에이전트는 할 수 없다고 답했습니다.

또는 grep 명령어를 실행하라고 했지만, 결과를 제대로 파싱하지 못했습니다. 사용자의 요구사항과 에이전트의 능력 사이에 큰 간격이 있었습니다.

바로 이런 문제를 해결하기 위해 풍부한 도구 세트가 등장했습니다. 도구 세트를 구축하면 다양한 작업을 처리할 수 있습니다.

또한 도구를 조합해서 복잡한 워크플로우 구현이 가능해집니다. 무엇보다 실무 생산성 향상이라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 search_files 함수를 보면 glob 패턴으로 파일을 검색하는 것을 알 수 있습니다.

".py"나 "test_.js" 같은 패턴을 사용할 수 있습니다. 다음으로 count_lines는 코드 줄 수를 세고, 빈 줄과 주석 수도 함께 반환합니다.

코드 품질 분석에 유용합니다. parse_json은 JSON 파일을 파이썬 딕셔너리로 변환합니다.

설정 파일이나 데이터 파일을 읽을 때 사용합니다. fetch_url은 HTTP 요청을 보냅니다.

외부 API를 호출하거나 웹페이지를 가져올 때 사용합니다. get_env는 환경 변수를 읽습니다.

API 키나 설정 값을 안전하게 가져올 수 있습니다. 실제로 더 많은 도구를 만들 수 있습니다.

예를 들어 calculate_expression으로 수식을 계산하거나, compress_file로 파일을 압축하거나, send_email로 이메일을 보낼 수 있습니다. create_directory, delete_file, move_file 같은 파일 시스템 관리 도구도 유용합니다.

extract_imports로 코드의 import 구문을 분석하거나, run_tests로 테스트를 실행할 수도 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 코드 마이그레이션 프로젝트를 진행한다고 가정해봅시다. 사용자가 "프로젝트의 모든 파이썬 파일에서 deprecated된 함수 사용을 찾아줘"라고 요청합니다.

에이전트는 다음과 같이 작동합니다. 먼저 search_files("*.py")로 모든 파이썬 파일을 찾습니다.

각 파일에 대해 read_file로 내용을 읽습니다. 정규표현식이나 AST 파싱으로 deprecated 함수 호출을 찾습니다.

결과를 정리해서 사용자에게 보고합니다. 이렇게 여러 도구를 조합하면 복잡한 작업을 자동화할 수 있습니다.

또 다른 예시로, "README.md에 설명된 API 엔드포인트가 실제로 동작하는지 확인해줘"라는 요청을 받았다고 합시다. 에이전트는 read_file("README.md")로 문서를 읽고, 정규표현식으로 URL을 추출하고, 각 URL에 대해 fetch_url을 호출해서 상태 코드를 확인합니다.

동작하지 않는 엔드포인트가 있으면 사용자에게 알려줍니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 도구를 만드는 것입니다. 도구가 100개가 넘어가면 LLM이 올바른 도구를 선택하기 어려워집니다.

비슷한 기능의 도구를 통합하고, 자주 사용하는 도구에 집중하세요. 보통 10-30개 정도가 적절합니다.

또 다른 주의사항은 에러 처리입니다. 모든 도구는 예외를 적절히 처리하고, 의미 있는 에러 메시지를 반환해야 합니다.

"에러 발생"보다는 "파일을 찾을 수 없습니다: /path/to/file.txt"처럼 구체적으로 알려주세요. 다시 김개발 씨의 이야기로 돌아가 봅시다.

김개발 씨는 일주일 동안 15개의 도구를 만들었습니다. 파일 시스템, Git, 코드 분석, 데이터 처리, HTTP 요청 등 다양한 카테고리를 커버했습니다.

팀원들에게 시연을 했더니 모두 감탄했습니다. "이제 정말 실용적이네요!" 도구 세트 구축을 제대로 이해하면 LLM 에이전트를 실무에서 바로 사용할 수 있는 수준으로 끌어올릴 수 있습니다.

다양한 도구가 모여야 진짜 똑똑한 에이전트가 됩니다.

실전 팁

💡 - 도구를 카테고리별로 분류해서 관리하세요 (파일, Git, 네트워크, 데이터 등)

  • 각 도구에 unit test를 작성해서 신뢰성을 높이세요
  • 자주 사용하는 도구 조합은 별도의 복합 도구로 만드는 것도 좋습니다

6. 실습 안전한 도구 실행 샌드박스

김개발 씨의 도구 세트는 이제 실무에서 사용할 수 있을 정도로 강력해졌습니다. 그런데 보안팀에서 연락이 왔습니다.

"도구들이 시스템에 위험한 작업을 할 수 있어요. 샌드박스를 만들어야 합니다."

안전한 도구 실행 샌드박스는 LLM이 호출하는 도구들을 격리된 환경에서 실행해서 시스템 보안을 지킵니다. 경로 검증, 명령어 화이트리스트, 리소스 제한 등을 적용해서 악의적이거나 실수로 인한 위험한 작업을 방지합니다.

다음 코드를 살펴봅시다.

import os
from pathlib import Path

class Sandbox:
    def __init__(self, allowed_dirs: list, max_file_size: int = 10_000_000):
        """샌드박스를 초기화합니다"""
        self.allowed_dirs = [Path(d).resolve() for d in allowed_dirs]
        self.max_file_size = max_file_size
        # 위험한 명령어 블랙리스트
        self.blocked_commands = ['rm -rf', 'sudo', 'chmod 777', 'mkfs']

    def validate_path(self, path: str) -> bool:
        """경로가 허용된 디렉토리 내에 있는지 검증합니다"""
        resolved = Path(path).resolve()
        return any(str(resolved).startswith(str(d)) for d in self.allowed_dirs)

    def validate_command(self, command: str) -> bool:
        """명령어가 안전한지 검증합니다"""
        return not any(blocked in command for blocked in self.blocked_commands)

    def safe_read_file(self, path: str) -> str:
        """안전하게 파일을 읽습니다"""
        if not self.validate_path(path):
            raise PermissionError(f"경로 접근 거부: {path}")
        if os.path.getsize(path) > self.max_file_size:
            raise ValueError(f"파일이 너무 큽니다: {path}")
        with open(path, 'r', encoding='utf-8') as f:
            return f.read()

김개발 씨의 챗봇은 이제 15개의 강력한 도구를 가지고 있었습니다. 팀원들은 매우 만족했고, 실제 업무에 사용하기 시작했습니다.

모든 것이 순조로워 보였습니다. 그런데 어느 날, 보안팀의 최시큐어 팀장이 찾아왔습니다.

"김개발 씨, 코드를 검토해봤는데 심각한 보안 문제가 있어요." 김개발 씨는 깜짝 놀랐습니다. "무슨 문제인가요?" 최시큐어 팀장이 설명했습니다.

"사용자가 악의적인 입력을 하면 시스템 전체가 위험해질 수 있어요. 예를 들어 '/etc/passwd'를 읽으려고 하거나, 'rm -rf /'를 실행하려고 할 수 있습니다." 샌드박스란 정확히 무엇일까요?

쉽게 비유하자면, 샌드박스는 마치 어린이 놀이터의 모래 놀이 공간과 같습니다. 아이들이 모래 안에서는 자유롭게 놀지만, 정해진 경계를 넘어서 도로로 나가면 안 됩니다.

울타리가 아이들을 보호합니다. 이처럼 샌드박스는 LLM과 도구들이 허용된 범위 내에서만 작동하도록 제한합니다.

샌드박스가 없던 시절에는 어땠을까요? 초기 LLM 에이전트들은 모든 파일에 접근할 수 있었습니다.

/etc/passwd도 읽을 수 있고, /home 디렉토리도 자유롭게 탐색할 수 있었습니다. 악의적인 사용자가 "모든 파일을 삭제해줘"라고 요청하면, 정말로 삭제될 수도 있었습니다.

프로덕션 환경에서는 사용할 수 없는 수준이었습니다. 바로 이런 문제를 해결하기 위해 샌드박스가 등장했습니다.

샌드박스를 사용하면 경로 접근을 제한할 수 있습니다. 또한 위험한 명령어를 차단하는 능력도 얻을 수 있습니다.

무엇보다 리소스 사용량을 제한해서 DoS 공격을 방지한다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 Sandbox 클래스를 보면 허용된 디렉토리 목록과 최대 파일 크기를 설정하는 것을 알 수 있습니다. Path.resolve()로 절대 경로로 변환해서 ".." 같은 트릭을 방지합니다.

blocked_commands 리스트에는 위험한 명령어를 정의합니다. validate_path 메서드는 경로가 allowed_dirs 중 하나로 시작하는지 확인합니다.

이렇게 하면 /etc나 /home 같은 민감한 디렉토리 접근을 막을 수 있습니다. validate_command는 명령어에 블랙리스트 문자열이 포함되어 있는지 확인합니다.

safe_read_file은 경로 검증과 파일 크기 검증을 모두 수행한 후에만 파일을 읽습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 코드 리뷰 봇을 프로덕션에 배포한다고 가정해봅시다. 봇은 /app/projects 디렉토리 내의 프로젝트만 접근할 수 있어야 합니다.

Sandbox를 초기화할 때 allowed_dirs=['/app/projects']로 설정합니다. 사용자가 "프로젝트 파일을 분석해줘"라고 요청하면, 봇은 /app/projects 내의 파일만 읽을 수 있습니다.

만약 악의적인 사용자가 "../../../etc/passwd"를 읽으려고 시도하면, validate_path가 False를 반환하고 PermissionError가 발생합니다. 또 다른 예시로, 셸 명령어 실행 시 샌드박스를 적용할 수 있습니다.

run_command를 호출하기 전에 validate_command로 검증합니다. "git status"는 허용되지만, "rm -rf /"는 차단됩니다.

더 나아가서 Docker 컨테이너 안에서 도구를 실행하면 완벽한 격리를 달성할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 블랙리스트만 믿는 것입니다. 블랙리스트는 우회가 가능합니다.

"rm -rf" 대신 "r""m -rf"를 입력하거나, Base64 인코딩을 사용할 수 있습니다. 더 안전한 방법은 화이트리스트입니다.

허용된 명령어 목록을 정의하고, 그 외에는 모두 차단하는 방식입니다. 또 다른 주의사항은 심볼릭 링크입니다.

허용된 디렉토리 안에 심볼릭 링크를 만들어서 외부 파일을 가리킬 수 있습니다. Path.resolve()는 심볼릭 링크를 따라가므로, 링크를 검증하는 로직도 추가해야 합니다.

리소스 제한도 중요합니다. 파일 크기뿐만 아니라 실행 시간, 메모리 사용량, 네트워크 대역폭도 제한해야 합니다.

무한 루프를 실행하거나 거대한 파일을 다운로드하는 것을 막아야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

최시큐어 팀장의 조언을 듣고 김개발 씨는 Sandbox 클래스를 만들었습니다. 모든 도구를 샌드박스로 래핑했습니다.

이제 read_file 대신 sandbox.safe_read_file을 사용합니다. 보안팀의 재검토를 통과했고, 드디어 프로덕션에 배포할 수 있게 되었습니다.

"보안이 이렇게 중요한 줄 몰랐어요." 김개발 씨가 말했습니다. 최시큐어 팀장이 웃으며 대답했습니다.

"강력한 도구일수록 더 철저한 보안이 필요합니다. 샌드박스는 선택이 아니라 필수예요." 안전한 도구 실행 샌드박스를 제대로 이해하면 LLM 에이전트를 프로덕션 환경에서 안전하게 운영할 수 있습니다.

기능만큼이나 보안도 중요합니다.

실전 팁

💡 - 화이트리스트 방식이 블랙리스트보다 안전합니다

  • Docker나 VM 같은 OS 레벨 격리를 추가로 고려하세요
  • 모든 도구 호출을 로깅해서 나중에 감사할 수 있게 하세요
  • 사용자별로 권한을 다르게 설정하는 것도 좋은 방법입니다

7. 도구 실행 흐름과 에러 핸들링

김개발 씨의 챗봇은 이제 샌드박스로 보호받으며 안전하게 작동하고 있었습니다. 그런데 사용자들이 불만을 제기하기 시작했습니다.

"에러가 나면 무슨 문제인지 모르겠어요."

도구 실행 흐름은 LLM이 도구를 선택하고, 매개변수를 결정하고, 도구를 실행하고, 결과를 해석하는 전체 과정입니다. 에러 핸들링을 통해 예외 상황을 우아하게 처리하고, 사용자에게 의미 있는 피드백을 제공합니다.

다음 코드를 살펴봅시다.

from typing import Callable, Any
import traceback

class ToolExecutor:
    def __init__(self, tools: dict[str, Callable], sandbox: Sandbox):
        """도구 실행기를 초기화합니다"""
        self.tools = tools
        self.sandbox = sandbox

    def execute(self, tool_name: str, parameters: dict) -> dict:
        """도구를 실행하고 결과를 반환합니다"""
        try:
            # 1. 도구 존재 여부 확인
            if tool_name not in self.tools:
                return {
                    "success": False,
                    "error": f"도구를 찾을 수 없습니다: {tool_name}"
                }

            # 2. 도구 실행 전 로깅
            print(f"[도구 실행] {tool_name} with {parameters}")

            # 3. 실제 도구 실행
            tool_func = self.tools[tool_name]
            result = tool_func(**parameters)

            # 4. 성공 응답 반환
            return {
                "success": True,
                "result": result
            }

        except PermissionError as e:
            # 권한 에러 처리
            return {"success": False, "error": f"권한 거부: {str(e)}"}
        except FileNotFoundError as e:
            # 파일 없음 에러 처리
            return {"success": False, "error": f"파일을 찾을 수 없음: {str(e)}"}
        except Exception as e:
            # 기타 모든 에러 처리
            return {
                "success": False,
                "error": f"실행 중 에러 발생: {str(e)}",
                "traceback": traceback.format_exc()
            }

김개발 씨의 챗봇은 드디어 프로덕션에 배포되었습니다. 처음 며칠은 순조로웠습니다.

사용자들이 신기해하며 여러 질문을 했고, 챗봇은 잘 대답했습니다. 그런데 일주일 후, 고객 지원팀에서 연락이 왔습니다.

"사용자들이 에러 메시지를 이해하지 못하고 있어요. '에러 발생'이라고만 나오면 뭘 어떻게 해야 할지 모르겠대요." 김개발 씨는 로그를 확인했습니다.

파일을 찾을 수 없거나, 권한이 없거나, 타임아웃이 발생하는 등 다양한 에러가 있었습니다. 하지만 모든 에러가 "에러 발생"으로 표시되고 있었습니다.

박시니어 씨가 조언했습니다. "에러 핸들링을 제대로 해야 합니다.

각 에러 타입별로 명확한 메시지를 제공하세요." 도구 실행 흐름이란 정확히 무엇일까요? 쉽게 비유하자면, 도구 실행 흐름은 마치 레스토랑의 주문 처리 과정과 같습니다.

손님이 메뉴를 주문하면, 웨이터가 주문을 받아서 주방에 전달하고, 요리사가 음식을 만들고, 다시 웨이터가 손님에게 가져다줍니다. 중간에 재료가 떨어지면 웨이터가 손님에게 "죄송합니다.

이 메뉴는 품절입니다"라고 알려줍니다. 이처럼 도구 실행도 여러 단계로 이루어지며, 각 단계에서 에러가 발생할 수 있습니다.

에러 핸들링이 없던 시절에는 어땠을까요? 초기 Function Calling 구현들은 에러가 나면 그냥 프로그램이 멈췄습니다.

또는 "Error"라는 한 단어만 반환했습니다. 사용자는 무엇이 잘못되었는지 알 수 없었고, LLM도 에러를 복구할 방법을 찾지 못했습니다.

개발자는 매번 로그를 뒤져서 문제를 파악해야 했습니다. 바로 이런 문제를 해결하기 위해 체계적인 에러 핸들링이 등장했습니다.

에러 핸들링을 제대로 하면 사용자가 문제를 이해할 수 있습니다. 또한 LLM이 에러를 보고 다른 방법을 시도할 수 있습니다.

무엇보다 디버깅이 훨씬 쉬워진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 ToolExecutor 클래스를 보면 도구 딕셔너리와 샌드박스를 받아서 초기화하는 것을 알 수 있습니다. execute 메서드는 도구 이름과 매개변수를 받습니다.

첫 번째 단계로 도구가 존재하는지 확인합니다. 존재하지 않으면 명확한 에러 메시지를 반환합니다.

두 번째 단계로 실행 전에 로그를 남깁니다. 나중에 문제를 추적할 때 유용합니다.

세 번째 단계로 실제 도구 함수를 호출합니다. 성공하면 success=True와 함께 결과를 반환합니다.

에러 처리 부분을 보면 여러 except 블록이 있습니다. PermissionError는 권한 문제를 의미합니다.

"권한 거부: 경로 접근 불가"처럼 구체적으로 알려줍니다. FileNotFoundError는 파일이 없을 때 발생합니다.

"파일을 찾을 수 없음: README.md"처럼 어떤 파일이 문제인지 명시합니다. 마지막 except는 모든 기타 에러를 잡습니다.

traceback을 포함해서 디버깅을 도와줍니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 사용자가 "config.json 파일을 읽어줘"라고 요청했는데, 파일이 없다고 가정해봅시다. ToolExecutor는 FileNotFoundError를 잡아서 "파일을 찾을 수 없음: config.json"을 반환합니다.

LLM은 이 에러 메시지를 보고 "config.json 파일이 존재하지 않습니다. 파일 이름을 확인하시거나, 파일을 생성하시겠어요?"라고 사용자에게 물어볼 수 있습니다.

또 다른 예시로, 사용자가 "/etc/passwd를 읽어줘"라고 요청하면 PermissionError가 발생합니다. LLM은 "죄송합니다.

해당 파일에 접근할 권한이 없습니다. 프로젝트 디렉토리 내의 파일만 읽을 수 있습니다"라고 설명할 수 있습니다.

더 나아가서, 재시도 로직을 추가할 수도 있습니다. 네트워크 에러가 발생하면 3번까지 재시도하고, 그래도 안 되면 포기합니다.

타임아웃 에러가 나면 더 긴 타임아웃으로 다시 시도할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 에러 메시지에 민감한 정보를 포함하는 것입니다. 내부 파일 경로, API 키, 데이터베이스 스키마 같은 것들이 에러 메시지에 노출되면 보안 문제가 됩니다.

에러 메시지를 sanitize해서 안전한 정보만 포함하도록 하세요. 또 다른 주의사항은 에러를 너무 일반화하는 것입니다.

"에러 발생"보다는 "파일 읽기 실패: 권한 없음"이 훨씬 유용합니다. 구체적인 에러 메시지가 문제 해결을 빠르게 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 김개발 씨는 ToolExecutor 클래스를 만들어서 모든 도구 호출을 래핑했습니다.

각 에러 타입별로 명확한 메시지를 제공했습니다. 며칠 후, 고객 지원팀에서 다시 연락이 왔습니다.

"에러 관련 문의가 많이 줄었어요. 사용자들이 이제 스스로 문제를 해결할 수 있대요!" 김개발 씨는 뿌듯했습니다.

"좋은 에러 메시지가 이렇게 중요한 줄 몰랐어요." 도구 실행 흐름과 에러 핸들링을 제대로 이해하면 사용자 경험이 크게 향상됩니다. 에러는 피할 수 없지만, 어떻게 처리하느냐가 중요합니다.

실전 팁

💡 - 에러 메시지는 "무엇이 잘못되었는지", "왜 그런지", "어떻게 해결할지" 세 가지를 포함하세요

  • 에러 코드를 정의해서 에러를 분류하면 통계 분석이 쉬워집니다
  • 재시도 로직에는 exponential backoff를 적용해서 서버 부하를 줄이세요

8. LLM과 도구 통합하기

김개발 씨는 이제 도구도 만들고, 샌드박스도 구축하고, 에러 핸들링도 했습니다. 마지막 퍼즐 조각이 남았습니다.

실제 LLM API와 통합하는 것입니다.

LLM과 도구 통합은 OpenAI나 Anthropic 같은 LLM API에 도구 스키마를 전달하고, LLM의 도구 호출 요청을 받아서 실제 함수를 실행하고, 결과를 다시 LLM에게 전달하는 전체 루프를 구현하는 것입니다.

다음 코드를 살펴봅시다.

from openai import OpenAI

client = OpenAI()

def chat_with_tools(user_message: str, tools: list, tool_executor: ToolExecutor):
    """도구를 사용할 수 있는 챗봇을 실행합니다"""
    messages = [{"role": "user", "content": user_message}]

    while True:
        # LLM에게 메시지와 도구 목록을 전달
        response = client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            tools=tools
        )

        message = response.choices[0].message

        # 도구 호출이 없으면 최종 답변 반환
        if not message.tool_calls:
            return message.content

        # 도구 호출 처리
        messages.append(message)

        for tool_call in message.tool_calls:
            # 도구 실행
            result = tool_executor.execute(
                tool_call.function.name,
                eval(tool_call.function.arguments)
            )

            # 결과를 메시지에 추가
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result)
            })

김개발 씨는 지금까지 모든 준비를 마쳤습니다. 도구들이 준비되었고, 샌드박스가 작동하고, 에러 핸들링도 완벽했습니다.

하지만 한 가지가 빠져 있었습니다. "이제 실제로 OpenAI API와 연결해야 하는데..." 김개발 씨는 API 문서를 펼쳐놓고 고민했습니다.

박시니어 씨가 다가왔습니다. "이제 마지막 단계예요.

LLM이 도구를 호출하고, 결과를 받고, 다시 생각하는 루프를 만들어야 합니다." LLM과 도구 통합이란 정확히 무엇일까요? 쉽게 비유하자면, LLM과 도구 통합은 마치 탐정과 조수의 협업과 같습니다.

탐정(LLM)이 "용의자의 알리바이를 확인해 봐"라고 지시하면, 조수(도구)가 현장에 가서 조사하고 돌아옵니다. 탐정은 그 정보를 바탕으로 다시 생각하고, "그럼 이번엔 CCTV 기록을 확인해 봐"라고 지시합니다.

이 과정이 반복되면서 사건이 해결됩니다. 통합이 없던 시절에는 어땠을까요?

개발자들은 LLM을 한 번 호출하고, 응답을 파싱하고, 도구를 실행하고, 결과를 다시 프롬프트에 넣고, LLM을 다시 호출하는 과정을 수동으로 했습니다. 코드가 복잡하고 버그가 많았습니다.

도구를 여러 번 호출해야 하는 경우 로직이 꼬이기 쉬웠습니다. 바로 이런 문제를 해결하기 위해 표준화된 통합 패턴이 등장했습니다.

통합 패턴을 사용하면 자동으로 도구 호출 루프를 처리할 수 있습니다. 또한 여러 도구를 순차적으로 호출하는 능력도 얻을 수 있습니다.

무엇보다 코드가 깔끔해진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 chat_with_tools 함수를 보면 사용자 메시지, 도구 목록, 도구 실행기를 받는 것을 알 수 있습니다. messages 배열로 대화 이력을 관리합니다.

while True 무한 루프로 도구 호출이 끝날 때까지 반복합니다. client.chat.completions.create로 LLM API를 호출하고, tools 매개변수로 도구 스키마를 전달합니다.

LLM은 응답에 tool_calls를 포함할 수 있습니다. 만약 tool_calls가 없으면 LLM이 최종 답변을 생성한 것이므로 내용을 반환하고 종료합니다.

tool_calls가 있으면 각 호출을 순회하며 처리합니다. tool_executor.execute로 실제 도구를 실행합니다.

결과를 role="tool"인 메시지로 만들어서 messages에 추가합니다. 그리고 루프를 계속해서 LLM에게 다시 전달합니다.

LLM은 도구 실행 결과를 보고 다음 행동을 결정합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 사용자가 "프로젝트의 파이썬 파일 중 테스트 커버리지가 낮은 파일을 찾아줘"라고 요청한다고 가정해봅시다. LLM은 다음과 같이 작동합니다.

첫 번째 턴: LLM이 "먼저 모든 파이썬 파일을 찾아야겠다"고 생각하고 search_files("*.py")를 호출합니다. 도구가 파일 목록을 반환합니다.

두 번째 턴: LLM이 결과를 받고 "이제 각 파일의 테스트 커버리지를 확인해야겠다"고 생각합니다. run_command("pytest --cov")를 호출합니다.

커버리지 리포트가 반환됩니다. 세 번째 턴: LLM이 리포트를 분석하고 "database.py가 커버리지 40%로 가장 낮네요"라고 사용자에게 답변합니다.

tool_calls가 없으므로 루프가 종료됩니다. 이렇게 여러 단계를 거쳐서 복잡한 작업을 수행할 수 있습니다.

각 단계는 자동으로 처리되고, 개발자는 도구만 제공하면 됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 무한 루프를 방지하지 않는 것입니다. LLM이 계속 도구를 호출하면 비용이 폭발하고 시간도 오래 걸립니다.

최대 반복 횟수를 설정해서 예를 들어 10번 이상 반복하면 강제로 종료하도록 하세요. 또 다른 주의사항은 도구 호출 결과의 크기입니다.

도구가 수천 줄의 로그를 반환하면 토큰을 너무 많이 소비합니다. 결과를 요약하거나 잘라내는 로직을 추가하세요.

그리고 tool_call.function.arguments는 JSON 문자열이므로 json.loads로 파싱해야 합니다. 위 코드에서는 eval을 사용했지만, 실제로는 json.loads가 더 안전합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 김개발 씨는 chat_with_tools 함수를 완성했습니다.

처음으로 전체 시스템을 실행해 봤습니다. "README.md 파일을 읽고 요약해줘"라고 입력했습니다.

LLM이 read_file을 호출하고, 파일 내용을 받고, 요약해서 답변했습니다. "와, 정말 작동한다!" 팀원들을 불러서 시연을 했습니다.

"지난 5개 커밋에서 변경된 파일을 알려줘"라고 물었더니, LLM이 git_log를 호출하고, 각 커밋의 diff를 읽고, 변경된 파일 목록을 정리해서 보여줬습니다. 박수가 터져 나왔습니다.

LLM과 도구 통합을 제대로 이해하면 정말 똑똑한 에이전트를 만들 수 있습니다. LLM의 추론 능력과 도구의 실행 능력이 결합되면 무한한 가능성이 열립니다.

실전 팁

💡 - 최대 반복 횟수를 설정해서 무한 루프를 방지하세요 (보통 5-10회)

  • 도구 실행 결과가 너무 길면 요약하거나 첫 N줄만 전달하세요
  • LLM에게 "도구를 신중하게 선택하라"는 시스템 프롬프트를 추가하면 불필요한 호출을 줄일 수 있습니다

9. 병렬 도구 호출과 성능 최적화

김개발 씨의 챗봇은 이제 완벽하게 작동했습니다. 그런데 사용자들이 또 다른 불만을 제기했습니다.

"답변이 너무 느려요. 파일 10개를 읽는데 왜 이렇게 오래 걸리나요?"

병렬 도구 호출은 독립적인 여러 도구를 동시에 실행해서 속도를 크게 향상시킵니다. 또한 캐싱, 결과 압축, 불필요한 호출 제거 등의 최적화 기법으로 성능과 비용을 개선합니다.

다음 코드를 살펴봅시다.

import asyncio
from concurrent.futures import ThreadPoolExecutor

class ParallelToolExecutor:
    def __init__(self, tools: dict, sandbox: Sandbox, max_workers: int = 5):
        """병렬 도구 실행기를 초기화합니다"""
        self.tools = tools
        self.sandbox = sandbox
        self.executor = ThreadPoolExecutor(max_workers=max_workers)
        self.cache = {}  # 결과 캐싱

    def execute_parallel(self, tool_calls: list) -> list:
        """여러 도구를 병렬로 실행합니다"""
        results = []

        with ThreadPoolExecutor(max_workers=len(tool_calls)) as executor:
            # 모든 도구를 동시에 실행
            futures = [
                executor.submit(self._execute_one, call)
                for call in tool_calls
            ]

            # 결과 수집
            for future in futures:
                results.append(future.result())

        return results

    def _execute_one(self, tool_call: dict) -> dict:
        """하나의 도구를 실행합니다 (캐싱 포함)"""
        cache_key = f"{tool_call['name']}:{str(tool_call['params'])}"

        # 캐시 확인
        if cache_key in self.cache:
            return {"cached": True, "result": self.cache[cache_key]}

        # 실제 실행
        tool_func = self.tools[tool_call['name']]
        result = tool_func(**tool_call['params'])

        # 캐시 저장
        self.cache[cache_key] = result
        return {"cached": False, "result": result}

김개발 씨의 챗봇은 기능적으로는 완벽했습니다. 모든 도구가 작동하고, 에러도 잘 처리하고, LLM과의 통합도 매끄러웠습니다.

하지만 성능 문제가 발생했습니다. 사용자 이주니어 씨가 불만을 제기했습니다.

"10개 파이썬 파일을 분석해달라고 했는데 1분이나 걸렸어요. 각 파일을 순서대로 읽어서 그런 것 같아요." 김개발 씨는 로그를 확인했습니다.

정말로 read_file을 10번 순차적으로 호출하고 있었습니다. 각 호출이 1초씩 걸려서 총 10초가 소요되었습니다.

박시니어 씨가 조언했습니다. "병렬 처리를 해야 합니다.

파일 10개를 동시에 읽으면 1초면 끝나요." 병렬 도구 호출이란 정확히 무엇일까요? 쉽게 비유하자면, 병렬 도구 호출은 마치 여러 직원이 동시에 일하는 것과 같습니다.

한 명의 직원이 서류 10개를 순서대로 복사하면 10분이 걸리지만, 직원 10명이 동시에 복사하면 1분이면 끝납니다. 이처럼 독립적인 작업들은 동시에 실행해서 시간을 절약할 수 있습니다.

병렬 처리가 없던 시절에는 어땠을까요? 초기 Function Calling 구현들은 모든 도구를 순차적으로 실행했습니다.

read_file("a.py"), read_file("b.py"), read_file("c.py")를 차례로 호출했습니다. 파일이 많아질수록 시간이 선형적으로 증가했습니다.

사용자는 답변을 기다리다가 지쳐서 포기하기도 했습니다. 바로 이런 문제를 해결하기 위해 병렬 도구 호출이 등장했습니다.

병렬 처리를 사용하면 속도가 몇 배 빨라집니다. 또한 사용자 경험이 크게 개선됩니다.

무엇보다 동일한 시간에 더 많은 작업을 처리할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 ParallelToolExecutor 클래스를 보면 ThreadPoolExecutor를 사용하는 것을 알 수 있습니다. max_workers로 동시 실행 개수를 제한합니다.

cache 딕셔너리로 이전 결과를 저장합니다. execute_parallel 메서드는 tool_calls 리스트를 받아서 각각을 별도 스레드에서 실행합니다.

executor.submit으로 작업을 제출하고, future.result()로 결과를 수집합니다. _execute_one 메서드는 실제 도구를 실행하기 전에 캐시를 확인합니다.

cache_key는 도구 이름과 매개변수를 조합해서 만듭니다. 동일한 호출이 반복되면 캐시된 결과를 즉시 반환합니다.

캐시에 없으면 도구를 실행하고 결과를 저장합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 사용자가 "프로젝트의 모든 파이썬 파일에서 TODO 주석을 찾아줘"라고 요청한다고 가정해봅시다. LLM은 먼저 search_files("*.py")로 파일 목록을 가져옵니다.

100개의 파일이 있다고 합시다. 순차 처리 방식이라면 read_file을 100번 순서대로 호출합니다.

각 호출이 0.5초 걸린다면 총 50초가 소요됩니다. 병렬 처리 방식이라면 100개의 read_file을 동시에 실행합니다.

max_workers=10이라면 10개씩 묶어서 실행합니다. 총 10번의 배치로 나뉘어서 5초면 끝납니다.

10배 빨라졌습니다. 게다가 캐싱을 활용하면 더 빨라집니다.

사용자가 "README.md를 다시 읽어줘"라고 요청하면, 캐시에서 즉시 반환합니다. API 호출이나 파일 I/O를 아낄 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 도구를 병렬로 실행하는 것입니다.

어떤 도구는 순서가 중요합니다. 예를 들어 write_file과 read_file을 병렬로 실행하면 race condition이 발생할 수 있습니다.

의존성이 있는 도구는 순차적으로 실행해야 합니다. 또 다른 주의사항은 리소스 소비입니다.

max_workers를 너무 크게 설정하면 메모리나 파일 디스크립터가 고갈될 수 있습니다. 적절한 값은 보통 CPU 코어 수의 2배 정도입니다.

캐싱도 주의가 필요합니다. 파일 내용이 변경되었는데 캐시된 결과를 반환하면 문제가 됩니다.

TTL(Time To Live)을 설정하거나, 파일의 수정 시간을 체크해서 캐시를 무효화해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

김개발 씨는 ParallelToolExecutor를 구현했습니다. 이주니어 씨에게 다시 테스트를 부탁했습니다.

"이제 파일 10개를 1초 만에 읽어요!" 이주니어 씨가 감탄했습니다. 김개발 씨는 뿌듯했습니다.

"성능 최적화가 이렇게 중요한 줄 몰랐어요." 병렬 도구 호출과 성능 최적화를 제대로 이해하면 사용자가 답답함 없이 빠르게 결과를 받을 수 있습니다. 속도는 곧 사용자 만족도입니다.

실전 팁

💡 - 독립적인 작업만 병렬로 실행하세요. 의존성이 있으면 순차 처리해야 합니다

  • 캐시에는 TTL을 설정해서 오래된 데이터를 자동으로 제거하세요
  • 병렬 처리 전에 프로파일링해서 실제로 병목이 맞는지 확인하세요

10. 실전 프로젝트 코드 분석 에이전트

김개발 씨는 이제 Function Calling의 모든 것을 배웠습니다. 팀장님이 최종 미션을 주었습니다.

"배운 것을 모두 활용해서 실전 프로젝트를 만들어보세요. 코드 분석 에이전트를 만들어주세요."

코드 분석 에이전트는 지금까지 배운 모든 기술을 종합한 실전 프로젝트입니다. 파일 검색, 코드 읽기, Git 분석, 통계 계산 등 다양한 도구를 조합해서 프로젝트의 코드 품질, 복잡도, 테스트 커버리지 등을 자동으로 분석합니다.

다음 코드를 살펴봅시다.

class CodeAnalysisAgent:
    def __init__(self):
        """코드 분석 에이전트를 초기화합니다"""
        self.sandbox = Sandbox(allowed_dirs=["/app/projects"])
        self.tools = {
            "search_files": search_files,
            "read_file": self.sandbox.safe_read_file,
            "count_lines": count_lines,
            "git_log": git_log,
            "git_diff": git_diff,
            "run_command": run_command
        }
        self.executor = ParallelToolExecutor(self.tools, self.sandbox)

    def analyze_project(self, project_path: str) -> dict:
        """프로젝트를 분석합니다"""
        # 1. 파일 통계
        py_files = search_files("*.py", project_path)
        total_files = len(py_files)

        # 2. 병렬로 코드 줄 수 계산
        line_stats = self.executor.execute_parallel([
            {"name": "count_lines", "params": {"file_path": f}}
            for f in py_files
        ])

        total_lines = sum(s["result"]["total"] for s in line_stats)

        # 3. Git 분석
        recent_commits = git_log(max_count=10)

        # 4. 테스트 실행
        test_result = run_command("pytest --cov", timeout=60)

        return {
            "files": total_files,
            "lines": total_lines,
            "commits": recent_commits,
            "test_coverage": test_result["stdout"]
        }

김개발 씨는 지금까지 많은 것을 배웠습니다. File System 도구, Terminal 실행 도구, Git 도구, 도구 스키마 정의, 샌드박스, 에러 핸들링, LLM 통합, 병렬 처리까지.

이제 이 모든 것을 하나로 합칠 시간이었습니다. 팀장님이 회의실로 김개발 씨를 불렀습니다.

"마지막 미션입니다. 지금까지 배운 모든 것을 활용해서 실전 프로젝트를 만들어보세요.

코드 분석 에이전트를 만들어주세요." 김개발 씨는 눈이 반짝였습니다. "어떤 기능이 필요한가요?" 팀장님이 설명했습니다.

"프로젝트 경로를 주면, 파일 개수, 코드 줄 수, 최근 커밋, 테스트 커버리지 등을 자동으로 분석해서 리포트를 만들어야 합니다. 개발자들이 프로젝트 상태를 한눈에 파악할 수 있게요." 코드 분석 에이전트란 정확히 무엇일까요?

쉽게 비유하자면, 코드 분석 에이전트는 마치 건강 검진 센터와 같습니다. 사람이 병원에 가면 혈액 검사, 엑스레이, 심전도 등 여러 검사를 받고, 최종 리포트를 받습니다.

이처럼 코드 분석 에이전트도 프로젝트에 대해 여러 검사를 수행하고, 종합 리포트를 제공합니다. 수동 분석을 하던 시절에는 어땠을까요?

개발자들은 프로젝트 상태를 파악하기 위해 여러 명령어를 수동으로 실행했습니다. find로 파일을 찾고, wc -l로 줄 수를 세고, git log로 커밋을 확인하고, pytest로 테스트를 실행했습니다.

각 결과를 복사해서 문서에 붙여넣고, 수동으로 정리했습니다. 시간이 많이 걸리고, 실수도 잦았습니다.

바로 이런 문제를 해결하기 위해 자동화된 코드 분석 에이전트가 등장했습니다. 코드 분석 에이전트를 사용하면 몇 초 만에 프로젝트를 분석할 수 있습니다.

또한 일관된 형식의 리포트를 얻을 수 있습니다. 무엇보다 반복 작업을 자동화한다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 CodeAnalysisAgent 클래스를 보면 지금까지 배운 모든 요소를 통합하는 것을 알 수 있습니다.

Sandbox로 안전성을 보장하고, 여러 도구를 딕셔너리로 관리하고, ParallelToolExecutor로 성능을 최적화합니다. analyze_project 메서드는 실제 분석 로직입니다.

첫 번째 단계로 search_files로 모든 파이썬 파일을 찾습니다. 두 번째 단계로 각 파일의 줄 수를 병렬로 계산합니다.

100개 파일이 있어도 몇 초면 끝납니다. 세 번째 단계로 git_log로 최근 10개 커밋을 가져옵니다.

네 번째 단계로 pytest를 실행해서 테스트 커버리지를 측정합니다. 마지막으로 모든 결과를 딕셔너리로 정리해서 반환합니다.

이 딕셔너리를 LLM에게 전달하면, LLM이 자연어로 리포트를 작성할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 신규 프로젝트를 인수인계 받았다고 가정해봅시다. 프로젝트가 어떤 상태인지 빠르게 파악해야 합니다.

CodeAnalysisAgent를 실행하면 다음과 같은 리포트를 받을 수 있습니다. "프로젝트는 총 127개의 파이썬 파일로 구성되어 있으며, 15,342줄의 코드가 있습니다.

최근 10개 커밋을 보면 주로 버그 수정과 리팩토링이 진행되었습니다. 테스트 커버리지는 78%로 양호한 편입니다.

하지만 utils 모듈의 커버리지가 45%로 낮으므로 테스트 보완이 필요합니다." 이런 리포트를 수동으로 만들려면 1시간 이상 걸리지만, 에이전트는 몇 초 만에 만들어냅니다. 또 다른 활용 예시로, 매주 금요일마다 자동으로 프로젝트 분석을 실행해서 팀 채널에 리포트를 올릴 수 있습니다.

"이번 주에 코드가 500줄 증가했고, 커밋이 37개 추가되었습니다. 테스트 커버리지는 75%에서 78%로 향상되었습니다." 팀원들이 프로젝트의 건강 상태를 지속적으로 모니터링할 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 정보를 수집하는 것입니다.

모든 메트릭을 측정하려고 하면 오히려 혼란스럽습니다. 핵심 지표 5-10개에 집중하세요.

파일 수, 코드 줄 수, 테스트 커버리지, 최근 커밋, 복잡도 정도면 충분합니다. 또 다른 주의사항은 분석 시간입니다.

대규모 프로젝트는 분석에 시간이 오래 걸릴 수 있습니다. 타임아웃을 적절히 설정하고, 사용자에게 진행 상황을 알려주세요.

다시 김개발 씨의 이야기로 돌아가 봅시다. 김개발 씨는 일주일 동안 CodeAnalysisAgent를 완성했습니다.

팀 전체를 불러서 최종 시연을 했습니다. "analyze this project"라고 입력했습니다.

에이전트가 파일을 검색하고, 병렬로 읽고, Git 로그를 분석하고, 테스트를 실행했습니다. 몇 초 후, 아름답게 포맷된 리포트가 나타났습니다.

팀장님이 박수를 쳤습니다. "정말 훌륭합니다.

이제 우리 팀의 생산성이 크게 향상될 것 같아요." 김개발 씨는 3개월 전의 자신을 떠올렸습니다. Function Calling이 뭔지도 몰랐던 초보 개발자였습니다.

이제는 파일 시스템, 터미널, Git, 샌드박스, 병렬 처리까지 모든 것을 다룰 수 있게 되었습니다. 박시니어 씨가 다가와 어깨를 두드렸습니다.

"잘했어요. 이제 진짜 Function Calling 전문가네요." 코드 분석 에이전트를 제대로 구현하면 지금까지 배운 모든 기술을 실전에서 활용할 수 있습니다.

여러분도 이 가이드를 따라서 나만의 에이전트를 만들어보세요. Function Calling의 세계는 무한한 가능성으로 가득합니다.

실전 팁

💡 - 리포트 형식을 템플릿화해서 일관성을 유지하세요

  • 분석 결과를 JSON이나 Markdown으로 내보낼 수 있게 하세요
  • 점진적으로 기능을 추가하세요. 처음부터 완벽하게 만들려고 하지 마세요
  • 사용자 피드백을 받아서 정말 유용한 메트릭만 남기세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#FunctionCalling#LLM#ToolUse#AIAgent#LLM,FunctionCalling,도구

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.