[졸프] Spring Boot에서 ML 질문 생성 API 연동 리팩토링: String 응답 → DTO 기반 blocked 처리

ssun·2025년 8월 18일

졸업프로젝트

목록 보기
17/21

이번 졸업 프로젝트에서는 도서 기반 질문 생성 서비스를 ML 서버와 연동해서 구현하고 있다.기존 질문 생성 API 명세서는 프엔과 맞춰본 것이기 때문에, ML과도 request,response 값을 같이 맞추어야했기 때문에 해당 명세서를 작성했다.

ML 질문 생성 서비스 API 명세

초기 버전에서는 ML 서버 응답을 단순히 String(질문 텍스트)로만 받아 저장했는데,
실제 명세에서는blocked:true/false, reason, latencyMs 등이 포함되어 있었다.

즉, 정책 차단(금칙어/무의미 질문 등) 케이스를 반영하지 못하고 있었던 것이다.
이번 글에서는 그 부분을 어떻게 리팩토링했는지 과정을 정리한다.

1. 문제 상황

기존 구현에서는:

private String trySyncGenerate(Book book) {
    return mlWebClient.post()
            .uri("/generate-question")
            .bodyValue(req)
            .retrieve()
            .bodyToMono(String.class)   // 질문을 String으로만 가정
            .block();
}

→ 이렇게 처리하다 보니, ML 응답의 blocked/reason 필드를 전혀 반영하지 못함.
결과적으로 정책 차단된 질문도 그대로 DB에 저장하는 문제가 있었다.

2. 리팩토링 과정

(1) DTO 정의

명세에 맞게 요청/응답 DTO를 추가했다.

// 요청 DTO
public record MlGenerateReq(Long bookId, String title) {}

// 응답 DTO
public record MlGenerateResp(
    Long bookId,
    boolean blocked,
    String question,   // blocked=false일 때만 유효
    Integer latencyMs, // optional
    String reason      // blocked=true일 때 optional
) {}

(2) 서비스 코드 수정

이제 ML 응답을 MlGenerateResp로 파싱하고, blocked 분기에 따라 상태 전이를 처리한다.

MlGenerateResp resp = trySyncGenerate(book);

if (resp.blocked()) {
    // 정책 차단 → FAILED
    draft.markFailed("blocked:" + resp.reason());
    return Result.created201(qid, FAILED, null);
} else {
    // 정상 생성 → READY
    draft.setQuestionContent(resp.question());
    draft.setStatus(READY);
    return Result.created201(qid, READY, null);
}
  • blocked=false → 정상 질문 생성 → READY 상태
  • blocked=true → 정책 차단 → FAILED 상태 + reason 저장

(3) 보조 유틸

에러 메시지/질문을 DB에 안전하게 저장하기 위해 유틸 메서드를 추가했다.

private String safe(String s) { 
    return s == null ? "" : s.replace("\"","'"); 
}

private String truncate(String s, int max) { 
    return s == null ? null : (s.length() <= max ? s : s.substring(0, max)); 
}
  • safe : null 처리 + 문자열 안의 " → ' 변환
  • truncate : 너무 긴 메시지를 DB 칼럼 길이에 맞게 잘라냄

3. 상태 전이 로직

최종적으로 서비스는 다음과 같이 동작한다.

상황ML 응답최종 상태DB 기록
정상 생성200 + blocked=falseREADYquestionContent 저장
정책 차단200 + blocked=trueFAILEDlastError = reason
ML 오류4xx/5xxFAILEDlastError = 예외 메시지
타임아웃응답 지연 (3s 초과)FAILED*lastError = timeout

* 타임아웃의 경우: 우선 GENERATING 상태로 두고 백그라운드 재시도 후 READY/FAILED로 최종 전이 가능

4. 결과

리팩토링 이후에는:

  • 명세 준수 → blocked:true/false 반영 가능
  • 운영 편의성 → reason이 그대로 DB에 저장되어 분석/통계에 활용 가능
  • 안정성 → 긴 메시지 자르기(truncate), 특수문자 처리(safe)로 DB 에러 방지
  • 확장성 → reason을 ENUM으로 정규화하더라도 현재 구조 유지 가능

5. 정리

이번 리팩토링의 핵심은 “ML 응답을 단순 String → DTO 기반으로 바꾸고, blocked 분기를 반영”이었다.

덕분에 정책 차단 케이스까지 서비스에 녹아들었고,
사용자 경험(재시도 버튼 노출)과 운영 편의성(사유 로그 관리) 모두 개선할 수 있었다.

추후에 데이터가 쌓이게 되면 이 reason을 ENUM으로 정규화해서 대시보드/통계 지표까지 확장하는 걸 다뤄보고 싶다.

profile
안녕하세요! 백다현입니다

0개의 댓글