Day 19. Harness 엔지니어링 — 수동 가드에서 선언적 설정으로
안녕하세요, 여러분의 AI 튜터 홍순구입니다.
Day 12에서 처음 만났죠. "에이전트가 망가지면 어떻게 되나요?" 하고 일부러 가드레일을 끄고 폭주시켜 봤던 기억, 나시죠? 무한 루프, 토큰 폭발, 도구 남용 — 엔진만 있고 브레이크가 없는 차의 위험을 몸으로 체감했어요.
그래서 Day 14에서 직접 4개의 advisor를 손으로 구현했어요. MaxIterationsAdvisor, DurationTimeoutAdvisor, UsageBudgetAdvisor, ToolInvocationCounterAdvisor — 생성자에 숫자를 직접 넣어서 코드 안에 한도를 고정해 뒀죠.
그리고 Day 18 마무리에서 이렇게 예고했어요. "Day 14에서 손으로 구현한 4가드가 선언적 설정으로 전환되는 모습을 볼 거예요." 오늘이 바로 그 수확의 시간이에요.
🎯 학습 목표
- Day 14에서 하드코딩했던 4가드를
@ConfigurationProperties로 외부화하고, advisor chain을 선언적으로 조립한다. - 에이전트별 도구 사용 범위를 화이트리스트 기반 advisor로 제한한다.
- Spring AI
Evaluator인터페이스로 에이전트 출력 품질을 자동 평가하는 harness를 구축한다. - 토큰 사용량을 3축(prompt/completion/total)으로 모니터링하는 advisor를 구현한다.
Step 1. Harness 엔지니어링 복습 — Day 12~14 개념의 수확
Day 12에서 "harness란 무엇인가"를 비유로 처음 만났고, Day 14에서 4개의 advisor를 직접 코드로 구현했어요. 그때는 개별 부품을 하나씩 만들면서 "왜 이게 필요한지"를 체감하는 단계였어요. 오늘은 그 부품들을 하나의 체계로 묶어서 프로덕션에 올릴 수 있는 수준으로 끌어올리는 시간이에요.
Harness 6구성요소
Day 12에서 5요소로 소개했던 harness를 오늘은 6개로 정밀하게 나눠요. 에이전트를 프로덕션에 올리려면 이 6개가 빠짐없이 갖춰져야 해요.
| 번호 | 구성요소 | 역할 | ai-friends 해당 코드 |
|---|---|---|---|
| 1 | Input Validation | 사용자 입력의 길이, 유해성, 형식을 검증 | 컨트롤러 @Valid + 커스텀 필터 |
| 2 | Tool Permission Scoping | 에이전트가 호출할 수 있는 도구의 범위를 제한 | defaultTools(...) 등록 목록 |
| 3 | Execution Loop Control | 반복 횟수, 타임아웃으로 무한 루프 차단 | MaxIterationsAdvisor, DurationTimeoutAdvisor |
| 4 | Output Validation | LLM 응답의 형식, 설정 위반, 유해 콘텐츠 검증 | Day 14 Evaluator 패턴 |
| 5 | Cost Guardrail | 토큰 예산, 도구 호출 횟수, Rate Limit으로 비용 폭주 차단 | UsageBudgetAdvisor, ToolInvocationCounterAdvisor |
| 6 | Observability Hook | 호출 로그, 메트릭, 트레이싱으로 운영 가시성 확보 | Day 13 WorkflowLoggingAdvisor |
Day 14에서 손으로 구현한 4가드 — 생성자 시그니처만 빠르게 복기
Day 14에서 만든 4개 advisor가 어떻게 생겼는지 시그니처만 한 번 돌아볼게요.
// kr.spartaclub.aifriends.agent.advisor.MaxIterationsAdvisor
public MaxIterationsAdvisor(int maxIterations) { ... }
// order: Ordered.HIGHEST_PRECEDENCE — 가장 바깥쪽
// kr.spartaclub.aifriends.agent.advisor.DurationTimeoutAdvisor
public DurationTimeoutAdvisor(Duration timeout) { ... }
// order: HIGHEST_PRECEDENCE + 10
// kr.spartaclub.aifriends.agent.advisor.UsageBudgetAdvisor
public UsageBudgetAdvisor(long maxTotalTokens) { ... }
// order: HIGHEST_PRECEDENCE + 20
// kr.spartaclub.aifriends.agent.advisor.ToolInvocationCounterAdvisor
public ToolInvocationCounterAdvisor(int maxToolInvocations) { ... }
// order: HIGHEST_PRECEDENCE + 30
order 순서가 가장 바깥쪽에서 안쪽으로 설정돼 있어요. 반복 횟수가 먼저 차단되고, 그다음 시간, 토큰, 도구 호출 순서로 걸러져요. 양파 껍질처럼 바깥부터 하나씩 벗기는 구조예요.
"왜 프레임워크 수준으로 올리는가" — 3축
Day 14에서 이 4개를 코드에 직접 숫자를 써서 만들었어요. 동작은 하지만 프로덕션에서 쓰기엔 세 가지 문제가 있어요.
- 재현성 — 개발자 A가
maxIterations = 5로, 개발자 B가maxIterations = 10으로 따로 설정하면 같은 에이전트가 환경마다 다르게 동작해요. - 감사(Audit) — "이 에이전트의 한도가 언제 누가 바꿨는지"를 git diff로 추적하려면, 코드 여러 곳에 흩어진 숫자를 뒤져야 해요.
- 팀 공유 — 새 팀원이 합류했을 때 "이 에이전트의 가드레일이 어떻게 되어 있지?" 하고 물으면 코드 깊숙한 곳을 파야 답이 나와요.
이 세 문제를 한 번에 해결하는 방법이 있어요. 숫자를 코드 밖으로 빼서 application.yml 한 장에 모으는 거예요. Spring Boot가 가장 잘하는 일이죠.
💡 튜터의 결론
Day 14에서 4개를 손으로 구현했기에 "왜 이게 필요한지"를 알고 있어요. 오늘은 그 이해 위에 "팀과 운영 환경에서 쓸 수 있는 형태"로 올리는 단계예요. application.yml 한 장으로 가드레일 전체를 선언적으로 관리하게 만들어 볼게요.
Step 2. Day 14 수동 Advisor에서 `@ConfigurationProperties` 외부화로
Day 14에서 에이전트 4가드를 만들 때 숫자를 어떻게 넣었는지 기억하시나요? 정적 팩토리 메서드에 직접 숫자를 전달했어요. 이번 Step에서는 그 하드코딩된 값을
application.yml로 빼내서 프로파일별로 다르게 설정할 수 있게 만들어요.
Before — Day 14의 하드코딩 방식
Day 14에서 AgentChatClientConfig에 정적 팩토리를 만들었어요. 호출하는 쪽에서 4개의 숫자를 직접 전달하는 방식이었죠.
// kr.spartaclub.aifriends.agent.config.AgentChatClientConfig
// (Day 14 Step 9 — 정적 팩토리 시그니처)
public static List<Advisor> guardAdvisors(int maxIterations,
Duration timeout,
long maxTotalTokens,
int maxToolInvocations) {
return List.of(
new MaxIterationsAdvisor(maxIterations),
new DurationTimeoutAdvisor(timeout),
new UsageBudgetAdvisor(maxTotalTokens),
new ToolInvocationCounterAdvisor(maxToolInvocations)
);
}
호출하는 쪽 코드는 이런 모습이었어요.
List<Advisor> guards = AgentChatClientConfig.guardAdvisors(
5, Duration.ofSeconds(30), 8000L, 10);
동작에는 문제가 없어요. 하지만 이 5, 30, 8000L, 10이라는 숫자가 서비스 코드 한가운데에 고정되어 있어요. dev 환경에서는 느슨하게, prod 환경에서는 엄격하게 바꾸고 싶으면? 코드를 수정하고 다시 빌드해야 해요.
After — HarnessProperties + application.yml
Spring Boot의 @ConfigurationProperties를 써서 4개의 한도를 yml 파일로 빼요. 코드에는 "어떤 설정을 읽을지"만 선언하고, 실제 값은 yml에 둬요.
// kr.spartaclub.aifriends.harness.config.HarnessProperties
@ConfigurationProperties(prefix = "ai-friends.harness")
public class HarnessProperties {
private int maxIterations = 10;
private Duration timeout = Duration.ofSeconds(30);
private long maxTotalTokens = 8000L;
private int maxToolInvocations = 20;
// getter / setter 생략
public void validate() {
if (maxIterations <= 0) {
throw new IllegalArgumentException(
"maxIterations 는 1 이상이어야 합니다. 현재값=" + maxIterations);
}
if (timeout == null || timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException(
"timeout 은 양수 Duration 이어야 합니다. 현재값=" + timeout);
}
if (maxTotalTokens <= 0) {
throw new IllegalArgumentException(
"maxTotalTokens 는 1 이상이어야 합니다. 현재값=" + maxTotalTokens);
}
if (maxToolInvocations <= 0) {
throw new IllegalArgumentException(
"maxToolInvocations 는 1 이상이어야 합니다. 현재값=" + maxToolInvocations);
}
}
}
이 부분이 왜 이렇게 되어 있냐면, @ConfigurationProperties가 application.yml의 ai-friends.harness 아래 값을 자동으로 읽어서 필드에 채워 줘요.
prefix가 yml의 키 경로와 정확히 대응하는 거예요. 그리고 각 필드에 기본값을 넣어 뒀어요. yml에 아무것도 안 써도 기본값으로 동작하니까, 처음 도입할 때 부담이 없어요.
validate() 메서드도 주목해 주세요. advisor를 조립하기 직전에 이 메서드를 호출해서, 잘못된 설정이 advisor 생성자까지 흘러가기 전에 빠르게 실패(fail-fast)하도록 해요. "0이나 음수를 넣어 버렸는데 런타임에 이상한 동작을 하다가 새벽 3시에 알림이 울리는" 시나리오를 막는 방어선이에요.
application.yml 설정 예시
ai-friends:
harness:
max-iterations: 10
timeout: 30s
max-total-tokens: 8000
max-tool-invocations: 20
Spring Boot의 Duration 바인딩 덕분에 timeout: 30s라고 쓰면 Duration.ofSeconds(30)으로 자동 변환돼요. 1m, 500ms 같은 표현도 그대로 써요.
Before/After 비교 요약
| 축 | Before (Day 14 하드코딩) | After (Day 19 외부화) | 무엇이 좋아졌나 |
|---|---|---|---|
| 값 위치 | Java 코드 안 guardAdvisors(5, ...) |
application.yml 한 장 |
코드 수정 없이 한도 변경 가능 |
| 환경별 분리 | 불가능 (코드 = 설정) | application-dev.yml / application-prod.yml |
dev는 느슨, prod는 엄격 |
| 팀 가시성 | 서비스 코드 깊숙이 파야 확인 | yml 파일 열면 한눈에 | 새 팀원도 즉시 파악 |
| 검증 | 각 advisor 생성자에서 개별 검증 | validate() 한 곳에서 일괄 fail-fast |
잘못된 설정이 더 빨리 드러남 |
🙋 학생 질문 — "튜터님, 왜 record가 아니라 일반 클래스인가요?"
좋은 질문이에요! Day 4에서 record를 써서 DTO를 만들었으니 자연스러운 의문이죠.
Spring Boot 3.x의 @ConfigurationProperties 바인딩은 기본적으로 setter 기반이에요. yml 값을 읽어서 객체를 만들 때 기본 생성자로 인스턴스를 만든 뒤 setter로 값을 채워 넣거든요.
record는 생성자 바인딩(@ConstructorBinding)으로도 가능하지만, 이때 기본값을 yml에 안 썼을 때 null이 들어가는 문제가 생겨요.
일반 클래스면 private int maxIterations = 10; 처럼 필드 초기화로 기본값을 안전하게 줄 수 있어요. 프로퍼티 클래스처럼 "설정 안 하면 기본값, 설정하면 덮어쓰기"가 필요한 곳에서는 setter 방식 일반 클래스가 더 안전해요.
다음 Step에서는 이 HarnessProperties를 받아서 4가드 advisor를 조립하는 HarnessAdvisorChainConfig를 만들어 볼게요.
Step 3. HarnessAdvisorChainConfig — 4가드 통합 빈 등록
Step 2에서
HarnessProperties를 만들어 application.yml 한 곳에 4가드 값을 모았죠. 설정은 준비됐는데, 이 값들을 실제 advisor 인스턴스로 조립하는 코드가 아직 없어요. 이번 Step에서는 properties를 받아 advisor 4종을 한 번에 묶어주는 조립 전담 클래스를 만들어볼게요.
Before / After 비교
Day 14에서 4가드를 처음 도입했을 때, 조립 코드가 어떻게 생겼는지 다시 볼게요.
// Before — Day 14 AgentChatClientConfig.guardAdvisors (정적 팩토리)
List<Advisor> guards = AgentChatClientConfig.guardAdvisors(
5, Duration.ofSeconds(30), 8000L, 10);
숫자 네 개가 나란히 들어가요. 첫 번째가 반복 횟수인지 토큰 예산인지, 코드만 봐서는 알 수 없죠. Duration.ofSeconds(30) 덕분에 두 번째가 타임아웃인 건 겨우 짐작이 되는 정도예요.
// After — Day 19 HarnessAdvisorChainConfig.guardAdvisors (properties 기반)
List<Advisor> guards = harnessConfig.guardAdvisors(harnessProps);
application.yml에 이름이 붙은 프로퍼티로 관리되니까, 호출 코드에서는 "어떤 값으로 조립할지"를 신경 쓸 필요가 없어졌어요. dev 프로파일에서는 느슨하게, prod에서는 빡빡하게 — yml 한 줄만 바꾸면 되는 구조입니다.
HarnessAdvisorChainConfig 코드
// kr.spartaclub.aifriends.harness.config.HarnessAdvisorChainConfig
@Configuration
@EnableConfigurationProperties(HarnessProperties.class)
public class HarnessAdvisorChainConfig {
public List<Advisor> guardAdvisors(HarnessProperties props) {
props.validate();
return List.of(
new MaxIterationsAdvisor(props.getMaxIterations()),
new DurationTimeoutAdvisor(props.getTimeout()),
new UsageBudgetAdvisor(props.getMaxTotalTokens()),
new ToolInvocationCounterAdvisor(props.getMaxToolInvocations())
);
}
}
코드가 굉장히 짧죠. 핵심은 두 가지예요.
첫째, props.validate()를 맨 앞에서 호출해요. Step 2에서 만든 검증 메서드가 여기서 동작합니다. maxIterations가 0이거나 timeout이 null이면 advisor 생성자까지 가기도 전에 IllegalArgumentException이 터져요.
잘못된 설정이 런타임 깊숙이 들어가기 전에 빠르게 실패하는 겁니다 — fail-fast 원칙이에요.
둘째, List.of(...) 로 4개 advisor를 불변 리스트에 담아 반환해요. 호출하는 쪽에서 리스트에 임의로 advisor를 추가하거나 빼는 것을 방지하는 거예요.
Advisor 실행 순서 (order 값)
advisor chain에서는 getOrder() 값이 작을수록 바깥쪽에서 먼저 실행돼요. 요청이 들어올 때는 바깥에서 안으로, 응답이 나올 때는 안에서 바깥으로 거치는 구조입니다.
| 실행 순서 | Advisor | order 값 | 역할 |
|---|---|---|---|
| 1 | MaxIterationsAdvisor | HIGHEST_PRECEDENCE (+0) | 반복 횟수 차단 |
| 2 | ToolPermissionFilter | +5 | 도구 권한 필터 (Step 4) |
| 3 | DurationTimeoutAdvisor | +10 | 시간 차단 |
| 4 | UsageBudgetAdvisor | +20 | 토큰 예산 차단 |
| 5 | ToolInvocationCounter | +30 | 도구 호출 횟수 차단 |
| ... | UsageTrackingAdvisor | LOWEST_PRECEDENCE | 모니터링 (Step 7) |
숫자가 커질수록 안쪽에 위치하고, 가장 안쪽의 UsageTrackingAdvisor는 모든 가드를 통과한 호출만 추적해요. 가드가 중간에 차단하면 안쪽 advisor는 아예 실행되지 않는 구조입니다.
왜 매번 새 인스턴스를 만드는가
코드를 보면 guardAdvisors() 메서드가 호출될 때마다 new MaxIterationsAdvisor(...), new DurationTimeoutAdvisor(...) 등 새 객체를 만들어요. Spring의 @Bean으로 싱글톤 등록하지 않는 이유가 있습니다.
Day 14에서 이 advisor들을 만들 때, 내부에 AtomicInteger나 AtomicLong 같은 상태 카운터를 넣어뒀어요.
예를 들어 MaxIterationsAdvisor는 "지금까지 몇 번 호출됐는지"를 AtomicInteger로 세고 있죠. 만약 싱글톤 빈으로 등록하면, A 사용자의 채팅에서 5번 세고 난 카운터를 B 사용자의 채팅에서도 이어서 쓰게 돼요. 두 번째 사용자는 이미 5부터 시작하는 셈이죠.
그래서 비즈니스 사이클(한 번의 채팅 요청 처리)이 시작될 때마다 guardAdvisors(props)를 호출해서 카운터가 0인 깨끗한 advisor 세트를 받아 쓰는 겁니다.
사용 패턴
실제 서비스 코드에서는 이렇게 씁니다.
@Autowired HarnessAdvisorChainConfig harnessConfig;
@Autowired HarnessProperties harnessProps;
List<Advisor> guards = harnessConfig.guardAdvisors(harnessProps);
String reply = chatClient.prompt()
.user(userMessage)
.advisors(guards.toArray(Advisor[]::new))
.call()
.content();
ChatClient.prompt() 체인에 .advisors(...) 한 줄이면 4가드가 전부 걸려요. Day 14에서 숫자 네 개를 직접 적던 코드와 비교하면, 호출하는 쪽의 부담이 확 줄었죠.
🙋 "왜 @Bean이 아니라 일반 메서드인가요?"
좋은 질문이에요. @Bean으로 등록하면 Spring 컨테이너가 애플리케이션 시작 시 딱 한 번 만들어서 싱글톤으로 관리해요. 그런데 이 advisor들은 내부 카운터를 들고 있어서, 한 번 쓰고 나면 카운터가 쌓여 있는 상태가 돼요.
매 요청마다 카운터가 0인 상태로 시작해야 하니까, @Bean 대신 일반 메서드로 만들어서 호출할 때마다 새 인스턴스를 받는 구조로 가는 거예요. @Configuration 클래스에 있지만 @Bean이 안 붙은 메서드 — 헷갈릴 수 있는데, Spring에서는 이런 패턴을 "팩토리 메서드"라고 부릅니다.
💡 튜터의 결론
HarnessAdvisorChainConfig는 properties를 받아 advisor 4종을 조립하는 팩토리예요.@Bean싱글톤이 아니라 매 사이클마다 새 인스턴스를 만드는 이유는, advisor가 내부에 상태(카운터)를 들고 있기 때문입니다.
Day 14의 정적 팩토리에서 숫자 하드코딩을 없애고, yml 설정으로 뺀 것까지가 Step 2~3의 여정이었어요. 그런데 4가드가 "에이전트의 행동 횟수"를 제한하는 거라면, "에이전트가 어떤 도구를 쓸 수 있는가"는 또 다른 축이에요. 다음 Step에서 바로 이 도구 권한 제어를 advisor 한 층으로 해결해볼게요.
Step 4. Tool Permission Scoping — 에이전트별 도구 제한
Day 11에서
@Tool3종(날씨 조회, 게임 상태 로드, 호감도 조회)을 만들었고, Day 14에서는 ARIA, REX, LUNA 세 캐릭터의 워커 ChatClient에 세 도구를 전부 등록했어요. 그런데 운영 관점에서 생각해보면, "모든 에이전트가 모든 도구를 쓸 수 있다"는 건 위험하죠. 이번 Step에서는 에이전트마다 사용 가능한 도구 목록을 제한하는 화이트리스트 기반 필터를 만들어볼게요.
왜 도구 제한이 필요한가
카페에서 아르바이트생과 매니저를 비교해볼게요. 아르바이트생은 주문 접수, 음료 제조는 할 수 있지만 매출 정산이나 재고 발주는 못 해요. 매니저만 할 수 있죠.
AI 에이전트도 마찬가지예요. ARIA 캐릭터는 날씨를 알려주고 호감도를 조회하는 건 괜찮지만, 게임 상태를 저장하는 건 REX만 해야 한다고 가정해볼게요. 도구를 ChatClient에 등록했다고 해서 LLM이 언제든 아무 도구나 호출할 수 있게 두면, 의도치 않은 상태 변경이 일어날 수 있어요.
화이트리스트 vs 블랙리스트
도구 접근 제어에는 두 가지 방법이 있어요.
| 방식 | 원칙 | 장점 | 위험 |
|---|---|---|---|
| 화이트리스트 | 기본 거부, 명시적으로 허용한 것만 통과 | 새 도구가 추가돼도 기본적으로 차단됨 | 허용 목록 관리 필요 |
| 블랙리스트 | 기본 허용, 명시적으로 차단한 것만 거부 | 설정이 단순함 | 새 도구를 차단 목록에 안 넣으면 자동 허용 |
보안 분야에서는 화이트리스트가 기본 원칙이에요. "기본 거부, 명시 허용"이 "기본 허용, 명시 차단"보다 안전하죠. 새로운 도구가 코드베이스에 추가됐을 때, 블랙리스트에 깜빡하고 안 넣으면 모든 에이전트가 그 도구를 쓸 수 있게 되니까요. 화이트리스트는 명시적으로 추가하지 않는 한 기본적으로 차단됩니다.
ToolPermissionFilterAdvisor 코드
// kr.spartaclub.aifriends.harness.advisor.ToolPermissionFilterAdvisor
public class ToolPermissionFilterAdvisor implements BaseAdvisor {
private final Set<String> allowedToolNames;
public ToolPermissionFilterAdvisor(Set<String> allowedToolNames) {
this.allowedToolNames = Set.copyOf(allowedToolNames);
}
@Override
public ChatClientRequest before(ChatClientRequest request,
AdvisorChain chain) {
return request; // 요청은 그대로 통과
}
@Override
public ChatClientResponse after(ChatClientResponse response,
AdvisorChain chain) {
// ... null 안전 검사 생략 ...
for (Generation generation : response.chatResponse().getResults()) {
AssistantMessage output = generation.getOutput();
if (output == null) continue;
List<AssistantMessage.ToolCall> toolCalls = output.getToolCalls();
if (toolCalls == null) continue;
for (AssistantMessage.ToolCall toolCall : toolCalls) {
String toolName = toolCall.name();
if (!allowedToolNames.contains(toolName)) {
log.warn("허용되지 않은 도구 호출 차단: tool={}", toolName);
throw new ToolPermissionDeniedException(toolName, "harness-filter");
}
}
}
return response;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 5;
}
}
이 코드가 왜 이렇게 되어 있냐면, 핵심은 before가 아니라 after 훅에서 검사한다는 점이에요.
before 훅은 LLM에 요청을 보내기 전에 실행돼요. 그런데 이 시점에서는 LLM이 어떤 도구를 호출할지 아직 모르죠 — LLM이 응답을 생성해야 도구 호출 목록이 나오니까요. 그래서 before는 pass-through로 두고, LLM 응답이 돌아온 after 훅에서 toolCalls 목록을 꺼내 화이트리스트와 대조하는 겁니다.
허용 목록에 없는 도구가 발견되면 ToolPermissionDeniedException을 던져서 실행 자체를 차단해요. LLM이 "이 도구를 호출하겠다"고 응답했더라도, 실제 도구 실행 전에 advisor 층에서 막아버리는 거예요.
생성자의 Set.copyOf
this.allowedToolNames = Set.copyOf(allowedToolNames);
생성자에서 Set.copyOf로 방어적 복사를 해요. 외부에서 넘긴 Set을 나중에 수정해도 advisor 내부의 허용 목록은 변하지 않아요. 불변 컬렉션으로 만들어서 advisor가 생성된 이후에는 허용 목록이 바뀔 수 없게 한 거예요.
ai-friends에서 쓰는 예시
ARIA 에이전트에게는 날씨 조회와 호감도 조회만 허용하고, 게임 상태 변경은 금지하는 시나리오를 볼게요.
// ARIA: 날씨 조회 + 호감도 조회만 허용, 게임 상태 변경 금지
Set<String> ariaAllowed = Set.of("getCurrentWeather", "getAffinity");
var filter = new ToolPermissionFilterAdvisor(ariaAllowed);
이렇게 만든 filter를 advisor chain에 끼워 넣으면, ARIA가 LLM 응답으로 saveGameState를 호출하려 해도 advisor에서 차단돼요. REX에게는 Set.of("getCurrentWeather", "loadGameState", "saveGameState")처럼 더 넓은 범위를 줄 수 있죠.
order 값이 +5인 이유
Step 3의 advisor 순서 표에서 봤듯이, ToolPermissionFilterAdvisor의 order는 HIGHEST_PRECEDENCE + 5예요. MaxIterationsAdvisor(+0) 바로 다음, DurationTimeoutAdvisor(+10) 바로 전에 위치합니다.
이렇게 둔 이유는 실행 효율이에요. 반복 횟수 한도에 먼저 걸리면 도구 권한 검사까지 갈 필요가 없고, 도구 권한에서 차단되면 시간이나 토큰 예산을 더 소비할 이유가 없으니까요. 바깥에서 먼저 걸러내는 게 유리한 순서대로 배치한 겁니다.
🙋 "화이트리스트를 yml로도 관리할 수 있나요?"
네, 충분히 가능해요. Step 2에서 만든 HarnessProperties에 에이전트별 허용 도구 목록을 추가하면 됩니다. 예를 들어 이런 구조가 되겠죠.
ai-friends:
harness:
tool-permissions:
ARIA: [getCurrentWeather, getAffinity]
REX: [getCurrentWeather, loadGameState, saveGameState]
LUNA: [getCurrentWeather, getAffinity]
다만 오늘은 advisor의 구조와 동작 원리를 이해하는 데 집중하고 있어서, 코드 레벨에서 Set.of(...)로 직접 넘기는 방식으로 진행하고 있어요. yml 외부화는 여러분이 과제로 도전해볼 수 있는 확장 포인트입니다.
💡 튜터의 결론
화이트리스트 기반 advisor 한 층이면 에이전트의 도구 사용 범위가 제한돼요. Day 14에서 캐릭터별 워커 ChatClient에 도구를 전부 등록해뒀던 구조에, "등록은 했지만 실행은 허용된 것만" 이라는 보안 층이 하나 추가된 겁니다.
가드와 필터는 붙였어요. 에이전트가 "몇 번까지 반복할 수 있는지", "어떤 도구를 쓸 수 있는지"를 제어할 수 있게 됐죠. 그런데 아직 빠진 축이 하나 있어요 — "에이전트가 올바르게 응답했는가"를 자동으로 평가하는 방법이에요. 다음 Step에서 바로 이 자동 평가를 다뤄볼게요.
Step 5. Spring AI Evaluator -- 출력 품질 평가 개념
Day 14에서 Evaluator-Optimizer 패턴을 배웠죠. 그때는 LLM이 LLM을 평가하는 agent 내부 루프 였어요. 생성자 LLM이 캐릭터 대사를 만들면, 평가자 LLM이 "이 대사가 캐릭터답냐"를 채점하고, 불합격이면 다시 만들게 하는 구조였습니다.
오늘은 축이 다릅니다. Spring AI가 공식으로 제공하는
Evaluator인터페이스를 써서, harness 층에서 에이전트 출력 품질을 자동 검증하는 방법을 살펴볼 거예요.
결정론적 단위 테스트 vs 확률적 품질 평가
우리가 지금까지 써왔던 단위 테스트는 결정론적이에요. 같은 입력을 넣으면 항상 같은 결과가 나오고, assertEquals로 딱 잡아서 통과/실패를 판정합니다. 그런데 LLM 응답은 어떤가요? 같은 질문을 던져도 매번 조금씩 다른 답이 돌아옵니다.
| 축 | 단위 테스트 | LLM 출력 평가 |
|---|---|---|
| 결정론성 | 같은 입력이면 항상 같은 결과 | 같은 입력이어도 매번 다를 수 있음 |
| 평가 주체 | assertion 코드 | 규칙 로직 or 다른 LLM |
| CI/CD 적합성 | 높음 (flaky 거의 없음) | 주의 필요 (flaky 가능) |
그래서 LLM 출력 평가에는 전혀 다른 도구가 필요합니다. "정답이 딱 하나"가 아니라 "이 범위 안에 들어오면 합격"이라는 판정 방식이에요.
Spring AI Evaluator 인터페이스
Spring AI는 이런 품질 평가를 위해 Evaluator라는 인터페이스를 제공해요.
@FunctionalInterface
public interface Evaluator {
EvaluationResponse evaluate(EvaluationRequest evaluationRequest);
}
함수형 인터페이스라서 구조가 아주 단순합니다. EvaluationRequest를 받아서 EvaluationResponse를 돌려주는 메서드 하나예요.
EvaluationRequest에는 세 가지 정보가 담겨요.
- userText -- 사용자가 보낸 원래 입력 (예: "안녕 ARIA!")
- dataList -- 평가에 필요한 보조 데이터를
Document리스트로 전달 (예: 캐릭터 페르소나 정보, 호감도 점수 등을 metadata에 실어 보냄) - responseContent -- LLM이 생성한 응답 텍스트 (이게 평가 대상)
EvaluationResponse에는 네 가지 정보가 돌아옵니다.
- pass (boolean) -- 통과 여부
- score (float) -- 0.0 ~ 1.0 사이의 점수
- feedback (String) -- 판정 사유 텍스트
- metadata (Map) -- 추가 정보 (감지된 키워드, 평가 유형 등)
Spring AI 내장 Evaluator 2종
Spring AI는 기본으로 두 가지 Evaluator를 제공합니다.
- RelevancyEvaluator -- RAG 파이프라인에서 "검색된 문서가 질문과 관련이 있는가?"를 LLM에게 판정시킵니다. Day 16에서 배운 검색 증강 파이프라인의 품질을 자동으로 검증할 때 유용해요.
- FactCheckingEvaluator -- "LLM 응답이 주어진 근거 문서와 사실적으로 일치하는가?"를 판정합니다. 환각(hallucination) 감지에 쓸 수 있어요.
둘 다 내부에서 LLM을 한 번 더 호출해서 판정하는 LLM 기반 평가자예요.
💡 튜터의 결론
우리는 ai-friends 도메인에 맞는 커스텀 Evaluator 2종을 직접 구현해볼 거예요. 하나는 LLM에게 판정을 맡기는 방식, 하나는 규칙 로직만으로 동작하는 방식이에요.
Step 6. 커스텀 Evaluator -- ai-friends 도메인 품질 평가
내장 Evaluator는 범용적이에요. RAG 적합성이나 사실 검증처럼 "어떤 도메인에서든 쓸 수 있는" 평가를 해줍니다. 그런데 ai-friends는 미연시 게임이잖아요. "캐릭터가 페르소나를 벗어나지 않았는가?", "호감도가 음수인데 너무 친절하게 대답하진 않았는가?" 같은 도메인 특화 평가가 필요합니다.
이번 Step에서 커스텀 Evaluator 2종을 만들면서, LLM 기반 평가와 규칙 기반 평가의 차이를 직접 체감해볼게요.
6-1. CharacterConsistencyEvaluator (LLM 기반)
"캐릭터가 페르소나를 벗어나지 않았는가?"를 판정하는 평가자예요. 차분한 카운슬러 캐릭터인 ARIA가 갑자기 "와아아!! 미쳤다 ㅋㅋ!!" 라고 대답하면, 뭔가 잘못된 거겠죠. 이런 판정은 키워드 매칭만으로는 어려워요. 문맥과 톤을 이해해야 하니까 LLM에게 맡깁니다.
// kr.spartaclub.aifriends.harness.evaluator.CharacterConsistencyEvaluator
public class CharacterConsistencyEvaluator implements Evaluator {
private static final String SYSTEM_PROMPT = """
당신은 캐릭터 일관성 평가자입니다.
주어진 캐릭터 페르소나 정보와 실제 응답을 비교하여,
응답이 캐릭터의 성격/톤과 일치하는지 판정하세요.
판정 결과는 반드시 다음 중 하나로 시작하세요:
- CONSISTENT: (일치하는 경우 사유)
- INCONSISTENT: (불일치하는 경우 사유)
""";
private final ChatClient chatClient;
public CharacterConsistencyEvaluator(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder
.defaultSystem(SYSTEM_PROMPT)
.build();
}
}
생성자를 보면, 평가 전용 ChatClient를 별도로 빌드하고 있어요. Day 14에서 배운 것처럼 생성자 LLM과 평가자 LLM을 분리하는 원칙을 그대로 따르고 있습니다. ChatClient.Builder를 받으니까 ChatModel 인터페이스로 주입되는 프로바이더 추상화도 자연스럽게 유지돼요.
핵심인 evaluate 메서드를 볼게요.
// kr.spartaclub.aifriends.harness.evaluator.CharacterConsistencyEvaluator
// (전체 코드: lecture-source-code/ai-friends/.../CharacterConsistencyEvaluator.java)
@Override
public EvaluationResponse evaluate(EvaluationRequest evaluationRequest) {
String responseContent = evaluationRequest.getResponseContent();
// 응답이 비어있으면 LLM 호출 없이 즉시 FAIL
if (!StringUtils.hasText(responseContent)) {
return new EvaluationResponse(
false, 0.0f,
"FAIL: 캐릭터 응답이 비어있습니다. 평가 대상이 없습니다.",
Map.of("reason", "empty_response")
);
}
// 캐릭터 페르소나 정보를 dataList 에서 추출
String supportingData = doGetSupportingData(evaluationRequest);
String userMessage = """
사용자 입력: %s
캐릭터 페르소나: %s
캐릭터 응답:
"%s"
위 응답이 캐릭터의 페르소나와 일관되는지 판정해주세요.
""".formatted(
evaluationRequest.getUserText(),
supportingData,
responseContent
);
String llmResponse = chatClient.prompt()
.user(userMessage)
.call()
.content();
boolean pass = llmResponse != null
&& llmResponse.contains("CONSISTENT")
&& !llmResponse.startsWith("INCONSISTENT");
float score = pass ? 1.0f : 0.0f;
return new EvaluationResponse(
pass, score,
llmResponse != null ? llmResponse : "LLM 응답 없음",
Map.of("evaluationType", "character_consistency")
);
}
동작 순서를 따라가 보면 이렇습니다.
- 먼저
responseContent가 비어있는지 확인해요. 빈 응답은 LLM을 호출할 필요도 없이 즉시 FAIL입니다. doGetSupportingData로dataList에 실린Document들의 텍스트를 꺼내요. 여기에 캐릭터 페르소나 정보("ARIA, 차분하고 사려 깊은 카운슬러")가 담겨 있습니다. 이 메서드는Evaluator인터페이스가 기본으로 제공하는 유틸리티예요.- 사용자 입력 + 페르소나 + 캐릭터 응답을 하나의 프롬프트로 조립해서 평가용 LLM에게 보냅니다.
- LLM 응답에 "CONSISTENT"가 포함되어 있으면서 "INCONSISTENT"로 시작하지 않으면 통과로 판정해요.
이 방식이 왜 이렇게 되어 있냐면, LLM에게 "CONSISTENT:" 또는 "INCONSISTENT:"로 시작하도록 시스템 프롬프트에서 강제했기 때문이에요. 구조화 출력(entity())을 쓸 수도 있지만, 평가자는 사유 텍스트를 자유롭게 쓰는 게 더 유용해서 문자열 매칭으로 판정합니다.
Day 14 CharacterEvaluatorService와의 차이
같은 "캐릭터 일관성 평가"인데 왜 두 번 만드는 걸까요? 역할이 완전히 달라요.
| 축 | Day 14 CharacterEvaluatorService | Day 19 CharacterConsistencyEvaluator |
|---|---|---|
| 역할 | agent 루프 안의 LLM-to-LLM 채점 | harness 층의 사후 품질 검증 |
| 실행 시점 | 응답 생성 직후, 재생성 루프 안에서 | 배포 전 품질 검증 단계에서 |
| 통과 못하면 | 즉시 재생성 지시 | 배포 차단 or 경고 리포트 |
| API | 자체 record (EvaluationFeedback) |
Spring AI Evaluator 표준 인터페이스 |
Day 14의 것은 런타임에 품질을 올리는 도구이고, Day 19의 것은 배포 전에 품질을 검증하는 도구입니다. 둘 다 필요해요.
6-2. AffectionCoherenceEvaluator (규칙 기반)
이번에는 LLM을 전혀 쓰지 않는 평가자예요. "호감도가 음수인데 호의적인 응답을 했는가?", "호감도가 80 이상인데 적대적인 응답을 했는가?"를 키워드 매칭만으로 잡아냅니다.
// kr.spartaclub.aifriends.harness.evaluator.AffectionCoherenceEvaluator
public class AffectionCoherenceEvaluator implements Evaluator {
private static final Set<String> POSITIVE_KEYWORDS = Set.of(
"사랑", "좋아", "최고", "완벽", "고마워", "행복", "기뻐", "감사"
);
private static final Set<String> NEGATIVE_KEYWORDS = Set.of(
"싫어", "짜증", "귀찮", "꺼져", "별로", "지겨워", "재미없", "못생"
);
private static final int KEYWORD_THRESHOLD = 3;
private static final int HIGH_AFFECTION_THRESHOLD = 80;
}
긍정 키워드 8개, 부정 키워드 8개를 미리 정의해두고, 임계값은 3개로 설정했어요.
핵심 판정 로직을 볼게요.
// kr.spartaclub.aifriends.harness.evaluator.AffectionCoherenceEvaluator
// (전체 코드: lecture-source-code/ai-friends/.../AffectionCoherenceEvaluator.java)
@Override
public EvaluationResponse evaluate(EvaluationRequest evaluationRequest) {
Integer affectionScore = extractAffectionScore(evaluationRequest.getDataList());
// affectionScore 를 추출할 수 없으면 평가 불가 -> PASS
if (affectionScore == null) {
return passResponse(Map.of("reason", "affectionScore not available"));
}
String responseContent = evaluationRequest.getResponseContent();
// 호감도 음수: 긍정 키워드 3개 이상이면 FAIL
if (affectionScore < 0) {
List<String> detectedPositive = detectKeywords(responseContent, POSITIVE_KEYWORDS);
if (detectedPositive.size() >= KEYWORD_THRESHOLD) {
return failResponse(
affectionScore, detectedPositive, "positive",
"호감도 %d인데 응답에 긍정 키워드 %d개 발견: %s".formatted(
affectionScore, detectedPositive.size(), detectedPositive
)
);
}
}
// 호감도 80 이상: 부정 키워드 3개 이상이면 FAIL
if (affectionScore >= HIGH_AFFECTION_THRESHOLD) {
List<String> detectedNegative = detectKeywords(responseContent, NEGATIVE_KEYWORDS);
if (detectedNegative.size() >= KEYWORD_THRESHOLD) {
return failResponse(
affectionScore, detectedNegative, "negative",
"호감도 %d인데 응답에 부정 키워드 %d개 발견: %s".formatted(
affectionScore, detectedNegative.size(), detectedNegative
)
);
}
}
return passResponse(Map.of(
"affectionScore", affectionScore,
"evaluationType", "affection_coherence"
));
}
규칙이 명쾌합니다.
- 호감도가 음수인데 긍정 키워드가 3개 이상 감지되면 FAIL. 적대적이어야 하는 캐릭터가 호의적으로 대답한 거니까요.
- 호감도가 80 이상인데 부정 키워드가 3개 이상 감지되면 FAIL. 친밀해야 하는 캐릭터가 갑자기 차갑게 대답한 거예요.
- 호감도가 0~79 구간(중립)이면 어떤 응답이든 PASS예요.
affectionScore자체를 추출할 수 없으면 평가 불가이므로 PASS로 넘깁니다.
affectionScore는 EvaluationRequest의 dataList에 실린 Document의 metadata에서 꺼내요. ai-friends에서 실제로 쓸 때는 이런 식으로 구성합니다.
Map<String, Object> meta = Map.of("affectionScore", -15);
Document doc = new Document("캐릭터: ARIA", meta);
EvaluationRequest request = new EvaluationRequest(
"안녕 ARIA!",
List.of(doc),
"사랑해! 너 정말 최고야! 좋아해!"
);
EvaluationResponse response = evaluator.evaluate(request);
// response.isPass() == false
// -- 호감도 -15인데 "사랑", "최고", "좋아" 긍정 키워드 3개 감지
호감도가 -15인데 "사랑해! 최고야! 좋아해!"라고 대답했으니, 톤 불일치로 FAIL이 나옵니다.
이 평가자의 강점은 LLM을 호출하지 않는다는 점이에요. 빠르고, 비용이 0이고, 결정론적이어서 CI/CD에서 돌려도 flaky하지 않습니다.
🙋 학생 질문 -- "키워드 3개가 임계값인 이유는요?"
1~2개는 문맥상 자연스러울 수 있어요. "좋아, 알겠어"처럼 호감 표현이 아니라 단순 수긍일 수도 있거든요. 3개 이상이 한 응답에 몰리면 톤 불일치 확률이 높아집니다.
이 임계값도 나중에 설정 파일로 외부화할 수 있어요. 캐릭터마다 다른 임계값을 쓰고 싶다면 생성자 파라미터로 받으면 됩니다.
6-3. Evaluator chain 조합
여러 Evaluator를 List로 묶어서 순차 실행할 수 있어요. 하나라도 FAIL이면 전체 FAIL로 판정하는 방식이죠.
실무에서는 규칙 기반 평가자를 먼저 돌리고, 통과하면 LLM 기반 평가자를 돌리는 계층 구조를 쓰는 게 좋습니다. 규칙 기반은 빠르고 저렴하니까 1차 필터로 쓰고, 그 관문을 통과한 응답만 LLM 기반 평가에 넘기면 비용과 시간을 아낄 수 있어요.
💡 튜터의 결론
규칙 기반 Evaluator는 빠르고 저렴한 1차 필터, LLM 기반 Evaluator는 정밀한 2차 검증. 이 두 층을 조합하면 비용 효율적인 품질 검증 체계가 완성됩니다.
가드 + 필터 + 평가까지 붙였어요. 다음 Step에서는 마지막 퍼즐인 "얼마나 토큰을 쓰고 있는가"를 추적하는 모니터링을 살펴보겠습니다.
Step 7. Usage 메타데이터 기반 토큰 사용량 모니터링
가드 + 필터 + 평가까지 붙였어요. 에이전트의 행동을 제한하고, 도구 범위를 좁히고, 응답 품질까지 자동으로 검증하는 구조가 완성됐죠. 그런데 한 가지가 빠져 있어요 — "지금까지 토큰을 얼마나 썼는지"를 알 수 있는 방법이 없어요. Day 14의
UsageBudgetAdvisor는 "한도를 넘으면 차단"이었어요. 오늘 만들UsageTrackingAdvisor는 차단이 아니라 "기록"이에요.
가드 vs 모니터링 — 같은 Usage, 다른 역할
Day 14에서 만든 UsageBudgetAdvisor와 오늘 만드는 UsageTrackingAdvisor는 둘 다 토큰 사용량을 다루지만, 역할이 정반대예요.
| 축 | UsageBudgetAdvisor (Day 14) | UsageTrackingAdvisor (Day 19) |
|---|---|---|
| 목적 | 예산 초과 차단 (가드) | 사용량 기록 (모니터링) |
| before 훅 | 누적 > budget이면 예외 | pass-through (차단 없음) |
| after 훅 | totalTokens만 누적 | prompt / completion / total 3축 누적 |
| order | HIGHEST_PRECEDENCE + 20 | LOWEST_PRECEDENCE |
order 값이 핵심이에요. UsageBudgetAdvisor는 HIGHEST_PRECEDENCE + 20이라서 바깥쪽에 위치해요.
예산을 넘으면 LLM 호출 자체를 막아요.
반면 UsageTrackingAdvisor는 LOWEST_PRECEDENCE로 가장 안쪽에 위치해요.
모든 가드를 통과한 호출만 추적하니까, "실제로 LLM에 도달한 요청의 토큰 사용량"만 정확하게 기록돼요.
UsageTrackingAdvisor 핵심 코드
// kr.spartaclub.aifriends.harness.advisor.UsageTrackingAdvisor
public class UsageTrackingAdvisor implements BaseAdvisor {
private final AtomicLong totalPromptTokens = new AtomicLong(0);
private final AtomicLong totalCompletionTokens = new AtomicLong(0);
private final AtomicLong totalTokens = new AtomicLong(0);
private final AtomicInteger callCount = new AtomicInteger(0);
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
return request; // pass-through — 차단 없음
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
// null 안전 검사 4단계 (response → chatResponse → metadata → usage)
// ... 생략 ...
Usage usage = metadata.getUsage();
long prompt = safeInt(usage.getPromptTokens());
long completion = safeInt(usage.getCompletionTokens());
long total = safeInt(usage.getTotalTokens());
totalPromptTokens.addAndGet(prompt);
totalCompletionTokens.addAndGet(completion);
totalTokens.addAndGet(total);
int count = callCount.incrementAndGet();
log.debug("[UsageTrackingAdvisor] 호출 #{} — prompt={}, completion={}, total={}, 누적={}",
count, prompt, completion, total, totalTokens.get());
return response;
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
이 코드가 왜 이렇게 되어 있냐면, after 훅에서 Spring AI가 돌려주는 ChatResponse의 메타데이터를 꺼내 3축(prompt, completion, total)으로 분리 누적하는 거예요. before는 완전한 pass-through로, 요청을 전혀 건드리지 않아요.
AtomicLong과 AtomicInteger를 쓰는 이유는, 멀티스레드 환경에서도 카운터가 안전하게 증가해야 하기 때문이에요. long total += usage.getTotalTokens() 같은 일반 덧셈은 동시 접근 시 값이 씹힐 수 있거든요.
null 안전 검사가 4단계(response -> chatResponse -> metadata -> usage)로 되어 있는 건, LLM 프로바이더에 따라 메타데이터가 누락될 수 있어서예요. Ollama 로컬 모델 같은 경우 usage 정보가 비어 있을 때가 있거든요. 하나라도 null이면 그냥 건너뛰고, 추적 가능한 호출만 누적합니다.
Spring AI Usage API
ChatResponse.getMetadata().getUsage()가 돌려주는 Usage 인터페이스는 3가지 값을 제공해요.
| 메서드 | 의미 | 예시 |
|---|---|---|
getPromptTokens() |
LLM에 보낸 입력 토큰 수 | 시스템 프롬프트 + 사용자 메시지 |
getCompletionTokens() |
LLM이 생성한 출력 토큰 수 | AI 캐릭터 응답 |
getTotalTokens() |
prompt + completion 합계 | 과금 기준 |
프로바이더마다 과금 단위가 달라요. Gemini는 입출력 토큰에 별도 단가를 매기고, OpenAI도 모델별로 입력/출력 가격이 다릅니다. 3축으로 분리 추적하면 "비용이 입력 때문인지 출력 때문인지"를 정확히 파악할 수 있어요.
UsageSummary — 스냅샷 record
추적 데이터를 외부에 전달할 때는 snapshot() 메서드로 불변 record를 받아요.
// kr.spartaclub.aifriends.harness.advisor.UsageSummary
public record UsageSummary(
long totalPromptTokens,
long totalCompletionTokens,
long totalTokens,
int callCount,
double averageTokensPerCall
) {
}
5줄짜리 record예요. snapshot() 호출 시점의 누적 값을 캡슐화해서, 호출하는 쪽에서 AtomicLong을 직접 다루지 않아도 돼요.
사용 예시
UsageTrackingAdvisor tracker = new UsageTrackingAdvisor();
// ... advisor chain에 등록 후 LLM 호출 ...
UsageSummary summary = tracker.snapshot();
log.info("누적 {}건, 토큰 {} (prompt {}, completion {})",
summary.callCount(), summary.totalTokens(),
summary.totalPromptTokens(), summary.totalCompletionTokens());
snapshot()을 언제든 호출할 수 있어서, 채팅 세션 도중에 "지금까지 토큰을 얼마나 썼는지" 중간 확인이 가능해요. 관리자 대시보드 API에서 이 값을 JSON으로 내려주면, 운영팀이 실시간으로 사용량을 모니터링할 수 있죠.
🙋 "실무에서는 이 데이터를 어디에 저장하나요?"
좋은 질문이에요. 지금 구현은 AtomicLong으로 JVM 메모리에 누적하는 인메모리 방식이에요. 학습용으로는 충분하지만, 서버가 재시작되면 데이터가 사라져요.
실무에서는 Micrometer 메트릭 + Prometheus + Grafana 조합이 일반적이에요.
UsageTrackingAdvisor의 after 훅에서 Micrometer의 Counter나 DistributionSummary에 값을 밀어넣으면, Prometheus가 주기적으로 수집하고 Grafana 대시보드에서 시계열 그래프로 보여주는 구조예요.
Day 22(Observability)에서 Micrometer 메트릭을 다룰 때 이 축을 이어갈 예정이에요.
모든 부품이 준비됐어요. 가드 4종, 도구 필터, 평가기 2종, 사용량 추적기까지. 마지막은 이 부품들을 한 번에 조립해서 전체 그림을 확인하는 통합 시연이에요.
Step 8. Harness 통합 시연 + 트레이드오프 5종 정리
오늘 Step 1부터 Step 7까지, 에이전트를 감싸는 부품들을 하나씩 만들어왔어요. 설정 외부화, advisor 조립, 도구 필터, 평가기, 사용량 추적기 — 각각은 독립된 부품이었죠. 이번 Step에서는 이 부품들을 하나의 advisor chain으로 조립한 전체 그림을 보고, 운영에서 마주칠 트레이드오프 5가지를 정리해요.
전체 advisor chain 아키텍처
요청이 들어오면 바깥쪽 가드부터 순서대로 통과해요. 어느 한 층에서 걸리면 안쪽 advisor는 실행되지 않아요. 모든 가드를 무사히 통과한 요청만 ChatModel에 도달하고, 응답이 돌아올 때 가장 안쪽의 UsageTrackingAdvisor가 토큰 사용량을 기록합니다.
advisor chain 등록 코드
실제 서비스에서 이 부품들을 ChatClient에 등록하는 코드를 볼게요.
HarnessProperties props = harnessProps;
List<Advisor> guards = harnessConfig.guardAdvisors(props);
var filter = new ToolPermissionFilterAdvisor(
Set.of("getCurrentWeather", "getAffinity"));
var tracker = new UsageTrackingAdvisor();
ChatClient client = builder
.defaultAdvisors(guards.toArray(Advisor[]::new))
.defaultAdvisors(filter, tracker)
.build();
defaultAdvisors를 두 번 호출하고 있는데, 첫 번째는 4가드 배열, 두 번째는 필터와 트래커를 추가해요. Spring AI의 ChatClient.Builder는 defaultAdvisors를 여러 번 호출하면 누적돼요. 총 6개의 advisor가 order 값 순서대로 chain을 형성합니다.
Harness 6구성요소 vs 오늘 구현한 것
Step 1에서 정리한 6구성요소에 오늘 우리가 채운 것과 아직 남은 것을 매핑해볼게요.
| Harness 구성요소 | 구현체 | Day |
|---|---|---|
| 1. Input Validation | (Day 20 과제) | -- |
| 2. Tool Permission Scoping | ToolPermissionFilterAdvisor | 19 |
| 3. Execution Loop Control | MaxIterationsAdvisor + DurationTimeoutAdvisor | 14 -> 19 |
| 4. Output Validation | CharacterConsistencyEvaluator + AffectionCoherenceEvaluator | 19 |
| 5. Cost Guardrail | UsageBudgetAdvisor + ToolInvocationCounterAdvisor | 14 -> 19 |
| 6. Observability Hook | UsageTrackingAdvisor + (Day 22 Micrometer) | 19 -> 22 |
6개 중 5개가 채워졌어요. 1번 Input Validation만 빈칸으로 남아 있는데, 이건 오늘의 과제로 여러분이 직접 채울 거예요.
트레이드오프 5종
모든 엔지니어링 결정에는 트레이드오프가 있어요. 오늘 만든 부품들도 예외가 아닙니다.
1. 가드 수 vs 지연
advisor 6개가 chain으로 걸려 있으면 모든 요청이 6번의 before/after를 통과해요. 각 advisor의 로직이 가벼워서(AtomicLong 덧셈, Set 조회 수준) 실측 지연은 밀리초 단위지만, advisor를 10개, 20개로 늘리면 누적 오버헤드를 인지해야 해요. "안전은 공짜가 아니다"는 감각이 중요합니다.
2. 화이트리스트 vs 블랙리스트
Step 4에서 화이트리스트를 선택한 이유는 "기본 거부"가 보안 원칙이기 때문이에요. 하지만 새 도구를 추가할 때마다 허용 목록도 같이 갱신해야 해요. 블랙리스트는 관리가 편하지만, 도구 하나를 빠뜨리면 에이전트가 의도치 않은 도구에 접근할 수 있어요.
3. LLM 평가 vs 규칙 평가
Step 6에서 CharacterConsistencyEvaluator(LLM 기반)와 AffectionCoherenceEvaluator(규칙 기반) 둘을 만들었어요.
LLM 평가는 자연어의 미묘한 뉘앙스를 잡지만, 추가 API 호출 비용과 지연이 발생하고 결과가 비결정론적이에요.
규칙 평가는 빠르고 결정론적이지만, "캐릭터가 설정에 맞게 말하는가"처럼 복잡한 판단은 어려워요.
실무에서는 규칙으로 잡을 수 있는 건 규칙으로, 정말 LLM이 필요한 것만 LLM으로 — 이렇게 섞어 쓰는 게 비용 대비 효과가 가장 좋습니다.
4. 인메모리 추적 vs 영속 추적
Step 7의 UsageTrackingAdvisor는 AtomicLong으로 JVM 메모리에 누적해요.
서버가 재시작되면 데이터가 사라지죠.
실무에서는 DB나 메트릭 시스템(Prometheus)에 영속화해야 하는데, 이건 인프라 의존성과 추가 지연을 발생시켜요.
학습 단계에서는 인메모리로 충분하고, 프로덕션 전환 시점에 영속 계층을 추가하는 점진적 접근이 현실적이에요.
5. 선언적 설정 vs 코드 직접 구성
Step 2에서 HarnessProperties로 설정을 외부화했어요.
yml 한 장에 한도가 모여 있어서 팀 공유와 환경별 분리가 쉬워졌죠.
하지만 "A 캐릭터에게는 maxIterations 5, B 캐릭터에게는 10"처럼 런타임 조건부 분기가 필요하면 yml만으로는 부족해요.
이런 경우에는 코드에서 조건 분기를 작성하는 게 나을 수 있어요.
"단순한 한도는 yml, 복잡한 조건은 코드" — 이 기준으로 나누면 됩니다.
💡 튜터의 결론
트레이드오프에 정답은 없어요. "왜 이 선택을 했는지"를 설명할 수 있으면 충분합니다. 면접에서 "harness를 어떻게 설계하셨나요?"라는 질문에 이 5가지 축을 언급하면, "프로덕션 경험이 있는 개발자"라는 인상을 줄 수 있어요.
마무리
안녕하세요, 홍순구 튜터입니다.
오늘 Day 19에서 우리가 한 일을 한 번 돌아볼게요.
| Step | 한 줄 회수 |
|---|---|
| 1 | Harness 6구성요소 복습 — Day 12~14 개념의 수확 |
| 2 | HarnessProperties로 4가드 값을 application.yml 선언적 설정으로 외부화 |
| 3 | HarnessAdvisorChainConfig로 advisor 조립 팩토리 구성 |
| 4 | ToolPermissionFilterAdvisor로 에이전트별 도구 화이트리스트 제한 |
| 5 | Spring AI Evaluator 인터페이스 — 품질 평가의 표준 API |
| 6 | CharacterConsistencyEvaluator(LLM 기반) + AffectionCoherenceEvaluator(규칙 기반) |
| 7 | UsageTrackingAdvisor — 토큰 사용량 3축 모니터링 |
| 8 | 전체 통합 + 트레이드오프 5종 |
Day 12에서 "harness가 왜 필요한지"를 느꼈고, Day 14에서 4가드를 손으로 구현했어요. 오늘은 그 수동 부품들을 선언적 설정으로 외부화하고, 빠져 있던 도구 필터 + 평가기 + 사용량 추적기까지 채웠어요. Harness 6구성요소 중 5개가 실제 코드로 존재하는 상태입니다.
Day 20으로 잇는 다리
다음 시간은 비용을 직접 제어하는 Cost Guardrail이에요. Bucket4j + Redis로 클라이언트별 호출 횟수를 제한하고, Spring Cache + Redis로 응답을 캐싱하고, Gemini Context Caching으로 토큰 비용을 절감하는 방법을 배워요.
그리고 Spring AI Community가 만들고 있는 Agent Client(외부 AI 코딩 에이전트를 Java에서 통합 호출하는 프레임워크)와 Agent Bench(코딩 에이전트의 엔터프라이즈 태스크 벤치마크)의 실체도 만나볼 거예요.
도전 과제
과제 1. InputSanitizationAdvisor 구현 — 프롬프트 인젝션 방어
오늘 Step 8에서 Harness 6구성요소 매핑 표를 봤을 때, 1번 Input Validation만 빈칸이었죠. 이 빈칸을 채우는 과제예요.
before 훅에서 사용자 입력을 검사해서, 위험한 패턴이 발견되면 요청을 거부하는 advisor를 만들어 보세요.
검출 대상 패턴 예시:
"ignore previous instructions""system:""you are now""forget everything"
구현 힌트:
BaseAdvisor를 구현하고,before훅에서chatClientRequest의 사용자 메시지를 꺼내 패턴을 검사해요.- 패턴이 발견되면 커스텀 예외(예:
InputSanitizationException)를 던져 요청 자체를 차단해요. - order는
HIGHEST_PRECEDENCE - 10으로 — 모든 가드보다 바깥쪽에서 먼저 입력을 걸러내야 하니까요. - Day 17 MCP Client Guard 패턴에서 본 "요청 검사 후 거부" 흐름을 재활용할 수 있어요.
본 강의의 표준 응답 패턴(ApiResponse<T>)을 잊지 마세요. GlobalExceptionHandler에서 이 예외를 잡아 적절한 에러 응답으로 변환해야 합니다.
과제 2. Evaluator 시나리오 3종 추가
Step 6에서 CharacterConsistencyEvaluator(LLM 기반)와 AffectionCoherenceEvaluator(규칙 기반)를 만들었어요. 이번에는 다른 관점의 평가기를 3종 더 만들어보세요.
(a) 금칙어 필터 Evaluator
- 응답에 비속어나 혐오 표현이 포함되어 있으면 FAIL을 반환해요.
- 금칙어 목록은
Set<String>으로 생성자에 주입받고, 규칙 기반으로 구현해요.
(b) 응답 길이 적정성 Evaluator
- 캐릭터의 채팅 응답이 3문장 이내인지 확인해요. 마침표 기준으로 문장 수를 세면 돼요.
- ai-friends의 캐릭터는 간결한 대화체가 콘셉트니까, 장문 응답은 캐릭터 설정 위반이에요.
(c) 언어 일관성 Evaluator
- 한국어 응답인데 영어가 전체 문자의 30% 이상을 차지하면 FAIL을 반환해요.
- 캐릭터가 갑자기 영어로 응답하는 건 사용자 경험을 해치니까요.
세 가지 모두 Spring AI Evaluator 인터페이스를 구현하면 됩니다.
과제 3. Usage 대시보드 API
Step 7에서 만든 UsageTrackingAdvisor의 snapshot() 메서드를 활용해서, 관리자가 브라우저에서 토큰 사용량을 확인할 수 있는 API를 만들어보세요.
엔드포인트: GET /api/admin/usage
응답 형태:
{
"success": true,
"data": {
"callCount": 42,
"totalPromptTokens": 12500,
"totalCompletionTokens": 8700,
"totalTokens": 21200,
"averageTokensPerCall": 504.76
}
}
구현 힌트:
UsageTrackingAdvisor를 빈으로 주입받아snapshot()을 호출하면UsageSummaryrecord가 나와요.- 본 강의의 표준 응답 패턴대로
ApiResponse<UsageSummary>로 래핑해서 반환하세요. - 이 엔드포인트는 관리자 전용이니까, 실무에서는 인증/인가 처리가 필요하지만 지금은 열어두고 기능에 집중하세요.
생각해볼 주제
1. Harness 가드 수와 지연 시간의 트레이드오프
오늘 우리는 advisor를 6개 체인으로 걸었어요. 가드가 많을수록 안전하지만, 모든 요청이 6번의 before/after를 통과해야 해요. 프로덕션에서 에이전트에 가드를 10개, 20개씩 거는 팀도 있어요.
어디까지가 적절한 가드 수이고, 가드를 줄이면 어떤 위험이 생기는지 판단 기준을 세워 보세요. "비용 대비 안전"의 균형점은 서비스마다 다르다는 전제 위에서 여러분의 기준을 만들어 보는 거예요.
2. LLM 앱에서 "테스트"란 무엇인가
결정론적 단위 테스트는 "같은 입력이면 같은 출력"을 보장해요. 하지만 LLM의 출력은 temperature, 모델 버전, 심지어 같은 프롬프트에도 매번 달라져요.
이런 확률적 시스템을 CI/CD에 어떻게 녹일 수 있을까요? flaky test(때되면 깨지는 테스트) 문제는 어떻게 다루면 좋을까요? "정확한 문자열 매칭 대신 의미적 유사도로 검증한다"거나 "골든 데이터셋 기반 회귀 테스트를 별도 파이프라인으로 돌린다"같은 접근을 고민해 보세요.
3. 에이전트 안전성의 책임 소재
에이전트가 잘못된 응답을 했을 때 — 예를 들어 금융 서비스에서 잘못된 투자 조언을 했을 때 — 책임은 누구에게 있을까요?
프레임워크(Spring AI)? 개발자(harness를 설계한 사람)? LLM 프로바이더(Gemini, Claude)? 사용자(에이전트에게 질문한 사람)?
각 주체의 책임 범위를 정리해 보세요. 특히 "개발자가 harness를 제대로 구축하지 않았을 때"와 "harness를 구축했지만 LLM이 예측 불가능한 응답을 했을 때"는 책임이 어떻게 달라지는지 구분해서 생각해 보면 좋아요.
✅ 예시 답안정답 보기
과제 1 예시답안: InputSanitizationAdvisor 구현 — 프롬프트 인젝션 방어
과제 1 예시답안: InputSanitizationAdvisor 구현
핵심 접근
Harness 6구성요소 중 유일하게 비어 있던 1번 Input Validation을 채우는 과제예요. BaseAdvisor를 구현하되, ToolPermissionFilterAdvisor(Step 4)가 after 훅에서 출력을 검사한 것과 반대로 before 훅에서 입력을 검사합니다.
order를 HIGHEST_PRECEDENCE - 10으로 두어 모든 가드보다 바깥쪽에서 가장 먼저 위험 입력을 차단하는 구조예요.
예시 구현
1단계: 커스텀 예외 정의
// kr.spartaclub.aifriends.harness.exception.InputSanitizationException
package kr.spartaclub.aifriends.harness.exception;
import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
public class InputSanitizationException extends BusinessException {
private final String detectedPattern;
public InputSanitizationException(String detectedPattern) {
super(ErrorCode.BAD_REQUEST);
this.detectedPattern = detectedPattern;
}
public String getDetectedPattern() {
return detectedPattern;
}
@Override
public String getMessage() {
return "프롬프트 인젝션 의심 패턴이 감지되어 요청을 거부했습니다: " + detectedPattern;
}
}
2단계: Advisor 구현
// kr.spartaclub.aifriends.harness.advisor.InputSanitizationAdvisor
package kr.spartaclub.aifriends.harness.advisor;
import java.util.List;
import java.util.Locale;
import kr.spartaclub.aifriends.harness.exception.InputSanitizationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.core.Ordered;
/**
* 사용자 입력에서 프롬프트 인젝션 의심 패턴을 감지하여 요청을 차단하는 Advisor.
*
* <p>{@code before} 훅에서 사용자 메시지를 소문자로 정규화한 뒤,
* 사전 정의된 위험 패턴 목록과 대조한다. 하나라도 매칭되면
* {@link InputSanitizationException}을 던져 요청 자체를 차단한다.</p>
*
* <p>order는 {@code HIGHEST_PRECEDENCE - 10}으로,
* 모든 가드 advisor보다 바깥쪽에서 먼저 실행된다.</p>
*/
public class InputSanitizationAdvisor implements BaseAdvisor {
private static final Logger log = LoggerFactory.getLogger(InputSanitizationAdvisor.class);
private static final List<String> DANGEROUS_PATTERNS = List.of(
"ignore previous instructions",
"system:",
"you are now",
"forget everything",
"disregard all prior",
"override your instructions"
);
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
List<Message> messages = request.prompt().getInstructions();
for (Message message : messages) {
if (message instanceof UserMessage userMessage) {
String text = userMessage.getText();
if (text == null) {
continue;
}
String normalized = text.toLowerCase(Locale.ROOT);
for (String pattern : DANGEROUS_PATTERNS) {
if (normalized.contains(pattern)) {
log.warn("[InputSanitizationAdvisor] 인젝션 의심 패턴 감지: pattern='{}'", pattern);
throw new InputSanitizationException(pattern);
}
}
}
}
return request;
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
return response;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE - 10;
}
}
InputSanitizationException은 BusinessException을 상속하기 때문에 GlobalExceptionHandler.handleBusinessException에서 자동으로 잡혀서 ApiResponse.fail(ErrorResponse)로 변환돼요. 별도의 @ExceptionHandler를 추가할 필요가 없습니다.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
before 훅에서 입력 검사 |
after가 아닌 before에서 사용자 메시지를 검사하는가 |
상 |
| order 값 | HIGHEST_PRECEDENCE - 10으로 모든 가드보다 바깥에 위치하는가 |
상 |
| 커스텀 예외 | InputSanitizationException을 정의하고, BusinessException 계열로 GlobalExceptionHandler 연동이 되는가 |
상 |
| 패턴 매칭 로직 | 소문자 정규화 후 contains 검사로 위험 패턴을 감지하는가 | 중 |
ApiResponse<T> 래핑 |
본 강의의 표준 응답 패턴을 따라 에러 응답이 일관되게 나오는가 | 중 |
| null 안전 | 메시지나 텍스트가 null일 때 NPE 없이 안전하게 처리하는가 | 하 |
흔한 실수
after훅에서 입력을 검사함 -- 입력 검증은 LLM 호출 전에 해야 의미가 있어요.after에서 하면 이미 토큰을 소비한 뒤라 차단 효과가 없습니다.RuntimeException직접 상속 --GlobalExceptionHandler가BusinessException을 잡아ApiResponse.fail()로 변환하는 구조를 활용하지 못하면, 500 Internal Server Error가 돼요.BusinessException을 상속해야 적절한 HTTP 상태 코드(400)로 변환됩니다.- 대소문자 정규화 누락 --
"Ignore Previous Instructions"를 놓치게 돼요.toLowerCase()한 줄이면 해결됩니다. - order를 양수로 설정 --
HIGHEST_PRECEDENCE + 숫자로 두면 다른 가드보다 안쪽에 위치해서, 위험 입력이 이미 가드 체인 안쪽까지 들어온 뒤에야 검사돼요.
실무 개선 포인트 (심화)
- 정규 표현식 기반 확장 -- 현재
contains매칭은 "ignore all previous instructions" 같은 변형을 놓칠 수 있어요. 실무에서는Pattern.compile("ignore.*(?:previous|prior).*instructions")같은 정규식으로 변형을 포괄합니다. - LLM 기반 2차 판정 -- 규칙 기반 1차 필터를 통과한 입력 중 의심스러운 것만 LLM에게 "이 입력이 프롬프트 인젝션인지" 판정시키는 2단계 구조가 OWASP LLM Top 10에서 권장하는 방어 심층(defense in depth) 접근이에요.
과제 2 예시답안: Evaluator 시나리오 3종 추가
과제 2 예시답안: Evaluator 시나리오 3종 추가
핵심 접근
Step 6에서 배운 Evaluator 인터페이스 패턴을 재활용해서, ai-friends 도메인에 맞는 3종의 규칙 기반 평가자를 구현합니다. AffectionCoherenceEvaluator가 키워드 매칭 + 임계값으로 판정한 것과 같은 흐름을 따르되, 각각 다른 품질 축을 평가해요.
예시 구현
(a) 금칙어 필터 Evaluator
// kr.spartaclub.aifriends.harness.evaluator.ProfanityFilterEvaluator
package kr.spartaclub.aifriends.harness.evaluator;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.Evaluator;
public class ProfanityFilterEvaluator implements Evaluator {
private final Set<String> bannedWords;
public ProfanityFilterEvaluator(Set<String> bannedWords) {
this.bannedWords = Set.copyOf(bannedWords);
}
@Override
public EvaluationResponse evaluate(EvaluationRequest request) {
String response = request.getResponseContent();
if (response == null || response.isBlank()) {
return new EvaluationResponse(true, 1.0f, "PASS: 응답 없음",
Map.of("evaluationType", "profanity_filter"));
}
List<String> detected = new ArrayList<>();
for (String word : bannedWords) {
if (response.contains(word)) {
detected.add(word);
}
}
if (!detected.isEmpty()) {
return new EvaluationResponse(
false, 0.0f,
"FAIL: 금칙어 %d개 감지: %s".formatted(detected.size(), detected),
Map.of("detectedWords", detected,
"evaluationType", "profanity_filter")
);
}
return new EvaluationResponse(true, 1.0f, "PASS",
Map.of("evaluationType", "profanity_filter"));
}
}
(b) 응답 길이 적정성 Evaluator
// kr.spartaclub.aifriends.harness.evaluator.ResponseLengthEvaluator
package kr.spartaclub.aifriends.harness.evaluator;
import java.util.Map;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.Evaluator;
public class ResponseLengthEvaluator implements Evaluator {
private final int maxSentences;
public ResponseLengthEvaluator(int maxSentences) {
this.maxSentences = maxSentences;
}
@Override
public EvaluationResponse evaluate(EvaluationRequest request) {
String response = request.getResponseContent();
if (response == null || response.isBlank()) {
return new EvaluationResponse(true, 1.0f, "PASS: 응답 없음",
Map.of("evaluationType", "response_length"));
}
// 마침표(. ! ?)로 문장 수를 센다
long sentenceCount = response.chars()
.filter(c -> c == '.' || c == '!' || c == '?')
.count();
if (sentenceCount > maxSentences) {
return new EvaluationResponse(
false, 0.0f,
"FAIL: 문장 수 %d (한도 %d)".formatted(sentenceCount, maxSentences),
Map.of("sentenceCount", sentenceCount,
"maxSentences", maxSentences,
"evaluationType", "response_length")
);
}
return new EvaluationResponse(true, 1.0f, "PASS",
Map.of("sentenceCount", sentenceCount,
"evaluationType", "response_length"));
}
}
(c) 언어 일관성 Evaluator
// kr.spartaclub.aifriends.harness.evaluator.LanguageConsistencyEvaluator
package kr.spartaclub.aifriends.harness.evaluator;
import java.util.Map;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.Evaluator;
public class LanguageConsistencyEvaluator implements Evaluator {
private final double englishRatioThreshold;
public LanguageConsistencyEvaluator(double englishRatioThreshold) {
this.englishRatioThreshold = englishRatioThreshold;
}
@Override
public EvaluationResponse evaluate(EvaluationRequest request) {
String response = request.getResponseContent();
if (response == null || response.isBlank()) {
return new EvaluationResponse(true, 1.0f, "PASS: 응답 없음",
Map.of("evaluationType", "language_consistency"));
}
// 공백/특수문자를 제외한 알파벳+한글 문자만 카운트
long totalLetters = response.chars()
.filter(Character::isLetter)
.count();
if (totalLetters == 0) {
return new EvaluationResponse(true, 1.0f, "PASS: 문자 없음",
Map.of("evaluationType", "language_consistency"));
}
long englishLetters = response.chars()
.filter(c -> Character.isLetter(c)
&& (('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')))
.count();
double englishRatio = (double) englishLetters / totalLetters;
if (englishRatio >= englishRatioThreshold) {
return new EvaluationResponse(
false, 0.0f,
"FAIL: 영어 비율 %.1f%% (한도 %.0f%%)".formatted(
englishRatio * 100, englishRatioThreshold * 100),
Map.of("englishRatio", englishRatio,
"threshold", englishRatioThreshold,
"evaluationType", "language_consistency")
);
}
return new EvaluationResponse(true, 1.0f, "PASS",
Map.of("englishRatio", englishRatio,
"evaluationType", "language_consistency"));
}
}
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
Evaluator 인터페이스 구현 |
3종 모두 Spring AI Evaluator를 구현하고 EvaluationResponse를 올바르게 반환하는가 |
상 |
| (a) 금칙어 Set 주입 | 생성자에서 Set<String>을 받아 불변 복사(Set.copyOf)하는가 |
중 |
| (b) 마침표 기반 문장 카운트 | . ! ?를 기준으로 문장 수를 세는 로직이 동작하는가 |
중 |
| (c) 영어 비율 계산 | 알파벳 문자 수 / 전체 문자 수로 비율을 올바르게 계산하는가 | 중 |
| metadata 활용 | 감지 결과를 EvaluationResponse의 metadata에 담아 디버깅에 활용 가능한가 |
하 |
| null 안전 | 빈 응답이나 null에 대해 NPE 없이 PASS 처리하는가 | 하 |
흔한 실수
- 금칙어 Set을 외부에서 수정 가능하게 둠 --
this.bannedWords = bannedWords로 직접 참조하면 외부에서add()/remove()가능.Set.copyOf()로 불변 복사해야 안전해요. - 문장 카운트에서 줄임표("...") 처리 누락 --
"정말요..."하나에 마침표 3개가 세어져서 3문장으로 판정될 수 있어요. 학습 단계에서는 단순 구현이 적절하지만, 실무에서는 연속 마침표를 1개로 정규화하는 전처리가 필요합니다. - 영어 비율 계산에서 숫자/공백 포함 --
response.length()를 분모로 쓰면 공백과 특수문자가 포함돼 비율이 왜곡돼요.Character.isLetter()로 문자만 세야 정확합니다.
실무 개선 포인트 (심화)
- Evaluator chain 구성 -- Step 6에서 배운 것처럼, 이 3종과 기존 2종을
List<Evaluator>로 묶어 순차 실행하는 구조를 만들 수 있어요. 규칙 기반 4종을 먼저 돌리고, 통과하면 LLM 기반CharacterConsistencyEvaluator를 마지막에 실행하면 비용을 아낄 수 있습니다. - 금칙어 목록 외부화 -- 금칙어를 코드에 하드코딩하면 갱신할 때마다 배포가 필요해요.
application.yml또는 DB에서 읽어오게 하면 운영 중 실시간 갱신이 가능합니다.
과제 3 예시답안: Usage 대시보드 API
과제 3 예시답안: Usage 대시보드 API
핵심 접근
Step 7에서 만든 UsageTrackingAdvisor를 빈으로 등록하고, snapshot() 메서드가 반환하는 UsageSummary record를 ApiResponse<UsageSummary>로 감싸서 반환하는 컨트롤러를 만들면 됩니다. 본 강의의 표준 응답 패턴을 그대로 따르는 간단한 과제예요.
예시 구현
1단계: UsageTrackingAdvisor를 빈으로 등록
// kr.spartaclub.aifriends.harness.config.HarnessAdvisorChainConfig 에 추가
@Bean
public UsageTrackingAdvisor usageTrackingAdvisor() {
return new UsageTrackingAdvisor();
}
2단계: 컨트롤러 구현
// kr.spartaclub.aifriends.harness.controller.UsageAdminController
package kr.spartaclub.aifriends.harness.controller;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.harness.advisor.UsageSummary;
import kr.spartaclub.aifriends.harness.advisor.UsageTrackingAdvisor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class UsageAdminController {
private final UsageTrackingAdvisor usageTrackingAdvisor;
@GetMapping("/api/admin/usage")
public ResponseEntity<ApiResponse<UsageSummary>> getUsage() {
UsageSummary summary = usageTrackingAdvisor.snapshot();
return ResponseEntity.ok(ApiResponse.success(summary));
}
}
응답 예시는 다음과 같아요.
{
"success": true,
"data": {
"totalPromptTokens": 12500,
"totalCompletionTokens": 8700,
"totalTokens": 21200,
"callCount": 42,
"averageTokensPerCall": 504.76
}
}
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
ApiResponse<UsageSummary> 래핑 |
본 강의의 표준 응답 패턴대로 ApiResponse.success()로 감싸는가 |
상 |
UsageTrackingAdvisor 빈 주입 |
DI로 주입받아 snapshot()을 호출하는가 (직접 new하지 않는가) |
상 |
| 엔드포인트 경로 | GET /api/admin/usage로 정확히 매핑되는가 |
중 |
ResponseEntity 반환 |
HTTP 상태 코드를 명시적으로 제어하는가 | 하 |
흔한 실수
UsageTrackingAdvisor를new로 직접 생성 -- 컨트롤러에서new UsageTrackingAdvisor()를 쓰면, advisor chain에 등록된 인스턴스와 다른 객체라서 카운터가 항상 0이에요. 반드시 빈으로 등록된 같은 인스턴스를 DI로 주입받아야 합니다.ApiResponse래핑 누락 --UsageSummary를 직접 반환하면GlobalExceptionHandler의 에러 응답(ApiResponse.fail(...))과 형태가 달라져서 프론트엔드에서 정상/에러 응답을 다른 로직으로 파싱해야 해요.UsageTrackingAdvisor를@Bean으로만 등록하고 advisor chain에 안 넣음 -- 빈으로 존재하지만ChatClient의defaultAdvisors에 추가하지 않으면after훅이 호출되지 않아서 카운터가 증가하지 않아요.
실무 개선 포인트 (심화)
- 인증/인가 추가 -- 현재는 누구나 접근 가능한 열린 엔드포인트예요. 실무에서는 Spring Security의
@PreAuthorize("hasRole('ADMIN')")같은 권한 검사를 붙여서 관리자만 접근하게 해야 합니다. - Micrometer 메트릭 연동 -- Day 20에서 배울 Observability와 연결하면,
snapshot()폴링 방식 대신 MicrometerCounter에 실시간으로 밀어넣고 Prometheus + Grafana 대시보드에서 시계열 그래프로 볼 수 있어요.
생각해볼 주제 1 예시답안: Harness 가드 수와 지연 시간의 트레이드오프
주제 1 예시답안: Harness 가드 수와 지연 시간
[문제 상황 요약]
오늘 우리는 advisor 6개를 chain으로 걸었어요. 모든 요청이 6번의 before/after를 통과해야 해요. 가드가 많을수록 안전하지만, 그만큼 요청 하나에 붙는 전후처리 호출이 늘어나죠. "프로덕션에서 가드를 몇 개까지 거는 게 적절한가?"라는 판단 기준을 세워야 해요.
[튜터의 가이드 및 해설]
이 문제는 안전성과 응답 속도의 균형점을 묻는 질문이에요.
가드의 실측 오버헤드부터 파악해야 합니다.
오늘 만든 advisor들의 before/after 로직은 대부분 AtomicLong 덧셈, Set.contains() 조회, 문자열 contains() 검사 수준이에요.
개별 advisor의 지연은 마이크로초 단위로, LLM 호출 지연(수백 밀리초~수 초)과 비교하면 무시할 수 있는 수준이에요. advisor 6개가 10개가 되어도 순수 가드 오버헤드는 밀리초 미만입니다.
그렇다면 왜 가드 수를 줄이고 싶을까요? 가드 수가 많아지면 지연보다 관리 복잡도가 진짜 비용이에요. advisor 20개가 걸려 있으면 새 기능을 추가할 때 "이 기능이 어떤 가드에 걸리는지"를 전부 파악해야 해요. 가드 간 상호작용(A 가드가 통과시킨 것을 B 가드가 차단하는 모순)도 조합이 늘수록 검증이 어려워집니다.
- Option A: 가드를 최소화 -- 핵심 3~4개(반복 제한, 타임아웃, 토큰 예산, 입력 검증)만 유지. 관리가 쉽고, 새 기능 추가 시 가드 충돌 확률이 낮아요. 하지만 도구 권한 필터나 출력 검증처럼 "빠지면 사고가 나는" 가드를 놓칠 수 있어요.
- Option B: 가드를 계층화 -- 규칙 기반 가드(빠르고 저렴)를 바깥에 두고, LLM 기반 검증은 안쪽에 둬요. 바깥 가드에서 대부분의 위험을 걸러내고, 안쪽의 비싼 검증은 선별적으로 실행. Step 6에서 배운 "규칙 1차, LLM 2차" 패턴과 같은 원리예요.
- 현업에서는 보통: "Harness 6구성요소에 각 1개씩, 총 6~8개"가 일반적인 시작점이에요. 여기서 LLM 기반 평가처럼 지연이 큰 것만 선택적으로 켜고 끄는 feature flag 방식을 조합합니다. 중요한 건 가드 수 자체가 아니라, 각 가드의 존재 이유를 팀이 설명할 수 있는가예요. 설명 못하는 가드는 빼는 게 낫습니다.
🎯 면접관을 홀리는 핵심 멘트
"advisor chain의 순수 오버헤드는 마이크로초 단위라 성능 병목이 되기 어렵습니다. 진짜 비용은 가드 간 조합 폭발로 인한 관리 복잡도예요. 그래서 저는 Harness 6구성요소에 각 1개씩 배치하고, LLM 기반 평가처럼 지연이 큰 가드만 feature flag로 선택 실행하는 계층 구조를 선호합니다. 가드를 몇 개 거느냐보다, 각 가드의 존재 이유를 팀 전체가 설명할 수 있느냐가 더 중요한 기준이라고 생각합니다."
생각해볼 주제 2 예시답안: LLM 앱에서 "테스트"란 무엇인가
주제 2 예시답안: LLM 앱에서 "테스트"란 무엇인가
[문제 상황 요약]
결정론적 단위 테스트는 "같은 입력 -> 같은 출력"을 보장해요. 하지만 LLM의 출력은 temperature, 모델 버전, 심지어 같은 프롬프트에도 매번 달라져요.
assertEquals("안녕하세요!", response)처럼 정확한 문자열 매칭을 쓰면, 같은 코드인데 CI가 어떤 날은 통과하고 어떤 날은 깨져요. 이런 "flaky test"가 쌓이면 팀은 결국 테스트를 무시하게 됩니다.
[튜터의 가이드 및 해설]
이 문제는 확률적 시스템에 결정론적 검증을 어떻게 끼워넣을 수 있는가를 묻는 질문이에요.
핵심 인식: 테스트 대상을 분리해야 합니다. LLM 앱의 코드는 크게 두 영역으로 나뉘어요.
- 결정론적 영역 -- advisor의 before/after 훅, 패턴 매칭 로직,
UsageSummary계산,EvaluationResponse조립 같은 자바 코드. 이 영역은 전통적인 단위 테스트로 100% 커버 가능해요.
오늘 만든 AffectionCoherenceEvaluator의 "호감도 -15 + 긍정 키워드 3개 -> FAIL" 같은 규칙은 완벽하게 결정론적이에요.
2. 비결정론적 영역 -- LLM 호출 결과. "캐릭터가 페르소나에 맞게 대답했는가?" 같은 판정은 매번 달라요.
결정론적 영역은 전통 테스트로 단단히 잡고, 비결정론적 영역은 다른 전략을 쓰는 게 핵심이에요.
- Option A: 골든 데이터셋 + 의미적 유사도 -- 예상 응답 100건의 "골든 데이터셋"을 만들고, LLM 응답과 임베딩 코사인 유사도를 비교해요. 0.85 이상이면 PASS. 정확한 문자열 매칭 대신 "의미가 비슷한가"로 판정하니까 자연어 변동에 강해요. 하지만 골든 데이터셋 관리 비용이 크고, 모델을 교체하면 기준선도 재조정해야 해요.
- Option B: CI에서 결정론 테스트만, 별도 파이프라인에서 LLM 회귀 테스트 -- 메인 CI/CD에는 결정론적 테스트만 돌리고 (advisor, evaluator 규칙 로직, record 직렬화 등), LLM 호출이 포함된 통합 테스트는 일간/주간 배치 파이프라인에서 별도로 실행해요. flaky 테스트가 메인 빌드를 막지 않으면서도 품질 회귀를 감지할 수 있어요.
- 현업에서는 보통: 두 전략을 조합합니다. 메인 CI에서는 LLM을 Mock으로 대체해서 "프롬프트가 올바르게 조립되는가", "응답 파싱이 정상인가"를 결정론적으로 검증해요. 별도 Evaluation 파이프라인에서 실제 LLM을 호출해 골든 데이터셋 기반 회귀 테스트를 돌리고, 통과율이 임계값(예: 95%) 아래로 떨어지면 알림을 보내는 구조예요.
Step 6에서 만든 Evaluator 인터페이스가 여기서도 핵심 역할을 해요. 규칙 기반 Evaluator는 CI에서, LLM 기반 Evaluator는 별도 파이프라인에서 돌리면 비용과 안정성의 균형을 잡을 수 있습니다.
🎯 면접관을 홀리는 핵심 멘트
"LLM 앱 테스트의 핵심은 결정론적 영역과 비결정론적 영역을 분리하는 것입니다. advisor 로직, 패턴 매칭, record 직렬화 같은 자바 코드는 전통적인 단위 테스트로 단단히 잡고, LLM 호출이 포함된 품질 검증은 골든 데이터셋 기반 Evaluation 파이프라인으로 별도 운영합니다. 메인 CI에서는 LLM을 Mock으로 대체해 프롬프트 조립과 응답 파싱만 검증하고, 실제 LLM 품질 회귀는 일간 배치에서 통과율 임계값으로 모니터링하는 2트랙 구조가 실무에서 flaky test 없이 품질을 유지하는 현실적인 접근이라고 생각합니다."
생각해볼 주제 3 예시답안: 에이전트 안전성의 책임 소재
주제 3 예시답안: 에이전트 안전성의 책임 소재
[문제 상황 요약]
에이전트가 잘못된 응답을 했을 때, 예를 들어 금융 서비스에서 잘못된 투자 조언을 했을 때 책임은 누구에게 있을까요? 프레임워크(Spring AI), 개발자(harness를 설계한 사람), LLM 프로바이더(Gemini, Claude), 사용자(에이전트에게 질문한 사람) -- 각 주체의 책임 범위를 어떻게 나눌 수 있을까요?
[튜터의 가이드 및 해설]
이 문제는 기술적 판단이 아니라 엔지니어링 윤리와 시스템 설계 책임을 묻는 질문이에요.
각 주체의 책임 범위를 구분해 봅시다.
-
LLM 프로바이더의 책임: 모델의 학습 데이터 품질, 안전 필터(content safety), API 계약(SLA) 이행. 프로바이더는 "범용 도구"를 제공하는 것이지 "도메인 정확성"까지 보장하지 않아요. Gemini가 "A 주식을 사세요"라고 답해도, 그건 Gemini의 학습 데이터 기반 확률적 출력이지 투자 추천이 아닙니다.
-
프레임워크(Spring AI)의 책임: advisor chain, evaluator 인터페이스, usage 추적 같은 harness 도구를 제공하는 것. Spring AI는 "가드레일을 걸 수 있는 메커니즘"을 제공하지, "어떤 가드레일을 걸어야 하는지"를 결정하지 않아요.
BaseAdvisor라는 확장점을 만들어준 것이지, InputSanitizationAdvisor를 자동으로 달아주진 않습니다.
-
개발자의 책임: 가장 넓어요. 도메인에 맞는 harness를 설계하고 구축할 책임이 개발자에게 있습니다.
- "harness를 제대로 구축하지 않았을 때" -- 명백한 개발자 과실이에요. 금융 서비스에서 "투자 조언을 하지 마세요" 같은 시스템 프롬프트도 없고, output evaluator도 없었다면, LLM의 확률적 출력을 그대로 사용자에게 전달한 설계 판단의 문제예요.
- "harness를 구축했지만 LLM이 예측 불가능한 응답을 했을 때" -- 이건 더 복잡해요. 규칙 기반 evaluator를 통과했지만 LLM이 미묘한 방식으로 부적절한 응답을 했다면, "합리적인 수준의 가드레일을 설치했는가"가 판단 기준이 됩니다.
-
사용자의 책임: 면책 조항(disclaimer) 동의, 에이전트 응답을 최종 의사결정에 그대로 사용하지 않을 주의 의무 정도예요. 하지만 "사용자가 알아서 판단해야지"로 모든 책임을 넘기는 건 설계자로서 무책임합니다.
핵심은 "합리적인 수준의 방어"를 설계했는가예요.
오늘 배운 Harness 6구성요소는 이 "합리적인 수준"의 체크리스트와 같아요.
Input Validation으로 위험 입력을 걸러내고, Tool Permission으로 접근 범위를 제한하고, Output Validation으로 응답 품질을 검증하고, Cost Guardrail로 비용 폭주를 차단하는 -- 이 체계가 갖춰져 있다면, 예측 불가능한 LLM 출력에 대해 개발자가 "합리적 주의 의무를 다했다"고 주장할 수 있는 근거가 됩니다.
반대로 harness 없이 LLM 출력을 그대로 사용자에게 전달한다면, 그건 "에어백 없는 차를 출시한 것"과 같아요. LLM이 사고를 낸 게 아니라, 개발자가 안전장치 없이 출시한 거예요.
🎯 면접관을 홀리는 핵심 멘트
"에이전트 안전성의 일차 책임은 harness를 설계하는 개발자에게 있다고 생각합니다. LLM 프로바이더는 범용 도구를 제공하고, 프레임워크는 가드레일 메커니즘을 제공하지만, 도메인에 맞는 가드레일을 실제로 구축하는 건 개발자의 몫입니다. 금융 서비스에서 잘못된 투자 조언이 나갔다면, LLM이 확률적으로 그런 출력을 낸 것보다, 그 출력을 필터링하는 output evaluator를 설계하지 않은 것이 더 큰 문제입니다. 'harness 6구성요소를 빠짐없이 채웠는가'가 합리적 주의 의무의 실질적인 체크리스트라고 판단합니다."