Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ dependencies {
// add-ons
implementation(project(":modules:jpa"))
implementation(project(":modules:redis"))
implementation(project(":modules:kafka"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))
implementation(project(":supports:kafka-events"))

// web
implementation("org.springframework.boot:spring-boot-starter-web")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

import java.util.TimeZone;

@EnableAsync
@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceApiApplication {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.loopers.application.event;

import java.time.ZonedDateTime;

/**
* 쿠폰 발급 요청 생성 이벤트.
* 발급 요청이 생성되었을 때 발행됨.
*/
public record CouponIssueRequestCreatedEvent(
String eventId,
String requestId,
Long couponId,
Long userId,
ZonedDateTime occurredAt
) {
public static CouponIssueRequestCreatedEvent of(
String eventId,
String requestId,
Long couponId,
Long userId
) {
return new CouponIssueRequestCreatedEvent(eventId, requestId, couponId, userId, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.application.event;

import java.time.ZonedDateTime;

/**
* 좋아요 취소 이벤트.
* 사용자가 상품 좋아요를 취소했을 때 발행됨.
*/
public record LikeCanceledEvent(
Long likeId,
Long userId,
Long productId,
ZonedDateTime occurredAt
) {
public static LikeCanceledEvent of(Long likeId, Long userId, Long productId) {
return new LikeCanceledEvent(likeId, userId, productId, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.application.event;

import java.time.ZonedDateTime;

/**
* 좋아요 생성 이벤트.
* 사용자가 상품에 좋아요를 등록했을 때 발행됨.
*/
public record LikeCreatedEvent(
Long likeId,
Long userId,
Long productId,
ZonedDateTime occurredAt
) {
public static LikeCreatedEvent of(Long likeId, Long userId, Long productId) {
return new LikeCreatedEvent(likeId, userId, productId, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.loopers.application.event;

import java.time.ZonedDateTime;

/**
* 주문 완료 이벤트.
* 주문이 성공적으로 생성되었을 때 발행됨.
*/
public record OrderCompletedEvent(
Long orderId,
Long userId,
Long productId,
Integer quantity,
Long totalAmount,
ZonedDateTime occurredAt
) {
public static OrderCompletedEvent of(Long orderId, Long userId, Long productId, Integer quantity, Long totalAmount) {
return new OrderCompletedEvent(orderId, userId, productId, quantity, totalAmount, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.loopers.application.event;

import java.time.ZonedDateTime;

/**
* 상품 조회 이벤트.
* 사용자가 상품 상세 페이지를 조회했을 때 발행됨.
*/
public record ProductViewedEvent(
Long userId,
Long productId,
ZonedDateTime occurredAt
) {
public static ProductViewedEvent of(Long userId, Long productId) {
return new ProductViewedEvent(userId, productId, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.loopers.application.event;

import com.loopers.domain.useraction.ActionType;

import java.time.ZonedDateTime;

/**
* 유저 행동 이벤트.
* 사용자의 행동(조회, 클릭, 좋아요, 주문 등)을 기록하기 위해 발행됨.
*/
public record UserActionEvent(
Long userId,
ActionType actionType,
Long targetId,
ZonedDateTime occurredAt
) {
public static UserActionEvent of(Long userId, ActionType actionType, Long targetId) {
return new UserActionEvent(userId, actionType, targetId, ZonedDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.loopers.application.event.listener;

import com.loopers.application.event.LikeCanceledEvent;
import com.loopers.application.event.LikeCreatedEvent;
import com.loopers.infrastructure.cache.LikeCountCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 좋아요 카운트 이벤트 리스너.
* 좋아요 생성/취소 이벤트 발생 시 Redis 카운터 갱신.
* Redis 작업만 수행하므로 별도 트랜잭션이 필요 없음.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeCountEventListener {

private final LikeCountCacheService likeCountCacheService;

/**
* 좋아요 생성 시 카운터 증가.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeCreated(LikeCreatedEvent event) {
Comment on lines +27 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) 전역 비동기 이벤트 멀티캐스터 설정 확인
rg -n -C4 --type=java 'ApplicationEventMulticaster|SimpleApplicationEventMulticaster|setTaskExecutor|@EnableAsync|TaskExecutor'

# 2) LikeCountEventListener의 `@Async` 적용 여부 및 관련 설정 확인
fd LikeCountEventListener.java --exec sed -n '1,220p' {}
rg -n -C3 --type=java '@Async|LikeCountEventListener'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8871


리스너에 @Async를 명시하지 않아 Redis I/O가 동기 실행되는 문제

애플리케이션에는 @EnableAsynceventExecutor 빈이 설정되어 있지만, LikeCountEventListener의 핸들러가 @Async 어노테이션을 누락했다. 따라서 @TransactionalEventListener(AFTER_COMMIT)만으로는 비동기 실행이 보장되지 않으며, Redis I/O가 동기로 실행되어 트랜잭션 커밋 직후에도 API 응답 지연과 스레드 점유가 증가한다. 같은 이벤트 리스너 구조를 사용하는 UserActionLogListener@Async("eventExecutor")를 명시하고 있으므로 이를 따르도록 수정해야 한다.

수정안: 두 핸들러 메서드(handleLikeCreated, handleLikeCanceled)에 @Async("eventExecutor") 어노테이션을 추가한다. 추가 테스트는 Redis 응답 지연을 주입했을 때 API 응답 시간이 리스너 처리 시간에 묶이지 않는지 검증하면 된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/listener/LikeCountEventListener.java`
around lines 27 - 28, The event listener methods in LikeCountEventListener are
missing asynchronous execution, causing Redis I/O to run synchronously; add the
`@Async`("eventExecutor") annotation to both handler methods (handleLikeCreated
and handleLikeCanceled) so they run on the configured eventExecutor thread pool
instead of blocking the transaction/HTTP thread, then run tests injecting Redis
latency to confirm API response times are no longer tied to listener processing.

try {
Long count = likeCountCacheService.increment(event.productId());
log.debug("좋아요 카운터 증가: productId={}, count={}", event.productId(), count);
} catch (Exception e) {
log.warn("좋아요 카운터 증가 실패 (무시): productId={}", event.productId(), e);
}
Comment on lines +32 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

예외를 무시만 하면 캐시-원본 불일치가 장기화될 수 있다

운영 관점에서 현재처럼 예외를 경고 로그만 남기고 종료하면 Redis 일시 장애 시 좋아요 카운트가 지속적으로 틀어질 수 있고, 자동 복구 경로가 없어 수동 정합화 비용이 커진다. 수정안은 실패 이벤트를 재시도 큐(또는 outbox 재발행)로 보내거나, 주기적 리컨실리에이션 작업을 추가해 최종 정합성을 보장하는 방식이다. 추가 테스트는 Redis 예외를 강제로 발생시킨 뒤 재시도/리컨실리에이션으로 카운트가 회복되는지 검증하면 된다.

Also applies to: 45-47

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/listener/LikeCountEventListener.java`
around lines 32 - 34, The catch block in LikeCountEventListener merely logs and
swallows exceptions causing long-term cache-source drift; modify the exception
handler in the method that processes the like event (LikeCountEventListener) to
publish the failed event to a retry/outbox mechanism (e.g., call a
RetryQueue.enqueue(event) or OutboxService.saveFailedEvent(event)) and log the
publish result, and also add or register a periodic reconciliation job (e.g.,
LikeCountReconciler.reconcileLikes()) to repair mismatches; add a
unit/integration test that forces a Redis exception when handling the event and
verifies the event is enqueued for retry/outbox and that the reconcilier can
recover the correct count.

}

/**
* 좋아요 취소 시 카운터 감소.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeCanceled(LikeCanceledEvent event) {
try {
Long count = likeCountCacheService.decrement(event.productId());
log.debug("좋아요 카운터 감소: productId={}, count={}", event.productId(), count);
} catch (Exception e) {
log.warn("좋아요 카운터 감소 실패 (무시): productId={}", event.productId(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.application.event.listener;

import com.loopers.application.event.OrderCompletedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 주문 알림 이벤트 리스너.
* 주문 완료 시 알림 발송 (현재는 로그로 대체).
*/
@Slf4j
@Component
public class OrderNotificationListener {

/**
* 주문 완료 시 알림 발송.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
log.info("주문 완료 알림: orderId={}, userId={}, totalAmount={}",
event.orderId(), event.userId(), event.totalAmount());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.loopers.application.event.listener;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.event.CouponIssueRequestCreatedEvent;
import com.loopers.application.event.LikeCanceledEvent;
import com.loopers.application.event.LikeCreatedEvent;
import com.loopers.application.event.OrderCompletedEvent;
import com.loopers.application.event.ProductViewedEvent;
import com.loopers.domain.outbox.OutboxEvent;
import com.loopers.domain.outbox.OutboxEventRepository;
import com.loopers.event.AggregateType;
import com.loopers.event.EventType;
import com.loopers.event.KafkaTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* Transactional Outbox Pattern을 위한 이벤트 리스너.
* 트랜잭션 커밋 전에 Outbox 테이블에 이벤트를 저장.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxEventListener {

private final OutboxEventRepository outboxEventRepository;
private final ObjectMapper objectMapper;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleLikeCreatedEvent(LikeCreatedEvent event) {
saveOutboxEvent(
EventType.LIKE_CREATED,
AggregateType.PRODUCT,
String.valueOf(event.productId()),
KafkaTopics.CATALOG_EVENTS,
event
);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleLikeCanceledEvent(LikeCanceledEvent event) {
saveOutboxEvent(
EventType.LIKE_CANCELED,
AggregateType.PRODUCT,
String.valueOf(event.productId()),
KafkaTopics.CATALOG_EVENTS,
event
);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOrderCompletedEvent(OrderCompletedEvent event) {
saveOutboxEvent(
EventType.ORDER_COMPLETED,
AggregateType.ORDER,
String.valueOf(event.orderId()),
KafkaTopics.ORDER_EVENTS,
event
);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleProductViewedEvent(ProductViewedEvent event) {
saveOutboxEvent(
EventType.PRODUCT_VIEWED,
AggregateType.PRODUCT,
String.valueOf(event.productId()),
KafkaTopics.CATALOG_EVENTS,
event
);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCouponIssueRequestCreatedEvent(CouponIssueRequestCreatedEvent event) {
saveOutboxEvent(
EventType.COUPON_ISSUE_REQUESTED,
AggregateType.COUPON,
String.valueOf(event.couponId()),
KafkaTopics.COUPON_ISSUE_REQUESTS,
event
);
}

private void saveOutboxEvent(
EventType eventType,
AggregateType aggregateType,
String aggregateId,
String topic,
Object event
) {
try {
String payload = objectMapper.writeValueAsString(event);
OutboxEvent outboxEvent = OutboxEvent.create(
eventType.name(),
aggregateType.name(),
aggregateId,
topic,
payload
);
outboxEventRepository.save(outboxEvent);
log.debug("Outbox event saved: type={}, aggregateId={}", eventType, aggregateId);
} catch (JsonProcessingException e) {
log.error("Failed to serialize event: {}", event, e);
throw new RuntimeException("Failed to serialize event for outbox", e);
Comment on lines +95 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

직렬화 실패를 일반 RuntimeException과 전체 이벤트 로깅으로 처리하면 응답과 로그가 불안정하다

Line 95-108은 요청 트랜잭션 안에서 실행되므로, 여기서 RuntimeException을 그대로 던지면 API 계층의 표준 오류 포맷을 우회한 500 응답으로 번질 수 있다. 또한 event 전체를 로그에 남기면 주문/사용자 식별자 등 payload가 장애 로그로 확산될 수 있다. 내부적으로는 cause를 보존한 CoreException 또는 전용 애플리케이션 예외를 던지고, 로그는 eventType, aggregateId, eventId 같은 최소 메타데이터만 남기도록 바꾸는 편이 안전하다. 추가 테스트로는 ObjectMapper 실패를 주입했을 때 표준 오류 흐름이 유지되고 로그나 예외 메시지에 payload 전체가 포함되지 않는지 검증해야 한다. Based on learnings, 'enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format' and as per coding guidelines, '로깅 시 민감정보 노출 가능성을 점검한다.'

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/listener/OutboxEventListener.java`
around lines 95 - 108, Replace the current error handling in OutboxEventListener
where objectMapper.writeValueAsString(event) is caught: instead of logging the
entire event and throwing a plain RuntimeException, catch
JsonProcessingException, log only minimal metadata (eventType, aggregateId,
eventId if available) and the fact serialization failed, and rethrow a
domain-aware exception (e.g., CoreException or a new
OutboxSerializationException) that preserves the original cause so
ApiControllerAdvice/central error handling can produce the unified API error
response; update any tests to inject a failing ObjectMapper and assert the
unified error flow and that logs do not contain the full event payload (verify
only metadata logged).

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.loopers.application.event.listener;

import com.loopers.application.event.UserActionEvent;
import com.loopers.domain.useraction.UserActionLog;
import com.loopers.domain.useraction.UserActionLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 사용자 행동 로그 이벤트 리스너.
* 사용자 행동 이벤트 발생 시 비동기로 로그 저장.
* @Async로 비동기 실행되므로 메인 트랜잭션과 커넥션 경쟁 없음.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserActionLogListener {

private final UserActionLogRepository userActionLogRepository;

/**
* 사용자 행동 로그 저장.
* @TransactionalEventListener와 함께 사용 시 REQUIRES_NEW 필수.
*/
@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleUserAction(UserActionEvent event) {
try {
UserActionLog actionLog = UserActionLog.create(
event.userId(),
event.actionType(),
event.targetId(),
event.occurredAt()
);
userActionLogRepository.save(actionLog);
log.debug("사용자 행동 로그 저장: userId={}, actionType={}, targetId={}",
event.userId(), event.actionType(), event.targetId());
} catch (Exception e) {
log.warn("사용자 행동 로그 저장 실패 (무시): userId={}, actionType={}",
event.userId(), event.actionType(), e);
}
}
}
Loading