Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
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.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
}
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

---

## 패키지 트리

```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 도입 권장 |
Loading