From cd7a0c51fff32e3932b269113505e636942c9276 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 10:11:26 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20Processor=20DB=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=EA=B1=B0,=20Writer=20IN=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=91=EB=B3=B5=20=EC=B2=B4=ED=81=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/config/BatchConfig.java | 6 ++-- .../processor/SportsDbEventItemProcessor.java | 7 ----- .../processor/TmdbMovieItemProcessor.java | 7 ----- .../processor/TmdbTvSeriesItemProcessor.java | 7 ----- .../batch/writer/ContentItemWriter.java | 28 +++++++++++-------- .../content/repository/ContentRepository.java | 10 +++++++ 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java b/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java index 69d7db48..dbf48dc3 100644 --- a/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java @@ -102,7 +102,7 @@ public TmdbMovieItemReader tmdbMovieItemReader() { @Bean public TmdbMovieItemProcessor tmdbMovieItemProcessor() { - return new TmdbMovieItemProcessor(contentRepository, objectMapper); + return new TmdbMovieItemProcessor(objectMapper); } // ── TMDB TV 시리즈 Job ───────────────────────────────────────── @@ -136,7 +136,7 @@ public TmdbTvSeriesItemReader tmdbTvSeriesItemReader() { @Bean public TmdbTvSeriesItemProcessor tmdbTvSeriesItemProcessor() { - return new TmdbTvSeriesItemProcessor(contentRepository, objectMapper); + return new TmdbTvSeriesItemProcessor(objectMapper); } // ── SportsDB 리그-시즌 Job ─────────────────────────────────────────────── @@ -170,7 +170,7 @@ public SportsDbEventItemReader sportsDbEventItemReader() { @Bean public SportsDbEventItemProcessor sportsDbEventItemProcessor() { - return new SportsDbEventItemProcessor(contentRepository, objectMapper); + return new SportsDbEventItemProcessor(objectMapper); } // ── SportsDB 일별 Job ────────────────────────────────────────── diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java b/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java index df5298cf..8b33d336 100644 --- a/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java +++ b/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java @@ -5,7 +5,6 @@ import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -21,16 +20,10 @@ @RequiredArgsConstructor public class SportsDbEventItemProcessor implements ItemProcessor { - private final ContentRepository contentRepository; private final ObjectMapper objectMapper; @Override public ContentWithMetaData process(SportsDbEventDto dto) { - if (contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, dto.idEvent())) { - log.debug("[SportsDB] 경기 스킵 (이미 존재) - idEvent={}, event={}", dto.idEvent(), dto.strEvent()); - return null; - } - Content content = Content.createByExternalSource( ContentType.SPORT, dto.strEvent(), diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java index 76b2fe55..87f1291c 100644 --- a/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java +++ b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java @@ -6,7 +6,6 @@ import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,16 +22,10 @@ public class TmdbMovieItemProcessor implements ItemProcessor { @Override public void write(Chunk chunk) { - List items = new java.util.ArrayList<>(chunk.getItems()); + List items = List.copyOf(chunk.getItems()); + + // 1. DB에 이미 존재하는 externalId 조회 후 신규 항목만 필터 (SELECT 1번) + List externalIds = items.stream() + .map(item -> item.content().getExternalId()) + .toList(); + ContentSource source = items.get(0).content().getSource(); + Set existingIds = contentRepository.findExternalIdsBySourceAndExternalIdIn(source, externalIds); - // 1. Content 일괄 저장 (청크 내 externalId 중복 제거 — TMDB가 페이지 간 동일 영화를 중복 반환하는 경우 대비) List deduplicatedItems = items.stream() - .collect(Collectors.toMap( - item -> item.content().getExternalId(), - Function.identity(), - (a, b) -> a, - LinkedHashMap::new - )) - .values() - .stream() + .filter(item -> !existingIds.contains(item.content().getExternalId())) .toList(); + if (deduplicatedItems.isEmpty()) { + log.info("[Batch] 신규 항목 없음 — 저장 생략 (청크 원본: {}건)", items.size()); + return; + } + List contents = deduplicatedItems.stream() .map(ContentWithMetaData::content) .toList(); diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index 34d4176f..6a3aa87f 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -3,10 +3,14 @@ import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.repository.querydsl.ContentQueryRepository; +import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ContentRepository extends JpaRepository, ContentQueryRepository { @@ -14,4 +18,10 @@ public interface ContentRepository extends JpaRepository, Content Optional findWithStatsAndTagsById(UUID id); boolean existsBySourceAndExternalId(ContentSource source, String externalId); + + @Query("SELECT c.externalId FROM Content c WHERE c.source = :source AND c.externalId IN :externalIds") + Set findExternalIdsBySourceAndExternalIdIn( + @Param("source") ContentSource source, + @Param("externalIds") List externalIds + ); } From 331da8316ec240c82a8b61ccab741a8d5744f586 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 10:17:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?test:=20Processor/Writer=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20-=20Processo?= =?UTF-8?q?rTest:=20ContentRepository=20mock=20=EB=B0=8F=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0=20-=20Conte?= =?UTF-8?q?ntItemWriterTest:=20IN=20=EC=BF=BC=EB=A6=AC=20stub=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20DB=20=EA=B8=B0=EB=B0=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0,=20early=20return=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SportsDbEventItemProcessorTest.java | 32 +------------- .../processor/TmdbMovieItemProcessorTest.java | 30 +------------ .../TmdbTvSeriesItemProcessorTest.java | 29 +------------ .../batch/writer/ContentItemWriterTest.java | 43 ++++++++++++++++--- 4 files changed, 42 insertions(+), 92 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java index fa622e9a..b498f2ad 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java @@ -1,58 +1,32 @@ package com.codeit.team5.mopl.content.batch.processor; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) class SportsDbEventItemProcessorTest { - @Mock - private ContentRepository contentRepository; - private SportsDbEventItemProcessor processor; @BeforeEach void setUp() { - processor = new SportsDbEventItemProcessor(contentRepository, new ObjectMapper()); - } - - @Test - @DisplayName("이미 존재하는 경기면 null을 반환한다") - void process_duplicate_returnsNull() throws Exception { - // given - SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea", - "https://thumb.jpg", "2024-12-26", "English Premier League", - "Soccer", "Arsenal", "Chelsea", "2", "1"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(true); - - // when - ContentWithMetaData result = processor.process(dto); - - // then - assertThat(result).isNull(); + processor = new SportsDbEventItemProcessor(new ObjectMapper()); } @Test - @DisplayName("새로운 경기면 ContentWithMetaData를 반환한다") + @DisplayName("경기 DTO를 ContentWithMetaData로 변환한다") void process_newEvent_returnsContentWithMetaData() throws Exception { // given SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea", "https://thumb.jpg", "2024-12-26", "English Premier League", "Soccer", "Arsenal", "Chelsea", "2", "1"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -72,7 +46,6 @@ void process_leagueName_normalizedToLowerCase() throws Exception { SportsDbEventDto dto = new SportsDbEventDto("456", "Arsenal vs Chelsea", null, "2024-12-26", "English Premier League", "Soccer", "Arsenal", "Chelsea", null, null); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "456")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -89,7 +62,6 @@ void process_noScore_descriptionContainsVs() throws Exception { SportsDbEventDto dto = new SportsDbEventDto("789", "Arsenal vs Chelsea", null, "2024-12-26", "English Premier League", "Soccer", "Arsenal", "Chelsea", null, null); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "789")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java index 3a40769b..fc85a4ca 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java @@ -1,48 +1,24 @@ package com.codeit.team5.mopl.content.batch.processor; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) class TmdbMovieItemProcessorTest { - @Mock - private ContentRepository contentRepository; - private TmdbMovieItemProcessor processor; @BeforeEach void setUp() { - processor = new TmdbMovieItemProcessor(contentRepository, new ObjectMapper()); - } - - @Test - @DisplayName("이미 존재하는 영화면 null을 반환한다") - void process_duplicate_returnsNull() throws Exception { - // given - TmdbMovieDto dto = new TmdbMovieDto(1L, "인터스텔라", "Interstellar", "overview", - "/poster.jpg", List.of(18L), "2014-11-05", 8.6, "en"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(true); - - // when - ContentWithMetaData result = processor.process(dto); - - // then - assertThat(result).isNull(); + processor = new TmdbMovieItemProcessor(new ObjectMapper()); } @Test @@ -51,7 +27,6 @@ void process_newMovie_returnsContentWithMetaData() throws Exception { // given TmdbMovieDto dto = new TmdbMovieDto(1L, "인터스텔라", "Interstellar", "overview", "/poster.jpg", List.of(18L), "2014-11-05", 8.6, "en"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -70,7 +45,6 @@ void process_nullPosterPath_thumbnailUrlIsNull() throws Exception { // given TmdbMovieDto dto = new TmdbMovieDto(2L, "포스터없는영화", "No Poster", "overview", null, List.of(), "2020-01-01", 7.0, "ko"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "2")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -86,7 +60,6 @@ void process_validGenreId_tagNamesIncluded() throws Exception { // given TmdbMovieDto dto = new TmdbMovieDto(3L, "드라마영화", "Drama", "overview", null, List.of(18L), "2020-01-01", 7.0, "ko"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "3")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -102,7 +75,6 @@ void process_nullGenreIds_tagNamesEmpty() throws Exception { // given TmdbMovieDto dto = new TmdbMovieDto(4L, "장르없는영화", "NoGenre", "overview", null, null, "2020-01-01", 7.0, "ko"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "4")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java index 11570392..e9a69154 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java @@ -1,48 +1,24 @@ package com.codeit.team5.mopl.content.batch.processor; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; import com.codeit.team5.mopl.content.entity.ContentSource; import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) class TmdbTvSeriesItemProcessorTest { - @Mock - private ContentRepository contentRepository; - private TmdbTvSeriesItemProcessor processor; @BeforeEach void setUp() { - processor = new TmdbTvSeriesItemProcessor(contentRepository, new ObjectMapper()); - } - - @Test - @DisplayName("이미 존재하는 TV 시리즈면 null을 반환한다") - void process_duplicate_returnsNull() throws Exception { - // given - TmdbTvDto dto = new TmdbTvDto(1L, "브레이킹 배드", "Breaking Bad", "overview", - "/poster.jpg", List.of(18L), "2008-01-20", 9.5, "en"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(true); - - // when - ContentWithMetaData result = processor.process(dto); - - // then - assertThat(result).isNull(); + processor = new TmdbTvSeriesItemProcessor(new ObjectMapper()); } @Test @@ -51,7 +27,6 @@ void process_newTvSeries_returnsContentWithMetaData() throws Exception { // given TmdbTvDto dto = new TmdbTvDto(1L, "브레이킹 배드", "Breaking Bad", "overview", "/poster.jpg", List.of(18L), "2008-01-20", 9.5, "en"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -70,7 +45,6 @@ void process_nullPosterPath_thumbnailUrlIsNull() throws Exception { // given TmdbTvDto dto = new TmdbTvDto(2L, "포스터없는시리즈", "No Poster", "overview", null, List.of(), "2020-01-01", 7.0, "ko"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "2")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); @@ -86,7 +60,6 @@ void process_nullGenreIds_tagNamesEmpty() throws Exception { // given TmdbTvDto dto = new TmdbTvDto(3L, "장르없는시리즈", "NoGenre", "overview", null, null, "2020-01-01", 7.0, "ko"); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "3")).willReturn(false); // when ContentWithMetaData result = processor.process(dto); diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java index d4ec9040..33317b41 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.content.batch.writer; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; @@ -18,6 +19,7 @@ import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; import java.util.List; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -59,6 +61,7 @@ void write_savesAllContents() throws Exception { ContentSource.TMDB, "2", null, "{}"); ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); ContentWithMetaData item2 = new ContentWithMetaData(content2, null, List.of()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content1, content2)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); @@ -71,15 +74,17 @@ void write_savesAllContents() throws Exception { } @Test - @DisplayName("청크 내 externalId 중복이 있으면 첫 번째 항목만 저장한다") - void write_deduplicatesChunkByExternalId() throws Exception { + @DisplayName("DB에 이미 존재하는 externalId는 저장하지 않는다") + void write_deduplicatesAgainstDb() throws Exception { // given Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", ContentSource.TMDB, "1", null, "{}"); - Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화1-중복", "desc", - ContentSource.TMDB, "1", null, "{}"); + Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화2", "desc", + ContentSource.TMDB, "2", null, "{}"); ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); ContentWithMetaData item2 = new ContentWithMetaData(content2, null, List.of()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())) + .willReturn(Set.of("1")); // content1은 이미 존재 given(contentRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); @@ -87,7 +92,29 @@ void write_deduplicatesChunkByExternalId() throws Exception { writer.write(new Chunk<>(List.of(item1, item2))); // then - verify(contentRepository).saveAll(List.of(content1)); + verify(contentRepository).saveAll(List.of(content2)); + } + + @Test + @DisplayName("청크 내 모든 항목이 DB에 이미 존재하면 저장을 생략한다") + void write_allExistingInDb_skipsAllSaves() throws Exception { + // given + Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화2", "desc", + ContentSource.TMDB, "2", null, "{}"); + ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); + ContentWithMetaData item2 = new ContentWithMetaData(content2, null, List.of()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())) + .willReturn(Set.of("1", "2")); + + // when + writer.write(new Chunk<>(List.of(item1, item2))); + + // then + verify(contentRepository, never()).saveAll(anyList()); + verify(contentStatsRepository, never()).saveAll(anyList()); + verify(binaryContentRepository, never()).saveAll(anyList()); } @Test @@ -98,6 +125,7 @@ void write_savesThumbnailsAndAttachesToContent() throws Exception { ContentSource.TMDB, "1", null, "{}"); ContentWithMetaData item = new ContentWithMetaData(content, "https://img.example.com/poster.jpg", List.of()); BinaryContent savedThumbnail = BinaryContent.externalUrl("https://img.example.com/poster.jpg"); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); given(binaryContentRepository.saveAll(anyList())).willReturn(List.of(savedThumbnail)); @@ -117,6 +145,7 @@ void write_skipsThumbnailWhenUrlIsNull() throws Exception { Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", ContentSource.TMDB, "1", null, "{}"); ContentWithMetaData item = new ContentWithMetaData(content, null, List.of()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); @@ -140,6 +169,7 @@ void write_sameThumbnailUrl_attachesSeparateBinaryContentToEach() throws Excepti ContentWithMetaData item2 = new ContentWithMetaData(content2, sharedUrl, List.of()); BinaryContent savedThumbnail1 = BinaryContent.externalUrl(sharedUrl); BinaryContent savedThumbnail2 = BinaryContent.externalUrl(sharedUrl); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content1, content2)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); given(binaryContentRepository.saveAll(anyList())).willReturn(List.of(savedThumbnail1, savedThumbnail2)); @@ -163,6 +193,7 @@ void write_existingTag_reusesWithoutCreatingNew() throws Exception { ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("액션")); Tag existingTag = Tag.create("액션"); ReflectionTestUtils.setField(existingTag, "id", UUID.randomUUID()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); given(tagRepository.findByNameIn(List.of("액션"))).willReturn(List.of(existingTag)); @@ -185,6 +216,7 @@ void write_newTag_createsAndSaves() throws Exception { ContentSource.TMDB, "1", null, "{}"); ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("드라마")); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); given(tagRepository.findByNameIn(List.of("드라마"))).willReturn(List.of()); @@ -216,6 +248,7 @@ void write_mixedExistingAndNewTags_allAttachedAsContentTag() throws Exception { ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("액션", "드라마")); Tag existingTag = Tag.create("액션"); ReflectionTestUtils.setField(existingTag, "id", UUID.randomUUID()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())).willReturn(Set.of()); given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); given(tagRepository.findByNameIn(List.of("액션", "드라마"))).willReturn(List.of(existingTag)); From 11330a2201ba41a45572f0ed72523396a0a6b094 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 10:30:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=BD=98=ED=85=90=EC=B8=A0=20API?= =?UTF-8?q?=20=EC=9D=B8=EA=B0=80=20=EC=A0=81=EC=9A=A9=20-=20SecurityConfig?= =?UTF-8?q?=EC=97=90=20=EA=B2=BD=EB=A1=9C=20=EA=B7=9C=EC=B9=99=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20EnableMethodSecurity=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20-=20ContentController=20=EA=B0=81=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EC=97=90=20PreAuthorize=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20-=20ContentCollectionController=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EB=B2=A8=EC=97=90=20PreAuthorize=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/config/SecurityConfig.java | 8 ++++++++ .../content/controller/ContentCollectionController.java | 2 ++ .../team5/mopl/content/controller/ContentController.java | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index 8bb3bc43..1a2b9a7c 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -15,6 +15,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -26,6 +27,7 @@ @Configuration @EnableWebSecurity +@EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -68,6 +70,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/api/users/*").authenticated() .requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated() + .requestMatchers(HttpMethod.GET, "/api/contents", "/api/contents/*").authenticated() + .requestMatchers(HttpMethod.POST, "/api/contents").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/contents/*").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/contents/*").hasRole("ADMIN") + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/users/**").authenticated() .requestMatchers("/api/follows/**").authenticated() .requestMatchers("/api/notifications/**").authenticated() diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java index 39cd681b..0269b966 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java @@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; @@ -24,6 +25,7 @@ @Slf4j @Validated +@PreAuthorize("hasRole('ADMIN')") @RestController @RequestMapping("/api/admin/contents/collect") public class ContentCollectionController implements ContentCollectionApi { diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java index a33af4e6..d6c60360 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -55,6 +56,7 @@ public void setAsText(String text) { } @Override + @PreAuthorize("hasRole('ADMIN')") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity postContent( @Valid @RequestPart("request") ContentCreateRequest request, @@ -66,6 +68,7 @@ public ResponseEntity postContent( } @Override + @PreAuthorize("hasRole('ADMIN')") @PatchMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity patchContent( @PathVariable UUID contentId, @@ -94,6 +97,7 @@ public ResponseEntity> getContents(@Valid Conten } @Override + @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/{contentId}") public ResponseEntity deleteContent(@PathVariable UUID contentId) { log.info("Content Delete request: DELETE /api/contents/{}", contentId); From d259cebb99dc515313b603a29e6cdf9f03130644 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 11:06:10 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EB=B9=88=20?= =?UTF-8?q?=EC=B2=AD=ED=81=AC=20=EB=B0=98=ED=99=98=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20ContentRepos?= =?UTF-8?q?itory=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/batch/writer/ContentItemWriter.java | 5 +++++ .../mopl/content/repository/ContentRepository.java | 2 -- .../content/batch/writer/ContentItemWriterTest.java | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java index f1cf9833..b4088c78 100644 --- a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java +++ b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java @@ -35,6 +35,11 @@ public class ContentItemWriter implements ItemWriter { public void write(Chunk chunk) { List items = List.copyOf(chunk.getItems()); + if (items.isEmpty()) { + log.debug("[Batch] 빈 청크 수신 — 저장 생략"); + return; + } + // 1. DB에 이미 존재하는 externalId 조회 후 신규 항목만 필터 (SELECT 1번) List externalIds = items.stream() .map(item -> item.content().getExternalId()) diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index 6a3aa87f..fd6d2d25 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -17,8 +17,6 @@ public interface ContentRepository extends JpaRepository, Content @EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"}) Optional findWithStatsAndTagsById(UUID id); - boolean existsBySourceAndExternalId(ContentSource source, String externalId); - @Query("SELECT c.externalId FROM Content c WHERE c.source = :source AND c.externalId IN :externalIds") Set findExternalIdsBySourceAndExternalIdIn( @Param("source") ContentSource source, diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java index 33317b41..b749a81b 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java @@ -95,6 +95,17 @@ void write_deduplicatesAgainstDb() throws Exception { verify(contentRepository).saveAll(List.of(content2)); } + @Test + @DisplayName("빈 청크가 전달되면 예외 없이 즉시 반환한다") + void write_emptyChunk_doesNothing() throws Exception { + // when + writer.write(new Chunk<>(List.of())); + + // then + verify(contentRepository, never()).findExternalIdsBySourceAndExternalIdIn(any(), anyList()); + verify(contentRepository, never()).saveAll(anyList()); + } + @Test @DisplayName("청크 내 모든 항목이 DB에 이미 존재하면 저장을 생략한다") void write_allExistingInDb_skipsAllSaves() throws Exception { From fe0880524636c17ce685766d7061c1b244dd2cc7 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 13:07:19 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20ContentItemWriter=20List.copyOf=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/batch/writer/ContentItemWriter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java index b4088c78..4a29a730 100644 --- a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java +++ b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java @@ -33,7 +33,7 @@ public class ContentItemWriter implements ItemWriter { @Override public void write(Chunk chunk) { - List items = List.copyOf(chunk.getItems()); + List items = chunk.getItems(); if (items.isEmpty()) { log.debug("[Batch] 빈 청크 수신 — 저장 생략"); @@ -47,7 +47,7 @@ public void write(Chunk chunk) { ContentSource source = items.get(0).content().getSource(); Set existingIds = contentRepository.findExternalIdsBySourceAndExternalIdIn(source, externalIds); - List deduplicatedItems = items.stream() + List deduplicatedItems = items.stream() .filter(item -> !existingIds.contains(item.content().getExternalId())) .toList(); @@ -70,7 +70,7 @@ public void write(Chunk chunk) { // 3. 썸네일 저장 (아이템별로 별도 BinaryContent를 저장 — thumbnail_id는 1:1 유니크 제약이라 // 서로 다른 콘텐츠가 같은 thumbnailUrl을 갖더라도 BinaryContent를 공유할 수 없다. // saveAll은 입력 순서를 보존해 반환하므로 인덱스로 1:1 매칭한다. - List itemsWithThumbnail = deduplicatedItems.stream() + List itemsWithThumbnail = deduplicatedItems.stream() .filter(item -> StringUtils.hasText(item.thumbnailUrl())) .toList(); if (!itemsWithThumbnail.isEmpty()) { From 351d8f22c6e30c1d9ce6f17d0e560089811fdb90 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 3 Jul 2026 13:44:41 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=B2=AD=ED=81=AC=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20externalId=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/writer/ContentItemWriter.java | 26 ++++++++++++++----- .../batch/writer/ContentItemWriterTest.java | 22 ++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java index 4a29a730..b06b13fc 100644 --- a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java +++ b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java @@ -11,6 +11,8 @@ import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -40,14 +42,26 @@ public void write(Chunk chunk) { return; } - // 1. DB에 이미 존재하는 externalId 조회 후 신규 항목만 필터 (SELECT 1번) - List externalIds = items.stream() - .map(item -> item.content().getExternalId()) - .toList(); - ContentSource source = items.get(0).content().getSource(); + // 1. 청크 내부 externalId 중복 제거 + // TMDB는 인기순 정렬 특성상 페이지 경계에서 같은 항목이 다음 페이지에 다시 나올 수 있어, + // 한 청크 안에 동일 externalId가 들어올 수 있다. contents(source, external_id) 유니크 제약에 + // 걸려 청크 전체가 롤백되는 것을 막기 위해 저장 전에 externalId 기준으로 먼저 걸러낸다. + // LinkedHashMap 병합 함수로 "먼저 온 항목 유지" 기준을 명시하고 원본 순서를 보존한다. + Map uniqueByExternalId = items.stream() + .collect(Collectors.toMap( + item -> item.content().getExternalId(), + item -> (ContentWithMetaData) item, + (first, second) -> first, + LinkedHashMap::new + )); + Collection uniqueItems = uniqueByExternalId.values(); + + // 2. DB에 이미 존재하는 externalId 조회 후 신규 항목만 필터 (SELECT 1번) + List externalIds = uniqueByExternalId.keySet().stream().toList(); + ContentSource source = uniqueItems.iterator().next().content().getSource(); Set existingIds = contentRepository.findExternalIdsBySourceAndExternalIdIn(source, externalIds); - List deduplicatedItems = items.stream() + List deduplicatedItems = uniqueItems.stream() .filter(item -> !existingIds.contains(item.content().getExternalId())) .toList(); diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java index b749a81b..78e75a0b 100644 --- a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java @@ -95,6 +95,28 @@ void write_deduplicatesAgainstDb() throws Exception { verify(contentRepository).saveAll(List.of(content2)); } + @Test + @DisplayName("같은 청크에 동일 externalId가 두 건 있으면 먼저 온 항목만 저장한다") + void write_deduplicatesWithinChunkByExternalId() throws Exception { + // given + Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + Content content1Dup = Content.createByExternalSource(ContentType.MOVIE, "영화1-중복", "desc", + ContentSource.TMDB, "1", null, "{}"); + ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); + ContentWithMetaData item1Dup = new ContentWithMetaData(content1Dup, null, List.of()); + given(contentRepository.findExternalIdsBySourceAndExternalIdIn(any(), anyList())) + .willReturn(Set.of()); // DB에는 없음 + given(contentRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + + // when + writer.write(new Chunk<>(List.of(item1, item1Dup))); + + // then — 먼저 온 content1만 저장 (유니크 제약 위반 방지) + verify(contentRepository).saveAll(List.of(content1)); + } + @Test @DisplayName("빈 청크가 전달되면 예외 없이 즉시 반환한다") void write_emptyChunk_doesNothing() throws Exception {