Skip to content

feat(#171): 메인 앱 @Scheduled 스케줄러 8개 pocat-batch 완전 이전 - #3

Merged
cdkkyj123 merged 7 commits into
mainfrom
feat/batch-scheduler-migration/#171
Jun 2, 2026
Merged

feat(#171): 메인 앱 @Scheduled 스케줄러 8개 pocat-batch 완전 이전#3
cdkkyj123 merged 7 commits into
mainfrom
feat/batch-scheduler-migration/#171

Conversation

@cdkkyj123

@cdkkyj123 cdkkyj123 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • POCAT 메인 앱의 @Scheduled 스케줄러 8개를 Spring Batch Job으로 pocat-batch 서버에 완전 이전
  • ASG 다중 인스턴스 환경에서의 스케줄러 중복 실행 근본 차단

이전 대상 (8개 Job)

Job 주기 전략
AiSessionCleanupJob fixedRate 5min lift-and-shift
AuctionRankingJob fixedDelay 60s lift-and-shift (Redis)
OutboxRelayJob fixedDelay 5s lift-and-shift (Kafka, 3-step)
CardSyncJob cron 일요일 00:00 lift-and-shift (TCGdex API)
AuctionActivationJob cron 19:00 Option 3 (DB 직접 + Outbox write)
AuctionExpirationJob cron 19:05~19:30 Option 3 (DB 직접 + Outbox write)
BuyoutRecoveryJob fixedDelay 60s Option 2 (Internal REST API)
RefundRetryJob fixedDelay 60s Option 2 (Internal REST API)

주요 변경사항

  • build.gradle: spring-kafka, redisson, spring-web 의존성 추가
  • KafkaProducerConfig: 금융(acks=all)/일반(acks=1) KafkaTemplate 분리
  • RedissonConfig: Auction 분산 락용 RedissonClient
  • AuctionBatchService: @transactional public 트랜잭션 경계 보장
  • OutboxReaperTasklet: stuck PROCESSING 이벤트 PENDING 복구
  • OutboxCleanupTasklet: SENT 7일 초과 이벤트 정리
  • BatchScheduler: 10개 Job 등록 (기존 2개 + 신규 8개)

테스트 결과

  • 10/10 PASSED (@SpringBatchTest + @ActiveProfiles("test"))
  • 외부 의존성(Redis/Kafka/Redisson/RestTemplate) Mockito mock 처리

보안 수정

  • Redisson tryLock(0, 30, TimeUnit.SECONDS) leaseTime 명시
  • AuctionBatchService 추출로 @transactional 경계 보장
  • OutboxReaperTasklet findAll() OOM → DB 레벨 필터 쿼리 교체

관련

  • ADR: docs/adr/ADR-003-batch-server-extraction.md
  • 가이드: docs/guide/batch-guide.md

Closes: #171

Summary by CodeRabbit

  • New Features

    • AI chat session cleanup; auction lifecycle (activation, expiration, ranking); card synchronization; refund retry; buyout recovery; outbox relay with Kafka; Redis-backed ranking/cache; expanded batch scheduler.
  • Tests

    • Added integration tests for new batch jobs.
  • Chores

    • Spring Boot and Gradle updated; Kafka and Redis dependencies added.

cdkkyj123 and others added 5 commits June 2, 2026 15:18
- AI Session Cleanup: 30분 비활성 세션 만료 (5분마다)
- Auction Ranking: Redis ZSet 경매 랭킹 갱신 (60초 interval)
- Outbox Relay: 3-step Job - 이벤트 릴레이/리커버리/정리 (5초마다)
- Card Sync: TCGdex API 카드 동기화 (매주 일요일 자정)
- Auction Activation: APPROVED 경매 활성화 (매일 19시, Redisson 락)
- Auction Expiration: 만료된 경매 자동 종료 (19시 5~30분)
- Buyout Recovery: PAYMENT_PENDING 복구 요청 (60초마다)
- Refund Retry: 환불 자동 재시도 (60초마다)

Config 추가:
- KafkaProducerConfig: 2개 template (기본, 금융 안정성)
- RestClientConfig: Main App 내부 API 호출용
- RedissonConfig: 분산 락 지원

Domain 엔티티 복제:
- AiChatSession, Auction, OutboxEvent, Refund, Card
- 모든 Repository, Service, Enum 구현

필수 의존성 추가: spring-kafka, redisson, spring-web
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cdkkyj123, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 46 minutes and 37 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6552d8a-5000-4b2a-b709-d353a5dc5363

📥 Commits

Reviewing files that changed from the base of the PR and between 590ce56 and cf08cdc.

📒 Files selected for processing (3)
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java
📝 Walkthrough

Walkthrough

Adds Spring Batch jobs, domain entities/repositories, outbox + Kafka delivery, Redis locking, REST clients, scheduler wiring, build/tooling upgrades, and integration tests to support auction/card/refund processing and background jobs.

Changes

Batch System Expansion

Layer / File(s) Summary
All changes (combined checkpoint)
build.gradle, gradle/wrapper/gradle-wrapper.properties, src/main/resources/application.yaml, src/test/resources/application.yaml, src/main/java/com/rocketcrew/pocatbatch/config/*, src/main/java/com/rocketcrew/pocatbatch/client/*, src/main/java/com/rocketcrew/pocatbatch/domain/**, src/main/java/com/rocketcrew/pocatbatch/job/**, src/main/java/com/rocketcrew/pocatbatch/scheduler/BatchScheduler.java, src/test/java/com/rocketcrew/pocatbatch/**
Complete set: upgrades build tooling, adds Kafka/Redisson/Rest configurations and beans, introduces many JPA entities (Auction, Card, Refund, AiChatSession, OutboxEvent, Like, Pokemon, Series, PokemonSet, AuctionBid) and enums, repositories and projection DTOs, outbox writer/processor, auction/ranking services, HTTP clients with idempotent retry, numerous tasklets and job configs (activation, expiration, ranking, outbox relay/reaper/cleanup, card sync, buyout recovery, refund retry, AI session cleanup), scheduler wiring, and Spring Batch integration tests. All changes are configuration/code/test additions across the batch subsystem.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • POCAT-sparta/pocat-batch#1: Modifies the batch scheduler and initial batch infrastructure; strongly related to scheduler/job orchestration changes here.

Poem

🐰 I hopped through code with quiet cheer,

Jobs and locks and Kafka near,
Outbox, refunds, cards in flight,
Scheduler wakes at Seoul's daylight,
A little rabbit, batch now clear.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/batch-scheduler-migration/#171

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (1)
src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java (1)

41-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same unconditional lock.unlock() risk as AuctionActivationTasklet.

This tasklet shares the identical lock-release pattern (tryLock with a 30s lease, then unconditional lock.unlock() in finally). The same IllegalMonitorStateException-on-expired-lease concern raised in AuctionActivationTasklet (Line 36-50) applies here; please apply the same isHeldByCurrentThread() guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java`
around lines 41 - 55, The finally block in AuctionExpirationTasklet currently
calls lock.unlock() unconditionally which can throw IllegalMonitorStateException
if the lease expired or the current thread doesn't hold the lock; update the
finally to check lock.isHeldByCurrentThread() before calling lock.unlock().
Locate the RLock usage in AuctionExpirationTasklet (the block that calls
redissonClient.getLock("auction:lock:" + auction.getId()), tryLock(...), and
finally) and wrap the unlock with an if (lock.isHeldByCurrentThread()) guard to
safely release only when held by the current thread.
🧹 Nitpick comments (15)
src/main/java/com/rocketcrew/pocatbatch/config/KafkaProducerConfig.java (1)

31-47: 💤 Low value

Use ProducerConfig/KafkaTemplate typed constants instead of raw string keys.

"acks" and "enable.idempotence" work, but typoed keys fail silently. Prefer ProducerConfig.ACKS_CONFIG and ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG for compile-time safety.

♻️ Proposed change
-        props.put("acks", "all");
-        props.put("enable.idempotence", true);
+        props.put(ProducerConfig.ACKS_CONFIG, "all");
+        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/config/KafkaProducerConfig.java`
around lines 31 - 47, Replace raw string property keys with the ProducerConfig
constants to avoid silent typos: in KafkaProducerConfig (methods creating
KafkaTemplate and paymentKafkaTemplate) change props.put("acks", "1") and
props.put("acks", "all") to use ProducerConfig.ACKS_CONFIG, and change
props.put("enable.idempotence", true) to
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG; update both producer creation sites
(e.g., the method that returns a default KafkaTemplate and
paymentKafkaTemplate()) so all Kafka producer config keys use the typed
constants.
src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java (1)

24-25: 💤 Low value

Redundant single-column index.

idx_cards_user_id is a left-prefix of the composite idx_cards_user_id_status, which can already serve user_id-only lookups. Dropping the standalone index reduces write/storage overhead unless a specific query plan needs it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java` around
lines 24 - 25, The standalone index idx_cards_user_id is redundant because the
composite idx_cards_user_id_status already covers left-prefix lookups for
user_id; remove the `@Index` entry with name "idx_cards_user_id" from the Card
entity's `@Table/`@Indexes so only the composite `@Index`(name =
"idx_cards_user_id_status", columnList = "user_id, status") remains, and run
migrations/tests to ensure no query needs the single-column index.
src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java (1)

19-21: ⚡ Quick win

RedissonClient mock is sufficient for the current auction job config tests.

AuctionActivationTasklet / AuctionExpirationTasklet only call redissonClient.getLock(...).tryLock(...) while iterating over auctionRepository results. In the test profile, there’s no auction data seeding (only H2 schema create-drop/schema init; no @Sql/Auction saves found in src/test/java, and no SQL data scripts in src/test/resources), so the auction lists are empty and the lock path isn’t exercised—those jobs complete successfully without stubbing getLock.
[optional] If future tests insert auctions, stub RedissonClient#getLock(...) to return a mocked RLock to prevent NPEs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java` around
lines 19 - 21, The current Mockito.mock(RedissonClient.class) is okay for
empty-test data, but to avoid future NPEs when tests insert auctions, stub
RedissonClient#getLock(...) to return a mocked RLock and stub RLock#tryLock(...)
as needed; update the test config method redissonClient() to create a mock
RedissonClient,
Mockito.when(redissonClient.getLock(anyString())).thenReturn(mockedRLock), and
stub mockedRLock.tryLock(...) (true/false depending on desired behavior) so
AuctionActivationTasklet and AuctionExpirationTasklet calls to
getLock(...).tryLock(...) are safe.
src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java (1)

14-16: ⚡ Quick win

Same soft-delete filter gap as Series.

@SQLDelete without a @SQLRestriction("deleted_at IS NULL") means soft-deleted PokemonSet rows are still read, and the set_id unique constraint will block re-inserting a previously deleted set_id. Apply the same restriction here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java`
around lines 14 - 16, The PokemonSet entity uses `@SQLDelete` to soft-delete but
lacks the corresponding read filter, causing soft-deleted rows to still be
returned and the unique constraint on set_id to block re-inserts; update the
PokemonSet class to add the same soft-delete read restriction used for Series
(e.g., add `@SQLRestriction`("deleted_at IS NULL") or the equivalent
`@Where/clause`) alongside the existing `@SQLDelete` and `@Table`(uniqueConstraints =
`@UniqueConstraint`(columnNames = "set_id")) so queries automatically exclude rows
with deleted_at set.
src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java (1)

13-16: ⚡ Quick win

Soft delete has no read-time filter; queries will return deleted rows and unique-constraint re-inserts will fail.

@SQLDelete rewrites deletes to set deleted_at, but without a @SQLRestriction/@Where clause, all find*/JPQL reads will still include soft-deleted Series. Additionally, because the row physically remains, re-inserting a name that was previously soft-deleted will violate the uniqueConstraints = @UniqueConstraint(columnNames = "name").

♻️ Suggested filter
 `@Entity`
 `@SQLDelete`(sql = "UPDATE series SET deleted_at = NOW() WHERE id = ?")
+@org.hibernate.annotations.SQLRestriction("deleted_at IS NULL")
 `@Table`(name = "series",
         uniqueConstraints = `@UniqueConstraint`(columnNames = "name"))

Please confirm the intended read semantics for soft-deleted rows and whether re-insertion of previously deleted names is expected in the card-sync flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java`
around lines 13 - 16, The Series entity uses `@SQLDelete` to soft-delete (sets
deleted_at) but lacks a read-time filter, so queries (and unique-constraint
checks) still see soft-deleted rows; update the entity to add a read filter
(e.g., add org.hibernate.annotations.Where(clause = "deleted_at IS NULL") on the
Series class) so find*/JPQL excludes soft-deleted rows, and adjust the
uniqueness strategy for name by removing the JPA UniqueConstraint or keeping it
for schema generation but adding a DB-side partial unique index (unique index on
name WHERE deleted_at IS NULL) in the migration scripts so re-inserts of
previously deleted names are allowed; reference Series, `@SQLDelete`, deleted_at,
and BaseEntity/deletedAt when making these changes.
src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java (1)

19-29: 💤 Low value

Consider aligning method name with its flexible signature.

The method name markProcessingIfPending suggests a specific PENDING → PROCESSING transition, but the signature accepts arbitrary from and to status parameters. This flexibility could lead to unintended usage (e.g., updating processedAt when transitioning to SENT, where it's semantically incorrect). Either rename to a generic name like updateStatusConditionally or create specific methods for each transition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java`
around lines 19 - 29, The method markProcessingIfPending on OutboxRepository has
a generic signature accepting arbitrary from/to OutboxStatus values but a
specific name implying PENDING→PROCESSING and always updates processedAt; rename
or split to avoid semantic mismatch: either rename markProcessingIfPending to a
generic name like updateStatusConditionally (and ensure javadoc reflects that
processedAt is set on all transitions) or create explicit methods such as
markProcessingIfPending(Long id) and updateStatusIfFrom(Long id, OutboxStatus
from, OutboxStatus to) where the former only sets status to PROCESSING and
processedAt, and the latter omits processedAt when inappropriate (e.g.,
transitions to SENT); update method names and Javadoc and keep the `@Query` and
parameter list consistent for the chosen option.
src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java (2)

52-57: 💤 Low value

Optional: pipeline the per-auction ZSET writes.

Each iteration issues a separate ZADD round trip. For large active-auction sets this is many sequential Redis calls. Consider batching via executePipelined (or a single multi-member add) to reduce latency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java`
around lines 52 - 57, The loop in AuctionRankingService that calls
redisTemplate.opsForZSet().add(newKey, auction.getId().toString(), score) for
each Auction causes one Redis round-trip per auction; change this to batch the
ZSET writes either by using redisTemplate.executePipelined(...) and performing
the adds inside the pipeline callback, or by building a single multi-member add
(e.g., collect TypedTuple entries / a Map of member->score and call
opsForZSet().add(newKey, entries)) to send one batched request for
activeAuctions.

64-66: ⚡ Quick win

Swallowing all exceptions here makes the tasklet's failure handling unreachable.

refreshRanking() catches every Exception and returns normally, so AuctionRankingTasklet.execute() (which wraps this call in a try/catch and rethrows a RuntimeException) can never observe a failure. The ranking job will therefore always be marked COMPLETED even when the refresh actually failed. Decide on a single strategy: either let the exception propagate so the tasklet/job reflects the failure, or keep swallowing here and remove the dead catch path in the tasklet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java`
around lines 64 - 66, The catch in AuctionRankingService.refreshRanking()
currently swallows all exceptions (log.warn(..., e)) which prevents
AuctionRankingTasklet.execute() from observing failures; change refreshRanking()
to either remove the broad try/catch so exceptions propagate, or after logging
rethrow the exception (e.g., throw e or wrap and throw a RuntimeException) so
AuctionRankingTasklet.execute() and the job framework can mark the job as
FAILED; update only the refreshRanking() method in AuctionRankingService (or
alternatively remove the dead catch in AuctionRankingTasklet.execute()) to
ensure failures are not silently suppressed.
src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java (1)

56-63: 🏗️ Heavy lift

Single transaction spans the entire catalog sync — large/long-running transaction risk.

Because the step's tasklet executes inside one transaction, every cardRepository.save(...) across all sets accumulates in a single unit of work. For the full TCGdex catalog this can mean a very large transaction (memory pressure in the persistence context, long-held DB locks, expensive rollback). A chunk-oriented step or per-set/per-card transaction boundary would bound the transaction size and let already-synced cards survive a mid-run failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java`
around lines 56 - 63, Current implementation in CardSyncTasklet runs all set
syncs inside one transaction, risking huge transactions; change to bound
transactions per set or per card by moving persistence out of the single Tasklet
transaction: refactor syncSet(String) to run in its own transactional boundary
(e.g., annotate syncSet or a new service method with `@Transactional`(propagation
= REQUIRES_NEW)) and ensure you flush/clear the persistence context (use
cardRepository.saveAndFlush(...) and EntityManager.clear()) after each set (or
implement a chunk-oriented Step using Spring Batch with a
reader/processor/writer instead of a long-running Tasklet) so that
cardRepository.save does not accumulate across all sets.
src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java (2)

30-34: ⚡ Quick win

Prefer serializing the outbox payload with an ObjectMapper instead of hand-built JSON.

The String.format approach is duplicated across activateAuction/endAuction and is fragile: it relies on LocalDateTime.toString() for the timestamp format (no control over ISO formatting/timezone) and would silently produce invalid JSON if any future field contained a quote/backslash. A shared ObjectMapper (or a small payload record) makes the contract explicit and consistent for consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java`
around lines 30 - 34, Replace the hand-built JSON payload in AuctionBatchService
(currently created via String.format in the activateAuction/endAuction paths)
with a properly serialized object: create a small payload record/class (e.g.,
AuctionOutboxPayload with auctionId, status, startedAt/endedAt as
Instant/OffsetDateTime/LocalDateTime) and use a shared ObjectMapper to serialize
it before calling outboxEventWriter.write; ensure the mapper is configured for
the desired ISO timestamp format and reuse the same serialization code for both
activateAuction and endAuction so the payload construction is safe, consistent,
and not reliant on LocalDateTime.toString().

25-25: 💤 Low value

Auction duration (7 hours) is a hard-coded magic number.

Consider externalizing this to configuration (e.g. a properties value) so the auction lifetime can be tuned without a code change and is documented alongside the other batch timing settings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java`
at line 25, Replace the hard-coded "7" hour duration used in AuctionBatchService
where endedAt is computed (now.plusHours(7)) with a configurable property: add a
configuration property (e.g. auction.duration.hours) in
application.properties/yml, bind it to a config class or inject it via `@Value`
into AuctionBatchService (or constructor) as an int/long, and use that injected
value (e.g. now.plusHours(auctionDurationHours)) when computing endedAt so the
auction lifetime is tunable without code changes.
src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java (1)

49-65: 💤 Low value

Same unreachable 5xx branch as MainAppBuyoutClient.

HttpClientErrorException is always 4xx, so Lines 54-65 (the // 5xx 오류는 재시도 retry path) are dead; 5xx is handled by the generic catch (Exception e) at Line 66.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java`
around lines 49 - 65, The current catch for HttpClientErrorException in
MainAppRefundClient makes the 5xx retry branch unreachable; change the exception
handling to either (a) split into two catches — catch HttpClientErrorException
for 4xx (keep the warn + RuntimeException skip) and add a catch
HttpServerErrorException to implement the 5xx retry logic (use attempt,
maxRetries, delayMs as in the diff), or (b) replace the single catch with catch
HttpStatusCodeException and inspect e.getStatusCode().is4xxClientError() vs
is5xxServerError() to decide skip vs retry—preserve the existing logging,
rethrow behavior, backoff (Thread.sleep and delayMs *= 2), and keep the generic
catch (Exception e) afterwards.
src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java (2)

32-80: ⚖️ Poor tradeoff

Retry/backoff logic is duplicated verbatim with MainAppRefundClient.

The entire attempt loop (headers, 3-attempt exponential backoff, 4xx-skip, interrupt handling) is identical except for the URL and log strings. Consider extracting a shared internal HTTP helper (e.g. a MainAppInternalClient that takes path + idempotency key) to avoid divergence over time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java`
around lines 32 - 80, Duplicate retry/backoff and header logic in recoverBuyout
is identical to MainAppRefundClient; extract it into a shared helper to avoid
duplication. Create a MainAppInternalClient (or static/internal method) that
accepts path (e.g. "/internal/auctions/%d/recover-buyout"), idempotency key
string, HttpMethod (POST), and returns the response or throws; move the loop,
retry count, exponential backoff, 4xx skip handling, InterruptedException
handling, headers creation (X-Internal-Token using internalToken and
Idempotency-Key) and restTemplate.exchange usage into that helper; then simplify
MainAppBuyoutClient.recoverBuyout to call
MainAppInternalClient.performWithRetry(path, idempotencyKey) and do only logging
on success/failure. Reference symbols: recoverBuyout, MainAppRefundClient,
MainAppInternalClient (new), restTemplate.exchange, internalToken, baseUrl,
Idempotency-Key.

49-65: 💤 Low value

Unreachable 5xx retry branch inside the HttpClientErrorException catch.

HttpClientErrorException is thrown only for 4xx, so e.getStatusCode().is4xxClientError() is always true and the method always throws at Line 52. The // 5xx 오류는 재시도 comment and the retry/backoff block below it (Lines 54-65) are dead code — actual 5xx responses surface as HttpServerErrorException and are handled by the generic catch (Exception e) at Line 66.

♻️ Drop the dead branch
             } catch (HttpClientErrorException e) {
-                if (e.getStatusCode().is4xxClientError()) {
-                    log.warn("경매 구매 확정 복구 4xx 오류: auctionId={}, status={}", auctionId, e.getStatusCode());
-                    throw new RuntimeException("4xx 오류로 스킵", e);
-                }
-                // 5xx 오류는 재시도
-                if (attempt == maxRetries) {
-                    log.error("경매 구매 확정 복구 최대 재시도 초과: auctionId={}", auctionId, e);
-                    throw e;
-                }
-                try {
-                    Thread.sleep(delayMs);
-                    delayMs *= 2;
-                } catch (InterruptedException ie) {
-                    Thread.currentThread().interrupt();
-                    throw new RuntimeException(ie);
-                }
+                log.warn("경매 구매 확정 복구 4xx 오류: auctionId={}, status={}", auctionId, e.getStatusCode());
+                throw new RuntimeException("4xx 오류로 스킵", e);
             } catch (Exception e) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java`
around lines 49 - 65, The catch for HttpClientErrorException contains an
unreachable 5xx retry branch; remove the retry/backoff logic from the
HttpClientErrorException handler (keep the 4xx warning and throw) and move or
duplicate the retry/backoff behavior into a catch for HttpServerErrorException
(or the existing generic Exception handler) so that server-side 5xx responses
use attempt/maxRetries, delayMs exponential backoff, logging and rethrowing as
intended; update logging messages in the HttpServerErrorException handler to
match the existing "경매 구매 확정 복구 최대 재시도 초과" pattern and preserve the
InterruptedException handling (Thread.currentThread().interrupt()) used around
Thread.sleep.
src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxProcessor.java (1)

42-58: 🏗️ Heavy lift

Blocking Kafka publish runs inside the open REQUIRES_NEW transaction.

The synchronous template.send(...).get(5, TimeUnit.SECONDS) is executed while the per-event transaction is open and the row is already locked (preempted to PROCESSING). Each event therefore holds a DB connection and row lock for up to 5s of network I/O, and the relay processes these sequentially, so a slow broker stretches the whole step and keeps locks alive longer than necessary.

Consider committing the PROCESSING preemption first, publishing outside the transaction, then marking SENT/retry in a short follow-up transaction. This keeps DB transactions off the network path while preserving the atomic claim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxProcessor.java`
around lines 42 - 58, The code currently performs template.send(...).get(5,
TimeUnit.SECONDS) inside the REQUIRES_NEW transaction in OutboxProcessor while
the row is marked PROCESSING, holding the DB lock; refactor so the claim
(marking PROCESSING) is committed first, perform the blocking Kafka send outside
any transaction, then in a short separate transactional method update the row to
SENT (use managed.markSent()) or to retry (use managed.markPendingForRetry())
based on the send result; move the exception handling that sets retry state into
that follow-up transactional method and ensure the blocking template.send call
is not executed while the original transaction is open.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java`:
- Around line 31-34: The code currently embeds redisPassword into redisUrl which
breaks on URL-reserved characters; instead build the address without credentials
using redisHost and redisPort (e.g. "redis://host:port") and, when redisPassword
is non-null/ non-empty, call config.useSingleServer().setPassword(redisPassword)
to supply credentials; update the block that constructs redisUrl and the call to
config.useSingleServer() so address is set separately and setPassword is used
when needed.

In `@src/main/java/com/rocketcrew/pocatbatch/config/RestClientConfig.java`:
- Around line 21-24: RestClientConfig currently calls deprecated
RestTemplateBuilder#setConnectTimeout and `#setReadTimeout`; update the builder
usage to call the non-deprecated methods connectTimeout and readTimeout instead,
passing the same Duration values (e.g., Duration.ofSeconds(5) and
Duration.ofSeconds(10)) so the returned builder.build() uses the new APIs;
locate the call site where the builder variable is configured (the code that
calls setConnectTimeout/setReadTimeout) and replace those calls with
connectTimeout(...) and readTimeout(...).

In `@src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java`:
- Around line 18-32: The unique constraint on tcgdex_id conflicts with
Hibernate's `@SQLRestriction` hiding soft-deleted rows, causing
cardRepository.existsByTcgdexId(...) in CardSyncTasklet to miss soft-deleted
records and attempt inserts that violate the DB constraint; fix by changing the
sync flow to detect soft-deleted rows and revive/update them instead of
inserting: add/implement a repository method that queries by tcgdexId while
ignoring the `@SQLRestriction` (e.g., findByTcgdexIdIncludingDeleted or a native
query), then in CardSyncTasklet use that method to check for an existing
soft-deleted Card and if found clear deleted_at and update fields (save as
update) rather than calling save to insert; alternatively, if you prefer
DB-level changes, replace the table unique constraint on tcgdex_id with a
partial/filtered unique index that applies only when deleted_at IS NULL so
inserts won’t conflict with soft-deleted rows (refer to Card,
`@SQLRestriction/`@SQLDelete, CardSyncTasklet, and
cardRepository.existsByTcgdexId).

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java`:
- Around line 40-48: The `@Modifying` annotation on method deleteOldEvents in
OutboxRepository should include clearAutomatically = true to avoid stale
entities after the bulk DELETE; update the `@Modifying` on deleteOldEvents (the
method named deleteOldEvents with `@Query` "DELETE FROM OutboxEvent...") to
`@Modifying`(clearAutomatically = true) so the persistence context is cleared
automatically after the operation, matching the other modifying query.
- Around line 31-38: The query method findByStatusAndProcessedAtBefore currently
returns an unbounded List which can OOM if many OutboxEvent rows are stuck in
PROCESSING; modify it to enforce a result limit by either renaming/overloading
the method to a bounded variant such as
findTop100ByStatusAndProcessedAtBefore(...) or add a Pageable parameter (e.g.,
Pageable pageable) and update the `@Query` signature accordingly so callers can
control page size; ensure the method still takes OutboxStatus status and
LocalDateTime before and that callers are updated to pass the desired
limit/pageable.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java`:
- Around line 36-43: The current OutboxEventWriter.write(String, String, String,
Object) method wraps the entire method in a try/catch so persistence errors
thrown by the overloaded write(String, String, String, String) call get caught
and rewrapped as a serialization failure; instead, only surround the
objectMapper.writeValueAsString(...) call with a try/catch (or perform
serialization into a local String payload first and handle its
JsonProcessingException specifically), then call the existing write(topic,
partitionKey, eventType, payload) without catching its exceptions so persistence
RuntimeExceptions propagate unchanged; adjust exception messages to reflect
serialization-only errors and keep the overloaded write(...) behavior intact.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/pokemon/entity/Pokemon.java`:
- Around line 13-16: The Pokemon entity declares `@SQLDelete` but lacks a matching
`@SQLRestriction`, so soft-deleted rows still appear; update the Pokemon class to
add `@SQLRestriction`("deleted_at IS NULL") (importing
org.hibernate.annotations.SQLRestriction) alongside the existing `@SQLDelete`, and
apply the same change to the PokemonSet entity/class so both use `@SQLDelete`(...)
and `@SQLRestriction`("deleted_at IS NULL") to enforce soft-delete filtering on
reads; ensure the annotations sit on the entity class declarations (e.g.,
Pokemon and PokemonSet) and adjust imports accordingly.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/refund/repository/RefundRepository.java`:
- Around line 19-26: The query in RefundRepository.findRetryableTargets
currently excludes FAILED_RETRYABLE rows with null nextRetryAt so they never get
retried; update the JPQL condition for the retryable branch to include rows
where r.nextRetryAt IS NULL OR r.nextRetryAt <= :now (i.e., change "(r.status =
:retryable AND r.nextRetryAt <= :now)" to "(r.status = :retryable AND
(r.nextRetryAt IS NULL OR r.nextRetryAt <= :now))") so that behavior matches
Refund.isRetryDue and RefundRetryTasklet will receive immediately due refunds;
leave the PROCESSING/updatedAt clause unchanged.

In
`@src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java`:
- Line 37: The tryLock call in AuctionActivationTasklet (the RLock.tryLock(0,
30, TimeUnit.SECONDS) invocation) can throw InterruptedException but the current
catch swallows it; modify the code to catch InterruptedException separately,
call Thread.currentThread().interrupt() to restore the interrupt flag, and then
handle/propagate as appropriate (e.g., exit the tasklet or rethrow a
runtime/checked exception) while keeping the existing general Exception catch
for other errors.
- Around line 36-50: The finally block unconditionally calls lock.unlock(),
which can throw IllegalMonitorStateException if the 30s lease expired; update
the cleanup to only unlock when the current thread still holds the lock (use
RLock.isHeldByCurrentThread()) or catch IllegalMonitorStateException around
lock.unlock(). Locate the lock created via
redissonClient.getLock("auction:lock:" + auction.getId()) and the tryLock(...) /
activateAuction(auction) block and change the finally to perform a guarded
unlock (check isHeldByCurrentThread() or catch the exception) so we don't crash
the iteration when the lease elapsed.

In
`@src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayJobConfig.java`:
- Around line 28-35: The job currently stops if OutboxRelayTasklet throws
because outboxRelayJob() uses simple .start(...).next(...), so to ensure
outboxReaperStep() and outboxCleanupStep() run even when outboxRelayStep fails,
change the job flow to handle exit statuses (e.g. use the JobBuilder/Flow API
with conditional transitions) so outboxRelayStep()
.on("FAILED").to(outboxReaperStep()).from(outboxRelayStep()).on("*").to(outboxReaperStep()).next(outboxCleanupStep()).end().build();
alternatively, modify OutboxRelayTasklet to catch exceptions and set the
StepExecution exit status to COMPLETED/CONTINUE so downstream steps
outboxReaperStep and outboxCleanupStep still execute—update outboxRelayJob,
OutboxRelayTasklet, and any Step/ExitStatus handling accordingly.

In `@src/main/resources/application.yaml`:
- Around line 45-47: The current default for main-app.internal-token uses a
hardcoded fallback ("dev-token") via ${POCAT_INTERNAL_TOKEN:dev-token}; change
this to require the env var so misconfiguration fails fast — remove the fallback
and use a required expansion (e.g. ${POCAT_INTERNAL_TOKEN} or the shell-style
fail-fast form ${POCAT_INTERNAL_TOKEN:?POCAT_INTERNAL_TOKEN must be set}) so the
application will not start with a predictable secret when POCAT_INTERNAL_TOKEN
is unset.

---

Duplicate comments:
In
`@src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java`:
- Around line 41-55: The finally block in AuctionExpirationTasklet currently
calls lock.unlock() unconditionally which can throw IllegalMonitorStateException
if the lease expired or the current thread doesn't hold the lock; update the
finally to check lock.isHeldByCurrentThread() before calling lock.unlock().
Locate the RLock usage in AuctionExpirationTasklet (the block that calls
redissonClient.getLock("auction:lock:" + auction.getId()), tryLock(...), and
finally) and wrap the unlock with an if (lock.isHeldByCurrentThread()) guard to
safely release only when held by the current thread.

---

Nitpick comments:
In `@src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java`:
- Around line 32-80: Duplicate retry/backoff and header logic in recoverBuyout
is identical to MainAppRefundClient; extract it into a shared helper to avoid
duplication. Create a MainAppInternalClient (or static/internal method) that
accepts path (e.g. "/internal/auctions/%d/recover-buyout"), idempotency key
string, HttpMethod (POST), and returns the response or throws; move the loop,
retry count, exponential backoff, 4xx skip handling, InterruptedException
handling, headers creation (X-Internal-Token using internalToken and
Idempotency-Key) and restTemplate.exchange usage into that helper; then simplify
MainAppBuyoutClient.recoverBuyout to call
MainAppInternalClient.performWithRetry(path, idempotencyKey) and do only logging
on success/failure. Reference symbols: recoverBuyout, MainAppRefundClient,
MainAppInternalClient (new), restTemplate.exchange, internalToken, baseUrl,
Idempotency-Key.
- Around line 49-65: The catch for HttpClientErrorException contains an
unreachable 5xx retry branch; remove the retry/backoff logic from the
HttpClientErrorException handler (keep the 4xx warning and throw) and move or
duplicate the retry/backoff behavior into a catch for HttpServerErrorException
(or the existing generic Exception handler) so that server-side 5xx responses
use attempt/maxRetries, delayMs exponential backoff, logging and rethrowing as
intended; update logging messages in the HttpServerErrorException handler to
match the existing "경매 구매 확정 복구 최대 재시도 초과" pattern and preserve the
InterruptedException handling (Thread.currentThread().interrupt()) used around
Thread.sleep.

In `@src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java`:
- Around line 49-65: The current catch for HttpClientErrorException in
MainAppRefundClient makes the 5xx retry branch unreachable; change the exception
handling to either (a) split into two catches — catch HttpClientErrorException
for 4xx (keep the warn + RuntimeException skip) and add a catch
HttpServerErrorException to implement the 5xx retry logic (use attempt,
maxRetries, delayMs as in the diff), or (b) replace the single catch with catch
HttpStatusCodeException and inspect e.getStatusCode().is4xxClientError() vs
is5xxServerError() to decide skip vs retry—preserve the existing logging,
rethrow behavior, backoff (Thread.sleep and delayMs *= 2), and keep the generic
catch (Exception e) afterwards.

In `@src/main/java/com/rocketcrew/pocatbatch/config/KafkaProducerConfig.java`:
- Around line 31-47: Replace raw string property keys with the ProducerConfig
constants to avoid silent typos: in KafkaProducerConfig (methods creating
KafkaTemplate and paymentKafkaTemplate) change props.put("acks", "1") and
props.put("acks", "all") to use ProducerConfig.ACKS_CONFIG, and change
props.put("enable.idempotence", true) to
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG; update both producer creation sites
(e.g., the method that returns a default KafkaTemplate and
paymentKafkaTemplate()) so all Kafka producer config keys use the typed
constants.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java`:
- Around line 52-57: The loop in AuctionRankingService that calls
redisTemplate.opsForZSet().add(newKey, auction.getId().toString(), score) for
each Auction causes one Redis round-trip per auction; change this to batch the
ZSET writes either by using redisTemplate.executePipelined(...) and performing
the adds inside the pipeline callback, or by building a single multi-member add
(e.g., collect TypedTuple entries / a Map of member->score and call
opsForZSet().add(newKey, entries)) to send one batched request for
activeAuctions.
- Around line 64-66: The catch in AuctionRankingService.refreshRanking()
currently swallows all exceptions (log.warn(..., e)) which prevents
AuctionRankingTasklet.execute() from observing failures; change refreshRanking()
to either remove the broad try/catch so exceptions propagate, or after logging
rethrow the exception (e.g., throw e or wrap and throw a RuntimeException) so
AuctionRankingTasklet.execute() and the job framework can mark the job as
FAILED; update only the refreshRanking() method in AuctionRankingService (or
alternatively remove the dead catch in AuctionRankingTasklet.execute()) to
ensure failures are not silently suppressed.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java`:
- Around line 30-34: Replace the hand-built JSON payload in AuctionBatchService
(currently created via String.format in the activateAuction/endAuction paths)
with a properly serialized object: create a small payload record/class (e.g.,
AuctionOutboxPayload with auctionId, status, startedAt/endedAt as
Instant/OffsetDateTime/LocalDateTime) and use a shared ObjectMapper to serialize
it before calling outboxEventWriter.write; ensure the mapper is configured for
the desired ISO timestamp format and reuse the same serialization code for both
activateAuction and endAuction so the payload construction is safe, consistent,
and not reliant on LocalDateTime.toString().
- Line 25: Replace the hard-coded "7" hour duration used in AuctionBatchService
where endedAt is computed (now.plusHours(7)) with a configurable property: add a
configuration property (e.g. auction.duration.hours) in
application.properties/yml, bind it to a config class or inject it via `@Value`
into AuctionBatchService (or constructor) as an int/long, and use that injected
value (e.g. now.plusHours(auctionDurationHours)) when computing endedAt so the
auction lifetime is tunable without code changes.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java`:
- Around line 24-25: The standalone index idx_cards_user_id is redundant because
the composite idx_cards_user_id_status already covers left-prefix lookups for
user_id; remove the `@Index` entry with name "idx_cards_user_id" from the Card
entity's `@Table/`@Indexes so only the composite `@Index`(name =
"idx_cards_user_id_status", columnList = "user_id, status") remains, and run
migrations/tests to ensure no query needs the single-column index.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java`:
- Around line 19-29: The method markProcessingIfPending on OutboxRepository has
a generic signature accepting arbitrary from/to OutboxStatus values but a
specific name implying PENDING→PROCESSING and always updates processedAt; rename
or split to avoid semantic mismatch: either rename markProcessingIfPending to a
generic name like updateStatusConditionally (and ensure javadoc reflects that
processedAt is set on all transitions) or create explicit methods such as
markProcessingIfPending(Long id) and updateStatusIfFrom(Long id, OutboxStatus
from, OutboxStatus to) where the former only sets status to PROCESSING and
processedAt, and the latter omits processedAt when inappropriate (e.g.,
transitions to SENT); update method names and Javadoc and keep the `@Query` and
parameter list consistent for the chosen option.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxProcessor.java`:
- Around line 42-58: The code currently performs template.send(...).get(5,
TimeUnit.SECONDS) inside the REQUIRES_NEW transaction in OutboxProcessor while
the row is marked PROCESSING, holding the DB lock; refactor so the claim
(marking PROCESSING) is committed first, perform the blocking Kafka send outside
any transaction, then in a short separate transactional method update the row to
SENT (use managed.markSent()) or to retry (use managed.markPendingForRetry())
based on the send result; move the exception handling that sets retry state into
that follow-up transactional method and ensure the blocking template.send call
is not executed while the original transaction is open.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java`:
- Around line 13-16: The Series entity uses `@SQLDelete` to soft-delete (sets
deleted_at) but lacks a read-time filter, so queries (and unique-constraint
checks) still see soft-deleted rows; update the entity to add a read filter
(e.g., add org.hibernate.annotations.Where(clause = "deleted_at IS NULL") on the
Series class) so find*/JPQL excludes soft-deleted rows, and adjust the
uniqueness strategy for name by removing the JPA UniqueConstraint or keeping it
for schema generation but adding a DB-side partial unique index (unique index on
name WHERE deleted_at IS NULL) in the migration scripts so re-inserts of
previously deleted names are allowed; reference Series, `@SQLDelete`, deleted_at,
and BaseEntity/deletedAt when making these changes.

In `@src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java`:
- Around line 14-16: The PokemonSet entity uses `@SQLDelete` to soft-delete but
lacks the corresponding read filter, causing soft-deleted rows to still be
returned and the unique constraint on set_id to block re-inserts; update the
PokemonSet class to add the same soft-delete read restriction used for Series
(e.g., add `@SQLRestriction`("deleted_at IS NULL") or the equivalent
`@Where/clause`) alongside the existing `@SQLDelete` and `@Table`(uniqueConstraints =
`@UniqueConstraint`(columnNames = "set_id")) so queries automatically exclude rows
with deleted_at set.

In `@src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java`:
- Around line 56-63: Current implementation in CardSyncTasklet runs all set
syncs inside one transaction, risking huge transactions; change to bound
transactions per set or per card by moving persistence out of the single Tasklet
transaction: refactor syncSet(String) to run in its own transactional boundary
(e.g., annotate syncSet or a new service method with `@Transactional`(propagation
= REQUIRES_NEW)) and ensure you flush/clear the persistence context (use
cardRepository.saveAndFlush(...) and EntityManager.clear()) after each set (or
implement a chunk-oriented Step using Spring Batch with a
reader/processor/writer instead of a long-running Tasklet) so that
cardRepository.save does not accumulate across all sets.

In `@src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java`:
- Around line 19-21: The current Mockito.mock(RedissonClient.class) is okay for
empty-test data, but to avoid future NPEs when tests insert auctions, stub
RedissonClient#getLock(...) to return a mocked RLock and stub RLock#tryLock(...)
as needed; update the test config method redissonClient() to create a mock
RedissonClient,
Mockito.when(redissonClient.getLock(anyString())).thenReturn(mockedRLock), and
stub mockedRLock.tryLock(...) (true/false depending on desired behavior) so
AuctionActivationTasklet and AuctionExpirationTasklet calls to
getLock(...).tryLock(...) are safe.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3264e994-c307-41bb-8419-a9a6a864bbd7

📥 Commits

Reviewing files that changed from the base of the PR and between 1bf3402 and 57ffda8.

📒 Files selected for processing (68)
  • build.gradle
  • gradle/wrapper/gradle-wrapper.properties
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java
  • src/main/java/com/rocketcrew/pocatbatch/config/KafkaProducerConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RestClientConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/ai/entity/AiChatSession.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/ai/repository/AiChatSessionRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/entity/Auction.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/enums/AuctionStatus.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/config/AuctionRankingProperties.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/dto/AuctionCountProjection.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/repository/AuctionBidRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/repository/LikeRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/repository/AuctionRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/bid/entity/AuctionBid.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/bid/enums/BidStatus.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/enums/CardCategory.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/enums/CardGrade.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/enums/CardSource.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/enums/CardStatus.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/repository/CardRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/like/entity/Like.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/entity/OutboxEvent.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/enums/OutboxStatus.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxProcessor.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/pokemon/entity/Pokemon.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/refund/entity/Refund.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/refund/entity/RefundStatus.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/refund/repository/RefundRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/aisession/AiSessionCleanupJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/aisession/AiSessionCleanupTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionranking/AuctionRankingJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionranking/AuctionRankingTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/buyoutrecovery/BuyoutRecoveryJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/buyoutrecovery/BuyoutRecoveryTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxCleanupTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxReaperTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/refundretry/RefundRetryJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/refundretry/RefundRetryTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/scheduler/BatchScheduler.java
  • src/main/resources/application.yaml
  • src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java
  • src/test/java/com/rocketcrew/pocatbatch/job/aisession/AiSessionCleanupJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/auctionranking/AuctionRankingJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/buyoutrecovery/BuyoutRecoveryJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayJobConfigTest.java
  • src/test/java/com/rocketcrew/pocatbatch/job/refundretry/RefundRetryJobConfigTest.java
  • src/test/resources/application.yaml

Comment thread src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java Outdated
Comment thread src/main/resources/application.yaml Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java (1)

49-60: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the ranking refresh single-writer.

newKey is shared by every execution, so overlapping runs can delete/rename each other’s staging ZSET and let an older snapshot overwrite a newer one. Since AuctionRankingTasklet invokes this method directly, this job still needs an execution lock or a run-scoped staging key plus a winner check before the final swap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java`
around lines 49 - 60, newKey is shared across concurrent runs causing race
conditions; change to use a run-scoped staging key and enforce single-writer
before swapping. In AuctionRankingService.buildAndSwapRanking (the block that
creates newKey, calls trimToCacheSize and redisTemplate.rename), generate a
unique staging key (e.g., RANKING_KEY + ":staging:" + runId/UUID), write the
ZSET there, trim it, then perform a safe swap only if this runner is the
winner—either acquire a short Redis distributed lock (SET NX with TTL) before
the rename and only rename while holding the lock, or use a Lua
script/transaction that checks a winner token and atomically renames/stores the
staging set into RANKING_KEY. Also ensure AuctionRankingTasklet obtains the same
execution lock or supplies a runId so only the winner executes the final rename.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java`:
- Around line 45-48: The catch block in AuctionExpirationTasklet that handles
InterruptedException after RLock.tryLock(...) currently re-interrupts the thread
and continues, which swallows the interruption and lets the step return
RepeatStatus.FINISHED; instead, stop normal processing and abort the batch step
by rethrowing the interruption (or throwing a runtime exception that wraps the
InterruptedException) from that catch block. Update the catch for
InterruptedException ie in AuctionExpirationTasklet to call
Thread.currentThread().interrupt() and then throw ie (or throw new
RuntimeException(ie)) so the step fails/aborts instead of continuing; keep the
log.warn("경매 종료 락 획득 중 인터럽트: auctionId={}", auction.getId()) for context.

---

Outside diff comments:
In
`@src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java`:
- Around line 49-60: newKey is shared across concurrent runs causing race
conditions; change to use a run-scoped staging key and enforce single-writer
before swapping. In AuctionRankingService.buildAndSwapRanking (the block that
creates newKey, calls trimToCacheSize and redisTemplate.rename), generate a
unique staging key (e.g., RANKING_KEY + ":staging:" + runId/UUID), write the
ZSET there, trim it, then perform a safe swap only if this runner is the
winner—either acquire a short Redis distributed lock (SET NX with TTL) before
the rename and only rename while holding the lock, or use a Lua
script/transaction that checks a winner token and atomically renames/stores the
staging set into RANKING_KEY. Also ensure AuctionRankingTasklet obtains the same
execution lock or supplies a runId so only the winner executes the final rename.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c210006a-ee7b-4212-bff2-ecccb0f9b4ae

📥 Commits

Reviewing files that changed from the base of the PR and between 57ffda8 and 590ce56.

📒 Files selected for processing (20)
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RestClientConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/auction/ranking/service/AuctionRankingService.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/repository/CardRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/pokemon/entity/Pokemon.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/refund/repository/RefundRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxReaperTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayJobConfig.java
  • src/main/resources/application.yaml
  • src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java
💤 Files with no reviewable changes (1)
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/entity/Card.java
🚧 Files skipped from review as they are similar to previous changes (16)
  • src/main/java/com/rocketcrew/pocatbatch/domain/card/repository/CardRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RestClientConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/series/entity/Series.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/pokemon/entity/Pokemon.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/set/entity/PokemonSet.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxRelayJobConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/refund/repository/RefundRepository.java
  • src/test/java/com/rocketcrew/pocatbatch/config/BatchTestConfig.java
  • src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java
  • src/main/java/com/rocketcrew/pocatbatch/domain/outbox/repository/OutboxRepository.java
  • src/main/java/com/rocketcrew/pocatbatch/job/outboxrelay/OutboxReaperTasklet.java
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppBuyoutClient.java
  • src/main/java/com/rocketcrew/pocatbatch/client/MainAppRefundClient.java
  • src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java

@cdkkyj123
cdkkyj123 merged commit e03d2dd into main Jun 2, 2026
1 check passed
@cdkkyj123
cdkkyj123 deleted the feat/batch-scheduler-migration/#171 branch June 2, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant