Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
7 changes: 7 additions & 0 deletions .env.example
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
22 changes: 22 additions & 0 deletions .gitignore
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/
89 changes: 88 additions & 1 deletion README.md
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) — 실행·운영 가이드
43 changes: 43 additions & 0 deletions build.gradle
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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
}
69 changes: 69 additions & 0 deletions docs/ADR-001-spring-batch-separation.md
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) — 로컬 실행 및 운영 가이드
141 changes: 141 additions & 0 deletions docs/ARCHITECTURE.md
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 도입 권장 |
Loading