규칙 대신 actor context로 시작하는 이유는 무엇인가요?
트래픽에서 AI agent를 발견한 많은 팀은 먼저 규칙을 적용합니다. user agent를 차단하고, challenge를 추가하고, rate limit을 더 엄격하게 설정합니다. 하지만 트래픽을 이해하기 전에 작성한 규칙은 대체로 엉뚱한 대상을 건드립니다. 정당한 assistant는 차단되고, 실제 사용자는 challenge를 받으며, 정말 우려했던 automation은 애초에 스스로를 식별하지 않았기 때문에 계속 들어옵니다.
actor context는 규칙보다 앞선 단계입니다. 각 request마다, 당신의 application이 누가 또는 무엇이 동작하고 있는지 관찰해 알 수 있는 내용을 기록합니다. 대화형 사람인지, AI assistant와 함께 일하는 사람인지, autonomous agent인지, 아니면 아직 구분할 수 없는 대상인지입니다. 그 판단의 근거도 함께 기록하고, request 결과 옆에 남겨둡니다. 이렇게 일주일이나 이주일만 해도 어떤 규칙 템플릿도 줄 수 없는 것을 얻게 됩니다. 바로 자신의 traffic에 대한 지도입니다. 사이트로 agent가 실제 들어오는지 아직 결정 중이라면, website traffic이 AI agent에서 오는지 확인하는 방법부터 시작하세요.
이 가이드는 단순 middleware를 사용해 Node.js application에서 그 계층을 구축합니다. Express에서 잘 동작하며, 같은 개념을 Fastify, Koa, Hono 또는 Next.js route handler에도 적용할 수 있습니다.
무엇을 만들게 되나요?
끝까지 따라오면 다음을 갖게 됩니다:
- 각 request에서 actor evidence를 추출하는 middleware.
- 모든 결과에 대해 근거를 명시한 작고 명확한 classification.
- request 처리를 바꾸지 않는 구조화된 observation logs.
- 나중의 handler가 읽을 수 있도록 결과를 request에 연결하는 방법.
여기에는 어떤 request도 차단하는 내용이 없습니다. 의도한 바입니다.
1단계: 서버가 실제로 볼 수 있는 evidence는 무엇인가요?
서버는 사람들이 생각하는 것보다 적게 봅니다. 코드를 쓰기 전에 edge에서 사용할 수 있는 signal을 정리하세요:
- Request signature. 일부 automated client는 HTTP Message Signatures(RFC 9421)를 사용해 request에 서명하고,
Signature-Input및Signatureheader를 보내며, 종종 operator가 공개한 key를 가리키는Signature-Agentheader를 함께 보냅니다. 이는 IETF의 Web Bot Auth protocol draft에 설명되어 있습니다. - Declared identity. client는
User-Agentheader,Fromheader, 또는 MCP client name과 같은 protocol metadata를 통해 자신을 밝힐 수 있습니다. MCP client는 2025-11-25 revision의 initialize request에서 스스로 정한clientInfoname을 보고하며, 2026-07-28 revision은 client identity를 per-request metadata로 전달합니다. 이것들은 선언이며, 누구나 복사할 수 있습니다. - Authentication context. request가 session cookie, bearer token 또는 API key를 포함하는지, 그리고 그것이 어떤 종류의 credential인지입니다.
- Protocol shape. Accept header, client가 HTML이나 JSON을 요청하는지, page asset을 불러오는지, redirect와 cookie를 어떻게 처리하는지입니다.
- Application declarations. 당신의 product는 네트워크가 알 수 없는 정보를 알고 있을 수 있습니다. 예를 들어 사용자가 "AI assisted" 옵션을 선택했는지, 또는 request가 내장 assistant feature에서 왔는지입니다.
각 signal의 강도는 다릅니다. 검증된 signature는 확인할 수 있습니다. user agent string은 주장일 뿐입니다. cookie가 없는 것은 단지 없는 것일 뿐입니다. 이들을 분리해서 보는 것이 이 작업의 핵심입니다.
2단계: 결과를 명시적으로 모델링하세요
detect logic를 쓰기 전에 actor result의 형태를 정의하세요. 작고 typed된 model은 classification이 무엇을 의미하는지 모두가 분명히 이해하게 해줍니다. 여기서 쓰는 네 가지 class는 Human, AI-assisted 또는 agent: application traffic을 위한 실용적 taxonomy에서 설명합니다.
export type ActorClass = "human" | "human_ai" | "agent" | "unknown";
export type EvidenceBasis = "verified" | "declared" | "inferred" | "unknown";
export interface ActorEvidence {
signaturePresent: boolean;
signatureAgent?: string;
declaredClient?: string;
credentialType: "session" | "api_key" | "bearer" | "none";
acceptsHtml: boolean;
appDeclaredAssist: boolean;
}
export interface ActorResult {
actor: ActorClass;
basis: EvidenceBasis;
reasons: string[];
evidence: ActorEvidence;
observedAt: string;
}가장 중요한 두 field는 다음과 같습니다. actor는 class입니다. basis는 그렇게 판단한 이유입니다. 검증된 signature에 기반한 agent result와 user agent string에 기반한 agent result는 둘 다 "agent"이지만, 나중에 다뤄야 할 방식은 매우 다르므로 절대 같은 것으로 합치면 안 됩니다.
3단계: middleware에서 evidence를 추출하세요
추출 단계는 header와 request state를 읽습니다. 아직 아무것도 판단하지 않습니다.
import type { Request, Response, NextFunction } from "express";
const KNOWN_AGENT_TOKENS = ["bot", "crawler", "spider", "agent", "gpt", "claude"];
export function extractEvidence(req: Request): ActorEvidence {
const ua = (req.get("user-agent") || "").toLowerCase();
const auth = req.get("authorization") || "";
const hasSession = Boolean(req.cookies?.session);
let credentialType: ActorEvidence["credentialType"] = "none";
if (hasSession) credentialType = "session";
else if (auth.toLowerCase().startsWith("bearer ")) credentialType = "bearer";
else if (req.get("x-api-key")) credentialType = "api_key";
const declaredToken = KNOWN_AGENT_TOKENS.find((t) => ua.includes(t));
return {
signaturePresent: Boolean(req.get("signature") && req.get("signature-input")),
signatureAgent: req.get("signature-agent") || undefined,
declaredClient: declaredToken ? ua.slice(0, 200) : undefined,
credentialType,
acceptsHtml: (req.get("accept") || "").includes("text/html"),
appDeclaredAssist: req.get("x-app-assist") === "1",
};
}이 code에 대한 몇 가지 참고사항입니다:
signaturePresent는 signature header가 존재한다는 사실만 기록합니다. validation은 별도 단계이며, signed agent request 검증 가이드에서 다룹니다.- user agent token list는 의도적으로 단순합니다. 이것은 declaration을 만들 뿐, verification은 만들지 않습니다.
x-app-assist는 사용자가 내장 AI feature를 통해 작업할 때 당신의 frontend가 보내는 내용을 대신합니다. 실제 mechanism으로 교체하세요.
4단계: 명시된 근거를 바탕으로 classification하세요
이제 evidence를 result로 바꿉니다. rule은 짧게 유지하고, strongest evidence에서 weakest로 정렬하며, 항상 Unknown으로 떨어지게 하세요.
export async function classify(
evidence: ActorEvidence,
verifySignature: () => Promise<boolean>,
): Promise<ActorResult> {
const reasons: string[] = [];
const observedAt = new Date().toISOString();
if (evidence.signaturePresent && (await verifySignature())) {
reasons.push("request signature validated");
return { actor: "agent", basis: "verified", reasons, evidence, observedAt };
}
if (evidence.appDeclaredAssist) {
reasons.push("application reported its built-in assistant");
return { actor: "human_ai", basis: "declared", reasons, evidence, observedAt };
}
if (evidence.declaredClient) {
reasons.push("client declared itself as automated");
return { actor: "agent", basis: "declared", reasons, evidence, observedAt };
}
if (evidence.credentialType === "session" && evidence.acceptsHtml) {
reasons.push("interactive browser session");
return { actor: "human", basis: "inferred", reasons, evidence, observedAt };
}
reasons.push("insufficient evidence");
return { actor: "unknown", basis: "unknown", reasons, evidence, observedAt };
}이 code가 하지 않는 일에 주목하세요. declaration을 verification으로 격상하지 않습니다. interactive signal이 없는 request에 Human을 할당하지도 않습니다. 판단할 수 없으면 Unknown이라고 말합니다. Unknown은 유효하고 유용한 답이며, visibility가 어디서 끝나는지 정확히 알려줍니다. 이에 대한 전체 설명은 Unknown이 AI traffic에 대해 유효한 result인 이유에서 다룹니다.
5단계: observation mode로 연결하세요
middleware를 초기에 mount하고, result를 request에 붙인 다음, log하세요. 그 결과로 분기하지 마세요.
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
app.use(async (req, res, next) => {
try {
const evidence = extractEvidence(req);
const result = await classify(evidence, async () => false);
res.locals.actor = result;
res.on("finish", () => {
logActor({
route: req.route?.path ?? req.path,
method: req.method,
status: res.statusCode,
actor: result.actor,
basis: result.basis,
reasons: result.reasons,
});
});
} catch {
res.locals.actor = undefined;
}
next();
});이것이 안전한 이유는 세 가지입니다:
tryblock은 detection 실패가 response에 영향을 주지 않도록 보장합니다. observation은 fail open이어야 합니다.- logging은
finish에서 이루어지므로 실제 status code를 기록하고, 나중에 classification과 outcome을 비교할 수 있습니다. verifySignature은 실제 validation을 구현할 때까지false로 stub 처리됩니다. 이렇게 해야 signed request를 declared 또는 unknown bucket에 두고 과장해서 다루지 않게 됩니다.
6단계: 단순한 debugging이 아니라 analysis를 위해 log를 남기세요
집계할 수 있는 log를 작성하세요. 요청당 하나의 structured line이면 충분합니다.
function logActor(entry: Record<string, unknown>) {
process.stdout.write(JSON.stringify({ type: "actor_observation", ...entry }) + "\n");
}이 log에는 개인 data를 넣지 마세요. 필요한 것은 route, class, basis, reason, outcome입니다. traffic 구성을 이해하는 데 전체 IP address, raw cookie, request body는 필요하지 않습니다. correlation을 위해 어떤 identifier를 보관해야 한다면 rotating salt로 hash하고, retention 기간은 짧게 설정하세요.
7단계: 행동하기 전에 먼저 data를 읽으세요
일반적인 주간 패턴이 보일 만큼 충분히 observation mode로 운영하세요. 그런 다음 몇 가지 질문에 답해 보세요:
- 어떤 route가 agent를 끌어들이나요? 문서, catalog, search endpoint에서 흔합니다. account와 checkout flow에서는 대체로 그러면 안 됩니다.
- verified와 declared agent result의 비중은 얼마나 되나요? 이 비율은 policy를 작성할 때 identity에 얼마나 안전하게 의존할 수 있는지 알려줍니다.
- Unknown은 어디에 집중되나요? 특정 route에서 Unknown rate가 높다면, 예를 들어 frontend code가 전혀 실행되지 않는 page처럼 해당 영역에서 signal이 부족하다는 뜻일 수 있습니다.
- class별 outcome은 다른가요? error rate, conversion 단계, abuse report를 class별로 비교하세요. 여기서 실제 policy 아이디어가 나옵니다.
답은 종종 예상과 다릅니다. 많은 팀이 자신들이 겪는 "bot 문제"의 상당 부분이 실제로는 정형화된 endpoint만 있으면 기꺼이 사용할 legitimate automation이라는 사실을 발견합니다.
8단계: 결과를 app의 나머지 부분에 노출하세요
data가 흐르기 시작하면, 비강제적 용도로 handler가 res.locals.actor를 읽게 하세요. 예를 들어 analytics tag, response format 선택, 또는 support ticket이 AI-assisted session에서 왔다는 기록입니다. 이런 용도는 누구도 막지 않으면서 경험을 개선합니다.
app.get("/docs/:page", (req, res) => {
const actor = res.locals.actor as ActorResult | undefined;
if (actor?.actor === "agent" && req.accepts("text/markdown")) {
return res.type("text/markdown").send(renderMarkdown(req.params.page));
}
return res.render("doc", { page: req.params.page });
});challenge, rate limit, step-up check 같은 enforcement는 false positive를 측정한 뒤에 나옵니다. actor-aware policy를 단계적으로 도입하는 가이드에서 그 과정을 다룹니다.
어떤 실수를 피해야 하나요?
- user agent를 identity로 취급하는 것. 가장 쉽게 바꿀 수 있는 header입니다. declaration으로 기록하세요. AI operator도 그렇게 말합니다. Anthropic의 호스팅 browser agent 식별 가이드는 누구나 같은 User-Agent header를 보낼 수 있다고 밝히며, traffic을 진짜로 취급하기 전에 공개된 IP address를 확인하라고 권고합니다.
- 기본값을 Human으로 두는 것. bot signal을 찾지 못할 때마다 Human을 반환하면, Human 수치가 부풀려지고 policy가 잘못된 traffic을 신뢰하게 됩니다.
- class와 basis를 섞는 것. 항상 둘 다 저장하세요. "Agent"만 보여 주는 dashboard는 검증된 operator와 복사된 string의 차이를 가립니다.
- observation 중에 차단하는 것. 임시 차단조차 측정을 무너뜨립니다. 차단된 client는 행동을 바꾸기 때문입니다.
Proof of Actor는 어디에 맞물리나요?
위의 middleware는 훌륭한 출발점이며, 핵심 model을 보여줍니다. evidence를 먼저 보고, 모든 result에 명시적 basis를 두며, Unknown을 보이게 유지하고, decision은 application에 맡깁니다. Proof of Actor는 같은 model을 바탕으로 server, edge, browser, MCP context 전반에서 더 넓은 signal coverage와 함께 발전하며, Detect, Understand, Route를 자신 있게 수행할 수 있도록 돕는 analytics를 제공합니다.
production app에서 더 나아가는 데 도움이 필요하다면, early access를 신청하세요. 전체 decision flow를 보려면 Proof of Actor가 작동하는 방식을 읽어 보세요.
출처
- RFC 9421: HTTP Message Signatures: signed request의 기반이 되는 standard
- draft-ietf-webbotauth-httpsig-protocol:
Signature-Agentheader를 정의하는 IETF Web Bot Auth draft (2026년 11월 기준 진행 중) - RFC 9110: HTTP Semantics, User-Agent and From: self-reported client identification header
- MCP specification 2025-11-25: Lifecycle:
initializerequest와clientInfo - MCP specification 2026-07-28: Versioning and Compatibility: per-request version, identity, capability metadata
- Anthropic: Anthropic-hosted browser가 자신을 식별하는 방법: user agent token, 공개된 IP address, 그리고 header는 복사될 수 있다는 경고