이 글은 승인된 포트폴리오로 고객사 제안서 초안을 만드는 영업 운영·자동화 담당자를 위한 가이드다. 제안서 자동화의 목표는 문서를 사람 없이 발송하는 것이 아니라 고객 요구사항을 구조화하고, 재사용이 허용된 포트폴리오를 찾고, 모든 사례 문장에 출처를 붙인 초안을 만든 뒤 담당자가 승인하도록 만드는 것이다.
이 글은 다음 흐름을 구현한다.
고객사 입력 검증 → Notion에서 후보 찾기 → 승인·공개 범위 필터 → 후보 랭킹 → 출처가 붙은 초안 → Google Docs 템플릿 채움 → 영업·법무 승인 → 발송
예시는 모두 가상의 데이터다. 실제 고객정보, 계약 내용, 비공개 성과 수치는 넣지 않는다. 또한 기존 글에 있던 처리시간 단축률, 월간 처리량, 수주율 같은 수치는 원본 타임로그·CRM 근거가 없어 사용하지 않는다.
먼저 정할 원칙: 생성보다 근거와 승인이 우선이다
안전한 제안서 파이프라인은 다음 네 가지 불변 조건을 가져야 한다.
public_scope가public이고 승인일이 있는 자료만 후보에 포함한다.- 제안서의 사례 문장마다
source_id와 원문 인용문을 보존한다. - 고객명과 성과 수치는 각각 별도 승인 플래그가 없으면 출력하지 않는다.
- 자동화 결과는 항상
pending_human_review에서 멈추며, 영업과 법무가 모두 승인해야 발송할 수 있다.
고객 요건을 발굴하고 우선순위를 매기는 방법은 리드 스코어링과 고객 요건 정리에서, Notion을 협업 허브로 운영하는 방법은 Notion·일정 협업 자동화에서 더 자세히 볼 수 있다.
1. 고객사 입력 스키마를 먼저 고정한다
자유형 메모를 바로 생성 모델에 넘기면 필수 조건이 빠지거나, 고객이 말하지 않은 요구가 섞이기 쉽다. 아래처럼 입력 계약부터 만든다.
{
"request_id": "REQ-DEMO-001",
"customer_alias": "샘플 제조사 A",
"industry": "제조",
"problems": ["설비 점검 기록 분산", "주간 보고서 작성 지연"],
"required_technologies": ["OCR", "workflow"],
"must_have": ["국내 저장 위치 검토", "관리자 승인 단계"],
"prohibited_content": ["실제 고객명", "승인되지 않은 성과 수치"],
"deadline": "2026-08-31",
"owner": "sales-demo@example.invalid"
}
필드별 운영 규칙은 다음과 같다.
| 필드 | 용도 | 검증 규칙 |
|---|---|---|
request_id |
실행·감사 로그 연결 | 중복 불가 |
customer_alias |
문서 내부 식별 | 실제 법인명 대신 승인된 별칭 사용 |
industry |
산업 필터와 랭킹 | 허용 목록 사용 |
problems |
검색 질의와 문제 정의 | 최소 1개, 원문 보존 |
required_technologies |
기술 적합도 계산 | 표준 태그로 정규화 |
must_have |
제안서 필수 조건 | 누락 시 검수 실패 |
prohibited_content |
출력 차단 규칙 | 생성 후에도 재검사 |
deadline, owner |
운영 책임 | 형식과 담당자 존재 확인 |
여기에는 민감한 원문 계약서나 개인 연락처를 넣지 않는다. 꼭 필요하다면 별도 접근통제 저장소에 두고 참조 ID만 전달한다.
2. 포트폴리오 메타데이터에 공개 범위와 승인 상태를 넣는다
검색 품질보다 먼저 재사용 가능 여부를 판정해야 한다. 최소 메타데이터는 다음과 같다.
{
"source_id": "PF-DEMO-001",
"title": "제조 현장 점검 기록 디지털화",
"industry": ["제조"],
"problems": ["점검 기록 분산", "주간 보고서 작성"],
"technologies": ["OCR", "workflow"],
"public_scope": "public",
"approved_at": "2026-06-15",
"client_display_name": "가상 고객사 알파",
"client_name_approved": false,
"evidence_quote": "샘플 현장의 종이 점검표를 승인 워크플로에 연결했다.",
"claims": [
{"text": "처리 시간이 크게 줄었다", "approved": false}
]
}
public_scope는 private, internal, public처럼 제한된 값만 허용한다. approved_at은 자료 자체의 외부 재사용 승인일이다. 고객명과 성과 수치는 민감도가 다르므로 client_name_approved, 개별 claims[].approved를 따로 관리한다. 자료가 공개 상태여도 고객명과 수치가 자동으로 공개되는 것은 아니다.
3. Notion 검색은 ‘후보 발견’에만 사용한다
Notion Search API는 연결(connection)에 공유된 페이지와 데이터 소스 중 제목에 질의가 포함된 항목을 찾는다. 객체 종류를 page 또는 data_source로 제한할 수 있고 페이지네이션도 지원한다. 반대로 특정 데이터 소스의 속성을 정교하게 필터링하는 용도는 아니다. 그런 경우 공식 문서가 안내하듯 해당 데이터 소스의 query 엔드포인트를 사용한다.
아래 요청은 제목에 ‘제조’를 포함한 공유 페이지 후보를 최근 수정 순으로 찾는 형태다. 토큰은 서버의 비밀 저장소에서 읽고 브라우저 코드나 로그에 노출하지 않는다.
curl --request POST 'https://api.notion.com/v1/search' \
--header 'Authorization: Bearer YOUR_NOTION_TOKEN' \
--header 'Content-Type: application/json' \
--header 'Notion-Version: 2026-03-11' \
--data '{
"query": "제조",
"filter": {"property": "object", "value": "page"},
"sort": {"direction": "descending", "timestamp": "last_edited_time"},
"page_size": 20
}'
검색 결과의 페이지 ID를 source_id로 바로 간주하지 말고, 페이지 속성에 저장한 내부 source_id를 읽는다. 이후 애플리케이션에서 public_scope == "public", approved_at != null 조건을 먼저 적용한다. 연결에 페이지가 공유되지 않았다면 검색 결과에 나오지 않으므로 권한 누락도 점검해야 한다.
4. 후보 랭킹은 승인 필터 뒤에서 수행한다
간단한 초기 랭킹은 설명 가능한 규칙으로 시작할 수 있다.
- 산업 일치: 3점
- 문제 태그 교집합: 태그당 2점
- 필수 기술 교집합: 태그당 2점
점수보다 중요한 것은 순서다. 비공개 자료를 높은 점수로 뽑은 뒤 문장 생성 단계에서 숨기는 방식이 아니라, 검색 직후 승인되지 않은 자료를 제거한 다음 랭킹해야 한다.
규모가 커지면 의미 검색을 선택지로 검토할 수 있다. OpenAI Retrieval 공식 가이드에 따르면 벡터 스토어 검색 결과에는 관련 청크, 유사도 점수, 원본 파일 정보가 포함되며 파일 속성 필터도 적용할 수 있다. 따라서 public_scope, 승인 상태 같은 속성을 검색 전에 제한하는 설계가 가능하다. 다만 파일 업로드·저장·검색은 데이터 처리 정책과 비용 검토가 필요하다. 이 글의 데모는 유료 모델이나 Retrieval API를 호출하지 않고 로컬 샘플 데이터만 사용한다.
5. 로컬 데모: 검색부터 사람 승인 대기까지
아래 Python 코드는 외부 패키지, API 키, 네트워크 없이 실행된다. 가상의 포트폴리오 5건 중 외부 공개와 자료 승인을 통과한 항목만 랭킹하고, 고객명·미승인 성과 문구를 제외한 인용 블록을 만든다. 마지막에는 Google Docs batchUpdate에 전달할 요청 본문을 생성하되 실제 API는 호출하지 않는다.
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
CUSTOMER = {
"request_id": "REQ-DEMO-001",
"customer_alias": "샘플 제조사 A",
"industry": "제조",
"problems": ["점검 기록 분산", "주간 보고서 작성"],
"required_technologies": ["OCR", "workflow"],
"must_have": ["관리자 승인 단계"],
"prohibited_content": ["실제 고객명", "승인되지 않은 성과 수치"],
}
PORTFOLIOS = [
{
"source_id": "PF-DEMO-001",
"title": "제조 현장 점검 기록 디지털화",
"industry": ["제조"],
"problems": ["점검 기록 분산", "주간 보고서 작성"],
"technologies": ["OCR", "workflow"],
"public_scope": "public",
"approved_at": "2026-06-15",
"client_display_name": "가상 고객사 알파",
"client_name_approved": False,
"evidence_quote": "샘플 현장의 종이 점검표를 승인 워크플로에 연결했다.",
"claims": [{"text": "처리 시간이 크게 줄었다", "approved": False}],
},
{
"source_id": "PF-DEMO-002",
"title": "유통 문서 OCR 분류",
"industry": ["유통"],
"problems": ["문서 분류"],
"technologies": ["OCR"],
"public_scope": "public",
"approved_at": "2026-05-02",
"client_display_name": "가상 고객사 베타",
"client_name_approved": False,
"evidence_quote": "샘플 입고 문서를 유형별 검토함으로 분기했다.",
"claims": [],
},
{
"source_id": "PF-DEMO-003",
"title": "제조 설비 보고 자동화",
"industry": ["제조"],
"problems": ["주간 보고서 작성"],
"technologies": ["workflow"],
"public_scope": "internal",
"approved_at": "2026-04-10",
"client_display_name": "비공개 예시",
"client_name_approved": False,
"evidence_quote": "내부 전용 샘플 문장",
"claims": [],
},
{
"source_id": "PF-DEMO-004",
"title": "제조 품질 문서 통합",
"industry": ["제조"],
"problems": ["점검 기록 분산"],
"technologies": ["workflow"],
"public_scope": "public",
"approved_at": None,
"client_display_name": "승인 대기 예시",
"client_name_approved": False,
"evidence_quote": "승인 전 샘플 문장",
"claims": [],
},
{
"source_id": "PF-DEMO-005",
"title": "공공기관 문의 분류",
"industry": ["공공"],
"problems": ["문의 분류"],
"technologies": ["NLP"],
"public_scope": "public",
"approved_at": "2026-03-20",
"client_display_name": "공개 승인 가상기관",
"client_name_approved": True,
"evidence_quote": "공개 샘플 문의를 담당 분류함에 연결했다.",
"claims": [],
},
]
@dataclass(frozen=True)
class Candidate:
source_id: str
score: int
title: str
citation: str
def validate_customer(customer: dict[str, Any]) -> None:
required = {
"request_id", "customer_alias", "industry", "problems",
"required_technologies", "must_have", "prohibited_content",
}
missing = sorted(required - customer.keys())
if missing:
raise ValueError(f"필수 입력 누락: {missing}")
if not customer["problems"]:
raise ValueError("problems는 1개 이상이어야 합니다")
def rank(customer: dict[str, Any], portfolios: list[dict[str, Any]]) -> tuple[list[Candidate], list[str]]:
candidates: list[Candidate] = []
blocked: list[str] = []
for item in portfolios:
if item["public_scope"] != "public" or not item["approved_at"]:
blocked.append(item["source_id"])
continue
score = 3 if customer["industry"] in item["industry"] else 0
score += 2 * len(set(customer["problems"]) & set(item["problems"]))
score += 2 * len(
set(customer["required_technologies"]) & set(item["technologies"])
)
if score <= 0:
continue
# 고객명과 성과 문구는 별도 승인을 통과한 경우에만 추가한다.
safe_parts = [item["evidence_quote"]]
if item["client_name_approved"]:
safe_parts.append(f"공개 명칭: {item['client_display_name']}")
safe_parts.extend(
claim["text"] for claim in item["claims"] if claim.get("approved") is True
)
citation = " | ".join(safe_parts)
candidates.append(
Candidate(item["source_id"], score, item["title"], citation)
)
candidates.sort(key=lambda x: (-x.score, x.source_id))
return candidates, blocked
def build_docs_requests(customer: dict[str, Any], top: Candidate) -> list[dict[str, Any]]:
replacements = {
"{{REQUEST_ID}}": customer["request_id"],
"{{CUSTOMER_ALIAS}}": customer["customer_alias"],
"{{PROBLEM}}": ", ".join(customer["problems"]),
"{{CASE_TITLE}}": top.title,
"{{CASE_CITATION}}": f"{top.citation} [source_id: {top.source_id}]",
"{{REVIEW_STATUS}}": "영업: 대기 / 법무: 대기",
}
return [
{
"replaceAllText": {
"containsText": {"text": token, "matchCase": True},
"replaceText": value,
}
}
for token, value in replacements.items()
]
def main() -> None:
validate_customer(CUSTOMER)
candidates, blocked = rank(CUSTOMER, PORTFOLIOS)
if not candidates:
raise RuntimeError("승인된 공개 포트폴리오 후보가 없습니다")
top = candidates[0]
output = {
"request_id": CUSTOMER["request_id"],
"portfolio_count": len(PORTFOLIOS),
"blocked_source_ids": blocked,
"ranked_candidates": [candidate.__dict__ for candidate in candidates],
"docs_batch_update": {"requests": build_docs_requests(CUSTOMER, top)},
"approval": {
"status": "pending_human_review",
"sales_approved": False,
"legal_approved": False,
"send_allowed": False,
},
}
print(json.dumps(output, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
이 데모의 점수는 실제 전환율이나 정확도가 아니라 정렬 규칙의 결과다. 샘플 5건은 정책 차단과 출처 연결을 확인하기 위한 테스트 픽스처이며, 운영 성능 자료로 사용할 수 없다.
6. Google Docs 템플릿은 한 번의 원자적 업데이트로 채운다
Google Docs documents.batchUpdate 공식 문서에 따르면 여러 업데이트 요청은 적용 전에 검증된다. 하나라도 유효하지 않으면 전체 요청이 실패하고 아무 변경도 적용되지 않으며, 유효한 요청들은 원자적으로 함께 적용된다.
로컬 데모가 만든 docs_batch_update를 다음 엔드포인트의 요청 본문으로 보낼 수 있다.
POST https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate
실서비스에서는 다음을 추가한다.
- 최소 권한의 OAuth 범위를 선택한다. 공식 문서에 제시된 범위는
documents,drive,drive.file이며, 애플리케이션이 만든 파일만 다룬다면 요구사항에 맞는 최소 범위를 검토한다. - 템플릿 원본을 직접 수정하지 말고 제안서별 사본을 만든다.
- 공동 편집 충돌을 피하려면 문서를 읽을 때 얻은 revision ID와
writeControl사용을 검토한다. source_id와 인용문을 독자가 보지 않는 별도 검수 표에도 남긴다.- API 오류 시 부분 성공으로 간주하지 말고 전체 작업을 재검토한다.
batchUpdate가 원자적이라는 사실은 내용이 사실임을 보장하지 않는다. 문서에 잘못된 문장을 일관되게 삽입할 수도 있으므로, 이후 검수 단계가 반드시 필요하다.
7. 생성 전후에 두 번 차단한다
검색 전 필터만으로는 부족하다. 템플릿, 사람이 붙여 넣은 문장, 이전 버전에서 금지 정보가 다시 들어올 수 있기 때문이다.
생성 전 검사
public_scope == "public"approved_at존재source_id존재evidence_quote비어 있지 않음- 고객명은
client_name_approved == true일 때만 허용 - 성과 문장은 개별
approved == true일 때만 허용
생성 후 검사
- 사례 단락마다
[source_id: ...]존재 - 허용되지 않은 고객명 사전과 일치하는 문자열 없음
%,배, 통화, 기간 단축 등 수치 패턴은 승인 근거 ID 없으면 차단- 고객의
must_have가 모두 반영됐는지 확인 - 영업·법무 승인 플래그가 모두 참이 아니면
send_allowed는 항상 거짓
정규식만으로는 문맥을 완전히 판단할 수 없다. 수치 탐지는 자동 차단 또는 검토 큐 전송에 쓰고, 최종 판단은 근거 문서에 접근할 수 있는 담당자가 한다.
8. 사람 승인 데모와 책임 분리
승인 상태는 문서 본문이 아니라 변경 이력이 남는 데이터베이스에 보관하는 편이 안전하다.
| 단계 | 담당 | 확인 항목 | 승인 전 동작 |
|---|---|---|---|
| 초안 생성 | 자동화 | 입력 스키마, 공개 범위, source_id |
pending_human_review |
| 영업 검수 | 영업 담당자 | 고객 요구와 제안 범위 일치 | 수정 또는 영업 승인 |
| 법무·보안 검수 | 법무/보안 담당자 | 고객명, 수치, 비밀정보, 라이선스 | 수정 또는 법무 승인 |
| 발송 승인 | 지정 책임자 | 두 승인과 최종 버전 일치 | 승인 전 발송 차단 |
승인 이벤트에는 request_id, 문서 revision ID, 승인자, 시각, 결정, 코멘트를 남긴다. 문서가 승인 뒤 바뀌면 기존 승인을 무효화하고 다시 검수한다.
9. 실패 조건·권한·비용·정책 한계
실패 조건
- Notion 연결에 자료가 공유되지 않아 검색 결과가 비는 경우
- 제목 검색만으로 관련 자료를 찾지 못하는 경우
- 공개 자료라도
approved_at,source_id, 인용문이 빠진 경우 - Docs 템플릿 토큰이 바뀌어 치환되지 않는 경우
- 공동 편집으로 revision이 달라진 경우
- 영업 또는 법무 승인이 누락된 경우
후보가 없으면 일반 문구를 지어내지 말고 “승인된 근거 없음”으로 중단한다.
권한과 비밀 관리
Notion 토큰, Google OAuth 자격증명, 모델 API 키는 서버 측 비밀 저장소에 둔다. 로그에는 토큰이나 원문 고객정보를 남기지 않는다. Notion 연결에는 포트폴리오 영역만 공유하고, Google 권한도 필요한 문서 범위로 제한한다.
비용
Notion과 Google Workspace의 API 사용량 한도 및 계정 요금제를 확인해야 한다. OpenAI Retrieval을 선택하면 벡터 스토리지와 모델 사용 비용, 보존 기간을 별도로 검토해야 한다. 무료 구간이나 가격은 바뀔 수 있으므로 구축 시점의 공식 가격 문서를 다시 확인한다. 이 글의 로컬 데모는 외부 API를 호출하지 않아 API 비용이 발생하지 않는다.
데이터·정책 한계
의미 검색 서비스에 자료를 업로드하기 전에 계약상 외부 처리 허용 여부, 데이터 저장 위치, 보존·삭제 정책, 개인정보 처리 근거를 확인한다. 고객이 제공한 문서를 모델 개선이나 범용 지식베이스에 임의로 재사용해서는 안 된다.
10. 운영 검증과 회귀 테스트
처음부터 자동화 효과를 단정하지 말고 익명 샘플로 측정 기준을 만든다.
| 측정 항목 | 정의 | 필요한 근거 |
|---|---|---|
| 초안 소요시간 | 입력 확정부터 승인 대기 문서 생성까지 | 실행 로그 타임스탬프 |
| 검색 적합도 | 검수자가 관련 있다고 판정한 후보 비율 | 익명 정답셋과 판정 기록 |
| 인용 완전성 | 사례 문장 중 유효한 source_id가 있는 비율 |
생성물 검사 로그 |
| 정책 차단 재현율 | 금지 고객명·미승인 수치를 차단한 비율 | 의도적으로 만든 음성 테스트셋 |
| 승인 후 수정률 | 승인 직전 사람이 고친 문장 비율 | 문서 revision diff |
샘플 5건만으로 일반적인 생산성이나 수주 성과를 주장할 수 없다. 표본 수, 데이터 구성, 측정 기간을 함께 공개하고 충분한 운영 로그가 쌓인 뒤에만 비교한다. 생성 결과의 변경을 지속적으로 점검하는 방법은 생성 결과 회귀 검증 가이드를 참고할 수 있다.
도입 체크리스트
- 고객사 입력 스키마와 필수 필드를 합의했다.
- 실제 고객명 대신 승인된 별칭을 쓴다.
- 포트폴리오에 산업, 문제, 기술, 공개 범위, 승인일을 저장한다.
- 검색 결과마다 내부
source_id와 원문 인용을 보존한다. - 미승인 고객명과 성과 수치를 생성 전후에 차단한다.
- Notion 연결과 Google OAuth에 최소 권한을 적용했다.
- Docs 템플릿 사본과 revision 충돌 정책을 마련했다.
- 영업·법무의 이중 승인 전에는 발송할 수 없다.
- 승인 후 문서 변경 시 재승인한다.
- 시간·정확도·성과 주장은 실제 로그와 정답셋으로만 계산한다.
자주 묻는 질문
Notion Search API만으로 포트폴리오 랭킹까지 할 수 있나?
제목 기반 후보 발견에는 쓸 수 있지만, 산업·기술·승인일 같은 속성 조건과 업무별 랭킹은 별도 로직이 필요하다. 특정 데이터 소스의 속성을 필터링하려면 해당 데이터 소스 query 엔드포인트를 사용한다.
의미 검색을 쓰면 키워드 검색은 버려도 되나?
아니다. 고유 제품명, 규격, 계약 코드처럼 정확히 일치해야 하는 조건은 키워드·메타데이터 필터가 유리하다. 의미 검색은 표현이 달라도 유사한 문제를 찾는 보조 수단으로 두고, 공개 범위 필터는 검색 전에 적용한다.
자동 생성 문서를 바로 보내도 되나?
안 된다. 출처가 붙어 있어도 고객 맥락과 계약상 표현을 사람이 확인해야 한다. 영업과 법무 승인을 모두 기록하고, 승인된 revision과 발송본이 같은지 확인한다.
수치가 없는 제안서는 설득력이 떨어지지 않나?
검증되지 않은 수치보다 문제, 적용 범위, 구현 방식, 검수 가능한 근거를 명확히 제시하는 편이 안전하다. 수치를 쓰려면 원본 측정 정의, 기간, 표본, 승인 상태를 함께 관리한다.
공식 문서
- Notion, Search by title — 확인일 2026-07-20
- Google Workspace,
documents.batchUpdate— 확인일 2026-07-20 - OpenAI, Retrieval — 확인일 2026-07-20
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| 외주 개발/디자인 프로젝트 관리를 위한 캘린더-노션-슬랙 실시간 태스크 동기화로 누락 방지 (0) | 2026.07.01 |
|---|---|
| K-Startup 정부지원사업 공고를 자동 수집·필터링하는 방법 (0) | 2026.06.25 |
| 국세청 홈택스 자료 연동을 위한 영수증 OCR 및 부가세 신고용 지출 증빙 자동 분류 (0) | 2026.06.24 |
| 정기 구독형 서비스(SaaS) 구축을 위한 토스페이먼츠 API와 n8n 연동 기초 (0) | 2026.06.24 |
| 디지털 상품(전자책/VOD) 판매 자동화: 결제 즉시 다운로드 링크 발송 시스템 (1) | 2026.06.24 |