문서 읽는 데 93분 · day22

Day 22. Observability 실전 — Micrometer · 감사 로그 · PII 마스킹

전체 26강 중 24강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요, 여러분의 AI 튜터 홍순구입니다.

지난 시간에 Day 14의 수동 에이전트를 선언적 AgentClient로 재구현하고, AgentBench로 회귀 평가 시나리오까지 작성했어요. YAML 한 장에 에이전트를 정의하고, run() 한 줄로 guard 4종이 자동 적용되는 구조까지 왔죠.

그런데 마지막에 복선 하나를 흘려뒀어요.

Day 19에서 만든 UsageTrackingAdvisorAtomicLong으로 토큰을 누적 추적했어요. 서버를 재시작하면? 다 날아가요. AgentBench의 passRate()도 콘솔에 한 번 찍히고 사라졌어요. 프로덕션에서 "지난주 대비 토큰 사용량이 얼마나 늘었는지", "에이전트 품질이 시간에 따라 어떻게 변하는지"를 추적하려면 인메모리가 아닌 영속 메트릭이 필요해요.

그리고 LLM 앱에는 전통 웹 앱에 없는 관측 축이 하나 더 있어요. "누가 언제 무엇을 물었고, LLM이 뭐라 답했는지"를 기록하는 감사 로그. 여기에 전화번호나 이메일이 섞여 있다면? PII 마스킹 없이 저장하면 개인정보보호법 위반이에요.

오늘은 이 세 가지를 코드로 채워요. Micrometer로 메트릭을 영속화하고, Advisor 기반 감사 로그를 만들고, PII 마스킹 필터를 끼워넣어요. 마지막엔 Prometheus + Grafana 대시보드에서 실제 시계열 그래프를 확인합니다.

🎯 오늘의 한 줄. Day 19의 인메모리 추적을 Micrometer 영속 메트릭으로 격상하고, 감사 로그 + PII 마스킹을 Advisor chain에 끼워넣어 운영의 눈을 완성한다.

🎯 학습 목표

  • Spring AI의 Observation 아키텍처와 Micrometer Observation API를 이해한다
  • UsageTrackingAdvisor의 AtomicLong 추적을 Micrometer DistributionSummary/Counter로 격상한다
  • 프롬프트/응답 전문을 DB에 저장하는 감사 로그 Advisor를 구현한다
  • 정규식 기반 PII 마스킹 필터를 감사 로그 직전에 끼워넣는다
  • AgentBench passRate()를 Gauge로, 토큰 임계값 초과를 Health Indicator로 노출한다
  • docker-compose에 Prometheus + Grafana를 추가하고 대시보드를 확인한다

Step 1. Observability 아키텍처 개요 — Spring AI가 자동으로 잡아주는 것들

에이전트를 프로덕션에 올릴 때, "잘 돌아가고 있는지"를 어떻게 알 수 있을까요? 전통적인 웹 앱이라면 HTTP 상태 코드, 응답 지연, 에러율 정도를 보면 됐어요. 그런데 LLM 앱은 축이 다릅니다.

LLM 앱에 필요한 관측 5축

전통 웹 앱 LLM 앱
지연 HTTP 응답 시간 LLM 호출 지연 (모델·프롬프트 길이에 따라 수십 배 차이)
비용 인프라 비용 (대체로 고정) 토큰 = 돈. 호출마다 과금, 예측 어려움
품질 에러율 (4xx/5xx) 환각률, 캐릭터 설정 위반, 응답 일관성
감사 접근 로그 프롬프트/응답 전문 기록 (규정 준수)
개인정보 입력 검증 PII 마스킹 (LLM이 개인정보를 응답에 넣을 수 있음)

Spring AI + Micrometer Observation API

Spring AI 1.1.x는 Micrometer Observation API를 내장하고 있어요. ChatClientcall()이나 stream()을 호출하면, 프레임워크가 자동으로 spring.ai.chat.client observation을 기록해요.

자동으로 잡히는 메트릭:

  • spring.ai.chat.client.duration — ChatClient 호출 지연 (Timer)
  • 모델 이름 태그 — 어떤 모델을 호출했는지
  • 프롬프트/응답 로깅spring.ai.chat.observations.include-prompt=true로 활성화 가능 (기본 off)

하지만 토큰 사용량 분포, 비용 알림, 감사 로그 저장, PII 마스킹은 자동으로 안 잡혀요. 이건 우리가 직접 만들어야 하는 영역이에요.

오늘 만들 Observability 컴포넌트 5종

컴포넌트 역할 Step
UsageTrackingMeterAdvisor 토큰 사용량을 Micrometer DistributionSummary로 기록 Step 3
AuditLoggingAdvisor 프롬프트/응답 전문을 DB에 저장 Step 4
PiiMaskingAdvisor 개인정보 패턴을 마스킹한 뒤 감사 로그에 기록 Step 5
HallucinationRateGauge AgentBench passRate()를 Gauge 메트릭으로 노출 Step 6
CostAlertHealthIndicator 토큰 임계값 초과 시 Actuator Health WARNING Step 6

Day 19~21에서 만든 부품들이 오늘 Observability 레이어로 연결되는 구조예요.


Step 2. Micrometer Observation 활성화 + 기본 메트릭 수집

코드를 쓰기 전에, Spring Boot Actuator와 Micrometer Prometheus Registry를 프로젝트에 추가해요.

의존성 추가

// build.gradle — Day 22 Observability 실전
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

spring-boot-starter-actuator가 Micrometer 코어 + /actuator 엔드포인트를 가져오고, micrometer-registry-prometheus가 Prometheus가 scrape할 수 있는 포맷으로 메트릭을 노출해요.

application.yml 설정

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: ${spring.application.name}
  • exposure.include/actuator/prometheus, /actuator/health, /actuator/metrics 엔드포인트를 열어요
  • show-details: always — Health Check에서 각 indicator의 상세 정보를 보여줘요 (Step 6에서 CostAlertHealthIndicator 상태 확인용)
  • metrics.tags.application — 모든 메트릭에 application=ai-friends 태그가 자동으로 붙어요. Grafana에서 앱 단위로 필터링할 때 유용해요

Actuator 엔드포인트 확인

앱을 띄우고 다음 URL을 확인해 볼 수 있어요.

  • http://localhost:8080/actuator/health — 앱 상태
  • http://localhost:8080/actuator/metrics — 등록된 메트릭 이름 목록
  • http://localhost:8080/actuator/prometheus — Prometheus가 scrape할 텍스트 포맷
🙋 학생 질문 — "메트릭 엔드포인트를 외부에 열어도 안전한가요?"

프로덕션에서는 Actuator 엔드포인트를 별도 포트로 분리하거나, Spring Security로 인증을 걸어요. management.server.port=9091 같은 설정으로 내부 네트워크에서만 접근 가능하게 하는 게 일반적이에요. 지금은 학습 환경이라 기본 포트에 열어두지만, 배포할 땐 반드시 분리하세요.

Spring AI 자동 Observation

Spring AI 1.1.x는 ChatClient 호출 시 자동으로 observation을 기록해요. Actuator 의존성만 추가하면 spring.ai.chat.client 관련 Timer가 자동 등록돼요. 프롬프트/응답 내용까지 로깅하려면 다음 설정을 켜야 하지만, 민감 정보 노출 위험 때문에 프로덕션에서는 기본 off로 두는 게 안전해요.

# 개발 환경에서만 — 프롬프트/응답 내용 로깅 (프로덕션에서는 off)
spring:
  ai:
    chat:
      observations:
        include-prompt: true
        include-completion: true

⚠️ 이 설정은 디버깅 전용이에요. 프롬프트에 사용자 개인정보가 포함될 수 있으므로 프로덕션에서는 꺼두세요. 감사 로그가 필요하다면 Step 4~5에서 만드는 Advisor 기반 감사 로그 + PII 마스킹 조합이 안전해요.


Step 3. 커스텀 메트릭 — UsageTrackingAdvisor를 Micrometer로 격상

Day 19에서 만든 UsageTrackingAdvisor를 기억하시죠? AtomicLong으로 프롬프트 토큰, 완성 토큰, 전체 토큰을 인메모리로 누적했어요. snapshot()을 호출하면 현재 시점의 스냅샷을 볼 수 있었죠.

문제는 서버를 재시작하면 다 날아간다는 거예요. "지난주 대비 이번 주 토큰 사용량이 얼마나 늘었는지"를 보려면 시계열 데이터가 필요해요.

Before (Day 19) vs After (Day 22)

Day 19 UsageTrackingAdvisor Day 22 UsageTrackingMeterAdvisor
저장 AtomicLong 인메모리 Micrometer DistributionSummary + Counter
영속성 서버 재시작 시 초기화 Prometheus가 scrape → 영속 시계열
조회 snapshot() 메서드 호출 /actuator/prometheus + Grafana 대시보드
분포 누적 합만 백분위 분포 (p50, p95, p99)

UsageTrackingMeterAdvisor 구현

// kr.spartaclub.aifriends.harness.observability.UsageTrackingMeterAdvisor
public class UsageTrackingMeterAdvisor implements BaseAdvisor {

    private final DistributionSummary promptTokenSummary;
    private final DistributionSummary completionTokenSummary;
    private final Counter callCounter;

    public UsageTrackingMeterAdvisor(MeterRegistry registry) {
        this.promptTokenSummary = DistributionSummary.builder("ai.token.prompt")
                .description("LLM 호출당 프롬프트 토큰 수")
                .baseUnit("tokens")
                .register(registry);
        this.completionTokenSummary = DistributionSummary.builder("ai.token.completion")
                .description("LLM 호출당 완성 토큰 수")
                .baseUnit("tokens")
                .register(registry);
        this.callCounter = Counter.builder("ai.llm.calls")
                .description("LLM 호출 횟수")
                .register(registry);
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response,
                                    AdvisorChain advisorChain) {
        if (response == null) {
            return null;
        }
        ChatResponse chatResponse = response.chatResponse();
        if (chatResponse == null) {
            return response;
        }
        ChatResponseMetadata metadata = chatResponse.getMetadata();
        if (metadata == null) {
            return response;
        }
        Usage usage = metadata.getUsage();
        if (usage == null) {
            return response;
        }

        long prompt = safeValue(usage.getPromptTokens());
        long completion = safeValue(usage.getCompletionTokens());

        promptTokenSummary.record(prompt);
        completionTokenSummary.record(completion);
        callCounter.increment();

        return response;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request,
                                    AdvisorChain advisorChain) {
        return request;
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }

    private static long safeValue(Integer value) {
        return value == null ? 0 : value.longValue();
    }
}

핵심 포인트:

  • DistributionSummary — 단순 합이 아닌 분포를 기록해요. Prometheus에서 p50, p95 백분위를 조회할 수 있어요. "호출의 95%는 프롬프트 토큰 500개 이하" 같은 인사이트를 얻을 수 있죠
  • Counter — 단순 증가 카운터. 호출 횟수를 세요
  • MeterRegistry — Spring Boot가 자동으로 주입하는 메트릭 레지스트리. Prometheus Registry가 연결되어 있으면 /actuator/prometheus에 자동 노출돼요
🙋 학생 질문 — "기존 UsageTrackingAdvisor는 지우나요?"

아니요, 유지해요. UsageTrackingAdvisorCostAlertHealthIndicator(Step 6)에서 현재 시점의 누적 토큰을 읽을 때 쓰여요. UsageTrackingMeterAdvisor시계열 기록 전용, UsageTrackingAdvisor스냅샷 조회 전용으로 역할이 분리돼요. 두 advisor를 동시에 chain에 끼워넣어도 order가 동일(LOWEST_PRECEDENCE)이라 간섭 없이 동작해요.

Prometheus에서 확인

앱을 띄운 뒤 LLM 호출을 한 번 하고 /actuator/prometheus를 보면 이런 메트릭이 보여요.

# HELP ai_token_prompt_tokens LLM 호출당 프롬프트 토큰 수
ai_token_prompt_tokens_count 1.0
ai_token_prompt_tokens_sum 127.0
ai_token_prompt_tokens_max 127.0

# HELP ai_llm_calls_total LLM 호출 횟수
ai_llm_calls_total 1.0

Day 19의 snapshot()을 콘솔에서 눈으로 확인하던 것이, 이제 Prometheus → Grafana 시계열 그래프로 영속 추적되는 거예요.


Step 4. 프롬프트/응답 감사 로그 저장 — Advisor 기반

"누가 언제 무엇을 물었고, LLM이 뭐라 답했는지" — 이게 왜 필요할까요?

감사 로그 엔티티

// kr.spartaclub.aifriends.harness.observability.AuditLogEntity
@Entity
@Table(name = "audit_log")
public class AuditLogEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "request_text", columnDefinition = "TEXT")
    private String requestText;

    @Column(name = "response_text", columnDefinition = "TEXT")
    private String responseText;

    @Column(name = "model_name")
    private String modelName;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    protected AuditLogEntity() {
    }

    public AuditLogEntity(String requestText, String responseText,
                          String modelName) {
        this.requestText = requestText;
        this.responseText = responseText;
        this.modelName = modelName;
        this.createdAt = LocalDateTime.now();
    }

    // getter 생략 — 코드베이스 참조
}

AuditLoggingAdvisor

// kr.spartaclub.aifriends.harness.observability.AuditLoggingAdvisor
public class AuditLoggingAdvisor implements BaseAdvisor {

    private final AuditLogRepository repository;

    public AuditLoggingAdvisor(AuditLogRepository repository) {
        this.repository = repository;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response,
                                    AdvisorChain advisorChain) {
        if (response == null) {
            return null;
        }

        String responseText = extractResponseText(response);
        AuditLogEntity entity = new AuditLogEntity(
                null, responseText, null);
        repository.save(entity);

        return response;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request,
                                    AdvisorChain advisorChain) {
        return request;
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 1;
    }

    private String extractResponseText(ChatClientResponse response) {
        ChatResponse chatResponse = response.chatResponse();
        if (chatResponse == null) {
            return null;
        }
        Generation result = chatResponse.getResult();
        if (result == null || result.getOutput() == null) {
            return null;
        }
        return result.getOutput().getText();
    }
}

핵심 설계 포인트:

  • order = LOWEST_PRECEDENCE - 1UsageTrackingMeterAdvisor(LOWEST_PRECEDENCE)보다 바깥에 위치해요. Advisor chain은 before가 바깥→안쪽, after가 안쪽→바깥 순서로 실행되니까, 감사 로그는 마스킹이 끝난 뒤 저장돼요
  • AuditLogRepository — Spring Data JPA Repository. JPA ddl-auto가 audit_log 테이블을 자동 생성해요

저장소 선택 가이드

저장소 장점 단점 적합한 경우
JDBC (MySQL) 기존 인프라 재활용, 트랜잭션 보장 검색 느림, 대용량 시 부담 로그량 적음, 단순 이력
Elasticsearch 전문 검색, 대용량 처리 별도 인프라 필요 로그 검색·분석 빈번

ai-friends는 학습 환경이라 이미 있는 MySQL을 재활용해요. 프로덕션에서 로그량이 많아지면 Elasticsearch로 전환을 검토하면 돼요.

🙋 학생 질문 — "감사 로그가 쌓이면 DB 부담이 크지 않나요?"

맞아요. 프로덕션에서는 보존 기간 정책(예: 90일 후 자동 삭제)과 비동기 저장(큐에 넣고 배치로 flush)을 적용해요. 지금은 학습 목적이라 동기 저장으로 두지만, 과제에서 비동기 전환을 다뤄볼 수 있어요.


Step 5. PII 마스킹 필터 구현

감사 로그에 "전화번호 010-1234-5678로 연락주세요"가 그대로 저장되면? 개인정보보호법 위반이에요. LLM은 사용자가 입력한 개인정보를 그대로 응답에 포함할 수 있어요. 감사 로그 저장 직전에 개인정보를 탐지하고 마스킹하는 레이어가 필요해요.

PiiDetectionUtil — 정규식 기반 탐지 + 마스킹

// kr.spartaclub.aifriends.harness.observability.PiiDetectionUtil
public final class PiiDetectionUtil {

    private static final Pattern PHONE_PATTERN =
            Pattern.compile("(0\\d{1,2})-(\\d{3,4})-(\\d{4})");

    private static final Pattern EMAIL_PATTERN =
            Pattern.compile(
                "([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})");

    private static final Pattern RESIDENT_ID_PATTERN =
            Pattern.compile("(\\d{6})-(\\d{7})");

    private PiiDetectionUtil() {
    }

    public static String mask(String input) {
        if (input == null) {
            return null;
        }
        String result = PHONE_PATTERN.matcher(input)
                .replaceAll("$1-****-****");
        result = RESIDENT_ID_PATTERN.matcher(result)
                .replaceAll("$1-*******");
        result = EMAIL_PATTERN.matcher(result)
                .replaceAll(mr -> {
                    String local = mr.group(1);
                    String domain = mr.group(2);
                    return local.charAt(0) + "***@" + domain;
                });
        return result;
    }

    public static boolean containsPii(String input) {
        if (input == null) {
            return false;
        }
        return PHONE_PATTERN.matcher(input).find()
                || EMAIL_PATTERN.matcher(input).find()
                || RESIDENT_ID_PATTERN.matcher(input).find();
    }
}

마스킹 결과 예시:

원본 마스킹 후
010-1234-5678 010-****-****
user@example.com u***@example.com
901231-1234567 901231-*******

PiiMaskingAdvisor

// kr.spartaclub.aifriends.harness.observability.PiiMaskingAdvisor
public class PiiMaskingAdvisor implements BaseAdvisor {

    @Override
    public ChatClientResponse after(ChatClientResponse response,
                                    AdvisorChain advisorChain) {
        if (response == null) {
            return null;
        }
        ChatResponse chatResponse = response.chatResponse();
        if (chatResponse == null) {
            return response;
        }
        Generation result = chatResponse.getResult();
        if (result == null || result.getOutput() == null
                || result.getOutput().getText() == null) {
            return response;
        }

        String original = result.getOutput().getText();
        if (!PiiDetectionUtil.containsPii(original)) {
            return response;
        }

        String masked = PiiDetectionUtil.mask(original);
        AssistantMessage maskedOutput = new AssistantMessage(masked);
        Generation maskedGeneration = new Generation(
                maskedOutput, result.getMetadata());
        ChatResponse maskedChatResponse = new ChatResponse(
                List.of(maskedGeneration),
                chatResponse.getMetadata()
        );
        return ChatClientResponse.builder()
                .chatResponse(maskedChatResponse)
                .context(response.context())
                .build();
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request,
                                    AdvisorChain advisorChain) {
        return request;
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

Advisor chain 순서가 왜 중요한가

order 값 Advisor after() 실행 순서
LOWEST_PRECEDENCE - 1 AuditLoggingAdvisor 2번째 (마스킹된 텍스트를 저장)
LOWEST_PRECEDENCE PiiMaskingAdvisor 1번째 (원본을 마스킹)
LOWEST_PRECEDENCE UsageTrackingMeterAdvisor 1번째 (토큰 기록)

after()는 안쪽(order 큰 쪽)에서 바깥(order 작은 쪽)으로 실행돼요. 그래서 마스킹이 먼저 일어나고, 감사 로그는 마스킹된 텍스트를 저장하게 돼요.

💡 GDPR에서는 "로그에 개인정보가 포함되지 않도록 기술적 조치를 취해야 한다"고 해요. PII 마스킹 + 감사 로그 분리가 이 조치의 실체예요.


Step 6. 환각 평가 + 비용 알림 메트릭화

Step 3~5에서 만든 Advisor 기반 관측을 두 가지 더 확장해요.

1. HallucinationRateGauge — AgentBench passRate()를 Gauge로

Day 21에서 만든 AgentBenchRunner가 시나리오를 돌린 뒤 AgentBenchResult를 반환했죠. 거기에 passRate() 메서드가 있었어요. 이걸 Micrometer Gauge로 노출하면 "에이전트 품질이 시간에 따라 어떻게 변하는지"를 대시보드에서 추적할 수 있어요.

// kr.spartaclub.aifriends.harness.observability.HallucinationRateGauge
public class HallucinationRateGauge {

    private final MeterRegistry registry;
    private final AtomicReference<Double> passRate =
            new AtomicReference<>(null);
    private volatile boolean registered = false;

    public HallucinationRateGauge(MeterRegistry registry) {
        this.registry = registry;
    }

    public void update(AgentBenchResult result) {
        passRate.set(result.passRate());
        if (!registered) {
            Gauge.builder("ai.agent.bench.pass_rate",
                          passRate,
                          ref -> ref.get() != null ? ref.get() : 0.0)
                    .description("에이전트 벤치마크 통과율 (0.0~1.0)")
                    .register(registry);
            registered = true;
        }
    }
}

AgentBenchRunner가 시나리오를 돌린 뒤 hallucinationRateGauge.update(result)를 호출하면, /actuator/prometheus에 이런 메트릭이 노출돼요.

ai_agent_bench_pass_rate 0.8

Grafana에서 이 값을 시계열로 그리면 "이번 주 에이전트 품질이 떨어지고 있다"를 시각적으로 확인할 수 있어요.

2. CostAlertHealthIndicator — 토큰 임계값 경고

Day 19의 UsageTrackingAdvisor에서 현재 누적 토큰을 읽어, 임계값을 넘으면 Actuator Health에 WARNING 상태를 보고해요.

// kr.spartaclub.aifriends.harness.observability.CostAlertHealthIndicator
public class CostAlertHealthIndicator implements HealthIndicator {

    private static final Status WARNING = new Status("WARNING");

    private final UsageTrackingAdvisor tracker;
    private final long threshold;

    public CostAlertHealthIndicator(UsageTrackingAdvisor tracker,
                                    long threshold) {
        this.tracker = tracker;
        this.threshold = threshold;
    }

    @Override
    public Health health() {
        long totalTokens = tracker.getTotalTokens();
        int callCount = tracker.getCallCount();

        Health.Builder builder = totalTokens >= threshold
                ? Health.status(WARNING)
                : Health.up();

        return builder
                .withDetail("totalTokens", totalTokens)
                .withDetail("threshold", threshold)
                .withDetail("callCount", callCount)
                .build();
    }
}

/actuator/health 응답에 다음이 추가돼요.

{
  "status": "UP",
  "components": {
    "costAlert": {
      "status": "UP",
      "details": {
        "totalTokens": 3200,
        "threshold": 100000,
        "callCount": 15
      }
    }
  }
}

토큰이 임계값을 넘으면 "status": "WARNING"으로 바뀌어요. 프로덕션에서는 이 Health 상태를 모니터링 시스템이 감시하다가, WARNING이 뜨면 Slack 알림을 보내는 식으로 연결해요.

Day 20 Cost Guardrail과의 연결

Day 20에서 만든 RateLimitAdvisor가 호출을 차단한 횟수도 메트릭으로 수집할 수 있어요. RateLimitExceededException이 발생한 횟수를 Counter로 세면 "Rate Limit이 얼마나 자주 걸리는지"를 대시보드에서 확인할 수 있죠. 이건 과제에서 직접 구현해 볼 수 있어요.


Step 7. docker-compose + Prometheus/Grafana 대시보드 통합

지금까지 만든 메트릭이 실제로 대시보드에서 어떻게 보이는지 확인해요. docker-compose에 Prometheus와 Grafana를 추가합니다.

docker-compose.yml 추가 서비스

  # Day 22 — Observability 실전
  prometheus:
    image: prom/prometheus:v2.53.0
    container_name: ai-friends-prometheus
    restart: unless-stopped
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    ports:
      - "9090:9090"
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL",
             "wget -qO- http://localhost:9090/-/healthy || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - ai-friends-net

  grafana:
    image: grafana/grafana:11.6.0
    container_name: ai-friends-grafana
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    volumes:
      - grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"
    depends_on:
      prometheus:
        condition: service_healthy
    networks:
      - ai-friends-net

Prometheus scrape 설정

# monitoring/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'ai-friends'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['app:8080']
  • scrape_interval: 15s — 15초마다 ai-friends 앱의 /actuator/prometheus를 수집해요
  • targets: ['app:8080'] — docker-compose 내부 네트워크에서 앱 컨테이너 이름(app)으로 접근해요

기동 순서

mysql → pgvector → redis → app → prometheus → grafana

Prometheus는 app이 healthy 상태가 된 뒤에 기동되고, Grafana는 Prometheus가 healthy 뒤에 기동돼요.

포트 정리

서비스 포트 용도
ai-friends 앱 8080 앱 + Actuator 엔드포인트
Prometheus 9090 메트릭 수집·조회
Grafana 3000 대시보드 시각화

Step 8. 커스텀 메트릭 시각화 — 대시보드 라이브 시연

지난 Step 에서 docker-compose.yml 에 Prometheus + Grafana 를 끼워 넣었어요. 그런데 학생이 ./run.sh 한 번 친 직후 브라우저로 http://localhost:3000 에 들어가면 — 데이터 소스도 등록되어 있고, 대시보드 한 장도 이미 떠 있어요. 누가 등록한 걸까요?

lecture-source-code/ai-friends/monitoring/ 폴더 안에 박혀 있는 세 개의 설정 파일이 그 답이에요.

1. monitoring 폴더 구조 — Grafana 자동 프로비저닝

monitoring/
├── prometheus.yml                              # Prometheus scrape 설정
└── grafana/
    └── provisioning/
        ├── datasources/
        │   └── datasource.yml                  # Prometheus 데이터 소스 자동 등록
        └── dashboards/
            ├── dashboards.yml                  # 대시보드 제공자 설정
            └── spring-ai-overview.json         # 대시보드 본체 (8개 패널)

Grafana 는 컨테이너 기동 시 /etc/grafana/provisioning/ 아래 두 디렉토리를 자동으로 스캔해요. datasources/ 의 YAML 은 데이터 소스로, dashboards/ 의 JSON 은 대시보드로 자동 로드돼요. 학생이 UI 에서 손으로 데이터 소스를 등록하거나 패널을 하나하나 만들 필요가 없어요.

본 강의의 결은 이거예요. 수동으로 패널을 만드는 노하우가 아니라 Spring AI 가 자동 노출하는 메트릭을 어떻게 읽고 시각화하는지 가 학습의 핵심이에요. 그래서 강의 코드베이스에 완성된 대시보드 한 장을 박아두고, ./run.sh 한 번에 그 결과를 학생이 즉시 보게 했어요.

2. datasource.yml — Prometheus 자동 등록

# monitoring/grafana/provisioning/datasources/datasource.yml
apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    editable: true
    uid: ai-friends-prom

핵심 줄 두 개를 짚을게요.

  • url: http://prometheus:9090 — Grafana 컨테이너 입장에서 Prometheus 는 동일 docker 네트워크의 다른 컨테이너예요. 호스트 이름이 prometheus 라서 localhost:9090 으로는 못 닿아요.
  • uid: ai-friends-prom — 대시보드 JSON 에서 이 UID 로 데이터 소스를 참조해요. UID 를 고정해두지 않으면 대시보드가 Data source not found 로 깨져요.

⚠️ 만약 connect: connection refused 가 뜬다면 Prometheus 컨테이너가 아직 기동 중일 수 있어요. docker compose psai-friends-prometheus 상태가 (healthy) 인지 먼저 확인하세요.

3. dashboards.yml — 대시보드 제공자

# monitoring/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1

providers:
  - name: 'ai-friends'
    orgId: 1
    folder: ''
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: true
    options:
      path: /etc/grafana/provisioning/dashboards
      foldersFromFilesStructure: false
  • updateIntervalSeconds: 30 — 30초마다 JSON 파일을 다시 읽어요. 강의 중에 JSON 을 고치면 컨테이너 재기동 없이 반영돼요.
  • allowUiUpdates: true — Grafana UI 에서도 대시보드를 수정할 수 있어요. 단 컨테이너를 내렸다 다시 띄우면 JSON 파일 기준으로 덮어써져요. 영구 보존은 JSON 을 직접 고쳐야 해요.

4. spring-ai-overview.json — 8개 패널 한눈에

http://localhost:3000 접속 (초기 계정 admin / admin, 첫 로그인 시 비밀번호 변경은 Skip 가능) → 좌측 햄버거 메뉴 → DashboardsSpring AI Overview — ai-friends 클릭. 패널이 8개 떠 있어요.

위치 패널 제목 시각화 타입 핵심 메트릭
상단 좌1 Chat 호출 누적 (전체) Stat gen_ai_client_operation_seconds_count{gen_ai_operation_name="chat"}
상단 좌2 Embedding 호출 누적 Stat gen_ai_client_operation_seconds_count{gen_ai_operation_name="embedding"}
상단 우1 Total 토큰 누적 (chat) Stat gen_ai_client_token_usage_total{gen_ai_token_type="total"}
상단 우2 에러율 (5m) Stat + 임계값 rate 분자/분모로 계산
중단 좌 Chat 호출 rate (per second, by model) Time series sum by (gen_ai_response_model) (rate(...[5m]))
중단 우 Token 사용량 rate (per second, by type) Time series sum by (gen_ai_token_type) (rate(...[5m]))
하단 좌 Chat 지연 시간 (avg, p95) Time series histogram_quantile(0.95, ...)
하단 우 Advisor 활성 횟수 (by advisor name) Time series spring_ai_advisor_seconds_count

패널을 둘러보기 전에 두 가지를 짚을게요.

메트릭 네이밍이 왜 gen_ai_* 인가 — Spring AI 1.1.x 는 OpenTelemetry GenAI 시맨틱 컨벤션을 따라서 LLM 관련 메트릭을 표준 이름으로 노출해요. 한 컨벤션을 따라야 다른 도구 (Datadog · New Relic · Honeycomb) 에 그대로 연동돼요. Step 2 의 application.yml 한 줄 (management.observations.annotations.enabled=true) 만 켜면 자동으로 활성화돼요.

spring_ai_advisor_* 는 별도 네임스페이스 — Day 13~14 에서 만든 advisor 들 (Logging · Timeout · Budget · ToolCallCounter 등) 의 호출 횟수와 지연 시간은 Spring AI 가 별도 네임스페이스로 노출해요. 한 화면에서 LLM 호출과 advisor 동작을 함께 볼 수 있어요.

5. 상단 Stat 패널 4종 — 누적 카운터 + 에러율

상단 4개 카드는 한 숫자로 끝나는 빠른 요약 지표예요.

Stat 1·2 — Chat / Embedding 호출 누적

sum(gen_ai_client_operation_seconds_count{gen_ai_operation_name="chat"})

gen_ai_client_operation_seconds_count 는 모든 LLM 호출의 누적 카운터예요. gen_ai_operation_name 라벨로 chat / embedding / 기타를 구분해요. sum(...) 으로 모든 모델·인스턴스를 합쳐 하나의 숫자로 보여줘요.

Stat 3 — Total 토큰 누적

sum(gen_ai_client_token_usage_total{gen_ai_operation_name="chat",gen_ai_token_type="total"})

토큰 사용량은 prompt · completion · total 세 갈래로 노출돼요. 여기선 total 만 골라 누적해요. 지금까지 ai-friends 가 LLM 에 던진 + 받은 토큰의 총합이에요.

Stat 4 — 에러율 (5m) + 임계값 색상

(sum(rate(gen_ai_client_operation_seconds_count{error!="none"}[5m])) or vector(0))
  / clamp_min(sum(rate(gen_ai_client_operation_seconds_count[5m])), 1e-9)

분자는 에러가 난 호출의 5분 rate, 분모는 전체 호출의 5분 rate. 나누면 최근 5분 에러율이에요.

JSON 의 thresholds 가 이 패널에 색을 입혀요.

"thresholds": {
  "mode": "absolute",
  "steps": [
    { "color": "green",  "value": null },
    { "color": "orange", "value": 0.05 },
    { "color": "red",    "value": 0.1 }
  ]
}

5% 아래면 초록, 5~10% 면 주황, 10% 초과면 빨강. 운영 상황을 한눈에 잡으려고 임계값을 셋으로 끊었어요.

💡 clamp_min(..., 1e-9) 가 들어간 이유는 0 으로 나누기 방어예요. 트래픽이 한동안 없으면 분모가 0 이라 결과가 NaN 이 돼요. 1e-9 같은 아주 작은 양수로 갈아끼우면 분자가 0 일 때 결과는 0, 분자가 양수일 때만 큰 비율이 나와요.

6. 중단 Time series 패널 2종 — 모델별 / 토큰 타입별 rate

Chat 호출 rate by model

sum by (gen_ai_response_model) (
  rate(gen_ai_client_operation_seconds_count{gen_ai_operation_name="chat"}[5m])
)

sum by (...) 가 핵심이에요. 모델별로 한 선씩 그려요. ai-friends 는 gpt-5-mini · gemini-2.5-flash · qwen2.5:1.5b 같은 여러 모델을 프로파일 스위칭으로 쓰니까, 한 화면에서 지금 어떤 모델이 가장 많이 호출되고 있는지가 보여요.

JSON 의 legendFormat: "{{gen_ai_response_model}}" 는 범례에 모델 이름을 자동으로 끼워요. Custom 모드로 직접 적지 않아도 라벨이 그대로 떠요.

Token 사용량 rate by type

sum by (gen_ai_token_type) (
  rate(gen_ai_client_token_usage_total{gen_ai_operation_name="chat"}[5m])
)

이번엔 gen_ai_token_type 으로 묶어요. prompt · completion · total 세 선이 동시에 그려져요. 프롬프트가 완성보다 훨씬 큰 모델 (RAG · Few-shot 위주) 인지, 완성이 더 많은 모델 (긴 답변 생성) 인지를 패턴으로 읽을 수 있어요.

💡 rate(metric[5m]) 은 지난 5분 동안의 초당 평균 증가율을 계산해요. 카운터 메트릭 (*_total · *_count) 을 시계열로 볼 때는 거의 항상 rate() 로 감싸요. 누적값을 그대로 그리면 우상향 직선만 보이고 의미가 없어요.

7. 하단 Time series 패널 2종 — 지연 시간 + advisor 호출

Chat 지연 시간 (avg, p95)

# avg
sum(rate(gen_ai_client_operation_seconds_sum{gen_ai_operation_name="chat"}[5m]))
  / clamp_min(sum(rate(gen_ai_client_operation_seconds_count{gen_ai_operation_name="chat"}[5m])), 1e-9)

# p95
histogram_quantile(
  0.95,
  sum by (le) (rate(gen_ai_client_operation_seconds_bucket{gen_ai_operation_name="chat"}[5m]))
)

지연 시간 메트릭은 Summary 가 아니라 Histogram 으로 노출돼요. Histogram 은 _sum · _count · _bucket 세 종류를 함께 노출하고, histogram_quantile() 로 임의 분위수 (p50 · p95 · p99) 를 계산할 수 있어요.

  • avg_sum / _count 로 평균 응답 시간
  • p95 — 95% 호출이 이 시간 안에 끝났다는 의미. 평균보다 꼬리가 두꺼운 LLM 응답 분포에서 핵심 지표예요.

💡 한 LLM 호출은 평균 1~2 초지만, p95 가 8~10 초까지 뛸 수 있어요. 평균만 보면 멀쩡한데 사용자 5% 가 답답해하는 상황을 잡으려고 p95 를 함께 그려요.

Advisor 활성 횟수 by name

sum by (spring_ai_advisor_name) (rate(spring_ai_advisor_seconds_count[5m]))

Day 13 의 WorkflowLoggingAdvisor, Day 14 의 DurationTimeoutAdvisor / UsageBudgetAdvisor / ToolInvocationCounterAdvisor 가 매 호출마다 어디서 얼마나 작동하는지가 보여요. 가드 advisor 의 조용한 작동을 시각화로 끌어 올린 자리예요.

가드 advisor 가 한 번도 발화하지 않는 그래프는 둘 중 하나예요. 가드가 죽었거나, 트래픽이 임계 안에서만 돌고 있거나. 둘 다 운영 관점에서 살펴볼 가치가 있어요.

8. 시간 범위 + 자동 새로고침 + UI 편집

대시보드 JSON 상단에 이런 줄이 박혀 있어요.

"refresh": "10s",
"time": { "from": "now-30m", "to": "now" }
  • 자동 새로고침 10초 — 10초마다 모든 패널이 다시 그려져요. 강의 시연 시 curl 한 줄 친 직후 그래프에 새 점이 찍히는 모습을 즉시 봐요.
  • 시간 범위 최근 30분 — 강의 진행 시간대를 그대로 담는 결로 설정했어요.

우측 상단의 시간 셀렉터·새로고침 셀렉터로 학생이 즉석에서 바꿔도 돼요. dashboards.ymlallowUiUpdates: true 덕에 UI 에서 패널을 추가하거나 수정할 수 있어요. 다만 컨테이너 재기동 시 JSON 기준으로 덮어쓰니, 영구 보존은 JSON 을 직접 고쳐야 한다는 점만 기억하세요.

9. 라이브 시연 — curl 한 줄로 그래프 점 찍기

자동 새로고침 10초를 켠 채로 다른 터미널에서 ai-friends 의 채팅 엔드포인트를 두드려 볼게요.

curl -X POST http://localhost:8080/api/soulmate/chat \
  -H "Content-Type: application/json" \
  -d '{"conversationId":"demo-day22","message":"오늘 기분 어때?"}'

./run.sh 가 띄운 ai-friends 가 LLM 한 번 호출하고 응답을 돌려줘요. 10초 안에 대시보드의 모든 패널에 변화가 떠요.

  • 상단 Stat 1 (Chat 호출 누적) → 숫자 +1
  • 상단 Stat 3 (Total 토큰 누적) → 토큰 사용량만큼 증가
  • 중단 좌 (Chat 호출 rate by model) → 호출한 프로파일의 모델 선이 살짝 솟음
  • 중단 우 (Token rate by type) → prompt · completion 두 선이 함께 솟음
  • 하단 좌 (지연 시간 avg, p95) → 새 점이 찍힘
  • 하단 우 (Advisor by name) → WorkflowLoggingAdvisor 등이 활성화 표시

연속으로 5~10 번 두드리면 모델별 분기·토큰 타입별 분기·지연 시간 분포가 또렷이 보여요. 학생에게 "어떤 모델 프로파일을 켜놓고 어떤 질문을 던지면 어떤 패턴이 나오는지" 를 즉석에서 비교해 보세요.

10. (선택) 알림 채널 — Contact point + Alert rule

에러율 패널 (Stat 4) 이 5분 연속 10% 를 넘으면 Slack 알림을 보내고 싶다면:

  1. 좌측 메뉴 → AlertingContact points+ Add contact point
    • Integration: Slack 선택
    • Webhook URL: Slack Incoming Webhook URL 입력
    • Test 으로 검증
  2. Alert rules+ New alert rule
    • Query: 에러율 패널의 PromQL 그대로 복사
    • Condition: IS ABOVE 0.1
    • Evaluation interval: 1m / For: 5m
    • Notifications → Contact point: 방금 만든 Slack 채널 선택

5분 연속 10% 초과 → Slack 발화. 운영 페이지콜 (PagerDuty · Opsgenie) 도 같은 방식으로 연동돼요.

11. 한 줄로 정리 — 자동 프로비저닝의 결

  • 데이터 소스 등록·대시보드 만들기 = JSON 한 장에 박제 → ./run.sh 한 번에 떠요
  • 메트릭 네이밍은 OpenTelemetry GenAI 표준 → 다른 도구로 옮겨도 PromQL 그대로 통해요
  • 패널은 시각화 타입 + PromQL 한 줄 + 라벨 분기 (by ...) 만 익혀두면 충분해요
  • 운영 알림은 Alerting → Contact point + Rule → Slack 한 줄

대시보드 그림 한 장이 Day 22 에서 만든 모든 advisor 와 메트릭의 마지막 도착점이에요.

🙋 학생 질문 — "패널 PromQL 을 직접 외워야 하나요?"

아니요. 외울 필요 없어요. Grafana 쿼리 입력란에 메트릭 이름 일부만 쳐도 자동완성이 떠요.

Prometheus 가 /actuator/prometheus 에서 수집한 메트릭 이름을 Grafana 가 미리 인덱싱해두기 때문이에요.

gen_ai_ 까지만 쳐도 Spring AI 가 자동 노출한 모든 메트릭이 후보로 떠요. spring_ai_advisor_ 도 마찬가지예요.

함수도 마찬가지예요. rate( 까지 치면 인자 힌트가 떠요.

rate(gen_ai_client_operation_seconds_count[5m]) 처럼 카운터·히스토그램의 _count · _sum · _bucket 메트릭에 [5m] 같은 시간 윈도우를 넣는 패턴만 익혀두면 충분해요. 본 강의의 대시보드 JSON 안 PromQL 8 개를 한 번씩 따라 쳐 보면 자연히 손에 익어요.

🙋 학생 질문 — "Prometheus + Grafana 말고 다른 선택지는 없나요?"

있어요. Datadog, New Relic, CloudWatch 같은 상용 관측 플랫폼은 설정이 더 간편하고 알림 기능이 강력해요. Micrometer는 registry만 바꾸면 Prometheus 대신 Datadog이나 CloudWatch로 메트릭을 보낼 수 있어요. micrometer-registry-datadog 의존성으로 교체하면 코드 변경 없이 전환돼요. 오늘은 무료 + 오픈소스인 Prometheus + Grafana로 원리를 익히는 데 집중해요.


마무리

오늘 Day 22에서 한 일을 정리해 볼게요.

Step 한 줄 요약
1 LLM 앱 관측 5축 + Spring AI Observation 아키텍처 개요
2 Actuator + Micrometer Prometheus 의존성 추가 + 기본 메트릭 확인
3 UsageTrackingMeterAdvisor — AtomicLong → DistributionSummary 격상
4 AuditLoggingAdvisor — 프롬프트/응답 감사 로그 DB 저장
5 PiiDetectionUtil + PiiMaskingAdvisor — 개인정보 마스킹
6 HallucinationRateGauge + CostAlertHealthIndicator — 품질·비용 메트릭
7 docker-compose에 Prometheus + Grafana 통합
8 monitoring/ 자동 프로비저닝 + spring-ai-overview.json 8 패널 둘러보기 (gen_ai_·spring_ai_advisor_ 메트릭)

Observability 트레이드오프 5종

1. 메트릭 정밀도 vs 수집 비용

모든 호출의 프롬프트 토큰을 DistributionSummary로 기록하면 정밀하지만, 초당 수천 호출이면 메트릭 카디널리티가 폭발해요. 프로덕션에서는 샘플링(10호출 중 1건만 기록)을 고려해야 해요.

2. 감사 로그 상세도 vs 저장 비용

프롬프트/응답 전문을 저장하면 디버깅에 유리하지만, 토큰 수천 개짜리 대화가 쌓이면 DB가 빠르게 커져요. 요약만 저장하거나, 보존 기간을 정하거나, 이상 응답만 전문 저장하는 전략을 선택해야 해요.

3. PII 마스킹 정밀도 vs 오탐

정규식 기반 마스킹은 빠르지만, "031-123-4567"이 전화번호인지 주문번호인지 구분 못 해요. 정밀도를 올리려면 NER(Named Entity Recognition) 모델을 쓸 수 있지만, 추가 지연과 비용이 발생해요.

4. Health Check 임계값 설정

임계값을 너무 낮추면 알림이 너무 자주 울리고, 너무 높이면 비용 폭주를 늦게 발견해요. 초기에는 일일 평균 토큰의 2배 정도로 시작하고, 운영 데이터를 보면서 조정하는 게 일반적이에요.

5. 동기 저장 vs 비동기 저장

AuditLoggingAdvisor가 동기로 DB에 저장하면 LLM 응답 지연에 DB 쓰기 시간이 더해져요. 비동기(큐 + 배치 flush)로 전환하면 지연은 줄지만, 앱 크래시 시 로그가 유실될 수 있어요.

Day 23으로 이어지는 다리

오늘 Prometheus + Grafana 대시보드를 만들었어요. 하지만 이건 인프라 메트릭 관점이에요. LLM 앱 운영에는 한 축이 더 있어요.

다음 시간은 LLM Ops & 마무리예요. 오늘 만든 메트릭과 감사 로그를 Langfuse(오픈소스 LLM Ops 플랫폼)에 연결하면 어떤 그림이 되는지 살펴봐요. 프롬프트 버전 관리, A/B 테스트, 비용 대시보드까지 — Prometheus가 보여주지 못하는 LLM 특화 관측을 다뤄요.

그리고 Spring AI 2.0 마이그레이션 방향도 정리하고, Day 1부터 Day 23까지 쌓아온 모든 축을 한눈에 회고합니다. 마지막 시간이에요.


도전 과제
과제 1. AuditLoggingAdvisor에 검색 필터 API 추가

현재 AuditLoggingAdvisor는 감사 로그를 저장만 해요. 특정 기간·키워드로 감사 로그를 조회하는 AuditLogSearchService와 컨트롤러를 구현하세요.

요구사항:

  • AuditLogRepositoryfindByCreatedAtBetween(LocalDateTime from, LocalDateTime to) 쿼리 메서드 추가
  • AuditLogSearchService에 기간 검색 + 응답 텍스트 키워드 검색(LIKE) 기능 구현
  • GET /api/admin/audit-logs?from=...&to=...&keyword=... 엔드포인트 추가
  • 응답은 ApiResponse<List<AuditLogResponse>>로 래핑

구현 힌트:

  • Spring Data JPA의 @Query를 사용하면 키워드 검색을 추가할 수 있어요
  • 페이징은 Pageable을 파라미터로 받으면 자동 적용돼요
과제 2. PII 마스킹 패턴 확장 + 마스킹 전략 선택

현재 PiiDetectionUtil은 전화번호·이메일·주민번호 3종만 마스킹해요. 여기에 신용카드 번호IP 주소 패턴을 추가하고, 마스킹 전략을 FULL(전체 마스킹)과 PARTIAL(일부만 노출) 중 선택할 수 있게 확장하세요.

요구사항:

  • 신용카드 번호 패턴: 1234-5678-9012-3456 → FULL: ****-****-****-**** / PARTIAL: ****-****-****-3456
  • IP 주소 패턴: 192.168.1.100 → FULL: ***.***.***.*** / PARTIAL: 192.168.*.*
  • PiiDetectionUtil.mask(String input, MaskingStrategy strategy) 오버로드 메서드 추가
  • MaskingStrategy enum: FULL, PARTIAL

생각해볼 주제
1. 감사 로그의 보존 기간을 어떻게 정할 것인가?

금융권은 5년, 의료는 10년, 일반 서비스는 90일~1년 등 업종마다 규정이 달라요. 보존 기간이 길면 저장 비용과 검색 지연이 올라가고, 짧으면 사후 분석이 어려워요. 우리 ai-friends 같은 게임 서비스와 의료 AI 서비스에서 감사 로그 정책이 어떻게 달라야 할지 생각해 보세요.

2. PII 마스킹은 어느 계층에서 해야 하는가?

오늘은 Advisor(애플리케이션 계층)에서 마스킹했어요. 하지만 DB 계층(저장 시 자동 암호화), 네트워크 계층(프록시에서 필터링), 또는 LLM 호출 전(프롬프트에서 PII 제거) 에서도 할 수 있어요. 각 계층의 장단점과, "LLM이 응답에 PII를 생성하는 경우"는 어느 계층에서 잡아야 하는지 생각해 보세요.

3. 메트릭 수집이 LLM 응답 지연에 미치는 영향을 어떻게 통제할 것인가?

오늘 만든 Advisor 3종(메트릭 기록 + 감사 로그 저장 + PII 마스킹)이 모두 after() 체인에 걸려 있어요. 각각 1~2ms라도 LLM 호출마다 3~6ms가 추가돼요. 초당 100호출이면 무시할 수 없는 오버헤드예요. 동기 vs 비동기 수집, 샘플링, 배치 flush 등 오버헤드를 줄이는 전략을 생각해 보세요.

✅ 예시 답안정답 보기

과제 1. AuditLoggingAdvisor에 검색 필터 API 추가

감사 로그를 저장만 하면 절반이에요. 사고가 났을 때 "3일 전 오후 2시~4시 사이에 '개인정보'가 포함된 응답이 있었는지" 빠르게 찾을 수 있어야 감사 로그의 가치가 살아납니다. Spring Data JPA의 쿼리 메서드 + @Query 조합으로 기간 검색과 키워드 검색을 구현해요.

AuditLogRepository — 쿼리 메서드 추가

// kr.spartaclub.aifriends.harness.observability.AuditLogRepository
public interface AuditLogRepository extends JpaRepository<AuditLogEntity, Long> {

    List<AuditLogEntity> findByCreatedAtBetween(
            LocalDateTime from, LocalDateTime to);

    @Query("SELECT a FROM AuditLogEntity a " +
           "WHERE a.createdAt BETWEEN :from AND :to " +
           "AND a.responseText LIKE %:keyword%")
    Page<AuditLogEntity> searchByPeriodAndKeyword(
            @Param("from") LocalDateTime from,
            @Param("to") LocalDateTime to,
            @Param("keyword") String keyword,
            Pageable pageable);
}

AuditLogResponse — 응답 DTO

// kr.spartaclub.aifriends.harness.observability.AuditLogResponse
public record AuditLogResponse(
        Long id,
        String requestText,
        String responseText,
        String modelName,
        LocalDateTime createdAt
) {
    public static AuditLogResponse from(AuditLogEntity entity) {
        return new AuditLogResponse(
                entity.getId(),
                entity.getRequestText(),
                entity.getResponseText(),
                entity.getModelName(),
                entity.getCreatedAt()
        );
    }
}

AuditLogSearchService

// kr.spartaclub.aifriends.harness.observability.AuditLogSearchService
@Service
@RequiredArgsConstructor
public class AuditLogSearchService {

    private final AuditLogRepository repository;

    public Page<AuditLogResponse> search(LocalDateTime from,
                                         LocalDateTime to,
                                         String keyword,
                                         Pageable pageable) {
        if (keyword == null || keyword.isBlank()) {
            return repository
                    .findByCreatedAtBetween(from, to)
                    .stream()
                    .map(AuditLogResponse::from)
                    .collect(Collectors.collectingAndThen(
                            Collectors.toList(),
                            list -> new PageImpl<>(list, pageable,
                                                   list.size())));
        }
        return repository
                .searchByPeriodAndKeyword(from, to, keyword, pageable)
                .map(AuditLogResponse::from);
    }
}

AuditLogController

// kr.spartaclub.aifriends.harness.observability.AuditLogController
@RestController
@RequestMapping("/api/admin/audit-logs")
@RequiredArgsConstructor
public class AuditLogController {

    private final AuditLogSearchService searchService;

    @GetMapping
    public ResponseEntity<ApiResponse<Page<AuditLogResponse>>> search(
            @RequestParam @DateTimeFormat(iso = ISO.DATE_TIME)
                    LocalDateTime from,
            @RequestParam @DateTimeFormat(iso = ISO.DATE_TIME)
                    LocalDateTime to,
            @RequestParam(required = false) String keyword,
            Pageable pageable) {
        Page<AuditLogResponse> result =
                searchService.search(from, to, keyword, pageable);
        return ResponseEntity.ok(ApiResponse.success(result));
    }
}

호출 예시:

GET /api/admin/audit-logs?from=2026-05-26T00:00:00&to=2026-05-26T23:59:59&keyword=호감도&page=0&size=20

채점 포인트

포인트 설명 배점
Repository 쿼리 메서드 findByCreatedAtBetween + @Query LIKE 검색 구현
컨트롤러 ApiResponse 래핑 ApiResponse<Page<AuditLogResponse>> 형태
페이징 적용 Pageable 파라미터로 자동 페이징
DTO 분리 Entity를 직접 반환하지 않고 Response DTO로 변환
@DateTimeFormat 파라미터 바인딩 ISO 날짜 형식 파싱

흔한 실수

  • Entity를 직접 반환: AuditLogEntity를 컨트롤러에서 직접 반환하면 JPA 프록시 직렬화 문제와 불필요한 필드 노출이 발생해요. DTO로 변환하세요
  • LIKE 검색 시 % 누락: @Query에서 %:keyword%로 양쪽에 와일드카드를 붙여야 부분 일치 검색이 돼요
  • keyword가 null일 때 NPE: keyword 파라미터가 없을 때를 분기 처리하지 않으면 LIKE %null%로 쿼리가 나가요

실무 개선 포인트 (심화)

비동기 저장 전환 — 현재 AuditLoggingAdvisor는 동기로 DB에 저장해요. 프로덕션에서는 ApplicationEventPublisher로 이벤트를 발행하고, @TransactionalEventListener로 비동기 저장하면 LLM 응답 지연에 DB 쓰기 시간이 더해지지 않아요. 단, 앱 크래시 시 이벤트 유실 가능성이 있으므로 메시지 큐(Redis Stream, Kafka)를 중간에 두는 방법도 고려해 볼 수 있어요.


과제 2. PII 마스킹 패턴 확장 + 마스킹 전략 선택

기존 3종(전화번호·이메일·주민번호)에 신용카드 번호와 IP 주소를 추가하고, 마스킹 강도를 FULL/PARTIAL로 선택할 수 있게 확장합니다.

MaskingStrategy enum

// kr.spartaclub.aifriends.harness.observability.MaskingStrategy
public enum MaskingStrategy {
    FULL,
    PARTIAL
}

PiiDetectionUtil 확장

// kr.spartaclub.aifriends.harness.observability.PiiDetectionUtil
// (기존 코드에 추가되는 부분)

private static final Pattern CREDIT_CARD_PATTERN =
        Pattern.compile("(\\d{4})-(\\d{4})-(\\d{4})-(\\d{4})");

private static final Pattern IP_PATTERN =
        Pattern.compile(
            "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})");

public static String mask(String input, MaskingStrategy strategy) {
    if (input == null) {
        return null;
    }
    String result = input;

    // 전화번호
    result = PHONE_PATTERN.matcher(result)
            .replaceAll("$1-****-****");

    // 주민번호
    result = RESIDENT_ID_PATTERN.matcher(result)
            .replaceAll("$1-*******");

    // 이메일
    result = EMAIL_PATTERN.matcher(result)
            .replaceAll(mr -> {
                String local = mr.group(1);
                String domain = mr.group(2);
                return local.charAt(0) + "***@" + domain;
            });

    // 신용카드 번호
    if (strategy == MaskingStrategy.FULL) {
        result = CREDIT_CARD_PATTERN.matcher(result)
                .replaceAll("****-****-****-****");
    } else {
        result = CREDIT_CARD_PATTERN.matcher(result)
                .replaceAll("****-****-****-$4");
    }

    // IP 주소
    if (strategy == MaskingStrategy.FULL) {
        result = IP_PATTERN.matcher(result)
                .replaceAll("***.***.***.***");
    } else {
        result = IP_PATTERN.matcher(result)
                .replaceAll("$1.$2.*.*");
    }

    return result;
}

마스킹 결과 예시

원본 FULL PARTIAL
1234-5678-9012-3456 ****-****-****-**** ****-****-****-3456
192.168.1.100 ***.***.***.*** 192.168.*.*
010-1234-5678 010-****-**** 010-****-**** (동일)

전화번호·이메일·주민번호는 FULL/PARTIAL 구분 없이 동일하게 마스킹해요. 이 3종은 부분 노출도 위험하기 때문이에요.

채점 포인트

포인트 설명 배점
MaskingStrategy enum 정의 FULL / PARTIAL 2종
신용카드 정규식 + 마스킹 분기 FULL은 전체, PARTIAL은 마지막 4자리 노출
IP 주소 정규식 + 마스킹 분기 FULL은 전체, PARTIAL은 앞 2옥텟 노출
기존 mask(String) 호환 유지 기존 메서드는 FULL 기본값으로 위임
containsPii() 에 신규 패턴 추가 신용카드·IP도 탐지 대상에 포함

흔한 실수

  • 신용카드 정규식이 날짜를 잡음: 2026-0526-1234-5678 같은 문자열을 신용카드로 오인할 수 있어요. 실무에서는 Luhn 알고리즘으로 체크섬 검증을 추가해요
  • IP 정규식이 버전 번호를 잡음: 1.1.0.0 같은 라이브러리 버전을 IP로 오인할 수 있어요. 옥텟 범위(0~255) 검증을 추가하면 정밀도가 올라가요
  • 기존 mask(String) 메서드를 깨뜨림: 오버로드를 추가할 때 기존 1-파라미터 메서드는 FULL 기본값으로 위임해야 해요

실무 개선 포인트 (심화)

NER 기반 PII 탐지 — 정규식은 패턴이 정해진 PII(전화번호, 이메일)에 강하지만, "홍길동"이라는 이름이나 "서울시 강남구 테헤란로 123"같은 주소는 잡지 못해요. NER(Named Entity Recognition) 모델을 사용하면 정규식으로 잡을 수 없는 PII도 탐지할 수 있어요. Spring AI의 ChatClient로 "이 텍스트에서 개인정보를 찾아주세요"라는 분류 호출을 추가할 수 있지만, 호출마다 LLM 비용이 발생하므로 "비용 vs 정밀도" 트레이드오프를 따져야 해요.


생각해볼 주제 1. 감사 로그의 보존 기간을 어떻게 정할 것인가?

[문제 상황 요약]

감사 로그를 무한정 쌓으면 저장 비용이 선형으로 증가하고, 검색 성능이 떨어져요. 반대로 너무 빨리 삭제하면 사고 조사나 규정 준수 감사에서 데이터가 없어요. 보존 기간은 "규정 요구 + 비즈니스 가치 + 비용"의 교차점에서 결정해야 해요.

[튜터의 가이드 및 해설]

보존 기간 결정에는 세 가지 축이 있어요.

1축: 법적 규정

업종마다 보존 의무 기간이 달라요.

업종 규정 보존 기간
금융 전자금융거래법 5년
의료 의료법 10년
개인정보 일반 개인정보보호법 수집 목적 달성 시 즉시 파기
EU GDPR 필요한 기간만 (최소화 원칙)

ai-friends 같은 게임 서비스라면 법적 의무 보존 기간은 짧지만, 의료 AI라면 10년을 유지해야 할 수도 있어요.

2축: 비즈니스 가치

감사 로그에서 "어떤 프롬프트가 환각을 유발했는지" 분석하면 프롬프트 품질을 개선할 수 있어요. 이 분석 가치는 보통 3~6개월이면 소진돼요. 6개월 전 로그로 프롬프트를 개선하는 경우는 드물죠.

3축: 비용

토큰 수천 개짜리 대화가 하루에 만 건 쌓이면, 월 수 GB 수준이에요. MySQL에서는 감당할 수 있지만, Elasticsearch는 인덱싱 비용이 추가돼요. 비용 임계점에서 "전문 저장 → 요약만 저장" 전환을 고려해야 해요.

현업에서 자주 쓰는 전략:

  • 핫-콜드 분리 — 최근 30일은 MySQL에 전문 보관(빠른 검색), 31~180일은 S3/GCS에 압축 아카이브, 180일 이후 삭제
  • 이상 응답만 전문 보관 — 정상 응답은 메타데이터(토큰 수, 모델명, 타임스탬프)만, 환각이나 PII 탐지된 응답만 전문 보관
  • TTL 기반 자동 삭제 — 스케줄러(@Scheduled)로 매일 새벽 DELETE FROM audit_log WHERE created_at < :cutoff 실행

🎯 면접관을 홀리는 핵심 멘트

"감사 로그 보존 기간은 '법적 의무 × 비즈니스 분석 가치 × 저장 비용'의 교차점에서 결정합니다. 의료 AI라면 10년 법적 의무가 있고, 게임 서비스라면 90일이면 충분하지만, 프롬프트 품질 분석 가치를 고려하면 6개월이 실용적인 선이에요. 핫-콜드 분리로 최근 데이터는 빠르게, 오래된 데이터는 저렴하게 보관하는 게 현업의 일반적인 패턴입니다."


생각해볼 주제 2. PII 마스킹은 어느 계층에서 해야 하는가?

[문제 상황 요약]

오늘은 Advisor 계층에서 PII를 마스킹했어요. 하지만 마스킹을 적용할 수 있는 계층은 여러 곳이에요. 각 계층마다 잡을 수 있는 PII의 종류와 성능 특성이 달라요. 특히 LLM 앱은 "LLM이 응답에 PII를 생성하는 경우"라는 전통 웹 앱에 없는 문제가 있어요.

[튜터의 가이드 및 해설]

PII 마스킹을 적용할 수 있는 4가지 계층을 비교해 볼게요.

Option A: 프롬프트 입력 단계 (LLM 호출 전)

사용자 입력에서 PII를 제거한 뒤 LLM에 보내는 방식이에요. LLM이 PII를 아예 보지 못하므로 가장 안전하지만, "내 전화번호 010-1234-5678을 캐릭터에게 알려줘" 같은 요청이 의도대로 동작하지 않아요. 사용자 의도를 깨뜨릴 수 있다는 단점이 있어요.

Option B: 애플리케이션 계층 (Advisor, 오늘 구현한 방식)

LLM 응답을 받은 뒤, 감사 로그에 저장하기 전에 마스킹해요. 장점은 비즈니스 로직과 분리되고, Advisor chain의 order로 실행 순서를 제어할 수 있다는 것. 단점은 LLM이 응답에 PII를 포함한 상태로 네트워크를 타고 온다는 것(메모리에는 원본이 존재)이에요.

Option C: DB 계층 (저장 시 암호화)

JPA @ColumnTransformer나 DB 내장 암호화(MySQL AES_ENCRYPT)로 저장 시점에 자동 암호화해요. DB 유출 시 안전하지만, 마스킹이 아니라 암호화라서 복호화 키가 유출되면 원본이 드러나요. 검색 성능도 떨어져요(암호화된 칼럼은 LIKE 검색 불가).

Option D: 네트워크 계층 (프록시 필터링)

API Gateway나 WAF에서 응답 본문의 PII를 필터링해요. 애플리케이션 코드 수정 없이 적용할 수 있지만, LLM 응답 본문은 구조가 다양해서(JSON 안의 문자열 필드) 범용 프록시로 정밀 마스킹이 어려워요.

현업에서는 보통 "다층 방어":

계층 적용 목적
입력 (Option A) 선택적 민감 시스템(의료·금융)에서 LLM 노출 자체를 차단
애플리케이션 (Option B) 필수 감사 로그 저장 직전 마스킹 — 오늘 구현한 방식
DB (Option C) 권장 저장 데이터 유출 방어 (암호화는 마스킹과 별개)
네트워크 (Option D) 선택적 외부 API 응답의 1차 필터

"LLM이 응답에 PII를 생성하는 경우"는 Option B(애플리케이션 계층)에서만 잡을 수 있어요. 입력에 PII가 없어도 LLM이 학습 데이터에서 개인정보를 꺼내올 수 있기 때문이에요. 그래서 애플리케이션 계층 마스킹은 LLM 앱에서 필수예요.

🎯 면접관을 홀리는 핵심 멘트

"전통 웹 앱은 '사용자가 입력한 PII'만 관리하면 되지만, LLM 앱은 '모델이 생성한 PII'까지 잡아야 합니다. 입력 필터링만으로는 부족하고, LLM 응답 후 애플리케이션 계층에서 마스킹하는 레이어가 필수예요. 현업에서는 입력 필터 + 응답 마스킹 + DB 암호화를 겹쳐서 다층 방어를 구축합니다."


생각해볼 주제 3. 메트릭 수집이 LLM 응답 지연에 미치는 영향을 어떻게 통제할 것인가?

[문제 상황 요약]

오늘 만든 Advisor 3종(UsageTrackingMeterAdvisor + AuditLoggingAdvisor + PiiMaskingAdvisor)이 모두 after() 체인에 걸려 있어요. LLM 응답이 올 때마다 3개의 후처리가 순차 실행되는 거죠. 각각 1~2ms라도 초당 100호출이면 초당 300~600ms의 추가 지연이 누적돼요. 관측을 위해 사용자 경험을 희생하는 건 본말전도예요.

[튜터의 가이드 및 해설]

오버헤드를 줄이는 세 가지 전략이 있어요.

전략 1: 비동기 수집

after() 안에서 직접 DB에 쓰거나 메트릭을 기록하지 않고, 이벤트를 발행하고 즉시 반환해요.

  • 경량 비동기: CompletableFuture.runAsync(() -> repository.save(entity)) — 별도 스레드에서 저장. 간단하지만 앱 크래시 시 유실
  • 이벤트 기반: ApplicationEventPublisher.publishEvent(auditEvent)@Async @EventListener 로 처리. Spring 이벤트 루프 활용
  • 메시지 큐: Redis Stream이나 Kafka에 이벤트를 넣고, 별도 컨슈머가 배치로 flush. 유실 위험 최소, 인프라 복잡도 증가

각 방식의 트레이드오프:

방식 지연 감소 유실 위험 인프라 복잡도
동기 (현재) 없음 없음 낮음
CompletableFuture 1~2ms 앱 크래시 시 낮음
이벤트 기반 1~2ms 앱 크래시 시 중간
메시지 큐 1~2ms 거의 없음 높음

전략 2: 샘플링

모든 호출을 기록하지 않고 N호출 중 1건만 기록해요. Micrometer의 DistributionStatisticConfig로 샘플링 비율을 설정할 수 있어요.

DistributionSummary.builder("ai.token.prompt")
        .publishPercentiles(0.5, 0.95, 0.99)
        .minimumExpectedValue(1.0)
        .maximumExpectedValue(10000.0)
        .register(registry);

메트릭 정밀도는 떨어지지만, 통계적으로 유의미한 분포는 충분히 얻을 수 있어요. 감사 로그는 샘플링하면 안 되고(규정 준수), 메트릭만 샘플링해요.

전략 3: 배치 flush

감사 로그를 건건이 INSERT하지 않고, 인메모리 버퍼에 모아뒀다가 100건 또는 5초마다 한 번에 saveAll() 해요.

// 개념 코드 — 배치 flush 패턴
private final List<AuditLogEntity> buffer =
        Collections.synchronizedList(new ArrayList<>());

public void addToBuffer(AuditLogEntity entity) {
    buffer.add(entity);
    if (buffer.size() >= 100) {
        flush();
    }
}

@Scheduled(fixedRate = 5000)
public void flush() {
    List<AuditLogEntity> batch;
    synchronized (buffer) {
        batch = new ArrayList<>(buffer);
        buffer.clear();
    }
    if (!batch.isEmpty()) {
        repository.saveAll(batch);
    }
}

DB 호출 횟수를 1/100로 줄일 수 있어요. 단, 버퍼에 쌓인 상태에서 앱이 종료되면 유실되므로 @PreDestroy에서 마지막 flush를 해야 해요.

현업에서의 일반적인 조합:

  • 메트릭: 동기 수집 유지 (Micrometer 자체가 충분히 가벼움, 건당 마이크로초 단위)
  • 감사 로그: 비동기 이벤트 기반 + 배치 flush
  • PII 마스킹: 동기 유지 (마스킹이 끝나야 응답을 반환할 수 있음)

🎯 면접관을 홀리는 핵심 멘트

"Observability 오버헤드 통제는 '무엇을 동기로 두고 무엇을 비동기로 빼는가'의 판단이에요. PII 마스킹은 응답 전에 끝나야 하므로 동기 필수, Micrometer 기록은 이미 마이크로초 단위라 동기로 충분, 감사 로그 DB 쓰기만 비동기 이벤트 + 배치 flush로 빼면 사용자 체감 지연 없이 규정 준수를 지킬 수 있습니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.