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
95 changes: 94 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
```text
com.rocketcrew.pocatbatch/
├── PocatBatchApplication
├── client/
│ ├── MainAiReindexClient # POST /internal/ai/reindex-cards (ADR-018, #222)
│ ├── MainAuctionLifecycleClient # POST /internal/auctions/{id}/activate|close-expired (#235)
│ ├── MainCardSyncClient # POST /internal/cards/sync (#235)
│ ├── MainAppBuyoutClient # 즉시구매 처리 위임
│ └── MainAppRefundClient # 환불 처리 위임
├── config/
│ ├── BatchConfig # JobRepository, JobLauncher, TransactionManager 설정
│ ├── JpaConfig # DataSource, EntityManagerFactory 설정
Expand All @@ -20,14 +26,26 @@ com.rocketcrew.pocatbatch/
│ └── service/
│ └── FreePostFlushService # 조회수·댓글수 DB 반영 (@Transactional REQUIRES_NEW)
├── job/
│ ├── auctionactivation/
│ │ ├── AuctionActivationJobConfig # auctionActivationJob 빈 등록 (#235)
│ │ └── AuctionActivationTasklet # APPROVED 경매 조회 → MainAuctionLifecycleClient.activate() (#235)
│ ├── auctionexpiration/
│ │ ├── AuctionExpirationJobConfig # auctionExpirationJob 빈 등록 (#235)
│ │ └── AuctionExpirationTasklet # 만료 ACTIVE 경매 조회 → MainAuctionLifecycleClient.closeExpired() (#235)
│ ├── cardsync/
│ │ ├── CardSyncJobConfig # cardSyncJob 빈 등록 (#235)
│ │ └── CardSyncTasklet # MainCardSyncClient.triggerSync() 1회 호출 (#235)
│ ├── aireindex/
│ │ ├── AiReindexJobConfig # aiReindexJob, aiReindexStep 빈 등록 (ADR-018, #222)
│ │ └── AiReindexTasklet # cursor 기반 카드ID 청크 조회 + MainAiReindexClient 호출
│ ├── ranking/
│ │ ├── FreePostRankingJobConfig # freePostRankingJob, freePostRankingStep 빈 등록
│ │ └── FreePostRankingTasklet # 랭킹 조회 → Redis ZSet RENAME
│ └── viewcount/
│ ├── ViewCountFlushJobConfig # viewCountFlushJob, viewCountFlushStep 빈 등록
│ └── ViewCountFlushTasklet # Redis 버퍼 → MySQL 플러시
└── scheduler/
└── BatchScheduler # @Scheduled(fixedDelay=60s) — JobLauncher 실행 트리거
└── BatchScheduler # @Scheduled(cron/fixedDelay) — JobLauncher 실행 트리거
```

---
Expand Down Expand Up @@ -192,6 +210,81 @@ DB 컬럼을 추가하지 않고 ES 문서 존재 여부(`metadata.cardId.keywor

---

## auctionActivationJob / auctionExpirationJob / cardSyncJob (Internal API 위임, #235)

> 경매 라이프사이클 처리와 카드 동기화를 배치 서버 내 자체 도메인 로직 없이 메인앱 Internal API로 위임하는 패턴 (#235). `aiReindexJob`(ADR-018)과 동일한 전략 C(internal API 위임) 적용.

### 주요 변경 (#235)

- 삭제: 기존 배치 서버 내 `AuctionBatchService`, `OutboxEventWriter` 등 경매/카드 자체 처리 로직
- 신규: `MainAuctionLifecycleClient` — 경매 활성화·만료 종료 위임 클라이언트
- 신규: `MainCardSyncClient` — 카드 동기화 트리거 위임 클라이언트

### Internal API 위임 대상

| Job | Tasklet | 위임 엔드포인트 | 비고 |
|-----|---------|----------------|------|
| `auctionActivationJob` | `AuctionActivationTasklet` | `POST /internal/auctions/{id}/activate` | APPROVED 경매 건별 호출, 3회 재시도(지수 백오프), `Idempotency-Key` 포함 |
| `auctionExpirationJob` | `AuctionExpirationTasklet` | `POST /internal/auctions/{id}/close-expired` | 만료 ACTIVE 경매 건별 호출, 3회 재시도(지수 백오프), `Idempotency-Key` 포함 |
| `cardSyncJob` | `CardSyncTasklet` | `POST /internal/cards/sync` | 주 1회(일요일 자정) 단일 트리거. 실제 동기화는 메인앱 `syncExecutor` 비동기 실행 |

### 처리 흐름 (경매 라이프사이클)

```text
BatchScheduler(@Scheduled cron)
├─ runAuctionActivation() [매일 19:00]
│ └─ JobLauncher.run(auctionActivationJob)
│ └─ auctionActivationStep
│ └─ AuctionActivationTasklet
│ ├─ AuctionRepository.findAllByStatus(APPROVED) → APPROVED 경매 목록
│ └─ (건별) MainAuctionLifecycleClient.activate(auctionId, jobExecutionId)
│ └─ POST /internal/auctions/{id}/activate
│ (X-Internal-Token, Idempotency-Key=auction-activate-{id}-{jobExecutionId})
│ → ApiResponseEnvelope<Boolean> (data: true=활성화, false=스킵)
└─ runAuctionExpiration() [매일 19:05~19:30 매분]
└─ JobLauncher.run(auctionExpirationJob)
└─ auctionExpirationStep
└─ AuctionExpirationTasklet
├─ AuctionRepository.findAllByStatusAndEndedAtLessThanEqual(ACTIVE, now) → 만료 경매 목록
└─ (건별) MainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId)
└─ POST /internal/auctions/{id}/close-expired
(X-Internal-Token, Idempotency-Key=auction-close-{id}-{jobExecutionId})
```

### 처리 흐름 (카드 동기화)

```text
BatchScheduler(@Scheduled cron "0 0 0 * * SUN")
└─ runCardSync()
└─ JobLauncher.run(cardSyncJob)
└─ cardSyncStep
└─ CardSyncTasklet
└─ MainCardSyncClient.triggerSync(jobExecutionId)
└─ POST /internal/cards/sync
(X-Internal-Token)
→ 202: 정상 트리거 완료
409(CARD_SYNC_IN_PROGRESS): 정상 스킵
401: RuntimeException (Job FAILED)
기타 4xx: 경고 로그 후 스킵
↓ (비동기)
메인앱 syncExecutor 카드 동기화 실행
```

### 인증

모든 internal API 호출에 `X-Internal-Token` 헤더를 포함한다. 토큰 값은 `${pocat.main-app.internal-token}` 환경변수(`.env`의 `POCAT_INTERNAL_TOKEN`)에서 주입된다.

### 배포 순서 의존성

```text
메인앱(POCAT) 배포 완료 → pocat-batch 배포
```

메인앱 먼저 배포하지 않으면 `auctionActivationJob`, `auctionExpirationJob`, `cardSyncJob` 실행 시 연결 오류 또는 404 발생.

---

## 알려진 한계 (Known Limitations)

| 항목 | 내용 | 대응 방안 |
Expand Down
62 changes: 62 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,60 @@ SHOW TABLES LIKE 'BATCH_%';

## 등록된 스케줄 Job

### auctionActivationJob (#235)

- **실행 주기**: 매일 19:00 (Asia/Seoul) — `cron = "0 0 19 * * *"`
- **중복 실행 방지**: ShedLock 적용 (`lockAtMostFor = PT30M`)
- **동작**: DB에서 `APPROVED` 상태 경매 목록을 조회한 뒤, 건별로 메인앱 `POST /internal/auctions/{id}/activate` 호출. 응답 `data: true` → 활성화 성공, `false` → 스킵.
- **주요 변경 (#235)**: 기존 배치 서버 내 자체 도메인 로직 제거 → 메인앱 Internal API 위임으로 전환.
- **의존성**: 메인앱 POCAT 먼저 배포 필요 (internal API 엔드포인트 존재 확인).
- **재시도**: 5xx·네트워크 오류 시 지수 백오프 최대 3회. 4xx 오류는 즉시 RuntimeException → 해당 경매 스킵.
- **오류 진단**:
- 401 → `POCAT_INTERNAL_TOKEN` 환경변수와 메인앱 설정값 일치 여부 확인
- 500 → 메인앱 로그 확인 (`/internal/auctions/{id}/activate`)
- **모니터링 포인트**:
- 배치 로그의 `활성화={}, 스킵={}, 실패={}` 카운터
- `failedCount > 0` 시 메인앱 경매 서비스 이상 여부 점검

---

### auctionExpirationJob (#235)

- **실행 주기**: 매일 19:05~19:30 매분 (Asia/Seoul) — `cron = "0 5-30 19 * * *"`
- **중복 실행 방지**: ShedLock 적용 (`lockAtMostFor = PT50S`)
- **동작**: DB에서 `ACTIVE` 상태이고 `endedAt <= now()`인 경매 목록을 조회한 뒤, 건별로 메인앱 `POST /internal/auctions/{id}/close-expired` 호출.
- **주요 변경 (#235)**: 기존 배치 서버 내 자체 도메인 로직 제거 → 메인앱 Internal API 위임으로 전환.
- **의존성**: 메인앱 POCAT 먼저 배포 필요.
- **재시도**: 5xx·네트워크 오류 시 지수 백오프 최대 3회. 4xx 오류는 즉시 RuntimeException → 해당 경매 스킵.
- **오류 진단**:
- 401 → `POCAT_INTERNAL_TOKEN` 환경변수 확인
- 500 → 메인앱 로그 확인 (`/internal/auctions/{id}/close-expired`)
- **모니터링 포인트**:
- 배치 로그의 `종료={}, 스킵={}, 실패={}` 카운터
- `failedCount > 0` 연속 발생 시 메인앱 경매 만료 처리 로직 점검

---

### cardSyncJob (#235)

- **실행 주기**: 매주 일요일 00:00 (Asia/Seoul) — `cron = "0 0 0 * * SUN"`
- **중복 실행 방지**: ShedLock 적용 (`lockAtMostFor = PT2H`)
- **동작**: 메인앱 `POST /internal/cards/sync` 트리거 요청 1회 발송. 실제 카드 동기화는 메인앱 `syncExecutor`에서 비동기 실행.
- **주요 변경 (#235)**: 기존 배치 서버 내 자체 카드 동기화 로직 제거 → 메인앱 Internal API 위임으로 전환.
- **의존성**: 메인앱 POCAT 먼저 배포 필요.
- **특이사항 (정상 동작)**:
- `202 Accepted` → 정상 트리거 완료 (비동기 처리 시작됨)
- `409 CARD_SYNC_IN_PROGRESS` → 이미 동기화 진행 중 — **정상 스킵** (오류 아님)
- `4xx` (401 제외) → 경고 로그 후 스킵
- **오류 진단**:
- 401 → `POCAT_INTERNAL_TOKEN` 환경변수 확인. 이 경우 RuntimeException 발생 → Job FAILED.
- 500 → 메인앱 로그 확인 (`/internal/cards/sync`)
- **모니터링 포인트**:
- 실제 동기화 완료 여부는 메인앱 로그의 `syncExecutor` 스레드 추적
- 409 반복 시 메인앱에서 이전 동기화가 완료되지 않은 것 → 메인앱 syncExecutor 처리 시간 점검

---

### aiReindexJob (ADR-018, #222)

- **실행 주기**: 매일 01:00 (Asia/Seoul)
Expand Down Expand Up @@ -142,3 +196,11 @@ JVM 레벨에서 timezone을 명시하지 않으면 서버 OS 설정을 따른
- [ ] JVM 옵션 `-Duser.timezone=Asia/Seoul` 추가했는가?
- [ ] Spring Batch 메타테이블(`BATCH_*`) 이 DB에 수동으로 생성되었는가? (`initialize-schema: never` 사용 시)
- [ ] Actuator health 엔드포인트(`/actuator/health`)로 정상 기동 확인했는가?

### #235 Internal API 위임 전환 추가 체크리스트

- [ ] **메인앱 POCAT 먼저 배포 완료**했는가? (`auctionActivationJob`, `auctionExpirationJob`, `cardSyncJob`은 메인앱 internal API 의존)
- [ ] `POCAT_INTERNAL_TOKEN` 환경변수가 메인앱 설정과 동일한 값으로 주입됐는가?
- [ ] `POCAT_API_BASE_URL` 이 운영 메인앱 URL로 올바르게 설정됐는가?
- [ ] 첫 배포 후 19:00 경매 활성화 배치 로그에서 `401` 오류 없음 확인했는가?
- [ ] 일요일 00:00 cardSyncJob 최초 실행 후 메인앱 `syncExecutor` 로그에서 동기화 완료 확인했는가?
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.rocketcrew.pocatbatch.client;

import com.rocketcrew.pocatbatch.client.dto.ApiResponseEnvelope;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
@Slf4j
public class MainAuctionLifecycleClient {

private final RestTemplate restTemplate;

@Value("${pocat.main-app.base-url}")
private String baseUrl;

@Value("${pocat.main-app.internal-token}")
private String internalToken;

/**
* 경매 활성화 요청
* POST {baseUrl}/internal/auctions/{auctionId}/activate
* 최대 3회 재시도 (지수 백오프)
* 4xx 오류는 즉시 RuntimeException 발생
*/
public boolean activate(Long auctionId, long jobExecutionId) {
String url = String.format("%s/internal/auctions/%d/activate", baseUrl, auctionId);
String idempotencyKey = String.format("auction-activate-%d-%d", auctionId, jobExecutionId);
return call(url, idempotencyKey, auctionId);
}

/**
* 경매 만료 종료 요청
* POST {baseUrl}/internal/auctions/{auctionId}/close-expired
* 최대 3회 재시도 (지수 백오프)
* 4xx 오류는 즉시 RuntimeException 발생
*/
public boolean closeExpired(Long auctionId, long jobExecutionId) {
String url = String.format("%s/internal/auctions/%d/close-expired", baseUrl, auctionId);
String idempotencyKey = String.format("auction-close-%d-%d", auctionId, jobExecutionId);
return call(url, idempotencyKey, auctionId);
}

private boolean call(String url, String idempotencyKey, Long auctionId) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.set("Idempotency-Key", idempotencyKey);

HttpEntity<Void> request = new HttpEntity<>(headers);

int maxRetries = 3;
long delayMs = 1000;

for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
ResponseEntity<ApiResponseEnvelope<Boolean>> responseEntity = restTemplate.exchange(
url, HttpMethod.POST, request,
new ParameterizedTypeReference<ApiResponseEnvelope<Boolean>>() {});
ApiResponseEnvelope<Boolean> body = responseEntity.getBody();
if (body == null) {
throw new IllegalStateException("경매 라이프사이클 응답 본문이 null입니다: auctionId=" + auctionId);
}
if (!body.success()) {
throw new IllegalStateException("경매 라이프사이클 요청이 실패했습니다: status=" + body.status() + ", auctionId=" + auctionId);
}
if (body.data() == null) {
throw new IllegalStateException("경매 라이프사이클 응답 데이터가 null입니다: auctionId=" + auctionId);
}
return body.data();
} catch (HttpClientErrorException e) {
log.warn("경매 라이프사이클 4xx 오류: auctionId={}, status={}", auctionId, e.getStatusCode());
throw new RuntimeException("4xx 오류로 스킵", e);
} catch (Exception e) {
if (attempt == maxRetries) {
log.error("경매 라이프사이클 요청 실패: auctionId={}", auctionId, e);
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
}
try {
Thread.sleep(delayMs);
delayMs *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}

throw new IllegalStateException("재시도 루프를 빠져나올 수 없습니다");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.rocketcrew.pocatbatch.client;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
@Slf4j
public class MainCardSyncClient {

private final RestTemplate restTemplate;

@Value("${pocat.main-app.base-url}")
private String baseUrl;

@Value("${pocat.main-app.internal-token}")
private String internalToken;

/**
* 카드 동기화 트리거 요청
* POST {baseUrl}/internal/cards/sync
* 202 -> 정상 처리
* 409 (CARD_SYNC_IN_PROGRESS) -> 로그 후 정상 스킵
* 401 -> RuntimeException
* 그 외 4xx -> 로그 후 정상 스킵
*/
public void triggerSync(long jobExecutionId) {
String url = String.format("%s/internal/cards/sync", baseUrl);

HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);

HttpEntity<Void> request = new HttpEntity<>(headers);

try {
restTemplate.exchange(url, HttpMethod.POST, request, Void.class);
log.info("카드 동기화 트리거 요청 성공: jobExecutionId={}", jobExecutionId);
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) {
log.error("카드 동기화 트리거 401 오류: jobExecutionId={}", jobExecutionId);
throw new RuntimeException("카드 동기화 트리거 인증 실패", e);
}

String responseBody = e.getResponseBodyAsString();
if (e.getStatusCode() == HttpStatus.CONFLICT && responseBody.contains("CARD_SYNC_IN_PROGRESS")) {
log.info("카드 동기화가 이미 진행 중이어서 스킵합니다: jobExecutionId={}", jobExecutionId);
return;
}

log.error("카드 동기화 트리거 4xx 오류: jobExecutionId={}, status={}, body={}",
jobExecutionId, e.getStatusCode(), responseBody);
throw new RuntimeException("카드 동기화 트리거 4xx 오류: status=" + e.getStatusCode(), e);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,4 @@ public class Auction extends BaseEntity {

@Column(name = "inspected_by")
private Long inspectedBy;

/**
* 승인된 경매를 실제 진행 상태로 전환하고 시작/종료 시각을 확정한다.
*/
public void activate(LocalDateTime startedAt, LocalDateTime endedAt) {
this.status = AuctionStatus.ACTIVE;
this.startedAt = startedAt;
this.endedAt = endedAt;
this.reason = null;
}

/**
* 진행 중인 경매를 정상 종료 상태로 전환한다.
*/
public void end() {
this.status = AuctionStatus.ENDED;
}
}
Loading
Loading