-
Notifications
You must be signed in to change notification settings - Fork 34
Round8 #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Round8 #203
Conversation
- Kafka Producer ์ค์ ์ถ๊ฐ (acks=all, idempotence=true) - Consumer Manual Ack ์ค์ - commerce-api์ Kafka ์์กด์ฑ ์ถ๊ฐ
- ProductViewedEvent ๋๋ฉ์ธ ์ด๋ฒคํธ ์ ์ - KafkaTopicRouter: ์ด๋ฒคํธ ํ์ ๋ณ ํ ํฝ ๋ผ์ฐํ - KafkaEventEnvelope: ์ด๋ฒคํธ ๋ฉํ๋ฐ์ดํฐ ํฌํจ Envelope ํจํด
- OutboxEvent ์ํฐํฐ ๋ฐ Repository ๊ตฌํ - BEFORE_COMMIT ํ์ด์ฆ ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ก ์์์ฑ ๋ณด์ฅ - OutboxEventPoller: PENDING ์ด๋ฒคํธ Kafka ๋ฐํ (5์ด ์ฃผ๊ธฐ) - OutboxEventCleaner: ์ค๋๋ ์ด๋ฒคํธ ์๋ ์ ๋ฆฌ ์ค์ผ์ค๋ฌ - At Least Once ๋ฐํ ๋ณด์ฅ
- ProductFacade: ์ํ ์กฐํ ์ ProductViewedEvent ๋ฐํ - OrderPlacementService: ์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ ๋ฐํ - OrderCompletedEvent: ํ๋งค๋ ์ง๊ณ๋ฅผ ์ํ ํ๋ ์ถ๊ฐ - OrderStatusEventListener: ์ด๋ฒคํธ ๋ฆฌ์ค๋ ๊ฐ์
- CacheInvalidationService: ์ฌ๊ณ ์์ง ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ถ๊ฐ - ProductRepository: ์ฌ๊ณ ์กฐํ ๋ฉ์๋ ์ถ๊ฐ - InMemoryProductRepository: ํ ์คํธ์ฉ ์ฌ๊ณ ์กฐํ ๊ตฌํ
- EventHandled: ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ์ ์ํ ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ด๋ ฅ ์ํฐํฐ - ProductMetrics: ์ํ๋ณ ์ง๊ณ ๋ฉํธ๋ฆญ ์ํฐํฐ (ํ์์คํฌํ ๊ธฐ๋ฐ Out-of-Order ์ฒ๋ฆฌ) - DlqMessage: Dead Letter Queue ๋ฉ์์ง ์ํฐํฐ - DlqPublisher: ๋ณต๊ตฌ ๋ถ๊ฐ๋ฅํ ์๋ฌ ์ฒ๋ฆฌ - RetryTracker: ๋ฉ์์ง๋ณ ์ฌ์๋ ํ์ ์ถ์ (์ต๋ 3ํ)
- Consumer์ฉ ์ด๋ฒคํธ DTO ์ ์ - MetricsAggregationService: ๋ฉํธ๋ฆญ ์ง๊ณ ๋น์ฆ๋์ค ๋ก์ง - event_handled ํ ์ด๋ธ ๊ธฐ๋ฐ ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ - ํ์์คํฌํ ๊ธฐ๋ฐ Out-of-Order ์ด๋ฒคํธ ์ฒ๋ฆฌ
- ProductMetricsConsumer: ์ํ ๋ฉํธ๋ฆญ Kafka Consumer - Manual Ack ๊ธฐ๋ฐ At Most Once ์ฒ๋ฆฌ - Batch Listener๋ก ์ฑ๋ฅ ์ต์ ํ - DLQ ๋ฐ ์ฌ์๋ ๋ฉ์ปค๋์ฆ ํตํฉ
- KafkaConnectionTest: Kafka ์ฐ๊ฒฐ ํ ์คํธ - MetricsAggregationServiceIdempotencyTest: ๋ฉฑ๋ฑ์ฑ ๊ฒ์ฆ - DlqPublisherTest: DLQ ๋ฐํ ํ ์คํธ - RetryTrackerTest: ์ฌ์๋ ์ถ์ ํ ์คํธ - ํ ์คํธ ์ค์ ํ์ผ ์ถ๊ฐ
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Walkthrough์ด PR์ Kafka ๊ธฐ๋ฐ์ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ฅผ ๋์ ํ์ฌ ์๊ฑฐ๋ ์์คํ ์ ๋น๋๊ธฐ ์ด๋ฒคํธ ์ฒ๋ฆฌ๋ฅผ ๊ตฌํํฉ๋๋ค. Outbox ํจํด, ์ด๋ฒคํธ ๋ผ์ฐํ , ๋ฉ์์ง ํ์, ๋ฉํธ๋ฆญ ์์ง, DLQ ์ฒ๋ฆฌ ๊ธฐ๋ฅ์ ์ถ๊ฐํ๋ฉฐ ์ํ ์กฐํ, ์ฃผ๋ฌธ ์๋ฃ ๋ฑ ์ฃผ์ ๋น์ฆ๋์ค ์ด๋ฒคํธ๋ฅผ Kafka๋ก ๋ฐํํฉ๋๋ค. Changes
Sequence DiagramsequenceDiagram
participant App as Application<br/>(ProductFacade)
participant Outbox as Outbox<br/>Repository
participant Poller as Outbox<br/>Poller
participant Kafka as Kafka<br/>Broker
participant Consumer as Kafka<br/>Consumer
participant Metrics as Metrics<br/>Service
participant DLQ as DLQ<br/>Storage
App->>Outbox: 1. Event Handler<br/>(ProductViewed)
Note over App,Outbox: BEFORE_COMMIT<br/>(same transaction)
Outbox->>Outbox: 2. Create OutboxEvent<br/>(status=PENDING)
rect rgb(200, 220, 255)
Note over Poller,Kafka: Outbox Poller (5s interval)
Poller->>Outbox: 3. Poll PENDING<br/>events
Poller->>Kafka: 4. Publish to Topic<br/>(KafkaEventEnvelope)
Kafka-->>Poller: 5. Send Success
Poller->>Outbox: 6. Mark PUBLISHED
end
rect rgb(200, 255, 220)
Note over Consumer,Metrics: Event Processing
Kafka->>Consumer: 7. Consume batch<br/>events
Consumer->>Metrics: 8. Delegate to<br/>handler
Metrics->>Metrics: 9. Check idempotency<br/>(EventHandled)
Metrics->>Metrics: 10. Update metrics<br/>(likeCount, viewCount)
Metrics->>Metrics: 11. Record as handled
Consumer->>Consumer: 12. Batch commit
end
rect rgb(255, 220, 200)
Note over Consumer,DLQ: Error Handling
Consumer--xMetrics: Error (e.g.,<br/>JsonProcessingException)
Consumer->>DLQ: 13. Determine DLQ<br/>vs Retry
alt DLQ Candidate
Consumer->>DLQ: 14. Publish to DLQ
DLQ->>DLQ: 15. Store DlqMessage
else Retryable
Consumer->>Consumer: 16. Track retry<br/>(RetryTracker)
Note over Consumer: Retry up to<br/>3 attempts
end
end
rect rgb(220, 200, 255)
Note over Poller: Cleanup Tasks
Poller->>Outbox: 17. Clean old<br/>PUBLISHED (7 days)
Poller->>Outbox: 18. Clean old FAILED<br/>(30 days, retries>3)
Poller->>Outbox: 19. Log statistics<br/>(hourly)
end
Estimated code review effort๐ฏ 4 (Complex) | โฑ๏ธ ~60 minutes ์ฃผ์๊ฐ ํ์ํ ์์ญ:
Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touchesโ Failed checks (2 warnings)
โ Passed checks (1 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 13
Caution
Some comments are outside the diff and canโt be posted inline due to platform limitations.
โ ๏ธ Outside diff range comments (1)
modules/kafka/src/main/resources/kafka.yml (1)
26-27: Consumer ์์ฑ๋ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ด ํ์ํฉ๋๋ค.Producer ์์ฑ๊ณผ ๋์ผํ๊ฒ, consumer properties ๋งต ๋ด์
enable-auto-commit๋ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ ์ฌ์ฉํด์ผ ํฉ๋๋ค.๐ ์ ์๋ ์์
properties: - enable-auto-commit: false + "[enable.auto.commit]": false
๐งน Nitpick comments (32)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/event/KafkaEventEnvelope.java (1)
12-41: ์ด๋ฒคํธ Envelope ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.๋ถ๋ณ ๋ ์ฝ๋ ๊ตฌ์กฐ์
ZonedDateTime์ฌ์ฉ์ ๋ถ์ฐ ์์คํ ์ ์ด๋ฒคํธ ๋ฐ์ดํฐ์ ์ ํฉํฉ๋๋ค. ๋ช ๊ฐ์ง ๊ฐ์ ์ฌํญ์ ์ ์ํฉ๋๋ค:
Compact constructor์์ ์ ํจ์ฑ ๊ฒ์ฆ ์ถ๊ฐ ๊ณ ๋ ค:
eventId,eventType๋ฑ ํ์ ํ๋์ ๋ํ null/๋น ๋ฌธ์์ด ์ฒดํฌ๋ฅผ ์ถ๊ฐํ๋ฉด ๋ฐํ์ ์ค๋ฅ๋ฅผ ์กฐ๊ธฐ์ ๋ฐ๊ฒฌํ ์ ์์ต๋๋ค.partitionKey ๋ฌธ์ ๋ช ํํ: ํ์ฌ ์ฃผ์์๋ "๋๋ฒ๊น ๋ฐ ๋ก๊น ์ฉ"์ผ๋ก๋ง ์ค๋ช ๋์ด ์์ผ๋, PR ์ค๋ช ์ ๋ฐ๋ฅด๋ฉด productId/orderNo๋ฅผ ๊ธฐ์ค์ผ๋ก ํํฐ์ ํค ์์ ๋ณด์ฅ์ด ์ด๋ฃจ์ด์ง๋๋ค. ์ค์ ๋ก Kafka ํํฐ์ ๋์๋ ์ฌ์ฉ๋๋ค๋ฉด ๋ฌธ์๋ฅผ ์ ๋ฐ์ดํธํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
๐ ์ ํ์ ๊ฐ์ ์: Compact constructor ์์
public record KafkaEventEnvelope<T>( String eventId, String eventType, String partitionKey, T payload, ZonedDateTime occurredAt -) { +) { + public KafkaEventEnvelope { + if (eventId == null || eventId.isBlank()) { + throw new IllegalArgumentException("eventId must not be null or blank"); + } + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("eventType must not be null or blank"); + } + if (occurredAt == null) { + throw new IllegalArgumentException("occurredAt must not be null"); + } + } }apps/commerce-streamer/src/test/resources/application-test.yml (1)
15-18: Kafka ๋ก๊น ์ค์ ์ ๋ฆฌ ๊ณ ๋ ค (์ ํ์ฌํญ)Kafka๊ฐ ๋นํ์ฑํ๋์ด ์๋๋ฐ
org.springframework.kafka๋ฅผ DEBUG ๋ ๋ฒจ๋ก ์ค์ ํ ๊ฒ์ ๋ค์ ๋ถ์ผ์นํด ๋ณด์ ๋๋ค. ํน์ ํ ์คํธ์์ Kafka๋ฅผ ํ์ฑํํ๋ ๊ฒฝ์ฐ๊ฐ ์๋ค๋ฉด ์ ์ฉํ ์ ์์ง๋ง, ๊ทธ๋ ์ง ์๋ค๋ฉด ์ด ๋ก๊น ์ค์ ์ ์ ๊ฑฐํ๋ ๊ฒ์ ๊ณ ๋ คํด๋ณด์ธ์.apps/commerce-streamer/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (1)
22-34: ๋ฐฐ์น ์ฒ๋ฆฌ ์ฑ๋ฅ ์ต์ ํ๋ฅผ ์ํด ์ค์ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํ์ธ์.ํ์ฌ ๊ตฌ์ฑ์ MANUAL ack์ ๋ฐฐ์น ๋ฆฌ์ค๋๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ํ์ต๋๋ค. Spring Kafka 2.3 ์ด์์์๋
enable.auto.commit์ด ์๋์ผ๋ก false๋ก ์ค์ ๋๋ฏ๋ก ๋ณ๋ ์ค์ ์ด ํ์ ์์ต๋๋ค.ํ๋ก๋์ ํ๊ฒฝ์ ์ฑ๋ฅ ์ต์ ํ๋ฅผ ์ํด ๋ค์ ์์ฑ ์ถ๊ฐ๋ฅผ ๊ถ์ฅํฉ๋๋ค:
max.poll.records: ๋ฐฐ์น๋น ์ต๋ ๋ ์ฝ๋ ์ ์ ํ (๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ, ๊ธฐ๋ณธ๊ฐ 500)concurrency: ๋์ ์ฒ๋ฆฌ ์ค๋ ๋ ์ (์ฒ๋ฆฌ๋ ํฅ์, ํํฐ์ ์์ ์ผ์น ๊ถ์ฅ)max.poll.interval.ms: ๋ฐฐ์น ์ฒ๋ฆฌ ์๊ฐ ์ด๊ณผ ๋ฐฉ์ง (๊ธฐ๋ณธ๊ฐ 300000ms)์์:
factory.setConcurrency(3); // ํ๊ฒฝ์ ๋ง๊ฒ ์กฐ์ ๋๋ application.yml์์:
spring: kafka: consumer: max-poll-records: 500 properties: "[max.poll.interval.ms]": 300000 listener: concurrency: 3apps/commerce-api/src/test/java/com/loopers/infrastructure/kafka/KafkaConnectionTest.java (3)
50-53: ํ ์คํธ์์ System.out.println ๋์ ๋ก๊น ์ ์ฌ์ฉํ์ธ์.ํ ์คํธ ์ถ๋ ฅ์
System.out.println๋์ ์ ์ ํ ๋ก๊น ํ๋ ์์ํฌ(์: SLF4J)๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ ์ ๊ฑฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค. ํ ์คํธ ๋ก๊ทธ๋ ํ ์คํธ ํ๋ ์์ํฌ์ ๋ฆฌํฌํ ๋ฉ์ปค๋์ฆ์ ํตํด ๊ด๋ฆฌ๋์ด์ผ ํฉ๋๋ค.๐ ๊ฐ์ ์ ์
- System.out.println("โ Kafka Producer ์ฐ๊ฒฐ ์ฑ๊ณต!"); - System.out.println("Topic: " + result.getRecordMetadata().topic()); - System.out.println("Partition: " + result.getRecordMetadata().partition()); - System.out.println("Offset: " + result.getRecordMetadata().offset()); + // ํ ์คํธ ์ฑ๊ณต ์ ์ถ๋ ฅ์ assertion๋ง์ผ๋ก ์ถฉ๋ถํ๋ฉฐ, ํ์์ ๋ก๊น ํ๋ ์์ํฌ ์ฌ์ฉ
45-48: ๋ฉ์์ง ํค์ ๊ฐ์ ๋ํ ๊ฒ์ฆ์ ์ถ๊ฐํ์ธ์.ํ์ฌ ํ ์คํธ๋ ํ ํฝ ์ด๋ฆ๋ง ํ์ธํ๊ณ ์์ต๋๋ค. ๋ฉ์์ง์ ํค์ ๊ฐ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ก๋์๋์ง ๊ฒ์ฆํ๋ฉด ํ ์คํธ๊ฐ ๋ ๊ฒฌ๊ณ ํด์ง๋๋ค.
๐ ๊ฐ์ ์ ์
// then SendResult<String, Object> result = future.get(10, TimeUnit.SECONDS); assertThat(result).isNotNull(); assertThat(result.getRecordMetadata().topic()).isEqualTo(topic); + assertThat(result.getProducerRecord().key()).isEqualTo(key); + assertThat(result.getProducerRecord().value()).isEqualTo(message);
67-70: ํ ์คํธ์์ System.out.println ๋์ ๋ก๊น ์ ์ฌ์ฉํ์ธ์.ํ ์คํธ ์ถ๋ ฅ์
System.out.println๋์ ์ ์ ํ ๋ก๊น ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ ์ ๊ฑฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ๊ฐ์ ์ ์
- System.out.println("โ Producer ์ค์ ํ์ธ:"); - System.out.println("acks: " + configs.get("acks")); - System.out.println("enable.idempotence: " + configs.get("enable.idempotence")); - System.out.println("retries: " + configs.get("retries")); + // ์ค์ ํ์ธ์ assertion๋ง์ผ๋ก ์ถฉ๋ถํ๋ฉฐ, ํ์์ ๋ก๊น ํ๋ ์์ํฌ ์ฌ์ฉapps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderPlacementService.java (2)
14-14: ์ํคํ ์ฒ ๊ฐ์ : ๋๋ฉ์ธ ์ด๋ฒคํธ๋ฅผ ํตํ ์บ์ ๋ฌดํจํ ๋ถ๋ฆฌ ๊ณ ๋ ค.ํ์ฌ ๋๋ฉ์ธ ์๋น์ค(
OrderPlacementService)๊ฐ ์ธํ๋ผ์คํธ๋ญ์ฒ ๋ ์ด์ด(CacheInvalidationService)์ ์ง์ ์์กดํ๊ณ ์์ต๋๋ค.์ด PR์์ ์ด๋ฏธ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ฅผ ๊ตฌํํ๊ณ ์์ผ๋ฏ๋ก, ์ฌ๊ณ ์์ง ์
StockDepletedEvent๋ฅผ ๋ฐํํ๊ณ ๋ณ๋์ ์ด๋ฒคํธ ๋ฆฌ์ค๋์์ ์บ์ ๋ฌดํจํ๋ฅผ ์ฒ๋ฆฌํ๋ ๋ฐฉ์์ผ๋ก ๋ถ๋ฆฌํ ์ ์์ต๋๋ค. ์ด๋ ๊ฒ ํ๋ฉด ๋ ์ด์ด ๊ฐ ์์กด์ฑ์ด ๊น๋ํด์ง๋๋ค.Also applies to: 33-33
90-95: ์ฌ๊ณ ํ์ธ๊ณผ ์บ์ ๋ฌดํจํ ์ฌ์ด์ ๊ฒฝ์ ์กฐ๊ฑด.
decreaseStock๊ณผgetStockQuantityํธ์ถ ์ฌ์ด์ ๋ค๋ฅธ ํธ๋์ญ์ ์ด ์ฌ๊ณ ๋ฅผ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค. ๋ค๋ง ์บ์ ๋ฌดํจํ๋ eventually consistent ํน์ฑ์ด๋ฏ๋ก, ์ต์ ์ ๊ฒฝ์ฐ ์ค๋ณต ๋ฌดํจํ๊ฐ ๋ฐ์ํ๊ฑฐ๋ ๋๋ฌผ๊ฒ ๋ฌดํจํ๊ฐ ๋๋ฝ๋ ์ ์์ง๋ง ๊ธฐ๋ฅ์ ๋ฌธ์ ๋ ์์ต๋๋ค.๋ ์ ํํ ์ฒ๋ฆฌ๋ฅผ ์ํ๋ค๋ฉด
decreaseStock์ด ๊ฐฑ์ ๋ ์ฌ๊ณ ์๋์ ๋ฐํํ๋๋ก ์์ ํ๊ฑฐ๋, ๋จ์ผ ์ฟผ๋ฆฌ๋ก ์ฌ๊ณ ๊ฐ์์ ์์ฌ ์๋ ํ์ธ์ ํจ๊ป ์ํํ๋ ๊ฒ์ ๊ณ ๋ คํด ๋ณด์ธ์.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java (2)
76-88: ์ ํ์ฌํญ: ์ค๋ณต ์ด๊ธฐํ ์ ๊ฑฐ ๊ฐ๋ฅLine 86์์
retryCount = 0์ ๋ช ์์ ์ผ๋ก ์ค์ ํ๊ณ ์์ง๋ง, Line 65์์ ์ด๋ฏธ ํ๋ ์ ์ธ ์ ๊ธฐ๋ณธ๊ฐ 0์ผ๋ก ์ด๊ธฐํ๋์ด ์์ต๋๋ค. ๋ช ์์ ์ค์ ์ ์ ๊ฑฐํ๋ฉด ์ฝ๋๊ฐ ๋ ๊ฐ๊ฒฐํด์ง๋๋ค.๐ ์ ์๋ ๋ฆฌํฉํ ๋ง
public static OutboxEvent create( String partitionKey, String eventType, String payload ) { OutboxEvent event = new OutboxEvent(); event.partitionKey = partitionKey; event.eventType = eventType; event.payload = payload; event.status = OutboxStatus.PENDING; - event.retryCount = 0; return event; }
101-105: PUBLISHED โ FAILED ์ ํ ์ publishedAt ์ฒ๋ฆฌ ๊ณ ๋ คํ์ฌ
markAsFailed()๋ ์ํ๋ฅผ FAILED๋ก ๋ณ๊ฒฝํ์ง๋ง, ์ด์ ์ PUBLISHED ์ํ์๋ค๋ฉดpublishedAtํ๋๊ฐ ๋จ์์๊ฒ ๋ฉ๋๋ค. FAILED ์ํ์์๋publishedAt์ด null์ด์ด์ผ ์๋ฏธ์ ๋ ๋ช ํํ ์ ์์ต๋๋ค. ์ฌ์๋ ํ ์คํจํ๋ ์๋๋ฆฌ์ค์์ ํผ๋์ ๋ฐฉ์งํ๋ ค๋ฉดpublishedAt์ null๋ก ์ฌ์ค์ ํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์.๐ ์ ์๋ ์์
public void markAsFailed(String errorMessage) { this.status = OutboxStatus.FAILED; this.retryCount++; this.errorMessage = errorMessage; + this.publishedAt = null; }apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/RetryTracker.java (1)
65-78: ์ฃผ์๊ณผ ๊ตฌํ ๋ถ์ผ์น ๋ฐ ๋ฉ๋ชจ๋ฆฌ ๋์ ์ํLine 72์ ์ฃผ์์์ "์ค์ ์ด์์์๋ ํ์์คํฌํ ๊ธฐ๋ฐ์ผ๋ก ์ค๋๋ ํญ๋ชฉ ์ญ์ "๋ผ๊ณ ๋ช ์๋์ด ์์ง๋ง, ์ค์ ๊ตฌํ์ ๋จ์ํ ํฌ๊ธฐ๊ฐ 10,000์ ์ด๊ณผํ ๋ ์ ์ฒด๋ฅผ ํด๋ฆฌ์ดํฉ๋๋ค. ์ด๋ ๋ค์๊ณผ ๊ฐ์ ๋ฌธ์ ๋ฅผ ์ผ๊ธฐํ ์ ์์ต๋๋ค:
- ๋ฉ๋ชจ๋ฆฌ ๋์:
clearRetryCount()๊ฐ ํธ์ถ๋์ง ์๋ ๊ฒฝ์ฐ(์: ์ปจ์๋จธ ๋น์ ์ ์ข ๋ฃ) ํญ๋ชฉ์ด ๋ฌดํ์ ๋์ ๋ฉ๋๋ค.- ๊ธ๊ฒฉํ ๋ฉ๋ชจ๋ฆฌ ํด์ : 10,000๊ฐ ๋๋ฌ ์ ์ ์ฒด ์ญ์ ๋ ์งํ ์ค์ธ ์ฌ์๋ ์ํ๋ฅผ ๋ชจ๋ ์๊ฒ ๋ง๋ญ๋๋ค.
- ์๊ณ๊ฐ ๊ทผ๊ฑฐ ๋ถ์กฑ: 10,000์ด๋ผ๋ ์ซ์์ ์ ํ ๊ทผ๊ฑฐ๊ฐ ๋ถ๋ช ํํฉ๋๋ค.
ํ์์คํฌํ ๊ธฐ๋ฐ TTL ๋ฐฉ์(์: 1์๊ฐ ์ด์ ๊ฒฝ๊ณผํ ํญ๋ชฉ๋ง ์ญ์ )์ผ๋ก ๊ตฌํํ๊ฑฐ๋, ์ฃผ๊ธฐ์ ์ธ cleanup์ ์ค์ผ์ค๋งํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
๐ ํ์์คํฌํ ๊ธฐ๋ฐ ์ ๋ฆฌ ์์
-private final ConcurrentHashMap<String, Integer> retryCountMap = new ConcurrentHashMap<>(); +private final ConcurrentHashMap<String, RetryEntry> retryCountMap = new ConcurrentHashMap<>(); + +private static class RetryEntry { + int count; + long timestamp; + RetryEntry(int count) { + this.count = count; + this.timestamp = System.currentTimeMillis(); + } +} public boolean canRetry(String topic, int partition, long offset) { String key = buildKey(topic, partition, offset); - int currentCount = retryCountMap.compute(key, (k, v) -> v == null ? 1 : v + 1); + RetryEntry entry = retryCountMap.compute(key, (k, v) -> + v == null ? new RetryEntry(1) : new RetryEntry(v.count + 1) + ); + int currentCount = entry.count; log.debug("[RetryTracker] Retry count for {}: {}/{}", key, currentCount, MAX_RETRY_COUNT); return currentCount <= MAX_RETRY_COUNT; } public void cleanupOldEntries() { - int sizeBefore = retryCountMap.size(); - if (sizeBefore > 10000) { - log.warn("[RetryTracker] Clearing all retry counts (size: {})", sizeBefore); - retryCountMap.clear(); - } + long now = System.currentTimeMillis(); + long oneHourAgo = now - 3600_000; + retryCountMap.entrySet().removeIf(entry -> entry.getValue().timestamp < oneHourAgo); + log.debug("[RetryTracker] Cleaned up old entries, current size: {}", retryCountMap.size()); }๊ทธ๋ฆฌ๊ณ ์ค์ผ์ค๋ง ์ถ๊ฐ:
@Scheduled(fixedRate = 300000) // 5๋ถ๋ง๋ค public void scheduledCleanup() { cleanupOldEntries(); }apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
71-80: ์ด๊ธฐ ์์ฑ ์ lastUpdated ์ค์ ์ ํ์์ฑ ์ฌ๊ณ
create()๋ฉ์๋์์lastUpdated๋ฅผZonedDateTime.now()๋ก ์ด๊ธฐํํ๊ณ ์์ต๋๋ค. ์ด๋ ์์ฑ ์งํ ๋ฐ์ํ ๊ณผ๊ฑฐ ์ด๋ฒคํธ(์: ์์ฑ ์์ ์ด์ ์ ํ์์คํฌํ๋ฅผ ๊ฐ์ง ์ด๋ฒคํธ)๊ฐisEventOutdated()์ฒดํฌ์ ๊ฑธ๋ ค ๋ฌด์๋ ์ ์์ต๋๋ค.์ด๊ธฐ ์์ฑ ์์๋
lastUpdated๋ฅผ null๋ก ๋์ด ์ฒซ ๋ฒ์งธ ์ด๋ฒคํธ๊ฐ ์ด๋ค ํ์์คํฌํ๋ฅผ ๊ฐ์ง๋ ์์ฉ๋๋๋ก ํ๋ ๊ฒ์ด ๋ ์์ ํ ์ ์์ต๋๋ค.๐ ์ ์๋ ์์
public static ProductMetrics create(Long productId) { ProductMetrics metrics = new ProductMetrics(); metrics.productId = productId; metrics.likeCount = 0; metrics.viewCount = 0; metrics.salesCount = 0; metrics.salesAmount = 0; - metrics.lastUpdated = ZonedDateTime.now(); + metrics.lastUpdated = null; // ์ฒซ ์ด๋ฒคํธ์ ํ์์คํฌํ๋ฅผ ์์ฉ return metrics; }apps/commerce-streamer/src/main/java/com/loopers/application/event/product/ProductViewedEvent.java (1)
5-11: ์ด๋ฒคํธ ์ ์ ์ค๋ณต์ ๊ณ ๋ คํด๋ณด์ธ์.์ด ์ด๋ฒคํธ๊ฐ commerce-api ๋ชจ๋์๋ ๋์ผํ๊ฒ ์ ์๋์ด ์์ต๋๋ค. ์ด๋ฒคํธ ์คํค๋ง๊ฐ producer์ consumer ๊ฐ์ ์ผ์นํด์ผ ํ๋ฏ๋ก ๊ณต์ ๋ชจ๋๋ก ์ถ์ถํ๋ ๊ฒ์ ๊ณ ๋ คํด๋ณด์ธ์. ์ด๋ ๊ฒ ํ๋ฉด ์คํค๋ง ๋ณ๊ฒฝ ์ ์ผ๊ด์ฑ์ ๋ณด์ฅํ ์ ์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (1)
247-261: DLQ ์ ์ก ํ ์ค๋ณต๋ clearRetryCount ํธ์ถ์ ์ ๋ฆฌํ์ธ์.Lines 251๊ณผ 260์์
retryTracker.clearRetryCount()๊ฐ ์ค๋ณต ํธ์ถ๋๊ณ ์์ต๋๋ค. ๋ ๊ฒฝ์ฐ ๋ชจ๋ DLQ๋ก ๋ฉ์์ง๋ฅผ ์ ์กํ ํ ์ฌ์๋ ์นด์ดํฐ๋ฅผ ํด๋ฆฌ์ดํ๋๋ฐ, ์ด๋ฅผ ํ ๋ฒ๋ง ํธ์ถํ๋๋ก ๋ฆฌํฉํ ๋งํ ์ ์์ต๋๋ค.๐ ์ ์ํ๋ ๊ฐ์
private void handleFailedRecord( ConsumerRecord<String, String> record, Exception exception, List<ConsumerRecord<String, String>> failedRecords ) { + boolean shouldPublishToDlq = false; + int retryCount = retryTracker.getRetryCount(record.topic(), record.partition(), record.offset()); + if (dlqPublisher.shouldSendToDlq(exception)) { // ๋ณต๊ตฌ ๋ถ๊ฐ๋ฅํ ์๋ฌ โ DLQ๋ก ์ ์ก - int retryCount = retryTracker.getRetryCount(record.topic(), record.partition(), record.offset()); - dlqPublisher.publishToDlq(record, exception, retryCount); - retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); + shouldPublishToDlq = true; } else { // ์ผ์์ ์๋ฌ โ ์ฌ์๋ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ if (retryTracker.canRetry(record.topic(), record.partition(), record.offset())) { failedRecords.add(record); } else { // ์ต๋ ์ฌ์๋ ํ์ ์ด๊ณผ โ DLQ๋ก ์ ์ก - int retryCount = retryTracker.getRetryCount(record.topic(), record.partition(), record.offset()); - dlqPublisher.publishToDlq(record, exception, retryCount); - retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); + shouldPublishToDlq = true; } } + + if (shouldPublishToDlq) { + dlqPublisher.publishToDlq(record, exception, retryCount); + retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); + } }apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicRouter.java (1)
22-38: ์ด๋ฒคํธ ํ์ ์ ์์ ๋๋ Enum์ผ๋ก ๊ด๋ฆฌํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
KafkaOutboxEventListener์์๋ ๋์ผํ ๋ฌธ์์ด ๋ฆฌํฐ๋ด("ORDER_PLACED","PRODUCT_LIKED"๋ฑ)์ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ์คํ๋ก ์ธํ ๋ฐํ์ ์๋ฌ๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด ์์ ๋๋ Enum์ผ๋ก ๊ด๋ฆฌํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ์์ ํด๋์ค ์์
public final class EventTypes { public static final String ORDER_PLACED = "ORDER_PLACED"; public static final String ORDER_COMPLETED = "ORDER_COMPLETED"; public static final String PAYMENT_COMPLETED = "PAYMENT_COMPLETED"; public static final String PRODUCT_LIKED = "PRODUCT_LIKED"; public static final String PRODUCT_UNLIKED = "PRODUCT_UNLIKED"; public static final String PRODUCT_VIEWED = "PRODUCT_VIEWED"; private EventTypes() {} }apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.java (1)
69-87:NullPointerException์ DLQ๋ก ์ฆ์ ์ ์กํ๋ ๊ฒ์ด ์ ์ ํ์ง ๊ฒํ ํ์ธ์.
NullPointerException์ ์ผ์์ ์ธ ๋ฐ์ดํฐ ๋ฌธ์ ๋ ๊ฒฝํฉ ์ํ์์๋ ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ค๋ฅธRuntimeException์ฒ๋ผ ์ฌ์๋ ํ DLQ๋ก ๋ณด๋ด๋ ๊ฒ์ด ๋ ์์ ํ ์ ์์ต๋๋ค. ๋ํ, Java 16+ ํจํด ๋งค์นญ์ ์ฌ์ฉํ๋ฉด ์ฝ๋๊ฐ ๋ ๊ฐ๊ฒฐํด์ง๋๋ค.๐ ํจํด ๋งค์นญ ์ฌ์ฉ ์์
public boolean shouldSendToDlq(Exception exception) { - // ์ญ์ง๋ ฌํ ์๋ฌ โ DLQ (์ฌ์๋ ๋ถ๊ฐ๋ฅ) - if (exception instanceof com.fasterxml.jackson.core.JsonProcessingException) { - return true; - } - - // IllegalArgumentException โ DLQ (๋ฐ์ดํฐ ์ค๋ฅ) - if (exception instanceof IllegalArgumentException) { - return true; - } - - // NullPointerException โ DLQ (๋ฐ์ดํฐ ์ค๋ฅ) - if (exception instanceof NullPointerException) { - return true; - } - - // ๊ทธ ์ธ์ ์๋ฌ๋ ์ผ์์ ์ฅ์ ์ผ ์ ์์ผ๋ฏ๋ก ์ฌ์๋ - return false; + return switch (exception) { + case com.fasterxml.jackson.core.JsonProcessingException _ -> true; // ์ญ์ง๋ ฌํ ์๋ฌ + case IllegalArgumentException _ -> true; // ๋ฐ์ดํฐ ์ค๋ฅ + // NullPointerException์ ์ฌ์๋ ํ DLQ๋ก ๋ณด๋ด๋ ๊ฒ์ ๊ณ ๋ ค + default -> false; + }; }apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java (2)
86-96: ํ ์คํธ ๊ฒฉ๋ฆฌ์ฑ ํฅ์์ ์ํดfindAll().get(0)๋์ ๋ ๋ช ํํ ์กฐํ ๋ฐฉ๋ฒ์ ์ฌ์ฉํ์ธ์.
@Transactional์ด๋ ธํ ์ด์ ์ผ๋ก ๋กค๋ฐฑ๋์ง๋ง, ๋ค๋ฅธ ํ ์คํธ์์ ๋จ์ ๋ฐ์ดํฐ๊ฐ ์์ ๊ฒฝ์ฐget(0)์ด ์์์น ๋ชปํ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํ ์ ์์ต๋๋ค. ํ ์คํธ ์์ ์ ๋ฐ์ดํฐ๋ฅผ ์ ๋ฆฌํ๊ฑฐ๋, ์ ์ฅ๋ ๋ ์ฝ๋๋ฅผ ์ง์ ๋ฐํ๋ฐ๋ ๋ฐฉ์์ ๊ณ ๋ คํ์ธ์.๐ ๊ฐ์ ๋ ํ ์คํธ ํจํด
@Test @DisplayName("์คํจํ ๋ฉ์์ง๋ฅผ DLQ ํ ์ด๋ธ์ ์ ์ฅ") void shouldSaveFailedMessageToDlq() { // given + dlqMessageRepository.deleteAll(); // ํ ์คํธ ๊ฒฉ๋ฆฌ ๋ณด์ฅ ConsumerRecord<String, String> record = new ConsumerRecord<>( "loopers.commerce.product-liked-v1", 0, 100L, "product-123", "{\"invalid\":\"json}" ); // ...
56-67: ํ ์คํธ ์ปค๋ฒ๋ฆฌ์ง ํฅ์์ ์ํด ์ถ๊ฐ ํ ์คํธ ์ผ์ด์ค๋ฅผ ๊ณ ๋ คํ์ธ์.
NullPointerException์ ๋ํ ํ ์คํธ์ DLQ ์ ์ฅ ์คํจ ์ ์์ธ๊ฐ ๋ฐ์ํ์ง ์๋์ง ํ์ธํ๋ ํ ์คํธ๋ ์ถ๊ฐํ๋ฉด ์ข์ต๋๋ค.์ถ๊ฐ ํ ์คํธ ์ผ์ด์ค๋ฅผ ์์ฑํด ๋๋ฆด๊น์?
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java (1)
11-14:existsById์ฌ์ ์ธ์ ๋ถํ์ํฉ๋๋ค.
JpaRepository๊ฐ ์ด๋ฏธexistsById(ID id)๋ฉ์๋๋ฅผ ์ ๊ณตํ๋ฏ๋ก ์ฌ์ ์ธํ ํ์๊ฐ ์์ต๋๋ค.๐ ์ ๊ฑฐ ์ ์
public interface EventHandledRepository extends JpaRepository<EventHandled, String> { - /** - * ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ฌ๋ถ ํ์ธ (๋ฉฑ๋ฑ์ฑ ์ฒดํฌ) - */ - boolean existsById(String eventId); - /** * ํน์ ๊ธฐ๊ฐ ์ด์ ์ ์ฒ๋ฆฌ ์๋ฃ ์ด๋ฒคํธ ์ญ์ (์ ๋ฆฌ์ฉ)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxEventListener.java (1)
34-38: ์ด๋ฒคํธ ํ์ ๋ฌธ์์ด์KafkaTopicRouter์ ๊ณต์ ํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
"ORDER_PLACED","PRODUCT_LIKED"๋ฑ์ ๋ฌธ์์ด์ดKafkaTopicRouter์ ์ด ํด๋์ค์์ ์ค๋ณต ์ฌ์ฉ๋๊ณ ์์ต๋๋ค. ์คํ๋ก ์ธํ ๋ฐํ์ ์๋ฌ๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด ๊ณต์ ์์๋ฅผ ์ฌ์ฉํ์ธ์.Also applies to: 48-52, 62-66, 76-80, 90-94, 104-108
apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java (1)
17-22: ๋๋์ ๋ฏธํด๊ฒฐ DLQ ๋ฉ์์ง๊ฐ ์์ ๊ฒฝ์ฐ๋ฅผ ๋๋นํด ํ์ด์ง๋ค์ด์ ์ ๊ณ ๋ คํ์ธ์.
findByResolvedFalseOrderByFailedAtDesc()์findByOriginalTopicOrderByFailedAtDesc()๊ฐ ์ ํ ์์ด ๋ชจ๋ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํฉ๋๋ค. ์ด์ ํ๊ฒฝ์์ DLQ ๋ฉ์์ง๊ฐ ๋ง์ด ์์ผ ๊ฒฝ์ฐ ๋ฉ๋ชจ๋ฆฌ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.๐ ํ์ด์ง๋ค์ด์ ์ ์ฉ ์์
List<DlqMessage> findByResolvedFalseOrderByFailedAtDesc(Pageable pageable);apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
53-60: ํฉํ ๋ฆฌ ๋ฉ์๋์์ null ๊ฐ ๊ฒ์ฆ์ ์ถ๊ฐํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์.
eventId,eventType,partitionKey๊ฐ null์ผ ๊ฒฝ์ฐ DB ์ ์ฅ ์์ ์์์ผ ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค. ํฉํ ๋ฆฌ ๋ฉ์๋์์ ์กฐ๊ธฐ์ ๊ฒ์ฆํ๋ฉด ๋๋ฒ๊น ์ด ์ฉ์ดํด์ง๋๋ค.๐ ๊ฒ์ฆ ์ถ๊ฐ ์์
public static EventHandled create(String eventId, String eventType, String partitionKey) { + Objects.requireNonNull(eventId, "eventId must not be null"); + Objects.requireNonNull(eventType, "eventType must not be null"); + Objects.requireNonNull(partitionKey, "partitionKey must not be null"); + EventHandled handled = new EventHandled(); handled.eventId = eventId; handled.eventType = eventType; handled.partitionKey = partitionKey; handled.handledAt = ZonedDateTime.now(); return handled; }apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPoller.java (2)
39-73: ๋จ์ผ ํธ๋์ญ์ ์์ ๋๋์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ DB ์ปค๋ฅ์ ์ ์ ๋ฌธ์ ๋ฅผ ๊ณ ๋ คํ์ธ์.ํ์ฌ ๊ตฌ์กฐ์์๋ 100๊ฐ์ ์ด๋ฒคํธ๊ฐ ํ๋์ ํธ๋์ญ์ ๋ด์์ ์์ฐจ์ ์ผ๋ก ์ฒ๋ฆฌ๋ฉ๋๋ค. Kafka ์ ์ก ์ง์ฐ์ด ๋ฐ์ํ๋ฉด DB ์ปค๋ฅ์ ์ด ์ค๋ ์ ์ ๋ ์ ์์ต๋๋ค. ๊ฐ ์ด๋ฒคํธ๋ฅผ ๊ฐ๋ณ ํธ๋์ญ์ ์ผ๋ก ์ฒ๋ฆฌํ๊ฑฐ๋, ์ปค๋ฅ์ ํ ์ค์ ์ ๊ฒํ ํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
๐ ๊ฐ์ ๋ฐฉ์
์ด๋ฒคํธ๋ณ ๊ฐ๋ณ ํธ๋์ญ์ ์ ์ฌ์ฉํ๋ฉด ํ ์ด๋ฒคํธ์ ์คํจ๊ฐ ๋ค๋ฅธ ์ด๋ฒคํธ์ ์ํฅ์ ์ฃผ์ง ์์ผ๋ฉฐ, DB ์ปค๋ฅ์ ์ ์ ์๊ฐ๋ ๋จ์ถ๋ฉ๋๋ค:
@Scheduled(fixedDelay = 5000, initialDelay = 5000) public void pollAndPublish() { List<OutboxEvent> pendingEvents = outboxRepository.findByStatusOrderByCreatedAtAsc( OutboxEvent.OutboxStatus.PENDING, BATCH_SIZE ); for (OutboxEvent event : pendingEvents) { processEventInTransaction(event); // ๋ณ๋ @Transactional ๋ฉ์๋๋ก ๋ถ๋ฆฌ } } @Transactional(propagation = Propagation.REQUIRES_NEW) public void processEventInTransaction(OutboxEvent event) { // ๊ฐ๋ณ ์ด๋ฒคํธ ์ฒ๋ฆฌ }
27-28:MAX_RETRY_COUNT๋ฅผOutboxEventCleaner์ ๊ณต์ ํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
OutboxEventCleaner์์๋ ๋์ผํ๊ฒ3์ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ๊ฐ์ด ๋ถ์ผ์นํ๋ฉด FAILED ์ด๋ฒคํธ๊ฐ ์๋์น ์๊ฒ ์ญ์ ๋๊ฑฐ๋ ์ฌ์๋๋ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (1)
194-196: ํ์์กด ๋ฌธ์์ด์ ์์๋ก ์ถ์ถํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
"Asia/Seoul"๋ฌธ์์ด์ด ํ๋์ฝ๋ฉ๋์ด ์์ต๋๋ค. ์ฌ๋ฌ ๊ณณ์์ ์ฌ์ฉ๋ ์ ์์ผ๋ฏ๋ก ์์๋ ์ค์ ์ผ๋ก ๊ด๋ฆฌํ๋ฉด ์ ์ง๋ณด์์ฑ์ด ํฅ์๋ฉ๋๋ค.์ ์๋ ๋ฆฌํฉํ ๋ง
+ private static final ZoneId ZONE_ID_SEOUL = ZoneId.of("Asia/Seoul"); + /** * LocalDateTime์ ZonedDateTime์ผ๋ก ๋ณํ (Asia/Seoul) */ private ZonedDateTime toZonedDateTime(java.time.LocalDateTime localDateTime) { - return localDateTime.atZone(ZoneId.of("Asia/Seoul")); + return localDateTime.atZone(ZONE_ID_SEOUL); }apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventCleaner.java (3)
31-31: ํ์์กด์ ๋ช ์์ ์ผ๋ก ์ง์ ํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
ZonedDateTime.now()๋ ์์คํ ๊ธฐ๋ณธ ํ์์กด์ ์ฌ์ฉํฉ๋๋ค. ์๋ฒ ํ๊ฒฝ์ ๋ฐ๋ผ ๋ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ ๋์ฌ ์ ์์ผ๋ฏ๋ก, ๋ช ์์ ์ผ๋ก ํ์์กด์ ์ง์ ํ๋ฉด ๋ ์์ธก ๊ฐ๋ฅํฉ๋๋ค.์ ์๋ ์์
- ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(7); + ZonedDateTime cutoffDate = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).minusDays(7);
61-65: ๋งค์ง ๋๋ฒ๋ฅผ ์์๋ก ์ถ์ถํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.์ฌ์๋ ํ์
3์ด ํ๋์ฝ๋ฉ๋์ด ์์ต๋๋ค.OutboxEventPoller์์๋ ๋์ผํ ๊ฐ์ ์ฌ์ฉํ ๊ฐ๋ฅ์ฑ์ด ๋์ผ๋ฏ๋ก, ๊ณตํต ์์๋ก ๊ด๋ฆฌํ๋ฉด ์ผ๊ด์ฑ์ ์ ์งํ๊ธฐ ์ข์ต๋๋ค.์ ์๋ ์์
+ private static final int MAX_RETRY_COUNT = 3; + private static final int PUBLISHED_RETENTION_DAYS = 7; + private static final int FAILED_RETENTION_DAYS = 30; + @Scheduled(cron = "0 0 4 * * SUN") @Transactional public void cleanOldFailedEvents() { - ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(30); + ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(FAILED_RETENTION_DAYS); // ... int deleted = outboxRepository.deleteFailedEventsOlderThan( OutboxEvent.OutboxStatus.FAILED, cutoffDate, - 3 // ์ต๋ ์ฌ์๋ ํ์ ์ด๊ณผํ ์ด๋ฒคํธ๋ง ์ญ์ + MAX_RETRY_COUNT );
91-99: ๊ฒฝ๊ณ ์๊ณ๊ฐ๋ ์์๋ก ๊ด๋ฆฌํ๋ฉด ์ข์ต๋๋ค.PENDING 1000๊ฐ, FAILED 100๊ฐ ์๊ณ๊ฐ์ด ํ๋์ฝ๋ฉ๋์ด ์์ต๋๋ค. ์ด์ ํ๊ฒฝ์์ ์กฐ์ ์ด ํ์ํ ์ ์์ผ๋ฏ๋ก ์์ ๋๋ ์ค์ ํ๋กํผํฐ๋ก ๊ด๋ฆฌํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java (2)
132-148: ์คํ ํธ๋ ์ด์ค์์ cause chain์ด ๋๋ฝ๋ฉ๋๋ค.ํ์ฌ ๊ตฌํ์ ์ต์์ ์์ธ์ ์คํ ํธ๋ ์ด์ค๋ง ์บก์ฒํฉ๋๋ค. ๋ํ๋ ์์ธ(wrapped exception)์ ๊ฒฝ์ฐ ๊ทผ๋ณธ ์์ธ์ ํ์ ํ๊ธฐ ์ด๋ ค์ธ ์ ์์ต๋๋ค. ๋๋ฒ๊น ์ ํ์ํ๋ค๋ฉด cause chain ํฌํจ์ ๊ณ ๋ คํด๋ณด์ธ์.
์ ์๋ ๊ฐ์
private static String getStackTraceAsString(Exception exception) { StringBuilder sb = new StringBuilder(); sb.append(exception.toString()).append("\n"); StackTraceElement[] stackTrace = exception.getStackTrace(); int maxLines = Math.min(stackTrace.length, 10); for (int i = 0; i < maxLines; i++) { sb.append("\tat ").append(stackTrace[i]).append("\n"); } if (stackTrace.length > maxLines) { sb.append("\t... ").append(stackTrace.length - maxLines).append(" more\n"); } + // Capture cause chain + Throwable cause = exception.getCause(); + if (cause != null) { + sb.append("Caused by: ").append(cause.toString()).append("\n"); + StackTraceElement[] causeTrace = cause.getStackTrace(); + int causeMaxLines = Math.min(causeTrace.length, 5); + for (int i = 0; i < causeMaxLines; i++) { + sb.append("\tat ").append(causeTrace[i]).append("\n"); + } + } return sb.toString(); }
125-127: ์ฌ์ฒ๋ฆฌ ์๋ฃ ์๊ฐ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํด๋ณด์ธ์.
markAsResolved()๋ฉ์๋์์resolvedํ๋๊ทธ๋ง ์ค์ ํ๊ณ ์์ต๋๋ค. ์ฌ์ฒ๋ฆฌ ์๋ฃ ์๊ฐ(resolvedAt)์ ๊ธฐ๋กํ๋ฉด ์ด์ ๋ชจ๋ํฐ๋ง๊ณผ ๊ฐ์ฌ(audit) ์ ์ ์ฉํ ์ ์์ต๋๋ค.์ ์๋ ๊ฐ์
+ @Column(name = "resolved_at") + private ZonedDateTime resolvedAt; + public void markAsResolved() { this.resolved = true; + this.resolvedAt = ZonedDateTime.now(); }apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
103-107: ์ค๋ณต DB ์กฐํ ๋ฐ์ - ์ฑ๋ฅ ์ต์ ํ ํ์
productReadService.getProductDetail()๋ด๋ถ์์ ์ด๋ฏธ Product ์ํฐํฐ๋ฅผ ์กฐํํ๊ณ ์๋๋ฐ, ์ฌ๊ธฐ์productRepository.findById()๋ก ๋์ผํ Product๋ฅผ ๋ค์ ์กฐํํ๊ณ ์์ต๋๋ค. ์ํ ์์ธ ์กฐํ๋ง๋ค ๋ถํ์ํ DB ํธ์ถ์ด ๋ฐ์ํฉ๋๋ค.๋ํ,
CoreException๊ณผErrorType์ fully qualified name ๋์ import ๋ฌธ์ ์ฌ์ฉํ๋ ๊ฒ์ด ์ผ๊ด์ฑ ์์ต๋๋ค.๐ ๊ฐ์ ์ ์
ProductDetailInfo์brandIdํ๋๋ฅผ ์ถ๊ฐํ๊ฑฐ๋,productReadService.getProductDetail()์ด brandId๋ ํจ๊ป ๋ฐํํ๋๋ก ์์ ํ๋ฉด ์ค๋ณต ์กฐํ๋ฅผ ์ ๊ฑฐํ ์ ์์ต๋๋ค.+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType;๋๋ ์บ์๋ ์ ๋ณด์์ brandId๋ฅผ ์ป์ ์ ์๋๋ก DTO๋ฅผ ํ์ฅํ๋ ๋ฐฉ์์ ๊ณ ๋ คํด์ฃผ์ธ์.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepository.java (1)
50-55: @Modifying ์ต์ ์ถ๊ฐ ๊ณ ๋ ค๋ฒํฌ ์ญ์ ํ ์์์ฑ ์ปจํ ์คํธ์ ์ญ์ ๋ ์ํฐํฐ๊ฐ ๋จ์์์ ์ ์์ต๋๋ค. ๋์ผ ํธ๋์ญ์ ๋ด์์ ํ์ ์กฐํ๊ฐ ์๋ค๋ฉด stale ๋ฐ์ดํฐ๋ฅผ ๋ฐํํ ์ ์์ต๋๋ค.
๐ ๊ฐ์ ์ ์
- @Modifying + @Modifying(clearAutomatically = true) @Query("DELETE FROM OutboxEvent o WHERE o.status = :status AND o.publishedAt < :publishedBefore")Line 64์
deleteFailedEventsOlderThan์๋ ๋์ผํ๊ฒ ์ ์ฉ ๊ถ์ฅํฉ๋๋ค.
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (42)
apps/commerce-api/build.gradle.kts(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderStatusEventListener.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/event/order/OrderCompletedEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/event/product/ProductViewedEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(4 hunks)apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderPlacementService.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicRouter.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/event/KafkaEventEnvelope.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxEventListener.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventCleaner.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPoller.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventWriter.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java(1 hunks)apps/commerce-api/src/main/resources/application.yml(1 hunks)apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/infrastructure/kafka/KafkaConnectionTest.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductLikedEvent.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductUnlikedEvent.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/application/event/order/OrderCompletedEvent.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/application/event/product/ProductViewedEvent.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/confg/kafka/KafkaConfig.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/RetryTracker.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/event/KafkaEventEnvelope.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java(1 hunks)apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java(1 hunks)apps/commerce-streamer/src/test/java/com/loopers/config/TestConfig.java(1 hunks)apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java(1 hunks)apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/RetryTrackerTest.java(1 hunks)apps/commerce-streamer/src/test/resources/application-test.yml(1 hunks)modules/kafka/src/main/resources/kafka.yml(1 hunks)
๐งฐ Additional context used
๐ง Learnings (8)
๐ Common learnings
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
๐ Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product ์ํฐํฐ (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)๋ ์ ์ฆ์ผ์ด์ค๋ณ๋ก ์๋์ ์ผ๋ก ๋ค๋ฅธ ๋ฝ ์ ๋ต์ ์ฌ์ฉํ๋ค: ์ข์์ ๊ธฐ๋ฅ์๋ ๋น๊ด์ ๋ฝ(findByIdForUpdate)์, ์ฌ๊ณ ์ฐจ๊ฐ์๋ ๋๊ด์ ๋ฝ(Version + ์ฌ์๋)์ ์ฌ์ฉํ๋ค.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/product/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/event/product/ProductViewedEvent.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductUnlikedEvent.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductLikedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
๐ Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService์์ ์ํ ๋ชฉ๋ก ์กฐํ ์ Redis ์บ์๋ฅผ ์ ์ฉํ์ผ๋ฉฐ, ์บ์ ํค๋ brandId, sortType, pageNumber, pageSize์ ์กฐํฉ์ผ๋ก ๊ตฌ์ฑ๋๊ณ TTL์ 5๋ถ์ผ๋ก ์ค์ ๋์ด ์๋ค.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
๐ Learning: 2025-12-19T03:23:51.674Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:51.674Z
Learning: In the loopers-spring-java-template project, test container configurations (MySQL, Redis, Kafka) use static initialization blocks for consistency across the template. This pattern should be maintained for uniformity, even if alternative lifecycle management approaches exist.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/config/TestConfig.javaapps/commerce-streamer/src/test/resources/application-test.ymlapps/commerce-api/src/test/java/com/loopers/infrastructure/kafka/KafkaConnectionTest.java
๐ Learning: 2025-12-18T13:24:54.339Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxEventListener.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPoller.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventWriter.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/event/KafkaEventEnvelope.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java
๐ Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
๐ Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java
๐ Learning: 2025-12-18T01:01:55.894Z
Learnt from: jikimee64
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 65
File: modules/kafka/src/main/resources/kafka.yml:25-32
Timestamp: 2025-12-18T01:01:55.894Z
Learning: In Spring Boot Kafka configuration YAML, properties inside spring.kafka.producer.properties and spring.kafka.consumer.properties maps must use exact Kafka client property names with dot notation, and must be quoted with bracket notation like "[enable.idempotence]": true and "[enable.auto.commit]": false to prevent YAML from parsing dots as nested keys. Spring Boot's relaxed binding only applies to top-level Spring Kafka properties, not to the properties map.
Applied to files:
modules/kafka/src/main/resources/kafka.yml
๐งฌ Code graph analysis (6)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.java (2)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/RetryTracker.java (1)
Slf4j(13-86)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(22-119)
apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
Entity(15-61)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
Entity(15-160)apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
errorType(3-6)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxEventListener.java (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventCleaner.java (1)
Slf4j(16-105)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPoller.java (1)
Slf4j(22-144)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventWriter.java (1)
Slf4j(14-50)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/RetryTracker.java (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.java (1)
Slf4j(16-88)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
result(7-19)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java (1)
Entity(15-149)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
Entity(15-160)apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
name(14-87)
๐ Additional comments (33)
apps/commerce-streamer/src/test/resources/application-test.yml (2)
2-4: LGTM! ํ์ค Testcontainers ์ค์ Testcontainers๋ฅผ ์ฌ์ฉํ MySQL ์ค์ ์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
TC_DAEMON=trueํ๋ผ๋ฏธํฐ๋ ํ ์คํธ ๊ฐ ์ปจํ ์ด๋ ์ฌ์ฌ์ฉ์ ํ์ฉํ์ฌ ์ฑ๋ฅ์ ๊ฐ์ ํฉ๋๋ค.
5-11: LGTM! ํ ์คํธ์ ์ ํฉํ JPA ์ค์
ddl-auto: create-drop๋ ํ ์คํธ ๊ฒฉ๋ฆฌ๋ฅผ ๋ณด์ฅํ๊ณ , SQL ๋ก๊น ์ค์ ์ ๋๋ฒ๊น ์ ์ ์ฉํฉ๋๋ค.apps/commerce-streamer/src/test/java/com/loopers/config/TestConfig.java (1)
8-17: Kafka ์๋ ์ค์ ์ ์ธ ์ ๋น ์์กด์ฑ ํ์ธ ํ์Kafka ์๋ ์ค์ ์ ์ ์ธํ๋ ์ ๊ทผ ๋ฐฉ์์ ์ฌ๋ฐ๋ฅด์ง๋ง, ํ๋ก๋์ Kafka ์ปจ์๋จธ๊ฐ
KafkaTemplate,ConsumerFactory๋ฑ์ Kafka ๋น์ ์ง์ ์ฃผ์ ๋ฐ๊ณ ์๋ค๋ฉด ํ ์คํธ ์ปจํ ์คํธ ๋ก๋ฉ์ด ์คํจํ ์ ์์ต๋๋ค. ๋ค์์ ํ์ธํด์ฃผ์ธ์:
- Kafka ๊ด๋ จ ๋น์ ์ง์
@Autowiredํ๋ ์ปจ์๋จธ๋ ์๋น์ค ํด๋์ค์ ์กด์ฌ ์ฌ๋ถ- ์ค์ ํ ์คํธ ์คํ ์ ๋น ์ด๊ธฐํ ์ค๋ฅ ๋ฐ์ ์ฌ๋ถ
์ด๋ฐ ๋ฌธ์ ๊ฐ ์๋ค๋ฉด, ํด๋น Kafka ์์กด ๋น๋ค์ ๋ณ๋ ์ค์ ์ผ๋ก ๋ถ๋ฆฌํ๊ฑฐ๋ ํ ์คํธ ํ๊ฒฝ์์๋ง ํ์ํ ํน์ ํจํค์ง๋ง ์ค์บํ๋๋ก ์์ ํด์ฃผ์ธ์.
apps/commerce-api/build.gradle.kts (1)
5-5: LGTM!Kafka ๋ชจ๋ ์์กด์ฑ ์ถ๊ฐ๊ฐ PR ๋ชฉํ์ธ Kafka ๊ธฐ๋ฐ ์ด๋ฒคํธ ํ์ดํ๋ผ์ธ ๊ตฌํ๊ณผ ์ ๋ง์ต๋๋ค.
apps/commerce-api/src/main/resources/application.yml (1)
24-24: LGTM!kafka.yml ์ค์ import ์ถ๊ฐ๊ฐ Kafka ๋ชจ๋ ์์กด์ฑ๊ณผ ์ผ๊ด์ฑ ์๊ฒ ๊ตฌ์ฑ๋์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.java (1)
38-46: LGTM!์ฌ๊ณ ์์ง ์ ์บ์ ๋ฌดํจํ ๋ฉ์๋๊ฐ ๊ธฐ์กด
invalidateOnLikeChange,invalidateOnProductUpdateํจํด๊ณผ ์ผ๊ด์ฑ ์๊ฒ ๊ตฌํ๋์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/event/order/OrderCompletedEvent.java (1)
11-18: LGTM!์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ์ ์ฃผ๋ฌธ ํญ๋ชฉ ์ ๋ณด(
OrderItemInfo)๋ฅผ ์ถ๊ฐํ์ฌ downstream consumer์์ ์ํ๋ณ ๋ฉํธ๋ฆญ(salesCount, salesAmount) ์ง๊ณ๊ฐ ๊ฐ๋ฅํด์ก์ต๋๋ค. ์ค์ฒฉ record ํจํด์ผ๋ก ๊ด๋ จ ํ์ ์ ํจ๊ป ์ ์งํ๋ ๊ฒ์ด ์ข์ต๋๋ค.apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/RetryTrackerTest.java (1)
1-115: LGTM!ํ ์คํธ ์ปค๋ฒ๋ฆฌ์ง๊ฐ ์ฐ์ํ๋ฉฐ RetryTracker์ ํต์ฌ ๋์์ ๋ชจ๋ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค:
- ์ฌ์๋ ํ์ฉ ๋ก์ง (์ต๋ 3ํ)
- ๋ฉ์์ง๋ณ ๋ ๋ฆฝ์ ์ธ ์ถ์
- ์ฑ๊ณต ์ ์นด์ดํฐ ์ด๊ธฐํ ๋ฐ ์ฌ์์
- ์กด์ฌํ์ง ์๋ ๋ฉ์์ง์ ๊ธฐ๋ณธ๊ฐ
๋ชจ๋ ํ ์คํธ ์ผ์ด์ค๊ฐ ๋ช ํํ๊ณ ์์ ๋์์ ์ ํํ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java (1)
29-29: LGTM!์ฌ๊ณ ์๋ ์กฐํ๋ฅผ ์ํ ์ฝ๊ธฐ ์ ์ฉ ๋ฉ์๋ ์ถ๊ฐ๊ฐ ์ ์ ํฉ๋๋ค. ๊ธฐ์กด ํจํด๊ณผ ์ผ๊ด์ฑ์ด ์์ผ๋ฉฐ, ๋๋ฉ์ธ ๋ ์ด์ด์ ์ํ ๋ณ๊ฒฝ ์์ด ์ฌ๊ณ ์ ๋ณด๋ฅผ ์กฐํํ ์ ์๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java (1)
141-149: LGTM!QueryDSL์ ์ฌ์ฉํ ํจ์จ์ ์ธ ๋ถ๋ถ ํ๋ ์กฐํ ๊ตฌํ์ ๋๋ค. ํ์ํ stock.quantity๋ง ์ ํํ์ฌ ๋ถํ์ํ ๋ฐ์ดํฐ ๋ก๋ฉ์ ๋ฐฉ์งํ๊ณ ์์ผ๋ฉฐ, null ์ฒดํฌ๋ฅผ ํตํด ์์ ํ ๊ธฐ๋ณธ๊ฐ(0)์ ๋ฐํํฉ๋๋ค. ์ฝ๊ธฐ ์ ์ฉ ์์ ์ผ๋ก ๋ถ์์ฉ์ด ์์ต๋๋ค.
apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java (1)
98-105: LGTM!ํ ์คํธ์ฉ ์ธ๋ฉ๋ชจ๋ฆฌ ๊ตฌํ์ด ํ๋ก๋์ ์ฝ๋์ ๋์ผํ ๊ณ์ฝ์ ์ถฉ์คํ ์ดํํ๊ณ ์์ต๋๋ค. ์กด์ฌํ์ง ์๋ ์ํ์ ๋ํด 0์ ๋ฐํํ๋ ๊ธฐ๋ณธ ๋์์ด ProductRepositoryImpl๊ณผ ์ผ๊ด์ฑ ์๊ฒ ๊ตฌํ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java (1)
93-96: ํ์์กด ๋ช ์์ ์ง์ ํ์BaseEntity์ OutboxEvent ๋ชจ๋
ZonedDateTime.now()๋ฅผ ์ฌ์ฉํ์ฌ ํ์์กด ์ฒ๋ฆฌ๊ฐ ์ผ๊ด๋์ด ์์ต๋๋ค. ๋ค๋ง, ์์คํ ๊ธฐ๋ณธ ํ์์กด์ ์์กดํ๊ณ ์์ด ๋ถ์ฐ ํ๊ฒฝ์์ ์์ธก ๋ถ๊ฐ๋ฅํ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ช ์์ ์ผ๋ก UTC ํ์์กด์ ์ง์ ํ๋ ๊ฒ์ด ๊ถ์ฅ๋ฉ๋๋ค:ZonedDateTime.now(ZoneId.of("UTC"))๋๋ZonedDateTime.now(ZoneOffset.UTC)Likely an incorrect or invalid review comment.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (2)
25-38: JPQL LIMIT ๊ตฌ๋ฌธ์ JPA ํ์ค ํธํ์ฑ ํ์ธ ํ์Lines 25, 31, 37์์ JPQL ์ฟผ๋ฆฌ์
LIMIT :limit๊ตฌ๋ฌธ์ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ์ด๋ Hibernate์์๋ ์ง์๋์ง๋ง JPA ํ์ค ๊ตฌ๋ฌธ์ ์๋๋๋ค.JPA 2.0 ์ด์ ์๋
setMaxResults()๋ฅผ ์ฌ์ฉํด์ผ ํ๋ฉฐ, JPA 3.1๋ถํฐ๋ ํ์ค LIMIT์ด ์ถ๊ฐ๋์์ต๋๋ค. ํ์ฌ ์ฌ์ฉ ์ค์ธ JPA ๋ฒ์ ๊ณผ JPA ํ๋ก๋ฐ์ด๋๋ฅผ ํ์ธํ์ฌ ํธํ์ฑ์ ๊ฒ์ฆํด์ผ ํฉ๋๋ค.#!/bin/bash # JPA ๋ฐ Hibernate ๋ฒ์ ํ์ธ rg "jakarta.persistence|org.hibernate" apps/commerce-streamer/build.gradle apps/commerce-streamer/pom.xml -A 2 -B 2 # Spring Data JPA ๋ฒ์ ํ์ธ rg "spring-boot-starter-data-jpa" apps/commerce-streamer/build.gradle apps/commerce-streamer/pom.xml -A 1ํ์ค ํธํ์ฑ์ ์ํด ๋ค์ดํฐ๋ธ ์ฟผ๋ฆฌ๋ก ๋ณ๊ฒฝํ๊ฑฐ๋, Pageable์ ์ฌ์ฉํ๋ ๋ฐฉ์๋ ๊ณ ๋ คํ ์ ์์ต๋๋ค.
๐ ํ์ค ํธํ ๋์
-@Query("SELECT pm FROM ProductMetrics pm ORDER BY pm.likeCount DESC LIMIT :limit") -List<ProductMetrics> findTopByLikeCount(@Param("limit") int limit); +// ์ต์ 1: Pageable ์ฌ์ฉ (ํ์ค ๋ฐฉ์) +@Query("SELECT pm FROM ProductMetrics pm ORDER BY pm.likeCount DESC") +List<ProductMetrics> findTopByLikeCount(Pageable pageable); + +// ์ฌ์ฉ: repository.findTopByLikeCount(PageRequest.of(0, limit))
18-20:findByIdWithLock()๋ฉ์๋๊ฐ ์ ์๋์์ผ๋ ์ค์ ๋ก ์ฌ์ฉ๋์ง ์์ - ๋์์ธ ์ผ๊ด์ฑ ํ์ธ ํ์ProductMetrics ์ํฐํฐ๋
@Versionํ๋๋ก ๋๊ด์ ๋ฝ์ ํ์ฑํํ๊ณ ์์ง๋ง, ProductMetricsRepository์findByIdWithLock()๋ฉ์๋๋PESSIMISTIC_WRITE๋ก ์ ์๋์ด ์์ต๋๋ค. ๊ทธ๋ฌ๋ MetricsAggregationService์์๋findById()๋ฅผ ์ฌ์ฉํ๊ณ ์์ผ์ดfindByIdWithLock()์ ์ค์ ๋ก ํธ์ถ๋์ง ์๊ณ ์์ต๋๋ค.ํ์ฌ ๊ตฌํ์ ๋ค์๊ณผ ๊ฐ์ด ๋์ํฉ๋๋ค:
findById()๋ก ์ํฐํฐ ์กฐํ- ์ํฐํฐ ๋ฉ์๋ ํธ์ถ ์ timestamp ๊ธฐ๋ฐ ์ด๋ฒคํธ ์์ ๊ฒ์ฆ
- save() ์
@Versionํ๋ ์๋ ๊ด๋ฆฌ (JPA ์๋ฌต์ ์ฒ๋ฆฌ)- EventHandledRepository๋ฅผ ํตํ ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ
๋น๊ด์ ๋ฝ ๋ฉ์๋์ธ
findByIdWithLock()์ ์ ์๋ง ๋์ด ์๊ณ ์ฌ์ฉ๋์ง ์์ผ๋ฏ๋ก, ๋ค์ ์ค ํ๋๋ฅผ ์งํํ์ธ์:
- ๋ถํ์ํ ๋ฉ์๋๋ผ๋ฉด ์ ๊ฑฐ
- ์๋๋ ์ค๊ณ๋ผ๋ฉด ์ค์ ํธ์ถ ์ง์ ์์ ์ฌ์ฉํ๋๋ก ๋ณ๊ฒฝ ๋ฐ ์๋ฌ ์ฒ๋ฆฌ ์ถ๊ฐ
apps/commerce-api/src/main/java/com/loopers/application/event/product/ProductViewedEvent.java (1)
5-11: LGTM!์ด๋ฒคํธ ๋ ์ฝ๋ ๊ตฌ์กฐ๊ฐ ๋ช ํํ๊ณ ์ ์ ํฉ๋๋ค. ๋น๋ก๊ทธ์ธ ์ฌ์ฉ์ ์ถ์ ์ ์ํ nullable memberId ์ค๊ณ๋ ํฉ๋ฆฌ์ ์ ๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductLikedEvent.java (1)
5-11: LGTM!์ด๋ฒคํธ ๊ตฌ์กฐ๊ฐ ๋ช ํํ๊ณ ๋ค๋ฅธ ์ด๋ฒคํธ ๋ ์ฝ๋๋ค๊ณผ ์ผ๊ด์ฑ์ด ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderStatusEventListener.java (1)
53-59: LGTM!์ฃผ๋ฌธ ์์ดํ ์ ์ด๋ฒคํธ ํ์ด๋ก๋๋ก ๋ณํํ๋ ๋ก์ง์ด ๋ช ํํ๊ณ ์ ํํฉ๋๋ค. ํจ์ํ ์คํ์ผ๋ก ๊ฐ๊ฒฐํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/application/event/order/OrderCompletedEvent.java (1)
7-19: LGTM!์ด๋ฒคํธ ๊ตฌ์กฐ๊ฐ ๋ช ํํ๊ณ ์ค์ฒฉ๋ OrderItemInfo ๋ ์ฝ๋๋ก ํ์ ์์ ์ฑ์ ํ๋ณดํ์ต๋๋ค. ๊ธ์ก ํ๋์ BigDecimal ์ฌ์ฉ๋ ์ ์ ํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/event/KafkaEventEnvelope.java (1)
12-42: LGTM!Kafka ์ด๋ฒคํธ ์๋ฒจ๋กํ ์ค๊ณ๊ฐ ์ฐ์ํฉ๋๋ค. ๋ฉํ๋ฐ์ดํฐ์ ํ์ด๋ก๋๋ฅผ ๋ช ํํ ๋ถ๋ฆฌํ๊ณ , ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ์ ์์ ๋ณด์ฅ์ ์ํ ํ์ ์ ๋ณด๋ฅผ ๋ชจ๋ ํฌํจํ๊ณ ์์ต๋๋ค. ZonedDateTime ์ฌ์ฉ๋ ๋ถ์ฐ ์์คํ ์ ์ ํฉํฉ๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/application/event/like/ProductUnlikedEvent.java (1)
5-11: LGTM!์ด๋ฒคํธ ๊ตฌ์กฐ๊ฐ ๋ช ํํ๊ณ ProductLikedEvent์ ์ผ๊ด์ฑ์ด ์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (1)
241-263: ์ค์ ์ง์ค์ ์๋ฌ ํธ๋ค๋ง ํจํด ์ ์ฉ์ ๊ฒํ ํ์ธ์.ํ์ต๋ ํจํด์ ๋ฐ๋ฅด๋ฉด, ์ด ์ฝ๋๋ฒ ์ด์ค์์๋ Kafka ์ปจ์๋จธ์ ์๋ฌ ํธ๋ค๋ง์
EventInboxAspect๋ฅผ ํตํด ์๋น์ค ๋ ์ด์ด์ ์์ํ๋ ์ค์ ์ง์ค์ ์ ๊ทผ ๋ฐฉ์์ ์ฌ์ฉํฉ๋๋ค. ๊ทธ๋ฌ๋ ์ด ์ปจ์๋จธ๋RetryTracker์DlqPublisher๋ฅผ ์ง์ ์ฌ์ฉํ๋ ์ปค์คํ ์๋ฌ ํธ๋ค๋ง์ ๊ตฌํํ๊ณ ์์ต๋๋ค.ํ์ฌ ๊ตฌํ์ด ์๋์ ์ผ๋ก ๋ค๋ฅธ ํจํด์ ์ฌ์ฉํ๋ ๊ฒ์ธ์ง ํ์ธ์ด ํ์ํฉ๋๋ค. ๋ง์ฝ ์ผ๊ด์ฑ์ ์ํด ์ค์ ์ง์ค์ ํจํด์ ์ ์ฉํด์ผ ํ๋ค๋ฉด, ์๋น์ค ๋ฉ์๋์
@InboxEvent์ด๋ ธํ ์ด์ ์ ์ฌ์ฉํ๊ณEventInboxAspect๊ฐ ์๋ฌ ํธ๋ค๋ง๊ณผ ์ด๋ฒคํธ ์ถ์ ์ ๋ด๋นํ๋๋ก ๋ฆฌํฉํ ๋งํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์.Based on learnings from PR #190.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/DlqPublisher.java (1)
30-61: LGTM!DLQ ์ ์ฅ ์คํจ ์ ์์ธ๋ฅผ ๋์ง์ง ์๋ ๋ฐฉ์ด์ ์ฒ๋ฆฌ๊ฐ ์ ๊ตฌํ๋์ด ์์ต๋๋ค. ๋ฌดํ ๋ฃจํ ๋ฐฉ์ง๋ฅผ ์ํ ์ ์ ํ ํจํด์ ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxEventListener.java (1)
30-109: ๊ตฌ์กฐ๊ฐ ์ ์ค๊ณ๋์์ต๋๋ค.
BEFORE_COMMIT๋จ๊ณ๋ฅผ ์ฌ์ฉํ์ฌ ์๋ณธ ํธ๋์ญ์ ๊ณผ Outbox ์ ์ฅ์ ์์์ฑ์ ๋ณด์ฅํ๊ณ ์์ต๋๋ค. ๊ฐ ์ด๋ฒคํธ ํธ๋ค๋ฌ๊ฐ ์ผ๊ด๋ ํจํด์ ๋ฐ๋ฅด๊ณ ์์ด ์ ์ง๋ณด์์ฑ์ด ์ข์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
15-61: LGTM!๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ์ ์ํ ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ด๋ ฅ ์ํฐํฐ๊ฐ ์ ์ค๊ณ๋์์ต๋๋ค.
eventId๋ฅผ PK๋ก ์ฌ์ฉํ์ฌ ์ค๋ณต ์ด๋ฒคํธ ์ฒ๋ฆฌ๋ฅผ ํจ๊ณผ์ ์ผ๋ก ๋ฐฉ์งํ ์ ์์ต๋๋ค. ์ธ๋ฑ์ค ์ค์ ๋ ์ฟผ๋ฆฌ ํจํด์ ์ ํฉํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventWriter.java (1)
29-49: LGTM! Outbox ์ด๋ฒคํธ ์ ์ฅ ๋ก์ง์ด ๋ช ํํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.JSON ์ง๋ ฌํ ์คํจ ์
IllegalStateException์ผ๋ก ๋ํํ์ฌ ํธ์ถ ์ธก์์ ์ ์ ํ ์ฒ๋ฆฌํ ์ ์๋๋ก ํ๊ณ , ๋ก๊น ๋ ์ ์ ํ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (2)
36-69: ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ ๋ก์ง์ด ์ ๊ตฌํ๋์์ต๋๋ค.์ค๋ณต ์ด๋ฒคํธ ์ฒดํฌ, out-of-order ์ด๋ฒคํธ ์ฒ๋ฆฌ, ๊ทธ๋ฆฌ๊ณ ์ฒ๋ฆฌ ์๋ฃ ๊ธฐ๋ก์ด ์ฒด๊ณ์ ์ผ๋ก ๊ตฌํ๋์ด ์์ต๋๋ค. out-of-order ์ด๋ฒคํธ๋
eventHandled์ ๊ธฐ๋กํ์ฌ ์ฌ์ฒ๋ฆฌ๋ฅผ ๋ฐฉ์งํ๋ ์ ์ด ์ข์ต๋๋ค.
112-151: ์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๋ก์ง ํ์ธ ์์ฒญ์ฃผ๋ฌธ ์์ดํ ์ด ๋ง์ ๊ฒฝ์ฐ ๊ฐ ์์ดํ ๋ณ๋ก
metricsRepository.save()ํธ์ถ์ด ๋ฐ์ํฉ๋๋ค. ๋จ์ผ ํธ๋์ญ์ ๋ด์์ ์ฒ๋ฆฌ๋๋ฏ๋ก ์ผ๊ด์ฑ์ ๋ณด์ฅ๋์ง๋ง, ๋๋ ์ฃผ๋ฌธ ์ ์ฑ๋ฅ ์ํฅ์ ๋ชจ๋ํฐ๋งํ์๊ธฐ ๋ฐ๋๋๋ค.๋ํ, ๋ชจ๋ ์์ดํ ์ด out-of-order๋ก ๋ฌด์๋์ด๋ ์ด๋ฒคํธ๊ฐ ์ฒ๋ฆฌ ์๋ฃ๋ก ๊ธฐ๋ก๋๋๋ฐ, ์ด๋ ์๋๋ ๋์์ธ์ง ํ์ธ์ด ํ์ํฉ๋๋ค.
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java (2)
55-81: ์ข์ ํ ์คํธ ๊ตฌ์กฐ์ ๋๋ค.given-when-then ํจํด์ ๋ฐ๋ฅด๊ณ , ์ค๋ณต ์ด๋ฒคํธ ์ฒ๋ฆฌ ์ ์ํ ๋ณํ๊ฐ ์์์ ๋ช ํํ๊ฒ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค.
eventHandledRepository.existsById()ํ์ธ๋ ํฌํจ๋์ด ์์ด ๋ฉฑ๋ฑ์ฑ ๋ฉ์ปค๋์ฆ์ ์์ ํ ํ ์คํธํฉ๋๋ค.
152-180: ์๋ก ๋ค๋ฅธ eventId ํ ์คํธ๊ฐ ๋์ ์ฒ๋ฆฌ๋ฅผ ์ ๊ฒ์ฆํฉ๋๋ค.๋ ๋ฆฝ์ ์ธ ์ด๋ฒคํธ๊ฐ ๊ฐ๊ฐ ์ฒ๋ฆฌ๋์ด ์ข์์ ์๊ฐ ๋์ ๋๋ ๊ฒ์ ํ์ธํ๋ ํ ์คํธ์ ๋๋ค. ๋ฉฑ๋ฑ์ฑ ๋ก์ง์ด eventId ๊ธฐ๋ฐ์์ ๋ช ํํ ๋ณด์ฌ์ค๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java (1)
15-20: ์ธ๋ฑ์ค ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.
original_topic,failed_at,error_type์ปฌ๋ผ์ ๋ํ ์ธ๋ฑ์ค๊ฐ ์ผ๋ฐ์ ์ธ ์กฐํ ํจํด(ํ ํฝ๋ณ ์กฐํ, ์๊ฐ์ ์กฐํ, ์๋ฌ ์ ํ๋ณ ์กฐํ)์ ์ ์ง์ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
32-33: ์์กด์ฑ ์ถ๊ฐ LGTM
ProductRepository์ApplicationEventPublisher์ถ๊ฐ๋ ์ด๋ฒคํธ ๋ฐํ ๊ธฐ๋ฅ์ ํ์ํ ์ ์ ํ ์์กด์ฑ์ ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepository.java (2)
37-42: ๋๋ฒ๊น ์ฉ ์ฟผ๋ฆฌ ๋ฉ์๋ LGTMSpring Data JPA ์ฟผ๋ฆฌ ๋ฉ์๋ ๋ค์ด๋ฐ ์ปจ๋ฒค์ ์ ์ ๋ฐ๋ฅด๊ณ ์์ต๋๋ค. ๋๋ฒ๊น ์ฉ๋๋ก ์ ์ ํฉ๋๋ค.
17-21: JPQL LIMIT ๊ตฌ๋ฌธ - Hibernate 6 ์ฌ์ฉ ์ ๋์ํ์ง๋ง ํ์ค JPA ์ด์์ฑ ๋ฌธ์
LIMIT :limit๊ตฌ๋ฌธ์ Hibernate์ HQL ํ์ฅ์ผ๋ก JPQL์์๋ ์ง์ limit ํค์๋๋ฅผ ํฌํจํ์ง ์๋ ๊ฒ์ด ํ์ค์ ๋๋ค. ์ด ํ๋ก์ ํธ๋ Spring Boot 3.4.4๋ฅผ ์ฌ์ฉํ๋ฏ๋ก Hibernate 6์ด ์ ์ฉ๋์ด ์์ด ๋ฐํ์์ ์ด ์ฟผ๋ฆฌ๋ ์ ์ ์๋ํฉ๋๋ค. ๊ทธ๋ฌ๋ JPQL๊ณผ Criteria API๋ LIMIT๊ณผ OFFSET ์ ์ ์ฟผ๋ฆฌ์ ์ผ๋ถ๋ก ์ง์ํ์ง ์์ง๋ง ์ด๋ฅผ ์ค์ ํ๋ API๋ฅผ ์ ๊ณตํ๋ฉฐ, Hibernate๊ฐ ์์ฑ๋ SQL ๋ฌธ์ ํ์ํ ์ ์ ์ถ๊ฐํฉ๋๋ค.ํ์ค JPA ์ด์์ฑ๊ณผ ๊ฐ๋ ์ฑ์ ์ํด
Pageable์ ์ฌ์ฉํ๊ฑฐ๋Query.setMaxResults()๋ก ๋ณ๊ฒฝํ๋ ๊ฒ์ด ๊ถ์ฅ๋ฉ๋๋ค.์ถ๊ฐ๋ก ๊ฒํ ํ ์ฌํญ:
- Line 28: ์ด๊ฑฐํ ์์ ๋์ ํ๋์ฝ๋ฉ๋
'FAILED'๋ฌธ์์ด ์ฌ์ฉ -@Param("status") OutboxEvent.OutboxStatus status๋ก ๋งค๊ฐ๋ณ์ํ ๊ถ์ฅ- Lines 50, 64:
@Modifying์ฟผ๋ฆฌ์clearAutomatically = true์ต์ ์ถ๊ฐ ๊ถ์ฅ
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
Show resolved
Hide resolved
| public boolean isValidTopic(String topicName) { | ||
| return topicName != null && | ||
| topicName.startsWith(TOPIC_PREFIX) && | ||
| topicName.endsWith(TOPIC_VERSION); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
#!/bin/bash
# Description: isValidTopic ๋ฉ์๋ ์ฌ์ฉ์ฒ ํ์ธ
rg -n "isValidTopic" --type=javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 229
isValidTopic() ๋ฉ์๋๊ฐ ์ฌ์ฉ๋์ง ์๊ณ ์์ต๋๋ค.
ํ์ฌ ์ฝ๋๋ฒ ์ด์ค์์ ์ด ๋ฉ์๋์ ํธ์ถ ์ง์ ์ ์ฐพ์ ์ ์์ต๋๋ค. ์ฌ์ฉ๋์ง ์๋ ๋ฉ์๋์ด๋ฏ๋ก ์ ๊ฑฐํ๊ฑฐ๋, ํฅํ ์ฌ์ฉ ๊ณํ์ด ์๋ค๋ฉด TODO ์ฃผ์์ ์ถ๊ฐํ์ธ์.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicRouter.java
around lines 43-47, the isValidTopic(String) method is unused; either remove the
method entirely (delete its declaration and any now-unused imports or
references, then run mvn/test to ensure no compile breakage) or, if you intend
to keep it for future use, add a clear TODO comment above the method explaining
expected future usage and add @SuppressWarnings("unused") to avoid linter noise.
| } catch (InterruptedException | ExecutionException e) { | ||
| throw new RuntimeException("Kafka send failed", e); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InterruptedException ์ฒ๋ฆฌ ์ ์ธํฐ๋ฝํธ ์ํ๋ฅผ ๋ณต์ํด์ผ ํฉ๋๋ค.
InterruptedException์ catchํ ํ ์ธํฐ๋ฝํธ ์ํ๋ฅผ ๋ณต์ํ์ง ์์ผ๋ฉด, ์ค๋ ๋ ์ข
๋ฃ ์๊ทธ๋์ด ์์ค๋์ด graceful shutdown์ด ์ ๋๋ก ๋์ํ์ง ์์ ์ ์์ต๋๋ค.
๐ ์์ ์ ์
} catch (InterruptedException | ExecutionException e) {
+ if (e instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
throw new RuntimeException("Kafka send failed", e);
}๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (InterruptedException | ExecutionException e) { | |
| throw new RuntimeException("Kafka send failed", e); | |
| } | |
| } catch (InterruptedException | ExecutionException e) { | |
| if (e instanceof InterruptedException) { | |
| Thread.currentThread().interrupt(); | |
| } | |
| throw new RuntimeException("Kafka send failed", e); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPoller.java
around lines 140 to 142, the current catch lumps InterruptedException and
ExecutionException together which loses the thread interrupt status; separate
the two exceptions, and in the InterruptedException handler call
Thread.currentThread().interrupt() to restore the interrupt flag and then
rethrow or wrap the exception (e.g., throw new RuntimeException("Kafka send
interrupted", e)); keep a separate ExecutionException handler that throws the
existing RuntimeException("Kafka send failed", e).
| @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'FAILED' AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit") | ||
| List<OutboxEvent> findRetryableEvents( | ||
| @Param("maxRetryCount") int maxRetryCount, | ||
| @Param("limit") int limit | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ํ๋์ฝ๋ฉ๋ enum ๊ฐ - ํ๋ผ๋ฏธํฐ ๋ฐ์ธ๋ฉ ์ฌ์ฉ ๊ถ์ฅ
'FAILED' ๋ฌธ์์ด์ด ์ง์ ์ฟผ๋ฆฌ์ ํ๋์ฝ๋ฉ๋์ด ์์ต๋๋ค. ๋ค๋ฅธ ๋ฉ์๋๋ค(findByStatusOrderByCreatedAtAsc)์ :status ํ๋ผ๋ฏธํฐ๋ฅผ ์ฌ์ฉํ๋๋ฐ, ์ด ๋ฉ์๋๋ง ์ผ๊ด์ฑ์ด ์์ต๋๋ค. enum์ @Enumerated ์ ๋ต์ด ๋ณ๊ฒฝ๋๊ฑฐ๋ ๊ฐ์ด ๋ณ๊ฒฝ๋๋ฉด ์ฟผ๋ฆฌ๊ฐ ์คํจํ ์ ์์ต๋๋ค.
๐ ๊ฐ์ ์ ์
- @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'FAILED' AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit")
+ @Query("SELECT o FROM OutboxEvent o WHERE o.status = :status AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit")
List<OutboxEvent> findRetryableEvents(
+ @Param("status") OutboxEvent.OutboxStatus status,
@Param("maxRetryCount") int maxRetryCount,
@Param("limit") int limit
);๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'FAILED' AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit") | |
| List<OutboxEvent> findRetryableEvents( | |
| @Param("maxRetryCount") int maxRetryCount, | |
| @Param("limit") int limit | |
| ); | |
| @Query("SELECT o FROM OutboxEvent o WHERE o.status = :status AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit") | |
| List<OutboxEvent> findRetryableEvents( | |
| @Param("status") OutboxEvent.OutboxStatus status, | |
| @Param("maxRetryCount") int maxRetryCount, | |
| @Param("limit") int limit | |
| ); |
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepository.java
around lines 28-32, the JPQL query hardcodes the status string 'FAILED'; replace
that literal with a bound parameter to match other methods and respect the
entity's @Enumerated mapping. Update the @Query to use :status, add an
@Param("status") OutboxStatus status parameter to the method signature, and
update any callers to pass the appropriate enum value so the enum mapping is
used instead of a literal string.
| @SpringBootTest( | ||
| classes = {com.loopers.confg.kafka.KafkaConfig.class}, | ||
| properties = { | ||
| "spring.kafka.bootstrap-servers=localhost:19092", | ||
| "spring.kafka.producer.acks=all", | ||
| "spring.kafka.producer.properties.enable.idempotence=true", | ||
| "spring.kafka.producer.retries=3", | ||
| "spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer", | ||
| "spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer" | ||
| } | ||
| ) | ||
| @ActiveProfiles("test") | ||
| class KafkaConnectionTest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
#!/bin/bash
# Find KafkaTestContainersConfig
fd -type f -name "*KafkaTestContainersConfig*"
# Search for its usage pattern in tests
rg -n --type=java "@Import.*KafkaTestContainersConfig|@SpringBootTest" -A 5 | head -100
# Check the file structure to understand import paths
git ls-files | grep -i "kafka.*test" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 10869
๐ Script executed:
#!/bin/bash
# Look for testFixtures or test container configs
find . -type d -name "testFixtures" -o -name "test-fixtures"
# Search for any KafkaTestContainers related files
rg -l "KafkaTestContainers" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 151
๐ Script executed:
#!/bin/bash
# Check if the actual test file exists and examine its content
find . -path "*commerce-api*" -name "KafkaConnectionTest.java" -type f
# Also check what's in the kafka test related files
rg -n "class.*Test" --type java | grep -i kafkaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 540
๐ Script executed:
#!/bin/bash
# Search for KafkaTestContainersConfig in the entire repository
rg -l "KafkaTestContainersConfig" --type java
# Check if modules/kafka directory exists
find . -type d -name "kafka" | grep -E "modules|testFixtures"
# Search for any file with TestContainers in kafka context
rg -l "TestContainers" --type java | grep -i kafkaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 157
๐ Script executed:
#!/bin/bash
# Read the full KafkaConnectionTest.java file to see its complete implementation
cat -n apps/commerce-api/src/test/java/com/loopers/infrastructure/kafka/KafkaConnectionTest.java
# Also check DlqPublisherTest to see the pattern used there
cat -n apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 8184
๐ Script executed:
#!/bin/bash
# Check if modules/kafka exists and what's inside
ls -la modules/ | grep -i kafka
# If it exists, check testFixtures
ls -la modules/kafka/src/ 2>/dev/null || echo "modules/kafka not found"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 301
๐ Script executed:
#!/bin/bash
# Explicitly search for any file with KafkaTestContainersConfig in its name or content
find . -name "*KafkaTestContainersConfig*" -type f
# Search for any testcontainers related Kafka config
rg "KafkaTestContainersConfig"
# Check what testFixtures modules actually have
find ./modules -type d -name "testFixtures"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 151
๐ Script executed:
#!/bin/bash
# Check the full structure of modules/kafka
find ./modules/kafka -type f -name "*.java" | head -20
# List all testFixtures directories and their content
find ./modules -type d -name "testFixtures" -exec find {} -name "*.java" \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 497
ํ ์คํธ ์ปจํ ์ด๋ ์ค์ ์ด ๋๋ฝ๋์์ต๋๋ค. ํ๋์ฝ๋ฉ๋ ๋ถํธ์คํธ๋ฉ ์๋ฒ ์ฃผ์๋ฅผ ์ ๊ฑฐํ์ธ์.
ํ
์คํธ๊ฐ localhost:19092์ ์์กดํ๊ณ ์์ด Kafka ์ธ์คํด์ค๊ฐ ์คํ ์ค์ด์ง ์์ผ๋ฉด ์คํจํฉ๋๋ค. ํ๋ก์ ํธ์ MySQL, Redis ํ
์คํธ์ฒ๋ผ ํ
์คํธ ์ปจํ
์ด๋๋ฅผ ์ฌ์ฉํ์ฌ ๊ด๋ฆฌ๋๋ Kafka ์ธ์คํด์ค๋ฅผ ์ค์ ํด์ผ ํฉ๋๋ค.
modules/kafka/src/testFixtures์KafkaTestContainersConfig๋ฅผ ์์ฑํ๊ณ ์ ์ ์ด๊ธฐํ ๋ธ๋ก์ผ๋ก Kafka ์ปจํ ์ด๋๋ฅผ ๊ตฌ์ฑํ์ธ์.@Import(KafkaTestContainersConfig.class)์ด๋ ธํ ์ด์ ์ ์ถ๊ฐํ์ธ์.- ํ๋์ฝ๋ฉ๋ bootstrap-servers ์์ฑ์ ์ ๊ฑฐํ๊ณ , Producer ์ค์ ์
KafkaConfig๋๋application-test.yml์์ ๊ด๋ฆฌํ์ธ์.
๋ํ ๋ผ์ธ 50-53, 67-70์ System.out.println ํธ์ถ์ @Slf4j ๋ก๊ฑฐ๋ก ๋ณ๊ฒฝํ์ธ์.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/test/java/com/loopers/infrastructure/kafka/KafkaConnectionTest.java
around lines 16-28, the test currently hardcodes
spring.kafka.bootstrap-servers=localhost:19092 and lacks Testcontainers setup;
remove the hardcoded bootstrap-servers property, create a
modules/kafka/src/testFixtures/KafkaTestContainersConfig that starts a
KafkaContainer in a static initializer and exposes its bootstrapServers,
annotate the test with @Import(KafkaTestContainersConfig.class) so the test uses
the container-managed address, move producer configuration out of the test
properties into KafkaConfig or application-test.yml for the test profile, and
replace the System.out.println calls at lines ~50-53 and ~67-70 with logging via
an @Slf4j logger.
| /** | ||
| * ๋ง์ง๋ง ์ ๋ฐ์ดํธ ์๊ฐ | ||
| */ | ||
| @Column(name = "last_updated", nullable = false) | ||
| private ZonedDateTime lastUpdated; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
์๋ก ๋ค๋ฅธ ์ด๋ฒคํธ ํ์ ๊ฐ ํ์์คํฌํ ์ถฉ๋ ๊ฐ๋ฅ์ฑ
ํ์ฌ ๋ชจ๋ ๋ฉํธ๋ฆญ ์
๋ฐ์ดํธ(์ข์์, ์กฐํ, ํ๋งค)๊ฐ ๋จ์ผ lastUpdated ํ๋๋ฅผ ๊ณต์ ํ๊ณ ์์ต๋๋ค. ์ด๋ก ์ธํด ๋ค์๊ณผ ๊ฐ์ ์๋๋ฆฌ์ค์์ ๋ฐ์ดํฐ ์์ค์ด ๋ฐ์ํ ์ ์์ต๋๋ค:
์๋๋ฆฌ์ค ์์:
- T1 ์์ : ProductViewedEvent ๋ฐ์ โ viewCount ์ฆ๊ฐ, lastUpdated = T1
- T0 ์์ : OrderCompletedEvent๊ฐ ๋คํธ์ํฌ ์ง์ฐ์ผ๋ก ๋ฆ๊ฒ ๋์ฐฉ (T0 < T1)
- T0 ์ด๋ฒคํธ๋
isEventOutdated()์ฒดํฌ์์ false๋ฅผ ๋ฐํํ์ฌ salesCount ์ ๋ฐ์ดํธ๊ฐ ๋ฌด์๋จ
๋ฌธ์ ์ :
์๋ก ๋
๋ฆฝ์ ์ธ ๋ฉํธ๋ฆญ(์กฐํ์ vs ํ๋งค๋)์ธ๋ฐ๋ ๋ถ๊ตฌํ๊ณ , ํ ๋ฉํธ๋ฆญ์ ์ต์ ์ด๋ฒคํธ๊ฐ ๋ค๋ฅธ ๋ฉํธ๋ฆญ์ ๊ณผ๊ฑฐ ์ด๋ฒคํธ๋ฅผ ์ฐจ๋จํฉ๋๋ค.
๊ถ์ฅ ์ฌํญ:
๋ฉํธ๋ฆญ ํ์
๋ณ๋ก ๋ณ๋์ ํ์์คํฌํ๋ฅผ ๊ด๋ฆฌํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์:
lastLikeUpdated,lastViewUpdated,lastSalesUpdated
์ด๋ ๊ฒ ํ๋ฉด ๊ฐ ๋ฉํธ๋ฆญ์ด ๋ ๋ฆฝ์ ์ผ๋ก ์๊ฐ์ ์ ๋ ฌ์ ๋ณด์ฅํ๋ฉด์๋ ์๋ก ์ํฅ์ ์ฃผ์ง ์์ต๋๋ค.
๐ ๋ฉํธ๋ฆญ๋ณ ํ์์คํฌํ ๋ถ๋ฆฌ ์์
-@Column(name = "last_updated", nullable = false)
-private ZonedDateTime lastUpdated;
+@Column(name = "last_like_updated")
+private ZonedDateTime lastLikeUpdated;
+
+@Column(name = "last_view_updated")
+private ZonedDateTime lastViewUpdated;
+
+@Column(name = "last_sales_updated")
+private ZonedDateTime lastSalesUpdated;
public boolean incrementLikeCount(ZonedDateTime eventOccurredAt) {
- if (isEventOutdated(eventOccurredAt)) {
+ if (isEventOutdated(eventOccurredAt, this.lastLikeUpdated)) {
return false;
}
this.likeCount++;
- this.lastUpdated = eventOccurredAt;
+ this.lastLikeUpdated = eventOccurredAt;
return true;
}
-private boolean isEventOutdated(ZonedDateTime eventOccurredAt) {
- return this.lastUpdated != null && eventOccurredAt.isBefore(this.lastUpdated);
+private boolean isEventOutdated(ZonedDateTime eventOccurredAt, ZonedDateTime lastUpdatedForMetric) {
+ return lastUpdatedForMetric != null && eventOccurredAt.isBefore(lastUpdatedForMetric);
}Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
around lines 56-60, the single lastUpdated field causes events for one metric to
block older-but-valid events for another; add separate timestamp fields (e.g.,
lastLikeUpdated, lastViewUpdated, lastSalesUpdated) annotated with @Column
(nullable = false or true as appropriate), persist them in the entity, and
update all places that call isEventOutdated() or set lastUpdated to instead
compare/update the metric-specific timestamp so likes, views, and sales are
ordered and validated independently.
| @KafkaListener( | ||
| topics = {"loopers.commerce.product-liked-v1"}, | ||
| groupId = "metrics-aggregator", | ||
| containerFactory = KafkaConfig.BATCH_LISTENER | ||
| ) | ||
| public void consumeProductLiked( | ||
| List<ConsumerRecord<String, String>> records, | ||
| Acknowledgment ack | ||
| ) { | ||
| log.info("[Consumer] Received {} product-liked events", records.size()); | ||
|
|
||
| List<ConsumerRecord<String, String>> failedRecords = new ArrayList<>(); | ||
|
|
||
| for (ConsumerRecord<String, String> record : records) { | ||
| try { | ||
| KafkaEventEnvelope<ProductLikedEvent> envelope = | ||
| objectMapper.readValue( | ||
| record.value(), | ||
| new TypeReference<KafkaEventEnvelope<ProductLikedEvent>>() {} | ||
| ); | ||
|
|
||
| aggregationService.handleProductLiked( | ||
| envelope.eventId(), | ||
| envelope.payload() | ||
| ); | ||
|
|
||
| // ์ฑ๊ณต ์ ์ฌ์๋ ์นด์ดํฐ ์ ๊ฑฐ | ||
| retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("[Consumer] Failed to process product-liked event - offset: {}, key: {}", | ||
| record.offset(), record.key(), e); | ||
| handleFailedRecord(record, e, failedRecords); | ||
| } | ||
| } | ||
|
|
||
| // ์คํจํ ๋ ์ฝ๋๊ฐ ์์ผ๋ฉด ์์ธ๋ฅผ ๋์ ธ์ ์ฌ์ฒ๋ฆฌ | ||
| if (!failedRecords.isEmpty()) { | ||
| log.warn("[Consumer] {} records failed, will retry", failedRecords.size()); | ||
| throw new RuntimeException( | ||
| String.format("Failed to process %d records", failedRecords.size()) | ||
| ); | ||
| } | ||
|
|
||
| ack.acknowledge(); // ๋ชจ๋ ์ฑ๊ณต ์์๋ง ์ปค๋ฐ | ||
| log.debug("[Consumer] Acknowledged {} product-liked events", records.size()); | ||
| } | ||
|
|
||
| /** | ||
| * ์ํ ์ข์์ ์ทจ์ ์ด๋ฒคํธ Consumer | ||
| */ | ||
| @KafkaListener( | ||
| topics = {"loopers.commerce.product-unliked-v1"}, | ||
| groupId = "metrics-aggregator", | ||
| containerFactory = KafkaConfig.BATCH_LISTENER | ||
| ) | ||
| public void consumeProductUnliked( | ||
| List<ConsumerRecord<String, String>> records, | ||
| Acknowledgment ack | ||
| ) { | ||
| log.info("[Consumer] Received {} product-unliked events", records.size()); | ||
|
|
||
| List<ConsumerRecord<String, String>> failedRecords = new ArrayList<>(); | ||
|
|
||
| for (ConsumerRecord<String, String> record : records) { | ||
| try { | ||
| KafkaEventEnvelope<ProductUnlikedEvent> envelope = | ||
| objectMapper.readValue( | ||
| record.value(), | ||
| new TypeReference<KafkaEventEnvelope<ProductUnlikedEvent>>() {} | ||
| ); | ||
|
|
||
| aggregationService.handleProductUnliked( | ||
| envelope.eventId(), | ||
| envelope.payload() | ||
| ); | ||
|
|
||
| retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("[Consumer] Failed to process product-unliked event - offset: {}, key: {}", | ||
| record.offset(), record.key(), e); | ||
| handleFailedRecord(record, e, failedRecords); | ||
| } | ||
| } | ||
|
|
||
| if (!failedRecords.isEmpty()) { | ||
| log.warn("[Consumer] {} records failed, will retry", failedRecords.size()); | ||
| throw new RuntimeException( | ||
| String.format("Failed to process %d records", failedRecords.size()) | ||
| ); | ||
| } | ||
|
|
||
| ack.acknowledge(); | ||
| log.debug("[Consumer] Acknowledged {} product-unliked events", records.size()); | ||
| } | ||
|
|
||
| /** | ||
| * ์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ Consumer (ํ๋งค๋ ์ง๊ณ์ฉ) | ||
| */ | ||
| @KafkaListener( | ||
| topics = {"loopers.commerce.order-completed-v1"}, | ||
| groupId = "metrics-aggregator", | ||
| containerFactory = KafkaConfig.BATCH_LISTENER | ||
| ) | ||
| public void consumeOrderCompleted( | ||
| List<ConsumerRecord<String, String>> records, | ||
| Acknowledgment ack | ||
| ) { | ||
| log.info("[Consumer] Received {} order-completed events", records.size()); | ||
|
|
||
| List<ConsumerRecord<String, String>> failedRecords = new ArrayList<>(); | ||
|
|
||
| for (ConsumerRecord<String, String> record : records) { | ||
| try { | ||
| KafkaEventEnvelope<OrderCompletedEvent> envelope = | ||
| objectMapper.readValue( | ||
| record.value(), | ||
| new TypeReference<KafkaEventEnvelope<OrderCompletedEvent>>() {} | ||
| ); | ||
|
|
||
| aggregationService.handleOrderCompleted( | ||
| envelope.eventId(), | ||
| envelope.payload() | ||
| ); | ||
|
|
||
| retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("[Consumer] Failed to process order-completed event - offset: {}, key: {}", | ||
| record.offset(), record.key(), e); | ||
| handleFailedRecord(record, e, failedRecords); | ||
| } | ||
| } | ||
|
|
||
| if (!failedRecords.isEmpty()) { | ||
| log.warn("[Consumer] {} records failed, will retry", failedRecords.size()); | ||
| throw new RuntimeException( | ||
| String.format("Failed to process %d records", failedRecords.size()) | ||
| ); | ||
| } | ||
|
|
||
| ack.acknowledge(); | ||
| log.debug("[Consumer] Acknowledged {} order-completed events", records.size()); | ||
| } | ||
|
|
||
| /** | ||
| * ์ํ ์กฐํ ์ด๋ฒคํธ Consumer (์กฐํ์ ์ง๊ณ์ฉ) | ||
| */ | ||
| @KafkaListener( | ||
| topics = {"loopers.commerce.product-viewed-v1"}, | ||
| groupId = "metrics-aggregator", | ||
| containerFactory = KafkaConfig.BATCH_LISTENER | ||
| ) | ||
| public void consumeProductViewed( | ||
| List<ConsumerRecord<String, String>> records, | ||
| Acknowledgment ack | ||
| ) { | ||
| log.info("[Consumer] Received {} product-viewed events", records.size()); | ||
|
|
||
| List<ConsumerRecord<String, String>> failedRecords = new ArrayList<>(); | ||
|
|
||
| for (ConsumerRecord<String, String> record : records) { | ||
| try { | ||
| KafkaEventEnvelope<ProductViewedEvent> envelope = | ||
| objectMapper.readValue( | ||
| record.value(), | ||
| new TypeReference<KafkaEventEnvelope<ProductViewedEvent>>() {} | ||
| ); | ||
|
|
||
| aggregationService.handleProductViewed( | ||
| envelope.eventId(), | ||
| envelope.payload() | ||
| ); | ||
|
|
||
| retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset()); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("[Consumer] Failed to process product-viewed event - offset: {}, key: {}", | ||
| record.offset(), record.key(), e); | ||
| handleFailedRecord(record, e, failedRecords); | ||
| } | ||
| } | ||
|
|
||
| if (!failedRecords.isEmpty()) { | ||
| log.warn("[Consumer] {} records failed, will retry", failedRecords.size()); | ||
| throw new RuntimeException( | ||
| String.format("Failed to process %d records", failedRecords.size()) | ||
| ); | ||
| } | ||
|
|
||
| ack.acknowledge(); | ||
| log.debug("[Consumer] Acknowledged {} product-viewed events", records.size()); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐ ๏ธ Refactor suggestion | ๐ Major
์ค๋ณต ์ฝ๋๋ฅผ ์ ๊ฑฐํ์ฌ ์ ์ง๋ณด์์ฑ์ ๊ฐ์ ํ์ธ์.
๋ค ๊ฐ์ ์ปจ์๋จธ ๋ฉ์๋๊ฐ ๊ฑฐ์ ๋์ผํ ๊ตฌ์กฐ๋ฅผ ๊ฐ์ง๊ณ ์์ต๋๋ค. ์ด๋ ์ฝ๋ ์ค๋ณต์ผ๋ก ์ธํด ์ ์ง๋ณด์๊ฐ ์ด๋ ต๊ณ ๋ฒ๊ทธ ๋ฐ์ ๊ฐ๋ฅ์ฑ์ ๋์ ๋๋ค. ๊ณตํต ๋ก์ง์ ์ ๋ค๋ฆญ ํฌํผ ๋ฉ์๋๋ก ์ถ์ถํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
๐ ์ ์ํ๋ ๋ฆฌํฉํ ๋ง
private <T> void processEvents(
List<ConsumerRecord<String, String>> records,
Acknowledgment ack,
TypeReference<KafkaEventEnvelope<T>> typeRef,
BiConsumer<String, T> handler,
String eventName
) {
log.info("[Consumer] Received {} {} events", records.size(), eventName);
List<ConsumerRecord<String, String>> failedRecords = new ArrayList<>();
for (ConsumerRecord<String, String> record : records) {
try {
KafkaEventEnvelope<T> envelope = objectMapper.readValue(
record.value(), typeRef
);
handler.accept(envelope.eventId(), envelope.payload());
retryTracker.clearRetryCount(record.topic(), record.partition(), record.offset());
} catch (Exception e) {
log.error("[Consumer] Failed to process {} event - offset: {}, key: {}",
eventName, record.offset(), record.key(), e);
handleFailedRecord(record, e, failedRecords);
}
}
if (!failedRecords.isEmpty()) {
log.warn("[Consumer] {} records failed, will retry", failedRecords.size());
throw new RuntimeException(
String.format("Failed to process %d records", failedRecords.size())
);
}
ack.acknowledge();
log.debug("[Consumer] Acknowledged {} {} events", records.size(), eventName);
}
@KafkaListener(
topics = {"loopers.commerce.product-liked-v1"},
groupId = "metrics-aggregator",
containerFactory = KafkaConfig.BATCH_LISTENER
)
public void consumeProductLiked(
List<ConsumerRecord<String, String>> records,
Acknowledgment ack
) {
processEvents(
records, ack,
new TypeReference<KafkaEventEnvelope<ProductLikedEvent>>() {},
aggregationService::handleProductLiked,
"product-liked"
);
}๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
around lines 43-236, four consumer methods duplicate the same batching,
deserialization, retry-tracking, error-collection and ack logic; extract that
common flow into a single private generic helper (e.g. private <T> void
processEvents(List<ConsumerRecord<String,String>> records, Acknowledgment ack,
TypeReference<KafkaEventEnvelope<T>> typeRef, BiConsumer<String,T> handler,
String eventName)) that: logs receipt, iterates records, deserializes with the
provided TypeReference, calls the handler (use method references to
aggregationService), clears retryTracker on success, uses handleFailedRecord on
catch, throws RuntimeException when failedRecords non-empty, then acknowledges
and logs; replace each consumeX method body with a single call to this helper
passing the appropriate TypeReference, handler and eventName and keep method
visibility and exception handling consistent.
| import com.loopers.application.event.like.ProductUnlikedEvent; | ||
| import com.loopers.application.event.order.OrderCompletedEvent; | ||
| import com.loopers.application.event.product.ProductViewedEvent; | ||
| import com.loopers.config.TestConfig; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
# Find TestConfig.java and examine its content
fd -e java "TestConfig.java" -x cat {}Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 612
๐ Script executed:
# Find and examine the test file to verify @Import annotation
fd -e java "MetricsAggregationServiceIdempotencyTest.java" -x cat {}Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 8994
TestConfig ์ํฌํธ๋ฅผ ์ ๊ฑฐํ๊ฑฐ๋ @Import ์ด๋
ธํ
์ด์
์ ์ถ๊ฐํ์ธ์.
TestConfig๊ฐ ์ํฌํธ๋์ด ์์ง๋ง @Import ์ด๋
ธํ
์ด์
์์ ์ฌ์ฉ๋์ง ์๊ณ ์์ต๋๋ค. @DataJpaTest๋ JPA ์ ์ฅ์๋ฅผ ์๋์ผ๋ก ๋ฐ๊ฒฌํ๋ฏ๋ก, TestConfig๊ฐ ํ์ํ์ง ์๋ค๋ฉด ์ํฌํธ๋ฅผ ์ ๊ฑฐํ์ธ์. ๋๋ ํ
์คํธ์์ TestConfig์ ์ค์ ์ด ํ์ํ๋ค๋ฉด @Import({MetricsAggregationService.class, TestConfig.class})๋ก ์ถ๊ฐํ์ธ์.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java
around line 7, the TestConfig import is present but not used in @Import; either
remove the unused import if TestConfig isn't needed or add it to the test's
@Import annotation (e.g., @Import({MetricsAggregationService.class,
TestConfig.class})) so the configuration is actually applied, and then run the
build to ensure there are no unused-import warnings.
| kafka: | ||
| enabled: false # ํ ์คํธ์์๋ Kafka ๋นํ์ฑํ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
#!/bin/bash
# Verify spring.kafka.enabled property usage and TestConfig setup
# 1. Check for spring.kafka.enabled property usage
echo "=== Searching for spring.kafka.enabled property references ==="
rg -n "spring\.kafka\.enabled|kafka\.enabled" --type yaml --type java --type kotlin -C 2
# 2. Check for @ConditionalOnProperty with kafka.enabled
echo -e "\n=== Searching for ConditionalOnProperty kafka patterns ==="
rg -n "@ConditionalOnProperty.*kafka|EnableConfigurationProperties.*Kafka" \
--type java --type kotlin -C 3
# 3. Find and examine TestConfig
echo -e "\n=== Searching for TestConfig file ==="
fd -n "TestConfig" --type f
# 4. Check for KafkaAutoConfiguration exclusion
echo -e "\n=== Searching for KafkaAutoConfiguration references ==="
rg -n "KafkaAutoConfiguration|kafka.*AutoConfiguration" \
--type java --type kotlin -C 3
# 5. Look for Kafka configuration beans
echo -e "\n=== Searching for Kafka bean definitions ==="
rg -n "@Bean.*kafka|KafkaTemplate|KafkaConsumer|KafkaProducer" \
--type java --type kotlin -C 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 8613
spring.kafka.enabled: false ์์ฑ ์ ๊ฑฐ ํ์
spring.kafka.enabled๋ Spring Boot์ ํ์ค ์์ฑ์ด ์๋๋ฉฐ, ์ฝ๋๋ฒ ์ด์ค์์ ๊ตฌํ๋์ง๋ ์์์ต๋๋ค. TestConfig์์ ์ด๋ฏธ @EnableAutoConfiguration(exclude = {KafkaAutoConfiguration.class})๋ฅผ ํตํด Kafka ์๋ ๊ตฌ์ฑ์ ์ ์ธํ๊ณ ์์ผ๋ฏ๋ก ์ด YAML ์์ฑ์ ๋ถํ์ํฉ๋๋ค. ์ ๊ฑฐํ์ธ์.
๐ค Prompt for AI Agents
In apps/commerce-streamer/src/test/resources/application-test.yml around lines
12 to 13, remove the kafka.enabled: false entry because Spring Boot does not
recognize spring.kafka.enabled and Kafka is already excluded via
@EnableAutoConfiguration(exclude = {KafkaAutoConfiguration.class}) in
TestConfig; simply delete this YAML property so the test config remains clean
and avoid relying on a non-existent property.
| properties: | ||
| enable.idempotence: true # ๋ฉฑ๋ฑ์ฑ ํ๋ก๋์: ์ค๋ณต ๋ฐํ ๋ฐฉ์ง | ||
| max.in.flight.requests.per.connection: 5 # ์์ ๋ณด์ฅ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kafka ์์ฑ์ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ด ํ์ํฉ๋๋ค.
spring.kafka.producer.properties ๋งต ๋ด์ ์์ฑ์ YAML์ด ์ (.)์ ์ค์ฒฉ ํค๋ก ํ์ฑํ์ง ์๋๋ก ๋ธ๋ํท ํ๊ธฐ๋ฒ๊ณผ ๋ฐ์ดํ๋ฅผ ์ฌ์ฉํด์ผ ํฉ๋๋ค. ํ์ฌ ์ค์ ์ ์๋ํ ๋๋ก ์๋ํ์ง ์์ ์ ์์ต๋๋ค.
ํ์ต๋ ๋ด์ฉ์ ๋ฐ๋ฅด๋ฉด, Spring Boot์ relaxed binding์ ์ต์์ Spring Kafka ์์ฑ์๋ง ์ ์ฉ๋๊ณ , properties ๋งต์๋ ์ ์ฉ๋์ง ์์ต๋๋ค.
๐ ์ ์๋ ์์
properties:
- enable.idempotence: true # ๋ฉฑ๋ฑ์ฑ ํ๋ก๋์: ์ค๋ณต ๋ฐํ ๋ฐฉ์ง
- max.in.flight.requests.per.connection: 5 # ์์ ๋ณด์ฅ
+ "[enable.idempotence]": true # ๋ฉฑ๋ฑ์ฑ ํ๋ก๋์: ์ค๋ณต ๋ฐํ ๋ฐฉ์ง
+ "[max.in.flight.requests.per.connection]": 5 # ์์ ๋ณด์ฅ๐ค Prompt for AI Agents
In modules/kafka/src/main/resources/kafka.yml around lines 19 to 21, the
properties under spring.kafka.producer.properties use dotted keys which YAML
will parse as nested keys; change them to bracket notation with quotes so the
dots are preserved as literal keys (for example use "['enable.idempotence']" and
"['max.in.flight.requests.per.connection']" as map keys) to ensure Spring
receives the exact property names; update the YAML keys accordingly and keep
values unchanged.
- ProductMetrics @Version ํ๋ ์ ๊ฑฐ (๋จ์ผ ์ปจ์๋จธ ํ๊ฒฝ์์ ๋ถํ์) - ํ์์คํฌํ ๊ธฐ๋ฐ out-of-order ์ด๋ฒคํธ ์ฒดํฌ ๋ก์ง ์ ๊ฑฐ - Kafka ํํฐ์ ์์ ๋ณด์ฅ์ผ๋ก ์ถฉ๋ถํ๋ฏ๋ก ๋ณต์กํ ๊ฒ์ฆ ๋ก์ง ๋จ์ํ - ๋ฉํธ๋ฆญ ์ ๋ฐ์ดํธ ๋ฉ์๋์์ ํ์์คํฌํ ํ๋ผ๋ฏธํฐ ์ ๊ฑฐ - event_handled ํ ์ด๋ธ ๊ธฐ๋ฐ ๋ฉฑ๋ฑ์ฑ๋ง ์ ์ง
- ConcurrentHashMap ๊ธฐ๋ฐ ์๋ ์ฌ์๋ ์นด์ดํฐ ์ ๊ฑฐ (๋ฉ๋ชจ๋ฆฌ ๋์ ์ํ ํด์) - DlqMessage retryCount ํ๋ ์ ๊ฑฐ - DlqPublisher.publishToDlq() retryCount ํ๋ผ๋ฏธํฐ ์ ๊ฑฐ - ProductMetricsConsumer์์ RetryTracker ์์กด์ฑ ์ ๊ฑฐ - ์๋ฌ ์ฒ๋ฆฌ ๋จ์ํ: ๋ณต๊ตฌ ๋ถ๊ฐ๋ฅํ ์๋ฌ๋ ์ฆ์ DLQ, ์ผ์์ ์๋ฌ๋ Kafka ์๋ ์ฌ์๋ - ๊ด๋ จ ํ ์คํธ ์ฝ๋ ์ ๋ฐ์ดํธ
- LikeFacade์์ ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ ๋ด๋น - LikeService์์ ์ค๋ณต ์ฒดํฌ ์ ๊ฑฐ - ๋ถํ์ํ productRepository.findById ํธ์ถ ์ ๊ฑฐ
- domain ํจํค์ง์ JpaRepository๋ฅผ infrastructure/persistence๋ก ์ด๋ - Clean Architecture ์์กด์ฑ ๋ฐฉํฅ ์ค์ - JpaConfig์์ domain ํจํค์ง ์ค์บ ์ ๊ฑฐ
- toLongOrNull ๋ฉ์๋๋ก ํต์ผ - ์์ธ throw ๋์ null ๋ฐํ + ๋ก๊น - ์ค๋ณต ํ์ ๋ณํ ์ฝ๋ ์ ๊ฑฐ - DlqMessage ๋ถํ์ํ null ์ฒดํฌ ์ ๊ฑฐ
- ๋ฏธ์ฌ์ฉ ๋ฉ์๋ ์ ๊ฑฐ (KafkaTopicRouter.isValidTopic) - AsyncConfig ๋ฐํ ํ์ ๋ช ํํ - KafkaConfig ํค ํ์ String์ผ๋ก ๋ณ๊ฒฝ - ํ ์คํธ ์ฝ๋ ๊ฐ์
๐ Summary
Kafka ๊ธฐ๋ฐ ์ด๋ฒคํธ ํ์ดํ๋ผ์ธ์ ๊ตฌ์ถํ์ฌ At Least Once Producer์ At Most Once Consumer ํจํด์ ๊ตฌํํ์ต๋๋ค.
1. Producer (commerce-api) - At Least Once ๋ณด์ฅ
2. Consumer (commerce-streamer) - At Most Once ๋ณด์ฅ
3. ์ด๋ฒคํธ ๋ฐํ ์ง์
๐ฌ Review Points
โ Checklist
Must-Have
Producer
Consumer
Nice-to-Have
ํ ์คํธ
Summary by CodeRabbit
๋ฆด๋ฆฌ์ค ๋ ธํธ
์๋ก์ด ๊ธฐ๋ฅ
์ธํ๋ผ ๊ฐ์
โ๏ธ Tip: You can customize this high-level summary in your review settings.