뉴스기사 관리 - MonewLabs/monew-web GitHub Wiki
자세히 보기
사용자가 특정 뉴스 기사를 처음 조회했을 때만 기사 조회 기록을 저장하고,
중복되지 않게 조회수를 1회 증가시키는 기능
POST /api/articles/{articleId}/article-views
위치 | 이름 | 타입 | 필수 | 설명 |
---|---|---|---|---|
Path | articleId |
UUID |
✅ | 조회 대상 뉴스 기사 ID |
Header | MoNew-Request-User-ID |
UUID |
✅ | 요청자(사용자) ID |
{
"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
}
- 사용자가 기사를 조회하면 해당
articleId
,userId
조합으로 조회 기록(ArticleView
)을 조회 - 처음 조회한 사용자일 경우:
-
ArticleView
저장 NewsArticle.viewCount++
-
- 이미 조회한 사용자일 경우:
- 아무것도 저장하지 않음
- 클라이언트에는 항상
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를 설계하고 구현하였습니다.
- 제목/요약 키워드 검색
- 관심사 ID, 출처 목록, 날짜 범위 필터링
- 정렬 기준:
publishDate
,commentCount
,viewCount
- 정렬 방향:
ASC
,DESC
- 커서 기반 페이지네이션 지원 (
after
기반) - 응답 DTO:
CursorPageResponseArticleDto
예시 쿼리 파라미터
GET /api/articles?orderBy=publishDate&direction=DESC&after=2024-05-01T00:00:00
- 요청 파라미터 수신 (
@RequestParam
,@RequestHeader
) - 정렬 필드 등 유효성 검사
- 서비스 호출 및 응답 반환
- 동적 조건 필터링
- 커서 페이지네이션 처리 (
hasNext
,nextCursor
,nextAfter
) - Mapper를 활용한 DTO 변환
-
NewsArticle
→ArticleDto
변환 - 재사용 가능한 컴포넌트
구성요소 | 설명 |
---|---|
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는 프론트엔드에서 기사 필터링 조건으로 사용됩니다.
예: 드롭다운에서 "NAVER", "연합뉴스" 등의 출처를 선택하여 기사 필터링
-
메서드:
GET
-
URL:
/api/articles/sources
{
"sources": [
"NAVER",
"연합뉴스",
"조선일보"
]
}
public record SourcesResponseDto(
List<String> sources
) {}
@Query("SELECT DISTINCT n.source FROM NewsArticle n WHERE n.deleted = false")
List<String> findDistinctSources();
@Transactional(readOnly = true)
public SourcesResponseDto getSources() {
List<String> sources = newsArticleRepository.findDistinctSources();
return new SourcesResponseDto(sources);
}
@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 제공
메서드 | URL |
---|---|
DELETE |
/api/articles/{id}/hard |
이름 | 타입 | 설명 |
---|---|---|
id |
UUID | 삭제 대상 뉴스 기사 ID |
-
204 No Content
: 삭제 성공 -
404 Not Found
: 해당 ID의 뉴스 기사가 존재하지 않을 경우
예외 상황 | 응답 코드 | 예외 구조 |
---|---|---|
존재하지 않는 ID | 404 |
RESOURCE_NOT_FOUND + details.parameter = articleId
|
항목 | 설명 |
---|---|
단위 테스트 | 별도 작성 예정 |
통합 테스트 |
NewsArticleControllerTest 내 성공/실패 테스트 작성 완료 |
- 존재하는 기사 ID 요청 시
204 No Content
- 존재하지 않는 ID 요청 시
404 Not Found
+ 에러 JSON
자세히 보기
뉴스 기사 데이터를 삭제하지 않고 deleted
플래그만 true로 설정하여 사용자에게 보이지 않도록 처리하는 기능입니다.
복구 가능성과 데이터 추적을 위해 실제 DB에서 삭제하지 않습니다.
-
메서드:
DELETE
-
URL:
/api/articles/{id}
위치 | 이름 | 타입 | 필수 | 설명 |
---|---|---|---|---|
Path | id |
UUID |
✅ | 논리 삭제할 뉴스 기사 ID |
-
204 No Content
: 삭제 성공 -
404 Not Found
: 해당 ID의 기사 없음 (또는 이미 삭제됨)
@Column(nullable = false)
private boolean deleted = false;
public void markAsDeleted() {
this.deleted = true;
}
Optional<NewsArticle> findByIdAndDeletedFalse(UUID id);
@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();
}
@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
}
- 삭제 성공 시:
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에 저장된 백업 파일을 기반으로 확장 가능하도록 설계
public interface BackupStorage {
List<NewsBackupDto> loadBackup(LocalDate date);
}
-
NewsBackupDto
: 복원 대상 뉴스 정보를 담는 DTO - 구현체만 바꿔 끼우면 Local → S3 로 유연하게 전환 가능
@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 없이도 복원 가능하도록 구성
@PostMapping("/api/articles/restore")
public ResponseEntity<Void> restoreArticles(@RequestParam LocalDate date) {
newsArticleService.restore(date);
return ResponseEntity.ok().build();
}
- 해당 날짜의 백업 파일을 로딩하고, 기존 뉴스와 비교하여 누락된 뉴스만 복구
-
interestId
유효성 검사를 포함
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가 존재하지 않으면 예외 발생
-
mock-backup/news-2025-06-02.json
생성 - Postman으로 다음 요청 전송:
POST http://localhost:8080/api/articles/restore?date=2025-06-02
- 복구된 뉴스는 DB에 바로 insert됨
- 현재는
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 |
- Jsoup을 활용하여 각 언론사의 RSS 링크에서
<item>
요소 추출 -
title
,link
,pubDate
,description
을 파싱하여NewsArticleRequestDto
생성 - 수집된 기사 중
관심사 키워드
를 포함한 기사만 저장 - 저장 전 중복 여부 검사 (
originalLink
기준)
장점: 빠르고 간편하게 각 언론사 기사 수집 가능, 인증 없이 접근 가능
주의: HTML 태그 포함된 요약, 광고/링크 기반 요약에 대한 정제 필요
- 관심 키워드를 기반으로 네이버 뉴스 검색 API 호출 (
query
,display=10
,sort=date
) - API 인증을 위해
X-Naver-Client-Id
,X-Naver-Client-Secret
헤더 설정 - 응답받은 JSON을
NaverNewsItem
으로 매핑 후toDto()
통해 변환하여 저장
장점: 최신 뉴스 기반, 키워드 중심 검색 가능
주의: 요약(description)에 광고/HTML/링크 등 불필요한 내용 포함 가능 → 정제 필수
- HTML 태그 제거 (Jsoup)
- HTML 엔티티 디코딩 (
"
→"
) - 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 파일 제공하여 협업 시 키 이름 공유
- summary 필드를 @Lob으로 선언해 PostgreSQL에서 oid 타입으로 생성
- 실제 summary 내용이 저장되지 않고, 숫자 형태의 LOB ID만 저장됨
private String summary;
- .env 자동 로딩 라이브러리 도입
- 관심 키워드 기반 뉴스 추천 기능 확장
- 뉴스 요약 자동화 (OpenAI 요약 모델 연계 가능)