-
Notifications
You must be signed in to change notification settings - Fork 34
Feature/week8 #197
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
Feature/week8 #197
Conversation
- PaymentRepository ๋ฐ ๊ด๋ จ ๋ฉ์๋์์ LocalDateTime ๋์ ZonedDateTime ์ฌ์ฉํ๋๋ก ์์ - ์ด๋ฒคํธ ๋ฆฌ์ค๋์ ํธ๋์ญ์ ์ ํ ๋ฐฉ์์ Propagation.REQUIRES_NEW๋ก ๊ฐ์
- ์ฌ์ฉ๋์ง ์๋ ๋ฐ์ดํฐ์์ค ์ค์ ํ์ผ(jpa.yml, redis.yml) ์ญ์ - KafkaConfig ํจํค์ง๋ช ์คํ ์์ (confg โ config) - build.gradle.kts์ kafka ๋ชจ๋ ์์กด์ฑ ์ถ๊ฐ ๋ฐ application.yml ์ค์ ๋ฐ์
- `OutboxEvent` ์ํฐํฐ ๋ฐ JPA ๋งคํ ์ถ๊ฐ๋ก Outbox ๊ธฐ๋ฐ ์ด๋ฒคํธ ๊ด๋ฆฌ ๊ตฌํ - `OutboxService`๋ฅผ ํตํด ์ด๋ฒคํธ ์ ์ฅ ๋ฐ ์ง๋ ฌํ ๋ก์ง ์ฒ๋ฆฌ - `OutboxRepository`๋ก ๋น๋๊ธฐ ์ด๋ฒคํธ ์กฐํ ๋ฐ ๊ด๋ฆฌ ๊ฐ๋ฅ
- `OutboxEvent` ๊ตฌ์กฐ ํ์ฅ: ๊ณ ์ `eventId` ํ๋ ์ถ๊ฐ ๋ฐ ์ ์ฅ ๋ก์ง ๊ฐ์ . - `LikeCreatedEvent`์ ์์ฑ ๋ฉ์๋(`of`) ๋์ ์ผ๋ก eventId ์๋ ์์ฑ ๋ฐ ํ์์คํฌํ ์ถ๊ฐ. - Kafka ์ฐ๋์ ์ํ `LikeEventOutboxHandler` ์ปดํฌ๋ํธ ์ถ๊ฐ. - `OutboxService`์ ์ด๋ฒคํธ ๋ฐํ ์๋ฃ ์ํ ๊ด๋ฆฌ ๋ก์ง(`markPublished`) ์ถ๊ฐ. - `LikeFacade` ๋ฐ `LikeCountAggregateListener`์์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๋ก์ง ํ์ฅ ๋ฐ ๊ด๋ จ ํด๋์ค ํจํค์ง ๊ตฌ์กฐ ์ ๋ฆฌ.
- `LikeEventConsumer` ์ถ๊ฐ๋ก Kafka ์ด๋ฒคํธ ์์ ๋ฐ ์ฒ๋ฆฌ ๋ก์ง ๊ตฌํ. - `ProductMetricsService`๋ฅผ ํตํด ์ข์์ ์ ์ ๋ฐ์ดํธ ์ฒ๋ฆฌ. - `ProductMetrics` ๋ฐ `EventHandled` ์ํฐํฐ ์ถ๊ฐ๋ก ์ด๋ฒคํธ ์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง ๋ฐ ๋ฉํธ๋ฆญ ์ ์ฅ. - ๊ด๋ จ JPA ๋ฆฌํฌ์งํ ๋ฆฌ ์ธํฐํ์ด์ค (`ProductMetricsRepository`, `EventHandledRepository`) ์ถ๊ฐ.
- ์ด์ ๋ฐ๋ฅธ ์ปจ์๋จธ ๋ฐ ํผ๋ธ๋ฆฌ์ ๊ตฌํ์ฒด ์์ - OrderCreatedEvent, LikeEventOutboxHandler, LikeCountAggregateListener ํจํค์ง ๊ตฌ์กฐ ์ ๋ฆฌ
- ์ํ ์กฐํ ์ก์ ์ ๊ธฐ๋กํ๊ธฐ ์ํ ProductViewEvent ๋์ - ProductViewEvent๋ฅผ ์ฒ๋ฆฌํ๊ณ Kafka์ ์ฐ๋ํ๋ ProductEventOutboxHandler ๊ตฌํ - ์ํ ์ ๋ณด ์กฐํ ์ ProductFacade์์ ProductViewEvent ๋ฐํํ๋๋ก ์์ - ์ด๋ฒคํธ ์ ์ฅ, ์ค๋ณต ์ ๊ฑฐ, Kafka ๋ฐํ ๋ก์ง ํฌํจ
- `OrderEventOutboxHandler` ์ถ๊ฐ๋ก ์ฃผ๋ฌธ ์์ฑ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๋ฐ Kafka ๋ฐํ ๋ก์ง ๊ตฌํ. - `OrderSalesAggregateListener`๋ฅผ ํตํด ํ๋งค ์ง๊ณ Kafka ์ด๋ฒคํธ ๋ฐํ ๋ฐ ๋น๋๊ธฐ ์ฒ๋ฆฌ ์ง์. - `OrderCreatedEvent` ํ์ฅ: ๊ณ ์ `eventId` ์ถ๊ฐ ๋ฐ ์ฃผ๋ฌธ ํ๋ชฉ ์ ๋ฌ ์ ๋ณด ๊ตฌ์กฐ ๋ณ๊ฒฝ. - ์ ๊ท `SalesCountEvent` ์ถ๊ฐ๋ก Kafka ๊ธฐ๋ฐ ํ๋งค ๋ฉํธ๋ฆญ ๊ด๋ฆฌ ๊ฐ๋ฅ. - `OrderFacade`, `PointPaymentEventListener`, `PgPaymentEventListener` ๋ฑ ๊ด๋ จ ํด๋์ค ์์ ๋ฐ ํจํค์ง ์ ๋ฆฌ.
- `LikeEventConsumer`๋ฅผ `MetricsEventConsumer`๋ก ๋ณ๊ฒฝํ๊ณ ์ํ ์งํ(์ข์์, ์กฐํ์, ํ๋งค๋) ์ฒ๋ฆฌ ์ง์. - `ProductMetricsService` ํ์ฅ: ์กฐํ์(`ProductViewEvent`), ํ๋งค๋(`SalesCountEvent`) ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ๋ก์ง ์ถ๊ฐ. - `ProductMetrics` ์ํฐํฐ์ ์กฐํ์์ ํ๋งค๋ ์์ฑ ๋ฐ ๊ด๋ จ ์ ๋ฐ์ดํธ ๋ฉ์๋ ์ถ๊ฐ.
- `OutboxEvent`, `ProductMetrics`, `EventHandled` ์ํฐํฐ์ @table ์ ๋ํ ์ด์ ์ถ๊ฐ. - `IdempotencyIntegrationTest` ๊ตฌํ์ผ๋ก ์ค๋ณต ์ด๋ฒคํธ ์ฒ๋ฆฌ ๊ฒ์ฆ.
โฆ ๋ก์ง ๊ฐ์ - SalesCountEvent๋ฅผ ์ ๊ฑฐํ๊ณ ๊ด๋ จ ๋ก์ง์ ProductStockEvent๋ก ์ ๋ฉด ๋์ฒด - MetricsEventConsumer์์ ProductStockEvent๋ฅผ ์ฒ๋ฆฌํ๋๋ก ์์ - ProductMetricsService๋ฅผ ํ์ฅํ์ฌ ์ฌ๊ณ ๊ธฐ๋ฐ ์งํ ๊ด๋ฆฌ ๋ฐ ์ฌ๊ณ ์์ง ์ Redis ์บ์ ์ญ์ ๋ก์ง ์ถ๊ฐ - ProductRepository์ findStockById ๋ฉ์๋, ProductService์ getStock ๋ฉ์๋ ์ถ๊ฐ - Redis ์บ์์์ ํ์ด์ง ์๋ต์ ๊ฐ์ธ๊ธฐ ์ํ PageWrapper ๋์ - ํจํด ๊ธฐ๋ฐ ์บ์ ์ญ์ ๋ฅผ ์ง์ํ๋๋ก RedisCacheHandler ์ด๋ ๋ฐ ๊ธฐ๋ฅ ํ์ฅ
- `OutboxEvent`์ `OutboxStatus` ๋ฐ `retryCount` ํ๋ ์ถ๊ฐ๋ก ์ํ ๊ด๋ฆฌ ์ง์. - ์คํจ ์ด๋ฒคํธ๋ฅผ ์ฒ๋ฆฌํ๊ธฐ ์ํ `markFailed` ๋ฉ์๋ ๊ตฌํ. - `OutboxRelay` ์ปดํฌ๋ํธ ์ถ๊ฐ๋ก ์คํจ ์ด๋ฒคํธ ์ฌ๋ฐํ ์ค์ผ์ค๋ง ๋ฐ ์ฒ๋ฆฌ ๋ก์ง ๊ตฌํ. - ๊ด๋ จ ์๋น์ค ๋ฐ ํธ๋ค๋ฌ์ ์คํจ ์ํ ์ ๋ฐ์ดํธ ๋ก์ง ํตํฉ (`markFailed` ํธ์ถ ์ถ๊ฐ).
- Kafka ์ค์ ์ enable.idempotence=true ๋ฐ acks=all ํ๋ก๋์ ์ต์ ์ถ๊ฐ - OutboxService์์ ์ฌ์ฉํ์ง ์๋ import ์ ๊ฑฐ
Walkthrough์ด PR์ ์์๋ฐ์ค ํจํด์ ๊ตฌํํ์ฌ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ฅผ ๊ฐํํฉ๋๋ค. Kafka ๋ชจ๋ ์์กด์ฑ์ ์ถ๊ฐํ๊ณ , ์ด๋ฒคํธ ์ง์์ฑ ๋ฐ ์ฌ์๋๋ฅผ ์ํ ์์๋ฐ์ค ์ธํ๋ผ(์ํฐํฐ, ์๋น์ค, ๋ฆฌํฌ์งํ ๋ฆฌ)๋ฅผ ๋์ ํฉ๋๋ค. ์ด๋ฒคํธ ํด๋์ค๋ฅผ ์ฌ๊ตฌ์ฑํ๊ณ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํ๋ฉฐ, ์ํ ๋ฉํธ๋ฆญ ์ถ์ ์ ๊ตฌํํฉ๋๋ค. ZonedDateTime ๋ง์ด๊ทธ๋ ์ด์ ๊ณผ ํธ๋์ญ์ ์ ํ ์ค์ ๋ ํฌํจ๋ฉ๋๋ค. Changes
Sequence Diagram(s)์์๋ฐ์ค ์ด๋ฒคํธ ํ๋ฆsequenceDiagram
participant App as Application<br/>(Facade)
participant Pub as ApplicationEventPublisher
participant Outbox as OutboxService
participant Repo as OutboxRepository
participant Relay as OutboxRelay<br/>(Scheduled)
participant Kafka as KafkaTemplate
App->>Pub: publishEvent(DomainEvent)
Pub->>Outbox: saveEvent(aggregateType,<br/>aggregateId, event)
Outbox->>Repo: save(OutboxEvent)
Repo-->>Outbox: OutboxEvent(status=INIT)
Note over Relay: 60์ด ์ฃผ๊ธฐ<br/>์คํ
Relay->>Repo: findTop10ByStatusIn<br/>(INIT, FAILED)
Repo-->>Relay: List<OutboxEvent>
loop Each Event
Relay->>Relay: deserialize & load<br/>event class
Relay->>Pub: publishEvent(DomainEvent)
Pub->>Kafka: send(topic, event)
alt Success
Kafka-->>Relay: success callback
Relay->>Outbox: markPublished(eventId)
else Failure
Kafka-->>Relay: error callback
Relay->>Outbox: markFailed(eventId)
end
end
์ํ ๋ฉํธ๋ฆญ ์ด๋ฒคํธ ์ฒ๋ฆฌ ํ๋ฆsequenceDiagram
participant Kafka as KafkaConsumer<br/>(MetricsEventConsumer)
participant Svc as ProductMetricsService
participant Repo as ProductMetricsRepository
participant Cache as RedisCacheHandler
participant EvtRepo as EventHandledRepository
Kafka->>Svc: processLikeCountEvent<br/>(LikeCountEvent)
Svc->>EvtRepo: isAlreadyHandled(eventId)
alt Already Processed
EvtRepo-->>Svc: true
Svc->>Kafka: ack()
else First Time
EvtRepo-->>Svc: false
Svc->>Repo: getOrCreateMetrics(productId)
Svc->>Repo: updateLikeCount(newCount)
Repo-->>Svc: ProductMetrics
Svc->>EvtRepo: save(EventHandled)
Svc->>Kafka: ack()
Note over Svc: productViewEvent<br/>& salesCountEvent<br/>์ ์ฌํ ํ๋ฆ
end
Estimated code review effort๐ฏ 4 (Complex) | โฑ๏ธ ~45 minutes ์ฃผ์ ๊น๊ฒ ๊ฒํ ํ ์์ญ:
Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touchesโ Failed checks (1 warning, 1 inconclusive)
โ Passed checks (1 passed)
โจ Finishing touches
๐งช Generate unit tests (beta)
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: 20
Caution
Some comments are outside the diff and canโt be posted inline due to platform limitations.
โ ๏ธ Outside diff range comments (6)
apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (1)
67-75: ์์ธ ์ฒ๋ฆฌ ๋ฒ์๊ฐPgPaymentEventListener์ ๋ถ์ผ์นํฉ๋๋ค.
PgPaymentEventListener๋Exception์ catchํ์ง๋ง, ์ด ๋ฆฌ์ค๋๋CoreException๋ง catchํฉ๋๋ค.processor.process()์์ ์๊ธฐ์น ์์ ์์ธ(์:RuntimeException)๊ฐ ๋ฐ์ํ๋ฉดfailPayment๊ฐ ํธ์ถ๋์ง ์๊ณ ์คํจ ์ด๋ฒคํธ๋ ๋ฐํ๋์ง ์์ ์ฃผ๋ฌธ์ด ์ผ๊ด์ฑ ์๋ ์ํ๋ก ๋จ์ ์ ์์ต๋๋ค.๐ ์ผ๊ด์ฑ ์๋ ์์ธ ์ฒ๋ฆฌ๋ฅผ ์ํ ์์ ์ ์
- } catch (CoreException e) { + } catch (Exception e) { orderService.failPayment(event.orderId()); eventPublisher.publishEvent(new PaymentRequestFailedEvent( event.orderId(), event.couponId(), e.getMessage() )); - }apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java (1)
48-49: ์์ธ๋ฅผ ๋ฌด์ํ๋ ๋น catch ๋ธ๋ก์ ์์ ํด์ผ ํฉ๋๋ค.๋น catch ๋ธ๋ก์ด ๋ชจ๋ ์์ธ๋ฅผ ์กฐ์ฉํ ์ผ์ผ๋ฒ๋ ค, ๊ฒฐ์ ๋ณต๊ตฌ ์คํจ๋ฅผ ์ถ์ ํ ์ ์์ต๋๋ค. PG ํด๋ผ์ด์ธํธ ์ค๋ฅ, ๊ฒฐ์ ์ํ ์ ๋ฐ์ดํธ ์คํจ ๋ฑ์ด ๋ฐ์ํด๋ ์ด๋ ํ ๋ก๊ทธ๋ ์๋ฆผ ์์ด ๋ฌด์๋ฉ๋๋ค.
์ต์ํ ์์ธ๋ฅผ ๋ก๊น ํด์ผ ํฉ๋๋ค:
๐ ์ ์๋ ์์ ์ฌํญ
} catch (Exception e) { + log.error("Failed to recover payment: paymentId={}, orderId={}", + payment.getId(), payment.getOrderId(), e); }๋ ๋์ ๋ฐฉ๋ฒ์ผ๋ก, ๋ชจ๋ํฐ๋ง์ ์ํด ๋ฉํธ๋ฆญ์ ์ถ๊ฐํ๋ ๊ฒ๋ ๊ณ ๋ คํด๋ณด์ธ์:
} catch (Exception e) { log.error("Failed to recover payment: paymentId={}, orderId={}", payment.getId(), payment.getOrderId(), e); // Optional: meterRegistry.counter("payment.recovery.failure").increment(); }apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (2)
41-44: ์์ธ ์ฒ๋ฆฌ์ ๋ถ๋ช ํ์ฑ โ ์์ธ ์ ํ๋ณ ์ฒ๋ฆฌ์ ๋ก๊น ์ถ๊ฐ ํ์๋ชจ๋ ์์ธ๋ฅผ ๋์ผํ๊ฒ ์ฒ๋ฆฌํ๋ฉฐ ๋ก๊น ์์ด ์กฐ์ฉํ ์ฌ์๋ ํ์๋ง ์ฆ๊ฐ์ํต๋๋ค. ๋ค์๊ณผ ๊ฐ์ ๋ค์ํ ์คํจ ์์ธ์ ๊ตฌ๋ถํ ์ ์์ต๋๋ค:
ClassNotFoundException: ์ด๋ฒคํธ ํ์ ์ด classpath์์ ๋ฐ๊ฒฌ๋์ง ์์ (์ค์ ์ค๋ฅ)JsonMappingException: ์ด๋ฒคํธ ์ง๋ ฌํ ๋ถ์ผ์น (์คํค๋ง ๋ฒ์ ํธํ์ฑ)- Kafka ์ ์ ์ค๋ฅ (์ผ์์ ์ฌ์๋ ๊ฐ๋ฅ)
- ์คํ๋ง ์ด๋ฒคํธ ํผ๋ธ๋ฆฌ์ ์ค๋ฅ
ํ์ฌ ๊ตฌ์กฐ๋ก๋ ์ฌ์๋ ๋ถ๊ฐ๋ฅํ ์ค๋ฅ(์: ClassNotFoundException)๋ MAX_RETRY_COUNT๊น์ง ๋ฐ๋ณต ์ํํ๊ฒ ๋ฉ๋๋ค.
๐ ์์ธ ์ ํ๋ณ ์ฒ๋ฆฌ ์ถ๊ฐ
} catch (Exception e) { + logger.error("Failed event retry failed: eventId={}, attempt={}/{}, exception={}", + failedEvent.getId(), failedEvent.getRetryCount() + 1, MAX_RETRY_COUNT, e.getMessage(), e); + + if (e instanceof ClassNotFoundException) { + logger.warn("Unrecoverable error: event class not found. Moving to permanent failure."); + failedEvent.markAsUnrecoverable(); + failedEventRepository.save(failedEvent); + return; + } + failedEvent.incrementRetryCount(); failedEventRepository.save(failedEvent); }
1-23: ์คํจํ ์ด๋ฒคํธ ์ฌ์๋ ์ค ์์ธ ์ฒ๋ฆฌ์ ๋ก๊น ์ถ๊ฐ ํ์lines 41-44์ ์์ธ ์ฒ๋ฆฌ ๊ตฌ๊ฐ์์ ๋ก๊น ์ด ๋ชจ๋ฒ ์ฌ๋ก์์๋ ๋ถ๊ตฌํ๊ณ ๋ก๊น ์ด ์์ต๋๋ค. ObjectMapper์ ์ญ์ง๋ ฌํ ์คํจ(line 35-36)์ Class.forName ์ค๋ฅ(line 34) ๋ฑ ๋ค์ํ ์ด์ ๋ก ์์ธ๊ฐ ๋ฐ์ํ ์ ์๋๋ฐ, ์ด๋ฅผ ๋ก๊น ํ์ง ์์ผ๋ฉด ๋ถ์ฐ ์์คํ ์์ ์ค๋ฅ ์์ธ์ ์ถ์ ํ๊ธฐ ์ด๋ ต์ต๋๋ค. ์ค๋ฅ ๋ฉ์์ง๋ฅผ ๊ธฐ๋กํ๋ ๊ฒ์ด ๊ถ์ฅ ์ฌํญ์ ๋๋ค.
๋ํ @transactional ๊ฒฝ๊ณ ๋ด์์ ApplicationEventPublisher.publishEvent() ํธ์ถ ์, ํธ๋์ญ์ ์ด ๋กค๋ฐฑ๋๋ฉด ์ด๋ฒคํธ๊ฐ ๋ ๋ฒ ๋ฐํ๋ ์ ์์ด ์ ํ์ฑ(At Most Once)์ ๋ณด์ฅํ ์ ์์ต๋๋ค. ์ด๋ฒคํธ ๋ฐํ ํ ํธ๋์ญ์ ์ด ๋กค๋ฐฑ๋๋ ๋ฌธ์ ๋ Spring์์ ์๋ ค์ง ๋ฌธ์ ์ด๋ฉฐ, Spring Modulith์ transactional outbox ํจํด์ผ๋ก ํด๊ฒฐํ ์ ์์ต๋๋ค. ํ์ฌ ์ฝ๋๋ฒ ์ด์ค์์ ์ฌ์ฉ ์ค์ธ EventInboxAspect ํจํด๊ณผ ์ผ๊ด์ฑ ์๋ ์ ๊ทผ์ ๊ฒํ ํ์ญ์์ค.
apps/commerce-api/src/main/resources/application.yml (1)
39-49: FeignException.NotFound์ ignore-exceptions์ ๋ค์ ์ถ๊ฐํ์ธ์.ํ์ฌ ์ค์ ์์
FeignException.NotFound๊ฐ ignore-exceptions์ ์์ด์, 404 ์๋ต๋ ์ฌ์๋ ๋์์ด ๋ฉ๋๋ค.
LoopersPgExecutor.execute()๋ฉ์๋๋ฅผ ๋ณด๋ฉด, HTTP ์์ฒญ์ด ์ฑ๊ณตํ์ ๋๋ง ์๋ต์ ์ฒ๋ฆฌํฉ๋๋ค. 404 ์ค๋ฅ๋ Feign ํด๋ผ์ด์ธํธ๊ฐFeignException.NotFound๋ฅผ ๋ฐ์์ํค๋ฏ๋ก, ์ด ์์ธ๊ฐ resilience4j ์ฌ์๋ ๋ฉ์ปค๋์ฆ๊น์ง ์ ๋ฌ๋ฉ๋๋ค. 404๋ ์์ฒญํ ์๋ํฌ์ธํธ๋ ๋ฆฌ์์ค๊ฐ ์กด์ฌํ์ง ์๋๋ค๋ ๋ป์ผ๋ก, ์ฌ์๋ํด๋ ์ฑ๊ณตํ ๊ฐ๋ฅ์ฑ์ด ๋ฎ์ต๋๋ค.๋น์ฆ๋์ค ๋ก์ง ์๋ฌ(400 ๋ฑ)์ ๋ง์ฐฌ๊ฐ์ง๋ก 404๋ ์ฌ์๋ ๋์์์ ์ ์ธํ์ฌ ๋ถํ์ํ ์ฌ์๋ ์๋๋ฅผ ๋ฐฉ์งํ์ธ์:
ignore-exceptions: - com.loopers.support.error.CoreException - feign.FeignException.BadRequest - feign.FeignException.NotFoundapps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)
23-60: Product ์ํฐํฐ์ likeCount๊ฐ ์ ๋ฐ์ดํธ๋์ง ์๊ณ ์์ต๋๋ค.
LikeCreatedEvent.of(productId, 1)๋ฅผ ๋ฐํํ์ง๋ง,Product์ํฐํฐ์likeCountํ๋๋ฅผ ์ค์ ๋ก ์ฆ๊ฐ/๊ฐ์์ํค๋ ๋ก์ง์ด ๋๋ฝ๋์์ต๋๋ค:
- Line 32:
likeService.save()๋Like๋ ์ฝ๋๋ง ์์ฑproduct.increaseLikeCount()๋๋ProductService.increaseLikeCount(productId)ํธ์ถ์ด ์์- Line 42, 58: ๋ฐํ๋๋
product.getLikeCount()๋ ์ ๋ฐ์ดํธ๋์ง ์์ ๊ฐ์ด๋ก ์ธํด ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ง๊ณ์ DB ์ํ ๊ฐ ๋ถ์ผ์น๊ฐ ๋ฐ์ํฉ๋๋ค.
๐ ์์ ์ ์
@Transactional public LikeInfo like(long userId, long productId) { Optional<Like> existingLike = likeService.findLike(userId, productId); - Product product = productService.getProduct(productId); if (existingLike.isPresent()) { + Product product = productService.getProduct(productId); return LikeInfo.from(existingLike.get(), product.getLikeCount()); } Like newLike = likeService.save(userId, productId); + int updatedCount = productService.increaseLikeCount(productId); eventPublisher.publishEvent(LikeCreatedEvent.of(productId, 1)); eventPublisher.publishEvent(new LikeActionTrackEvent( userId, productId, "LIKE" )); - return LikeInfo.from(newLike, product.getLikeCount()); + return LikeInfo.from(newLike, updatedCount); } @Transactional public int unLike(long userId, long productId) { likeService.unLike(userId, productId); + int updatedCount = productService.decreaseLikeCount(productId); eventPublisher.publishEvent(LikeCreatedEvent.of(productId, -1)); eventPublisher.publishEvent(new LikeActionTrackEvent( userId, productId, "UNLIKE" )); - return productService.getProduct(productId).getLikeCount(); + return updatedCount; }
๐งน Nitpick comments (5)
modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java (1)
11-17: ํ์์คํฌํ ์ฒ๋ฆฌ ์ผ๊ด์ฑ์ ๊ณ ๋ คํ์ธ์.
System.currentTimeMillis()๋ฅผ ์ฌ์ฉํ์ฌ ํ์์คํฌํ๋ฅผ ์์ฑํ๊ณ ์์ต๋๋ค. PR ์ค๋ช ์์ ์ธ๊ธ๋ ZonedDateTime ๋ง์ด๊ทธ๋ ์ด์ ๊ณผ ๋ฌ๋ฆฌ, ์ด ์ด๋ฒคํธ๋ long ํ์ ์ ์ํฌํฌ ๋ฐ๋ฆฌ์ด๋ฅผ ์ฌ์ฉํฉ๋๋ค.๊ณ ๋ ค ์ฌํญ:
- ํ์์กด ์ ๋ณด ๋ถ์ฌ: ์ํฌํฌ ํ์์คํฌํ๋ UTC๋ก ๊ฐ์ฃผ๋์ง๋ง ๋ช ์์ ์ด์ง ์์ต๋๋ค.
- ๋ค๋ฅธ ์ด๋ฒคํธ๋ค๊ณผ์ ์ผ๊ด์ฑ: ์์คํ ์ ๋ฐ์์ ZonedDateTime์ ์ฌ์ฉํ๋ค๋ฉด ์ด ์ด๋ฒคํธ๋ ๋ง์ถ๋ ๊ฒ์ด ์ข์ต๋๋ค.
- ์ง๋ ฌํ: long์ JSON ์ง๋ ฌํ๊ฐ ๊ฐ๋จํ์ง๋ง, ISO-8601 ๋ฌธ์์ด์ด ๋ ๋ช ํํ ์ ์์ต๋๋ค.
๋ํ,
productId์ ๋ํ null ๊ฒ์ฆ์ด ์์ต๋๋ค. ํ์ํ๋ค๋ฉด ํฉํ ๋ฆฌ ๋ฉ์๋์์ ๊ฒ์ฆ์ ์ถ๊ฐํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์.๐ ZonedDateTime ์ฌ์ฉ ์ ์
package com.loopers.event; +import java.time.ZonedDateTime; import java.util.UUID; public record ProductViewEvent( String eventId, Long productId, - long timestamp + ZonedDateTime timestamp ) { public static ProductViewEvent from(Long productId) { return new ProductViewEvent( UUID.randomUUID().toString(), productId, - System.currentTimeMillis() + ZonedDateTime.now() ); } }apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java (1)
18-29: ํ ์คํธ ๊ตฌ์ฑ์ ๋จ์ํํ ์ ์์ต๋๋ค.
RedisConfig๋ฅผ importํ๋ฉด์ ๋์์RedisCacheHandler๋ฅผ ๋ชจํนํ๊ณ ์์ต๋๋ค. ๋ชจํน๋ ๋น์ ์ฌ์ฉํ๋ค๋ฉดRedisConfigimport๊ฐ ๋ถํ์ํ ์ ์์ต๋๋ค.๐ ๋ฆฌํฉํ ๋ง ์ ์
@SpringBootTest -@Import(RedisConfig.class) class IdempotencyIntegrationTest {๋๋ ์ค์ Redis ์ฐ๋์ด ํ์ํ๋ค๋ฉด
RedisCacheHandler๋ชจํน์ ์ ๊ฑฐํ๋ ๊ฒ์ ๊ณ ๋ คํ์ธ์.apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
17-29:ProductEventOutboxHandler์์ ์ผ๊ด์ฑ์ ์ํด@Async์ถ๊ฐ๋ฅผ ๊ฒํ ํ์ธ์.
ProductEventOutboxHandler๋@Async์ด๋ ธํ ์ด์ ์ ์ฌ์ฉํ์ง๋ง, ์ด ํธ๋ค๋ฌ์OrderEventOutboxHandler๋ชจ๋ ๋๋ฝ๋์ด ์์ต๋๋ค. ํธ๋์ญ์ ์ปค๋ฐ ํ ๋น๋๊ธฐ ์ฒ๋ฆฌ๋ฅผ ์ํด@Async๋ฅผ ์ถ๊ฐํ๋ ๊ฒ์ด ์ํคํ ์ฒ ์ผ๊ด์ฑ์ ๊ฐ์ ํ ์ ์์ต๋๋ค.+ @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(LikeCreatedEvent event) {apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java (1)
36-36: ํ์์คํฌํ ์ด๊ธฐํ ๋ฐฉ์ ๊ฐ์ ๊ณ ๋ ค
createdAtํ๋๋ฅผLocalDateTime.now()๋ก ํ๋ ์ ์ธ ์์ ์ ์ด๊ธฐํํ๋ ๊ฒ์ ์๋ํ์ง๋ง, JPA ์๋ช ์ฃผ๊ธฐ์ ๋ ๋ช ํํ๊ฒ ํตํฉํ ์ ์์ต๋๋ค.๐ ๋์ ์ ์
์ต์ 1: @CreationTimestamp ์ฌ์ฉ (Hibernate ์ ์ฉ)
+ import org.hibernate.annotations.CreationTimestamp; + + @CreationTimestamp - private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime createdAt;์ต์ 2: @PrePersist ์ฝ๋ฐฑ
- private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + }์ต์ 3: ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ธฐ๋ณธ๊ฐ (์ ํธ)
ALTER TABLE outbox_event MODIFY COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;+ @Column(insertable = false, updatable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime createdAt;apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
54-56: ์บ์ ๋ฌดํจํ ๋ฒ์๊ฐ ๊ณผ๋ํ๊ฒ ๋์
deleteByPattern("product:list")๋ ๋ชจ๋ ์ํ ๋ชฉ๋ก ์บ์๋ฅผ ์ญ์ ํ์ฌ, ์ฌ๊ณ ์์ง๋ ๋จ์ผ ์ํ๊ณผ ๋ฌด๊ดํ ๋ค๋ฅธ ์ํ ๋ชฉ๋ก ์กฐํ๊น์ง ์บ์ ๋ฏธ์ค๋ฅผ ์ ๋ฐํฉ๋๋ค.
ProductService(apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java)๋ฅผ ๋ณด๋ฉด ์บ์ ํค๊ฐproduct:list:{ํ์ด์ง ์ ๋ณด}์product:list:brand:{brandId}ํํ๋ก ์ ์ฅ๋ฉ๋๋ค. ํน์ ์ํ์ ์ฌ๊ณ ๋ณ๊ฒฝ์ ํด๋น ์ํ์ด ํฌํจ๋ ์บ์๋ง ๋ฌดํจํํ๋ฉด ๋ฉ๋๋ค.๋ ๋์ ์ ๊ทผ๋ฒ:
- ์ํ ID๋ฅผ ์บ์ ํค์ ํฌํจ์์ผ ์ ๋ฐํ ๋ฌดํจํ (
product:list:contains:{productId})- ๋๋ ์บ์ TTL์ ์์กดํ๊ณ ์ค์๊ฐ ์ฌ๊ณ ๋ ์์ธ ์กฐํ ์บ์ ๋ฌดํจํ๋ง ์ํ
๐ ์์ธ ์บ์๋ง ๋ฌดํจํํ๋ ๋ฐฉ์
if (event.currentStock() <= 0) { redisCacheHandler.delete("product:detail:" + event.productId()); - redisCacheHandler.deleteByPattern("product:list"); + // ๋ชฉ๋ก ์บ์๋ TTL(5๋ถ)์ ์์กดํ๊ฑฐ๋, + // ์ํ๋ณ ๋ชฉ๋ก ์บ์ ๋งคํ ํ ์ด๋ธ์ ์ ์งํ์ฌ ์ ํ์ ๋ฌดํจํ }
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (47)
apps/commerce-api/build.gradle.kts(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java(0 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java(0 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java(2 hunks)apps/commerce-api/src/main/resources/application.yml(2 hunks)apps/commerce-api/src/main/resources/jpa.yml(0 hunks)apps/commerce-api/src/main/resources/redis.yml(0 hunks)apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.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/ProductMetricsService.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java(1 hunks)apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java(1 hunks)apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java(1 hunks)modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java(1 hunks)modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java(1 hunks)modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java(1 hunks)modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java(1 hunks)modules/kafka/src/main/resources/kafka.yml(1 hunks)modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java(2 hunks)modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java(1 hunks)
๐ค Files with no reviewable changes (4)
- apps/commerce-api/src/main/resources/jpa.yml
- apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java
- apps/commerce-api/src/main/resources/redis.yml
- apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java
๐งฐ Additional context used
๐ง Learnings (11)
๐ 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/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.javamodules/kafka/src/main/java/com/loopers/event/ProductStockEvent.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.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.ymlmodules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java
๐ Learning: 2025-12-07T12:45:45.556Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 52
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/Payment.kt:160-181
Timestamp: 2025-12-07T12:45:45.556Z
Learning: In the Payment.confirmPayment method in com.loopers.domain.payment.Payment, the when-branch order is intentionally designed with business logic checks before timeout: (1) check SUCCESS/FAILED status first, (2) check for null match (business failure), (3) check timeout only as last resort for PENDING transactions. If matched transaction is PENDING and not timed out, no branch matches and payment stays IN_PROGRESS for scheduler retry. This design prioritizes business outcome determination over time-based fallback.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.javaapps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.javaapps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java
๐ Learning: 2025-11-30T15:44:28.336Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 48
File: apps/commerce-api/src/main/kotlin/com/loopers/application/order/OrderFacade.kt:80-83
Timestamp: 2025-11-30T15:44:28.336Z
Learning: User ghojeong prefers explicit state management over transaction rollback for handling payment failures in order creation flow. When payment fails after order creation, the order should be explicitly marked as cancelled and persisted, rather than relying on transaction rollback.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.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-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.javamodules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.javamodules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.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-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.javaapps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.javaapps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.javaapps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.javaapps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
๐ Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier์ ๊ฐ์ ์ฌ์ฉ์ ์
๋ ฅ ID์ ProductLikeId, OrderItemId ๋ฑ์ ๋๋ฉ์ธ ID๋ ๊ฒ์ฆ ํจํด์ด ๋ค๋ฆ
๋๋ค. UserIdentifier๋ ์ฌ์ฉ์๊ฐ ์
๋ ฅํ๋ ๋ก๊ทธ์ธ ID๋ก์ ์ ๊ท์, ๊ธธ์ด ๋ฑ์ ํ์ ๊ฒ์ฆ์ด ํ์ํ์ง๋ง, ๋๋ฉ์ธ ID๋ ์์คํ
์์ ์์ฑํ๋ ์๋ณ์(UUID, DB ์์ฑ ID)์ด๋ฏ๋ก ํจํด ๊ฒ์ฆ์ด ๋ถํ์ํฉ๋๋ค. ๋๋ฉ์ธ ID VO๋ ๋จ์ํ record์ empty() ํฉํ ๋ฆฌ ๋ฉ์๋๋ง์ผ๋ก ์ถฉ๋ถํฉ๋๋ค.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.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/MetricsEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
๐ Learning: 2025-11-20T13:22:51.519Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 98
File: core/domain/src/main/java/com/loopers/core/domain/order/DefaultCoupon.java:15-62
Timestamp: 2025-11-20T13:22:51.519Z
Learning: In the order domain coupon implementation, DefaultCoupon serves as an internal helper/DTO class to carry AbstractCoupon's base fields during database reconstruction. It is used as an intermediate object when converting CouponEntity to specific coupon types (RateDiscountCoupon or AmountDiscountCoupon). The actual discount information (rate/amount) is stored in separate tables (rate_discount_coupon and amount_discount_coupon) and fetched via RateDiscountCouponJpaRepository and AmountDiscountCouponJpaRepository. DefaultCoupon is never returned to callers directly.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
๐ Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java
๐งฌ Code graph analysis (7)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (6)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
RequiredArgsConstructor(13-40)apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
Component(14-47)apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (1)
Component(15-49)apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
Component(10-30)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
Component(10-27)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
result(7-19)
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (4)
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
Component(14-47)apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
Component(10-30)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
Component(10-27)apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
Component(12-33)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java (1)
Entity(15-54)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
Entity(11-47)apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
name(14-87)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (5)
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
Component(14-47)apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (1)
Component(15-49)apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
Component(10-27)apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
Component(12-33)apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java (1)
Component(17-57)
apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (2)
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)
RequiredArgsConstructor(24-91)apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java (1)
Component(23-71)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (2)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
Component(10-30)apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
Component(12-33)
modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java (1)
modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java (1)
PageWrapper(8-31)
๐ Additional comments (29)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java (1)
3-3: Import ๊ฒฝ๋ก ์ ๋ฐ์ดํธ ํ์ธKafkaConfig์ ํจํค์ง๋ช ์์ ์ ๋ฐ๋ผ import ๋ฌธ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฐ์ดํธ๋์์ต๋๋ค.
modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (1)
1-1: ํจํค์ง๋ช ์คํ ์์ ์๋ฃํจํค์ง๋ช ์ ์คํ(confg โ config) ์์ ์ด ์๋ฃ๋์์ต๋๋ค. ์ฝ๋๋ฒ ์ด์ค ์ ์ฒด์์ ์ด์ ํจํค์ง๋ช "com.loopers.confg"์ ๋ํ ์ฐธ์กฐ๊ฐ ๋จ์์์ง ์์ผ๋ฉฐ, ์๋ก์ด ํจํค์ง๋ช "com.loopers.config.kafka"๋ก ๋ชจ๋ ์ฐธ์กฐ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฐ์ดํธ๋์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (3)
11-12: LGTM!
UserService์์กด์ฑ ์ถ๊ฐ๊ฐPgPaymentEventListener์ ๋์ผํ ํจํด์ ๋ฐ๋ฅด๊ณ ์์ต๋๋ค. ์ด๋ฒคํธ ํ์ด๋ก๋์์ ์ง์ User ๊ฐ์ฒด๋ฅผ ๊ฐ์ ธ์ค๋ ๋์ ์๋น์ค๋ฅผ ํตํด ์กฐํํ๋ ๋ฐฉ์์ผ๋ก ์ ํํ ๊ฒ์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ์์ ์ ์ต์ ์ฌ์ฉ์ ์ํ๋ฅผ ๋ณด์ฅํฉ๋๋ค.Also applies to: 31-31
34-35: LGTM!
AFTER_COMMITํ์ด์ฆ์REQUIRES_NEW์ ํ ์ค์ ์ ์กฐํฉ์ด ์ฌ๋ฐ๋ฆ ๋๋ค. ์๋ณธ ํธ๋์ญ์ ์ปค๋ฐ ํ ๋ ๋ฆฝ์ ์ธ ์ ํธ๋์ญ์ ์์ ๊ฒฐ์ ์ฒ๋ฆฌ๊ฐ ์ํ๋๋ฉฐ, ์ด๋PgPaymentEventListener์ ์ผ๊ด๋ ํจํด์ ๋๋ค.
47-53: LGTM!์ด๋ฒคํธ ํ์ด๋ก๋์์
userId๋ฅผ ํตํด ์ฌ์ฉ์๋ฅผ ์กฐํํ๋ ๋ฐฉ์์ผ๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค. ์ด๋ฒคํธ ์ฒ๋ฆฌ ์์ ์ ์ต์ ์ฌ์ฉ์ ๋ฐ์ดํฐ๋ฅผ ๋ณด์ฅํ๋ฉฐ,PgPaymentEventListener์ ๋์ผํ ํจํด์ ๋๋ค.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)
17-17: ProductRepository ์ธํฐํ์ด์ค์ ์ ์๋ findStockById ๋ฉ์๋๊ฐ ๊ตฌํ์ฒด์์ ๋๋ฝ๋จProductRepository ์ธํฐํ์ด์ค(line 17)์
int findStockById(Long id);๊ฐ ์ ์ธ๋์ด ์์ผ๋, ProductRepositoryImpl๊ณผ ProductJpaRepository์์ ์ด๋ฅผ ๊ตฌํํ์ง ์์์ต๋๋ค. ProductService.getStock()์ด ์ด ๋ฉ์๋๋ฅผ ํธ์ถํ๋ ค๊ณ ํ๋ฏ๋ก ํ์ฌ ์ฝ๋๋ ์ปดํ์ผ๋์ง ์์ต๋๋ค. ProductJpaRepository์@Query์ด๋ ธํ ์ด์ ์ผ๋ก ํด๋น ์ฟผ๋ฆฌ๋ฅผ ๊ตฌํํ๊ณ , ProductRepositoryImpl์์ ์ด๋ฅผ ์์ํด์ผ ํฉ๋๋ค.โ Skipped due to learnings
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 + ์ฌ์๋)์ ์ฌ์ฉํ๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java (1)
5-5: JPA ํ์์กด ์ฒ๋ฆฌ ํ์ธ
ZonedDateTime์ผ๋ก์ ๋ณ๊ฒฝ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ฉ๋์์ต๋๋ค. Spring Data JPA๋ZonedDateTimeํ๋ผ๋ฏธํฐ๋ฅผ ์ฟผ๋ฆฌ์์ ์๋์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค.๋ฐ์ดํฐ๋ฒ ์ด์ค ์ปฌ๋ผ ํ์ ์ด ํ์์กด ์ ๋ณด๋ฅผ ์ ์ฅํ ์ ์๋์ง(์:
TIMESTAMP WITH TIME ZONE) ๋๋ JPAAttributeConverter๊ฐ ์ ์ ํ ๊ตฌ์ฑ๋์ด ์๋์ง ํ์ธํ์ธ์.Also applies to: 12-12
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java (1)
6-6: ๊ตฌํ์ฒด ์ ๋ฐ์ดํธ ํ์ธ ์๋ฃ์ธํฐํ์ด์ค ๋ณ๊ฒฝ์ ๋ง์ถฐ ๊ตฌํ์ฒด๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฐ์ดํธ๋์์ต๋๋ค.
ZonedDateTimeํ๋ผ๋ฏธํฐ๊ฐ JPA repository๋ก ์ ํํ ์ ๋ฌ๋ฉ๋๋ค.Also applies to: 29-31
apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java (1)
3-3: ํ์์กด ์ธ์ datetime ๋ง์ด๊ทธ๋ ์ด์ ํ์ธ ํ์
LocalDateTime์์ZonedDateTime์ผ๋ก์ ๋ณ๊ฒฝ์ด ๊ฒฐ์ ๋๋ฉ์ธ์์ ์ผ๊ด๋๊ฒ ์ ์ฉ๋์์ต๋๋ค. ๋ค๋ง ๋ค์ ์ฌํญ๋ค์ ํ์ธํ์ธ์:
- ํ์์กด ์ ๋ต ๋ช ํํ: BaseEntity์์
ZonedDateTime.now()๋ฅผ ์ฌ์ฉํ๊ณ ์์ผ๋, ๋ถ์ฐ ์์คํ ์์ ์๊ตฌ๋๋ ์ผ๊ด๋ ํ์์กด ์ ๋ต(์: UTC)์ด ๋ช ์์ ์ผ๋ก ์ ์ฉ๋์ด ์๋์ง ํ์ธ ํ์- ๋ฐ์ดํฐ๋ฒ ์ด์ค persistence ๋์: Hibernate๊ฐ ZonedDateTime์ ํ์ค TIMESTAMP ์ปฌ๋ผ์ ์ ์ฅํ ๋ offset๋ง ๋ณด์กด๋๊ณ timezone ID๋ ์ ์ฅ๋์ง ์์ผ๋ฏ๋ก, ์ด๋ฌํ ๋์์ด ์๋๋ ๊ฒ์ธ์ง ํ์ธ
- ํ์์กด ์ ๋ณด ํ์์ฑ: ๋ฐ์ดํฐ๋ฒ ์ด์ค์ timezone ID๊ฐ ํ์ํ๋ค๋ฉด
TIMESTAMP WITH TIMEZONE์ปฌ๋ผ ์ฌ์ฉ ๋๋ ๋ณ๋ ํ๋ ์ถ๊ฐ ๊ฒํapps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (2)
24-46: ๋ ๊ฐ์ ๋ค๋ฅธ ์ฌ์๋ ๋ฉ์ปค๋์ฆ์ ์๋ ๋ช ํํ ํ์ํ์ฌ ์ฝ๋์๋ ๋ ๊ฐ์ง ๋ณ๊ฐ์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๊ฒฝ๋ก๊ฐ ์์ต๋๋ค:
- OutboxRelay (60์ด ์ฃผ๊ธฐ): OutboxEvent ํ ์ด๋ธ์ ๋ชจ๋ ๋ฏธ๋ฐํ ๋ฉ์์ง ์ฒ๋ฆฌ โ ํ์ค Outbox ํจํด
- DeadLetterQueueProcessor (300์ด ์ฃผ๊ธฐ): FailedEvent ํ ์ด๋ธ์ ์คํจํ ์ด๋ฒคํธ ์ฒ๋ฆฌ โ CouponUsageEventListener, LikeCountAggregateListener ๋ฑ์์ ํน์ ์์ธ(๋๊ด์ ์ ๊ธ, ๋น์ฆ๋์ค ๋ก์ง ์ค๋ฅ)๊ฐ ๋ฐ์ํ์ ๋๋ง ํธ์ถ
๋ ๋ฉ์ปค๋์ฆ์ ๊ฐ์ ์ด๋ฒคํธ๋ฅผ ์ค๋ณต ์ฒ๋ฆฌํ์ง ์์ผ๋, ์ ์ฌํ ์ฌ์๋ ๊ตฌ์กฐ(์ต๋ 5ํ, ๋์ผํ ๋ก์ง)๋ฅผ ๊ฐ์ง๊ณ ์์ต๋๋ค. ์ด ๋ ๊ณ์ธต์ ์ญํ ๋ถ๋ด์ด ๋ช ํํ์ง ์์ผ๋ฏ๋ก, ๋ค์์ ๊ฒํ ํ์ธ์:
- DeadLetterQueueProcessor๊ฐ ํ์ํ๊ฐ, ์๋๋ฉด OutboxRelay๋ง์ผ๋ก ์ถฉ๋ถํ๊ฐ?
- ๋ ๋ฉ์ปค๋์ฆ์ด ๋ชจ๋ ํ์ํ๋ค๋ฉด ๊ฐ๊ฐ์ ์ฑ ์์ ๋ช ํํ ํ๊ณ , ์ฝ๋ ์ฃผ์์ผ๋ก ์ค๊ณ ์๋๋ฅผ ๋ฌธ์ํํ ๊ฒ
24-39: applicationEventPublisher๋ Kafka๊ฐ ์๋ in-memory ์ด๋ฒคํธ ๊ธฐ๋ฐ์ด๋ฏ๋ก ํธ๋์ญ์ ๋๊ธฐํ ์ฐ๋ ค๋ ๋ถ์ ์ ํจ์ฝ๋ ๊ฒํ ์์ ์ ์ํ Kafka ํธ๋์ญ์ + DB ํธ๋์ญ์ ์์์ฑ ๋ฌธ์ ๋ ์ด ๊ตฌํ์ ์ ์ฉ๋์ง ์์ต๋๋ค.
eventPublisher๋ApplicationEventPublisher๋ก, Spring์ in-memory ๋๊ธฐ ์ด๋ฒคํธ ๋ฉ์ปค๋์ฆ์ด๋ฉฐ, ์ค์ Kafka ์ ์ก์@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)๋ฅผ ํตํด OutboxHandler์์ ์ด๋ฃจ์ด์ง๋๋ค.๋ค๋ง ๋ค์ ๊ฐ์ ์ด ๊ณ ๋ คํ ๊ฐ์น๊ฐ ์์ต๋๋ค:
- ์ค 38-39์ ๋ฐํ-์ญ์ ์์: ๋ฐํ ํ ์ญ์ ์คํจ ์ FailedEvent ๋ ์ฝ๋๊ฐ ๋จ์ ๋ค์ ์ฌ์๋์์ ์ค๋ณต ๋ฐํ๋ ์ ์์ต๋๋ค. ํธ๋์ญ์ ๋ด์์ ์ญ์ ๋ณด์ฅ ๋๋ ์ญ์ ๋จผ์ ์ฒ๋ฆฌ๋ฅผ ๊ฒํ ํ์ธ์.
- ๊ด๋ฒ์ํ ์์ธ ์ฒ๋ฆฌ: ๋ชจ๋
Exception์ ๋์ผํ๊ฒ ์ทจ๊ธํ๊ณ ์์ผ๋ฉฐ, ์ค์ ์์ธ ๋ฉ์์ง ๋ก๊น ์ด ์์ต๋๋ค. ๋ณต๊ตฌ ๋ถ๊ฐ๋ฅํ ์ค๋ฅ(ClassNotFoundException, JSON ํ์ฑ ์ค๋ฅ)์ ์ผ์์ ์ค๋ฅ๋ฅผ ๊ตฌ๋ถํ๋ ๊ฒ์ด ์ข์ต๋๋ค.Likely an incorrect or invalid review comment.
apps/commerce-api/build.gradle.kts (1)
5-5: LGTM!Kafka ๋ชจ๋ ์์กด์ฑ ์ถ๊ฐ๊ฐ ์ ์ ํ๋ฉฐ, ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ ๊ตฌํ์ ์ํ ์ฌ๋ฐ๋ฅธ ํตํฉ์ ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java (1)
20-22: AFTER_COMMIT ๋ฆฌ์ค๋์ ํธ๋์ญ์ ์ ํ ์ ๋ต์ ๊ฒํ ํ์ธ์.
REQUIRES_NEW์ ํ ์์ฑ์ด ์ถ๊ฐ๋์ด ์ฟ ํฐ ํ์ ์ด ๋ ๋ฆฝ์ ์ธ ํธ๋์ญ์ ์์ ์คํ๋ฉ๋๋ค. ์ด๋ ๋ถ๋ชจ ํธ๋์ญ์ (์ฃผ๋ฌธ/๊ฒฐ์ )์ด ์ด๋ฏธ ์ปค๋ฐ๋ ํ ์คํ๋๋ฏ๋ก, ์ฟ ํฐ ํ์ ์คํจ ์ ์ฃผ๋ฌธ/๊ฒฐ์ ๋ ์ฑ๊ณต ์ํ๋ก ๋จ๊ฒ ๋ฉ๋๋ค.ํ์ฌ ๊ตฌ์กฐ:
- ์ฃผ๋ฌธ/๊ฒฐ์ ํธ๋์ญ์ ์ปค๋ฐ โ AFTER_COMMIT ์ด๋ฒคํธ ๋ฐํ
- ์ ํธ๋์ญ์ ์์ ์ฟ ํฐ ํ์ ์๋
- ์คํจ ์
FailedEventStore.scheduleRetry๋ก ์ฌ์๋ ์์ฝ์ด ํจํด์ ์ต์ข ์ผ๊ด์ฑ(eventual consistency)์ ์ ๊ณตํ์ง๋ง, ์ฟ ํฐ ํ์ ๊ณผ ๊ฒฐ์ ๊ฐ ์ผ์์ ๋ถ์ผ์น๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค. ๋น์ฆ๋์ค ์๊ตฌ์ฌํญ์ ๋ฐ๋ผ ์ด๋ฌํ ํธ๋ ์ด๋์คํ๊ฐ ํ์ฉ๋๋์ง ํ์ธํ์ธ์.
apps/commerce-api/src/main/resources/application.yml (1)
26-26: LGTM!Kafka ์ค์ ํ์ผ ์ํฌํธ๊ฐ ์ถ๊ฐ๋์ด ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ ํตํฉ์ด ์๋ฃ๋์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
35-36:getProductInfo()๋ฉ์๋์@Transactional์ด๋ ธํ ์ด์ ์ถ๊ฐ ํ์
ProductViewEvent๋ฐํ ๋ก์ง์ดProductEventOutboxHandler์ Transactional Outbox ํจํด์ผ๋ก ์ ์ ํ ํตํฉ๋์ด ์์ต๋๋ค. ๊ทธ๋ฌ๋ ํ์ฌgetProductInfo()๋ฉ์๋๋@Transactional์ด๋ ธํ ์ด์ ์ด ์์ด ๋ค์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค:
- TransactionalEventListener ์๋ ๋ถ๊ฐ: ํธ๋ค๋ฌ์
@TransactionalEventListener(phase = AFTER_COMMIT)๋ ํธ๋์ญ์ ์ปจํ ์คํธ๊ฐ ํ์ํ๋ฐ, ๋ฉ์๋์ ํธ๋์ญ์ ์ด ์์ผ๋ฉด AFTER_COMMIT ์์ ์ด ๋ช ํํ์ง ์์ต๋๋ค.- ์ด๋ฒคํธ ์์ ๋ณด์ฅ ๋ถ๊ฐ: ํธ๋์ญ์ ์ด ์์ผ๋ฉด ์ด๋ฒคํธ๊ฐ ๋ฐ์ดํฐ ๋ณ๊ฒฝ ์ ์ ๋ฐํ๋ ์ ์์ต๋๋ค.
๋ฉ์๋์
@Transactional์ด๋ ธํ ์ด์ ์ ์ถ๊ฐํ์ฌ ํธ๋์ญ์ ๊ฒฝ๊ณ๋ฅผ ๋ช ํํ ์ ์ํ์ธ์.โ Skipped due to learnings
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 + ์ฌ์๋)์ ์ฌ์ฉํ๋ค.apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)
3-4: ํจํค์ง ๊ตฌ์กฐ ๊ฐ์ ์ด ์ ์ ํฉ๋๋ค.์ด๋ฒคํธ ํด๋์ค๋ค์ ๋ณ๋์
event์๋ธํจํค์ง๋ก ๋ถ๋ฆฌํ์ฌ ์ฝ๋ ๊ตฌ์กฐ๊ฐ ๋ช ํํด์ก์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java (1)
1-10: ๋ฉฑ๋ฑ์ฑ ๊ตฌํ์ ์ํ ํ์ค ๋ฆฌํฌ์งํ ๋ฆฌ์ ๋๋ค.
EventHandled์ํฐํฐ์ ๋ฐ์ดํฐ ์ก์ธ์ค๋ฅผ ์ํ ํ์ค JPA ๋ฆฌํฌ์งํ ๋ฆฌ๋ก, ์ค๋ณต ์ด๋ฒคํธ ํํฐ๋ง์ ์ฌ์ฉ๋ฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java (1)
1-5: ์์๋ฐ์ค ํจํด์ ์ํ ๊ด๋ฆฌ๊ฐ ์ ์ ํฉ๋๋ค.INIT, PUBLISHED, FAILED ์ธ ๊ฐ์ง ์ํ๋ก ์์๋ฐ์ค ์ด๋ฒคํธ์ ๋ผ์ดํ์ฌ์ดํด์ ๋ช ํํ๊ฒ ํํํ๊ณ ์์ต๋๋ค.
modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java (1)
1-9: ์ด๋ฒคํธ ํ์ด๋ก๋ ์ ์๊ฐ ๋ช ํํฉ๋๋ค.Java record๋ฅผ ์ฌ์ฉํ ๋ถ๋ณ ์ด๋ฒคํธ ๊ตฌ์กฐ๋ก,
eventId,productId,currentLikeCountํ๋๊ฐ ๋ช ํํ๊ฒ ์ ์๋์ด ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java (1)
4-4: ์บ์ ํธ๋ค๋ฌ ํจํค์ง ์ด๋์ด ๋ฐ์๋์์ต๋๋ค.
RedisCacheHandler์ ํจํค์ง๊ฐsupport.cache์์core.cache๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java (1)
1-10: ๋ฉํธ๋ฆญ ์ง๊ณ๋ฅผ ์ํ ํ์ค ๋ฆฌํฌ์งํ ๋ฆฌ์ ๋๋ค.
ProductMetrics์ํฐํฐ์ CRUD ์์ ์ ์ํ ํ์ค JPA ๋ฆฌํฌ์งํ ๋ฆฌ๋ก, ์ ํ ๋ฉํธ๋ฆญ ์๋น์ค์์ ํ์ฉ๋ฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java (1)
1-1: ํจํค์ง ์ฌ๊ตฌ์ฑ์ด ์ ์ ํฉ๋๋ค.์ด๋ฒคํธ ํด๋์ค๋ฅผ
event์๋ธํจํค์ง๋ก ์ด๋ํ์ฌ ์ฝ๋ ๊ตฌ์กฐ๊ฐ ๊ฐ์ ๋์์ต๋๋ค. ๋ก์ง์ ๋ณ๊ฒฝ๋์ง ์์์ต๋๋ค.apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java (1)
40-63: ์ด ํ ์คํธ๋ ์ค์ ๋ก ๋ฉฑ๋ฑ์ฑ์ ์ฌ๋ฐ๋ฅด๊ฒ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค.ProductStockEvent.of()๋ฉ์๋๋ ํธ์ถ๋ ๋๋ง๋ค ์๋ก์ด UUID๋ฅผ ์์ฑํ์ง๋ง, ํ ์คํธ ์ฝ๋๋ ์ฒซ ๋ฒ์งธ ์ค์์firstEvent์ธ์คํด์ค๋ฅผ ํ ๋ฒ๋ง ์์ฑํ ํ ๋์ผํ ์ธ์คํด์ค๋ฅผ ๋ ๋ฒ ์ฌ์ฌ์ฉํฉ๋๋ค. ๋ฐ๋ผ์eventId๋ ๋ ํธ์ถ์์ ๋์ผํ๋ฉฐ, ํ ์คํธ๋ ์๋ํ ๋๋ก ์ค๋ณต ์ด๋ฒคํธ์ ๋ฉฑ๋ฑ์ฑ์ ๊ฒ์ฆํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
11-25: ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ๋ฅผ ์ํ ์ํฐํฐ ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.
eventId๋ฅผ ๊ธฐ๋ณธ ํค๋ก ์ฌ์ฉํ์ฌ ์ค๋ณต ์ด๋ฒคํธ ํํฐ๋ง์ ํจ๊ณผ์ ์ผ๋ก ๊ตฌํํ์ต๋๋ค. ๊ธฐ์กด ์ฝ๋๋ฒ ์ด์ค์OutboxEvent,ProductMetrics์ ๋์ผํ๊ฒLocalDateTime์ ์ฌ์ฉํ์ฌ ์ผ๊ด์ฑ์ ์ ์งํ๊ณ ์์ต๋๋ค.modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java (1)
5-21: ์ด๋ฒคํธ ๋ ์ฝ๋ ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.Kafka ์ง๋ ฌํ์ ์ ํฉํ
longํ์ ์ ํ์์คํฌํ์ UUID ๊ธฐ๋ฐeventId์์ฑ ํจํด์ด ๋ค๋ฅธ ์ด๋ฒคํธ(LikeCreatedEvent,ProductViewEvent)์ ์ผ๊ด์ฑ์ ์ ์งํ๊ณ ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java (1)
27-27:getStock()ํธ์ถ ์์ ์ ๋ํ ๊ณ ๋ ค๊ฐ ํ์ํฉ๋๋ค.
AFTER_COMMIT์์ ์getStock()์ ํธ์ถํ๋ฏ๋ก, ์ฃผ๋ฌธ ์ฒ๋ฆฌ ์์ ๊ณผ ์ฌ๊ณ ์กฐํ ์์ ์ฌ์ด์ ๋ค๋ฅธ ์ฃผ๋ฌธ์ ์ํด ์ฌ๊ณ ๊ฐ ๋ณ๊ฒฝ๋ ์ ์์ต๋๋ค. ๋ฉํธ๋ฆญ ์ฉ๋๋ผ๋ฉด ํ์ฉ ๊ฐ๋ฅํ์ง๋ง, ์ ํํ ์ฌ๊ณ ์ค๋ ์ท์ด ํ์ํ๋ค๋ฉด ์ฃผ๋ฌธ ์ฒ๋ฆฌ ํธ๋์ญ์ ๋ด์์ ์บก์ฒํด์ผ ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java (1)
6-21: ์ด๋ฒคํธ ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.
DomainEvent์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ์ฌ ํ์ ์์ ์ฑ์ ํ๋ณดํ๊ณ , ํฉํ ๋ฆฌ ๋ฉ์๋ ํจํด์ด ๋ค๋ฅธ ์ด๋ฒคํธ๋ค๊ณผ ์ผ๊ด์ฑ์ ์ ์งํฉ๋๋ค.productIdํ๋ผ๋ฏธํฐ๊ฐLong์์long์ผ๋ก ์ธ๋ฐ์ฑ๋๋ ๋ถ๋ถ์ null ์ ๋ ฅ ์ fail-fast ๋์์ ์ ๊ณตํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java (1)
10-19: Repository ๊ตฌํ์ด ์ฌ๋ฐ๋ฆSpring Data JPA์ ์ฟผ๋ฆฌ ๋ฉ์๋ ๋ค์ด๋ฐ ๊ท์น์ ์ ํํ๊ฒ ๋ฐ๋ฅด๊ณ ์์ผ๋ฉฐ, outbox ํจํด์ ์๊ตฌ์ฌํญ(eventId ์กฐํ, ์ฌ์๋ ๋์ ์ด๋ฒคํธ ์กฐํ)์ ์ถฉ์กฑํฉ๋๋ค.
findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc๋ฉ์๋๋ช ์ด ๊ธธ์ง๋ง, Spring Data์ ์๋ ์ฟผ๋ฆฌ ์์ฑ ๊ท์น์ ๋ฐ๋ฅธ ๊ฒ์ผ๋ก ๋ช ํํ๊ณ ํ์ ์์ ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)
66-75: ์ด๋ฒคํธ ํ์ด๋ก๋ ํ์ฅ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ฉ๋จ
OrderCreatedEvent.of()๋ฅผ ์ฌ์ฉํ์ฌorderItems,cardNo,couponId๋ฑ ์ถ๊ฐ ์ปจํ ์คํธ๋ฅผ ํฌํจํ๋๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค. ์ด๋ ๋ค์ด์คํธ๋ฆผ ์ด๋ฒคํธ ์๋น์๊ฐ ๋ ํ๋ถํ ์ ๋ณด๋ฅผ ํ์ฉํ ์ ์๊ฒ ํฉ๋๋ค.OrderCreatedEvent์ ๋ชจ๋ ํ๋(String, Long, List, PaymentType enum)๋ Spring Kafka์ JsonSerializer์ Jackson ObjectMapper๋ฅผ ํตํด ์ง๋ ฌํ ๊ฐ๋ฅํ๋ฉฐ, JacksonConfig์์
READ_ENUMS_USING_TO_STRING๋ฐNON_NULLํฌํจ ์ค์ ์ด ์ ์ ํ ๊ตฌ์ฑ๋์ด ์์ด Kafka๋ก์ ์ง๋ ฌํ ๋ฐ ์ ์ก์ด ๋ฌธ์ ์์ด ์๋ํฉ๋๋ค.
| try { | ||
| Class<?> eventClass = Class.forName(outbox.getEventType()); | ||
| Object originalEvent = objectMapper.readValue(outbox.getPayload(), eventClass); | ||
|
|
||
| eventPublisher.publishEvent(originalEvent); | ||
|
|
||
| log.info("[Outbox Relay] ์ด๋ฒคํธ ์ฌ๋ฐํ ์ฑ๊ณต: {}", outbox.getEventId()); | ||
| outbox.markPublished(); | ||
|
|
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.
์ฌ๋ฐํ ๋ก์ง์ด ๋ฌดํ ๋ฃจํ๋ฅผ ์ ๋ฐํ ์ ์์
ApplicationEventPublisher.publishEvent()๋ก ์ญ์ง๋ ฌํ๋ ์ด๋ฒคํธ๋ฅผ ์ฌ๋ฐํํ๋ฉด, ์๋ณธ @TransactionalEventListener ํธ๋ค๋ฌ๋ค(์: ProductEventOutboxHandler, LikeEventOutboxHandler)์ด ๋ค์ ํธ๋ฆฌ๊ฑฐ๋ฉ๋๋ค. ์ด๋:
- ๋์ผ ์ด๋ฒคํธ๊ฐ outbox ํ ์ด๋ธ์ ์ค๋ณต ์ ์ฅ๋ฉ๋๋ค
- Kafka๋ก ์ค๋ณต ๋ฐํ์ด ๋ฐ์ํฉ๋๋ค
- ์ฌ์๋ ๋ก์ง์ ๋ณธ๋ ๋ชฉ์ (๋ฐํ ์คํจ ๋ณต๊ตฌ)์ด ๋ฌ์ฑ๋์ง ์์ต๋๋ค
OutboxRelay์ ๋ชฉ์ ์ outbox์ ์ ์ฅ๋์์ผ๋ Kafka ๋ฐํ์ ์คํจํ ์ด๋ฒคํธ๋ฅผ ์ฌ์ ์กํ๋ ๊ฒ์ ๋๋ค. ๋ฐ๋ผ์ ApplicationEventPublisher๊ฐ ์๋๋ผ ์ง์ KafkaTemplate์ ์ฌ์ฉํ์ฌ Kafka๋ก๋ง ์ฌ๋ฐํํด์ผ ํฉ๋๋ค.
๐ ๊ถ์ฅ ์์ ๋ฐฉ์
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxRelay {
private final OutboxRepository outboxRepository;
- private final ApplicationEventPublisher eventPublisher;
+ private final KafkaTemplate<Object, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
@Scheduled(fixedDelay = 60000)
@Transactional
public void resendPendingEvents() {
List<OutboxEvent> pendingEvents = outboxRepository.findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc(
List.of(OutboxStatus.INIT, OutboxStatus.FAILED), 5
);
if (pendingEvents.isEmpty()) return;
for (OutboxEvent outbox : pendingEvents) {
try {
Class<?> eventClass = Class.forName(outbox.getEventType());
Object originalEvent = objectMapper.readValue(outbox.getPayload(), eventClass);
- eventPublisher.publishEvent(originalEvent);
+ // Determine topic and key based on event type/aggregate
+ String topic = determineTopicFromAggregateType(outbox.getAggregateType());
+ kafkaTemplate.send(topic, outbox.getAggregateId(), originalEvent)
+ .whenComplete((result, ex) -> {
+ if (ex == null) {
+ outbox.markPublished();
+ } else {
+ log.error("[Outbox Relay] Kafka ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId(), ex);
+ outbox.markFailed();
+ }
+ outboxRepository.save(outbox);
+ });
- log.info("[Outbox Relay] ์ด๋ฒคํธ ์ฌ๋ฐํ ์ฑ๊ณต: {}", outbox.getEventId());
- outbox.markPublished();
} catch (Exception e) {
log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId(), e);
outbox.markFailed();
+ outboxRepository.save(outbox);
}
}
}
+
+ private String determineTopicFromAggregateType(String aggregateType) {
+ return switch (aggregateType) {
+ case "PRODUCT_VIEW", "PRODUCT_METRICS" -> "catalog-events";
+ case "ORDER" -> "order-events";
+ default -> throw new IllegalArgumentException("Unknown aggregate type: " + aggregateType);
+ };
+ }
}Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
around lines 34-42, remove the use of
ApplicationEventPublisher.publishEvent(...) which re-triggers
@TransactionalEventListener handlers and causes duplicate outbox/Kafka writes;
instead use the project's KafkaTemplate to publish directly to the appropriate
Kafka topic using the outbox payload (or topic name stored on the outbox), wait
for successful send/ack (or handle the Future callback) and only call
outbox.markPublished() after the send succeeds; also catch and log send failures
(and avoid re-publishing via ApplicationEventPublisher) so retries are handled
without invoking application event listeners.
| } catch (Exception e) { | ||
| log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId()); | ||
| outbox.markFailed(); | ||
| } |
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.
์์ธ ์ ๋ณด๊ฐ ๋ก๊ทธ์ ๋๋ฝ๋จ
์ฌ๋ฐํ ์คํจ ์ ์์ธ ๊ฐ์ฒด๋ฅผ ๋ก๊น ํ์ง ์์ ์คํจ ์์ธ ํ์ ์ด ์ด๋ ต์ต๋๋ค.
๐ ์์ ์ ์
} catch (Exception e) {
- log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId());
+ log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId(), e);
outbox.markFailed();
}๐ 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 (Exception e) { | |
| log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId()); | |
| outbox.markFailed(); | |
| } | |
| } catch (Exception e) { | |
| log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}", outbox.getEventId(), e); | |
| outbox.markFailed(); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
around lines 43 to 46, the catch block logs the eventId but omits the exception
details; update the log.error call to include the caught exception so stacktrace
and message are recorded (e.g., log.error("[Outbox Relay] ์ฌ๋ฐํ ์คํจ: {}",
outbox.getEventId(), e)); keep calling outbox.markFailed() after logging.
| public record OrderCreatedEvent( | ||
| String eventId, | ||
| Long orderId, | ||
| Long userId, | ||
| List<OrderItemInfo> items, | ||
| long finalAmount, | ||
| PaymentType paymentType, | ||
| String cardType, | ||
| String cardNo, | ||
| Long couponId | ||
| ) { |
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 . -type f -name "*.java" | grep -i order | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1779
๐ Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1600
๐ Script executed:
rg -n "OrderCreatedEvent" -A 3 -B 1 --type java | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 5711
๐ Script executed:
rg -n "log\." --type java | grep -i "ordercreated\|cardno" -C 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
rg -n "KafkaTemplate\|kafkaTemplate\|send" --type java -C 3 | grep -A 5 -B 5 "OrderCreatedEvent" | head -40Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
rg -n "KafkaTemplate\|kafkaTemplate" --type java -A 5 -B 2 | head -60Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1255
๐ Script executed:
rg -n "serializer\|Serializer" --type java | grep -i "order\|kafka"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
fd "application\\.yml\|application\\.yaml" | xargs cat -nRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
rg -n "@JsonIgnore\|@Transient\|mask\|encrypt" --type java | grep -i "card\|sensitive"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๋ฏผ๊ฐํ ๊ฒฐ์ ์ ๋ณด(cardNo) ๋ ธ์ถ - ์ฆ์ ์์ ํ์
OrderCreatedEvent์ cardNo ํ๋๊ฐ Kafka ๋ฉ์์ง๋ก ๊ทธ๋๋ก ๋ฐํ๋๊ณ ์์ต๋๋ค. OrderEventOutboxHandler.java 18๋ฒ์งธ ์ค์์ ์ ์ฒด ์ด๋ฒคํธ ๊ฐ์ฒด๋ฅผ ๋ง์คํน ์์ด ์ ์กํ๋ฏ๋ก, ์นด๋ ๋ฒํธ๊ฐ ํ๋ฌธ์ผ๋ก Kafka์ ์ ์ฅ๋๊ณ ์์ต๋๋ค.
์ด๋ PCI-DSS ๊ท์ ์๋ฐ์ ๋๋ค. ์ฆ์ ๋ค์์ ์์ ํ์ธ์:
cardNo๋ฅผOrderCreatedEvent์์ ์ ๊ฑฐํ๊ฑฐ๋ ๋ณ๋์ ๋ณด์ ์ด๋ฒคํธ๋ก ๋ถ๋ฆฌ- ๊ฒฐ์ ์ ๋ณด๋
PaymentRequestedEvent๋ฑ ๋ณ๋ ๋๋ฉ์ธ ์ด๋ฒคํธ๋ก๋ง ์ฒ๋ฆฌ - ๋ฏผ๊ฐํ ๊ฒฐ์ ๋ฐ์ดํฐ๋ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ์์ ์ ์ธ
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java
around lines 9-19, the OrderCreatedEvent currently contains the sensitive cardNo
field which is being published to Kafka unmasked; remove cardNo from this event
(or replace it with non-sensitive data such as token or cardLast4) and move any
full payment details into a separate secure payment-only event (e.g.,
PaymentRequestedEvent) or out-of-band secure store; update all
producers/handlers (including OrderEventOutboxHandler) to stop
serializing/publishing cardNo and to use the new event or token-only fields
instead so no raw PAN is emitted to Kafka.
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handle(OrderCreatedEvent event) { | ||
| kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event) | ||
| .whenComplete((result, ex) -> { | ||
| if (ex == null) { | ||
| outboxService.markPublished(event.eventId()); | ||
| } else { | ||
| outboxService.markFailed(event.eventId()); | ||
| } | ||
| }); | ||
| } |
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.
Outbox ํจํด์ด ๋ถ์์ ํ๊ฒ ๊ตฌํ๋จ
์ด ํธ๋ค๋ฌ๋ outboxService.saveEvent() ํธ์ถ์ ๋๋ฝํ์ฌ ๋ค๋ฅธ ํธ๋ค๋ฌ๋ค(LikeEventOutboxHandler, ProductEventOutboxHandler)๊ณผ ์ผ๊ด์ฑ์ด ์์ต๋๋ค:
ํ์ฌ ๊ตฌํ:
public void handle(OrderCreatedEvent event) {
kafkaTemplate.send("order-events", ...) // saveEvent() ํธ์ถ ์์
.whenComplete(...);
}๋ค๋ฅธ ํธ๋ค๋ฌ๋ค์ ํจํด (LikeEventOutboxHandler):
public void handle(LikeCreatedEvent event) {
outboxService.saveEvent("PRODUCT_METRICS", ..., event); // โ outbox์ ๋จผ์ ์ ์ฅ
kafkaTemplate.send("catalog-events", ...)
.whenComplete(...);
}๋ฌธ์ ์ :
- Outbox ํ
์ด๋ธ์ ์ด๋ฒคํธ๊ฐ ์ ์ฅ๋์ง ์์ Kafka ๋ฐํ ์คํจ ์
OutboxRelay๊ฐ ์ฌ์๋ํ ์ ์์ต๋๋ค - At Least Once ์ ๋ฌ ๋ณด์ฅ์ด ๊นจ์ง๋๋ค
- ํธ๋์ญ์ ์ปค๋ฐ๊ณผ ๋ฉ์์ง ๋ฐํ ๊ฐ์ ์์์ฑ์ด ๋ณด์ฅ๋์ง ์์ต๋๋ค
๐ ์์ ์ ์
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
+ outboxService.saveEvent("ORDER", String.valueOf(event.orderId()), event);
+
kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event)
.whenComplete((result, ex) -> {๋จ, ์ด ์์ ๋ ProductEventOutboxHandler์์ ์ง์ ํ ํธ๋์ญ์
์ ํ ๋ฌธ์ ๋ฅผ ๋์ผํ๊ฒ ๊ฐ๊ณ ์์ต๋๋ค. ๊ทผ๋ณธ์ ์ธ ํด๊ฒฐ์ ์ํด์๋ outbox ์ ์ฅ์ AFTER_COMMIT ์ด์ ์ ์ํํด์ผ ํฉ๋๋ค.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java
around lines 16-26, the handler omits persisting the event to the outbox; add a
call to outboxService.saveEvent(...) at the start of handle(OrderCreatedEvent)
(before kafkaTemplate.send) using the same outbox category/topic key pattern as
other handlers (e.g. "ORDER_EVENTS"), include the event payload and id
(event.eventId()) so the outbox row exists for retries, then keep the existing
kafkaTemplate.send(...).whenComplete(...) logic and continue to call
outboxService.markPublished(event.eventId()) or
outboxService.markFailed(event.eventId()) in the completion callback; ensure the
saveEvent call runs inside the transaction (i.e., before AFTER_COMMIT send) to
mirror other handlers.
| public void handleOrderCreated(OrderCreatedEvent event) { | ||
|
|
||
| event.items().forEach(item -> { | ||
|
|
||
| int currentStock = productService.getStock(item.productId()); | ||
| ProductStockEvent kafkaEvent = ProductStockEvent.of( | ||
| item.productId(), | ||
| item.quantity(), | ||
| currentStock | ||
| ); | ||
|
|
||
| outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent); | ||
| eventPublisher.publishEvent(kafkaEvent); | ||
| }); | ||
| } |
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.
์๋ฌ ์ฒ๋ฆฌ ๋ก์ง์ด ๋๋ฝ๋์์ต๋๋ค.
LikeCountAggregateListener๋ try-catch์ failedEventStore.scheduleRetry()๋ฅผ ์ฌ์ฉํ์ฌ ์คํจํ ์ด๋ฒคํธ๋ฅผ ์ฒ๋ฆฌํ์ง๋ง, ์ด ๋ฆฌ์ค๋์๋ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์์ต๋๋ค. forEach ๋ด์์ ์์ธ ๋ฐ์ ์ ํ์ ์์ดํ
์ฒ๋ฆฌ๊ฐ ์ค๋จ๋๊ณ , ์คํจํ ์ด๋ฒคํธ๊ฐ ์ฌ์๋๋์ง ์์ต๋๋ค.
๐ ์ ์๋ ์์
+ private final FailedEventStore failedEventStore;
+
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
event.items().forEach(item -> {
-
- int currentStock = productService.getStock(item.productId());
- ProductStockEvent kafkaEvent = ProductStockEvent.of(
- item.productId(),
- item.quantity(),
- currentStock
- );
-
- outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent);
- eventPublisher.publishEvent(kafkaEvent);
+ try {
+ int currentStock = productService.getStock(item.productId());
+ ProductStockEvent kafkaEvent = ProductStockEvent.of(
+ item.productId(),
+ item.quantity(),
+ currentStock
+ );
+
+ outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent);
+ eventPublisher.publishEvent(kafkaEvent);
+ } catch (Exception e) {
+ failedEventStore.scheduleRetry(event, "Stock event failed: " + e.getMessage());
+ }
});
}Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
around lines 23-37, the per-item processing lacks error handling so an exception
in one item will stop the loop and no retry is scheduled; wrap the body of the
forEach for each item in a try-catch, move saving/publishing inside the try, and
in the catch call the failedEventStore.scheduleRetry(...) method (matching the
pattern/signature used in LikeCountAggregateListener), log the error with
context (productId, quantity) and continue so remaining items are processed.
| @KafkaListener( | ||
| topics = "catalog-events", | ||
| groupId = "metrics-group" | ||
| ) | ||
| public void consumeLikeCount(ConsumerRecord<String, LikeCountEvent> record, Acknowledgment ack) { | ||
| try { | ||
| metricsService.processLikeCountEvent(record.value()); | ||
| ack.acknowledge(); | ||
| } catch (Exception e) { | ||
| log.error("์ข์์ ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ์คํจ: {}", e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| @KafkaListener( | ||
| topics = "catalog-events", | ||
| groupId = "metrics-group" | ||
| ) | ||
| public void consumeProductView(ProductViewEvent event, Acknowledgment ack) { | ||
| try { | ||
| metricsService.processProductViewEvent(event); | ||
| ack.acknowledge(); | ||
| } catch (Exception e) { | ||
| log.error("์กฐํ์ ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ์คํจ: {}", event.eventId(), e); | ||
| } | ||
| } | ||
|
|
||
| @KafkaListener( | ||
| topics = "catalog-events", | ||
| groupId = "metrics-group" | ||
| ) | ||
| public void consumeSalesCount(ProductStockEvent event, Acknowledgment ack) { | ||
| try { | ||
| metricsService.processSalesCountEvent(event); | ||
| ack.acknowledge(); | ||
| } catch (Exception e) { | ||
| log.error("ํ๋งค๋ ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ์คํจ: {}", event.eventId(), 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.
๐งฉ Analysis chain
๐ Script executed:
# Find all references to catalog-events topic
rg -n "catalog-events" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 848
๐ Script executed:
# Find KafkaTemplate usage and how events are being sent
rg -n "kafkaTemplate" --type java -A 5 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 7095
๐ Script executed:
# Find MetricsEventConsumer and related service class
fd -t f "MetricsEventConsumer\|MetricsService" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 259
๐ Script executed:
# Check for EventInboxAspect and @InboxEvent usage
rg -n "EventInboxAspect\|@InboxEvent" --type java -B 2 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find the metricsService implementation
fd -t f "metricsService\|MetricsService" -g "*.java"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 181
๐ Script executed:
# Find MetricsService implementation
rg -n "class MetricsService" --type java -A 30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find event class definitions
rg -n "class LikeCountEvent\|class ProductViewEvent\|class ProductStockEvent" --type java -B 2 -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if there's type information in message deserialization config
rg -n "ObjectMapper\|TypeIdStrategy\|ClassPathXmlApplicationContext" --type java modules/kafkaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Look for test cases showing actual behavior
fd -type f -name "*MetricsEventConsumer*Test*"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
๐ Script executed:
# Check if there's any message filtering or type validation in consumers
rg -n "instanceof\|getClass\|getLikeCount\|getProductView\|getSalesCount" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find MetricsService class
rg -n "class MetricsService" -A 30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find event class definitions
rg -n "record LikeCountEvent\|class LikeCountEvent\|record ProductViewEvent\|class ProductViewEvent" -B 2 -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Look at the full MetricsEventConsumer file
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2235
๐ Script executed:
# Check how Spring deserializes messages - look for ObjectMapper or Kafka config
rg -n "ObjectMapper\|KafkaListenerContainerFactory\|MessageConverter" modules/kafka/srcRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Search for any event type definitions
find . -name "*Event.java" -o -name "*Event.kt" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1051
๐ Script executed:
# Check the Kafka config for message converter settings
cat -n modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4441
๐ Script executed:
# Look at the event class definitions
cat -n modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 279
๐ Script executed:
# Check ProductViewEvent
cat -n modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 555
๐ Script executed:
# Check ProductStockEvent
cat -n modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 700
๐ Script executed:
# Check for EventInboxAspect usage and @InboxEvent in service methods
rg -n "EventInboxAspect\|@InboxEvent\|ProductMetricsService" -B 2 -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find ProductMetricsService implementation
find . -name "ProductMetricsService.java" -o -name "*MetricsService*.java" | grep -v testRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 180
๐ Script executed:
# Check how ProductStockEvent is produced
rg -n "ProductStockEvent" -B 5 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 13309
๐ Script executed:
# View the full ProductMetricsService file
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2982
๐ Script executed:
# Check if there's any @InboxEvent or EventInboxAspect usage anywhere
rg -n "@InboxEvent\|EventInboxAspect" -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check IncreaseProductViewKafkaConsumer to understand the pattern mentioned in learning
cat -n apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 245
๐ Script executed:
# Find the IncreaseProductViewKafkaConsumer file
find . -name "IncreaseProductViewKafkaConsumer.java"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Look for any other consumer classes to understand the pattern
find . -name "*KafkaConsumer.java" -type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 181
๐ Script executed:
# Check if ProductStockEvent is actually being sent to catalog-events
rg -n "send.*ProductStockEvent\|ProductStockEvent.*send" -B 5 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check what event type is actually sent by LikeEventOutboxHandler
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1369
๐ Script executed:
# Check what LikeCreatedEvent looks like
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 712
๐ Script executed:
# Check if ProductStockEvent is actually sent to Kafka
rg -n "kafkaTemplate.send.*ProductStockEvent\|ProductStockEvent.*kafkaTemplate" -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check OrderSalesAggregateListener to see if ProductStockEvent is sent
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1661
๐ Script executed:
# Check if LikeCountEvent and LikeCreatedEvent are related
rg -n "LikeCountEvent\|LikeCreatedEvent" -B 2 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check ProductEventOutboxHandler to see what it sends
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1501
๐ Script executed:
# Look for any event conversion or mapping
rg -n "LikeCountEvent\|ProductViewEvent" apps/commerce-api -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Search for who actually sends ProductStockEvent to Kafka
rg -n "ProductStockEvent" -B 5 -A 5 | grep -A 10 -B 10 "kafkaTemplate\|send"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if there's a listener for ProductStockEvent that sends it to Kafka
rg -n "@EventListener\|@TransactionalEventListener" apps/commerce-api -A 20 | grep -A 20 "ProductStockEvent"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Verify the actual event type mismatch - check what LikeEventOutboxHandler sends
rg -n "send.*LikeCreatedEvent\|LikeCreatedEvent.*send" -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if there's any conversion from LikeCreatedEvent to LikeCountEvent
rg -n "new LikeCountEvent\|LikeCountEvent.of\|LikeCountEvent.from" -B 3 -A 3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if OrderSalesAggregateListener or any other handler sends ProductStockEvent to Kafka
rg -n "kafkaTemplate.*send\|KafkaTemplate" apps/commerce-api -B 5 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check all TransactionalEventListener handlers that deal with events
rg -n "@TransactionalEventListener" apps/commerce-api -A 25Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 21153
๐ Script executed:
# Verify what events are actually published to catalog-events topic
rg -n "topics = \"catalog-events\"" -B 10 -A 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4907
๐ Script executed:
# Check if there's any mapping/conversion between event types
rg -n "LikeCreatedEvent\|LikeCountEvent\|ProductViewEvent\|ProductStockEvent" apps/commerce-api/src/main/java/com/loopers/application -lRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Confirm what LikeCountAggregateListener publishes
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2200
๐ Script executed:
# Check if there's a listener/handler for LikeCountEvent that sends it to Kafka
rg -n "LikeCountEvent" -B 5 -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 13453
๐ Script executed:
# Verify if OrderSalesAggregateListener sends ProductStockEvent to Kafka anywhere
rg -n "OrderSalesAggregateListener\|ProductStockEvent" apps/commerce-api/src/main/java/com/loopers/application/order -B 3 -A 15 | grep -A 15 "kafkaTemplate\|send"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Final verification - check if there's any listener that converts LikeCountEvent to send to Kafka
rg -n "@TransactionalEventListener\|@EventListener" apps/commerce-api -A 30 | grep -B 5 -A 25 "LikeCountEvent"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Confirm ProductStockEvent is never sent to Kafka
rg -n "kafkaTemplate.*send.*ProductStockEvent\|ProductStockEvent.*kafkaTemplate" -rRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 501
๐ Script executed:
# Check if there's any handler that listens to LikeCountEvent and sends it to Kafka
rg -n "LikeCountEvent" apps/commerce-api -B 5 -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4572
likeCount ๋ฆฌ์ค๋์ ํ์ ๋ถ์ผ์น ๋ฐ ์๋ฌ ์ฒ๋ฆฌ ๋ฉ์ปค๋์ฆ ๋ถ์ฌ
์ธ ๊ฐ์ @KafkaListener ๋ฉ์๋๊ฐ ๋์ผํ catalog-events ํ ํฝ๊ณผ metrics-group ๊ทธ๋ฃน ID๋ฅผ ์ฌ์ฉํ๋ฉฐ, ๋ค์๊ณผ ๊ฐ์ ์ฌ๊ฐํ ๋ฌธ์ ๋ค์ด ์์ต๋๋ค.
ํ์ ๋ถ์ผ์น:
LikeEventOutboxHandler๋LikeCreatedEvent๋ฅผ Kafka๋ก ๋ฐํํฉ๋๋ค.LikeCountAggregateListener๋LikeCreatedEvent๋ฅผ ์ฒ๋ฆฌํ์ฌLikeCountEvent๋ฅผ ์์ฑํ์ง๋ง, ์ด๋ ์ ํ๋ฆฌ์ผ์ด์ ์ด๋ฒคํธ๋ก๋ง ๋ฐํ๋๋ฉฐ Kafka๋ก ์ ์ก๋์ง ์์ต๋๋ค.- ๊ฒฐ๊ณผ์ ์ผ๋ก
consumeLikeCount๋LikeCountEvent๋ฅผ ๊ธฐ๋ํ์ง๋งLikeCreatedEvent๋ฅผ ์์ ํ๋ฏ๋ก ์ญ์ง๋ ฌํ์ ์คํจํฉ๋๋ค.
๋น๊ธฐ๋ฅ์ ๋ฆฌ์ค๋:
OrderSalesAggregateListener๋ProductStockEvent๋ฅผ ์ ํ๋ฆฌ์ผ์ด์ ์ด๋ฒคํธ๋ก๋ง ๋ฐํํ๋ฏ๋ก Kafka๋ก ์ ์ก๋์ง ์์ต๋๋ค.consumeSalesCount๋ ๋ฉ์์ง๋ฅผ ๋ฐ์ ์ ์์ด ๋์ํ์ง ์์ต๋๋ค.
์๋ฌ ์ฒ๋ฆฌ ์๋ฐ:
- ๋ชจ๋ ๋ฆฌ์ค๋์์
ack.acknowledge()๊ฐ try ๋ธ๋ก ๋ด์์ ์คํ๋๋ฏ๋ก, ์์ธ ๋ฐ์ ์์๋ ๋ฉ์์ง๋ ์ด๋ฏธ ํ์ธ๋ ์ํ์ ๋๋ค. - ์คํจํ ๋ฉ์์ง๋ ์๊ตฌ์ ์ผ๋ก ์์ค๋๋ฉฐ ์ฌ์๋ ๋ฉ์ปค๋์ฆ์ด ์์ต๋๋ค.
- ๋ค๋ฅธ ์ปจ์๋จธ๋ค์ฒ๋ผ ์๋น์ค ๋ฉ์๋์
@InboxEvent์ด๋ ธํ ์ด์ ์ ์ ์ฉํ์ฌ EventInboxAspect ๊ธฐ๋ฐ์ ์ค์ํ๋ ์๋ฌ ์ฒ๋ฆฌ๋ฅผ ํ์ฉํ์ธ์.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
around lines 21โ58, the three @KafkaListener methods are mismatched with what is
actually published to Kafka and lack centralized inbox-based error handling:
change the consumeLikeCount signature to consume the actual Kafka payload (e.g.,
LikeCreatedEvent) or modify the producer to publish LikeCountEvent to Kafka so
types align; remove or convert consumeSalesCount/consumeProductView to
application-event listeners if those events are not published to Kafka (or
ensure those events are published to the catalog-events topic); remove
per-method try/ack anti-pattern by annotating the underlying metricsService
processing methods with @InboxEvent so EventInboxAspect handles retries/failures
and only acknowledge (ack.acknowledge()) after successful processing; finally
update log calls to pass the caught exception (e) as the last parameter so stack
traces are logged.
| } catch (Exception e) { | ||
| log.error("์ข์์ ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ์คํจ: {}", e.getMessage()); | ||
| } |
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.
์์ธ ๋ฐ์ ์ ๋ฉ์์ง ์์ค ๋ฐฉ์ง ํ์
ํ์ฌ ์๋ฌ ํธ๋ค๋ง์ ์์ธ๋ฅผ ๋ก๊น
๋ง ํ๊ณ ack.acknowledge()๋ฅผ ํธ์ถํ์ง ์์ง๋ง, try-catch ๋ธ๋ก ๋ฐ์ผ๋ก ์์ธ๊ฐ ์ ํ๋์ง ์์ Kafka๊ฐ ๋ฉ์์ง๋ฅผ ์ฌ์ฒ๋ฆฌํ์ง ์์ ์ ์์ต๋๋ค. PR ๋ชฉํ์์ ์ธ๊ธํ At Least Once ๋ณด์ฅ์ด ์๋ฐ๋ฉ๋๋ค.
๋ํ, ํ์ต๋ ํจํด์ ๋ฐ๋ฅด๋ฉด ์ด ์ฝ๋๋ฒ ์ด์ค๋ EventInboxAspect๋ฅผ ํตํ ์ค์ ์ง์ค์ ์๋ฌ ์ฒ๋ฆฌ๋ฅผ ์ฌ์ฉํฉ๋๋ค. ๊ฐ ์ปจ์๋จธ์์ ๊ฐ๋ณ์ ์ผ๋ก ์๋ฌ ์ฒ๋ฆฌ๋ฅผ ๊ตฌํํ๋ฉด ๋ก์ง์ด ์ค๋ณต๋ฉ๋๋ค.
Based on learnings, the codebase uses EventInboxAspect for centralized error handling in Kafka consumers.
๐ ์ค์ ์ง์ค์ ์๋ฌ ์ฒ๋ฆฌ ์ ์ฉ ์ ์
์๋น์ค ๋ฉ์๋์ @InboxEvent ์ด๋
ธํ
์ด์
์ ์ถ๊ฐํ์ฌ EventInboxAspect๊ฐ ์๋ฌ๋ฅผ ์ฒ๋ฆฌํ๋๋ก ๋ณ๊ฒฝ:
@KafkaListener(topics = "catalog-events", groupId = "metrics-group")
public void consumeLikeCount(ConsumerRecord<String, LikeCountEvent> record, Acknowledgment ack) {
- try {
metricsService.processLikeCountEvent(record.value());
ack.acknowledge();
- } catch (Exception e) {
- log.error("์ข์์ ๋ฉํธ๋ฆญ ์ฒ๋ฆฌ ์คํจ: {}", e.getMessage());
- }
}๊ทธ๋ฆฌ๊ณ ProductMetricsService์ ๋ฉ์๋์ @InboxEvent ์ถ๊ฐ:
@InboxEvent // Aspect๊ฐ ์คํจ ์ EventInbox์ ์ ์ฅ
@Transactional
public void processLikeCountEvent(LikeCountEvent event) {
// ...
}Also applies to: 42-44, 55-57
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
around lines 29-31 (and similarly 42-44, 55-57), the consumer currently catches
exceptions and logs them which swallows errors and prevents EventInboxAspect
from centrally handling failures and guaranteeing At-Least-Once delivery; remove
the local try-catch so exceptions are allowed to propagate, stop calling
ack.acknowledge() on caught errors, and instead annotate the corresponding
service processing method in ProductMetricsService with @InboxEvent (and keep it
@Transactional) so EventInboxAspect will persist failures to EventInbox and
manage acknowledgements/retries centrally. Ensure no local duplicate
error-handling logic remains in these consumer methods.
| acks: all | ||
| properties: | ||
| enable.idempotence: true |
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.
enable.idempotence ์์ฑ์ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ผ๋ก ์์ ํ์ธ์.
spring.kafka.producer.properties ๋ด Kafka ํด๋ผ์ด์ธํธ ์์ฑ์ YAML์์ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ ์ฌ์ฉํด์ผ ํ๋ฉฐ (์: "[spring.json.add.type.headers]": false), ํ์ฌ ์ฝ๋์ enable.idempotence: true๋ ์๋ํ ๋๋ก ์๋ํ์ง ์์ต๋๋ค. YAML์ด ์ ์ ์ค์ฒฉ ํค๋ก ํ์ฑํ์ฌ Kafka ํ๋ก๋์์ ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฌ๋์ง ์์ ๊ฒ์
๋๋ค.
๋ค์๊ณผ ๊ฐ์ด ์์ ํ์ธ์:
properties:
"[enable.idempotence]": true๐ค Prompt for AI Agents
modules/kafka/src/main/resources/kafka.yml lines 18-20: ํ์ฌ
spring.kafka.producer.properties์ ์ค์ ๋ enable.idempotence: true๋ YAML์ด ์ ํ๊ธฐ๋ฒ์ ์ค์ฒฉ
ํค๋ก ํด์ํด Kafka ํด๋ผ์ด์ธํธ์ ์ ๋ฌ๋์ง ์์ต๋๋ค; ํด๋น ํค๋ฅผ ๋ธ๋ํท ํ๊ธฐ๋ฒ์ผ๋ก ๋ณ๊ฒฝํด YAML์์ ๋ฌธ์์ด ํค๋ก ์ทจ๊ธ๋๋๋ก ํ๊ณ ๊ฐ์
true๋ก ์ ์งํ์ธ์ (์: "[enable.idempotence]": true).
| public class PageWrapper<T> { | ||
| private List<T> content; | ||
| private long totalElements; | ||
| private int pageNumber; | ||
| private int pageSize; | ||
|
|
||
| public PageWrapper() {} | ||
|
|
||
| public PageWrapper(Page<T> page) { | ||
| this.content = page.getContent(); | ||
| this.totalElements = page.getTotalElements(); | ||
| this.pageNumber = page.getNumber(); | ||
| this.pageSize = page.getSize(); | ||
| } | ||
|
|
||
| public Page<T> toPage() { | ||
| return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements); | ||
| } | ||
|
|
||
| public List<T> getContent() { return content; } | ||
| public long getTotalElements() { return totalElements; } | ||
| public int getPageNumber() { return pageNumber; } | ||
| public int getPageSize() { return pageSize; } | ||
| } |
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
์ญ์ง๋ ฌํ๋ฅผ ์ํ setter ๋ฉ์๋๋ฅผ ์ถ๊ฐํ์ธ์.
PageWrapper ํด๋์ค๋ Spring Data์ Page ๊ฐ์ฒด๋ฅผ ์ง๋ ฌํ ๊ฐ๋ฅํ ํํ๋ก ๋ณํํ๊ธฐ ์ํ ๋ํผ์
๋๋ค.
ํ์ฌ ๊ตฌ์กฐ์ ๋ฌธ์ ์ :
- Setter ๋ถ์ฌ: ํ๋๊ฐ private์ด๊ณ setter๊ฐ ์์ด Jackson๊ณผ ๊ฐ์ ์ง๋ ฌํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ์ญ์ง๋ ฌํ ์ ํ๋๋ฅผ ์ฑ์ธ ์ ์์ต๋๋ค.
@JsonProperty์ด๋ ธํ ์ด์ ์ด๋ setter ๋ฉ์๋๊ฐ ํ์ํฉ๋๋ค. - NPE ์ํ:
toPage()๋ฉ์๋๋content,totalElements๋ฑ์ด null์ด ์๋๋ผ๊ณ ๊ฐ์ ํ์ง๋ง, ์ญ์ง๋ ฌํ ์คํจ ์ null์ผ ์ ์์ต๋๋ค. - ์ ํจ์ฑ ๊ฒ์ฆ ๋ถ์ฌ:
pageSize๊ฐ 0์ด๊ฑฐ๋ ์์์ธ ๊ฒฝ์ฐPageRequest.of()์์ ์์ธ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
๐ Setter ์ถ๊ฐ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฆ ์ ์
public class PageWrapper<T> {
private List<T> content;
private long totalElements;
private int pageNumber;
private int pageSize;
public PageWrapper() {}
public PageWrapper(Page<T> page) {
this.content = page.getContent();
this.totalElements = page.getTotalElements();
this.pageNumber = page.getNumber();
this.pageSize = page.getSize();
}
public Page<T> toPage() {
+ if (content == null) {
+ throw new IllegalStateException("content must not be null");
+ }
+ if (pageSize <= 0) {
+ throw new IllegalStateException("pageSize must be greater than 0");
+ }
return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements);
}
public List<T> getContent() { return content; }
public long getTotalElements() { return totalElements; }
public int getPageNumber() { return pageNumber; }
public int getPageSize() { return pageSize; }
+
+ public void setContent(List<T> content) { this.content = content; }
+ public void setTotalElements(long totalElements) { this.totalElements = totalElements; }
+ public void setPageNumber(int pageNumber) { this.pageNumber = pageNumber; }
+ public void setPageSize(int pageSize) { this.pageSize = pageSize; }
}๐ค Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java
around lines 8-31, add public setter methods (or annotate fields with
@JsonProperty) so Jackson can populate content, totalElements, pageNumber and
pageSize during deserialization, and harden toPage() against null/invalid values
by treating null content as Collections.emptyList(), null totalElements as 0L,
null/negative pageNumber as 0, and replacing pageSize <= 0 with 1 before calling
PageRequest.of(...); ensure the default no-arg constructor remains and perform
these simple validations inside toPage() to avoid NPEs and
IllegalArgumentException from PageRequest.
| public void deleteByPattern(String pattern) { | ||
| Set<String> keys = redisTemplate.keys(pattern + "*"); | ||
| if (keys != null && !keys.isEmpty()) { | ||
| redisTemplate.delete(keys); | ||
| } | ||
| } |
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.
Redis KEYS ๋ช ๋ น์ด๋ ํ๋ก๋์ ํ๊ฒฝ์์ ์ฑ๋ฅ ๋ฌธ์ ์ ๋ฐ
redisTemplate.keys(pattern + "*")๋ O(N) ๋ธ๋กํน ์ฐ์ฐ์ผ๋ก, Redis์ ๋ชจ๋ ํค๋ฅผ ์ค์บํ๋ ๋์ ๋ค๋ฅธ ๋ชจ๋ ์์
์ ์ฐจ๋จํฉ๋๋ค. ๋๊ท๋ชจ ๋ฐ์ดํฐ์
์์๋ ์ ์ด๊ฐ Redis๊ฐ ์๋ต ๋ถ๊ฐ ์ํ๊ฐ ๋ ์ ์์ต๋๋ค.
ํ๋ก๋์
ํ๊ฒฝ์์๋ SCAN ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ์ฌ ๋น๋ธ๋กํน ๋ฐฉ์์ผ๋ก ํค๋ฅผ ์ํํด์ผ ํฉ๋๋ค.
๐ SCAN ๊ธฐ๋ฐ ๊ตฌํ ์ ์
public void deleteByPattern(String pattern) {
- Set<String> keys = redisTemplate.keys(pattern + "*");
- if (keys != null && !keys.isEmpty()) {
- redisTemplate.delete(keys);
- }
+ redisTemplate.execute((RedisCallback<Void>) connection -> {
+ ScanOptions options = ScanOptions.scanOptions()
+ .match(pattern + "*")
+ .count(100) // ๋ฐฐ์น ํฌ๊ธฐ
+ .build();
+
+ Cursor<byte[]> cursor = connection.scan(options);
+ while (cursor.hasNext()) {
+ connection.del(cursor.next());
+ }
+ cursor.close();
+ return null;
+ });
}๐ค Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java
around lines 58 to 63, calling redisTemplate.keys(pattern + "*") uses the
blocking O(N) KEYS command which can freeze Redis in production; replace it with
a SCAN-based, non-blocking iteration: use RedisTemplate/ConnectionFactory to
open a cursor (ScanOptions.match(pattern + "*")) and iterate through results,
collect keys in batches (e.g., 100-1000) and call delete on each batch, ensuring
the cursor is closed in a finally block and handling potential exceptions; this
will perform incremental, non-blocking scans and safe batched deletes suitable
for production.
๐ Summary
commerce-api์commerce-streamer๊ฐ์ ๋ฐ์ดํฐ ์ ์ก์ ์ํด Kafka๋ฅผ ๋์ ํ๊ณ , ๋ถ์ฐ ํ๊ฒฝ์์์ ๋ฐ์ดํฐ ์ ํฉ์ฑ์ ํด๊ฒฐํ์ต๋๋ค.event_handledํ ์ด๋ธ์ ํตํด ์ค๋ณต ์ ์ ๋ ์ด๋ฒคํธ๋ฅผ ๊ฑธ๋ฌ๋ด์ด ์ต์ข ์์น๊ฐ ๋จ ํ ๋ฒ๋ง ๋ฐ์๋๋๋ก ์ค๊ณํ์ต๋๋ค.๐ฌ Review Points
Outboxํ ์ด๋ธ์ ์ด๋ฒคํธ๋ฅผ ๋จผ์ ์ ์ฅํ๊ณ ,OutboxRelay์ค์ผ์ค๋ฌ๊ฐ ๋ฏธ๋ฐํ ๊ฑด์ ์ฌ์๋ํ๋๋ก ํ์ฌ At Least Once๋ฅผ ๋ณด์ฅํ์ต๋๋ค.event_idPK ๊ธฐ๋ฐ์ ๋ฉฑ๋ฑ ์ฒ๋ฆฌ ๋ก์ง์ ๊ตฌํํ์ฌ At Most Once ์คํ์ ๋ณด์ฅํ์ต๋๋ค.โ Checklist
Producer
Consumer
event_handledํ ์ด๋ธ์ ํตํ ๋ฉฑ๋ฑ ์ฒ๋ฆฌ ๊ตฌํSummary by CodeRabbit
๋ฆด๋ฆฌ์ฆ ๋ ธํธ
์๋ก์ด ๊ธฐ๋ฅ
๊ฐ์ ์ฌํญ
์์คํ ์์ ์ฑ
โ๏ธ Tip: You can customize this high-level summary in your review settings.