최근 프로젝트에서 헬스장 리뷰 시스템에 AI 기반 모더레이션을 적용했지만 생각치도 못한 문제와 마주했다 .
헬스장 리뷰 시스템에 OpenAI API를 활용한 악성 콘텐츠 필터링을 구현했지만, 실제 운영 환경에서 예상치 못한 문제점들이 드러났다:
특히 심각했던 것은 리뷰 길이가 300자를 넘을 경우 모더레이션 처리 시간이 4초 이상 소요되어, 사용자들이 리뷰 제출 후 페이지를 이탈하는 비율이 35%까지 증가한 점이었다.
이 문제를 해결하기 위해 전체 아키텍처를 비동기 처리 방식으로 재설계했다:
배달 주문(비동기 작업 시작): 봉천동 마라돼지는 두잇으로 신전떡볶이를 주문한다.
주문 후 다른 일 하기(스레드 블로킹 없음): 음식이 도착할 때까지 개발 작업을 계속한다.
떡볶이 조리 중(API 처리 중): OpenAI가 리뷰를 분석 중이다.
초인종(콜백 알림): API 처리가 완료되면 subscribe() 내 람다가 실행된다.
음식을 접시에 담기(결과 처리): 모더레이션 결과를 받아 적절한 액션을 취한다.
구현한 리뷰 모더레이션 시스템의 전체 흐름은 다음과 같다:
사용자가 헬스장이나 트레이너에 대한 리뷰를 작성한다
->
리뷰가 데이터베이스에 저장된다
->
비동기적으로 OpenAI API를 통해 리뷰 내용을 검수한다
->
검수 결과에 따라 리뷰의 표시 여부를 결정하고 DB에 표시 여부를 업데이트 한 뒤에 사용자에게 리뷰 결과 알림을 보낸다(FCM)
이 과정에서 핵심은 사용자가 리뷰를 제출한 후 OpenAI API 호출 및 처리가 완료될 때까지 기다릴 필요 없이 즉시 다른 작업을 할 수 있도록 비동기 방식으로 구현한 점이다.
구현 코드 중 핵심 부분은 다음과 같다:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/moderation")
@Tag(name = "Review Content Moderation API")
public class ModerationController {
private final ModerationService moderationService;
@PostMapping
public Mono<Map<String, Object>> checkReview(@RequestBody List<ReviewResponseDTO> reviewResponseDTO) {
List<String> reviewTexts = reviewResponseDTO.stream()
.map(ReviewResponseDTO::getContent)
.toList();
return moderationService.moderateReview(reviewTexts);
}
}
이 컨트롤러는 리뷰 텍스트 목록을 받아 모더레이션 서비스로 전달한다.
반환 타입이 Mono<Map<String, Object>>인 것에 주목하자.
이는 결과가 즉시 반환되지 않고, 비동기적으로 결과가 준비되면 그때 반환된다는 것을 의미한다.
@Service
public class ModerationService {
private final WebClient webClient;
@Value("${OPENAI_API_KEY}")
private String apiKey;
public ModerationService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("https://api.openai.com/v1/chat/completions").build();
}
public Mono<Map<String, Object>> moderateReview(List<String> reviewTexts) {
return webClient.post()
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.bodyValue(Map.of(
"model", "gpt-3.5-turbo",
"messages", List.of(
Map.of("role", "system", "content", "..."),
Map.of("role", "user", "content", String.join("\n---\n", reviewTexts))
),
"response_format", Map.of("type", "json_object")
))
.retrieve()
.bodyToMono(Map.class)
.map(response -> {
// OpenAI API 응답 처리 로직
// ...
return Map.of("results", List.of(result));
});
}
}
여기서 WebClient를 사용해 OpenAI API를 호출하고,
그 결과를 Mono로 래핑하여 반환한다.
중요한 점은 이 메서드 호출 시점에 실제 HTTP 요청이 즉시 발생하지 않는다는 것이다.
실제 요청은 반환된 Mono에 대해 subscribe() 메서드가 호출될 때 실행된다.
@Transactional
public void checkReviewModeration(Review review) {
String reviewText = review.getContent();
List<String> reviewTexts = List.of(reviewText);
log.info("리뷰 ID: {} 모더레이션 요청 시작", review.getId());
moderationService.moderateReview(reviewTexts)
.doOnSubscribe(s -> log.info("리뷰 ID: {} 모더레이션 API 호출 구독 시작", review.getId()))
.doOnNext(result -> log.info("리뷰 ID: {} 모더레이션 결과 수신 완료", review.getId()))
.subscribe(
result -> {
try {
log.info("리뷰 ID: {} 모더레이션 결과 처리 시작", review.getId());
handleModerationResult(result, review);
} catch (Exception e) {
log.error("리뷰 ID: {} 모더레이션 결과 처리 중 예외 발생: {}", review.getId(), e.getMessage(), e);
}
},
error -> {
log.error("리뷰 ID: {} 모더레이션 API 호출 실패: {}", review.getId(), error.getMessage());
notificationService.sendNotificationToMember(
review.getMember(),
"리뷰 검토 중 오류가 발생했습니다",
"시스템 오류가 발생했습니다. 나중에 다시 시도해주세요.",
PROCESSING_ERROR
);
error.printStackTrace();
},
() -> log.info("리뷰 ID: {} 모더레이션 프로세스 완료", review.getId())
);
}
리뷰 생성 로직에서 checkReviewModeration 메서드를 호출하여 모더레이션 프로세스를 시작한다. 여기서 핵심은 subscribe() 메서드를 호출함으로써 실제 비동기 처리가 시작된다는 점이다. 이때 현재 트랜잭션과는 별개로 동작하게 된다.
@Transactional
public void handleModerationResult(Map<String, Object> result, Review review) {
log.info("리뷰 ID: {} 모더레이션 결과 처리 함수 시작", review.getId());
try {
// 결과 데이터 검증
if (result == null) {
log.error("리뷰 ID: {} 모더레이션 결과가 null입니다", review.getId());
return;
}
// ChatGPT API 응답에서 결과 추출
if (!result.containsKey("results")) {
log.error("리뷰 ID: {} 모더레이션 결과에 'results' 키가 없습니다: {}", review.getId(), result);
return;
}
List<Map<String, Object>> results = (List<Map<String, Object>>) result.get("results");
if (results == null || results.isEmpty()) {
log.error("리뷰 ID: {} 모더레이션 결과 리스트가 비어있습니다", review.getId());
return;
}
// ChatGPT API 응답으로 변환된 결과 가져오기
Map<String, Object> gptResult = results.get(0);
if (!gptResult.containsKey("flagged")) {
log.error("리뷰 ID: {} 모더레이션 결과에 'flagged' 키가 없습니다: {}", review.getId(), gptResult);
return;
}
boolean flagged = (boolean) gptResult.get("flagged");
log.info("리뷰 ID: {} 모더레이션 결과 - flagged: {}", review.getId(), flagged);
if (flagged) {
try {
// 어떤 카테고리가 위반되었는지 확인
if (!gptResult.containsKey("categories")) {
log.warn("리뷰 ID: {} 위반으로 표시되었으나 categories 정보가 없습니다", review.getId());
}
Map<String, Boolean> categories = (Map<String, Boolean>) gptResult.getOrDefault("categories", Map.of());
// 위반 카테고리들을 로그로 기록
List<String> violatedCategories = categories.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.toList();
String reason = (String) gptResult.getOrDefault("reason", "알 수 없는 이유");
log.info("리뷰 ID: {}가 다음 카테고리에서 위반 감지됨: {}, 이유: {}",
review.getId(), violatedCategories, reason);
// 리뷰 비활성화
review.changeActive(false);
log.info("리뷰 ID: {} 비활성화 처리 완료", review.getId());
reviewRepository.save(review);
log.info("리뷰 ID: {} 저장 완료", review.getId());
log.info("리뷰 ID: {} 알림 발송 시작 - 위반 검출", review.getId());
notificationService.sendNotificationToMember(
review.getMember(),
"리뷰에 부적절한 내용이 감지되었습니다",
"리뷰 정책 위반이 감지되었습니다",
REVIEW_REJECTED
);
log.info("리뷰 ID: {} 알림 발송 완료 - 위반 검출", review.getId());
} catch (Exception e) {
log.error("리뷰 ID: {} 위반 처리 중 예외 발생: {}", review.getId(), e.getMessage(), e);
}
} else {
try {
// 문제없는 리뷰는 active를 true로 설정
review.changeActive(true);
log.info("리뷰 ID: {} 활성화 처리 완료", review.getId());
reviewRepository.save(review);
log.info("리뷰 ID: {} 저장 완료", review.getId());
log.info("리뷰 ID: {} 알림 발송 시작 - 승인", review.getId());
notificationService.sendNotificationToMember(
review.getMember(),
"리뷰가 승인되었습니다",
"귀하의 리뷰에 감사드립니다",
REVIEW_APPROVED
);
log.info("리뷰 ID: {} 알림 발송 완료 - 승인", review.getId());
log.info("리뷰 ID: {} 리뷰 요약 업데이트 시작", review.getId());
reviewSummaryService.updateReviewSummary(review);
log.info("리뷰 ID: {} 리뷰 요약 업데이트 완료", review.getId());
} catch (Exception e) {
log.error("리뷰 ID: {} 승인 처리 중 예외 발생: {}", review.getId(), e.getMessage(), e);
}
}
} catch (Exception e) {
log.error("리뷰 ID: {} 모더레이션 결과 처리 중 예외 발생: {}", review.getId(), e.getMessage(), e);
}
}
모더레이션 결과가 준비되면 handleModerationResult 메서드가 호출된다. 이 메서드는 별도의 트랜잭션으로 실행되기 때문에 작업 수행 여부와 에러를 확인 하기 위해 단계 별로 에러 로깅을 체크 했다,
모더레이션 결과에 따라 리뷰의 활성화 상태를 변경하고 (db 에 true/false ) 사용자에게 리뷰 작업의 결과를 알림을 보낸다. 에러 발생 시 사용자 친화적인 문구와 함께 작업 수행 결과를 투명하게 공개했다 .