AI 에이전트가 실제로 선택하고 감당할 수 있는 Apify Actor 설계하기
요약
본 글은 Apify Actor가 단순한 수동 사용자 인터페이스를 넘어, Claude나 커스텀 루프 내의 실제 AI 에이전트에 의해 호출되고 설계되어야 함을 강조합니다. 저자는 SEC EDGAR 금융 정보 등 전문적인 데이터를 다루는 Actor 설계를 예시로 들며, 에이전트 친화적이고 경제적으로 작동하는 Actor를 만드는 방법을 제시합니다.
핵심 포인트
- AI 에이전트는 스크롤이나 추측 없이 JSON 입력을 구성하여 실행한다.
- Apify MCP 서버를 통해 에이전트가 사용할 수 있는 도구(tools)를 제공해야 한다.
- Actor의 설명(description)은 에이전트에게 읽히는 홍보 문구 역할을 하므로 신중하게 작성해야 한다.
대부분의 Apify Actor는 스토어 페이지를 열고, README를 읽고, 양식을 작성한 다음 Start 버튼을 클릭하는 사람을 위해 만들어졌습니다. 그런 사람은 스크롤하거나, 추측하거나, 무언가 잘못되었을 때 재시도할 수 있습니다.
점점 더 많은 호출자가 사람이 아닙니다. Claude, Cursor 또는 사용자 지정 루프 내의 에이전트는 Apify MCP 서버에 연결하여 스토어를 검색하고, 사용자의 Actor에 대한 짧은 카드를 읽고, 입력 스키마로부터 JSON 입력을 구성한 다음, 지출 한도(spending cap)를 설정하여 실행합니다. 이들은 README 스크린샷을 절대 볼 수 없습니다. "최대 결과(Max results)"가 무엇을 의미하는지 물어볼 수도 없습니다. 만약 실행이 실패하면, 단순히 다른 사람의 Actor로 넘어갈 수 있습니다.
저는 Ryan Low입니다. 저는 Apify Store에서 locaihost라는 이름으로 이벤트당 비용을 지불하는 Actor를 게시합니다. 여기에는 SEC EDGAR 금융 정보 및 내부자 거래, Greenhouse/Lever/Ashby/Personio/Teamtailor의 채용 페이지 직무, 영국/EU/캐나다 정부 입찰 건 등이 포함되며 몇 가지 다른 것도 있습니다. 이 글에서는 제 SEC EDGAR Actor에 대한 실제 에이전트 호출 과정을 살펴보고, Actor가 에이전트에게 사용 가능하고(그리고 경제적이며) 되기 위해 필요한 설계 결정 사항들을 다룹니다. 아래의 모든 코드는 실제로 배포되는 Actor에서 가져온 것입니다. 제가 직접 측정하지 않은 내용은 생략했습니다.
1. 설정: 단 하나의 명령어
Apify는 MCP 서버를 호스팅합니다. Claude Code에서는 다음 단일 명령어로 이를 추가할 수 있습니다.
claude mcp add apify https://mcp.apify.com/ -t http
처음 사용할 때는 브라우저 OAuth 흐름이 열리면서 Apify에 로그인하라는 요청을 받기 때문에 토큰을 붙여넣을 필요가 없습니다. 그 후 에이전트는 search-actors, fetch-actor-details, call-actor, get-dataset-items와 같은 도구들을 갖게 됩니다.
특정 Actor에 에이전트를 고정하려면 URL에 직접 전달할 수 있습니다 (https://mcp.apify.com?tools=locaihost/sec-edgar,...). 나열된 각 Actor는 자체적인 도구로 나타납니다.
2. 에이전트가 실제로 보는 것
에이전트를 위한 코드를 작성하기 전에, 저는 apify-mcp-server의 소스 코드를 읽었습니다. 세 가지 발견 사항이 다른 모든 것을 형성했습니다.
search-actors는 Store 검색 API(GET /v2/store?search=...)를 감싼 얇은 Wrapper입니다. MCP 서버 자체적으로 재랭킹(re-ranking)을 수행하지 않기 때문에 에이전트 순위는 곧 Store의 순위와 같습니다: 관련성(relevance)에 더해 Apify가 Store에 사용하는 품질 및 인기 신호(신뢰성, 사용량, 리뷰 등)가 포함됩니다.- 기본
limit은 5입니다. 에이전트에게는 "SEC filings"나 "insider trades"와 같은 1~3개의 키워드로 검색하도록 지시됩니다. 오직 다섯 개의 카드만 반환됩니다. - 카드는 액터(Actor)의 설명(description)을 있는 그대로 사용하고, 여기에 입력 필드 목록이 추가되어 구성됩니다.
.actor/actor.json파일의description에 작성한 내용 전체가 에이전트가 읽는 홍보 문구(pitch)가 됩니다.
첫 두 가지 포인트에 대해서는 솔직해져야 합니다. 제 액터들은 몇 주밖에 되지 않아 사용량이 거의 없습니다. Store API를 대상으로 에이전트 스타일의 쿼리(query)를 실행했을 때, 제가 테스트한 어떤 쿼리에서도 어떤 것도 상위 5개 안에 나타나지 않았습니다. 완전히 새로운 액터는 스키마가 아무리 깨끗해도 search-actors를 통해 선택되지 않습니다. 사용량이 순위를 만들고, 순위가 사용량을 만듭니다. 제가 찾은 지름길은 없습니다.
그렇다면 왜 신경 써야 할까요? 검색(search)이 유일한 진입 경로가 아니기 때문입니다. 에이전트는 다음 경우에도 새로운 액터에 도달할 수 있습니다:
- 사용자가 이름을 언급하는 경우 ("use locaihost/sec-edgar")
- MCP URL의
?tools=를 통해 고정(pin)되는 경우 - 사용자 또는 에이전트가 이미 읽은 README, 블로그 게시물 또는 발행된 작업에 표시되는 경우
이 세 가지 경우 모두에서 에이전트는 검색을 건너뛰고 fetch-actor-details와 call-actor로 바로 이동합니다. 그 이후부터는 입력 스키마(input schema), 실패 모드(failure modes), 그리고 과금 기준점(charge floor)이 호출이 작동할지 여부를 결정합니다. 이 부분은 첫날부터 사용자가 통제할 수 있습니다.
3. 실제 호출 예시 (A real call)
NVIDIA의 최근 내부자 거래(insider trades)에 대해 요청했을 때, 제가 MCP call-actor 도구를 통해 locaihost/sec-edgar를 대상으로 수행한 호출입니다:
{
"actor": "locaihost/sec-edgar",
"input": {
...
에이전트는 스스로 두 가지 좋은 선택을 했습니다. 필요한 행의 수만큼 maxResults를 설정했고, maxTotalChargeUsd로 실행 총 지출액을 10센트에 제한했습니다. 이 두 가지는 액터(Actor)가 이를 준수할 때만 작동하며, 이는 아래에서 다시 다루겠습니다.
결과는 요약하면 다음과 같습니다:
status: SUCCEEDED
runtime: 4.24 s
compute units: 0.0012
...
약 4초 만에 Form 4 거래가 담긴 다섯 개의 행이 처리되었습니다. 필드 이름들이 대부분 설명해 주고 있습니다. "누가, 얼마나 팔았는지"를 계산하는 에이전트는 acquiredDisposed: "D"와 transactionCode: "S"로 필터링하고 value를 합산합니다. 이것은 README가 필요 없을 정도입니다.
더 자세히 살펴볼 만한 필드가 하나 있습니다: 사람의 경우 insiderName은 null입니다. Form 4 제출 서류는 개별 신고자(individual filer)의 이름을 기재하지만, 액터는 의도적으로 자연인의 이름은 절대 출력하지 않습니다. 내부자(insider)는 역할(insiderRole: "Officer (CFO)", isDirector, isTenPercentOwner)로 식별됩니다. 이름은 펀드, LLC, 지주회사와 같은 조직에 대해서만 출력됩니다:
// 법인 엔티티 마커. 이 중 어느 것과도 일치하지 않는 이름은 사람으로 간주되며 절대 출력되지 않습니다.
export const isEntityName = (name: string): boolean =>
ENTITY.test(name.trim()) && !/\b(FAMILY|REVOCABLE|IRREVOCABLE|LIVING)\b/i.test(name);
...
가족 신탁(Family)과 취소 가능 신탁(revocable trusts)은 사람으로 간주됩니다. 보고 의무 보유자 주소는 전혀 읽히지 않습니다. 에이전트에게는 인간 사용자보다 이것이 더 중요합니다. 왜냐하면 에이전트는 당신이 반환하는 모든 것을 CRM, 이메일 또는 보고서에 기꺼이 복사할 것이기 때문입니다. 개인 데이터가 데이터셋에 존재하지 않는다면, 거기에서 유출될 수도 없습니다. 저는 모든 액터에게 "비즈니스 데이터만"을 엄격한 규칙으로 만들었습니다 (저는 싱가포르에 거주하며, PDPA가 명백한 관심사이기 때문입니다). 내부자 거래 분석의 경우, 어차피 필요한 것은 역할과 규모입니다.
4. 입력 스키마: 명확하게 요구되는 필드 하나
입력 스키마는 에이전트의 도구 정의(tool definition)가 됩니다. 제 스키마는 열한 개의 필드를 가지고 있지만, 에이전트는 단지 하나만 이해하면 됩니다:
"companies": {
"title": "Companies",
"type": "array",
...
다른 모든 필드는 합리적인 default 값을 가집니다: 세 가지 모드, 지난 90일, 8개 기간, 최신 신고서 우선. 최소한의 유효 호출은 {"companies": ["NVDA"]}입니다.
필수 필드의 경우 prefill을 사용해야 하며, default를 사용해서는 안 됩니다. 이것이 가장 빠지기 쉬운 함정입니다. default 값도 가지고 있는 필수 필드는 에이전트가 받는 도구 스키마에서 required 플래그를 조용히 잃어버립니다. 에이전트에게 이 필드는 선택 사항처럼 보입니다. 그러면 에이전트는 해당 필드를 포함하지 않고 Actor를 호출하고, 플랫폼이 사용자의 default 값을 채우고, 에이전트는 다른 것을 물어봤음에도 불구하고 AAPL, MSFT, NVDA에 대한 데이터를 받게 됩니다. prefill은 콘솔에서 사람들을 위해 양식을 채워주고 여전히 예시 값을 보여주지만, required 속성은 살아남습니다. Apify가 에이전트에게 Actor를 보이도록 만드는 방법에 대한 자체 문서는 이를 명확하게 말합니다: default 값이 있는 필수 필드는 항상 버그입니다.
제가 모든 스키마에서 따르는 몇 가지 작은 규칙들:
- 500자 미만의 설명. 필드 및 Actor 설명은 도구 정의와 검색 카드에 표시됩니다. 긴 설명은 잘리거나 에이전트의 컨텍스트를 차지합니다. 무엇을 입력해야 하는지, 어떤 형식으로 해야 하는지, 하나의 예시와 함께 필수적인 내용부터 배치하세요.
- 모델이 보낼 가능성이 있는 것을 허용할 것.
companies는 티커(tickers), CIK 또는 이름 모두를 받습니다.sinceDate는2026-07-01또는 `
제 첫 버전에서는 명확한 메시지와 함께 Actor.fail()을 호출했습니다. 이는 맞는 것 같았지만, 견고성 검사(robustness pass)를 실행하고 두 가지 문제를 발견하면서 상황이 달라졌습니다. 실패(FAILED) 런은 Actor의 성공률에 포함되어 품질 점수에 영향을 미치고, 이 품질 점수는 검색 순위(에이전트가 보는 것과 같은 순위)에 영향을 미칩니다. 그리고 에이전트에게 '실패'는 "이 도구가 고장났다"처럼 보일 뿐, "당신이 잘못된 티커를 보냈다"라는 의미로 해석되지 않습니다.
따라서 유효하지 않은 입력은 이제 **성공(SUCCEEDED)**으로 끝나며, 비용 청구는 없고 어떻게 수정해야 하는지 알려주는 메시지가 표시됩니다:
if (unresolved.length) log.warning(`EDGAR에서 찾을 수 없음 (티커 또는 CIK 사용): ${unresolved.join(', ')}`);
if (!refs.length) {
// 알 수 없는 티커는 입력 문제이며, 충돌이 아닙니다: 비용 청구 없이 SUCCEEDED로 종료합니다.
...
종료 메시지는 런의 상태 메시지가 되며, 이는 에이전트가 다시 읽을 수 있는 런 상세 정보의 일부입니다. "EDGAR에서 찾을 수 없음: NVIDIAA. 티커 추가 (AAPL)…"라는 메시지를 읽은 에이전트는 다음 호출에서 스스로를 수정합니다. Actor.fail()은 실제 실패에만 사용됩니다. 즉, 모든 회사 조회에서 상위 스트림 오류(upstream error)가 발생했을 때만 런이 실패하는 것입니다. 그때는 정말로 무언가가 고장 난 경우이기 때문입니다.
careers-jobs Actor는 한 단계 더 나아가서 아무것도 가져오기 전에 (문장, 마크업, 300자 붙여넣기) 쓰레기를 거부합니다. 따라서 companies 배열에 프롬프트 주입된 쓰레기는 절대로 외부 요청이나 비용 청구로 이어지지 않습니다.
6. 신뢰할 수 있는 이벤트당 과금 방식
제 모든 Actor는 이벤트당 과금 방식을 사용합니다: 작은 초기 수수료와 레코드당 하나의 이벤트가 발생합니다. sec-edgar의 경우 기본 요금제에서 1,000개 레코드당 $2이므로, 위의 다섯 줄 NVIDIA 호출은 약 1센트가 들었습니다. 에이전트는 이 모델과 잘 작동합니다: 사용한 만큼 정확하게 비용을 지불하며, maxTotalChargeUsd를 통해 하드 상한선(hard ceiling)을 제공받습니다.
SDK에는 함정이 있습니다. pay-per-event 방식에서는 Actor.pushData(items, eventName)이 몇 개의 항목이 청구되었고 저장되었는지 알려주는 ChargeResult를 반환합니다. 사용자의 청구 한도가 배치 중간에 도달하면 SDK는 나머지 데이터를 버립니다. 제 Actor는 배치를 묶어 전송(API 호출당 100개 항목)하는데, 배치에서는 반환된 ChargeResult가 과소 또는 과대 계산될 수 있습니다. 정확히 왜 그런지는 제가 완전히 이해하지 못하지만, 클라이언트가 내부적으로 청크 요청을 처리하는 것이 원인일 가능성이 높습니다. 이것이 중요한 이유는 제 '마지막 실행 이후 변경된 항목만' 모드가 어떤 항목들이 전달되었는지 기억하기 때문입니다. 잘못된 카운트를 신뢰하면, 사용자는 자신이 받은 적 없는 항목에 대해 비용을 지불하거나 다시는 볼 수 없게 됩니다.
해결책은 반환 값에 의존하는 것을 멈추고 청구 관리자(charging manager)의 카운터를 전후로 측정하는 것입니다. 이것이 careers-jobs Actor에서 가져온 실제 push 함수입니다 (sec-edgar도 EVENT = 'record'로 동일한 것을 사용합니다):
// charged-event 카운터 델타를 측정하세요; 호출당 ChargeResult는 배치에 대해 신뢰할 수 없습니다.
const charging = Actor.getChargingManager();
const isPpe = charging.getPricingInfo().isPayPerEvent;
...
이후 BatchEmitter는 chargedCount를 진실로 간주합니다: 오직 그 개수만큼의 항목만 방출된 것으로 계산되고, 오직 그 항목들의 키만이 '본 것(seen)' 목록에 추가되며, 한계에 도달하면 실행이 깔끔하게 중지됩니다:
const res = (await this.push(batch.map((b) => b.item))) || {};
const stored = Math.min(res.chargedCount ?? batch.length, batch.length);
this.emitted += stored;
...```
따라서 에이전트의 10센트 한도에 의해 버려진 항목은 '본 것'으로 표시되지 않으며 다음 실행 때 다시 돌아옵니다. `!isPpe` 분기 또한 중요합니다: pay-per-event가 아닌 경우(예: 로컬 실행) ChargeResult는 모든 것이 저장되었음에도 불구하고 0이 청구되었다고 보고합니다.
테스트 시 알아두면 좋은 점이 하나 더 있습니다: 실행의 `chargedEventCounts`와 데이터셋의 `itemCount`는 실행 종료 시점보다 몇 초 정도 지연됩니다. 결제 버그를 발견했다고 결정하기 전에 다시 가져오세요.
## 7. 작은 예산으로 시작하기
이벤트당 과금(Pay-per-event) Actors에는 `minimalMaxTotalChargeUsd`라는 가격 설정이 있습니다. 이는 호출자가 실행을 시작할 때 설정할 수 있는 가장 낮은 `maxTotalChargeUsd`입니다. 에이전트의 예산이 이 하한선보다 낮으면, 해당 실행은 시작되지 않습니다.
에이전트는 의도적으로 작은 예산을 사용하는 경향이 있습니다. Claude는 5개 행 조회(five-row lookup)에 $0.10을 선택했는데, 이는 에이전트가 할 수 있는 합리적인 행동입니다. 만약 하한선이 1달러였다면 그 호출은 차단되었을 것입니다. 제가 Actors의 가격을 재조정했을 때, 모든 Actor의 하한선을 **$0.05**로 낮췄습니다.
"minimalMaxTotalChargeUsd": 0.05
두 가지 관련된 세부 사항이 있습니다. 첫째, API를 통해 이벤트당 과금 가격을 변경할 때는 새로운 `pricingInfos` 항목을 **추가(append)**해야 하며, 기존 항목을 대체하는 것은 실패합니다. 둘째, 기본 실행 메모리는 `actor.json`에서 1 GB로 설정되는데, 이는 Actor-start 이벤트가 메모리 1GB당 과금되기 때문입니다. 에이전트들은 메모리를 거의 재정의하지 않으므로, 개발자가 선택한 기본값이 곧 그들이 지불하는 금액이 됩니다.
## 8. 갖추면 좋은 다른 가드레일(guardrails)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기