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
31 changes: 31 additions & 0 deletions docs/BACKEND_CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,5 +688,36 @@ private String payload;
| Debezium | 3.1 |
| Resilience4j | Spring Cloud 번들 |
| JWT (jjwt) | 0.13.0 |

---

## 8. Istio VirtualService & 컨트롤러 @RequestMapping 규칙

Kubernetes 환경에서 Istio VirtualService가 서비스별 prefix(`/release`, `/payment`, `/media` 등)를 `/`로 rewrite하여 서비스에 전달한다.

**규칙**: 클래스 레벨 `@RequestMapping`에 서비스 prefix를 포함하면 안 된다.

```java
// ❌ 잘못된 예 — Istio가 /release를 strip하므로 서비스엔 /{id}로 도착 → 404
@RestController
@RequestMapping("/release")
public class ReleaseCommandController { ... }

// ✅ 올바른 예 — prefix 없이 메서드 레벨 경로만 사용
@RestController
public class ReleaseCommandController { ... }
```

**Admin 경로**: `/admin/{service}` prefix도 동일하게 `/admin`까지만 사용한다.

```java
// ❌
@RequestMapping("/admin/release")

// ✅
@RequestMapping("/admin")
```

적용 서비스: `release`, `payment`, `media` (그 외 서비스 추가 시 동일 규칙 적용)
| Lombok | Spring Boot 관리 |
| SonarCloud | GitHub Actions 통합 |
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package kr.magicbox.notification.adapter.in.web.controller;

import kr.magicbox.notification.adapter.in.web.dto.request.ReadNotificationsRequest;
import kr.magicbox.notification.adapter.in.web.dto.request.RegisterFcmTokenRequest;
import kr.magicbox.notification.application.dto.command.ReadNotificationCommand;
import kr.magicbox.notification.application.dto.command.RegisterFcmTokenCommand;
import kr.magicbox.notification.application.port.in.ReadNotificationUseCase;
import kr.magicbox.notification.application.port.in.RegisterFcmTokenUseCase;
import kr.magicbox.notification.domain.vo.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -18,6 +22,7 @@
public class NotificationCommandController {

private final RegisterFcmTokenUseCase registerFcmTokenUseCase;
private final ReadNotificationUseCase readNotificationUseCase;

@PostMapping("/fcm-token")
public ResponseEntity<Void> registerFcmToken(
Expand All @@ -26,4 +31,12 @@ public ResponseEntity<Void> registerFcmToken(
registerFcmTokenUseCase.register(RegisterFcmTokenCommand.of(userId.value(), request.token()));
return ResponseEntity.ok().build();
}

@PatchMapping("/read")
public ResponseEntity<Void> readNotifications(
@AuthenticationPrincipal UserId userId,
@RequestBody ReadNotificationsRequest request) {
readNotificationUseCase.readAll(ReadNotificationCommand.of(request.notificationIds(), userId.value()));
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.magicbox.notification.adapter.in.web.dto.request;

import java.util.List;

public record ReadNotificationsRequest(List<Long> notificationIds) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import kr.magicbox.notification.adapter.out.persistence.repository.NotificationJpaRepository;
import kr.magicbox.notification.application.port.out.NotificationRepositoryPort;
import kr.magicbox.notification.domain.aggregate.Notification;
import kr.magicbox.notification.domain.enums.NotificationStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
Expand All @@ -20,6 +22,24 @@ public void save(Notification notification) {
notificationJpaRepository.save(NotificationEntity.from(notification));
}

@Override
public void update(Notification notification) {
notificationJpaRepository.findByIdAndUserId(
notification.getId().value(), notification.getUserId().value())
.ifPresent(NotificationEntity::markRead);
}

@Override
public void updateAllByIdsAndUserId(List<Long> notificationIds, Long userId) {
notificationJpaRepository.updateStatusByIdsAndUserId(notificationIds, userId, NotificationStatus.READ);
}

@Override
public Optional<Notification> findByIdAndUserId(Long notificationId, Long userId) {
return notificationJpaRepository.findByIdAndUserId(notificationId, userId)
.map(NotificationEntity::toDomain);
}

@Override
public List<Notification> findAllByUserId(Long userId) {
return notificationJpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package kr.magicbox.notification.adapter.out.persistence.repository;

import kr.magicbox.notification.adapter.out.persistence.entity.NotificationEntity;
import kr.magicbox.notification.domain.enums.NotificationStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface NotificationJpaRepository extends JpaRepository<NotificationEntity, Long> {
List<NotificationEntity> findAllByUserIdOrderByCreatedAtDesc(Long userId);
Optional<NotificationEntity> findByIdAndUserId(Long id, Long userId);

@Modifying
@Query("UPDATE NotificationEntity n SET n.status = :status WHERE n.id IN :ids AND n.userId = :userId")
void updateStatusByIdsAndUserId(@Param("ids") List<Long> ids, @Param("userId") Long userId, @Param("status") NotificationStatus status);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.magicbox.notification.application.dto.command;

import java.util.List;

public record ReadNotificationCommand(
List<Long> notificationIds,
Long userId
) {
public static ReadNotificationCommand of(List<Long> notificationIds, Long userId) {
return new ReadNotificationCommand(notificationIds, userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.magicbox.notification.application.port.in;

import kr.magicbox.notification.application.dto.command.ReadNotificationCommand;

public interface ReadNotificationUseCase {
void readAll(ReadNotificationCommand command);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import kr.magicbox.notification.domain.aggregate.Notification;

import java.util.List;
import java.util.Optional;

public interface NotificationRepositoryPort {
void save(Notification notification);
void update(Notification notification);
void updateAllByIdsAndUserId(List<Long> notificationIds, Long userId);
Optional<Notification> findByIdAndUserId(Long notificationId, Long userId);
List<Notification> findAllByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kr.magicbox.notification.application.service;

import kr.magicbox.notification.application.dto.command.ReadNotificationCommand;
import kr.magicbox.notification.application.port.in.ReadNotificationUseCase;
import kr.magicbox.notification.application.port.out.NotificationRepositoryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ReadNotificationService implements ReadNotificationUseCase {

private final NotificationRepositoryPort notificationRepositoryPort;

@Transactional
@Override
public void readAll(ReadNotificationCommand command) {
notificationRepositoryPort.updateAllByIdsAndUserId(command.notificationIds(), command.userId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package kr.magicbox.order.adapter.in.kafka.aop;

import kr.magicbox.order.adapter.in.kafka.event.InboxEvent;
import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties;
import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity;
import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxStatus;
import kr.magicbox.order.adapter.out.persistence.repository.OrderInboxJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

private final OrderInboxJpaRepository orderInboxJpaRepository;
private final TransactionTemplate transactionTemplate;
private final InboxProperties inboxProperties;

@Around("@annotation(kr.magicbox.order.adapter.in.kafka.annotation.Idempotent)")
public Object around(ProceedingJoinPoint pjp) {
ConsumerRecord<String, ?> consumerRecord = extractRecord(pjp);
String eventKey = consumerRecord.key() != null
? consumerRecord.key()
: consumerRecord.topic() + "-" + consumerRecord.partition() + "-" + consumerRecord.offset();
InboxEvent event = (InboxEvent) consumerRecord.value();
Instant occurredAt = event.occurredAt();

if (isTooOld(occurredAt)) {
log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. key={}, occurredAt={}", eventKey, occurredAt);
transactionTemplate.executeWithoutResult(status ->
orderInboxJpaRepository.save(OrderInboxEntity.builder()
.eventKey(eventKey)
.topic(consumerRecord.topic())
.partition(consumerRecord.partition())
.offset(consumerRecord.offset())
.status(OrderInboxStatus.DEAD_LETTERED)
.occurredAt(occurredAt)
.build())
);
return null;
}

return transactionTemplate.execute(status -> {
if (orderInboxJpaRepository.existsByEventKey(eventKey)) {
log.warn("[Inbox] 중복 메시지 폐기. key={}", eventKey);
return null;
}
OrderInboxEntity inbox = orderInboxJpaRepository.save(OrderInboxEntity.builder()
.eventKey(eventKey)
.topic(consumerRecord.topic())
.partition(consumerRecord.partition())
.offset(consumerRecord.offset())
.status(OrderInboxStatus.PENDING)
.occurredAt(occurredAt)
.build());
try {
pjp.proceed();
} catch (Error e) {
throw e;
} catch (RuntimeException e) {
status.setRollbackOnly();
throw e;
} catch (Throwable e) {
status.setRollbackOnly();
throw new IllegalStateException(e);
}
inbox.markProcessed();
return null;
});
}

private boolean isTooOld(Instant occurredAt) {
return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES));
}

@SuppressWarnings("unchecked")
private ConsumerRecord<String, ?> extractRecord(ProceedingJoinPoint pjp) {
return Arrays.stream(pjp.getArgs())
.filter(ConsumerRecord.class::isInstance)
.map(arg -> (ConsumerRecord<String, ?>) arg)
.findFirst()
.orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;
import java.util.List;

public record DeliveryCompletedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("order_line_id") Long orderLineId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("delivery_id") Long deliveryId,
@JsonProperty("tracking_number") String trackingNumber,
@JsonProperty("delivered_at") Instant deliveredAt,
@JsonProperty("items") List<ItemPayload> items,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {
public record ItemPayload(
@JsonProperty("product_id") Long productId,
@JsonProperty("quantity") int quantity
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;

public record DeliveryStartedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("order_line_id") Long orderLineId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("delivery_id") Long deliveryId,
@JsonProperty("carrier") String carrier,
@JsonProperty("carrier_code") String carrierCode,
@JsonProperty("tracking_number") String trackingNumber,
@JsonProperty("dispatched_at") Instant dispatchedAt,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.magicbox.order.adapter.in.kafka.event;

import java.time.Instant;

public interface InboxEvent {
Instant occurredAt();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;

public record PaymentCancelFailedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("reason") String reason,
@JsonProperty("pg_code") String pgCode,
@JsonProperty("pg_message") String pgMessage,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;
import java.util.List;

public record PaymentCancelSucceededEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("pg_transaction_id") String pgTransactionId,
@JsonProperty("refund_amount") long refundAmount,
@JsonProperty("currency") String currency,
@JsonProperty("refunded_at") Instant refundedAt,
@JsonProperty("items") List<ItemPayload> items,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {
public record ItemPayload(
@JsonProperty("product_id") Long productId,
@JsonProperty("quantity") int quantity
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;

public record PaymentFailedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("reason") String reason,
@JsonProperty("pg_code") String pgCode,
@JsonProperty("pg_message") String pgMessage,
@JsonProperty("occurred_at") Instant occurredAt
) implements InboxEvent {}
Loading
Loading