Skip to content

feat: 중복 기사 필터링 3단계 구현 및 최신 기사 우선 정렬 개선#1

Open
moonjun1 wants to merge 1 commit into
devfrom
feat/duplicate-filter-feedback-improvement
Open

feat: 중복 기사 필터링 3단계 구현 및 최신 기사 우선 정렬 개선#1
moonjun1 wants to merge 1 commit into
devfrom
feat/duplicate-filter-feedback-improvement

Conversation

@moonjun1

Copy link
Copy Markdown
Owner

📋 개요

피드백에서 제기된 핵심 문제 두 가지 — 중복 기사 범람오래된 기사 우선 노출 — 를 해결하기 위한 개선 작업입니다.
추가로 한국어 키워드 URL 경로 변수 처리 문제(보안 설정) 및 관리자 배치 수동 실행 인증 우회 설정도 포함됩니다.


🐛 문제 배경

문제 1 — 중복 기사

DeepSearch API로 수집되는 뉴스는 동일 사건을 여러 언론사·편집자가 반복 송고하여 아래와 같은 중복이 다수 발생합니다.

삼성전자 3분기 실적 발표              (한국경제, 10:00)
[속보] 삼성전자 3분기 실적 발표       (한국경제, 10:01)
삼성전자 3분기 실적 발표 (종합)       (한국경제, 10:15)
삼성전자 3분기 실적 발표 (종합2)      (한국경제, 10:30)

기존 코드에는 중복 제거 로직이 없어 모두 DB에 적재되고 사용자에게 중복 기사가 배달되었습니다.

문제 2 — 오래된 기사 우선 노출

  • DeepSearch API 기본 정렬: published_at 오름차순 → 가장 오래된 기사부터 수집
  • ElasticSearch 검색 기본 정렬: _score 단일 정렬 → 최신 기사가 관련도 낮으면 묻힘
  • 결과: 핫토픽 클릭 시 오늘 기사보다 며칠 전 기사가 먼저 표시

✅ 변경 사항

1. 중복 기사 필터링 — 3단계 파이프라인

Level 1 · 수집 단계 인메모리 중복 제거

BatchConfig.javanewsProcessor()

배치가 API 응답을 처리하는 ItemProcessor 단계에서 스레드 안전(thread-safe) Set을 사용해 동일 배치 내 중복을 실시간 차단합니다.

Set<String> seenTitles = Collections.synchronizedSet(new HashSet<>());

String normalizedTitle = dto.getTitle()
        .replaceAll("\[.*?\]|\(.*?\)", "")  // [속보], (종합) 등 태그 제거
        .replaceAll("\s+", "")                  // 공백 완전 제거
        .toLowerCase();                          // 대소문자 통일

String titleKey = dto.getPublisher() + "::" +
        (normalizedTitle.length() > 30
                ? normalizedTitle.substring(0, 30)
                : normalizedTitle);

if (!seenTitles.add(titleKey)) {
    return null;  // ItemWriter로 전달되지 않음 → DB 저장 skip
}
  • 언론사별로 키를 분리(publisher::title앞30자)하여 다른 언론사의 동일 제목은 허용
  • null 반환 시 Spring Batch가 자동으로 해당 아이템을 skip 처리

Level 2 · 배치 완료 후 DB 사후 처리

BatchJobCompletionListener.javaafterJob()

BatchStatus.COMPLETED 이후 자동 실행되는 리스너에서 3단계 SQL을 순차 실행합니다.

Step 1 — 완전 동일 (title + publisher) 중복 제거

DELETE FROM news WHERE id IN (
    SELECT id FROM (
        SELECT id,
               ROW_NUMBER() OVER (
                   PARTITION BY title, publisher
                   ORDER BY published_at DESC, id DESC
               ) AS rn
        FROM news
        WHERE published_at >= CURDATE() - INTERVAL 1 DAY
          AND published_at < CURDATE()
    ) t WHERE t.rn > 1
);

(title, publisher) 완전 동일 그룹에서 가장 최신 1건만 유지

Step 2 — 정규화 유사 제목 중복 제거

DELETE FROM news WHERE id IN (
    SELECT id FROM (
        SELECT id,
               ROW_NUMBER() OVER (
                   PARTITION BY
                       LEFT(REPLACE(REGEXP_REPLACE(title,'\[.*?\]|\(.*?\)|\s',''),' ',''), 30),
                       publisher
                   ORDER BY published_at DESC, id DESC
               ) AS rn
        FROM news
        WHERE published_at >= CURDATE() - INTERVAL 1 DAY
          AND published_at < CURDATE()
    ) t WHERE t.rn > 1
);

[속보]·(종합2) 등 태그 제거 + 공백 제거 후 앞 30자 기준으로 유사 중복 통합

Step 3 — 노이즈성 기사 제거

DELETE FROM news WHERE (
    title REGEXP '^\[(속보|단독|긴급|알림)\]$'  -- 태그만 있는 제목
    OR title LIKE '%〔%'                           -- 특수 괄호 광고성
    OR title LIKE '%▶%'                            -- 영상 유도 아이콘
    OR LENGTH(title) < 10                          -- 10자 미만 초단문
)
AND published_at >= CURDATE() - INTERVAL 1 DAY
AND published_at < CURDATE();

전체 중복 필터링 흐름

DeepSearch API 응답
      │
      ▼
 [Level 1] newsProcessor()         ← 배치 수집 중 실시간 차단
   ・ 태그 제거 + 앞 30자 비교
   ・ 중복 → null 반환(skip)
      │
      ▼
   DB INSERT
      │
      ▼
 [Level 2] afterJob() 리스너       ← 배치 완료 직후 자동 실행
   ・ Step 1: 완전 동일 중복 제거
   ・ Step 2: 유사 제목 중복 제거
   ・ Step 3: 노이즈 기사 제거
      │
      ▼
   정제된 뉴스 DB

2. 최신 기사 우선 정렬

DeepSearch API 수집 정렬 변경

BatchConfig.javagetAPIResponse(), getNewsList()

// Before
.queryParam("order", "published_at")

// After
.queryParam("order", "-published_at")   // 마이너스 접두사 = 내림차순

두 메서드 모두 동일하게 적용하여 수집 단계부터 최신 기사가 먼저 저장됩니다.

ElasticSearch 검색 결과 정렬 변경

KakaoNewsService.java

// Before: 관련도(score) 단일 정렬
.sort(sort -> sort.score(sc -> sc.order(SortOrder.Desc)))

// After: 최신순 우선, 관련도 보조
.sort(sort -> sort.field(f -> f
        .field("published_at")
        .order(SortOrder.Desc)))         // 1순위: 최신 기사
.sort(sort -> sort.score(sc -> sc
        .order(SortOrder.Desc)))         // 2순위: 관련도

3. 한국어 URL 경로 변수 허용 (SecurityConfig)

SecurityConfig.java

/api/hottopic/{keyword} 엔드포인트에 한국어 키워드를 URL 인코딩하여 전달하면 Spring Security의 StrictHttpFirewall이 비ASCII 문자를 차단하여 400 에러가 발생하던 문제를 해결합니다.

@Bean
public HttpFirewall allowEncodedKoreanFirewall() {
    StrictHttpFirewall firewall = new StrictHttpFirewall();
    firewall.setAllowUrlEncodedPercent(true);
    firewall.setAllowUrlEncodedSlash(true);
    firewall.setAllowSemicolon(true);
    firewall.setAllowUrlEncodedDoubleSlash(true);
    return firewall;
}

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.httpFirewall(allowEncodedKoreanFirewall());
}

4. JWT 필터 우회 경로 추가 (JwtAuthenticationFilter)

JwtAuthenticationFilter.javashouldNotFilter()

/api/admin/** 경로를 JWT 인증 필터에서 제외하여 배치 수동 실행(POST /api/admin/batch/run) 등 관리자 작업을 인증 없이 호출할 수 있도록 합니다. (SecurityConfig에서도 동일 경로 permitAll() 적용)


📊 개선 효과 요약

항목 Before After
배치 수집 중 중복 제거 로직 없음 인메모리 Set으로 실시간 차단
DB 완전 동일 중복 그대로 적재 ROW_NUMBER로 최신 1건 유지
DB 유사 제목 중복 그대로 적재 정규화 30자 기준 통합
노이즈·광고성 기사 배달됨 배치 직후 자동 삭제
DeepSearch 수집 정렬 오래된 기사 우선 최신 기사 우선
ES 검색 결과 정렬 관련도(score) 단일 최신일자 우선 + 관련도 보조
한국어 URL 경로 StrictFirewall 400 차단 URL 인코딩 허용

📁 변경 파일 목록

파일 변경 유형 내용
Global/News/Batch/configuration/BatchConfig.java 수정 newsProcessor 중복 필터, API 정렬 변경
Global/News/Batch/listener/BatchJobCompletionListener.java 수정 afterJob 3단계 SQL 중복 제거
Domain/Kakao/service/KakaoNewsService.java 수정 ES 검색 정렬 published_at 우선
SecurityConfig.java 수정 StrictHttpFirewall 한국어 URL 허용
Global/JWT/JwtAuthenticationFilter.java 수정 /api/admin/** shouldNotFilter 추가
docker-compose.yml 수정 로컬 MySQL 활성화
report/REPORT.md 신규 로컬 환경 구축 및 전체 작업 보고서
report/feedback_improvement_report.md 신규 피드백 개선 항목 상세 보고서
report/verification_report.md 신규 관리자 에이전트 검증 결과 보고서
report/seed_data.py 신규 로컬 목 데이터 주입 스크립트
report/screenshots/*.png 신규 홈·모달·마이페이지·설정 스크린샷 4장

🧪 테스트 체크리스트

  • GET /api/hottopic → 10개 한국어 키워드 정상 반환
  • GET /api/hottopic/삼성전자 (URL 인코딩) → 최신순 기사 5건 반환 (StrictFirewall 수정 전 400 → 수정 후 200)
  • 배치 실행 후 BatchJobCompletionListener 로그에 중복 제거 건수 출력 확인
  • GET /sub/history (JWT 인증) → 히스토리 10건 정상 조회
  • MySQL news 테이블 43건, ElasticSearch news-index-nori 43 docs 동기화 일치
  • 마이페이지 발송 히스토리 날짜별 표시 정상
  • 설정 페이지 배송 설정 feat: 중복 기사 필터링 3단계 구현 및 최신 기사 우선 정렬 개선 #1/Oauth2.0 구현 News-Deliver/Server#2 키워드·요일·시간 정상 표시

🔗 관련 문서

  • 전체 작업 보고서: report/REPORT.md
  • 피드백 개선 보고서: report/feedback_improvement_report.md
  • 관리자 검증 보고서: report/verification_report.md

- [중복 필터링 Level1] BatchConfig.newsProcessor()에 스레드 안전 Set 추가
  속보/종합 태그 제거 후 앞 30자 기준으로 배치 수집 중 인메모리 중복 차단

- [중복 필터링 Level2] BatchJobCompletionListener afterJob() 3단계 SQL 후처리
  Step1: ROW_NUMBER로 완전 동일(title+publisher) 중복 제거, 최신 1건 유지
  Step2: REGEXP_REPLACE 정규화 유사 제목 중복 제거
  Step3: 노이즈·광고성·10자 미만 기사 삭제

- [최신 정렬] DeepSearch API order 파라미터 published_at → -published_at
  getAPIResponse(), getNewsList() 두 메서드 모두 적용

- [최신 정렬] KakaoNewsService ES 검색 정렬 변경
  score DESC 단일 → published_at DESC 우선 + score DESC 보조

- [보안] SecurityConfig에 StrictHttpFirewall 설정 추가
  한국어 키워드 URL 인코딩 경로 변수 허용(/api/hottopic/{keyword})

- [인증] JwtAuthenticationFilter shouldNotFilter에 /api/admin/** 경로 추가

- report/ 디렉토리 추가: 작업 보고서, 피드백 개선 보고서, 검증 보고서, 스크린샷 4장
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant