Spring WebFlux와 Mono , OpenAI 를 활용한 리뷰 모더레이션 시스템 구현

SUUUI·2025년 3월 21일
0

최근 프로젝트에서 헬스장 리뷰 시스템에 AI 기반 모더레이션을 적용했지만 생각치도 못한 문제와 마주했다 .

AI 기반 리뷰 모더레이션의 성능 개선: Mono 비동기 처리로 사용자 경험 개선하기

😳문제 발견

헬스장 리뷰 시스템에 OpenAI API를 활용한 악성 콘텐츠 필터링을 구현했지만, 실제 운영 환경에서 예상치 못한 문제점들이 드러났다:

  1. 응답 지연: 리뷰 텍스트가 길어질수록 모더레이션 API 응답 시간이 길어져(평균 2.8초) 사용자가 제출 후 긴 로딩 시간을 경험함
  2. API 비용 증가: 트래픽이 증가하면서 외부 API 호출 비용이 예상보다 2배 이상 증가
  3. 동시성 문제: 피크 시간대에 여러 리뷰가 동시에 제출될 때 서버 자원이 API 응답 대기에 묶여 전체 시스템 성능 저하

특히 심각했던 것은 리뷰 길이가 300자를 넘을 경우 모더레이션 처리 시간이 4초 이상 소요되어, 사용자들이 리뷰 제출 후 페이지를 이탈하는 비율이 35%까지 증가한 점이었다.

해결 접근

이 문제를 해결하기 위해 전체 아키텍처를 비동기 처리 방식으로 재설계했다:

  1. Spring WebFlux의 Mono 활용: 리뷰 제출과 모더레이션 처리를 분리하여 비동기적으로 처리
  2. 단계별 에러 처리 강화: 비동기 작업이 동일 트랜잭션 내에서 수행되어도 결과와 무관하게 성공 응답이 반환되는 문제를 발견. 각 처리 단계별 상세 로깅을 구현하고 모더레이션 결과에 따른 세분화된 상태 코드(성공/실패/에러)를 도입하여 사용자에게 투명한 피드백 제공.
  3. 상태 기반 알림 시스템: 모더레이션 완료 시 푸시 알림으로 사용자에게 결과 전달

비동기 처리 쉽게 이해하기 🤪

배달 주문(비동기 작업 시작): 봉천동 마라돼지는 두잇으로 신전떡볶이를 주문한다.
주문 후 다른 일 하기(스레드 블로킹 없음): 음식이 도착할 때까지 개발 작업을 계속한다.
떡볶이 조리 중(API 처리 중): OpenAI가 리뷰를 분석 중이다.
초인종(콜백 알림): API 처리가 완료되면 subscribe() 내 람다가 실행된다.

음식을 접시에 담기(결과 처리): 모더레이션 결과를 받아 적절한 액션을 취한다.

  1. 아키텍처 개요

구현한 리뷰 모더레이션 시스템의 전체 흐름은 다음과 같다:

사용자가 헬스장이나 트레이너에 대한 리뷰를 작성한다
->
리뷰가 데이터베이스에 저장된다
->
비동기적으로 OpenAI API를 통해 리뷰 내용을 검수한다
->
검수 결과에 따라 리뷰의 표시 여부를 결정하고 DB에 표시 여부를 업데이트 한 뒤에 사용자에게 리뷰 결과 알림을 보낸다(FCM)

이 과정에서 핵심은 사용자가 리뷰를 제출한 후 OpenAI API 호출 및 처리가 완료될 때까지 기다릴 필요 없이 즉시 다른 작업을 할 수 있도록 비동기 방식으로 구현한 점이다.
구현 코드 중 핵심 부분은 다음과 같다:

  1. 컨트롤러 계층: 모더레이션 요청 접수
@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>>인 것에 주목하자.

이는 결과가 즉시 반환되지 않고, 비동기적으로 결과가 준비되면 그때 반환된다는 것을 의미한다.

  1. 서비스 계층: OpenAI API 호출
@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() 메서드가 호출될 때 실행된다.

  1. 리뷰 생성 및 모더레이션 연동
@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() 메서드를 호출함으로써 실제 비동기 처리가 시작된다는 점이다. 이때 현재 트랜잭션과는 별개로 동작하게 된다.

  1. 모더레이션 결과 처리
@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 ) 사용자에게 리뷰 작업의 결과를 알림을 보낸다. 에러 발생 시 사용자 친화적인 문구와 함께 작업 수행 결과를 투명하게 공개했다 .

  1. 프로젝트에서 Mono 를 사용하여 얻은 장점
  • 사용자 경험 개선: 사용자는 리뷰를 제출하고 즉시 다른 작업을 할 수 있다
  • 서버 리소스 효율성: 모더레이션 API 호출 동안 스레드가 블로킹되지 않아 서버 리소스를 효율적으로 사용한다
  • 확장성: 트래픽이 증가해도 동시에 많은 리뷰를 처리할 수 있다
  • 분리된 관심사: 리뷰 저장과 모더레이션 프로세스가 분리되어 있어 각 컴포넌트를 독립적으로 개선할 수 있다
  1. Mono 사용 시 마주한 도전과 한계
  • 디버깅 복잡성: 비동기 흐름으로 인해 에러 추적과 디버깅이 어려워짐
  • 학습 곡선: 리액티브 프로그래밍 패러다임에 익숙해지는 데 시간 소요
  • 트랜잭션 관리 복잡화: 비동기 작업과 트랜잭션 경계를 명확히 관리해야 하는 부담
  • 테스트 작성 난이도: 비동기 코드 테스트가 동기식 코드보다 구현과 이해가 복잡함
profile
간단한 개발 기록

0개의 댓글