-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Spring Batch 인프라 구축 — 자유게시판 스케줄러 분리 샘플 #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| DB_URL=jdbc:mysql://localhost:3306/pocat?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 | ||
| DB_USERNAME=root | ||
| DB_PASSWORD=password | ||
| REDIS_HOST=localhost | ||
| REDIS_PORT=6379 | ||
| REDIS_PASSWORD= | ||
| BATCH_SERVER_PORT=8081 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Environment | ||
| .env | ||
| *.env.local | ||
|
|
||
| # Build | ||
| build/ | ||
| .gradle/ | ||
| out/ | ||
|
|
||
| # IDE | ||
| .idea/ | ||
| *.iml | ||
| *.iws | ||
| *.ipr | ||
|
|
||
| # OS | ||
| .DS_Store | ||
| Thumbs.db | ||
|
|
||
| # Logs | ||
| *.log | ||
| logs/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,89 @@ | ||
| # pocat-batch | ||
| 프로젝트 배치 활용을 위한 독립 배치 서버 | ||
|
|
||
| POCAT 메인 앱의 `@Scheduled` 스케줄러를 별도 서버로 분리한 Spring Batch 샘플 레포. | ||
| 멀티 인스턴스 배포 환경에서 스케줄러 중복 실행을 방지하기 위해 구축되었다. | ||
|
|
||
| > **참고**: 이 레포는 팀원 참고용 샘플 구현이다. 실제 운영 전환 전에 ShedLock 도입 및 메인 앱 스케줄러 폐기 계획을 검토해야 한다. | ||
|
|
||
| --- | ||
|
|
||
| ## 메인 앱과의 관계 | ||
|
|
||
| | 항목 | 내용 | | ||
| |------|------| | ||
| | DB | POCAT 메인 앱과 동일한 MySQL DB 공유 | | ||
| | 엔티티 | 메인 앱 엔티티 복제 (`FreePost`, `BaseEntity`) | | ||
| | Redis | 동일 Redis 인스턴스 사용 | | ||
| | 병행 운영 | 초기에는 메인 앱 스케줄러와 병행 실행됨 (중복 주의) | | ||
|
|
||
| --- | ||
|
|
||
| ## 구현된 Job | ||
|
|
||
| ### 1. `freePostRankingJob` | ||
|
|
||
| 자유게시판 인기 랭킹을 Redis ZSet으로 갱신하는 Job. | ||
|
|
||
| - **Tasklet**: `FreePostRankingTasklet` | ||
| - **동작**: `FreePostRepository.findTopByPopularScore` 조회 → Redis `ranking:free:popular` ZSet RENAME | ||
| - **스케줄**: `fixedDelay=60s` (이전 실행 완료 후 60초) | ||
|
|
||
| ### 2. `viewCountFlushJob` | ||
|
|
||
| Redis 버퍼에 누적된 조회수·댓글수를 MySQL DB에 플러시하는 Job. | ||
|
|
||
| - **Tasklet**: `ViewCountFlushTasklet` | ||
| - **동작**: `FreePostFlushService.increaseViewCount` + `updateCommentCount` (`@Transactional REQUIRES_NEW`) | ||
| - **스케줄**: `fixedDelay=60s` | ||
|
|
||
| --- | ||
|
|
||
| ## 빠른 시작 | ||
|
|
||
| ### 전제조건 | ||
|
|
||
| - Java 17+ | ||
| - MySQL 실행 중 (POCAT 메인 앱 DB 접근 가능) | ||
| - Redis 실행 중 | ||
|
|
||
| ### 환경 변수 설정 | ||
|
|
||
| 프로젝트 루트에 `.env` 파일 생성 (`.env.example` 참고): | ||
|
|
||
| ```bash | ||
| cp .env.example .env | ||
| # .env 파일에서 DB_URL, DB_USERNAME, DB_PASSWORD, REDIS_HOST, REDIS_PORT 수정 | ||
| ``` | ||
|
|
||
| ### 실행 | ||
|
|
||
| ```bash | ||
| ./gradlew bootRun --args='--spring.profiles.active=local' | ||
| ``` | ||
|
|
||
| timezone 설정이 필요한 경우: | ||
|
|
||
| ```bash | ||
| ./gradlew bootRun -Duser.timezone=Asia/Seoul --args='--spring.profiles.active=local' | ||
| ``` | ||
|
|
||
| 최초 실행 시 Spring Batch 메타테이블(`BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION` 등)이 자동 생성된다. | ||
|
|
||
| --- | ||
|
|
||
| ## 향후 작업 | ||
|
|
||
| - **ShedLock 도입**: 다중 배치 인스턴스 배포 시 중복 실행 방지 (현재는 단일 인스턴스 전제) | ||
| - **다른 도메인 Job 추가 방법**: | ||
| 1. `job/{domain}/` 패키지에 `*JobConfig`, `*Tasklet` 클래스 추가 | ||
| 2. `BatchScheduler`에 `JobLauncher.run()` 호출 메서드 추가 | ||
| 3. 필요 시 `domain/{도메인}/` 패키지에 엔티티·레포지토리 복제 | ||
| - **메인 앱 스케줄러 단계적 폐기**: `FreePostRankingScheduler`, `ViewCountFlushScheduler` 비활성화 또는 삭제 | ||
|
|
||
| --- | ||
|
|
||
| ## 관련 문서 | ||
|
|
||
| - [`docs/ADR-001-spring-batch-separation.md`](docs/ADR-001-spring-batch-separation.md) — 분리 결정 배경 | ||
| - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — 패키지 구조·Job 흐름·Redis 키 표 | ||
| - [`docs/RUNBOOK.md`](docs/RUNBOOK.md) — 실행·운영 가이드 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| plugins { | ||
| id 'java' | ||
| id 'org.springframework.boot' version '3.3.5' | ||
| id 'io.spring.dependency-management' version '1.1.7' | ||
| } | ||
|
|
||
| group = 'com.rocketcrew' | ||
| version = '0.0.1-SNAPSHOT' | ||
| description = 'pocat-batch' | ||
|
|
||
| java { | ||
| toolchain { | ||
| languageVersion = JavaLanguageVersion.of(17) | ||
| } | ||
| } | ||
|
|
||
| repositories { | ||
| mavenCentral() | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation 'org.springframework.boot:spring-boot-starter-batch' | ||
| implementation 'org.springframework.boot:spring-boot-starter-data-jpa' | ||
| implementation 'org.springframework.boot:spring-boot-starter-data-redis' | ||
| implementation 'org.springframework.boot:spring-boot-starter-actuator' | ||
| implementation 'io.github.cdimascio:dotenv-java:3.0.0' | ||
| compileOnly 'org.projectlombok:lombok' | ||
| annotationProcessor 'org.projectlombok:lombok' | ||
| runtimeOnly 'com.mysql:mysql-connector-j' | ||
| testImplementation 'org.springframework.boot:spring-boot-starter-test' | ||
| testImplementation 'org.springframework.batch:spring-batch-test' | ||
| testRuntimeOnly 'com.h2database:h2' | ||
| testCompileOnly 'org.projectlombok:lombok' | ||
| testAnnotationProcessor 'org.projectlombok:lombok' | ||
| } | ||
|
|
||
| tasks.named('test') { | ||
| useJUnitPlatform() | ||
| } | ||
|
|
||
| jar { | ||
| enabled = false | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| # ADR-001: Spring Batch 스케줄러 분리 | ||
|
|
||
| | 항목 | 내용 | | ||
| |------|------| | ||
| | **Status** | Accepted | | ||
| | **Date** | 2026-05-24 | | ||
| | **Deciders** | POCAT 팀 | | ||
|
|
||
| --- | ||
|
|
||
| ## Context | ||
|
|
||
| POCAT 메인 앱에는 자유게시판 관련 `@Scheduled` 스케줄러가 2개 존재한다. | ||
|
|
||
| - `FreePostRankingScheduler` — 인기 랭킹 Redis ZSet 갱신 | ||
| - `ViewCountFlushScheduler` — 조회수·댓글수 Redis 버퍼 → MySQL DB 플러시 | ||
|
|
||
| 현재 구조에서는 메인 앱 서버를 멀티 인스턴스로 스케일아웃할 경우, 각 인스턴스가 독립적으로 스케줄러를 실행하여 **동일 Job이 중복 실행**되는 문제가 발생한다. 조회수 플러시의 경우 중복 실행 시 데이터 정합성 문제로 이어질 수 있다. | ||
|
|
||
| --- | ||
|
|
||
| ## Decision | ||
|
|
||
| 별도 `pocat-batch` 서버를 구축하고, 자유게시판 스케줄링 Job 2개를 **Spring Batch Tasklet 기반**으로 분리 구현한다. | ||
|
|
||
| - Job 실행 방식: `fixedDelay=60s` (이전 실행 완료 후 60초 대기) | ||
| - JobRepository 저장소: **MySQL `BATCH_*` 메타테이블** 채택 (실행 이력 관리 목적) | ||
| - 초기에는 메인 앱 스케줄러와 **병행 운영**하며, 안정성 확인 후 메인 앱 스케줄러를 단계적으로 폐기한다. | ||
|
|
||
| --- | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### ShedLock | ||
|
|
||
| - 메인 앱에 ShedLock 라이브러리를 추가하여 분산 잠금으로 중복 실행 방지 | ||
| - **거절 이유**: 현재 태스크 범위를 초과. 별도 ADR에서 검토 예정 (다중 배치 인스턴스 대비) | ||
|
|
||
| ### `fixedRate` / cron 표현식 | ||
|
|
||
| - `fixedRate`는 이전 실행이 끝나지 않아도 다음 실행이 트리거됨 | ||
| - **거절 이유**: 장기 실행 Job 시 백로그 누적 위험. `fixedDelay`가 더 안전함 | ||
|
|
||
| ### In-memory JobRepository | ||
|
|
||
| - Spring Batch 기본 설정인 `MapJobRepositoryFactoryBean` 사용 | ||
| - **거절 이유**: 애플리케이션 재시작 시 실행 이력 소실. 운영 환경에서 Job 실행 이력 추적 불가 | ||
|
|
||
| --- | ||
|
|
||
| ## Consequences | ||
|
|
||
| **긍정적 효과** | ||
| - 메인 앱 스케일아웃 시 스케줄러 중복 실행 문제 해소 (배치 서버는 단일 인스턴스 운영) | ||
| - Spring Batch 메타테이블을 통한 Job 실행 이력 영속 관리 가능 | ||
| - 스케줄러 관련 로직을 메인 앱에서 분리하여 메인 앱 복잡도 감소 | ||
|
|
||
| **부정적 효과 / 주의사항** | ||
| - 초기 병행 운영 기간 중 동일 작업이 메인 앱 + 배치 서버에서 각 1회씩 총 2회 수행됨 | ||
| - 운영 전환 시 메인 앱의 `FreePostRankingScheduler`, `ViewCountFlushScheduler`를 반드시 비활성화(`@Scheduled` 제거 또는 클래스 삭제) 해야 함 | ||
| - 향후 배치 서버를 멀티 인스턴스로 운영할 경우 ShedLock 도입 필요 | ||
|
|
||
| --- | ||
|
|
||
| ## Related | ||
|
|
||
| - POCAT 메인 레포 `docs/adr/ADR-003-batch-server-extraction.md` | ||
| - [`ARCHITECTURE.md`](ARCHITECTURE.md) — 상세 패키지 구조 및 Job 흐름 | ||
| - [`RUNBOOK.md`](RUNBOOK.md) — 로컬 실행 및 운영 가이드 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # ARCHITECTURE — pocat-batch | ||
|
|
||
| --- | ||
|
|
||
| ## 패키지 트리 | ||
|
|
||
| ``` | ||
| com.rocketcrew.pocatbatch/ | ||
| ├── PocatBatchApplication | ||
| ├── config/ | ||
| │ ├── BatchConfig # JobRepository, JobLauncher, TransactionManager 설정 | ||
| │ ├── JpaConfig # DataSource, EntityManagerFactory 설정 | ||
| │ └── RedisConfig # RedisTemplate, StringRedisTemplate 설정 | ||
| ├── domain/freepost/ | ||
| │ ├── entity/ | ||
| │ │ ├── BaseEntity # createdAt, updatedAt (MappedSuperclass) | ||
| │ │ └── FreePost # 자유게시판 게시글 엔티티 (메인 앱에서 복제) | ||
| │ ├── repository/ | ||
| │ │ └── FreePostRepository # JPA 레포지토리 (인기 점수 정렬 조회 포함) | ||
| │ └── service/ | ||
| │ └── FreePostFlushService # 조회수·댓글수 DB 반영 (@Transactional REQUIRES_NEW) | ||
| ├── job/ | ||
| │ ├── ranking/ | ||
| │ │ ├── FreePostRankingJobConfig # freePostRankingJob, freePostRankingStep 빈 등록 | ||
| │ │ └── FreePostRankingTasklet # 랭킹 조회 → Redis ZSet RENAME | ||
| │ └── viewcount/ | ||
| │ ├── ViewCountFlushJobConfig # viewCountFlushJob, viewCountFlushStep 빈 등록 | ||
| │ └── ViewCountFlushTasklet # Redis 버퍼 → MySQL 플러시 | ||
| └── scheduler/ | ||
| └── BatchScheduler # @Scheduled(fixedDelay=60s) — JobLauncher 실행 트리거 | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Job/Step/Tasklet 흐름 | ||
|
|
||
| ```mermaid | ||
| flowchart TD | ||
| subgraph Scheduler["BatchScheduler (@Scheduled fixedDelay=60s)"] | ||
| S1[runFreePostRankingJob] | ||
| S2[runViewCountFlushJob] | ||
| end | ||
|
|
||
| S1 --> JL1["JobLauncher.run(freePostRankingJob)"] | ||
| JL1 --> STEP1["freePostRankingStep"] | ||
| STEP1 --> T1["FreePostRankingTasklet"] | ||
| T1 --> R1["FreePostRepository.findTopByPopularScore"] | ||
| R1 --> R2["Redis ZSet RENAME\nranking:free:popular:new → ranking:free:popular"] | ||
|
|
||
| S2 --> JL2["JobLauncher.run(viewCountFlushJob)"] | ||
| JL2 --> STEP2["viewCountFlushStep"] | ||
| STEP2 --> T2["ViewCountFlushTasklet"] | ||
| T2 --> FS1["FreePostFlushService.increaseViewCount\n(@Transactional REQUIRES_NEW)"] | ||
| T2 --> FS2["FreePostFlushService.updateCommentCount\n(@Transactional REQUIRES_NEW)"] | ||
| FS1 --> DB[(MySQL)] | ||
| FS2 --> DB | ||
| ``` | ||
|
|
||
| ### 텍스트 다이어그램 (Mermaid 미지원 환경) | ||
|
|
||
| ``` | ||
| BatchScheduler(@Scheduled fixedDelay=60s) | ||
| ├─ runFreePostRankingJob() | ||
| │ └─ JobLauncher.run(freePostRankingJob) | ||
| │ └─ freePostRankingStep | ||
| │ └─ FreePostRankingTasklet | ||
| │ ├─ FreePostRepository.findTopByPopularScore | ||
| │ └─ Redis ZSet RENAME(ranking:free:popular:new → ranking:free:popular) | ||
| │ | ||
| └─ runViewCountFlushJob() | ||
| └─ JobLauncher.run(viewCountFlushJob) | ||
| └─ viewCountFlushStep | ||
| └─ ViewCountFlushTasklet | ||
| ├─ FreePostFlushService.increaseViewCount (@Transactional REQUIRES_NEW) | ||
| └─ FreePostFlushService.updateCommentCount (@Transactional REQUIRES_NEW) | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Redis 키 표 | ||
|
|
||
| | 키 | 용도 | TTL | | ||
| |----|------|-----| | ||
| | `ranking:free:popular` | 자유게시판 인기 랭킹 ZSet (서빙용) | 70s | | ||
| | `ranking:free:popular:new` | 랭킹 갱신 임시 키 (RENAME 전) | - | | ||
| | `view:free:buffer` | FreePost 조회수 버퍼 (게시글 ID → 조회수 증가량) | - | | ||
| | `view:free:buffer:processing` | 조회수 플러시 처리 중 키 (RENAME 후) | - | | ||
| | `view:free:buffer:processing:failed` | 조회수 플러시 실패 재처리 큐 | 24h | | ||
| | `comment:free:buffer` | FreePost 댓글수 버퍼 (게시글 ID → 댓글수 증가량) | - | | ||
| | `comment:free:buffer:processing` | 댓글수 플러시 처리 중 키 (RENAME 후) | - | | ||
| | `comment:free:buffer:processing:failed` | 댓글수 플러시 실패 재처리 큐 | 24h | | ||
|
|
||
| --- | ||
|
|
||
| ## 트랜잭션 경계 | ||
|
|
||
| | 레이어 | 클래스 | 전파 수준 | 이유 | | ||
| |--------|--------|-----------|------| | ||
| | Tasklet | `FreePostRankingTasklet` | Spring Batch 기본 (Step 트랜잭션) | 랭킹 조회는 읽기 전용; Redis RENAME은 원자적 | | ||
| | Tasklet | `ViewCountFlushTasklet` | 트랜잭션 없음 (서비스 위임) | 레코드별 독립 처리 위해 서비스 계층에 위임 | | ||
| | Service | `FreePostFlushService.increaseViewCount` | `REQUIRES_NEW` | 게시글별 독립 커밋; 하나 실패가 전체 롤백 전파 방지 | | ||
| | Service | `FreePostFlushService.updateCommentCount` | `REQUIRES_NEW` | 동상 (게시글별 독립 커밋) | | ||
|
|
||
| > `REQUIRES_NEW` 사용 이유: Redis 버퍼에서 읽은 게시글 ID 목록을 순회하며 건별로 DB 업데이트할 때, 특정 게시글 업데이트 실패가 전체 배치 롤백으로 이어지지 않도록 격리. 실패 항목은 `*:failed` 키에 재적재하여 다음 주기에 재처리. | ||
|
|
||
| --- | ||
|
|
||
| ## Spring Batch 메타테이블 | ||
|
|
||
| MySQL에 자동 생성되는 `BATCH_*` 테이블로 Job 실행 이력을 영속 관리한다. | ||
|
|
||
| | 테이블 | 용도 | | ||
| |--------|------| | ||
| | `BATCH_JOB_INSTANCE` | Job 인스턴스 (이름 + JobParameter 조합) | | ||
| | `BATCH_JOB_EXECUTION` | Job 실행 기록 (시작·종료·상태) | | ||
| | `BATCH_JOB_EXECUTION_PARAMS` | Job 실행 시 파라미터 | | ||
| | `BATCH_STEP_EXECUTION` | Step 실행 기록 | | ||
|
|
||
| > 운영 환경에서는 BATCH_* 테이블을 별도 스키마(`batch`)에 분리 운영 권장. | ||
|
|
||
| --- | ||
|
|
||
| ## 보안 설계 결정 | ||
|
|
||
| | 항목 | 결정 | | ||
| |------|------| | ||
| | DB 크리덴셜 | 환경변수(`${DB_URL}`, `${DB_USERNAME}`, `${DB_PASSWORD}`) — 코드 하드코딩 없음 | | ||
| | Redis 인증 | `${REDIS_PASSWORD:}` — 운영 환경 `requirepass` 설정 필수 | | ||
| | dotenv 우선순위 | OS 환경변수 > `.env` 파일 (OS 환경변수 존재 시 `.env` 값 무시) | | ||
| | BATCH_* 스키마 | `initialize-schema: always` (개발용) — 운영 배포 시 `never`로 변경 필수 | | ||
| | Actuator 노출 | `health, info, metrics` 최소 범위만 노출 | | ||
| | `.env` 파일 | `.gitignore` 에 포함 — Git 커밋 방지 | | ||
|
|
||
| ## 알려진 한계 (Known Limitations) | ||
|
|
||
| | 항목 | 내용 | 대응 방안 | | ||
| |------|------|-----------| | ||
| | 병행 운영 | 메인 앱 `@Scheduled` 스케줄러와 배치 서버가 동시 실행 시 동일 Redis 키 조작 가능 | 운영 배포 전 메인 앱 스케줄러 비활성화 | | ||
| | Redis 비원자성 | `rename(buffer, processing)` 후 서버 재시작 시 중간 상태 잔류 — 다음 사이클에 `processingKey hasKey` 로직으로 복구 | 수용 가능. 고가용성 요구 시 Lua 스크립트 원자화 검토 | | ||
| | 다중 배치 인스턴스 | 배치 서버 다중 배포 시 중복 실행 방지 없음 | ShedLock 도입 (별도 ADR) | | ||
| | 테스트 Redis 의존 | 통합 테스트가 실제 Redis 서버 필요 | Testcontainers 또는 embedded-redis 도입 권장 | |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.