Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6a7244e
feat: Spring Batch 의존성 추가 및 Spring Batch 메타데이터 테이블 추가
plzslp Jun 30, 2026
b7302ba
feat: Spring Batch 콘텐츠 수집 Reader 구현
plzslp Jul 1, 2026
4ead325
feat: Spring Batch 콘텐츠 수집 Processor 구현
plzslp Jul 1, 2026
71d2f10
feat: TMDB/SportsDB 공통 ContentItemWriter 추가
plzslp Jul 1, 2026
1f012a4
feat: BatchConfig 추가
plzslp Jul 1, 2026
bb07e0a
feat: SportsDB 일별 경기 수집 배치 추가
plzslp Jul 1, 2026
ebf8084
feat: Spring Batch 콘텐츠 수집 스케줄러 구현
plzslp Jul 1, 2026
cfec809
refactor: 스케줄링 설정 분리 및 배치 비동기 실행 환경 구성
plzslp Jul 1, 2026
1118d42
refactor: 콘텐츠 수집 로직을 Async 서비스에서 Spring Batch Job으로 전환
plzslp Jul 1, 2026
4ad169c
refactor: ContentCollectionControllerTest 리펙토링
plzslp Jul 1, 2026
f580580
feat: 콘텐츠 수집 엔드포인트 입력값 검증 추가
plzslp Jul 1, 2026
2e71aad
refactor: Writer 로직에서 externalId 중복 제거 로직 추가
plzslp Jul 1, 2026
4518fbc
feat: SportsDB 일별 수집에 날짜 파라미터 추가 및 엔드포인트 추가
plzslp Jul 1, 2026
4e50848
refactor: PageRangeRequest 파라미터를 필수값으로 변경
plzslp Jul 1, 2026
e168306
test: ItemReader 테스트 코드 작성
plzslp Jul 1, 2026
f0e8590
test: ItemProcessor 테스트 코드 작성
plzslp Jul 1, 2026
933d69c
test: ItemWriter 테스트 코드 작성
plzslp Jul 1, 2026
f8b67ba
fix: ContentCollectionScheduler도 비동기 처리로 수정
plzslp Jul 1, 2026
9df8d67
fix: TMDB Reader 빈 페이지에서 Job 조기 종료 버그 수정
plzslp Jul 1, 2026
4248686
refactor: ContentItemWriter 썸네일 저장 방식 개선 및 dead code 제거
plzslp Jul 1, 2026
ed86c25
docs: 주석 수정
plzslp Jul 1, 2026
06fb5ba
refactor: 배치 Job 실행 스레드 관리 및 Reader 상태 격리 개선
plzslp Jul 2, 2026
e0b5d2c
fix: SportsDB 일별 수집 date 파라미터가 실존하지 않는 날짜를 통과시키는 문제 수정
plzslp Jul 2, 2026
41d3fcf
feat: SportsDB 일별 수집 리그별 재시도 및 상태코드 기반 재시도 정책 추가
plzslp Jul 2, 2026
dd5a877
fix: TMDB 빈 페이지 응답 시 조기 종료하도록 수정
plzslp Jul 2, 2026
f0d34c4
fix: Scheduled, LocalDate.now에 타임 존 설정 추가
plzslp Jul 2, 2026
8ac9103
fix: 중복 썸네일은 별도의 BinaryContent로 연결되도록 수정
plzslp Jul 2, 2026
0ee5494
fix: Job 실행 실패 시 예외를 삼키지 않고 BatchJobLaunchException으로 던짐
plzslp Jul 2, 2026
0696250
fix: PageRangeRequest 필드 검증과 중복되는 클래스 레벨 검증 제거
plzslp Jul 2, 2026
6c64153
test: TMDB endPage 클램핑 테스트가 실제로 검증하도록 수정
plzslp Jul 2, 2026
41a0e50
test: ContentItemWriter 태그 조회/생성/연결 테스트 추가
plzslp Jul 2, 2026
d162395
refactor: MethodArgumentTypeMismatchException 핸들러 메서드 추가로 인한 UserCont…
plzslp Jul 2, 2026
e4e9bf2
docs: 기존 콘텐츠 테스트 코드 given/when/then 주석 추가
plzslp Jul 2, 2026
4c78bf8
refactor: TMDB Reader의 외부 API 호출을 BeforeStep으로 이동
plzslp Jul 2, 2026
4ff1eeb
fix: noSkip이 재시도 정책의 상태코드 판단을 무력화하던 문제 수정
plzslp Jul 2, 2026
e60b992
fix: BatchJobLaunchException이 예외 원인을 유지하도록 수정
plzslp Jul 2, 2026
7e1cf5e
fix: 스케줄러 Job 실패 로그에 스택트레이스 추가, 잘못된 league 값 테스트 추가
plzslp Jul 2, 2026
5cfaafd
fix: V14, V15 schema.sql 추가로 인한 Spring Batch 메타데이터 sql문 버전 변경
plzslp Jul 2, 2026
1c60e82
fix: TMDB genreIds 누락 시 NPE 방지
plzslp Jul 2, 2026
1096668
fix: SportsDbEventItemReader에 재시도 로직 누락 수정
plzslp Jul 2, 2026
c1809a4
fix: 콘텐츠 수집 컨트롤러, 스케줄러 예외 로깅 개선
plzslp Jul 2, 2026
a334cad
refactor: 미사용 코드 정리 및 테스트 보강
plzslp Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand Down Expand Up @@ -53,6 +54,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.batch:spring-batch-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
Expand Down
48 changes: 22 additions & 26 deletions src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.global.async.CompositeTaskDecorator;
import com.codeit.team5.mopl.global.async.MdcTaskDecorator;
import com.codeit.team5.mopl.global.async.SecurityContextTaskDecorator;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

@Bean(name = "binaryContentUploadExecutor")
public Executor binaryContentUploadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("binary-upload-");
executor.setTaskDecorator(new CompositeTaskDecorator(
List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator())));
executor.initialize();
return executor;
}
private static final int CORE_POOL_SIZE = 2;
private static final int MAX_POOL_SIZE = 4;
private static final int QUEUE_CAPACITY = 10;

@Bean(name = "contentCollectionExecutor")
public Executor contentCollectionExecutor() {
/**
* 배치 Job을 비동기로 실행하기 위한 스레드 풀.
* <p>
* 동시성 상한을 두어 수집 요청이 몰려도 서버 자원이 무한정 잠식되지 않도록 한다.
* 큐까지 가득 차면 AbortPolicy로 요청을 거부하며, 이때 발생하는 TaskRejectedException은
* GlobalExceptionHandler에서 503으로 매핑된다.
* <p>
* MDC(requestId 등)만 복사한다. 콘텐츠 수집은 스케줄러로도 실행되는 시스템 작업이라
* 인증 컨텍스트에 의존하지 않으므로 SecurityContext는 복사하지 않는다.
*/
@Bean
public ThreadPoolTaskExecutor batchJobTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("content-collect-");
executor.setTaskDecorator(new CompositeTaskDecorator(
List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator())));
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(QUEUE_CAPACITY);
executor.setThreadNamePrefix("batch-job-");
executor.setTaskDecorator(new MdcTaskDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
Expand Down
228 changes: 228 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/BatchConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData;
import com.codeit.team5.mopl.content.batch.processor.SportsDbEventItemProcessor;
import com.codeit.team5.mopl.content.batch.processor.TmdbMovieItemProcessor;
import com.codeit.team5.mopl.content.batch.processor.TmdbTvSeriesItemProcessor;
import com.codeit.team5.mopl.content.batch.reader.SportsDbDayItemReader;
import com.codeit.team5.mopl.content.batch.reader.SportsDbEventItemReader;
import com.codeit.team5.mopl.content.batch.reader.TmdbMovieItemReader;
import com.codeit.team5.mopl.content.batch.reader.TmdbTvSeriesItemReader;
import com.codeit.team5.mopl.content.batch.retry.LoggingSkipListener;
import com.codeit.team5.mopl.content.batch.retry.SelectiveRetryPolicy;
import com.codeit.team5.mopl.content.batch.retry.SelectiveSkipPolicy;
import com.codeit.team5.mopl.content.batch.writer.ContentItemWriter;
import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient;
import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient;
import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto;
import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto;
import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
import com.codeit.team5.mopl.tag.repository.TagRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.launch.support.TaskExecutorJobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.FixedBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
@RequiredArgsConstructor
public class BatchConfig {
Comment thread
plzslp marked this conversation as resolved.

private static final int CHUNK_SIZE = 100;
private static final int RETRY_LIMIT = 3;
private static final int TMDB_SKIP_LIMIT = 10;
private static final int SPORTS_DB_SKIP_LIMIT = 3;
private static final int EXTERNAL_FETCH_MAX_ATTEMPTS = 2;
private static final long EXTERNAL_FETCH_BACKOFF_MS = 200;

private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final TmdbApiClient tmdbApiClient;
private final SportsDbApiClient sportsDbApiClient;
private final ContentRepository contentRepository;
private final ContentStatsRepository contentStatsRepository;
private final BinaryContentRepository binaryContentRepository;
private final TagRepository tagRepository;
private final ObjectMapper objectMapper;

// ── 비동기 JobLauncher ───────────────────────────────

@Bean
public JobLauncher asyncJobLauncher(JobRepository jobRepository,
ThreadPoolTaskExecutor batchJobTaskExecutor) throws Exception {
TaskExecutorJobLauncher launcher = new TaskExecutorJobLauncher();
launcher.setJobRepository(jobRepository);
launcher.setTaskExecutor(batchJobTaskExecutor);
launcher.afterPropertiesSet();
return launcher;
Comment thread
plzslp marked this conversation as resolved.
}

// ── TMDB 영화 Job ──────────────────────────────────────────────

@Bean
public Job tmdbMovieJob() {
return new JobBuilder("tmdbMovieJob", jobRepository)
.start(tmdbMovieStep())
.build();
}

@Bean
public Step tmdbMovieStep() {
return new StepBuilder("tmdbMovieStep", jobRepository)
.<TmdbMovieDto, ContentWithMetaData>chunk(CHUNK_SIZE, transactionManager)
.reader(tmdbMovieItemReader())
.processor(tmdbMovieItemProcessor())
.writer(contentItemWriter())
.faultTolerant()
.retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT))
.skipPolicy(new SelectiveSkipPolicy(TMDB_SKIP_LIMIT))
.listener(new LoggingSkipListener())
.build();
Comment thread
plzslp marked this conversation as resolved.
}
Comment thread
plzslp marked this conversation as resolved.

@Bean
@StepScope
public TmdbMovieItemReader tmdbMovieItemReader() {
return new TmdbMovieItemReader(tmdbApiClient, externalFetchRetryTemplate());
}

@Bean
public TmdbMovieItemProcessor tmdbMovieItemProcessor() {
return new TmdbMovieItemProcessor(contentRepository, objectMapper);
}

// ── TMDB TV 시리즈 Job ─────────────────────────────────────────

@Bean
public Job tmdbTvSeriesJob() {
return new JobBuilder("tmdbTvSeriesJob", jobRepository)
.start(tmdbTvSeriesStep())
.build();
}

@Bean
public Step tmdbTvSeriesStep() {
return new StepBuilder("tmdbTvSeriesStep", jobRepository)
.<TmdbTvDto, ContentWithMetaData>chunk(CHUNK_SIZE, transactionManager)
.reader(tmdbTvSeriesItemReader())
.processor(tmdbTvSeriesItemProcessor())
.writer(contentItemWriter())
.faultTolerant()
.retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT))
.skipPolicy(new SelectiveSkipPolicy(TMDB_SKIP_LIMIT))
.listener(new LoggingSkipListener())
.build();
}

@Bean
@StepScope
public TmdbTvSeriesItemReader tmdbTvSeriesItemReader() {
return new TmdbTvSeriesItemReader(tmdbApiClient, externalFetchRetryTemplate());
}

@Bean
public TmdbTvSeriesItemProcessor tmdbTvSeriesItemProcessor() {
return new TmdbTvSeriesItemProcessor(contentRepository, objectMapper);
}

// ── SportsDB 리그-시즌 Job ───────────────────────────────────────────────

@Bean
public Job sportsDbEventJob() {
return new JobBuilder("sportsDbEventJob", jobRepository)
.start(sportsDbEventStep())
.build();
}

@Bean
public Step sportsDbEventStep() {
return new StepBuilder("sportsDbEventStep", jobRepository)
.<SportsDbEventDto, ContentWithMetaData>chunk(CHUNK_SIZE, transactionManager)
.reader(sportsDbEventItemReader())
.processor(sportsDbEventItemProcessor())
.writer(contentItemWriter())
.faultTolerant()
.retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT))
.skipPolicy(new SelectiveSkipPolicy(SPORTS_DB_SKIP_LIMIT))
.listener(new LoggingSkipListener())
.build();
}

@Bean
@StepScope
public SportsDbEventItemReader sportsDbEventItemReader() {
return new SportsDbEventItemReader(sportsDbApiClient, externalFetchRetryTemplate());
}

@Bean
public SportsDbEventItemProcessor sportsDbEventItemProcessor() {
return new SportsDbEventItemProcessor(contentRepository, objectMapper);
}

// ── SportsDB 일별 Job ──────────────────────────────────────────

@Bean
public Job sportsDbDayJob() {
return new JobBuilder("sportsDbDayJob", jobRepository)
.start(sportsDbDayStep())
.build();
}

@Bean
public Step sportsDbDayStep() {
return new StepBuilder("sportsDbDayStep", jobRepository)
.<SportsDbEventDto, ContentWithMetaData>chunk(CHUNK_SIZE, transactionManager)
.reader(sportsDbDayItemReader())
.processor(sportsDbEventItemProcessor())
.writer(contentItemWriter())
.faultTolerant()
.retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT))
.skipPolicy(new SelectiveSkipPolicy(SPORTS_DB_SKIP_LIMIT))
.listener(new LoggingSkipListener())
.build();
}

@Bean
@StepScope
public SportsDbDayItemReader sportsDbDayItemReader() {
return new SportsDbDayItemReader(sportsDbApiClient, externalFetchRetryTemplate());
}

// @BeforeStep에서 여러 건을 순회 조회하는 Reader(TMDB 영화/TV, SportsDB)가 공용으로 쓰는
// 재시도 정책 (최대 2회 시도, 200ms 고정 백오프). @BeforeStep은 청크 단위 faultTolerant 재시도의
// 보호 범위 밖이라, 한 건의 일시적 오류로 전체 수집이 실패해 이미 모은 데이터까지 유실되는 것을
// 막기 위해 재시도해도 성공 가능성이 있는 오류만 재시도한다.
@Bean
public RetryTemplate externalFetchRetryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
retryTemplate.setRetryPolicy(new SelectiveRetryPolicy(EXTERNAL_FETCH_MAX_ATTEMPTS));

FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
backOffPolicy.setBackOffPeriod(EXTERNAL_FETCH_BACKOFF_MS);
retryTemplate.setBackOffPolicy(backOffPolicy);

return retryTemplate;
}

// ── 공통 Writer ────────────────────────────────────────────────

@Bean
public ContentItemWriter contentItemWriter() {
return new ContentItemWriter(contentRepository, contentStatsRepository,
binaryContentRepository, tagRepository);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.codeit.team5.mopl.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class ScheduleConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.content.batch.dto;

import com.codeit.team5.mopl.content.entity.Content;
import java.util.List;

public record ContentWithMetaData(
Content content,
String thumbnailUrl,
List<String> tagNames
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.codeit.team5.mopl.content.batch.exception;

import com.codeit.team5.mopl.content.exception.ContentException;
import org.springframework.http.HttpStatus;

public class BatchJobLaunchException extends ContentException {

public BatchJobLaunchException(String jobName, Throwable cause) {
super(HttpStatus.INTERNAL_SERVER_ERROR, "배치 작업(" + jobName + ") 실행에 실패했습니다.");
initCause(cause);
}
}
Loading
Loading