Repository: with-one-voice/ticketing-service
Pull request #104: Feature/#103 feat notification update kafka topic
Author: login: lime1st
Labels: name: feat
description: ✨ 새로운 기능 추가
Created: 2025-04-17T21:19:25Z | Merged: 2025-04-18T03:21:36Z

## #️⃣ Issue Number


> IssueNumber :  #103 

<!---해결할 관련된 이슈 번호를 작성해주세요 (예: #123). -->

## 📄 설명

> PR에 대한 간략한 설명을 작성해주세요.
> 어떤 문제를 해결했는지, 새로운 기능을 추가했는지 등 내용을 자세히 기입해 주세요.

notification:
카프카 토픽 수신 처리

payment:
카카오페이 전송 메시지 일부 수정

<!--- 변경 사항 및 관련 이슈에 대해 간단하게 작성해주세요. 어떻게보다 무엇을 왜 수정했는지 설명해주세요. -->

## 🛠️ PR 유형

어떤 변경 사항이 있나요?

- [x] 새로운 기능 추가
- [ ] 버그 수정
- [ ] CSS 등 사용자 UI 디자인 변경
- [x] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
- [ ] 코드 리팩토링
- [x] 주석 추가 및 수정
- [ ] 문서 수정
- [ ] 테스트 추가, 테스트 리팩토링
- [ ] 빌드 부분 혹은 패키지 매니저 수정
- [ ] 파일 혹은 폴더명 수정
- [ ] 파일 혹은 폴더 삭제

## 📸스크린샷 (선택)




## 💬 공유사항 to 리뷰어
notification 토픽 알림 발송 테스트 완료
<!--- 리뷰어가 중점적으로 봐줬으면 좋겠는 부분이 있으면 적어주세요. -->
<!--- 논의해야할 부분이 있다면 적어주세요.-->
<!--- ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **신규 기능**
  - Kafka 토픽 메시지 구조를 위한 `KafkaTopicMessage` 레코드가 추가되었습니다.
  - 좌석 관련 알림을 처리하는 `SeatTopicConsumer` 및 티켓 상태 알림을 처리하는 `TicketTopicConsumer` Kafka 컨슈머가 추가되었습니다.

- **버그 수정**
  - 푸시 및 이메일 알림 메시지에서 사용자 이름 뒤에 "님"이 추가되어 더욱 자연스러운 메시지가 제공됩니다.
  - 푸시 알림에서 사용자 이메일 기반으로 개인화된 메시지가 전송됩니다.

- **리팩터링**
  - 일부 클래스명, 패키지, 필드명이 명확성을 위해 변경되었습니다.
  - 하드코딩된 결제 관련 값들이 실제 결제 정보 기반으로 동적으로 처리됩니다.

- **테스트**
  - 전체 테스트 코드가 활성화되어 테스트가 정상적으로 실행됩니다.

- **문서화**
  - 일부 주석이 추가되거나 수정되었습니다.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/config/src/main/resources/config-repo/notification-service.yml b/config/src/main/resources/config-repo/notification-service.yml
index 6996642a..787e585d 100644
--- a/config/src/main/resources/config-repo/notification-service.yml
+++ b/config/src/main/resources/config-repo/notification-service.yml
@@ -49,7 +49,7 @@ spring:
 discord:
   name: ${DISCORD_NAME:notification}
   webhook:
-    url: ${DISCORD_WEBHOOK_URL:https://discord.com/api/webhooks/1362258183048925205/lpvOYdaEwe2xkILzvYcAIKhCXEf6z2mZXcuKkLMbfGy90tBy8AJrrBc1bJ9oxRGa2EJs}
+    url: ${DISCORD_WEBHOOK_URL}
 
 springdoc:
   swagger-ui:
diff --git a/notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java b/notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java
index b82e915a..ce6c1d5a 100644
--- a/notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java
+++ b/notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java
@@ -1,6 +1,6 @@
 package com.onevoice.notification.application.client;
 
-import com.onevoice.notification.application.dto.event.DiscordMessage;
+import com.onevoice.notification.application.dto.message.DiscordMessage;
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
diff --git a/notification/src/main/java/com/onevoice/notification/application/dto/event/DiscordMessage.java b/notification/src/main/java/com/onevoice/notification/application/dto/message/DiscordMessage.java
similarity index 82%
rename from notification/src/main/java/com/onevoice/notification/application/dto/event/DiscordMessage.java
rename to notification/src/main/java/com/onevoice/notification/application/dto/message/DiscordMessage.java
index 5e4a09fc..711b2bd1 100644
--- a/notification/src/main/java/com/onevoice/notification/application/dto/event/DiscordMessage.java
+++ b/notification/src/main/java/com/onevoice/notification/application/dto/message/DiscordMessage.java
@@ -1,4 +1,4 @@
-package com.onevoice.notification.application.dto.event;
+package com.onevoice.notification.application.dto.message;
 
 import java.util.List;
 
diff --git a/notification/src/main/java/com/onevoice/notification/application/dto/message/KafkaTopicMessage.java b/notification/src/main/java/com/onevoice/notification/application/dto/message/KafkaTopicMessage.java
new file mode 100644
index 00000000..e5d86d73
--- /dev/null
+++ b/notification/src/main/java/com/onevoice/notification/application/dto/message/KafkaTopicMessage.java
@@ -0,0 +1,15 @@
+package com.onevoice.notification.application.dto.message;
+
+import java.util.UUID;
+
+public record KafkaTopicMessage(
+    String topic,
+    Payload payload
+) {
+
+    public record Payload(
+        UUID userId
+    ) {
+
+    }
+}
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java
index ca755f98..70a5fd75 100644
--- a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java
@@ -25,7 +25,6 @@ public class EmailNotificationHandler implements NotificationHandler {
     private final EmailServerChecker emailServerChecker;
     private final NotificationService notificationService;
 
-    // 각 구현체가 자신의 구현체를 반환하도록 하여 팩토리에서 타입을 따로 비교하지 않아도 된다.
     @Override
     public NotificationType supports() {
         return NotificationType.EMAIL;
@@ -74,7 +73,7 @@ private SimpleMailMessage getSimpleMailMessage(NotificationEvent event, String e
     }
 
     private String getText(String username, String message) {
-        return "to: " + username + "," + System.lineSeparator() + System.lineSeparator()
+        return "to: " + username + "님," + System.lineSeparator() + System.lineSeparator()
             + message + System.lineSeparator()
             + System.lineSeparator() + System.lineSeparator()
             + "from: " + System.lineSeparator() + "With One Voice";
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java
index 9b92f204..60933b5a 100644
--- a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java
@@ -5,6 +5,7 @@
 
 public interface NotificationHandler {
 
+    // 각 구현체가 자신의 구현체를 반환하도록 하여 팩토리에서 타입을 따로 비교하지 않아도 된다.
     NotificationType supports();
 
     void handle(NotificationEvent event);
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java
index 8fd1fe7a..0356a38b 100644
--- a/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java
@@ -1,11 +1,14 @@
 package com.onevoice.notification.infrastructure.message.handler;
 
 import com.onevoice.notification.application.client.DiscordClient;
-import com.onevoice.notification.application.dto.event.DiscordMessage;
+import com.onevoice.notification.application.client.UserClient;
 import com.onevoice.notification.application.dto.event.NotificationEvent;
+import com.onevoice.notification.application.dto.message.DiscordMessage;
+import com.onevoice.notification.application.dto.query.FindUserQuery;
 import com.onevoice.notification.application.service.NotificationService;
 import com.onevoice.notification.domain.NotificationStatus;
 import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.exception.NotificationUserNotFoundException;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -16,8 +19,9 @@
 @RequiredArgsConstructor
 public class PushNotificationHandler implements NotificationHandler {
 
-    private final DiscordClient discordClient;
-    private final NotificationService notificationService;
+    private final DiscordClient discord;
+    private final UserClient userClient;
+    private final NotificationService service;
 
     @Override
     public NotificationType supports() {
@@ -27,18 +31,20 @@ public NotificationType supports() {
     @Override
     public void handle(NotificationEvent event) {
         try {
-            discordClient.push(getMessage(event));
-            notificationService.updateStatus(event.notificationId(), NotificationStatus.SENT);
+            discord.push(getMessage(event));
+            service.updateStatus(event.notificationId(), NotificationStatus.SENT);
         } catch (Exception e) {
             log.error("Push notification failed", e);
-            notificationService.updateStatus(event.notificationId(), NotificationStatus.FAILED);
+            service.updateStatus(event.notificationId(), NotificationStatus.FAILED);
         }
     }
 
     private DiscordMessage getMessage(NotificationEvent event) {
+        FindUserQuery query = userClient.findUserById(event.userId())
+            .orElseThrow(NotificationUserNotFoundException::new);
         return new DiscordMessage(
             event.title(), // 메인 메시지 설정
-            "WOV Spring Boot", // 사용자 이름 설정
+            query.email() + "님", // 사용자 이름 설정
             null, // 아바타 URL 설정
             List.of(new DiscordMessage.Embed( // 유효한 임베드를 리스트로 추가
                 event.message(),
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/NotificationKafkaConsumer.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java
similarity index 92%
rename from notification/src/main/java/com/onevoice/notification/infrastructure/message/NotificationKafkaConsumer.java
rename to notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java
index fb4b613f..9cb8d370 100644
--- a/notification/src/main/java/com/onevoice/notification/infrastructure/message/NotificationKafkaConsumer.java
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java
@@ -1,22 +1,22 @@
-package com.onevoice.notification.infrastructure.message;
+package com.onevoice.notification.infrastructure.message.kafka;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
 import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
 import com.onevoice.notification.application.service.NotificationService;
 import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
 import java.util.UUID;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.kafka.annotation.KafkaListener;
 import org.springframework.stereotype.Component;
 
-@Slf4j(topic = "NotificationEmailConsumer")
+@Slf4j(topic = "NotificationTopicConsumer")
 @Component
 @RequiredArgsConstructor
-public class NotificationKafkaConsumer extends SecuredKafkaListener {
+public class NotificationTopicConsumer extends SecuredKafkaListener {
 
     private final NotificationService service;
     private final ObjectMapper objectMapper;
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
new file mode 100644
index 00000000..2094e8c9
--- /dev/null
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
@@ -0,0 +1,88 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.exception.NotificationUserNotFoundException;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        processTopicMessage(
+            message,
+            "seat_confirm",
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요."
+        );
+    }
+
+    @KafkaListener(topics = "seat_cancel")
+    public void seatCancel(String message) {
+        log.info("received seat cancel: {}", message);
+
+        processTopicMessage(
+            message,
+            "seat_cancel",
+            "좌석 확정 실패 알림",
+            "요청하신 좌석이 취소되었습니다."
+        );
+    }
+
+    @KafkaListener(topics = "seat_expired")
+    public void seatExpired(String message) {
+        log.info("received seat expired: {}", message);
+
+        processTopicMessage(
+            message,
+            "seat_expired",
+            "좌석 선점 만료 알림",
+            "요청하신 좌석의 선점 기한이 만료되었습니다."
+        );
+    }
+
+    private void processTopicMessage(String message, String topic, String title, String content) {
+        log.info("received {}: {}", topic, message);
+
+        UUID userId = getUserId(message)
+            .orElseThrow(NotificationUserNotFoundException::new);
+
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            title,
+            content,
+            "topic: " + topic
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    private Optional<UUID> getUserId(String message) {
+        try {
+            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
+                KafkaTopicMessage.class);
+            return Optional.ofNullable(topicMessage.payload().userId());
+        } catch (JsonProcessingException e) {
+            log.error("메시지 파싱 실패: {}", message, e);
+        }
+        return Optional.empty();
+    }
+}
diff --git a/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java
new file mode 100644
index 00000000..f43a2719
--- /dev/null
+++ b/notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java
@@ -0,0 +1,49 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "TicketTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class TicketTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "ticket_status")
+    public void ticketStatus(String message) {
+        log.info("received ticket status: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "티켓 확정 알림",
+            "결제하신 티켓이 확정되었습니다. 마이페이지를 확인해주세요.",
+            "topic: ticket_status"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    private UUID getUserId(String message) {
+        try {
+            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
+                KafkaTopicMessage.class);
+            return topicMessage.payload().userId();
+        } catch (JsonProcessingException e) {
+            log.error("failed to parse", e);
+        }
+        return null;
+    }
+}
diff --git a/notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java b/notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java
index 2df73bc6..3b98500f 100644
--- a/notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java
+++ b/notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java
@@ -1,54 +1,52 @@
-//package com.onevoice.notification;
-//
-//import org.junit.jupiter.api.AfterAll;
-//import org.junit.jupiter.api.BeforeAll;
-//import org.junit.jupiter.api.Test;
-//import org.springframework.boot.test.context.SpringBootTest;
-//import org.springframework.context.annotation.Import;
-//import org.springframework.test.context.ActiveProfiles;
-//import org.springframework.test.context.DynamicPropertyRegistry;
-//import org.springframework.test.context.DynamicPropertySource;
-//import org.testcontainers.containers.GenericContainer;
-//import org.testcontainers.containers.PostgreSQLContainer;
-//import org.testcontainers.containers.wait.strategy.Wait;
-//
-//@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
-//@ActiveProfiles("test")
-//@Import(TestMailConfig.class)
-//class NotificationApplicationTests {
-//
-//    private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
-//        "postgres:latest").withExposedPorts(5432);
-//
-//    private static final GenericContainer<?> redis = new GenericContainer<>(
-//        "redis:7").withExposedPorts(6379)
-//        .waitingFor(Wait.forListeningPort());
-//
-//    @BeforeAll
-//    static void startContainers() {
-//        postgres.start();
-//        redis.start();
-//    }
-//
-//    @DynamicPropertySource
-//    static void configureDatabase(DynamicPropertyRegistry registry) {
-//        registry.add("spring.datasource.url", postgres::getJdbcUrl);
-//        registry.add("spring.datasource.username", postgres::getUsername);
-//        registry.add("spring.datasource.password", postgres::getPassword);
-//
-//        registry.add("spring.data.redis.host", redis::getHost);
-//        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
-//    }
-//
+package com.onevoice.notification;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class NotificationApplicationTests {
+
+    private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
+        "postgres:latest").withExposedPorts(5432);
+
+    private static final GenericContainer<?> redis = new GenericContainer<>(
+        "redis:7").withExposedPorts(6379)
+        .waitingFor(Wait.forListeningPort());
+
+    @BeforeAll
+    static void startContainers() {
+        postgres.start();
+        redis.start();
+    }
+
+    @DynamicPropertySource
+    static void configureDatabase(DynamicPropertyRegistry registry) {
+        registry.add("spring.datasource.url", postgres::getJdbcUrl);
+        registry.add("spring.datasource.username", postgres::getUsername);
+        registry.add("spring.datasource.password", postgres::getPassword);
+
+        registry.add("spring.data.redis.host", redis::getHost);
+        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
+    }
+
 //    @Test
 //    void contextLoads() {
 //
 //    }
-//
-//    @AfterAll
-//    static void stopContainers() {
-//        postgres.stop();
-//        redis.stop();
-//    }
-//}
-//
+
+    @AfterAll
+    static void stopContainers() {
+        postgres.stop();
+        redis.stop();
+    }
+}
+
diff --git a/notification/src/test/java/com/onevoice/notification/TestMailConfig.java b/notification/src/test/java/com/onevoice/notification/TestMailConfig.java
deleted file mode 100644
index c8a947d9..00000000
--- a/notification/src/test/java/com/onevoice/notification/TestMailConfig.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.onevoice.notification;
-
-import static org.mockito.Mockito.mock;
-
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.mail.javamail.JavaMailSender;
-
-@TestConfiguration
-public class TestMailConfig {
-
-    @Bean
-    public JavaMailSender javaMailSender() {
-        return mock(JavaMailSender.class);
-    }
-}
\ No newline at end of file
diff --git a/payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java b/payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java
index 85ff9eba..a8070cca 100644
--- a/payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java
+++ b/payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java
@@ -19,7 +19,6 @@ public class KakaoPayReadyCommand {
     private Integer quantity;
     private Integer totalAmount;
     private Integer taxFreeAmount;
-    private Integer vatAmount;
     private String approvalUrl;
     private String cancelUrl;
     private String failUrl;
diff --git a/payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java b/payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java
index 22073121..33f6e976 100644
--- a/payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java
+++ b/payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java
@@ -48,13 +48,12 @@ public KakaoPayReadyQuery ready(UUID paymentId) {
         // Request param: 필수 값만 지정
         KakaoPayReadyCommand readyCommand = KakaoPayReadyCommand.builder()
             .cid(cid)
-            .partnerOrderId("1")
+            .partnerOrderId(paymentQuery.paymentId().toString())
             .partnerUserId("1")
-            .itemName("상품명")
+            .itemName(paymentQuery.paymentId().toString())
             .quantity(1)
-            .totalAmount(1100)
+            .totalAmount(paymentQuery.paymentAmount())
             .taxFreeAmount(0)
-            .vatAmount(100)
             .approvalUrl(host + "/kakao/approve/" + paymentId)
             .cancelUrl(host + "/kakao/cancel/" + paymentId)
             .failUrl(host + "/kakao/fail/" + paymentId)
@@ -79,7 +78,7 @@ public KakaoPayReadyQuery ready(UUID paymentId) {
 
     @Override
     public String approve(UUID paymentId, String pgToken) {
-
+        log.info("APPROVING kakaoPayService: {}", paymentId);
         HttpHeaders headers = new HttpHeaders();
         headers.add("Authorization", "DEV_SECRET_KEY " + kakaoPaySecretKey);
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -87,12 +86,12 @@ public String approve(UUID paymentId, String pgToken) {
         // ready 할 때 저장해놓은 TID(pgKey)로 승인 요청
         FindPaymentQuery paymentQuery = paymentService.read(paymentId);
         String tid = paymentQuery.pgKey();
-
+        log.info("APPROVED tid: {}", tid);
         // Request param
         KakaoPayApproveCommand approveCommand = KakaoPayApproveCommand.builder()
             .cid(cid)
             .tid(tid)
-            .partnerOrderId("1")
+            .partnerOrderId(paymentQuery.paymentId().toString())
             .partnerUserId("1")
             .pgToken(pgToken)
             .build();
diff --git a/payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java b/payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java
index e56ffa79..4bc3a367 100644
--- a/payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java
+++ b/payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java
@@ -16,11 +16,13 @@
 import java.util.Objects;
 import java.util.UUID;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+@Slf4j
 @Service
 @Transactional
 @RequiredArgsConstructor
@@ -41,19 +43,12 @@ public UUID create(CreatePaymentCommand command) {
         );
         Payment saved = repository.save(payment);
 
-        KafkaTopicType kafkaTopicType = KafkaTopicType.PAYMENT_CREATE;
+        KafkaTopicType topicType = KafkaTopicType.PAYMENT_CREATE;
         PaymentCreateMessage payload = new PaymentCreateMessage(command.ticketId());
-        eventPublish(kafkaTopicType, payload);
+        eventPublish(topicType, payload);
         return saved.getPaymentId();
     }
 
-    private <T> void eventPublish(KafkaTopicType kafkaTopicType, T payload) {
-        GenericKafkaEvent<T> event = new GenericKafkaEvent<>(
-            kafkaTopicType.getTopic(),
-            payload);
-        eventPublisher.publishEvent(event);
-    }
-
     @Override
     @Transactional(readOnly = true)
     public ListPaymentQuery reads(UUID userid, Pageable pageable) {
@@ -81,20 +76,21 @@ public FindPaymentQuery read(UUID userId, UUID paymentId) {
 
     @Override
     public void update(UUID paymentId, PaymentStatus paymentStatus) {
+        log.info("Updating payment {}", paymentId);
         Payment payment = repository.findById(paymentId)
             .orElseThrow(PaymentNotFoundException::new);
         payment.update(paymentStatus);
 
         // 결제 결과 이벤트 발행
-        KafkaTopicType kafkaTopicType;
+        KafkaTopicType topicType;
         if (Objects.requireNonNull(paymentStatus) == PaymentStatus.PG_APPROVE) {
+            topicType = KafkaTopicType.PAYMENT_SUCCESS;
             PaymentSuccessMessage payload = new PaymentSuccessMessage(payment.getTicketId());
-            kafkaTopicType = KafkaTopicType.PAYMENT_SUCCESS;
-            eventPublish(kafkaTopicType, payload);
+            eventPublish(topicType, payload);
         } else {
-            kafkaTopicType = KafkaTopicType.PAYMENT_FAIL;
+            topicType = KafkaTopicType.PAYMENT_FAIL;
             PaymentFailMessage payload = new PaymentFailMessage(payment.getTicketId());
-            eventPublish(kafkaTopicType, payload);
+            eventPublish(topicType, payload);
         }
     }
 
@@ -157,4 +153,11 @@ public ListPaymentQuery search(
             .toList();
         return new ListPaymentQuery(queryList);
     }
+
+    private <T> void eventPublish(KafkaTopicType topicType, T payload) {
+        GenericKafkaEvent<T> event = new GenericKafkaEvent<>(
+            topicType.getTopic(),
+            payload);
+        eventPublisher.publishEvent(event);
+    }
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 10**

<details>
<summary>🔭 Outside diff range comments (2)</summary><blockquote>

<details>
<summary>payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java (1)</summary><blockquote>

`101-116`: _🧹 Nitpick (assertive)_

**오류 처리 개선 가능성**

현재 구현은 HTTP 상태 코드 예외를 잡아 응답 본문을 반환하고 있지만, 추가적인 분석이나 로깅은 없습니다. 오류 상황에서 더 자세한 정보를 로깅하고, 가능하다면 오류 유형에 따라 다른 처리 로직을 구현하는 것이 좋을 것 같습니다.


오류 처리를 개선하기 위해 다음과 같은 변경을 고려해보세요:

```diff
try {
    ResponseEntity<String> response = new RestTemplate().postForEntity(
        "https://open-api.kakaopay.com/online/v1/payment/approve",
        entityMap,
        String.class
    );

    String approveResponse = response.getBody();
    // TODO: response 에서 카드 결제가 성공했는지 실패했는지 확인해야 한다.
    // 참고 https://developers.kakaopay.com/docs/payment/online/single-payment
    paymentService.update(paymentId, PaymentStatus.PG_APPROVE);
    return approveResponse;
} catch (HttpStatusCodeException ex) {
+   log.error("카카오페이 결제 승인 실패: paymentId={}, status={}, response={}", 
+             paymentId, ex.getStatusCode(), ex.getResponseBodyAsString());
+   paymentService.update(paymentId, PaymentStatus.PG_FAIL);
    return ex.getResponseBodyAsString();
+} catch (Exception ex) {
+   log.error("카카오페이 결제 승인 중 예상치 못한 오류 발생: paymentId={}", paymentId, ex);
+   paymentService.update(paymentId, PaymentStatus.PG_FAIL);
+   return "결제 처리 중 오류가 발생했습니다.";
}
```

</blockquote></details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java (1)</summary><blockquote>

`24-38`: _🧹 Nitpick (assertive)_

**KafkaTopicMessage record 활용 제안**  
수동 `JsonNode` 파싱 대신 PR에 추가된 `KafkaTopicMessage` 레코드를 사용하면 코드 가독성 및 타입 안전성을 높일 수 있습니다. 다음과 같이 리팩터링을 고려해보세요:

```diff
-import com.fasterxml.jackson.databind.JsonNode;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;

 // ...

-    JsonNode node = objectMapper.readTree(message);
-    UUID userId = UUID.fromString(node.get("userId").asText());
-    NotificationType type = NotificationType.valueOf(node.get("type").asText());
+    KafkaTopicMessage payload = objectMapper.readValue(message, KafkaTopicMessage.class);
+    UUID userId = payload.userId();
+    NotificationType type = payload.type();

-    CreateNotificationCommand command = new CreateNotificationCommand(
-        userId,
-        type,
-        "[WOV] 결제하신 내역입니다.",
-        getMessage(node),
-        getMetadata(node)
-    );
+    CreateNotificationCommand command = new CreateNotificationCommand(
+        userId,
+        type,
+        payload.title(),             // KafkaTopicMessage에 title 필드가 있다면
+        payload.message(),           // message 필드
+        payload.metadata()           // metadata 필드
+    );
```

이렇게 하면 JSON 구조 변경 시 일일이 필드를 탐색하는 수고를 덜 수 있습니다.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: ASSERTIVE**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between cdb0a6d91546b1f25585b0b857f3edc1a7efb5e9 and f48e713bdb9eeb346c9d48f1d768da8a661a6e89.

</details>

<details>
<summary>📒 Files selected for processing (13)</summary>

* `notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/application/dto/message/DiscordMessage.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/application/dto/message/KafkaTopicMessage.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java` (3 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java` (2 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java` (1 hunks)
* `notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java` (1 hunks)
* `notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java` (1 hunks)
* `payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java` (0 hunks)
* `payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java` (2 hunks)
* `payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java` (4 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* payment/src/main/java/com/onevoice/payment/application/dto/command/KakaoPayReadyCommand.java

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java (1)</summary><blockquote>

<details>
<summary>payment/src/test/java/com/onevoice/payment/PaymentApplicationTests.java (1)</summary>

* `SpringBootTest` (14-51)

</details>

</blockquote></details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java (1)</summary><blockquote>

<details>
<summary>notification/src/main/java/com/onevoice/notification/exception/NotificationUserNotFoundException.java (1)</summary>

* `NotificationUserNotFoundException` (6-11)

</details>

</blockquote></details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java (2)</summary><blockquote>

<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/config/SecuredKafkaListener.java (1)</summary>

* `SecuredKafkaListener` (14-33)

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java (1)</summary>

* `Slf4j` (16-79)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: 🧪 Build & Test Changed Modules (payment)
* GitHub Check: 🧪 Build & Test Changed Modules (notification)

</details>

<details>
<summary>🔇 Additional comments (17)</summary><blockquote>

<details>
<summary>payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java (4)</summary>

`19-19`: **로깅 기능 추가는 좋은 접근입니다.**

Lombok의 @Slf4j 애노테이션을 추가하여 로깅 기능을 활성화한 것은 좋은 개선입니다. 이를 통해 애플리케이션의 디버깅과 모니터링이 용이해질 것입니다.



Also applies to: 25-25

---

`46-46`: **변수명 일관성 개선이 좋습니다.**

`kafkaTopicType`에서 `topicType`으로 변수명을 변경한 것은 코드의 일관성을 높이는 좋은 변경입니다. 이러한 일관된 네이밍 규칙은 코드 가독성과 유지보수성을 향상시킵니다.



Also applies to: 48-48

---

`79-79`: **로그 메시지 추가는 적절합니다.**

결제 업데이트 시작 시점에 로그 메시지를 추가한 것은 모니터링과 디버깅에 도움이 됩니다. 시스템의 상태 변화를 추적하기 쉬워집니다.

---

`157-162`: **이벤트 발행 메소드 위치 이동 및 매개변수명 변경**

`eventPublish` 메소드를 클래스 끝으로 이동하고 매개변수명을 변경한 것은 코드 구조와 일관성 측면에서 적절한 개선입니다. 프라이빗 헬퍼 메소드를 클래스 끝에 배치하는 것은 가독성을 높이는 좋은 관행입니다.

</details>
<details>
<summary>payment/src/main/java/com/onevoice/payment/application/service/KakaoPayServiceImpl.java (3)</summary>

`51-56`: **하드코딩된 값 대신 실제 결제 정보 사용**

하드코딩된 값 대신 실제 결제 정보를 사용하도록 수정한 것은 매우 중요한 개선입니다. 이제 `partnerOrderId`, `itemName`, `totalAmount`가 실제 결제 데이터를 반영하므로 정확성과 추적성이 크게 향상됩니다. 또한 `vatAmount` 필드가 제거되어 간결해졌습니다.


실제 결제 ID를 사용하여 트랜잭션을 추적하는 것이 가능해졌고, 실제 결제 금액이 카카오페이에 전송되므로 결제 오류 가능성이 크게 줄어들 것입니다.

---

`81-81`: **로깅 개선으로 추적성 향상**

결제 승인 과정에서 로깅을 추가한 것은 디버깅과 모니터링을 용이하게 만드는 좋은 개선입니다. 특히 결제 ID와 TID를 로깅함으로써 문제 발생 시 트랜잭션 추적이 용이해질 것입니다.



Also applies to: 89-89

---

`94-94`: **결제 ID를 활용한 partnerOrderId 설정**

`partnerOrderId`에 하드코딩된 값 대신 실제 결제 ID를 사용하도록 변경한 것은 매우 적절합니다. 이로써 카카오페이 시스템에서의 주문 추적과 내부 시스템에서의 결제 추적 간의 일관성이 보장됩니다.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/application/dto/message/DiscordMessage.java (1)</summary>

`1-1`: **패키지 구조 일관성 확인**  
`DiscordMessage` 클래스의 패키지가 `dto.message`로 옮겨진 것이 경로와 일치하는지 확인해주세요. 파일 경로와 `package` 선언이 맞아야 import 충돌 없이 정상 동작합니다.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/application/client/DiscordClient.java (1)</summary>

`3-3`: **DTO 패키지 경로 반영 확인**  
`DiscordMessage`의 import 경로가 새로운 `dto.message` 패키지로 올바르게 업데이트되었습니다. 다른 곳에서도 해당 DTO를 사용하는 부분이 모두 반영되었는지 검토 부탁드립니다.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/NotificationHandler.java (1)</summary>

`8-8`: **인터페이스 레벨 주석으로 중앙집중화**  
팩토리 로직에서 개별 구현체의 `supports()` 타입 비교가 불필요함을 설명하는 주석이 인터페이스로 이동되어 가독성이 좋아졌습니다. 구현체에서 동일 주석을 제거한 것도 적절합니다.

</details>
<details>
<summary>notification/src/test/java/com/onevoice/notification/NotificationApplicationTests.java (1)</summary>

`1-54`: **테스트 환경 설정 완료, 좋은 구현입니다!**

테스트 컨테이너를 활용한 통합 테스트 환경 구성이 적절하게 이루어졌습니다. PostgreSQL과 Redis를 사용하는 테스트 환경을 동적으로 구성하고 있으며, 이는 실제 환경과 유사한 조건에서 애플리케이션을 테스트할 수 있게 합니다.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/application/dto/message/KafkaTopicMessage.java (1)</summary>

`1-15`: **매우 간결하고 명확한 DTO 설계입니다.**

Kafka 토픽 메시지를 처리하기 위한 레코드 구조가 잘 설계되었습니다. 중첩된 레코드를 사용하여 페이로드 정보를 구조화한 점이 좋습니다.

다만, 향후 다양한 Kafka 토픽 메시지 타입을 지원해야 할 경우, 현재 구조에서는 userId만 포함하고 있어 확장이 필요할 수 있습니다. 필요시 페이로드 구조를 확장하는 것을 고려해보세요.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/PushNotificationHandler.java (4)</summary>

`4-5`: **임포트 경로 수정이 적절합니다.**

`DiscordMessage` 클래스의 임포트 경로가 변경되었고, `UserClient` 및 `NotificationUserNotFoundException` 관련 임포트가 추가되었습니다. 패키지 구조 개선에 맞게 적절히 수정되었습니다.



Also applies to: 6-7, 11-11

---

`22-24`: **의존성 이름이 더 명확해졌습니다.**

클라이언트와 서비스 객체의 이름이 더 명확하게 변경되었습니다(`discordClient` → `discord`, `notificationService` → `service`). 또한 `UserClient`를 추가하여 사용자 정보를 조회할 수 있게 되었습니다.

---

`34-35`: **메소드 호출이 의존성 이름 변경에 맞게 업데이트되었습니다.**

변경된 의존성 이름(`discord`, `service`)에 맞게 메소드 호출이 적절히 업데이트되었습니다.



Also applies to: 38-38

---

`43-48`: **사용자 정보를 활용한 개인화된 알림 메시지 개선이 좋습니다.**

사용자 ID를 기반으로 사용자 정보를 조회하여 알림 메시지에 이메일과 "님"을 추가하는 방식으로 개인화된 메시지를 구성하도록 개선되었습니다. 사용자를 찾지 못할 경우 적절한 예외를 발생시키는 것도 좋습니다.

다만, 이메일 주소를 사용자 이름으로 사용하는 것은 개인정보 보호 측면에서 다시 검토해볼 필요가 있습니다. 사용자 이름(닉네임) 등 다른 식별자 사용을 고려해보세요.

</details>
<details>
<summary>notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java (1)</summary>

`16-23`: **클래스 구조 및 의존성 주입이 잘 설계되었습니다.**

Kafka 토픽 소비자의 로깅, 컴포넌트 선언, 생성자 주입이 모두 적절하게 구현되었습니다. 특히 Lombok을 활용한 간결한 코드 구성이 좋습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-17T21:22:35Z
user: login: kopite97
body: 수고하셨습니다
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T03:21:26Z

Review comments:
diff_hunk: @@ -81,20 +76,21 @@ public FindPaymentQuery read(UUID userId, UUID paymentId) {
 
     @Override
     public void update(UUID paymentId, PaymentStatus paymentStatus) {
+        log.info("Updating payment {}", paymentId);
         Payment payment = repository.findById(paymentId)
             .orElseThrow(PaymentNotFoundException::new);
         payment.update(paymentStatus);
 
         // 결제 결과 이벤트 발행
-        KafkaTopicType kafkaTopicType;
+        KafkaTopicType topicType;
         if (Objects.requireNonNull(paymentStatus) == PaymentStatus.PG_APPROVE) {
             PaymentSuccessMessage payload = new PaymentSuccessMessage(payment.getTicketId());
-            kafkaTopicType = KafkaTopicType.PAYMENT_SUCCESS;
-            eventPublish(kafkaTopicType, payload);
+            topicType = KafkaTopicType.PAYMENT_SUCCESS;
+            eventPublish(topicType, payload);
         } else {
-            kafkaTopicType = KafkaTopicType.PAYMENT_FAIL;
+            topicType = KafkaTopicType.PAYMENT_FAIL;
             PaymentFailMessage payload = new PaymentFailMessage(payment.getTicketId());
-            eventPublish(kafkaTopicType, payload);
+            eventPublish(topicType, payload);
         }
path: payment/src/main/java/com/onevoice/payment/application/service/PaymentServiceImpl.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Kafka 이벤트 발행 코드 구조 개선**

이벤트 발행 코드의 구조를 변경하고 변수명을 일관성 있게 수정한 것은 좋은 접근입니다. 다만, 성공과 실패 케이스에서 `topicType` 변수의 선언 위치가 다른데, 일관성을 위해 두 케이스 모두 메시지 페이로드 생성 전에 토픽 타입을 설정하는 것이 더 좋을 것 같습니다.

두 케이스의 코드 구조를 더 일관되게 만들기 위해 다음과 같이 수정하는 것을 고려해보세요:

```diff
  // 결제 결과 이벤트 발행
  KafkaTopicType topicType;
  if (Objects.requireNonNull(paymentStatus) == PaymentStatus.PG_APPROVE) {
-   PaymentSuccessMessage payload = new PaymentSuccessMessage(payment.getTicketId());
    topicType = KafkaTopicType.PAYMENT_SUCCESS;
+   PaymentSuccessMessage payload = new PaymentSuccessMessage(payment.getTicketId());
    eventPublish(topicType, payload);
  } else {
    topicType = KafkaTopicType.PAYMENT_FAIL;
    PaymentFailMessage payload = new PaymentFailMessage(payment.getTicketId());
    eventPublish(topicType, payload);
  }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

`````suggestion
        KafkaTopicType topicType;
        if (Objects.requireNonNull(paymentStatus) == PaymentStatus.PG_APPROVE) {
            topicType = KafkaTopicType.PAYMENT_SUCCESS;
            PaymentSuccessMessage payload = new PaymentSuccessMessage(payment.getTicketId());
            eventPublish(topicType, payload);
        } else {
            topicType = KafkaTopicType.PAYMENT_FAIL;
            PaymentFailMessage payload = new PaymentFailMessage(payment.getTicketId());
            eventPublish(topicType, payload);
        }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:33Z
author_association: NONE
diff_hunk: @@ -74,7 +73,7 @@ private SimpleMailMessage getSimpleMailMessage(NotificationEvent event, String e
     }
 
     private String getText(String username, String message) {
-        return "to: " + username + "," + System.lineSeparator() + System.lineSeparator()
+        return "to: " + username + "님," + System.lineSeparator() + System.lineSeparator()
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**이메일 인사말 대상 검증 필요**  
인사말에 `"님"`을 추가했는데, 실제로는 `username` 매개변수에 이메일 주소(`email`)가 전달되고 있습니다. 이메일 주소에 `"님"`을 붙이는 UX가 부적절할 수 있으며, 변수명(`username`)과 로직이 일치하는지도 재검토가 필요합니다.  

현재 `getText(String username, ...)` 호출부:
```java
String email = userClient.findUserById(...).map(FindUserQuery::email).orElseThrow();
SimpleMailMessage message = getSimpleMailMessage(event, email);
```
- 이메일이 아닌 사용자 이름을 조회해 인사말로 사용해야 합니다.
- 또는 변수명과 리턴 메시지를 이메일 전송용임을 반영하도록 수정해야 합니다.  
이 부분을 어떻게 처리할지 확인 부탁드립니다.

---

🏁 Script executed:

```shell
#!/bin/bash
set -e

# Locate the handler class and show its beginning
rg -n "class EmailNotificationHandler" -A20

# Show the getSimpleMailMessage method implementation
rg -n "getSimpleMailMessage" -C10

# Show the getText method signature and its usage
rg -n "getText" -C5
```

Length of output: 15220

---



</details>

**이메일 인사말 대상 검증 및 변수명/로직 수정 필요**  
`getText` 메서드에 이메일 주소를 `username`으로 넘겨 `"님"`을 붙이고 있어 부적절한 UX를 낳고 있습니다. 아래 중 한 가지 방안을 선택해 수정해주세요:

- 사용자 이름(name)을 조회해 인사말로 사용  
  ```java
  // handle() 내부
  String name = userClient.findUserById(event.userId())
      .map(FindUserQuery::name)
      .orElseThrow();
  SimpleMailMessage message = getSimpleMailMessage(event, email, name);
  …
  
  // getSimpleMailMessage 시그니처 변경
  private SimpleMailMessage getSimpleMailMessage(NotificationEvent event, String email, String name) {
      message.setText(getText(name, event.message()));
      …
  }
  ```
- 변수명과 메서드명을 이메일 전송용으로 명시하고 인사말에서 “님” 제거  
  ```diff
  - private String getText(String username, String message) {
  -     return "to: " + username + "님," + …;
  + private String buildEmailBody(String email, String message) {
  +     return "to: " + email + "," + System.lineSeparator() + …;
  ```
파일: notification/src/main/java/com/onevoice/notification/infrastructure/message/handler/EmailNotificationHandler.java  
라인: 75–80

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -16,7 +16,7 @@
 @Slf4j(topic = "NotificationEmailConsumer")
 @Component
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/NotificationTopicConsumer.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Slf4j 로깅 토픽 명 불일치**  
`@Slf4j(topic = "NotificationEmailConsumer")`로 지정된 로깅 토픽이 클래스명(`NotificationTopicConsumer`)과 불일치합니다. 로그 필터링이나 모니터링 시 혼선이 발생할 수 있으니 로깅 토픽명을 업데이트해주세요.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "TicketTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class TicketTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "ticket_status")
+    public void ticketStatus(String message) {
+        log.info("received ticket status: {}", message);
+
+        try {
+            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
+                KafkaTopicMessage.class);
+            UUID userId = topicMessage.payload().userId();
+            CreateNotificationCommand command = new CreateNotificationCommand(
+                userId,
+                NotificationType.PUSH,
+                "티켓 확정 알림",
+                "결제하신 티켓이 확정되었습니다. 마이페이지를 확인해주세요.",
+                "topic: ticket_status"
+            );
+            withSecurityContext(userId, () -> service.create(command));
+        } catch (JsonProcessingException e) {
+            log.error("failed to parse", e);
+        }
+    }
+}
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/TicketTopicConsumer.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Kafka 토픽 소비자 구현이 잘 되었습니다.**

티켓 상태 알림을 위한 Kafka 토픽 소비자가 적절하게 구현되었습니다. 메시지를 파싱하고 알림 서비스를 호출하는 로직이 명확합니다.

몇 가지 개선 가능한 부분:

1. `getUserId` 메소드를 추출하여 코드를 더 간결하게 만들 수 있습니다. `SeatTopicConsumer` 클래스와 같은 패턴을 사용하는 것이 좋겠습니다.
2. 예외 처리 시 null을 반환하는 대신 더 명확한 예외 처리 방법을 고려해보세요.


다음과 같이 코드를 리팩토링할 수 있습니다:

```diff
 @KafkaListener(topics = "ticket_status")
 public void ticketStatus(String message) {
     log.info("received ticket status: {}", message);

+    UUID userId = getUserId(message);
+    if (userId != null) {
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "티켓 확정 알림",
+            "결제하신 티켓이 확정되었습니다. 마이페이지를 확인해주세요.",
+            "topic: ticket_status"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+}
+
+private UUID getUserId(String message) {
     try {
         KafkaTopicMessage topicMessage = objectMapper.readValue(message,
             KafkaTopicMessage.class);
-        UUID userId = topicMessage.payload().userId();
-        CreateNotificationCommand command = new CreateNotificationCommand(
-            userId,
-            NotificationType.PUSH,
-            "티켓 확정 알림",
-            "결제하신 티켓이 확정되었습니다. 마이페이지를 확인해주세요.",
-            "topic: ticket_status"
-        );
-        withSecurityContext(userId, () -> service.create(command));
+        return topicMessage.payload().userId();
     } catch (JsonProcessingException e) {
         log.error("failed to parse", e);
+        return null;
     }
 }
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,79 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
+            "topic: seat_confirm"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_cancel")
+    public void seatCancel(String message) {
+        log.info("received seat cancel: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 실패 알림",
+            "요청하신 좌석이 취소되었습니다.",
+            "topic: seat_cancel"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_expired")
+    public void seatExpired(String message) {
+        log.info("received seat expired: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 선점 만료 알림",
+            "요청하신 좌석의 선점 기한이 만료되었습니다.",
+            "topic: seat_expired"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    private UUID getUserId(String message) {
+        try {
+            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
+                KafkaTopicMessage.class);
+            return topicMessage.payload().userId();
+        } catch (JsonProcessingException e) {
+            log.error("failed to parse", e);
+        }
+        return null;
+    }
+}
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**메시지 문자열 외부화를 고려해 보세요.**

알림 제목과 내용이 코드에 하드코딩되어 있습니다. 국제화 지원이나 메시지 변경 시 유지보수성을 높이기 위해 메시지를 외부화하는 것이 좋습니다.

Spring의 MessageSource를 활용하여 메시지를 외부화하는 방법을 고려해 보세요:

1. messages.properties 파일에 키-값 쌍으로 메시지 정의:
```
notification.seat.confirm.title=좌석 확정 알림
notification.seat.confirm.message=요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.
# 다른 메시지도 유사하게 정의
```

2. MessageSource를 주입받아 메시지 사용:
```java
private final MessageSource messageSource;

// 알림 생성 시
String title = messageSource.getMessage("notification.seat.confirm.title", null, Locale.getDefault());
String content = messageSource.getMessage("notification.seat.confirm.message", null, Locale.getDefault());
```

이렇게 하면 다국어 지원이 필요할 때 쉽게 확장할 수 있습니다.

---

_🧹 Nitpick (assertive)_

**코드 중복이 있으므로 리팩토링을 고려해 보세요.**

세 가지 Kafka 리스너 메서드가 매우 유사한 구조를 가지고 있습니다. 메시지 타입에 따라 제목과 내용만 달라지므로, 이 부분을 리팩토링하여 중복을 줄일 수 있습니다.

다음과 같은 리팩토링을 고려해 보세요:

```java
private void processTopicMessage(String message, String topic, String title, String content) {
    log.info("received {}: {}", topic, message);
    
    UUID userId = getUserId(message);
    if (userId == null) {
        log.error("사용자 ID를 추출할 수 없어 알림을 생성할 수 없습니다. message: {}", message);
        return;
    }
    
    CreateNotificationCommand command = new CreateNotificationCommand(
        userId,
        NotificationType.PUSH,
        title,
        content,
        "topic: " + topic
    );
    withSecurityContext(userId, () -> service.create(command));
}

@KafkaListener(topics = "seat_confirm")
public void seatConfirm(String message) {
    processTopicMessage(
        message,
        "seat_confirm",
        "좌석 확정 알림",
        "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요."
    );
}

// 나머지 리스너 메서드도 동일하게 변경
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,79 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
+            "topic: seat_confirm"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_cancel")
+    public void seatCancel(String message) {
+        log.info("received seat cancel: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 실패 알림",
+            "요청하신 좌석이 취소되었습니다.",
+            "topic: seat_cancel"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_expired")
+    public void seatExpired(String message) {
+        log.info("received seat expired: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 선점 만료 알림",
+            "요청하신 좌석의 선점 기한이 만료되었습니다.",
+            "topic: seat_expired"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**좌석 만료 알림 처리 로직에도 null 체크가 필요합니다.**

좌석 만료 메시지 처리에도 동일하게 userId가 null일 경우에 대한 체크가 필요합니다.

이전 코멘트와 유사한 null 체크 로직을 추가해주세요.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,79 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
+            "topic: seat_confirm"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_cancel")
+    public void seatCancel(String message) {
+        log.info("received seat cancel: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 실패 알림",
+            "요청하신 좌석이 취소되었습니다.",
+            "topic: seat_cancel"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**좌석 취소 알림 처리 로직에도 null 체크가 필요합니다.**

좌석 취소 메시지 처리에도 동일하게 userId가 null일 경우에 대한 체크가 필요합니다.

이전 코멘트와 유사한 null 체크 로직을 추가해주세요.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,79 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
+            "topic: seat_confirm"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**좌석 확정 알림 처리 로직 구현이 적절합니다만, null 처리가 필요합니다.**

좌석 확정 메시지를 수신하고 알림을 생성하는 로직은 잘 작성되었습니다. 그러나 `getUserId()` 메서드에서 파싱 실패 시 null을 반환할 수 있는데, 이에 대한 null 체크가 없습니다.

다음과 같이 null 체크를 추가하는 것이 좋겠습니다:

```diff
    @KafkaListener(topics = "seat_confirm")
    public void seatConfirm(String message) {
        log.info("received seat confirm: {}", message);

        UUID userId = getUserId(message);
+       if (userId == null) {
+           log.error("사용자 ID를 추출할 수 없어 알림을 생성할 수 없습니다. message: {}", message);
+           return;
+       }
        CreateNotificationCommand command = new CreateNotificationCommand(
            userId,
            NotificationType.PUSH,
            "좌석 확정 알림",
            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
            "topic: seat_confirm"
        );
        withSecurityContext(userId, () -> service.create(command));
    }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

`````suggestion
    @KafkaListener(topics = "seat_confirm")
    public void seatConfirm(String message) {
        log.info("received seat confirm: {}", message);

        UUID userId = getUserId(message);
        if (userId == null) {
            log.error("사용자 ID를 추출할 수 없어 알림을 생성할 수 없습니다. message: {}", message);
            return;
        }
        CreateNotificationCommand command = new CreateNotificationCommand(
            userId,
            NotificationType.PUSH,
            "좌석 확정 알림",
            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
            "topic: seat_confirm"
        );
        withSecurityContext(userId, () -> service.create(command));
    }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,79 @@
+package com.onevoice.notification.infrastructure.message.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.onevoice.notification.application.dto.command.CreateNotificationCommand;
+import com.onevoice.notification.application.dto.message.KafkaTopicMessage;
+import com.onevoice.notification.application.service.NotificationService;
+import com.onevoice.notification.domain.NotificationType;
+import com.onevoice.notification.infrastructure.config.SecuredKafkaListener;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j(topic = "SeatTopicConsumer")
+@Component
+@RequiredArgsConstructor
+public class SeatTopicConsumer extends SecuredKafkaListener {
+
+    private final NotificationService service;
+    private final ObjectMapper objectMapper;
+
+    @KafkaListener(topics = "seat_confirm")
+    public void seatConfirm(String message) {
+        log.info("received seat confirm: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 알림",
+            "요청하신 좌석이 확정되었습니다. 결제를 완료해주세요.",
+            "topic: seat_confirm"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_cancel")
+    public void seatCancel(String message) {
+        log.info("received seat cancel: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 확정 실패 알림",
+            "요청하신 좌석이 취소되었습니다.",
+            "topic: seat_cancel"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    @KafkaListener(topics = "seat_expired")
+    public void seatExpired(String message) {
+        log.info("received seat expired: {}", message);
+
+        UUID userId = getUserId(message);
+        CreateNotificationCommand command = new CreateNotificationCommand(
+            userId,
+            NotificationType.PUSH,
+            "좌석 선점 만료 알림",
+            "요청하신 좌석의 선점 기한이 만료되었습니다.",
+            "topic: seat_expired"
+        );
+        withSecurityContext(userId, () -> service.create(command));
+    }
+
+    private UUID getUserId(String message) {
+        try {
+            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
+                KafkaTopicMessage.class);
+            return topicMessage.payload().userId();
+        } catch (JsonProcessingException e) {
+            log.error("failed to parse", e);
+        }
+        return null;
+    }
path: notification/src/main/java/com/onevoice/notification/infrastructure/message/kafka/SeatTopicConsumer.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**예외 처리 시 null 반환은 위험할 수 있습니다.**

JSON 파싱 예외가 발생했을 때 null을 반환하고 있는데, 이 경우 호출자가 null 체크를 하지 않으면 NullPointerException이 발생할 수 있습니다.

두 가지 개선 방안이 있습니다:
1. null 대신 Optional<UUID>를 반환하도록 변경
2. null을 반환하되 호출하는 모든 곳에서 null 체크 추가 (앞서 제안한 대로)

```diff
-   private UUID getUserId(String message) {
+   private Optional<UUID> getUserId(String message) {
        try {
            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
                KafkaTopicMessage.class);
-           return topicMessage.payload().userId();
+           return Optional.ofNullable(topicMessage.payload().userId());
        } catch (JsonProcessingException e) {
            log.error("failed to parse", e);
        }
-       return null;
+       return Optional.empty();
    }
```

또한 로그 메시지를 더 상세하게 개선하는 것이 좋겠습니다:
```diff
-           log.error("failed to parse", e);
+           log.error("메시지 파싱 실패: {}", message, e);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

`````suggestion
    // add at top of file if not already present:
    // import java.util.Optional;

    private Optional<UUID> getUserId(String message) {
        try {
            KafkaTopicMessage topicMessage = objectMapper.readValue(message,
                KafkaTopicMessage.class);
            return Optional.ofNullable(topicMessage.payload().userId());
        } catch (JsonProcessingException e) {
            log.error("메시지 파싱 실패: {}", message, e);
        }
        return Optional.empty();
    }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-17T21:22:34Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-17T21:19:31Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto incremental reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

이번 변경 사항에서는 알림(notification) 및 결제(payment) 시스템의 여러 부분에 걸쳐 다양한 구조적, 기능적 수정이 이루어졌습니다. 알림 모듈에서는 `DiscordMessage` 클래스의 패키지 위치가 변경되었고, Kafka 메시지 처리를 위한 `KafkaTopicMessage` 레코드와 이를 활용하는 새로운 Kafka Consumer 클래스(`SeatTopicConsumer`, `TicketTopicConsumer`)가 추가되었습니다. 기존의 `NotificationKafkaConsumer`는 `NotificationTopicConsumer`로 클래스명과 패키지가 변경되었습니다. `PushNotificationHandler`는 `UserClient`를 통해 사용자 정보를 조회하여 알림 메시지에 사용자 이메일과 한글 호칭("님")을 포함하도록 개선되었습니다. 테스트 코드(`NotificationApplicationTests`)는 주석이 해제되어 실제 테스트가 가능해졌습니다. 결제 모듈에서는 `KakaoPayReadyCommand`에서 `vatAmount` 필드가 제거되었고, `KakaoPayServiceImpl`에서 결제 요청 시 하드코딩된 값 대신 실제 결제 정보가 동적으로 사용되도록 수정되었습니다. 또한, `PaymentServiceImpl`에는 로깅 기능이 추가되고 변수명이 일관성 있게 정리되었습니다.

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Kafka as Kafka
    participant SeatTopicConsumer
    participant TicketTopicConsumer
    participant NotificationService
    participant SecurityContext

    Kafka->>SeatTopicConsumer: seat_confirm / seat_cancel / seat_expired 메시지 수신
    SeatTopicConsumer->>SeatTopicConsumer: 메시지 역직렬화 및 userId 추출
    SeatTopicConsumer->>SecurityContext: withSecurityContext(userId)
    SecurityContext->>NotificationService: create(CreateNotificationCommand)
    NotificationService-->>SecurityContext: 알림 생성 완료

    Kafka->>TicketTopicConsumer: ticket_status 메시지 수신
    TicketTopicConsumer->>TicketTopicConsumer: 메시지 역직렬화 및 userId 추출
    TicketTopicConsumer->>SecurityContext: withSecurityContext(userId)
    SecurityContext->>NotificationService: create(CreateNotificationCommand)
    NotificationService-->>SecurityContext: 알림 생성 완료
```

```mermaid
sequenceDiagram
    participant PushNotificationHandler
    participant UserClient
    participant DiscordClient
    participant NotificationService

    PushNotificationHandler->>UserClient: findUserById(userId)
    UserClient-->>PushNotificationHandler: 사용자 정보 반환 (이메일 등)
    PushNotificationHandler->>DiscordClient: send(DiscordMessage(이메일+님, ...))
    DiscordClient-->>PushNotificationHandler: 전송 결과
    PushNotificationHandler->>NotificationService: 기타 알림 처리
```

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- DwQgtGAEAqAWCWBnSTIEMB26CuAXA9mAOYCmGJATmriQCaQDG+Ats2bgFyQAOFk+AIwBWJBrngA3EsgEBPRvlqU0AgfFwA6NPEgQAfACgjoCEYDEZyAAUASpETZWaCrKNxU3bABsvkCiQBHbGlcABpIcVwvOkgAIgAxEmpsfwB6MwBGAAYAZkgAMyTcSAx8cXz4Bmp4fCxsblpqEkgAazR8toj8bkrQ2PRaWn9ERGkUEeDITNyULFxYZvEGFpJxDCIwUYoJSub/bnxEdXwXDRgFyGZtLHz8BmxkfHyIi9txrpRmbmi2DGKAaXanQIPQYl2kiDQpD8ohI3HEtUgsEwtC88HWBROJTK8AqVQRGEQZwAgoN1DUMGgfLJwupZgwvNglMhmIpcZVqrVkAR7Cxmk8Xs1AW18FY0PJcFRCcwkEdEWwRlCxgB3dQIOYXbji37FVm0bwkDRuC4MZHrMboyWKbAMZrkZUFbAYMQUqnqeT+LxNeg8wEdNBdUE8Ch3CHooiYvilcocglEyDEry1IhHJQvJDheaUZrOO21MD5J0u2pUxhm0jIBym9Dc2QHBQUfzFwmZlT2eAALxztCED1wOsQ4QkzngKmiJTQbDLmAr4RRkHqjRo3PwClY7HjcGab1o+DG0fpjLTAmwEYqAA9pOEAKoASUgzPgRCwppnV4Uaf8+TQYhO4fCu73DqnJ1A0TSDvwfDLsUaBknGRoGFuODzFiTAYBUFDMNyyLFNB4aRtiMb4hSgaVOg0QUMUtBIFquDVsiMgkGQa7fKsMRVraIyFtSZxIW82hYR8ZBmragqEeyxGIogsiIDQzCQHIsw0EQVBrBGfrAt0ZEKpC0Jmqi+Hzl+6L4Vqsg6uCirQmhNB/ARwpoKK4oIfoxjgFAZD0AKaB4IQpDkKpMRMOufxcLw/DCKI4hSDI8hMEoVCqOoWg6K5JhQO4yCoJgyF+WQyg0PQwU6lwVAOg4TguApcWKMoSWaNouhgIYbmmAY0YSSBqSIBQDCpFc6KpEIaDDqkwWpLUJASPguypB1eJdWg3DfLGFJjWi7CpAAIkgTAULQADCG1/Bow3DhwBixFdBgWAmt7EPlgX0BVVxVQKr7mogxrNPAXwnMUslNBZtxQRcAAGO2IHttAALIQkqYOMF6IyQMqaDIB9pD0PkIbyWDwUaJN027Bo82rbUWjLWikkYBotAEBoU3sBokPQ3DVkkIjPL4ywhPkMTtqkziC0EpTK003TDM6UqLO7ScsPw6QYNnAAcquZQLHw8X8nwSZEGRmMqtmlywYaRjmJYiY0KpFIrmJSgMs4IGPM8JDngclExFingCNTkDsOS0hGGr5BGldsRGBAYBGGTNPdb1/XXENI1oGNLATfzM22nNwvkxgqRLeLXX0/g/WKyQ21y/t7O6YaZ1oBd4c3Zb93+QV7GOK98jveWQeIZqP5tNCDtejbiIClmkAQ1XCsc6dKeIxU45oxjvfY7jU8E0TWeGrHIFi9T+8l4zUh/Fzq488wfNTTvQtEfvheH6Lx/S0rvGazm/jYvwWZa73duT21pBT4HtcBEnNs3BMXhrbOw+JPEeTs4z8Fdu7f6Xs+A+z9gHcQfcQ5mybgYKOMdc5xx6n1Aa+d65p2YBnG+s094EgLlTPOqQS5lw5qkDSaBoBaQYDXGW9dG7XVusSVuj1vT2E7s4buzxDZfQMMSEoJAHQACkU4wmhkjdGyAwZcJ4aCfhSt3iWhDPqW09B0RiS1MsJUm9ebbxJgwikB886S3wBoV+nN36oCbPLf2zoloOC9MuCIypVwVBIF4WgiAuABjBgAZUlOGRGlI2D0DBiCSoiN5wBnILJGIvj9pTzFLIJMsFlbnDtCEGIYMSllNoIjQpRVai4GuMgAMRx1jjgiVE5BEQ6zNDBteO8W0UmThqQ8Sgt4GlnAAEIaw0fLdpX9frMDwGOZoS4AxVEbPASgyBHzPhiDyfYwx2DoEgFw0iYJPH2ElDaXAKRmiqnmOgLA2i7ijkKguLYKAlB/HZJQHg4p6kIQtlAmBSCeTwNEKPWBAo3agPQTwbAvsyLYL2fIqApIlD0CaVPPRvDDGc1RmqAoeyok6IABSJIoPhTJDBwh1PwLBYFpSWW0AAJSI0sZfa+Atd4kIfswiWL9y4aEJQY8uYNCEJkGDEPJ3z8W1JBRyxGLzYDksiekqlwzbxbR+VMrlPLCTwDTLooE3CiXStmHYq+DjBZOIpo/VxYq56SsqMSmVBCiHtSFYwshidBpUPGg6iuTr87ohxuje5YgnnsNrqkfSFFUgAFEBpeDVvfAkAAJFEFF57nUusIluD0AoSJetIvpcjvprgsoir0JkMTzGoP7H8mrfqsWAgSGEjyKCEhQGA/gyo5gDI+CNGaPox2mlEC0LKGpmjfl/FVFeMJWRSFoO/RYbtiggyuLgNStrJ4kAzQpRQ8hV2G3oIpQunl6UXH+CcJIWBYC1D/HiSR+QLxxEABtN/R2jWzEpM/t4yj0XBUkxQ9aJyDhD1LiWQ97miyTpRiHGLAp6xAIFwYDqSSB9HPhhrDhqQNsB/fh1W6tf6QD1mRVCLSQy+HyEmB0q6qTWzoGCyBVsCq2zgRcBBY8B0ItQZ7LyGDUVYIBTg+ReCw7XUIW1CN8dyFJxDenMNOds1rSjVQZDDz42eKTfmygqQs2dVzcZighaG7FojiIsR5bvmVrerI/+RhFGdKIOOYq5zWPyvoCofAUgxJgwcMtf6iAqXcvBChCxC6p5mZFhSPNGBUSUBNdbJdhpKl1vOQ2tpLxW1JGrJ2n47AQK9pSAO9QjwR39O4Hh3kYkl0ECqrufcZQlHHNXA1ige76vNGClqOliAuT2DhIgyJsgKOOmdASUsQC5Go2NlcJQnGRHQJ42N6F/HYWIN48JpFYmUVorBBi3Bk05MRwU9HP1WnajKaDZQlO1DaECs0+Z7T6FdOxr7RXQzyaTNWAeLARLecUtpas4I2zXGHPt2elIlz05Pq1tqSDsHNMIcUURo7FGq64MRJ9KuEx1pRIBiUA11LZAGDdywEMrYR09lnzeUTmEuH/bniQIeinnlqeYoIo7OluB5BUrBtRKG8tGfsAI2LmeYNwhgwjfEygOxbQy62KrzmnKt1T0B4jNgsXsQOkmdhaQewyDjOxhS6JCk8C9oK5PSEU4bIMYKMxln/tGxYmTfhGjDAdcrHkIWObrpfCG2MfF3gkgmhT1ILgL1MXX20C4HSUoDogiUH55PYDsw90VcUjn/VkAdhxOA1Lk6FRUvXi2LM2Q0yosVNvM8bPvzUAHluE6WgtJcKwBDMqdpCX/UUmr5QLN8R8Cd9TeeW08IKQVKQqzPxniADkyAcOgZ6RYtfYFvk8kmWJfwSSmb0GA6v/2p7b1U/oBq39/R0T5NZd5cll5npJPWN41eb4Bg2+A5sBrDAJIFA/gd0+M8x7ssBPEddhhigJ4Lhbk0I9Nmw10Csiw151tLZNtBMAFdtBd4UUEjtgFMF0UpNMVI5IB4hrdv9yQxteVgdEBQch9agsd0stERgLpIBdA5VcUp4o9hwaByVKRfAR8KBy9igy9jpcAABuGVKACg7VNncZedYpdHRgjAZgigHHZGGJAwDgqAMGXgmPSvUsRffaUQh8GeUQ6QyAQAJMIeC6U+DF10RjCLCJDzCJd9ppCdDOD9D7DDCnDfAMcQJldthdhxIktahgjNcrDbCfDo9+CjCAjVDIjQiNddhPCoA1ZkcKwPgDck92xnxkhhhCCJMyJLRKAstJDyIw9/50AVlgCQNfAdtBstCrtI5FNVDHsKFk5RpQ1M56EOidMY0KB9M0hDM2h/RTNVD9FKgDouRHBKBrMhE7NS024npJFKoZEsi+4kJrEh5Nk9tBM+lAEtDUZ0Ytj150M+Uw075PsKZBjEC/sPEbVuYt4+jHVVCNB7jfsnkni55xi2h58TQTj2dV1/BcMLi8ZAiCQuFZjCR5iNCPgwYoSKRpiGBYSKp0sZsNYgUlsmjWCsovgys/gKssQ/dlsv5VszZzYNtIVeM8SBM8COcCDvYSizsSC+4oAjptEFC0kuAkTVCYS5i2AESYjkTahUT0T4STU7V+Vb4I1PjvshiRjDRID/i0ANAxSMAJShT0sYcbtiFwDOjVMXtei6Fs4lMvjhi41Rjy5Ug1TUhldqBtS4ThTFiYd7My14d1iu5q03MFElEHQrkED4T8Sp5HTcBnSMSETUASczFuskRjNxtqAwBPQJEI1/ZT4wEzhbxig0R8kB0dt/AhRLVrkYkp5RhqAAB9NCDCZgeXcsooaszAW0Lwes+cULRsxFeAfwGZcgrEYrTVBlbvDREgSQMYAMFReJAAeRVkslrnCD1lNxHPgHhHCB3SoDECXMLwNTQ3kknluTMnqWqgfDNzpTdA7HwjpEtFXDiQ9T4RtUEBEDEDnFSyAIeIHzBgOn8CaE1NmKcFS3VTJQ6X/0APTOFwa0gFFysGvHiRzTBk5UzHUGiFg3LiANsjCA9wZRPKhjpVnwphy2KnnFQCzCwDdlEDwBiA1UsQ6XIp7PG3uCFxqmAPPDEM8xCw1WVwYvdFhJoBYv11WCT3CGVAQGrF4GkEcDHHkDIAcCKMnm6C2ywGGIHRv0nkLhDCjxjxz14PgGiArGzOeEnJnOBQoDYu/B0oORSHwm3I50lB/AJBfM9xDD4FQD1ixhfICxKG8F8Gsp8VWCqw4wIpOJNxPMp3+Rp1mCfJ7RBkH3AOSLVw9zBinMijEBhlvQ0PcveEwGjAkTOIDHiSjwxCG0mjshv1cvwgt19hiE7yBUniwtiHDMjPhNiHQIhQUuwP2NwKQUOzQWOyILZMiFINlRVmUVDNglxT5Iat4UlOFOlMVw6MDS6LUxoQ0wtMVIeIM1tPtMmtBGmoWPrhkOsFZMT0UHaX81tQSSKEapmvYO8OJmeiKFhNrJpTfwjE8W5S8L0LuqTNwAOmbMiWepQ1evLnet0Kni+orNwCnx6B7IBvwjeu9Xk19SUwWuNJ6PUzePDQGLWu+JtI4XtOgEqBWAjKmp1KhxTiWNh09LWOc02JrX9PtEuRLODOFNDLBgJuWFWCupYJjL+FMRtBiEsUnnTMtOVOzNzK52ktAMZv9GuQnDSUI0JtWErMBkeUQFiGyVfN4FDBGAtGdBYDhvLnaWQEMtnOQ3DHjGvAOEUthEkEMjnKVGHMXIPzQAdE8UzAWHeQPRPXhDtmZEz3PJ+mKGvIuQtX9FRIT0fKih+XwjUWWFGwwDP0SuSvjzSoqXiA3hhS2FHDRC7HoDdsHRso3KHUnjBmA2mUXgzoHnZVZXnAQN+w/K/KKBIF/JYCuAAtJVeQvAVVUJPNaXMtmGomIgxADCWCJqANrIqypEoE0ByzArHVQFGFwgvmgtgsBMG1bqItN1IsvHuG+SoveXossuFzQpAI+QAIkRUouHXLsomS2HLuPPRGmhaEQxituIwDis5kToYG/JoH4ti30sgBNuwv9pzoqzMq8AgmPS92cuQFcoCqQlxyynaQwGyu+VysgHysBpYmKowuCoABkWABB8AWgBcuRfssR0RIqSJ5wyr1gMqd1PIB9RgGKYgrk8zbIgUBB0ZmjtEWruMsCpaGSur8CerijTt/EBqOTIBhqHREHv86A+T2aiauboy6dkaE5FqTT0azTMbDSRbrT/tNrLVUglHOaSaXS9qF4hqRq+rjqAt/M+TbGvrR7VhElkhItaUDaOZotLE5G2bFbiadrSaEbrskb5qE5oJujU5TT3slNNTiQRUQJoAQgiRocm4PTViK1Edaa/SkIA4v4l517gCSqziixW72AYgjIQgn1Wc7KxzAH1FRsUhRJtYdcxKdgJ9EAvAg8vKaoQpCowAJ9cIQhWb4nEmCRknZJEB1UzjvyXQHDr8gKfBB0CTQEIIsqyhnZwhN91nXzciTqlI7KP9DwmQxhHc/LuBMKkh9p8A6sBQrBDhcAIN4kABFPBj3GwOgYikZmya4fZACWQVJMiLW3rY+msp8FIMBrETB/CeZTraCXZjy74H8EgV9KJGqkZ/Z9JGyHdAh2CSLblGbJbZUFbU2KWv3cIF3fARjZjcIfs6BhM1LaDIgSo+BXEZ4UbaIbpiIKUI4KFC4QpgoDeHzYA2gQZu3IBHkMi3ejZCIH52qPhzA2Bekg4xkxFURlk8R87LFaR1cXE1cGFTqg7ER0TMRyTSRxAVloE7k/k8AhJouCZlJjWixIdLF5AfwChBcZ0NeDnGfXdLEAQUcjEMp/pjjPU31MyHUI04NLR5ajG1ISNzaF1OONhQi1LThNAEUEpT52CWQP8tuzdNJktO6KmrJjY30t8eRJCTfMG6gYkVkJ0XARGCeMdMGW8YA0gBE0Ek9ILGIXckLByJyWQHN2gPNje9uxBrEqjHZ4BN18kwbNAiBGktqwR1V4RpkjV8TLV9knVuQ3pD13t5POwuI5odt5SIFPg+tifZnXxk4i1LN8UEdsd/8hpc6xNv4aN57NGuNnRhN7UJN8ZtaVN8d2gDNh94dpIUd/NlEazEJto27d93AT9qJ17DTRDphB1taVI7OQdkpD+28Qkt09JlY8RJzbJitlHfuBdytmEZF0SZEfabWV/aoMEYcRkXWsSXD8UNlCyMS4bSe18tSoLUsfwDPKZjuzVUdoF1jqkYIA5TPDdIV9DSeRDyADPU4DBkChaakWkeLMGb80dv+wSkLYbXAAKKc/aI1Z1qedQE9FWcZRea3ZZJDVYKWs2jEU5M3YkntWA5oVTove/GgR/Z4Lu1/QGxFkLAgVpLwK9xtnlSsVzvEuy7AUsVTyca9oOwkILryZ4AMBj2gJj4vWT7LJCMGS9ht5nGt3y9dPtyu83MT4ofjg3fZbMvToTqQIzxQBc/AIgfWDEFWk9DcWo7sbgwNkGHMQT/IQDQ/OlKaF+1T2ywkOpxEIvKlaAfVBCzC8GUz8zyzige+1ARcCRPfUYKxf9uyIvdziMQLyDvpAMULuIDIZqypU7pbaSp5QrHvYs8DhMKwe8UTuTodffH8BipocIRDv/UQQArZPwagTWQrLAOj1F6lhKIr9j8BLjJVqFA1nAuFdd9V01zV81wOGTS7cNtqdDlGmN79t7HeP98yADzDh7bDiuEpHUfDwjot5YktzJsj8tnuSt2tJbWM/msSAh5gIhkhsGAAAXiS8HyAABYhANbUGSIpXKRKrqMeu+uIx96Qs2f2AOfvhNDeGEwteIwBuLJsozrW1HdWlKIjjwYjuaBdURkeP2BpkmVzvcA3HVb3e/hfeHhossWPg9YzuGeLuDVA2rKd94GLhhwzzNf2dorJ4rksLwLmhJk4sMzzkiD6DLzP9zQISp41TUToABkZcGVy+GsccuQJa/huWfwQwUYS7v6m7rPS7Y/Ovok2n/AOmHhuWDD+CwYmY/hgc0V6Du/3gavi/AtgsS6DPpm7GpbPJHeeG8c1QhmsjLyh0KWPcatjLxlAN2dDvY/WcMleFq+SUeR91qwhaRqgXCrahMznFdWtjnOTY0w8TR/GuSj8+MQySWIbEnwBPCdIIQ5vSoIq1pLbYceHVPHsaw3aE8t2xPaTGQUyL6sxIRwAon9hdiIDvkRPYgha3naf98EiNZqK1A8ivlvIvkUtt8hFacBYe5UcjopG1iJQ1ADUVKOQPSh1p1AlZM1IgErJ989kJLWgMrXt7FBXIBgbgYrwAAcJAAAOwZAcgAgWgAIAACcTEAQDkAV4AA2BgGoNoAK8ZB+QDILQHkE6CZBjQGQWgB0E6CMgNgkgDILUFNQWo3A4qLwP4GCC5uyiOgJWVX76AgAA=== -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=with-one-voice/ticketing-service&utm_content=104):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: kopite97
created_at: 2025-04-18T03:21:04Z
body: ⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸⧹╲⎝⧹༼◕ ͜ﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞﱞo.◕ ༽⧸⎠╱⧸
author_association: CONTRIBUTOR
user: login: kopite97
created_at: 2025-04-18T03:22:07Z
body: ̥ ̥̥ ̥̥̥ ̥̥̥̥ ̥̥̥̥̥ ̥̥̥̥̥̥̊̊̊̊̊̊̊̊̊̊̊̊ ̥̥̥̥̥̥̥̊̊̊̊̊̊̊̊̊̊̊̊ ̥̥̥̥̥̥̥̥̊̊̊̊̊̊̊̊̊̊̊̊ ̥̥̥̥̥̥̥̊̊̊̊̊̊̊̊̊̊̊̊ ̥̥̥̥̥̥̊̊̊̊̊̊̊̊̊̊̊̊ ̥̥̥̥̥ ̥̥̥̥ ̥̥̥ ̥̥ ̥

author_association: CONTRIBUTOR