diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..121c9c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# ================================================================ +# POCAT Batch 환경 변수 예시 +# 복사 후 실제 값 입력: cp .env.example .env +# +# [보안 주의] +# - 운영 환경에서는 .env 파일 대신 OS 환경변수 또는 Secrets Manager +# (AWS KMS / HashiCorp Vault 등) 로 크리덴셜을 주입하세요. +# - .env 파일은 절대 Git에 커밋하지 마세요 (.gitignore 처리됨). +# - 운영 DB 계정은 최소 권한(pocat 테이블 + BATCH_* 테이블 읽기/쓰기만) 사용. +# ================================================================ + +# == 데이터베이스 == +# 운영: RDS endpoint 사용. 로컬 개발용 기본값만 기재. +DB_URL=jdbc:mysql://localhost:3306/pocat?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +DB_USERNAME=root +# 운영 환경에서는 강력한 패스워드로 교체 필수 +DB_PASSWORD=password + +# == Redis == +REDIS_HOST=localhost +REDIS_PORT=6379 +# Redis requirepass 설정 시 반드시 입력 (운영 환경 필수) +REDIS_PASSWORD= + +# == 배치 서버 == +BATCH_SERVER_PORT=8081 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d4635f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 874b632..24240fc 100644 --- a/README.md +++ b/README.md @@ -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) — 실행·운영 가이드 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0d06b3d --- /dev/null +++ b/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.18' + 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 +} diff --git a/docs/ADR-001-spring-batch-separation.md b/docs/ADR-001-spring-batch-separation.md new file mode 100644 index 0000000..1bfbc08 --- /dev/null +++ b/docs/ADR-001-spring-batch-separation.md @@ -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) — 로컬 실행 및 운영 가이드 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..782dbec --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,141 @@ +# ARCHITECTURE — pocat-batch + +--- + +## 패키지 트리 + +```text +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 미지원 환경) + +```text +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 도입 권장 | diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..57c9ebd --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,128 @@ +# RUNBOOK — pocat-batch + +--- + +## 로컬 실행 전제조건 + +| 항목 | 요구사항 | +|------|----------| +| Java | 17 이상 | +| MySQL | 로컬에서 실행 중 (POCAT 메인 앱 DB 접근 가능) | +| Redis | 로컬에서 실행 중 (`localhost:6379` 기본) | +| Gradle | Wrapper 포함 (`./gradlew` 사용) | + +> MySQL과 Redis가 실행되어 있지 않으면 애플리케이션 시작 시 연결 오류로 즉시 종료된다. + +--- + +## .env 파일 작성 + +프로젝트 루트에 `.env` 파일을 생성한다. `.env.example`을 복사해서 시작한다. + +```bash +cp .env.example .env +``` + +`.env` 파일 항목 설명: + +| 환경 변수 | 설명 | 예시 | +|-----------|------|------| +| `DB_URL` | MySQL JDBC URL (POCAT 메인 DB) | `jdbc:mysql://localhost:3306/pocat?serverTimezone=Asia/Seoul&characterEncoding=UTF-8` | +| `DB_USERNAME` | MySQL 사용자명 | `root` | +| `DB_PASSWORD` | MySQL 비밀번호 | `password` | +| `REDIS_HOST` | Redis 호스트 | `localhost` | +| `REDIS_PORT` | Redis 포트 | `6379` | +| `BATCH_SERVER_PORT` | 배치 서버 HTTP 포트 (메인 앱과 충돌 방지) | `8081` | + +--- + +## 최초 실행 + +```bash +./gradlew bootRun --args='--spring.profiles.active=local' +``` + +timezone 명시가 필요한 경우: + +```bash +./gradlew bootRun -Duser.timezone=Asia/Seoul --args='--spring.profiles.active=local' +``` + +### 최초 실행 확인 로그 + +Spring Batch가 MySQL에 메타테이블을 자동 생성한다. 아래와 같은 로그가 출력되면 정상이다. + +```text +Executing SQL script from class path resource [org/springframework/batch/core/schema-mysql.sql] +HikariPool-1 - Starting... +HikariPool-1 - Start completed. +``` + +MySQL에서 `BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION` 등 테이블이 생성된 것을 확인할 수 있다: + +```sql +SHOW TABLES LIKE 'BATCH_%'; +``` + +--- + +## 수동 Job 실행 + +현재 구현에서 Job은 `BatchScheduler`가 `@Scheduled(fixedDelay=60s)`로 자동 실행한다. +별도의 HTTP 엔드포인트나 CLI 트리거는 제공하지 않는다. + +**수동으로 즉시 실행하려면**: 애플리케이션을 재시작하면 시작 60초 후 첫 번째 Job이 실행된다. + +--- + +## 운영 주의사항 + +### 병행 운영 기간 + +배치 서버 도입 초기에는 메인 앱의 `FreePostRankingScheduler`, `ViewCountFlushScheduler`와 병행 실행된다. +이 기간 중 동일 작업이 2회 수행되므로 **조회수·댓글수 중복 플러시 여부**를 모니터링해야 한다. + +메인 앱 스케줄러를 비활성화하려면: +- `@Scheduled` 어노테이션 제거 또는 +- 해당 스케줄러 클래스에 `@Profile("!prod")` 추가로 운영 프로파일에서 제외 + +### BATCH_* 테이블 스키마 분리 + +운영 환경에서는 `BATCH_*` 메타테이블을 별도 스키마(예: `batch`)에 분리 운영하는 것을 권장한다. + +```yaml +# application-prod.yml 예시 +spring: + batch: + jdbc: + schema: always + datasource: + batch: + url: jdbc:mysql://prod-db:3306/batch?serverTimezone=Asia/Seoul +``` + +### timezone 설정 + +JVM 레벨에서 timezone을 명시하지 않으면 서버 OS 설정을 따른다. 운영 서버에서 KST 기준 스케줄 로그를 확인하려면 JVM 옵션에 다음을 추가한다: + +```text +-Duser.timezone=Asia/Seoul +``` + +### 다중 배치 인스턴스 운영 시 + +현재 구현은 **단일 인스턴스 전제**이다. 다중 인스턴스 배포 시 ShedLock 또는 Quartz Cluster 도입이 필요하다. ADR-001 참고. + +--- + +## 운영 배포 체크리스트 + +배포 전 아래 항목을 반드시 확인할 것: + +- [ ] `.env` 파일이 Git에 커밋되지 않았는가? (`.gitignore` 확인) +- [ ] `application.yaml`의 `initialize-schema: never` 로 변경했는가? (운영 DB 스키마 보호) +- [ ] Redis `requirepass` 설정 및 `REDIS_PASSWORD` 환경변수 주입 완료했는가? +- [ ] 메인 앱(`POCAT`) 의 `FreePostRankingScheduler`, `ViewCountFlushScheduler` 비활성화 또는 삭제했는가? (중복 실행 방지) +- [ ] JVM 옵션 `-Duser.timezone=Asia/Seoul` 추가했는가? +- [ ] Spring Batch 메타테이블(`BATCH_*`) 이 DB에 수동으로 생성되었는가? (`initialize-schema: never` 사용 시) +- [ ] Actuator health 엔드포인트(`/actuator/health`)로 정상 기동 확인했는가? diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9103fcf --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pocat-batch' diff --git a/src/main/java/com/rocketcrew/pocatbatch/PocatBatchApplication.java b/src/main/java/com/rocketcrew/pocatbatch/PocatBatchApplication.java new file mode 100644 index 0000000..69fd940 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/PocatBatchApplication.java @@ -0,0 +1,22 @@ +package com.rocketcrew.pocatbatch; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class PocatBatchApplication { + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + dotenv.entries().forEach(e -> { + // OS 환경변수가 이미 존재하면 덮어쓰지 않음 + if (System.getenv(e.getKey()) == null) { + System.setProperty(e.getKey(), e.getValue()); + } + }); + SpringApplication.run(PocatBatchApplication.class, args); + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/config/BatchConfig.java b/src/main/java/com/rocketcrew/pocatbatch/config/BatchConfig.java new file mode 100644 index 0000000..8a21499 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/config/BatchConfig.java @@ -0,0 +1,9 @@ +package com.rocketcrew.pocatbatch.config; + +import org.springframework.context.annotation.Configuration; + +// Spring Boot 3.x 자동 구성이 JobRepository, JobLauncher, TransactionManager를 제공한다. +// 커스터마이징이 필요할 경우 이 클래스에서 빈을 재정의한다. +@Configuration +public class BatchConfig { +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/config/JpaConfig.java b/src/main/java/com/rocketcrew/pocatbatch/config/JpaConfig.java new file mode 100644 index 0000000..84ca5d0 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/config/JpaConfig.java @@ -0,0 +1,13 @@ +package com.rocketcrew.pocatbatch.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.boot.autoconfigure.domain.EntityScan; + +@Configuration +@EnableJpaAuditing +@EntityScan(basePackages = "com.rocketcrew.pocatbatch.domain") +@EnableJpaRepositories(basePackages = "com.rocketcrew.pocatbatch.domain") +public class JpaConfig { +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/BaseEntity.java b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/BaseEntity.java new file mode 100644 index 0000000..32844a8 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/BaseEntity.java @@ -0,0 +1,29 @@ +package com.rocketcrew.pocatbatch.domain.freepost.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/FreePost.java b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/FreePost.java new file mode 100644 index 0000000..74cc0bd --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/entity/FreePost.java @@ -0,0 +1,28 @@ +package com.rocketcrew.pocatbatch.domain.freepost.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "free_posts") +@SQLRestriction("deleted_at IS NULL") +public class FreePost extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "view_count", nullable = false) + private int viewCount; + + @Column(name = "comment_count", nullable = false) + private int commentCount; +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/repository/FreePostRepository.java b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/repository/FreePostRepository.java new file mode 100644 index 0000000..b99ec36 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/repository/FreePostRepository.java @@ -0,0 +1,27 @@ +package com.rocketcrew.pocatbatch.domain.freepost.repository; + +import com.rocketcrew.pocatbatch.domain.freepost.entity.FreePost; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface FreePostRepository extends JpaRepository { + + int COMMENT_WEIGHT = 3; + + @Modifying + @Query("UPDATE FreePost f SET f.viewCount = f.viewCount + :count WHERE f.id = :postId") + int increaseViewCount(@Param("postId") Long postId, @Param("count") int count); + + @Modifying + @Query("UPDATE FreePost f SET f.commentCount = GREATEST(0, f.commentCount + :delta) WHERE f.id = :postId") + int updateCommentCount(@Param("postId") Long postId, @Param("delta") int delta); + + @Query("SELECT f FROM FreePost f WHERE f.createdAt >= :since ORDER BY (f.viewCount + f.commentCount * 3) DESC") + List findTopByPopularScore(Pageable pageable, @Param("since") LocalDateTime since); +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/service/FreePostFlushService.java b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/service/FreePostFlushService.java new file mode 100644 index 0000000..dae8d33 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/domain/freepost/service/FreePostFlushService.java @@ -0,0 +1,35 @@ +package com.rocketcrew.pocatbatch.domain.freepost.service; + +import com.rocketcrew.pocatbatch.domain.freepost.repository.FreePostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FreePostFlushService { + + private final FreePostRepository freePostRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void increaseViewCount(Long postId, int count) { + if (count < 0) { + throw new IllegalArgumentException("View count increment must not be negative: " + count); + } + int updated = freePostRepository.increaseViewCount(postId, count); + if (updated == 0) { + log.warn("increaseViewCount skipped: postId={} not found (deleted), no retry", postId); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateCommentCount(Long postId, int delta) { + int updated = freePostRepository.updateCommentCount(postId, delta); + if (updated == 0) { + log.warn("updateCommentCount skipped: postId={} not found (deleted), no retry", postId); + } + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfig.java b/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfig.java new file mode 100644 index 0000000..261224b --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfig.java @@ -0,0 +1,37 @@ +package com.rocketcrew.pocatbatch.job.ranking; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +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.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class FreePostRankingJobConfig { + + public static final String JOB_NAME = "freePostRankingJob"; + public static final String STEP_NAME = "freePostRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final FreePostRankingTasklet freePostRankingTasklet; + + @Bean(name = JOB_NAME) + public Job freePostRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(freePostRankingStep()) + .build(); + } + + @Bean(name = STEP_NAME) + public Step freePostRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(freePostRankingTasklet, transactionManager) + .build(); + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingTasklet.java new file mode 100644 index 0000000..e86ba9e --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingTasklet.java @@ -0,0 +1,74 @@ +package com.rocketcrew.pocatbatch.job.ranking; + +import com.rocketcrew.pocatbatch.domain.freepost.entity.FreePost; +import com.rocketcrew.pocatbatch.domain.freepost.repository.FreePostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FreePostRankingTasklet implements Tasklet { + + public static final String RANKING_KEY = "ranking:free:popular"; + private static final String RANKING_NEW_KEY = "ranking:free:popular:new"; + + private final StringRedisTemplate redisTemplate; + private final FreePostRepository freePostRepository; + + @Value("${pocat.batch.ranking.free.cache-size:100}") + private int cacheSize; + + @Value("${pocat.batch.ranking.free.ttl-seconds:70}") + private int ttlSeconds; + + @Value("${pocat.batch.ranking.free.popular-days:7}") + private int popularDays; + + @Value("${pocat.batch.ranking.free.comment-weight:3}") + private int commentWeight; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + List posts = freePostRepository.findTopByPopularScore( + PageRequest.of(0, cacheSize), + LocalDateTime.now().minusDays(popularDays)); + + if (posts.isEmpty()) { + log.info("랭킹 갱신 대상 게시글 없음"); + return RepeatStatus.FINISHED; + } + + try { + redisTemplate.delete(RANKING_NEW_KEY); + + for (FreePost post : posts) { + double score = post.getViewCount() + (double) post.getCommentCount() * commentWeight; + redisTemplate.opsForZSet().add(RANKING_NEW_KEY, post.getId().toString(), score); + } + + if (Boolean.TRUE.equals(redisTemplate.hasKey(RANKING_NEW_KEY))) { + redisTemplate.rename(RANKING_NEW_KEY, RANKING_KEY); + redisTemplate.expire(RANKING_KEY, ttlSeconds, TimeUnit.SECONDS); + log.info("자유게시판 랭킹 갱신 완료: {}개", posts.size()); + } + } finally { + redisTemplate.delete(RANKING_NEW_KEY); + } + + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfig.java b/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfig.java new file mode 100644 index 0000000..24e1179 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfig.java @@ -0,0 +1,37 @@ +package com.rocketcrew.pocatbatch.job.viewcount; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +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.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class ViewCountFlushJobConfig { + + public static final String JOB_NAME = "viewCountFlushJob"; + public static final String STEP_NAME = "viewCountFlushStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ViewCountFlushTasklet viewCountFlushTasklet; + + @Bean(name = JOB_NAME) + public Job viewCountFlushJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(viewCountFlushStep()) + .build(); + } + + @Bean(name = STEP_NAME) + public Step viewCountFlushStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(viewCountFlushTasklet, transactionManager) + .build(); + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushTasklet.java new file mode 100644 index 0000000..3df7df2 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushTasklet.java @@ -0,0 +1,107 @@ +package com.rocketcrew.pocatbatch.job.viewcount; + +import com.rocketcrew.pocatbatch.domain.freepost.service.FreePostFlushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ViewCountFlushTasklet implements Tasklet { + + private static final String FREE_BUFFER_KEY = "view:free:buffer"; + private static final String FREE_PROCESSING_KEY = "view:free:buffer:processing"; + private static final String FREE_COMMENT_BUFFER_KEY = "comment:free:buffer"; + private static final String FREE_COMMENT_PROCESSING_KEY = "comment:free:buffer:processing"; + + private final StringRedisTemplate redisTemplate; + private final FreePostFlushService flushService; + + private enum FlushType { FREE_VIEW, FREE_COMMENT } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + runSafely("flushFreeView", () -> flushBuffer(FREE_PROCESSING_KEY, FREE_BUFFER_KEY, FlushType.FREE_VIEW)); + runSafely("flushFreeComment", () -> flushBuffer(FREE_COMMENT_PROCESSING_KEY, FREE_COMMENT_BUFFER_KEY, FlushType.FREE_COMMENT)); + return RepeatStatus.FINISHED; + } + + private void runSafely(String label, Runnable task) { + try { + task.run(); + } catch (Exception e) { + log.error("{} 실패", label, e); + } + } + + private void flushBuffer(String processingKey, String bufferKey, FlushType type) { + String failedKey = processingKey + ":failed"; + + if (Boolean.TRUE.equals(redisTemplate.hasKey(failedKey))) { + log.warn("이전 실패 항목 발견, 버퍼 재병합: key={}", failedKey); + Set> failedEntries = + redisTemplate.opsForZSet().rangeWithScores(failedKey, 0, -1); + if (failedEntries != null) { + for (ZSetOperations.TypedTuple entry : failedEntries) { + redisTemplate.opsForZSet().incrementScore(bufferKey, entry.getValue(), entry.getScore()); + } + } + redisTemplate.delete(failedKey); + } + + if (Boolean.TRUE.equals(redisTemplate.hasKey(processingKey))) { + log.warn("미처리 데이터 발견, DB 업데이트 재시도: key={}", processingKey); + flushKey(processingKey, type); + } + + if (!Boolean.TRUE.equals(redisTemplate.hasKey(bufferKey))) { + return; + } + + redisTemplate.rename(bufferKey, processingKey); + flushKey(processingKey, type); + } + + private void flushKey(String key, FlushType type) { + Set> entries = + redisTemplate.opsForZSet().rangeWithScores(key, 0, -1); + + if (entries == null || entries.isEmpty()) { + redisTemplate.delete(key); + return; + } + + String failedKey = key + ":failed"; + + for (ZSetOperations.TypedTuple entry : entries) { + if (entry.getValue() == null || entry.getScore() == null) { + log.warn("유효하지 않은 ZSet 엔트리 스킵: key={}", key); + continue; + } + try { + Long postId = Long.parseLong(entry.getValue()); + int count = (int) Math.round(entry.getScore()); + switch (type) { + case FREE_VIEW -> flushService.increaseViewCount(postId, count); + case FREE_COMMENT -> flushService.updateCommentCount(postId, count); + } + } catch (Exception e) { + log.error("flush 실패: entry={}, key={}", entry.getValue(), key, e); + redisTemplate.opsForZSet().incrementScore(failedKey, entry.getValue(), entry.getScore()); + redisTemplate.expire(failedKey, 24, TimeUnit.HOURS); + } + } + + redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/scheduler/BatchScheduler.java b/src/main/java/com/rocketcrew/pocatbatch/scheduler/BatchScheduler.java new file mode 100644 index 0000000..40ede18 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/scheduler/BatchScheduler.java @@ -0,0 +1,62 @@ +package com.rocketcrew.pocatbatch.scheduler; + +import com.rocketcrew.pocatbatch.job.ranking.FreePostRankingJobConfig; +import com.rocketcrew.pocatbatch.job.viewcount.ViewCountFlushJobConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class BatchScheduler { + + private final JobLauncher jobLauncher; + private final Job freePostRankingJob; + private final Job viewCountFlushJob; + + // @RequiredArgsConstructor는 @Qualifier 미지원 → 수동 생성자 필수 + public BatchScheduler( + JobLauncher jobLauncher, + @Qualifier(FreePostRankingJobConfig.JOB_NAME) Job freePostRankingJob, + @Qualifier(ViewCountFlushJobConfig.JOB_NAME) Job viewCountFlushJob) { + this.jobLauncher = jobLauncher; + this.freePostRankingJob = freePostRankingJob; + this.viewCountFlushJob = viewCountFlushJob; + } + + @Scheduled(fixedDelay = 60_000) + public void runFreePostRanking() { + launch(freePostRankingJob, "freePostRankingJob"); + } + + @Scheduled(fixedDelay = 60_000) + public void runViewCountFlush() { + launch(viewCountFlushJob, "viewCountFlushJob"); + } + + private void launch(Job job, String label) { + try { + JobParameters params = new JobParametersBuilder() + .addLong("ts", System.currentTimeMillis()) + .toJobParameters(); + JobExecution jobExecution = jobLauncher.run(job, params); + if (jobExecution.getStatus() == BatchStatus.COMPLETED) { + log.info("{} 실행 완료", label); + } else { + log.error("{} 비정상 종료: status={}, failures={}", + label, + jobExecution.getStatus(), + jobExecution.getAllFailureExceptions()); + } + } catch (Exception e) { + log.error("{} 실행 실패", label, e); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..0f1a9f2 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,48 @@ +spring: + application: + name: pocat-batch + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: ${DB_DRIVER:com.mysql.cj.jdbc.Driver} + + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + batch: + jdbc: + initialize-schema: always + job: + enabled: false + +server: + port: ${BATCH_SERVER_PORT:8081} + +management: + endpoints: + web: + exposure: + include: health,info,metrics + +pocat: + batch: + ranking: + free: + cache-size: 100 + ttl-seconds: 70 + popular-days: 7 + comment-weight: 3 diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfigTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfigTest.java new file mode 100644 index 0000000..7496e89 --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/job/ranking/FreePostRankingJobConfigTest.java @@ -0,0 +1,38 @@ +package com.rocketcrew.pocatbatch.job.ranking; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBatchTest +@SpringBootTest +@ActiveProfiles("test") +class FreePostRankingJobConfigTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(FreePostRankingJobConfig.JOB_NAME) + private Job freePostRankingJob; + + @Test + void freePostRankingJob_실행_성공() throws Exception { + jobLauncherTestUtils.setJob(freePostRankingJob); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addLong("ts", System.currentTimeMillis()) + .toJobParameters()); + + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + } +} diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfigTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfigTest.java new file mode 100644 index 0000000..a691c7c --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/job/viewcount/ViewCountFlushJobConfigTest.java @@ -0,0 +1,38 @@ +package com.rocketcrew.pocatbatch.job.viewcount; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBatchTest +@SpringBootTest +@ActiveProfiles("test") +class ViewCountFlushJobConfigTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(ViewCountFlushJobConfig.JOB_NAME) + private Job viewCountFlushJob; + + @Test + void viewCountFlushJob_실행_성공() throws Exception { + jobLauncherTestUtils.setJob(viewCountFlushJob); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addLong("ts", System.currentTimeMillis()) + .toJobParameters()); + + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..1383be4 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,22 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + + data: + redis: + host: localhost + port: 6379 + + batch: + jdbc: + initialize-schema: always + job: + enabled: false