문제 상황
Edukit 서비스는 교사들을 위해 AI를 활용하여 학생부 작성 업무를 돕는 서비스다. 하지만 이 서비스를 만드는 구성원은 현직 교사가 아닌 개발자 3명이었기 때문에, AI가 생성하는 응답의 품질을 명확하게 판단하기 어려웠다. 이를 보완하기 위해 설문조사를 통해 만족도를 수집했지만, 운영 서버에 새 기능을 배포할 때마다 질문을 초기화해야 하는 번거로움이 있었고, 사용자 수에 비해 응답률도 높지 않아 한계가 있었다. 이러한 상황이 반복되면서 서비스 개선 과정에서도 응답 품질에 대한 확신을 점점 잃게 되었다.
그러던 중 소마 전담 멘토링을 통해 커스텀 메트릭이라는 개념을 알게 되었고, 이를 활용하면 실제 사용 패턴을 기반으로 AI 응답 품질을 정량적으로 확인할 수 있지 않을까? 라는 생각을 하게 되었다. 본 글에서는 이러한 기능을 추가한 과정을 다뤄보고자 한다.
커스텀 메트릭 적용

우리 서비스는 위와 같이 각 생기부 작성 항목 별로 학생의 특성을 입력하고 생성 버튼을 누르면 3가지 버전의 AI 생성 응답 값을 제공하고 있다.

그리고 이렇게 생성된 3가지 버전 중 마음에 드는 문장이나 버전을 선택해서 사용자가 해당 학생에 대한 최종본을 저장할 수 있는 플로우이다.
첫번째 가설
<가설>
AI 응답 품질이 좋을수록 API 실제 호출 대비 실제 응답 완성률이 높을 것이다.
<측정 방법>
완성률 = (최종 저장 완료 수) / (AI 생성 요청 수) × 100%
AI를 이용하여 생성 요청을 보낸 호출 횟수 대비 실제 최종본을 저장하는 API 호출 응답 횟수가 적다면, AI 응답 품질이 좋지 않다는 가설을 세웠고 따라서 AI 생성 API 응답 호출과 생기부 최종 완료 저장본 개수를 커스텀 메트릭으로 추가하여 완성율을 계산한 뒤, 그라파나 대시보드에 시각화하였다.
구현
커스텀 메트릭은 비즈니스 로직과는 분리해서 구현하는 것이 유지보수 성과 코드 가독성 면에서 좋을 것이라 판단하였고 따라서 AOP를 활용하여 비즈니스 로직과 부가 로직이 명확히 구분되도록 분리하였다.
@Component
@RequiredArgsConstructor
public class StudentRecordMetricsAspect {
private final MeterRegistry meterRegistry;
private static final String COMPLETION_METRIC = "student_record_completion_total";
private static final String AI_GENERATION_REQUEST_METRIC = "student_record_ai_generation_requests_total";
public void recordCompletion(final StudentRecordType type, final String description) {
if (isCompleted(type, description)) {
meterRegistry.counter(COMPLETION_METRIC,
"type", type.name(), "action", "completion")
.increment();
}
}
public void recordAIGenerationRequest(final StudentRecordType type) {
meterRegistry.counter(AI_GENERATION_REQUEST_METRIC,
"type", type.name(), "action", "ai_generation")
.increment();
}
private boolean isCompleted(final StudentRecordType type, final String description) {
if (description == null || description.trim().isEmpty()) {
return false;
}
int minBytes = (type == StudentRecordType.SUBJECT) ? 1000 : 750;
return description.getBytes(StandardCharsets.UTF_8).length >= minBytes;
}
}
단순히 사용자의 저장 시도만 카운트하는 방식으로는 실제로 의미 있는 지표를 얻기 어렵다고 판단하였다. 사용자가 AI가 생성한 결과를 단순히 확인만 하고 저장하지 않을 수도 있고, 의미 없는 내용을 입력한 뒤 저장하는 경우도 존재하기 때문이다.
이를 보완하기 위해 updateStudentRecord()와 같은 저장 메서드에 커스텀 메트릭 수집 로직을 적용하였다. 다만 저장된 최종본의 바이트 수가 기준 바이트 수의 절반을 넘지 못한다면, 유효하지 않은 저장으로 판단하여 지표에서 제외하였다. 구체적으로 일반 항목은 기준 바이트 수를 1500바이트로 설정하고, 이의 절반인 750바이트를 최소 유효 기준으로 삼았다. 반면 SUBJECT(세부능력특기사항) 항목의 경우 기본 바이트 수 제한이 2000바이트이므로, 기준을 1000바이트로 설정하였다.
최종적으로 완성률 계산식은 다음과 같이 정의된다.
(student_record_completion_total / student_record_ai_generation_requests_total) * 100
여기서 student_record_ai_generation_requests_total은 AI가 생성 요청을 받은 횟수이고, student_record_completion_total은 실제로 데이터베이스에 최종본을 저장한 횟수다. 예를 들어 AI 생성 요청이 총 100번 있었고, 그 중 70번은 사용자가 유요한 저장을 시도했으면 응답 완성률은 70%인 것이다.
결과

두번째 가설
<가설>
AI 품질이 좋을수록 재생성 요청 비율이 낮을 것이다.
<측정 방법>
- 동일 recordId에 대한 연속 AI 생성 요청 횟수
- 재생성률 = (재생성 요청 수) / (첫 생성 요청 수)
사용자는 자신이 작성하고자 하는 페이지에 들어가 AI 요청을 한다. 이때, 해당 요청이 같은 recordId로 요청된다면, 이는 재요청을 보냈음을 의미한다. 여기서 만약 같은 생기부 항목에 대해 재요청 횟수가 많다면 이는 곧 AI 응답 품질이 마음에 들지 않았기 떄문이라는 가설을 세웠다.
구현
public void recordFirstGeneration(final StudentRecordType type) {
meterRegistry.counter(AI_FIRST_GENERATION_METRIC,
"type", type.name(), "action", "first_generation")
.increment();
}
public void recordRegeneration(final StudentRecordType type) {
meterRegistry.counter(AI_REGENERATION_METRIC,
"type", type.name(), "action", "regeneration")
.increment();
}
@Around("@annotation(com.edukit.common.annotation.AIGenerationMetrics)")
public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
if (args.length >= 3) {
long recordId = (Long) args[1];
StudentRecordType recordType = (StudentRecordType) args[2];
try {
boolean isFirstGeneration = generationTrackingService.isFirstGeneration(recordId);
// 전체 AI 생성 요청 카운트
metricsService.recordAIGenerationRequest(recordType);
// 첫 생성 vs 재생성 구분 메트릭
if (isFirstGeneration) {
metricsService.recordFirstGeneration(recordType);
log.debug("First generation request for recordId: {}", recordId);
} else {
metricsService.recordRegeneration(recordType);
log.debug("Regeneration request for recordId: {}", recordId);
}
} catch (Exception e) {
log.warn("Error collecting AI generation metrics for recordId: {}", recordId, e);
}
}
return joinPoint.proceed();
}
트라블 슈팅
재요청 비율을 집계하기 위해서는 같은 recordId에 대해 생성 요청이 들어왔을 때, 그 요청이 첫번째 요청인지 재요청인지를 판별해야하는 것이 필수적이었다.
private final ConcurrentHashMap<Long, GenerationInfo> generationCounts = new ConcurrentHashMap<>();
public boolean isFirstGeneration(long recordId) {
GenerationInfo info = generationCounts.compute(recordId, (key, existing) -> {
if (existing == null) {
return new GenerationInfo(1, LocalDateTime.now());
} else {
existing.incrementCount();
return existing;
}
});
boolean isFirst = info.getCount() == 1;
log.debug("RecordId: {}, Generation count: {}, Is first: {}", recordId, info.getCount(), isFirst);
return isFirst;
}
처음에는 요청으로 들어온 recordId를 키로 하여 ConcurrentHashMap에 저장하고, 해당 키가 이미 존재하면 재요청으로, 존재하지 않으면 최초 요청으로 판별하는 방식으로 로직을 구현했다. 그러나 배포 후 확인해 보니 재요청 비율이 정상적으로 집계되지 않는 문제가 발생했다. 원인은 서비스가 API 서버 2대에서 동작하는 분산 환경이었기 때문이다. 같은 recordId에 대한 두 번째 요청이라 하더라도, 첫 번째 요청과 다른 서버로 전달된다면 해당 서버의 메모리에 있는 ConcurrentHashMap에는 해당 키가 존재하지 않는다. 그 결과 서버는 이를 다시 최초 요청으로 잘못 판별하게 되어, 집계가 올바르게 이루어지지 않았다. 이를 위해 생각했던 방안은 아래와 같았다.
1. DB에 테이블을 따로 만들어 횟수를 추적하자.
가장 단순한 해결책은 DB에 횟수 추적용 테이블을 두고, 요청이 들어올 때마다 해당 레코드에 X-Lock을 걸어 카운트를 집계하는 방식이었다. 하지만 우리 서비스는 특정 기간에 AI 생기부 생성 요청이 집중되는 특성이 있어, 요청마다 DB를 직접 갱신하고 조회하는 방식은 큰 부하를 유발할 수 있다고 판단했다. 또한, 카운트를 추적하기 위해 커넥션 풀과 Lock을 점유하면 다른 I/O 작업까지 지연될 수 있어, 결국 전체 API 응답 속도가 저하될 우려가 있어 이 방안은 기각했다.
2. Redis 활용 - 분산락 기반 카운팅
단일 인스턴스 메모리 기반 카운팅은 분산 환경에서 정확한 집계를 보장하지 못하므로, 중앙집중형 카운터를 사용해 모든 인스턴스가 동일한 카운터를 공유하도록 했다. 가장 단순한 방법은 Redis의 원자적 `INCR` 연산으로 카운트를 증가시키는 것이다. 이때, 사용이 끝난 키를 자동으로 삭제하여 메모리를 관리하기 위해 EXPIRE를 설정해 두었다. 인터뷰와 설문조사를 통해 선생님들이 하나의 생기부 항목을 작성하는데 평균 1~3일 정도가 걸린다는 점을 반영하여 TTL은 3일로 설정하였으며 해당 기간이 지나면 집계가 자동 초기화될 수 있도록 구현하였다.
newCount = INCR(key)
if newCount == 1:
EXPIRE(key, ttl)
return newCount == 1
하지만, 이렇게 PR을 올라고 보니 CodeRabbit이 아래와 같은 리뷰를 남겨주었다.

위의 리뷰를 간단히 요약하면, INCR와 EXPIRE를 한 번의 원자적 실행으로 묶어야 안전하다는 것이다. 지금의 로직과 같이, 두 연산을 따로 실행하면 어떤 문제가 생길 수 있을까?
예를 들어, 클라이언트가 `INCR` 요청을 보내고 정상 응답을 받았다. 하지만 직후 네트워크가 끊겨 `EXPIRE` 명령이 Redis에 도달하지 못한다면 어떻게 될까? 이 경우 해당 키에는 TTL이 설정되지 않아 영구 키로 남게 되어 메모리 공간을 영구히 차지하고, 집계가 계속 누적되어 버리는 문제가 발생하게 된다. 비슷하게,`INCR`는 성공했지만 프로세스가 재시작되어 `EXPIRE` 호출이 수행되지 못하는 경우에도 같은 문제가 발생한다.
이 문제를 해결하기 위해 `INCR`과 `EXPIRE`를 Redis 서버 내에서 단일 원자적 명령으로 실행하도록 Lua 스크립트를 적용했다.
private static final String LUA_SCRIPT =
"local ttl = tonumber(ARGV[1]) or 0 " +
"local c = redis.call('INCR', KEYS[1]) " +
"if c == 1 and ttl > 0 then " +
" redis.call('EXPIRE', KEYS[1], ttl) " +
"end " +
"return c";
private static final DefaultRedisScript<Long> INCR_EXPIRE_SCRIPT;
static {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(LUA_SCRIPT);
script.setResultType(Long.class);
INCR_EXPIRE_SCRIPT = script;
}
private final StringRedisTemplate redisTemplate;
public boolean isFirstGeneration(long recordId) {
String key = getCountKey(recordId);
Long newCount = redisTemplate.execute(
INCR_EXPIRE_SCRIPT,
Collections.singletonList(key),
String.valueOf(ttlSeconds)
);
if (newCount == null) {
log.warn("Redis script returned null for key: {}", key);
return false;
}
boolean isFirst = newCount == 1L;
if (isFirst) {
log.debug("RecordId: {}, Generation count set to 1 (first)", recordId);
} else {
log.debug("RecordId: {}, Generation count: {} (regeneration)", recordId, newCount);
}
return isFirst;
}
Lua 스크립트는 서버에서 하나의 트랜잭션처럼 실행되므로, “스크립트 전송 → 서버에서 `INCR + EXPIRE` 실행 → 응답 수신” 전체 과정이 원자적으로 보장된다. 이 방식으로 `EXPIRE` 누락 문제를 방지할 수 있게 되었으며 분산 환경에서도 정확한 카운팅이 가능하도록 구현하였다.
결과

아래와 같이 각 생활기록부 항목 별로 재요청 비율을 시각화하였다. 이를 통해 재요청 비율이 상대적으로 높은 세부능력특기사항에 대한 AI 응답 품질이 떨어짐을 파악할 수 있고 이를 기반으로 AI 프롬프팅을 고도화하거나 개선점을 고민하는 등 서비스를 발전시킬 수 있는 정량적인 지표로 활용할 수 있을 것이라 판단된다.
'Spring' 카테고리의 다른 글
[EduKit] 사용자 경험을 개선해보자 (feat. Polling, SSE) (0) | 2025.08.14 |
---|---|
AWS Lambda를 활용한 일괄 인증 자동화 시스템 구축기 (0) | 2025.08.06 |
MDC를 활용한 로깅 개선기 (0) | 2025.08.03 |
효율적인 로그 관리를 위한 Spring Boot + Logback 설정 (1) | 2025.07.05 |
[EduKit] 멀티모듈 도입기 (2) | 2025.06.26 |