뉴스기사 관리 - MonewLabs/monew-web GitHub Wiki

📌 기사 뷰 등록 API

자세히 보기

기사 뷰 등록 API

사용자가 특정 뉴스 기사를 처음 조회했을 때만 기사 조회 기록을 저장하고,
중복되지 않게 조회수를 1회 증가시키는 기능


Endpoint

POST /api/articles/{articleId}/article-views

Request

위치 이름 타입 필수 설명
Path articleId UUID 조회 대상 뉴스 기사 ID
Header MoNew-Request-User-ID UUID 요청자(사용자) ID

Response (200 OK)

{
  "id": "기사 뷰 기록 ID",
  "viewedBy": "사용자 ID",
  "createdAt": "2025-05-30T15:20:14.000Z",
  "articleId": "기사 ID",
  "source": "NAVER",
  "sourceUrl": "https://example.com/article",
  "articleTitle": "기사 제목",
  "articlePublishedDate": "2025-05-30T14:00:00.000Z",
  "articleSummary": "요약 내용",
  "articleCommentCount": 5,
  "articleViewCount": 103
}

동작 설명

  1. 사용자가 기사를 조회하면 해당 articleId, userId 조합으로 조회 기록(ArticleView)을 조회
  2. 처음 조회한 사용자일 경우:
    • ArticleView 저장
    • NewsArticle.viewCount++
  3. 이미 조회한 사용자일 경우:
    • 아무것도 저장하지 않음
  4. 클라이언트에는 항상 ArticleViewDto 응답 반환

관련 클래스

  • ArticleView.java (엔티티)
  • ArticleViewRepository.java
  • ArticleViewDto.java
  • ArticleViewMapper.java
  • NewsArticleController.java/article-views 엔드포인트 구현
  • NewsArticleService.java → 서비스 로직 처리
  • NewsArticleControllerTest.java → 단위 테스트 포함

예외 처리

상황 예외 HTTP
존재하지 않는 기사 ID NotFoundException 404

※ 모든 예외는 GlobalExceptionHandler에서 처리


개발 목적 및 효과

  • 사용자의 뉴스 기사 1회 조회만 기록
  • 중복 조회에 따른 조회수 왜곡 방지
  • 향후 viewedByMe 플래그, 사용자 관심도 기반 추천 등에 활용 가능

📌 뉴스 기사 목록 조회 API 구현

자세히 보기

개요

뉴스 기사 데이터를 다양한 조건으로 필터링, 정렬하고 커서 기반 페이지네이션을 통해 효율적으로 조회하는 API를 설계하고 구현하였습니다.


주요 기능

  • 제목/요약 키워드 검색
  • 관심사 ID, 출처 목록, 날짜 범위 필터링
  • 정렬 기준: publishDate, commentCount, viewCount
  • 정렬 방향: ASC, DESC
  • 커서 기반 페이지네이션 지원 (after 기반)
  • 응답 DTO: CursorPageResponseArticleDto
예시 쿼리 파라미터
GET /api/articles?orderBy=publishDate&direction=DESC&after=2024-05-01T00:00:00

구현 구조

1. Controller (NewsArticleController)

  • 요청 파라미터 수신 (@RequestParam, @RequestHeader)
  • 정렬 필드 등 유효성 검사
  • 서비스 호출 및 응답 반환

2. Service (NewsArticleService)

  • 동적 조건 필터링
  • 커서 페이지네이션 처리 (hasNext, nextCursor, nextAfter)
  • Mapper를 활용한 DTO 변환

3. Mapper (NewsArticleMapper)

  • NewsArticleArticleDto 변환
  • 재사용 가능한 컴포넌트

4. QueryDSL 기반 Repository 구조

구성요소 설명
NewsArticleRepositoryCustom 쿼리 메서드 정의
NewsArticleRepositoryImpl QueryDSL 기반 구현
NewsArticleRepository JpaRepository 확장

사용 기술 및 이유

기술 목적 장점
QueryDSL 동적 조건 처리 가독성, 타입 안전성
커서 페이지네이션 성능 최적화 중복/누락 방지, 대용량 처리에 유리
DTO + Mapper 응답 구조화 도메인 보호, 클라이언트 응답 맞춤화
Controller 유효성 검사 입력 오류 사전 방지 빠른 피드백, 오류 명확화
계층 분리 설계 구조적 개발 테스트 용이성, 유지보수 편의

커서 페이지네이션 처리 방식

  • after: 기준이 되는 LocalDateTime을 문자열로 전달
  • 쿼리 조건: WHERE article.date < :after
  • limit + 1개 조회 후, 다음 커서 정보 계산
  • 응답에 nextCursor, nextAfter, hasNext 포함
예시 응답 구조
{
  "content": [...],
  "nextCursor": "2024-04-30T13:22:01",
  "nextAfter": "2024-04-30T13:22:01",
  "hasNext": true,
  "totalElements": 10,
  "size": 10
}

안정적인 정렬을 위해 추후 date + id 복합 커서를 Base64로 인코딩한 방식으로 확장 예정


확장 고려사항

  • Monew-Request-User-ID는 현재 기능에는 사용되지 않지만 다음과 같은 확장 가능성이 있음:
    • 개인화 필터링
    • 기사 조회수 집계
    • 읽음 여부 저장

📌 출처 목록 조회 API 구현

자세히 보기

개요

뉴스 기사 목록 화면에서 출처 기준으로 필터링할 수 있도록,
등록된 뉴스 기사들의 출처 목록을 반환하는 API를 구현했습니다.

해당 API는 프론트엔드에서 기사 필터링 조건으로 사용됩니다.
예: 드롭다운에서 "NAVER", "연합뉴스" 등의 출처를 선택하여 기사 필터링


API 명세

  • 메서드: GET
  • URL: /api/articles/sources

응답 형식

{
  "sources": [
    "NAVER",
    "연합뉴스",
    "조선일보"
  ]
}

구현 구조

1. DTO

public record SourcesResponseDto(
    List<String> sources
) {}

2. Repository

@Query("SELECT DISTINCT n.source FROM NewsArticle n WHERE n.deleted = false")
List<String> findDistinctSources();

3. Service

@Transactional(readOnly = true)
public SourcesResponseDto getSources() {
    List<String> sources = newsArticleRepository.findDistinctSources();
    return new SourcesResponseDto(sources);
}

4. Controller

@GetMapping("/api/articles/sources")
public ResponseEntity<SourcesResponseDto> getSources() {
    return ResponseEntity.ok(newsArticleService.getSources());
}

예외 처리

  • 전역 예외 처리기(GlobalExceptionHandler)에서 Exception.class를 처리하고 있음
  • 예외 발생 시 JSON 형태로 응답
{
  "code": "INTERNAL_SERVER_ERROR",
  "message": "서버 내부 오류입니다.",
  "exceptionType": "IllegalStateException",
  "status": 500
}

테스트 코드

@Test
@DisplayName("출처 목록 조회 성공")
void getSources_Success() throws Exception {
    List<String> mockSources = List.of("NAVER", "연합뉴스");
    given(newsArticleService.getSources())
        .willReturn(new SourcesResponseDto(mockSources));

    mockMvc.perform(get("/api/articles/sources"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.sources[0]").value("NAVER"))
        .andExpect(jsonPath("$.sources[1]").value("연합뉴스"));
}
@Test
@DisplayName("출처 목록 조회 실패 - 서버 예외 발생 시 500 반환")
void getSources_Fail() throws Exception {
    given(newsArticleService.getSources())
        .willThrow(new IllegalStateException("DB 오류"));

    mockMvc.perform(get("/api/articles/sources"))
        .andExpect(status().isInternalServerError())
        .andExpect(jsonPath("$.code").value("INTERNAL_SERVER_ERROR"))
        .andExpect(jsonPath("$.exceptionType").value("IllegalStateException"));
}

기타

  • @Transactional(readOnly = true)를 사용해 읽기 전용 트랜잭션으로 최적화
  • DTO로 감싸는 이유는 확장성과 응답 일관성을 고려한 설계

📌 뉴스 기사 물리 삭제 기능 구현

자세히 보기

개요

뉴스 기사 데이터를 데이터베이스에서 완전히 제거하는 물리 삭제 기능을 구현하였다.
이 기능은 관리자 또는 내부 운영 도구에서 사용될 가능성이 높은 API로, 논리 삭제(deleted=true)와는 구분된다.


기능 설명

  • 논리 삭제는 데이터를 보존한 채 사용자에게만 숨기기 위한 삭제 방식
  • 물리 삭제는 DB에서 영구적으로 삭제하는 방식
  • 별도의 엔드포인트(/hard)로 구분하여 API 제공

API 정보

메서드 URL
DELETE /api/articles/{id}/hard

요청 Path 변수

이름 타입 설명
id UUID 삭제 대상 뉴스 기사 ID

응답

  • 204 No Content: 삭제 성공
  • 404 Not Found: 해당 ID의 뉴스 기사가 존재하지 않을 경우

예외 처리

예외 상황 응답 코드 예외 구조
존재하지 않는 ID 404 RESOURCE_NOT_FOUND + details.parameter = articleId

테스트 정보

항목 설명
단위 테스트 별도 작성 예정
통합 테스트 NewsArticleControllerTest 내 성공/실패 테스트 작성 완료

통합 테스트 시나리오

  1. 존재하는 기사 ID 요청 시 204 No Content
  2. 존재하지 않는 ID 요청 시 404 Not Found + 에러 JSON

📌 뉴스 기사 논리 삭제 기능 구현

자세히 보기

개요

뉴스 기사 데이터를 삭제하지 않고 deleted 플래그만 true로 설정하여 사용자에게 보이지 않도록 처리하는 기능입니다.
복구 가능성과 데이터 추적을 위해 실제 DB에서 삭제하지 않습니다.


API 명세

  • 메서드: DELETE
  • URL: /api/articles/{id}

요청 정보

위치 이름 타입 필수 설명
Path id UUID 논리 삭제할 뉴스 기사 ID

응답

  • 204 No Content: 삭제 성공
  • 404 Not Found: 해당 ID의 기사 없음 (또는 이미 삭제됨)

구현 구조

1. Entity

@Column(nullable = false)
private boolean deleted = false;

public void markAsDeleted() {
    this.deleted = true;
}

2. Repository

Optional<NewsArticle> findByIdAndDeletedFalse(UUID id);

3. Service

@Transactional
public void deleteLogically(UUID articleId) {
    NewsArticle article = newsArticleRepository.findByIdAndDeletedFalse(articleId)
        .orElseThrow(() -> new CustomException(
            ErrorCode.RESOURCE_NOT_FOUND,
            new ErrorDetail("UUID", "articleId", articleId.toString()),
            ExceptionType.NEWSARTICLE
        ));
    article.markAsDeleted();
}

4. Controller

@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Void> deleteArticleLogically(@PathVariable UUID id) {
    newsArticleService.deleteLogically(id);
    return ResponseEntity.noContent().build();
}

예외 처리

상황 응답 코드 예외 코드
삭제 대상 기사가 없음 404 RESOURCE_NOT_FOUND (각종 ExceptionType 포함)

예외 응답 예시:

{
  "code": "RESOURCE_NOT_FOUND",
  "message": "요청한 리소스를 찾을 수 없습니다.",
  "details": {
    "parameter": "articleId",
    "value": "28dcfbc4-..."
  },
  "exceptionType": "NEWSARTICLE",
  "status": 404
}

테스트 정보

Controller 테스트 (NewsArticleControllerTest)

  • 삭제 성공 시: 204 No Content
  • 존재하지 않는 기사 ID: 404 Not Found + 에러 JSON 확인
@DisplayName("뉴스 기사 논리 삭제 성공")
@Test
void deleteArticle_Success() throws Exception {
    UUID articleId = UUID.randomUUID();
    willDoNothing().given(newsArticleService).deleteLogically(articleId);

    mockMvc.perform(delete("/api/articles/{id}", articleId))
        .andExpect(status().isNoContent());
}
@DisplayName("뉴스 기사 논리 삭제 실패 - 존재하지 않는 기사")
@Test
void deleteArticle_Fail_NotFound() throws Exception {
    UUID articleId = UUID.randomUUID();

    willThrow(new CustomException(
        ErrorCode.RESOURCE_NOT_FOUND,
        new ErrorDetail("UUID", "articleId", articleId.toString()),
        ExceptionType.NEWSARTICLE
    )).given(newsArticleService).deleteLogically(articleId);

    mockMvc.perform(delete("/api/articles/{id}", articleId))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
        .andExpect(jsonPath("$.exceptionType").value("NEWSARTICLE"))
        .andExpect(jsonPath("$.details.parameter").value("articleId"));
}

관련 클래스

  • NewsArticle.java (deleted 필드, markAsDeleted() 메서드)
  • NewsArticleRepository.java
  • NewsArticleService.java
  • NewsArticleController.java
  • NewsArticleControllerTest.java

개발 목적 및 효과

  • 데이터를 유지하며 복구 가능성 보장
  • 사용자 기준으로는 삭제 처리되지만, 운영상 추적 가능
  • 논리 삭제된 데이터는 모든 조회 API에서 제외 (deleted = false 조건)

📌 뉴스 복구 기능 구현

자세히 보기

개요

뉴스 기사 수집 중 배치 타이밍에 따라 데이터 유실이 발생하는 상황을 고려해,
백업 파일을 기반으로 뉴스 데이터를 복구하는 API를 개발하였다.


구현 목표

  • 뉴스 수집 배치 중 유실된 데이터를 백업 파일 기반으로 복원
  • 초기에는 로컬 JSON 파일 기반으로 동작
  • 이후에는 AWS S3에 저장된 백업 파일을 기반으로 확장 가능하도록 설계

개발 과정

1. 인터페이스 설계

public interface BackupStorage {
    List<NewsBackupDto> loadBackup(LocalDate date);
}
  • NewsBackupDto: 복원 대상 뉴스 정보를 담는 DTO
  • 구현체만 바꿔 끼우면 Local → S3 로 유연하게 전환 가능

2. Local 환경 구현

@Component
public class LocalBackupStorage implements BackupStorage {
    private final Path backupDir = Path.of("mock-backup");

    @Override
    public List<NewsBackupDto> loadBackup(LocalDate date) {
        Path path = backupDir.resolve("news-" + date + ".json");
        // ObjectMapper로 JSON 파싱
    }
}
  • src/main/resources/mock-backup/news-2025-06-02.json 같은 파일을 읽는다.
  • 테스트 초기에는 실제 DB 없이도 복원 가능하도록 구성

3. API 구현

@PostMapping("/api/articles/restore")
public ResponseEntity<Void> restoreArticles(@RequestParam LocalDate date) {
    newsArticleService.restore(date);
    return ResponseEntity.ok().build();
}
  • 해당 날짜의 백업 파일을 로딩하고, 기존 뉴스와 비교하여 누락된 뉴스만 복구
  • interestId 유효성 검사를 포함

4. 복구 로직 핵심

for (NewsBackupDto backup : backups) {
    if (!newsRepository.existsByOriginalLink(backup.originalLink())) {
        NewsArticle article = backup.toEntity(
            interestRepository.findById(backup.interestId())
                .orElseThrow(() -> new IllegalArgumentException("관심사 없음"))
        );
        newsRepository.save(article);
    }
}
  • originalLink 기준으로 중복 여부 판단
  • 관심사 ID가 존재하지 않으면 예외 발생

수동 테스트 방법 (Postman)

  1. mock-backup/news-2025-06-02.json 생성
  2. Postman으로 다음 요청 전송:
POST http://localhost:8080/api/articles/restore?date=2025-06-02
  1. 복구된 뉴스는 DB에 바로 insert됨

향후 계획: AWS S3 연동

  • 현재는 LocalBackupStorage를 사용하지만, 이후 S3BackupStorage로 교체 예정
@Component
public class S3BackupStorage implements BackupStorage {
    public List<NewsBackupDto> loadBackup(LocalDate date) {
        // S3에서 json 다운로드 후 복원
    }
}
  • AWS S3 설정 (region, bucket, prefix) 기반으로 동작
  • Spring Profile 또는 config 설정으로 교체 가능

주요 고민과 해결

고민 해결 방법
S3 연동 전, 테스트 데이터를 어떻게 처리할까? 로컬 백업 JSON을 만들어 LocalBackupStorage 구현
관심사 ID가 없을 경우 예외 처리 방법은? IllegalArgumentException("관심사 없음") 명시
S3 연동은 어떻게 확장 가능할까? 공통 인터페이스 도입 + S3Client 설정 클래스 분리

📌 뉴스 수집 기능 구현

자세히 보기

개요

키워드 기반의 관심 뉴스 기사를 자동으로 수집하여 데이터베이스에 저장하는 기능
다양한 출처를 활용하여 RSS 또는 외부 API를 통해 주기적으로 뉴스를 수집하며,
불필요한 기사나 중복된 내용을 걸러내고, 요약 정보(summary) 정제를 통해 콘텐츠 품질을 확보하였음


주요 수집 출처

출처 방식 URL 예시
한국경제 RSS https://www.hankyung.com/feed/all-news
조선일보 RSS https://www.chosun.com/arc/outboundfeeds/rss/?outputType=xml
연합뉴스TV RSS http://www.yonhapnewstv.co.kr/category/news/headline/feed/
네이버 뉴스 OpenAPI https://openapi.naver.com/v1/search/news.json

기능별 구현 내용

RSS 기반 뉴스 수집 (RssCollector)

  • Jsoup을 활용하여 각 언론사의 RSS 링크에서 <item> 요소 추출
  • title, link, pubDate, description을 파싱하여 NewsArticleRequestDto 생성
  • 수집된 기사 중 관심사 키워드를 포함한 기사만 저장
  • 저장 전 중복 여부 검사 (originalLink 기준)

장점: 빠르고 간편하게 각 언론사 기사 수집 가능, 인증 없이 접근 가능
주의: HTML 태그 포함된 요약, 광고/링크 기반 요약에 대한 정제 필요


네이버 뉴스 OpenAPI 수집 (NaverApiCollector)

  • 관심 키워드를 기반으로 네이버 뉴스 검색 API 호출 (query, display=10, sort=date)
  • API 인증을 위해 X-Naver-Client-Id, X-Naver-Client-Secret 헤더 설정
  • 응답받은 JSON을 NaverNewsItem으로 매핑 후 toDto() 통해 변환하여 저장

장점: 최신 뉴스 기반, 키워드 중심 검색 가능
주의: 요약(description)에 광고/HTML/링크 등 불필요한 내용 포함 가능 → 정제 필수


summary 정제 로직

  • HTML 태그 제거 (Jsoup)
  • HTML 엔티티 디코딩 (&quot;")
  • URL 디코딩 (%EC%8B%9C)
  • 불필요한 링크, "더보기", "파일보기", ".pdf", 언론사명만 있는 텍스트 제거
  • 일정 길이 미만 또는 특수문자/숫자만 있는 요약 제거

정제 메서드: cleanSummary(String input)


환경 변수 설정

  • 민감한 정보(API Key)는 코드에 직접 작성하지 않고 환경변수로 주입
  • application.yml 내 설정 예시:
naver:
  api:
    client-id: ${NAVER_API_CLIENT_ID}
    client-secret: ${NAVER_API_CLIENT_SECRET}
  • 로컬 개발 시 .env 또는 IntelliJ의 Run Configurations를 통해 환경변수 수동 지정
  • .env.example 파일 제공하여 협업 시 키 이름 공유

엔티티 개선 (NewsArticle)

기존 문제

  • summary 필드를 @Lob으로 선언해 PostgreSQL에서 oid 타입으로 생성
  • 실제 summary 내용이 저장되지 않고, 숫자 형태의 LOB ID만 저장됨

개선 사항

private String summary;

향후 개선/확장 아이디어

  • .env 자동 로딩 라이브러리 도입
  • 관심 키워드 기반 뉴스 추천 기능 확장
  • 뉴스 요약 자동화 (OpenAI 요약 모델 연계 가능)

⚠️ **GitHub.com Fallback** ⚠️