본문 내용으로 바로가기
jwjoo.
Back to Projects

효드림 - 실버 세대 맞춤형 헬스케어 커머스

기저질환 및 알레르기 필터링과 LLM 기반 추천, Redis Streams 실시간 이벤트 수집 및 딥러닝 리뷰 감성 분석을 결합한 백엔드 시스템

Spring BootFastAPIAI / LLM

프로젝트 배경 및 목표

기존 커머스의 단순 판매량 중심 추천 방식은 만성 질환이나 알레르기를 보유한 실버 세대 사용자에게 적합하지 않았습니다.

본 프로젝트는 사용자의 건강 데이터(질환, 알레르기, 개선 목표)를 기반으로 안전하고 개인화된 상품을 제공하는 백엔드 시스템을 구축하는 것을 목표로 했습니다.

  • 건강 데이터 기반 필터링: 사용자의 알레르기 유발 성분을 사전 배제하고, 등록된 질환 및 건강 개선 목표에 부합하는 상품을 선별합니다.
  • 모듈러 모놀리스(Modular Monolith): 도메인 간 결합도를 낮추기 위해 직접 참조 대신 ID 참조 방식을 적용하여 향후 마이크로서비스 전환에 대비했습니다.
  • 실시간 데이터 및 AI 파이프라인: 네이버 쇼핑 상품 수집, 딥러닝 기반 리뷰 감성 분석, LLM 맞춤 추천, Redis Streams 기반 행동 분석을 단일 파이프라인으로 연계했습니다.

시스템 아키텍처 및 기술 스택

┌────────────────────────────────────────────────────────┐
│              Client / Swagger OpenAPI                  │
└──────────────────────────┬─────────────────────────────┘
                           │ HTTP / JSON
                           ▼
┌────────────────────────────────────────────────────────┐
│     Spring Boot 3.4 Core Backend (Modular Monolith)     │
│  - User Domain (Disease, Allergy, Health Goal)         │
│  - Product Domain (SearchLog, Naver Import, DB Upsert) │
│  - Order Domain (Real-time Sales Sync)                 │
└──────────────┬───────────────────┬─────────────────────┘
               │ (OpenFeign)       │ (OpenFeign)
               ▼                   ▼
┌────────────────────────┐  ┌────────────────────────────┐
│  AI Recommender Server │  │ AI Review Sentiment Server │
│   (FastAPI + GPT-4o)   │  │   (FastAPI + TF/Keras)     │
└────────────────────────┘  └──────────────┬─────────────┘
                                           │ (OpenFeign)
                                           ▼
                            ┌────────────────────────────┐
                            │    SmartStore Crawler      │
                            │ (undetected-chromedriver)  │
                            └────────────────────────────┘
               ▲
               │ Streams (XADD / XREAD) & Caching
               ▼
┌────────────────────────────────────────────────────────┐
│             MySQL 8.0 & Redis 7.0 In-Memory            │
│       (Domain DB / Streams / ZSet / 24h Cache)         │
└────────────────────────────────────────────────────────┘

Backend

  • Framework & Language: Java 21, Spring Boot 3.4
  • Architecture: Modular Monolith (도메인 간 ID 참조)
  • Persistence: Spring Data JPA, Hibernate, MySQL 8.0
  • Client: Spring Cloud OpenFeign

AI & Data Engine

  • LLM Engine: Python 3.10, FastAPI, OpenAI GPT-4o-mini
  • Sentiment Analysis: Python 3.9, TensorFlow/Keras (best_model.h5), MeCab
  • Crawler: Python 3.10, FastAPI, undetected-chromedriver, Selenium
  • Cache & Streaming: Redis 7.0 (Redis Streams, Sorted Set)

핵심 기술적 도전 및 구현 내용

1. 계층형 하이브리드 추천 파이프라인

  • Challenge: 규칙 기반 필터링만으로는 사용자 상황에 맞춘 유연한 추천이 어렵고, 모든 추천에 LLM을 직접 호출하면 응답 지연과 API 비용이 증가하는 문제가 있었습니다.
  • Solution: 4단계의 계층형 추천 구조를 설계하여 정확도와 성능을 확보했습니다.
    1. 알레르기 필터링: 사용자 프로필의 알레르기 성분이 포함된 상품을 DB 조회 단계에서 제외.
    2. 동일 질환 그룹 기반 추천: 동일한 기저질환을 보유한 사용자들이 많이 구매하거나 선호한 상품 목록 조회.
    3. LLM 기반 추천 (GPT-4o-mini): 인기순 80개와 최신 등록 20개로 구성된 100개 후보군을 프롬프트로 전달하고, 사용자 건강 목표 및 질환에 적합한 5개 상품 ID를 JSON 형식으로 수신.
    4. 실시간 인터랙션 결합: Redis Streams에 수집된 사용자 최근 행동 데이터를 조합하여 최종 추천 목록 생성.
# ai-server/app/gpt_recommender.py (후보군 요약 및 JSON 스키마 강제 프롬프트)
prompt = f"""
너는 헬스케어 쇼핑몰의 추천 AI다.
[사용자 정보] 질병: {', '.join(req.diseases)}, 알레르기: {', '.join(req.allergies)}, 목표: {', '.join(req.goals)}
[후보 상품 목록] (총 100개)
{candidates_text}

[추천 규칙]
1. 사용자의 알레르기 성분이 포함된 상품은 절대 제외할 것
2. 질병 및 개선 목표와 관련된 효능이 있는 상품을 우선 선택하여 정확히 5개 선정
[출력 형식] {{"product_ids": [ID1, ID2, ID3, ID4, ID5]}}
"""

2. Redis Streams & ZSet을 활용한 실시간 사용자 행동 수집

  • Challenge: 상품 클릭 및 조회 이벤트를 발생할 때마다 RDBMS에 기록할 경우 I/O 부하와 커넥션 점유가 증가했습니다.
  • Solution: Redis Streams와 ZSet을 결합한 비동기 이벤트 수집 구조를 적용했습니다.
    • EventController에서 클릭 이벤트를 Redis Stream(product-events)에 발행(XADD)하여 API 응답 지연 최소화.
    • 백그라운드의 StreamConsumer가 이벤트를 읽어 사용자 및 카테고리별 실시간 가중치 점수를 Redis ZSet에 누적(ZINCRBY).
    • 실시간 점수를 인메모리에서 즉시 조회하여 추천 가중치로 활용.
// backend/.../product/service/StreamConsumer.java (Stream 이벤트 컨슈밍 및 ZSet 누적)
@Scheduled(fixedRate = 1000)
public void processClickEvents() {
    List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
            .read(Consumer.from("rec-group", "consumer-1"),
                  StreamReadOptions.empty().count(100),
                  StreamOffset.create("product-events", ReadOffset.lastConsumed()));

    for (var record : records) {
        String category = (String) record.getValue().get("category");
        String userId = (String) record.getValue().get("userId");
        // 사용자별 실시간 관심 카테고리 점수 누적
        redisTemplate.opsForZSet().incrementScore("user:interest:" + userId, category, 1.0);
        redisTemplate.opsForStream().acknowledge("product-events", "rec-group", record.getId());
    }
}

3. 리뷰 감성 분석 파이프라인 및 SSR 크롤링 최적화

  • Challenge: 네이버 스마트스토어의 단순 평점은 이벤트성 리뷰로 인해 편향될 수 있으며, 브라우저 전체 렌더링 방식의 크롤링은 속도 저하를 유발했습니다.
  • Solution: SSR 초기 데이터 직접 파싱과 자체 감성 분석 모델을 구축했습니다.
    • SSR 데이터 파싱: Selenium 브라우저 대기 대신 HTML 내부 window.__PRELOADED_STATE__ JSON을 정규식으로 직접 추출하여 상품 정보 및 결제 식별 키를 빠르게 수집.
    • 감성 분석 모델 서빙: 수집된 리뷰 텍스트를 MeCab 형태소 분석기와 불용어 목록으로 전처리한 뒤, 사전 학습된 Keras 모델(best_model.h5)을 통해 실시간 긍/부정 비율을 수치화.
# crawler/app/crawler.py (SSR window.__PRELOADED_STATE__ JSON 정규식 직접 추출)
def extract_preloaded_state(html: str) -> Optional[dict]:
    pattern = r'window\.__PRELOADED_STATE__\s*=\s*({.*?});?\s*</script>'
    match = re.search(pattern, html, re.DOTALL)
    return json.loads(match.group(1)) if match else None

4. SearchLog 기반 24시간 캐싱 및 DB Upsert

  • Challenge: 검색 요청마다 외부 네이버 쇼핑 API를 호출할 경우 API 호출 제한(Quota) 초과 및 응답 지연이 발생했습니다.
  • Solution: SearchLog 엔티티를 활용한 24시간 캐싱 정책을 적용했습니다.
    • 검색 키워드 유입 시 SearchLoglastApiCallAt을 확인하여, 24시간 이내 호출 이력이 있다면 내부 DB 인덱스를 통해 응답 반환.
    • 신규 키워드이거나 24시간이 경과한 경우에만 네이버 쇼핑 API를 호출해 상품을 가져오고 DB에 Upsert 수행. 외부 API 호출 빈도를 크게 절감.
// backend/.../product/service/ProductService.java (24시간 캐싱 & 외부 API 호출 제어)
SearchLog log = searchLogRepository.findById(keyword).orElse(null);
boolean needApiCall = (log == null) || 
    (log.getLastApiCallAt() != null && log.getLastApiCallAt().isBefore(LocalDateTime.now().minusHours(24)));

if (needApiCall) {
    naverShoppingService.importNaverProducts(keyword);
    if (log == null) log = new SearchLog(keyword, LocalDateTime.now(), LocalDateTime.now());
    else log.recordApiCall();
    searchLogRepository.save(log);
}

5. Service 계층 In-Memory Map 인덱싱을 통한 N+1 쿼리 최적화

  • Challenge: 주문 생성(OrderService.order) 및 주문 내역 조회(getMyOrders) 시 연관 상품을 개별 조회하면서 N+1 쿼리가 발생했습니다.
  • Solution:
    • hibernate.default_batch_fetch_size: 100 설정을 적용.
    • 대상 상품 ID들을 Set<Long>으로 취합한 뒤 productRepository.findAllById(productIds) 단일 IN 쿼리로 일괄 조회.
    • 조회 결과를 Map<Long, Product>로 변환하여 $O(1)$ 복잡도로 주문 상품 DTO와 매핑, 데이터베이스 쿼리 수를 $O(1)$로 고정.
// backend/.../order/service/OrderService.java (단일 IN 절 쿼리 & In-Memory Map 매핑)
Set<Long> productIds = orders.stream()
        .flatMap(o -> o.getOrderItems().stream())
        .map(OrderItem::getProductId)
        .collect(Collectors.toSet());

// N번 개별 쿼리 대신 1번의 IN 절 쿼리로 일괄 조회 후 메모리 매핑
Map<Long, String> productNameMap = productRepository.findAllById(productIds).stream()
        .collect(Collectors.toMap(Product::getId, Product::getName));

Troubleshooting

문제 1: 주문 및 취소 시 실시간 판매량 데이터 불일치

  • 현상: 배치 스케줄러로만 판매량을 집계할 경우 주문 즉시 인기순 정렬에 반영되지 않고, 주문 취소 건이 랭킹에 잔존하는 문제 발생.
  • 해결: OrderService 내에서 주문 생성 시 recentSalestotalSales를 즉시 증가시키고 취소 시 즉시 감소시키는 트랜잭션 동기화 로직을 구현했습니다. ProductScheduler는 누락 데이터 보정 역할만 수행하도록 역할을 분리했습니다.

문제 2: LLM 응답 포맷 검증 및 예외 처리

  • 현상: GPT 모델이 간혹 요청하지 않은 ID를 반환하거나 5개 미만/초과의 결과를 반환하는 포맷 오류 발생 가능성.
  • 해결: 시스템 프롬프트에 JSON 출력 포맷을 명시하고, Python 서버 레이어에서 반환된 ID가 백엔드에서 넘겨준 후보군 풀(candidates)에 존재하는지 및 길이가 정확히 5개인지 검증하는 방어 코드를 구현하여 잘못된 응답을 차단했습니다.

문제 3: 비동기 파이프라인의 동시성 충돌 및 DB 커넥션 고갈

  • 현상:
    • 상품 상세 조회/리뷰 작성 시 백그라운드 비동기(@Async)로 크롤링 및 AI 감성 분석이 트리거되는데, 여러 요청이 동시 유입될 때 애플리케이션 레벨의 상태 확인이나 JPA 낙관적 락(@Version)만으로는 Race Condition을 완벽히 통제하지 못해 ObjectOptimisticLockingFailureException 충돌 및 중복 API 호출이 발생했습니다.
    • 또한 비동기 메서드 전체에 @Transactional을 걸 경우 외부 API 호출(수 초의 네트워크 I/O) 동안 DB 커넥션을 계속 붙잡고 있어 커넥션 풀 고갈(Connection Starvation) 위험이 있었습니다.
  • 해결:
    • DB 원자적 쿼리(startSyncNative)를 통한 락 선점: JPA 영속성 컨텍스트를 우회하고 MySQL INSERT ... ON DUPLICATE KEY UPDATE 네이티브 쿼리를 실행해 DB 레벨에서 원자적(Atomic)으로 PROGRESS 상태를 선점했습니다. 영향받은 행 수가 0이면 다른 스레드가 이미 처리 중인 것으로 판단하여 즉시 조기 종료(Early Return) 처리했습니다.
    • 트랜잭션 분리 및 비관적 락(TransactionTemplate & REQUIRES_NEW): 메서드 레벨 @Transactional을 제거하고 트랜잭션을 [① 선점 쿼리 후 즉시 커밋] $\rightarrow$ [② 커넥션 없는 외부 API 호출] $\rightarrow$ [③ findByIdWithLock(PESSIMISTIC_WRITE) 기반 결과 저장]의 3단계로 분리하여 커넥션 점유 시간을 수 밀리초 단위로 최소화하고 데이터 정합성을 확보했습니다.
// backend/.../product/service/ProductSyncService.java (트랜잭션 분리 및 DB 원자적 선점)
@Async
public void updateProductDetailsAsync(Long productId) {
    TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
    txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

    // 1. [DB 원자적 선점] Native Query로 PROGRESS 상태 원자적 선점 (영향 행 0이면 즉시 스킵)
    int updatedRows = txTemplate.execute(status -> productRepository.startSyncNative(productId));
    if (updatedRows == 0) return;

    // 2. [Non-Transaction] 수 초 이상 걸리는 외부 I/O 동안 DB 커넥션 미점유
    CrawlerResponseDto crawledData = crawlerClient.crawlProduct(new CrawlRequest(itemUrl, 5));

    // 3. [비관적 락 결과 저장] PESSIMISTIC_WRITE(SELECT FOR UPDATE)로 안전하게 최종 갱신
    txTemplate.execute(status -> {
        Product product = productRepository.findByIdWithLock(productId)
                .orElseThrow(() -> new RuntimeException("상품 없음"));
        product.getDetail().updateCrawledData(...);
        product.getDetail().setStatus(AnalysisStatus.COMPLETED);
        productRepository.saveAndFlush(product);
        return null;
    });
}

프로젝트 성과 및 배운 점

  • 모듈러 모놀리스 구조 정립: 도메인 간 직접 참조를 제한하고 ID 참조 방식을 유지함으로써 유지보수성과 확장성을 높였습니다.
  • 다양한 백엔드 기술 통합: Spring Boot 백엔드를 중심으로 Python AI 서비스, Redis 스트리밍, 머신러닝 모델, 크롤러를 안정적인 구조로 연계했습니다.
  • 성능 및 동시성 최적화: Native Query 기반 원자적 상태 선점 및 트랜잭션 분리로 동시성 이슈를 해결하였으며, In-Memory Map 인덱싱을 통한 N+1 쿼리 해결과 24시간 검색 캐싱으로 백엔드 응답 성능을 극대화했습니다.