From c3fd31beafeb71b1f1de7e34196e07f8b7fdc43b Mon Sep 17 00:00:00 2001 From: cdkkyj123 Date: Mon, 15 Jun 2026 23:19:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(#235):=20=EA=B2=BD=EB=A7=A4=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94/=EB=A7=8C=EB=A3=8C/=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EB=A9=94=EC=9D=B8=EC=95=B1=20internal=20API=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/MainAuctionLifecycleClient.java | 99 ++++++ .../pocatbatch/client/MainCardSyncClient.java | 62 ++++ .../domain/auction/entity/Auction.java | 17 - .../auction/service/AuctionBatchService.java | 69 ---- .../outbox/service/OutboxEventWriter.java | 46 --- .../AuctionActivationTasklet.java | 63 ++-- .../AuctionExpirationTasklet.java | 70 ++-- .../job/cardsync/CardSyncTasklet.java | 129 +------- .../MainAuctionLifecycleClientTest.java | 306 ++++++++++++++++++ .../client/MainCardSyncClientTest.java | 134 ++++++++ .../AuctionActivationTaskletTest.java | 124 +++++++ .../AuctionExpirationTaskletTest.java | 130 ++++++++ .../job/cardsync/CardSyncTaskletTest.java | 69 ++++ 13 files changed, 985 insertions(+), 333 deletions(-) create mode 100644 src/main/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClient.java create mode 100644 src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java delete mode 100644 src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java delete mode 100644 src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java create mode 100644 src/test/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClientTest.java create mode 100644 src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java create mode 100644 src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java create mode 100644 src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java create mode 100644 src/test/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTaskletTest.java diff --git a/src/main/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClient.java b/src/main/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClient.java new file mode 100644 index 0000000..47dda44 --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClient.java @@ -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 request = new HttpEntity<>(headers); + + int maxRetries = 3; + long delayMs = 1000; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + ResponseEntity> responseEntity = restTemplate.exchange( + url, HttpMethod.POST, request, + new ParameterizedTypeReference>() {}); + ApiResponseEnvelope 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("재시도 루프를 빠져나올 수 없습니다"); + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java b/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java new file mode 100644 index 0000000..f73599a --- /dev/null +++ b/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java @@ -0,0 +1,62 @@ +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 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.warn("카드 동기화 트리거 4xx 오류로 스킵: jobExecutionId={}, status={}, body={}", + jobExecutionId, e.getStatusCode(), responseBody); + } + } +} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/auction/entity/Auction.java b/src/main/java/com/rocketcrew/pocatbatch/domain/auction/entity/Auction.java index 85f8c25..fb82a43 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/domain/auction/entity/Auction.java +++ b/src/main/java/com/rocketcrew/pocatbatch/domain/auction/entity/Auction.java @@ -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; - } } diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java b/src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java deleted file mode 100644 index fecf3f9..0000000 --- a/src/main/java/com/rocketcrew/pocatbatch/domain/auction/service/AuctionBatchService.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.rocketcrew.pocatbatch.domain.auction.service; - -import com.rocketcrew.pocatbatch.domain.auction.entity.Auction; -import com.rocketcrew.pocatbatch.domain.auction.ranking.repository.AuctionBidRepository; -import com.rocketcrew.pocatbatch.domain.auction.repository.AuctionRepository; -import com.rocketcrew.pocatbatch.domain.outbox.service.OutboxEventWriter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AuctionBatchService { - - private final AuctionRepository auctionRepository; - private final OutboxEventWriter outboxEventWriter; - private final AuctionBidRepository auctionBidRepository; - - @Transactional - public void activateAuction(Auction auction) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime startedAt = now; - LocalDateTime endedAt = now.plusHours(7); - - auction.activate(startedAt, endedAt); - auctionRepository.save(auction); - - Map payload = new HashMap<>(); - payload.put("eventType", "auction.activated"); - payload.put("auctionId", auction.getId()); - payload.put("sellerId", auction.getSellerId()); - payload.put("startedAt", startedAt.toString()); - payload.put("endedAt", endedAt.toString()); - - outboxEventWriter.write("auction", String.valueOf(auction.getId()), "auction.activated", payload); - log.info("경매 활성화 처리: auctionId={}", auction.getId()); - } - - @Transactional - public void endAuction(Auction auction) { - auction.end(); - auctionRepository.save(auction); - - Long winnerId = auction.getHighestBidderId(); - List loserIds = winnerId != null - ? auctionBidRepository.findLoserIdsByAuctionIdExcluding(auction.getId(), winnerId) - : auctionBidRepository.findAllBidderIdsByAuctionId(auction.getId()); - - Map payload = new HashMap<>(); - payload.put("eventType", "auction.ended"); - payload.put("auctionId", auction.getId()); - payload.put("cardId", auction.getCardId()); - payload.put("sellerId", auction.getSellerId()); - payload.put("winnerId", winnerId); - payload.put("finalPrice", auction.getHighestPrice()); - payload.put("endedAt", auction.getEndedAt().toString()); - payload.put("loserIds", loserIds); - - outboxEventWriter.write("auction", String.valueOf(auction.getId()), "auction.ended", payload); - log.info("경매 종료 처리: auctionId={}", auction.getId()); - } -} diff --git a/src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java b/src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java deleted file mode 100644 index 8e28c54..0000000 --- a/src/main/java/com/rocketcrew/pocatbatch/domain/outbox/service/OutboxEventWriter.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.rocketcrew.pocatbatch.domain.outbox.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.rocketcrew.pocatbatch.domain.outbox.entity.OutboxEvent; -import com.rocketcrew.pocatbatch.domain.outbox.repository.OutboxRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class OutboxEventWriter { - - private final OutboxRepository outboxRepository; - private final ObjectMapper objectMapper; - - /** - * 비즈니스 이벤트를 JSON 페이로드로 변환하여 아웃박스 테이블에 PENDING 상태로 저장합니다. - * - * @param topic 카프카 토픽명 - * @param partitionKey 카프카 파티션 키 - * @param eventType 이벤트 타입 - * @param payload JSON 페이로드 - */ - public void write(String topic, String partitionKey, String eventType, String payload) { - try { - OutboxEvent outboxEvent = OutboxEvent.pending(topic, partitionKey, eventType, payload); - outboxRepository.save(outboxEvent); - } catch (Exception e) { - throw new RuntimeException("아웃박스 이벤트 저장 중 치명적 에러 발생: " + eventType, e); - } - } - - /** - * 객체를 JSON으로 변환하여 아웃박스에 저장합니다. - */ - public void write(String topic, String partitionKey, String eventType, Object eventPayload) { - String payload; - try { - payload = objectMapper.writeValueAsString(eventPayload); - } catch (JsonProcessingException e) { - throw new RuntimeException("이벤트 직렬화 실패: " + eventType, e); - } - write(topic, partitionKey, eventType, payload); - } -} diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java index 0e9189b..63761f9 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java +++ b/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java @@ -1,13 +1,11 @@ package com.rocketcrew.pocatbatch.job.auctionactivation; +import com.rocketcrew.pocatbatch.client.MainAuctionLifecycleClient; import com.rocketcrew.pocatbatch.domain.auction.entity.Auction; import com.rocketcrew.pocatbatch.domain.auction.enums.AuctionStatus; import com.rocketcrew.pocatbatch.domain.auction.repository.AuctionRepository; -import com.rocketcrew.pocatbatch.domain.auction.service.AuctionBatchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.step.tasklet.Tasklet; @@ -15,7 +13,6 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -23,47 +20,39 @@ public class AuctionActivationTasklet implements Tasklet { private final AuctionRepository auctionRepository; - private final AuctionBatchService auctionBatchService; - private final RedissonClient redissonClient; + private final MainAuctionLifecycleClient mainAuctionLifecycleClient; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - try { - List approvedAuctions = auctionRepository.findAllByStatus(AuctionStatus.APPROVED); - int activatedCount = 0; + long jobExecutionId = chunkContext.getStepContext().getStepExecution().getJobExecution().getId(); - for (Auction auction : approvedAuctions) { - RLock lock = redissonClient.getLock("auction:lock:" + auction.getId()); - boolean locked; - try { - locked = lock.tryLock(0, 30, TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - log.warn("경매 활성화 락 획득 중 인터럽트: auctionId={}", auction.getId()); - throw new RuntimeException("경매 활성화 인터럽트 발생", ie); - } - if (!locked) { - log.debug("경매 활성화 락 실패 (이미 처리 중): auctionId={}", auction.getId()); - continue; - } + List approvedAuctions = auctionRepository.findAllByStatus(AuctionStatus.APPROVED); + + if (approvedAuctions.isEmpty()) { + log.info("경매 활성화 대상 없음"); + return RepeatStatus.FINISHED; + } - try { - auctionBatchService.activateAuction(auction); + int activatedCount = 0; + int skippedCount = 0; + int failedCount = 0; + + for (Auction auction : approvedAuctions) { + try { + boolean activated = mainAuctionLifecycleClient.activate(auction.getId(), jobExecutionId); + if (activated) { activatedCount++; - } catch (Exception e) { - log.error("경매 활성화 실패: auctionId={}", auction.getId(), e); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + } else { + skippedCount++; } + } catch (Exception e) { + failedCount++; + log.error("경매 활성화 실패: auctionId={}", auction.getId(), e); } - - log.info("경매 활성화 완료: {} 개 경매 활성화됨", activatedCount); - return RepeatStatus.FINISHED; - } catch (Exception e) { - log.error("경매 활성화 작업 실패", e); - throw new RuntimeException("경매 활성화 중 오류 발생", e); } + + log.info("경매 활성화 완료: 대상={}, 활성화={}, 스킵={}, 실패={}", + approvedAuctions.size(), activatedCount, skippedCount, failedCount); + return RepeatStatus.FINISHED; } } diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java index a9ea907..2b84793 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java +++ b/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java @@ -1,13 +1,11 @@ package com.rocketcrew.pocatbatch.job.auctionexpiration; +import com.rocketcrew.pocatbatch.client.MainAuctionLifecycleClient; import com.rocketcrew.pocatbatch.domain.auction.entity.Auction; import com.rocketcrew.pocatbatch.domain.auction.enums.AuctionStatus; import com.rocketcrew.pocatbatch.domain.auction.repository.AuctionRepository; -import com.rocketcrew.pocatbatch.domain.auction.service.AuctionBatchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.step.tasklet.Tasklet; @@ -16,7 +14,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -24,51 +21,42 @@ public class AuctionExpirationTasklet implements Tasklet { private final AuctionRepository auctionRepository; - private final AuctionBatchService auctionBatchService; - private final RedissonClient redissonClient; + private final MainAuctionLifecycleClient mainAuctionLifecycleClient; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - try { - LocalDateTime now = LocalDateTime.now(); - List expiredAuctions = auctionRepository.findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc( - AuctionStatus.ACTIVE, now - ); + long jobExecutionId = chunkContext.getStepContext().getStepExecution().getJobExecution().getId(); - int expiredCount = 0; + LocalDateTime now = LocalDateTime.now(); + List expiredAuctions = auctionRepository.findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc( + AuctionStatus.ACTIVE, now + ); - for (Auction auction : expiredAuctions) { - RLock lock = redissonClient.getLock("auction:lock:" + auction.getId()); - boolean locked; - try { - locked = lock.tryLock(0, 30, TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - log.warn("경매 종료 락 획득 중 인터럽트: auctionId={}", auction.getId()); - throw new RuntimeException("경매 종료 인터럽트 발생", ie); - } - if (!locked) { - log.debug("경매 종료 락 실패 (이미 처리 중): auctionId={}", auction.getId()); - continue; - } + if (expiredAuctions.isEmpty()) { + log.info("경매 종료 대상 없음"); + return RepeatStatus.FINISHED; + } - try { - auctionBatchService.endAuction(auction); - expiredCount++; - } catch (Exception e) { - log.error("경매 종료 실패: auctionId={}", auction.getId(), e); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + int closedCount = 0; + int skippedCount = 0; + int failedCount = 0; + + for (Auction auction : expiredAuctions) { + try { + boolean closed = mainAuctionLifecycleClient.closeExpired(auction.getId(), jobExecutionId); + if (closed) { + closedCount++; + } else { + skippedCount++; } + } catch (Exception e) { + failedCount++; + log.error("경매 종료 실패: auctionId={}", auction.getId(), e); } - - log.info("경매 종료 완료: {} 개 경매 종료됨", expiredCount); - return RepeatStatus.FINISHED; - } catch (Exception e) { - log.error("경매 종료 작업 실패", e); - throw new RuntimeException("경매 종료 중 오류 발생", e); } + + log.info("경매 종료 완료: 대상={}, 종료={}, 스킵={}, 실패={}", + expiredAuctions.size(), closedCount, skippedCount, failedCount); + return RepeatStatus.FINISHED; } } diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java index a6672e0..8aa1468 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java +++ b/src/main/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTasklet.java @@ -1,146 +1,29 @@ package com.rocketcrew.pocatbatch.job.cardsync; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.rocketcrew.pocatbatch.domain.card.entity.Card; -import com.rocketcrew.pocatbatch.domain.card.entity.enums.CardCategory; -import com.rocketcrew.pocatbatch.domain.card.entity.enums.CardGrade; -import com.rocketcrew.pocatbatch.domain.card.entity.enums.CardSource; -import com.rocketcrew.pocatbatch.domain.card.entity.enums.CardStatus; -import com.rocketcrew.pocatbatch.domain.card.repository.CardRepository; -import com.rocketcrew.pocatbatch.domain.outbox.service.OutboxEventWriter; +import com.rocketcrew.pocatbatch.client.MainCardSyncClient; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.step.tasklet.Tasklet; import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.Optional; @Slf4j @Component @RequiredArgsConstructor public class CardSyncTasklet implements Tasklet { - private final CardRepository cardRepository; - private final OutboxEventWriter outboxEventWriter; - private final ObjectMapper objectMapper; - private final RestTemplate restTemplate; - - @Value("${pocat.batch.card-sync.admin-user-id:1}") - private Long adminUserId; - - private static final String TCGDEX_SETS_URL = "https://api.tcgdex.net/v2/en/sets"; - private static final String TCGDEX_SET_URL = "https://api.tcgdex.net/v2/en/sets/"; - private static final String TCGDEX_CARD_URL = "https://api.tcgdex.net/v2/en/cards/"; - - private static final CardGrade[] GRADES = CardGrade.values(); + private final MainCardSyncClient mainCardSyncClient; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - log.info("[CardSync] 배치 동기화 시작 (adminUserId={})", adminUserId); - - int totalSynced = 0; + long jobExecutionId = chunkContext.getStepContext().getStepExecution().getJobExecution().getId(); - try { - String setsJson = restTemplate.getForObject(TCGDEX_SETS_URL, String.class); - if (setsJson == null) { - log.warn("[CardSync] TCGdex 응답 null — 동기화 스킵"); - return RepeatStatus.FINISHED; - } - JsonNode setsArray = objectMapper.readTree(setsJson); + log.info("[CardSync] 메인 앱 카드 동기화 트리거 요청 시작: jobExecutionId={}", jobExecutionId); + mainCardSyncClient.triggerSync(jobExecutionId); + log.info("[CardSync] 메인 앱 카드 동기화 트리거 요청 완료: jobExecutionId={}", jobExecutionId); - for (JsonNode setNode : setsArray) { - String setId = setNode.path("id").asText(); - try { - totalSynced += syncSet(setId, totalSynced); - } catch (Exception e) { - log.warn("[CardSync] 세트 동기화 실패 ({}): {}", setId, e.getMessage()); - } - } - - } catch (Exception e) { - log.error("[CardSync] 배치 동기화 실패 — 스킵 처리", e); - } - - log.info("[CardSync] 배치 동기화 완료 — 신규 카드 총 {}개", totalSynced); return RepeatStatus.FINISHED; } - - private int syncSet(String setId, int offset) throws Exception { - String setJson = restTemplate.getForObject(TCGDEX_SET_URL + setId, String.class); - if (setJson == null) return 0; - JsonNode setRoot = objectMapper.readTree(setJson); - - String setName = setRoot.path("name").asText(""); - JsonNode cardNodes = setRoot.path("cards"); - - int synced = 0; - for (JsonNode cardNode : cardNodes) { - String tcgdexId = cardNode.path("id").asText(); - - Optional existing = cardRepository.findByTcgdexIdIncludingDeleted(tcgdexId); - if (existing.isPresent()) { - // soft-deleted 포함 이미 존재 → skip (soft-deleted여도 tcgdex_id 중복이므로 skip) - continue; - } - - try { - String cardJson = restTemplate.getForObject(TCGDEX_CARD_URL + tcgdexId, String.class); - JsonNode cardRoot = objectMapper.readTree(cardJson); - - String name = cardRoot.path("name").asText(); - String localId = cardRoot.path("localId").asText(); - String imageBase = cardRoot.path("image").asText(""); - String imageUrl = imageBase.isEmpty() ? null : imageBase + "/high.webp"; - String rarity = cardRoot.path("rarity").asText(""); - CardCategory category = parseCategory(cardRoot.path("category").asText("")); - CardGrade grade = GRADES[(offset + synced) % GRADES.length]; - - Card card = Card.builder() - .userId(adminUserId) - .tcgdexId(tcgdexId) - .name(name) - .seriesId(null) - .pokemonSetId(null) - .pokemonId(null) - .cardNumber(localId) - .rarity(rarity.isEmpty() ? "UNKNOWN" : rarity) - .category(category) - .grade(grade) - .imageUrl(imageUrl) - .source(CardSource.TCGDEX) - .status(CardStatus.ACTIVE) - .build(); - - try { - Card saved = cardRepository.save(card); - outboxEventWriter.write("card", String.valueOf(saved.getId()), "card.synced", saved.getId()); - synced++; - } catch (DataIntegrityViolationException e) { - log.warn("[CardSync] 중복 카드 스킵 (race): {}", tcgdexId); - } - - } catch (Exception e) { - log.warn("[CardSync] 카드 처리 실패 ({}): {}", tcgdexId, e.getMessage()); - } - } - - return synced; - } - - private CardCategory parseCategory(String category) { - if (category == null || category.isBlank()) return CardCategory.UNKNOWN; - return switch (category.toUpperCase()) { - case "POKEMON" -> CardCategory.POKEMON; - case "TRAINER", "TRAINERS" -> CardCategory.TRAINERS; - case "ENERGY" -> CardCategory.ENERGY; - default -> CardCategory.UNKNOWN; - }; - } } diff --git a/src/test/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClientTest.java b/src/test/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClientTest.java new file mode 100644 index 0000000..39d2969 --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/client/MainAuctionLifecycleClientTest.java @@ -0,0 +1,306 @@ +package com.rocketcrew.pocatbatch.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class MainAuctionLifecycleClientTest { + + private static final String BASE_URL = "http://localhost:8080"; + private static final String INTERNAL_TOKEN = "test-token"; + + private RestTemplate restTemplate; + private MockRestServiceServer mockServer; + private MainAuctionLifecycleClient mainAuctionLifecycleClient; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + + mainAuctionLifecycleClient = new MainAuctionLifecycleClient(restTemplate); + ReflectionTestUtils.setField(mainAuctionLifecycleClient, "baseUrl", BASE_URL); + ReflectionTestUtils.setField(mainAuctionLifecycleClient, "internalToken", INTERNAL_TOKEN); + } + + // ------------------------------------------------------------ + // activate() + // ------------------------------------------------------------ + + @Test + void activate_정상_200_응답이면_data값을_그대로_반환한다_true() { + // given + long auctionId = 1L; + long jobExecutionId = 100L; + + String responseBody = """ + { + "success": true, + "status": 200, + "data": true + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + boolean result = mainAuctionLifecycleClient.activate(auctionId, jobExecutionId); + + // then + assertThat(result).isTrue(); + mockServer.verify(); + } + + @Test + void activate_정상_200_응답이면_data값을_그대로_반환한다_false() { + // given + long auctionId = 2L; + long jobExecutionId = 101L; + + String responseBody = """ + { + "success": true, + "status": 200, + "data": false + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + boolean result = mainAuctionLifecycleClient.activate(auctionId, jobExecutionId); + + // then + assertThat(result).isFalse(); + mockServer.verify(); + } + + @Test + void activate_요청_헤더에_X_Internal_Token과_Idempotency_Key를_포함한다() { + // given + long auctionId = 10L; + long jobExecutionId = 200L; + String expectedIdempotencyKey = String.format("auction-activate-%d-%d", auctionId, jobExecutionId); + + String responseBody = """ + { + "success": true, + "status": 200, + "data": true + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(header("X-Internal-Token", INTERNAL_TOKEN)) + .andExpect(header("Idempotency-Key", expectedIdempotencyKey)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + mainAuctionLifecycleClient.activate(auctionId, jobExecutionId); + + // then + mockServer.verify(); + } + + @Test + void activate_5xx_오류시_최대_3회까지_재시도한다() { + // given + long auctionId = 20L; + long jobExecutionId = 300L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andRespond(withServerError()); + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andRespond(withServerError()); + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andRespond(withServerError()); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.activate(auctionId, jobExecutionId)) + .isInstanceOf(Exception.class); + + mockServer.verify(); + } + + @Test + void activate_401_오류시_RuntimeException을_던진다() { + // given + long auctionId = 30L; + long jobExecutionId = 400L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.activate(auctionId, jobExecutionId)) + .isInstanceOf(RuntimeException.class); + + mockServer.verify(); + } + + @Test + void activate_400_오류시_RuntimeException으로_스킵된다() { + // given + long auctionId = 40L; + long jobExecutionId = 500L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/activate")) + .andRespond(withStatus(HttpStatus.BAD_REQUEST)); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.activate(auctionId, jobExecutionId)) + .isInstanceOf(RuntimeException.class); + + mockServer.verify(); + } + + // ------------------------------------------------------------ + // closeExpired() + // ------------------------------------------------------------ + + @Test + void closeExpired_정상_200_응답이면_data값을_그대로_반환한다_true() { + // given + long auctionId = 1L; + long jobExecutionId = 100L; + + String responseBody = """ + { + "success": true, + "status": 200, + "data": true + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + boolean result = mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId); + + // then + assertThat(result).isTrue(); + mockServer.verify(); + } + + @Test + void closeExpired_정상_200_응답이면_data값을_그대로_반환한다_false() { + // given + long auctionId = 2L; + long jobExecutionId = 101L; + + String responseBody = """ + { + "success": true, + "status": 200, + "data": false + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + boolean result = mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId); + + // then + assertThat(result).isFalse(); + mockServer.verify(); + } + + @Test + void closeExpired_요청_헤더에_X_Internal_Token과_Idempotency_Key를_포함한다() { + // given + long auctionId = 10L; + long jobExecutionId = 200L; + String expectedIdempotencyKey = String.format("auction-close-%d-%d", auctionId, jobExecutionId); + + String responseBody = """ + { + "success": true, + "status": 200, + "data": true + } + """; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(header("X-Internal-Token", INTERNAL_TOKEN)) + .andExpect(header("Idempotency-Key", expectedIdempotencyKey)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + // when + mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId); + + // then + mockServer.verify(); + } + + @Test + void closeExpired_5xx_오류시_최대_3회까지_재시도한다() { + // given + long auctionId = 20L; + long jobExecutionId = 300L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andRespond(withServerError()); + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andRespond(withServerError()); + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andRespond(withServerError()); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId)) + .isInstanceOf(Exception.class); + + mockServer.verify(); + } + + @Test + void closeExpired_401_오류시_RuntimeException을_던진다() { + // given + long auctionId = 30L; + long jobExecutionId = 400L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId)) + .isInstanceOf(RuntimeException.class); + + mockServer.verify(); + } + + @Test + void closeExpired_400_오류시_RuntimeException으로_스킵된다() { + // given + long auctionId = 40L; + long jobExecutionId = 500L; + + mockServer.expect(requestTo(BASE_URL + "/internal/auctions/" + auctionId + "/close-expired")) + .andRespond(withStatus(HttpStatus.BAD_REQUEST)); + + // when & then + assertThatThrownBy(() -> mainAuctionLifecycleClient.closeExpired(auctionId, jobExecutionId)) + .isInstanceOf(RuntimeException.class); + + mockServer.verify(); + } +} diff --git a/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java b/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java new file mode 100644 index 0000000..3fe2f17 --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java @@ -0,0 +1,134 @@ +package com.rocketcrew.pocatbatch.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class MainCardSyncClientTest { + + private static final String BASE_URL = "http://localhost:8080"; + private static final String INTERNAL_TOKEN = "test-token"; + private static final String SYNC_URL = BASE_URL + "/internal/cards/sync"; + + private RestTemplate restTemplate; + private MockRestServiceServer mockServer; + private MainCardSyncClient mainCardSyncClient; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + + mainCardSyncClient = new MainCardSyncClient(restTemplate); + ReflectionTestUtils.setField(mainCardSyncClient, "baseUrl", BASE_URL); + ReflectionTestUtils.setField(mainCardSyncClient, "internalToken", INTERNAL_TOKEN); + } + + @Test + void triggerSync_202_응답이면_정상_처리된다() { + // given + long jobExecutionId = 100L; + + mockServer.expect(requestTo(SYNC_URL)) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.ACCEPTED)); + + // when & then + assertThatCode(() -> mainCardSyncClient.triggerSync(jobExecutionId)) + .doesNotThrowAnyException(); + + mockServer.verify(); + } + + @Test + void triggerSync_요청_헤더에_X_Internal_Token을_포함한다() { + // given + long jobExecutionId = 100L; + + mockServer.expect(requestTo(SYNC_URL)) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(header("X-Internal-Token", INTERNAL_TOKEN)) + .andRespond(withStatus(HttpStatus.ACCEPTED)); + + // when + mainCardSyncClient.triggerSync(jobExecutionId); + + // then + mockServer.verify(); + } + + @Test + void triggerSync_409_CARD_SYNC_IN_PROGRESS_응답이면_정상_스킵된다() { + // given + long jobExecutionId = 200L; + + String responseBody = """ + { + "success": false, + "status": 409, + "data": "CARD_SYNC_IN_PROGRESS" + } + """; + + mockServer.expect(requestTo(SYNC_URL)) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.CONFLICT) + .body(responseBody) + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatCode(() -> mainCardSyncClient.triggerSync(jobExecutionId)) + .doesNotThrowAnyException(); + + mockServer.verify(); + } + + @Test + void triggerSync_401_오류시_RuntimeException을_던진다() { + // given + long jobExecutionId = 300L; + + mockServer.expect(requestTo(SYNC_URL)) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + // when & then + assertThatThrownBy(() -> mainCardSyncClient.triggerSync(jobExecutionId)) + .isInstanceOf(RuntimeException.class); + + mockServer.verify(); + } + + @Test + void triggerSync_400_CARD_SYNC_IN_PROGRESS가_아닌_경우_정상_스킵된다() { + // given + long jobExecutionId = 400L; + + String responseBody = """ + { + "success": false, + "status": 400, + "data": "INVALID_REQUEST" + } + """; + + mockServer.expect(requestTo(SYNC_URL)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .body(responseBody) + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatCode(() -> mainCardSyncClient.triggerSync(jobExecutionId)) + .doesNotThrowAnyException(); + + mockServer.verify(); + } +} diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java new file mode 100644 index 0000000..69683a2 --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java @@ -0,0 +1,124 @@ +package com.rocketcrew.pocatbatch.job.auctionactivation; + +import com.rocketcrew.pocatbatch.client.MainAuctionLifecycleClient; +import com.rocketcrew.pocatbatch.domain.auction.entity.Auction; +import com.rocketcrew.pocatbatch.domain.auction.enums.AuctionStatus; +import com.rocketcrew.pocatbatch.domain.auction.repository.AuctionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.test.MetaDataInstanceFactory; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuctionActivationTaskletTest { + + @Mock + private AuctionRepository auctionRepository; + + @Mock + private MainAuctionLifecycleClient mainAuctionLifecycleClient; + + @InjectMocks + private AuctionActivationTasklet auctionActivationTasklet; + + private ChunkContext newChunkContext(long jobExecutionId) { + StepContext stepContext = new StepContext( + MetaDataInstanceFactory.createStepExecution( + MetaDataInstanceFactory.createJobExecution( + "auctionActivationJob", jobExecutionId, jobExecutionId + ), + "auctionActivationStep", + 1L + ) + ); + return new ChunkContext(stepContext); + } + + private Auction mockAuction(long id) { + Auction auction = mock(Auction.class); + lenient().when(auction.getId()).thenReturn(id); + return auction; + } + + @Test + void execute_대상_경매가_없으면_즉시_종료하고_클라이언트를_호출하지_않는다() throws Exception { + // given + long jobExecutionId = 1L; + when(auctionRepository.findAllByStatus(AuctionStatus.APPROVED)) + .thenReturn(Collections.emptyList()); + + // when + RepeatStatus status = auctionActivationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(auctionRepository, times(1)).findAllByStatus(AuctionStatus.APPROVED); + verifyNoInteractions(mainAuctionLifecycleClient); + } + + @Test + void execute_여러_경매중_true_false_예외가_혼합된_경우_집계가_정확하다() throws Exception { + // given + long jobExecutionId = 2L; + + Auction auction1 = mockAuction(1L); + Auction auction2 = mockAuction(2L); + Auction auction3 = mockAuction(3L); + + when(auctionRepository.findAllByStatus(AuctionStatus.APPROVED)) + .thenReturn(List.of(auction1, auction2, auction3)); + + when(mainAuctionLifecycleClient.activate(eq(1L), eq(jobExecutionId))).thenReturn(true); + when(mainAuctionLifecycleClient.activate(eq(2L), eq(jobExecutionId))).thenReturn(false); + when(mainAuctionLifecycleClient.activate(eq(3L), eq(jobExecutionId))) + .thenThrow(new RuntimeException("internal api error")); + + // when + RepeatStatus status = auctionActivationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(mainAuctionLifecycleClient, times(1)).activate(eq(1L), eq(jobExecutionId)); + verify(mainAuctionLifecycleClient, times(1)).activate(eq(2L), eq(jobExecutionId)); + verify(mainAuctionLifecycleClient, times(1)).activate(eq(3L), eq(jobExecutionId)); + } + + @Test + void execute_모든_경매가_true를_반환하면_전부_활성화된_것으로_집계된다() throws Exception { + // given + long jobExecutionId = 3L; + + Auction auction1 = mockAuction(10L); + Auction auction2 = mockAuction(11L); + + when(auctionRepository.findAllByStatus(AuctionStatus.APPROVED)) + .thenReturn(List.of(auction1, auction2)); + + when(mainAuctionLifecycleClient.activate(eq(10L), eq(jobExecutionId))).thenReturn(true); + when(mainAuctionLifecycleClient.activate(eq(11L), eq(jobExecutionId))).thenReturn(true); + + // when + RepeatStatus status = auctionActivationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(mainAuctionLifecycleClient, times(2)).activate(anyLong(), eq(jobExecutionId)); + } +} diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java new file mode 100644 index 0000000..863994c --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java @@ -0,0 +1,130 @@ +package com.rocketcrew.pocatbatch.job.auctionexpiration; + +import com.rocketcrew.pocatbatch.client.MainAuctionLifecycleClient; +import com.rocketcrew.pocatbatch.domain.auction.entity.Auction; +import com.rocketcrew.pocatbatch.domain.auction.enums.AuctionStatus; +import com.rocketcrew.pocatbatch.domain.auction.repository.AuctionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.test.MetaDataInstanceFactory; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuctionExpirationTaskletTest { + + @Mock + private AuctionRepository auctionRepository; + + @Mock + private MainAuctionLifecycleClient mainAuctionLifecycleClient; + + @InjectMocks + private AuctionExpirationTasklet auctionExpirationTasklet; + + private ChunkContext newChunkContext(long jobExecutionId) { + StepContext stepContext = new StepContext( + MetaDataInstanceFactory.createStepExecution( + MetaDataInstanceFactory.createJobExecution( + "auctionExpirationJob", jobExecutionId, jobExecutionId + ), + "auctionExpirationStep", + 1L + ) + ); + return new ChunkContext(stepContext); + } + + private Auction mockAuction(long id) { + Auction auction = mock(Auction.class); + lenient().when(auction.getId()).thenReturn(id); + return auction; + } + + @Test + void execute_대상_경매가_없으면_즉시_종료하고_클라이언트를_호출하지_않는다() throws Exception { + // given + long jobExecutionId = 1L; + when(auctionRepository.findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc( + eq(AuctionStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // when + RepeatStatus status = auctionExpirationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(auctionRepository, times(1)) + .findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc(eq(AuctionStatus.ACTIVE), any(LocalDateTime.class)); + verifyNoInteractions(mainAuctionLifecycleClient); + } + + @Test + void execute_여러_경매중_true_false_예외가_혼합된_경우_집계가_정확하다() throws Exception { + // given + long jobExecutionId = 2L; + + Auction auction1 = mockAuction(1L); + Auction auction2 = mockAuction(2L); + Auction auction3 = mockAuction(3L); + + when(auctionRepository.findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc( + eq(AuctionStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(List.of(auction1, auction2, auction3)); + + when(mainAuctionLifecycleClient.closeExpired(eq(1L), eq(jobExecutionId))).thenReturn(true); + when(mainAuctionLifecycleClient.closeExpired(eq(2L), eq(jobExecutionId))).thenReturn(false); + when(mainAuctionLifecycleClient.closeExpired(eq(3L), eq(jobExecutionId))) + .thenThrow(new RuntimeException("internal api error")); + + // when + RepeatStatus status = auctionExpirationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(1L), eq(jobExecutionId)); + verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(2L), eq(jobExecutionId)); + verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(3L), eq(jobExecutionId)); + } + + @Test + void execute_모든_경매가_true를_반환하면_전부_종료된_것으로_집계된다() throws Exception { + // given + long jobExecutionId = 3L; + + Auction auction1 = mockAuction(10L); + Auction auction2 = mockAuction(11L); + + when(auctionRepository.findAllByStatusAndEndedAtLessThanEqualOrderByEndedAtAsc( + eq(AuctionStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(List.of(auction1, auction2)); + + when(mainAuctionLifecycleClient.closeExpired(eq(10L), eq(jobExecutionId))).thenReturn(true); + when(mainAuctionLifecycleClient.closeExpired(eq(11L), eq(jobExecutionId))).thenReturn(true); + + // when + RepeatStatus status = auctionExpirationTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(mainAuctionLifecycleClient, times(2)).closeExpired(anyLong(), eq(jobExecutionId)); + } +} diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTaskletTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTaskletTest.java new file mode 100644 index 0000000..5d468e7 --- /dev/null +++ b/src/test/java/com/rocketcrew/pocatbatch/job/cardsync/CardSyncTaskletTest.java @@ -0,0 +1,69 @@ +package com.rocketcrew.pocatbatch.job.cardsync; + +import com.rocketcrew.pocatbatch.client.MainCardSyncClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.test.MetaDataInstanceFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CardSyncTaskletTest { + + @Mock + private MainCardSyncClient mainCardSyncClient; + + @InjectMocks + private CardSyncTasklet cardSyncTasklet; + + private ChunkContext newChunkContext(long jobExecutionId) { + StepContext stepContext = new StepContext( + MetaDataInstanceFactory.createStepExecution( + MetaDataInstanceFactory.createJobExecution( + "cardSyncJob", jobExecutionId, jobExecutionId + ), + "cardSyncStep", + 1L + ) + ); + return new ChunkContext(stepContext); + } + + @Test + void execute_정상_호출시_mainCardSyncClient_triggerSync를_1회_호출한다() throws Exception { + // given + long jobExecutionId = 1L; + + // when + RepeatStatus status = cardSyncTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId)); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(mainCardSyncClient, times(1)).triggerSync(jobExecutionId); + } + + @Test + void execute_클라이언트가_예외를_던지면_step이_실패한다() { + // given + long jobExecutionId = 2L; + doThrow(new RuntimeException("internal api error")) + .when(mainCardSyncClient).triggerSync(jobExecutionId); + + // when & then + assertThatThrownBy(() -> cardSyncTasklet.execute( + mock(StepContribution.class), newChunkContext(jobExecutionId))) + .isInstanceOf(RuntimeException.class); + + verify(mainCardSyncClient, times(1)).triggerSync(jobExecutionId); + } +} From e6fa94b2a0ab791c62a6a28b1a84992f17f106e1 Mon Sep 17 00:00:00 2001 From: cdkkyj123 Date: Tue, 16 Jun 2026 08:47:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(#235):=20RUNBOOK/ARCHITECTURE=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(Internal=20API=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=20=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 95 +++++++++++++++++++++++++++++++++++++++++++- docs/RUNBOOK.md | 62 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23d83b4..8a23d97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 설정 @@ -20,6 +26,18 @@ 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 @@ -27,7 +45,7 @@ com.rocketcrew.pocatbatch/ │ ├── ViewCountFlushJobConfig # viewCountFlushJob, viewCountFlushStep 빈 등록 │ └── ViewCountFlushTasklet # Redis 버퍼 → MySQL 플러시 └── scheduler/ - └── BatchScheduler # @Scheduled(fixedDelay=60s) — JobLauncher 실행 트리거 + └── BatchScheduler # @Scheduled(cron/fixedDelay) — JobLauncher 실행 트리거 ``` --- @@ -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 (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`)에서 주입된다. + +### 배포 순서 의존성 + +``` +메인앱(POCAT) 배포 완료 → pocat-batch 배포 +``` + +메인앱 먼저 배포하지 않으면 `auctionActivationJob`, `auctionExpirationJob`, `cardSyncJob` 실행 시 연결 오류 또는 404 발생. + +--- + ## 알려진 한계 (Known Limitations) | 항목 | 내용 | 대응 방안 | diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index bd4a9bb..337edb8 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -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) @@ -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` 로그에서 동기화 완료 확인했는가? From 299799477bf24b1ef96fc8ed995274147e49b412 Mon Sep 17 00:00:00 2001 From: cdkkyj123 Date: Tue, 16 Jun 2026 10:01:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore(#235):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9E=AC=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 0412253c8bbdddcee95e0c33ccea5b014b0207ee Mon Sep 17 00:00:00 2001 From: cdkkyj123 Date: Tue, 16 Jun 2026 10:12:25 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(#235):=20=EC=99=B8=EB=B6=80=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20MD040,=204xx=20fai?= =?UTF-8?q?l-fast,=20failedCount>0=20step=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 2 +- .../pocatbatch/client/MainCardSyncClient.java | 3 ++- .../AuctionActivationTasklet.java | 3 +++ .../AuctionExpirationTasklet.java | 3 +++ .../pocatbatch/client/MainCardSyncClientTest.java | 6 +++--- .../AuctionActivationTaskletTest.java | 15 +++++++++------ .../AuctionExpirationTaskletTest.java | 15 +++++++++------ 7 files changed, 30 insertions(+), 17 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a23d97..1351c2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -277,7 +277,7 @@ BatchScheduler(@Scheduled cron "0 0 0 * * SUN") ### 배포 순서 의존성 -``` +```text 메인앱(POCAT) 배포 완료 → pocat-batch 배포 ``` diff --git a/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java b/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java index f73599a..8817ba6 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java +++ b/src/main/java/com/rocketcrew/pocatbatch/client/MainCardSyncClient.java @@ -55,8 +55,9 @@ public void triggerSync(long jobExecutionId) { return; } - log.warn("카드 동기화 트리거 4xx 오류로 스킵: jobExecutionId={}, status={}, body={}", + log.error("카드 동기화 트리거 4xx 오류: jobExecutionId={}, status={}, body={}", jobExecutionId, e.getStatusCode(), responseBody); + throw new RuntimeException("카드 동기화 트리거 4xx 오류: status=" + e.getStatusCode(), e); } } } diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java index 63761f9..800c093 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java +++ b/src/main/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTasklet.java @@ -53,6 +53,9 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon log.info("경매 활성화 완료: 대상={}, 활성화={}, 스킵={}, 실패={}", approvedAuctions.size(), activatedCount, skippedCount, failedCount); + if (failedCount > 0) { + throw new RuntimeException("경매 활성화 중 " + failedCount + "건 실패 — 로그 확인 필요"); + } return RepeatStatus.FINISHED; } } diff --git a/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java b/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java index 2b84793..55237e3 100644 --- a/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java +++ b/src/main/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTasklet.java @@ -57,6 +57,9 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon log.info("경매 종료 완료: 대상={}, 종료={}, 스킵={}, 실패={}", expiredAuctions.size(), closedCount, skippedCount, failedCount); + if (failedCount > 0) { + throw new RuntimeException("경매 종료 중 " + failedCount + "건 실패 — 로그 확인 필요"); + } return RepeatStatus.FINISHED; } } diff --git a/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java b/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java index 3fe2f17..fc9fa6b 100644 --- a/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java +++ b/src/test/java/com/rocketcrew/pocatbatch/client/MainCardSyncClientTest.java @@ -108,7 +108,7 @@ void setUp() { } @Test - void triggerSync_400_CARD_SYNC_IN_PROGRESS가_아닌_경우_정상_스킵된다() { + void triggerSync_400_오류시_RuntimeException을_던진다() { // given long jobExecutionId = 400L; @@ -126,8 +126,8 @@ void setUp() { .contentType(MediaType.APPLICATION_JSON)); // when & then - assertThatCode(() -> mainCardSyncClient.triggerSync(jobExecutionId)) - .doesNotThrowAnyException(); + assertThatThrownBy(() -> mainCardSyncClient.triggerSync(jobExecutionId)) + .isInstanceOf(RuntimeException.class); mockServer.verify(); } diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java index 69683a2..6da4036 100644 --- a/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java +++ b/src/test/java/com/rocketcrew/pocatbatch/job/auctionactivation/AuctionActivationTaskletTest.java @@ -19,6 +19,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -72,7 +73,7 @@ private Auction mockAuction(long id) { } @Test - void execute_여러_경매중_true_false_예외가_혼합된_경우_집계가_정확하다() throws Exception { + void execute_여러_경매중_예외가_발생하면_전체_처리_후_step이_실패한다() throws Exception { // given long jobExecutionId = 2L; @@ -88,12 +89,14 @@ private Auction mockAuction(long id) { when(mainAuctionLifecycleClient.activate(eq(3L), eq(jobExecutionId))) .thenThrow(new RuntimeException("internal api error")); - // when - RepeatStatus status = auctionActivationTasklet.execute( - mock(StepContribution.class), newChunkContext(jobExecutionId)); + ChunkContext chunkContext = newChunkContext(jobExecutionId); + StepContribution contribution = mock(StepContribution.class); + + // when & then: 루프는 전부 실행(건별 스킵)하되 failedCount>0이면 step 실패 + assertThatThrownBy(() -> auctionActivationTasklet.execute(contribution, chunkContext)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("1건 실패"); - // then - assertThat(status).isEqualTo(RepeatStatus.FINISHED); verify(mainAuctionLifecycleClient, times(1)).activate(eq(1L), eq(jobExecutionId)); verify(mainAuctionLifecycleClient, times(1)).activate(eq(2L), eq(jobExecutionId)); verify(mainAuctionLifecycleClient, times(1)).activate(eq(3L), eq(jobExecutionId)); diff --git a/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java b/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java index 863994c..838146f 100644 --- a/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java +++ b/src/test/java/com/rocketcrew/pocatbatch/job/auctionexpiration/AuctionExpirationTaskletTest.java @@ -20,6 +20,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; @@ -76,7 +77,7 @@ private Auction mockAuction(long id) { } @Test - void execute_여러_경매중_true_false_예외가_혼합된_경우_집계가_정확하다() throws Exception { + void execute_여러_경매중_예외가_발생하면_전체_처리_후_step이_실패한다() throws Exception { // given long jobExecutionId = 2L; @@ -93,12 +94,14 @@ private Auction mockAuction(long id) { when(mainAuctionLifecycleClient.closeExpired(eq(3L), eq(jobExecutionId))) .thenThrow(new RuntimeException("internal api error")); - // when - RepeatStatus status = auctionExpirationTasklet.execute( - mock(StepContribution.class), newChunkContext(jobExecutionId)); + ChunkContext chunkContext = newChunkContext(jobExecutionId); + StepContribution contribution = mock(StepContribution.class); + + // when & then: 루프는 전부 실행(건별 스킵)하되 failedCount>0이면 step 실패 + assertThatThrownBy(() -> auctionExpirationTasklet.execute(contribution, chunkContext)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("1건 실패"); - // then - assertThat(status).isEqualTo(RepeatStatus.FINISHED); verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(1L), eq(jobExecutionId)); verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(2L), eq(jobExecutionId)); verify(mainAuctionLifecycleClient, times(1)).closeExpired(eq(3L), eq(jobExecutionId));