포스트

로컬 LLM + Claude Code로 PM 업무 자동화 에이전트 만들기

로컬 LLM + Claude Code로 PM 업무 자동화 에이전트 만들기

PM으로 일하면서 가장 많이 하는 작업은 반복적인 문서 작업이다. 회의록 요약, 스펙 초안, 이슈 우선순위 정리… 이 루틴을 AI 에이전트로 자동화하면 얼마나 달라질까? 직접 구현해봤다. 로컬 LLM(Ollama + Mistral)과 Claude Code를 조합한 PM 워크플로우 에이전트다.


왜 로컬 LLM인가?

클라우드 API는 편하지만, 회사 내부 문서나 미팅 내용을 외부 서버로 보내는 건 보안 이슈가 따른다. 로컬 LLM은:

  • 보안: 인터넷 없이 동작하며 민감한 내부 데이터가 외부로 나가지 않는다
  • 비용: API 호출 비용이 없다. 반복 작업이 많을수록 절감 효과가 커진다
  • 속도: 네트워크 레이턴시가 없어 로컬 워크플로우에서 빠르게 응답한다
  • 오프라인: 인터넷 연결 없이도 에이전트를 실행할 수 있다

다만 로컬 LLM은 클라우드 API 대비 추론 능력이 제한적이다. 긴 컨텍스트 처리, 복잡한 코드 생성, 정밀한 판단이 필요한 작업은 Claude Code에 위임하는 역할 분리가 핵심이다.


Ollama 설치 및 모델 설정

Ollama 설치

ollama.com에서 macOS/Linux/Windows 설치 파일을 받거나 아래 명령어로 설치한다.

1
2
3
4
5
# macOS
brew install ollama

# Linux
curl -fsSL https://ollama.com/install.sh | sh

설치 후 서버를 실행한다.

1
ollama serve

기본 포트는 11434이며, http://localhost:11434로 REST API가 열린다.

모델 선택 가이드

로컬 LLM은 모델 크기와 성능 사이의 트레이드오프가 크다. PM 업무 자동화 용도에서 실용적인 모델들을 비교하면 다음과 같다.

모델크기메모리 요구한국어 지원용도
mistral:7b4.1GB8GB RAM보통일반 요약, 분류
llama3.1:8b4.7GB8GB RAM보통범용
gemma3:12b8.1GB16GB RAM양호긴 문서 처리
qwen2.5:14b9GB16GB RAM우수한국어 중심 작업
llama3.1:70b40GB64GB RAM우수고품질 추론

한국어 문서를 주로 다루는 PM이라면 qwen2.5:14b가 실용적인 선택이다.

1
2
3
4
5
# 모델 다운로드 (최초 1회)
ollama pull qwen2.5:14b

# 실행 테스트
ollama run qwen2.5:14b "PRD란 무엇인가?"

에이전트 전체 아키텍처

graph TD
    A["PM 입력 (회의록 / 이슈 / git log)"] --> B{"라우터 (router.py)"}
    B -->|단순 요약/분류| C["로컬 LLM (Ollama + qwen2.5)"]
    B -->|복잡한 판단/코드| D["Claude Code (Claude API)"]
    C --> E["후처리 (parser.py)"]
    D --> E
    E --> F[출력]
    F --> G[Notion]
    F --> H[GitHub Issues]
    F --> I[Slack]

에이전트는 크게 세 레이어로 구성된다.

  1. 라우터(Router): 입력 태스크의 복잡도를 판단해 로컬 LLM과 Claude Code 중 어디로 보낼지 결정한다.
  2. 프로세서(Processor): 각 LLM에 맞는 프롬프트로 실제 작업을 수행한다.
  3. 싱크(Sink): 결과를 Notion, GitHub, Slack 등 PM 도구로 내보낸다.

환경 세팅

1
2
3
4
mkdir pm-agent && cd pm-agent
python -m venv .venv && source .venv/bin/activate

pip install ollama anthropic python-dotenv notion-client slack-sdk
1
2
3
4
5
6
# .env
ANTHROPIC_API_KEY=sk-ant-...
NOTION_TOKEN=secret_...
NOTION_DATABASE_ID=...
SLACK_BOT_TOKEN=xoxb-...
SLACK_CHANNEL_ID=C0...

회의록 요약 에이전트

회의록을 넣으면 결정 사항, 액션 아이템, 리스크를 구조화해서 추출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# agents/meeting_summarizer.py
import ollama

PROMPT_TEMPLATE = """다음 회의록을 PM 관점에서 분석해줘.
아래 형식으로 정확히 출력해:

## 결정 사항
- (결정된 내용을 bullet로)

## 액션 아이템
| 항목 | 담당자 | 기한 |
|---|---|---|
| ... | ... | ... |

## 리스크 / 미결 사항
- (리스크나 추가 논의 필요 항목)

회의록:
{transcript}"""


def summarize_meeting(transcript: str) -> str:
    response = ollama.chat(
        model='qwen2.5:14b',
        messages=[{
            'role': 'user',
            'content': PROMPT_TEMPLATE.format(transcript=transcript)
        }]
    )
    return response['message']['content']


if __name__ == '__main__':
    sample = """
    참석: 김개발, 박디자인, 이PM
    일시: 2026-05-20

    이PM: 이번 스프린트 결제 모듈 오류 원인 파악됐나요?
    김개발: 네, PG사 웹훅 응답 타임아웃이 원인이었습니다. 재시도 로직 추가하면 됩니다.
    이PM: 기한은요?
    김개발: 수요일까지 가능합니다.
    박디자인: 에러 화면 UI도 같이 수정할게요. 목요일 드리겠습니다.
    이PM: 좋습니다. 이번 주 금요일 핫픽스 배포 목표로 진행하죠.
    """
    print(summarize_meeting(sample))

PRD 초안 자동 생성 에이전트

사용자 피드백이나 이슈 메모를 입력하면 PRD 초안을 작성해준다. 이 작업은 컨텍스트가 길고 구조화된 출력이 필요하기 때문에 Claude API를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# agents/prd_drafter.py
import anthropic

client = anthropic.Anthropic()

PRD_SYSTEM_PROMPT = """당신은 경험 많은 Technical PM입니다.
입력된 문제 설명을 바탕으로 PRD(Product Requirements Document) 초안을 작성합니다.
PRD는 다음 섹션을 포함해야 합니다:
1. Problem Statement
2. Goals & Success Metrics
3. User Stories
4. Scope (In / Out of Scope)
5. Edge Cases & Constraints
마크다운 형식으로 작성하세요."""


def draft_prd(problem_description: str) -> str:
    message = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=2048,
        system=PRD_SYSTEM_PROMPT,
        messages=[{
            "role": "user",
            "content": f"다음 문제를 해결하는 기능의 PRD를 작성해줘:\n\n{problem_description}"
        }]
    )
    return message.content[0].text


if __name__ == '__main__':
    problem = """
    사용자들이 비밀번호를 자주 잊어버려서 고객센터 문의의 30%가 비밀번호 초기화 관련이다.
    소셜 로그인(Google, Kakao)을 도입하면 이 문제를 해결하고 신규 가입 전환율도 올릴 수 있다.
    """
    print(draft_prd(problem))

이슈 우선순위 분류 에이전트 (MoSCoW)

GitHub Issues나 Jira 티켓 목록을 입력받아 MoSCoW 프레임워크로 분류한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# agents/issue_prioritizer.py
import ollama
import json

MOSCOW_PROMPT = """다음 이슈 목록을 MoSCoW 프레임워크로 분류해줘.

분류 기준:
- Must Have: 서비스 운영에 필수, 없으면 출시 불가
- Should Have: 중요하지만 잠시 미룰 수 있음
- Could Have: 있으면 좋지만 우선순위 낮음
- Won't Have: 이번 스프린트에서 하지 않음

반드시 JSON 형식으로만 응답해:
must

이슈 목록:
{issues}"""


def prioritize_issues(issues: list[str]) -> dict:
    issues_text = '\n'.join(f'- {issue}' for issue in issues)

    response = ollama.chat(
        model='qwen2.5:14b',
        messages=[{
            'role': 'user',
            'content': MOSCOW_PROMPT.format(issues=issues_text)
        }],
        format='json'
    )

    return json.loads(response['message']['content'])


if __name__ == '__main__':
    issues = [
        "결제 실패 시 사용자에게 에러 메시지 표시 안됨 (버그)",
        "다크모드 지원",
        "프로필 이미지 편집 기능",
        "소셜 로그인 도입",
        "앱 아이콘 리디자인",
        "세션 만료 시 자동 로그아웃 처리 없음 (보안)",
        "푸시 알림 개인화 설정",
    ]
    result = prioritize_issues(issues)
    for category, items in result.items():
        print(f"\n[{category.upper()}]")
        for item in items:
            print(f"  - {item}")

릴리스 노트 자동 생성 에이전트

git log를 기반으로 사용자 친화적인 릴리스 노트를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# agents/release_note_generator.py
import subprocess
import ollama


def get_git_log(n: int = 30) -> str:
    result = subprocess.run(
        ['git', 'log', f'--oneline', f'-{n}'],
        capture_output=True, text=True
    )
    return result.stdout.strip()


RELEASE_PROMPT = """다음 git commit 목록을 바탕으로 릴리스 노트를 작성해줘.

규칙:
- 개발자 용어 대신 사용자 관점의 언어로 작성
- 🚀 신기능, 🐛 버그 수정, ⚡ 성능 개선, 🔒 보안 패치로 아이콘 구분
- 내부 리팩토링이나 CI 변경은 제외
- 간결하고 명확하게

git log:
{git_log}"""


def generate_release_notes(version: str = "v1.0.0") -> str:
    git_log = get_git_log()

    response = ollama.chat(
        model='qwen2.5:14b',
        messages=[{
            'role': 'user',
            'content': RELEASE_PROMPT.format(git_log=git_log)
        }]
    )

    notes = response['message']['content']
    return f"# Release Notes {version}\n\n{notes}"


if __name__ == '__main__':
    print(generate_release_notes("v2.3.0"))

태스크 라우터 — 로컬 LLM vs Claude 자동 선택

복잡도에 따라 어느 LLM을 사용할지 자동으로 결정하는 라우터다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# router.py
from enum import Enum

class TaskType(Enum):
    SIMPLE_SUMMARY = "simple_summary"       # 로컬 LLM
    ISSUE_CLASSIFICATION = "classification" # 로컬 LLM
    RELEASE_NOTES = "release_notes"         # 로컬 LLM
    PRD_DRAFT = "prd_draft"                 # Claude
    COMPLEX_ANALYSIS = "complex_analysis"   # Claude
    CODE_REVIEW = "code_review"             # Claude

LOCAL_TASKS = {
    TaskType.SIMPLE_SUMMARY,
    TaskType.ISSUE_CLASSIFICATION,
    TaskType.RELEASE_NOTES,
}

CLAUDE_TASKS = {
    TaskType.PRD_DRAFT,
    TaskType.COMPLEX_ANALYSIS,
    TaskType.CODE_REVIEW,
}


def route(task_type: TaskType, payload: str) -> str:
    if task_type in LOCAL_TASKS:
        return _run_local(task_type, payload)
    elif task_type in CLAUDE_TASKS:
        return _run_claude(task_type, payload)
    else:
        raise ValueError(f"Unknown task type: {task_type}")


def _run_local(task_type: TaskType, payload: str) -> str:
    import ollama
    response = ollama.chat(
        model='qwen2.5:14b',
        messages=[{'role': 'user', 'content': payload}]
    )
    return response['message']['content']


def _run_claude(task_type: TaskType, payload: str) -> str:
    import anthropic
    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=2048,
        messages=[{"role": "user", "content": payload}]
    )
    return message.content[0].text

Notion 연동

에이전트가 생성한 문서를 Notion 데이터베이스에 자동으로 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# sinks/notion_sink.py
import os
from notion_client import Client

notion = Client(auth=os.environ["NOTION_TOKEN"])
DATABASE_ID = os.environ["NOTION_DATABASE_ID"]


def push_to_notion(title: str, content: str, doc_type: str = "PM Doc") -> str:
    """생성된 문서를 Notion 데이터베이스에 저장하고 페이지 URL을 반환한다."""
    response = notion.pages.create(
        parent={"database_id": DATABASE_ID},
        properties={
            "Name": {"title": [{"text": {"content": title}}]},
            "Type": {"select": {"name": doc_type}},
            "Status": {"select": {"name": "Draft"}},
        },
        children=[
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": content[:2000]}}]
                }
            }
        ]
    )
    return response["url"]


if __name__ == '__main__':
    from dotenv import load_dotenv
    load_dotenv()

    url = push_to_notion(
        title="소셜 로그인 기능 PRD",
        content="## Problem Statement\n비밀번호 분실 문의가 전체 CS의 30%를 차지...",
        doc_type="PRD"
    )
    print(f"Notion 페이지 생성 완료: {url}")

Slack 알림 연동

작업 완료 시 담당자에게 Slack으로 결과를 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# sinks/slack_sink.py
import os
from slack_sdk import WebClient

slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
CHANNEL = os.environ["SLACK_CHANNEL_ID"]


def notify_slack(summary: str, doc_url: str = None) -> None:
    blocks = [
        {
            "type": "section",
            "text": {"type": "mrkdwn", "text": f"🤖 *PM 에이전트 작업 완료*\n\n{summary[:500]}"}
        }
    ]
    if doc_url:
        blocks.append({
            "type": "actions",
            "elements": [{
                "type": "button",
                "text": {"type": "plain_text", "text": "Notion에서 보기"},
                "url": doc_url
            }]
        })

    slack.chat_postMessage(channel=CHANNEL, blocks=blocks)

Claude Code 활용 팁

Claude Code는 단순 코드 에디터가 아니라 에이전트 프레임워크로 쓸 수 있다.

CLI로 배치 작업

1
2
3
4
5
# git log 기반 릴리스 노트 자동 생성
claude --print "다음 git log를 기반으로 릴리스 노트를 작성해줘: $(git log --oneline -20)"

# 파일 읽어서 PRD 작성
claude --print "$(cat meeting_notes.txt)를 바탕으로 PRD 초안을 작성해줘"

CLAUDE.md로 컨텍스트 영속화

세션 간 컨텍스트 유지가 필요하면 CLAUDE.md에 프로젝트 배경을 저장해두면 된다. 매번 반복 설명 없이 바로 작업에 들어갈 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# CLAUDE.md 예시

## 제품 개요
- 서비스명: [서비스명]
- 타겟 사용자: 20-30대 직장인
- 핵심 가치: 업무 효율화

## PM 용어 정의
- Sprint: 2주 단위 개발 사이클
- PRD: Product Requirements Document
- NSM: North Star Metric — 월간 활성 사용자 수

## 현재 분기 OKR
- O: 결제 전환율 개선
- KR1: 결제 완료율 72% → 80%
- KR2: 결제 페이지 이탈률 28% → 18%

파이프라인에서 Claude Code 호출

1
2
3
4
5
6
7
8
9
10
11
12
13
# agents/claude_code_agent.py
import subprocess


def run_claude_code(prompt: str) -> str:
    """Claude Code CLI를 서브프로세스로 호출한다."""
    result = subprocess.run(
        ['claude', '--print', prompt],
        capture_output=True,
        text=True,
        cwd='/path/to/project'  # CLAUDE.md가 있는 프로젝트 루트
    )
    return result.stdout.strip()

Claude Code를 서브프로세스로 호출할 때는 `--print` 플래그를 사용해 non-interactive 모드로 실행하고, `cwd`를 CLAUDE.md가 있는 프로젝트 루트로 지정해야 컨텍스트가 올바르게 로드된다.


전체 파이프라인 실행 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# main.py
import os
from dotenv import load_dotenv

from agents.meeting_summarizer import summarize_meeting
from agents.prd_drafter import draft_prd
from agents.issue_prioritizer import prioritize_issues
from agents.release_note_generator import generate_release_notes
from sinks.notion_sink import push_to_notion
from sinks.slack_sink import notify_slack

load_dotenv()


def run_meeting_pipeline(transcript: str):
    print("📝 회의록 요약 중...")
    summary = summarize_meeting(transcript)

    print("📤 Notion에 저장 중...")
    url = push_to_notion(title="회의록 요약", content=summary, doc_type="Meeting Note")

    print("💬 Slack 알림 전송...")
    notify_slack(summary=summary[:300], doc_url=url)

    print(f"✅ 완료: {url}")
    return url


def run_sprint_pipeline(issues: list[str], git_log_count: int = 20):
    print("🎯 이슈 우선순위 분류 중...")
    priorities = prioritize_issues(issues)

    print("📋 릴리스 노트 생성 중...")
    release_notes = generate_release_notes()

    content = f"## 이슈 우선순위\n{priorities}\n\n## 릴리스 노트\n{release_notes}"
    url = push_to_notion(title="스프린트 리뷰", content=content, doc_type="Sprint Review")

    notify_slack(summary="스프린트 리뷰 문서가 생성되었습니다.", doc_url=url)
    return url


if __name__ == '__main__':
    # 사용 예시
    with open('today_meeting.txt') as f:
        transcript = f.read()
    run_meeting_pipeline(transcript)

비용 비교

월 200회 PM 문서 자동화 작업 기준 추정치다.

방식월 비용비고
GPT-4o API 전량 사용$40~80입출력 토큰 기준
Claude API 전량 사용$30~60claude-opus 기준
로컬 LLM 전량 사용$0전기세 제외
로컬 + Claude 혼합$5~15복잡한 작업만 Claude

단순 반복 작업 80%를 로컬 LLM으로 처리하면 API 비용을 70~80% 절감하면서도 복잡한 작업에서는 Claude의 품질을 유지할 수 있다.


마치며

로컬 LLM으로 일상 반복 작업을, Claude Code로 복잡한 판단이 필요한 작업을 처리하는 분리 구조가 PM 에이전트 설계의 핵심이다.

처음에는 회의록 요약 하나만 자동화해도 하루 30분을 아낄 수 있다. 여기에 이슈 분류, PRD 초안, 릴리스 노트까지 더하면 PM이 실제로 집중해야 할 일 등 사용자 인터뷰, 전략 수립, 팀 조율 등에 쓸 수 있는 시간이 늘어난다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.