Repository: capstone-mindmate/mindmate-backend
Pull request #67: [Feat#61] 채팅 암호화/복호화 로직
Author: login: unie12
Created: 2025-04-16T10:01:16Z | Merged: 2025-04-17T07:10:24Z

## 🛰️ Issue Number
#61 

## 🪐 작업 내용
1. AES-GCM 알고리즘 추가(암호화/복호화)
2. 채팅 알림 컨슈머 추가



Diff:
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/domain/ChatMessage.java b/src/main/java/com/mindmate/mindmate_server/chat/domain/ChatMessage.java
index 078f2624..916d0156 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/domain/ChatMessage.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/domain/ChatMessage.java
@@ -42,10 +42,9 @@ public class ChatMessage extends BaseTimeEntity {
 
     @Column(columnDefinition = "TEXT")
     private String content;
-
-
-    // todo: 삭제??
-    private String filteredContent;
+    
+    @Transient
+    private String decryptedContent;
 
     @Enumerated(EnumType.STRING)
     private MessageType type;
@@ -53,20 +52,17 @@ public class ChatMessage extends BaseTimeEntity {
     private boolean isRead;
     private LocalDateTime readAt;
 
+    private boolean encrypted = false;
+    
     @Builder
     public ChatMessage(ChatRoom chatRoom, User sender, String content, MessageType type) {
         this.chatRoom = chatRoom;
         this.sender = sender;
-//        this.senderRole = senderRole;
         this.content = content;
         this.type = type;
         this.isRead = false;
     }
 
-    public void setFilteredContent(String filteredContent) {
-        this.filteredContent = filteredContent;
-    }
-
     public boolean isCustomForm() {
         return this.type == MessageType.CUSTOM_FORM;
     }
@@ -74,4 +70,21 @@ public boolean isCustomForm() {
     public void setCustomForm(CustomForm customForm) {
         this.customForm = customForm;
     }
+
+    public void updateEncryptedContent(String encryptedContent) {
+        this.content = encryptedContent;
+        this.encrypted = true;
+    }
+    
+    public void setDecryptedContent(String decryptedContent) {
+        this.decryptedContent = decryptedContent;
+    }
+    
+    // 내용 반환 시에 복호화된 내용을 우선적으로 사용
+    public String getContent() {
+        if (decryptedContent != null) {
+            return decryptedContent;
+        }
+        return content;
+    }
 }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageEvent.java b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageEvent.java
index 9c9eed3f..bb917876 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageEvent.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageEvent.java
@@ -1,5 +1,6 @@
 package com.mindmate.mindmate_server.chat.dto;
 
+import com.mindmate.mindmate_server.chat.domain.FilteringWordCategory;
 import com.mindmate.mindmate_server.chat.domain.MessageType;
 import com.mindmate.mindmate_server.user.domain.RoleType;
 import lombok.AllArgsConstructor;
@@ -21,6 +22,15 @@ public class ChatMessageEvent {
     private String content;
     private MessageType type;
 
+    private Long recipientId;
+    private boolean recipientActive;
+
+    private boolean filtered;
+    private FilteringWordCategory filteringWordCategory;
+
+    private boolean encrypted;
+    private String plainContent;
+
     public void setId(Long id) {
         this.messageId = id;
     }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageNotificationEvent.java b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageNotificationEvent.java
new file mode 100644
index 00000000..c44bb319
--- /dev/null
+++ b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageNotificationEvent.java
@@ -0,0 +1,39 @@
+package com.mindmate.mindmate_server.chat.dto;
+
+import com.mindmate.mindmate_server.notification.domain.NotificationType;
+import com.mindmate.mindmate_server.notification.dto.NotificationEvent;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class ChatMessageNotificationEvent implements NotificationEvent {
+    private final Long recipientId;
+    private final Long senderId;
+    private final String senderName;
+    private final Long roomId;
+    private final String messageContent;
+    private final Long messageId;
+
+    @Override
+    public String getTitle() {
+        return String.format("새 메시지: %s", senderName);
+    }
+
+    @Override
+    public String getContent() {
+        return messageContent.length() > 20
+                ? messageContent.substring(0, 19) + "..."
+                : messageContent;
+    }
+
+    @Override
+    public NotificationType getType() {
+        return NotificationType.CHAT_MESSAGE;
+    }
+
+    @Override
+    public Long getRelatedEntityId() {
+        return roomId;
+    }
+}
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageResponse.java b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageResponse.java
index bf10ba7a..11292d5b 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageResponse.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/dto/ChatMessageResponse.java
@@ -31,7 +31,6 @@ public class ChatMessageResponse {
     private Map<ReactionType, Integer> reactionCounts;
     private ReactionType userReaction;
 
-    private boolean filtered;
     private boolean error;
     private String errorMessage;
 
@@ -65,12 +64,11 @@ public static ChatMessageResponse from(ChatMessage message, Long currentUserId)
 //                .roomId(message.getChatRoom().getId())
                 .senderId(message.getSender().getId())
                 .senderName(message.getSender().getProfile() != null ? message.getSender().getProfile().getNickname() : "Unknown")
-                .content(message.getFilteredContent() != null ? message.getFilteredContent() : message.getContent())
+                .content(message.getContent())
                 .type(message.getType())
                 .createdAt(message.getCreatedAt())
                 .reactionCounts(reactionCounts)
                 .userReaction(userReaction)
-                .filtered(message.getFilteredContent() != null)
                 .error(false)
                 .customForm(customFormResponse)
                 .build();
@@ -87,7 +85,6 @@ public static ChatMessageResponse filteredResponse(Long roomId, Long senderId, S
                 .type(type)
                 .createdAt(LocalDateTime.now())
                 .reactionCounts(new HashMap<>())
-                .filtered(true)
                 .error(false)
                 .build();
     }
@@ -103,7 +100,6 @@ public static ChatMessageResponse errorResponse(Long roomId, Long senderId, Stri
                 .type(type)
                 .createdAt(LocalDateTime.now())
                 .reactionCounts(new HashMap<>())
-                .filtered(false)
                 .error(true)
                 .errorMessage(errorMessage)
                 .build();
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/repository/ChatRoomRepositoryImpl.java b/src/main/java/com/mindmate/mindmate_server/chat/repository/ChatRoomRepositoryImpl.java
index bfbf9533..2c557b19 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/repository/ChatRoomRepositoryImpl.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/repository/ChatRoomRepositoryImpl.java
@@ -1,6 +1,7 @@
 package com.mindmate.mindmate_server.chat.repository;
 
 import com.mindmate.mindmate_server.chat.domain.ChatRoomStatus;
+import com.mindmate.mindmate_server.chat.domain.MessageType;
 import com.mindmate.mindmate_server.chat.domain.QChatMessage;
 import com.mindmate.mindmate_server.chat.domain.QChatRoom;
 import com.mindmate.mindmate_server.chat.dto.ChatRoomResponse;
@@ -166,21 +167,37 @@ private BooleanExpression createRoleCondition(Long userId, String roleType) {
 
     private List<ChatRoomResponse> convertToDto(List<Tuple> results, QChatRoom chatRoom, QChatMessage lastMessage) {
         return results.stream()
-                .map(tuple -> ChatRoomResponse.builder()
-                        .roomId(tuple.get(chatRoom.id))
-                        .matchingId(tuple.get(chatRoom.matching.id))
-                        .chatRoomStatus(tuple.get(chatRoom.chatRoomStatus))
-                        .lastMessageTime(tuple.get(chatRoom.lastMessageTime))
-                        .lastMessage(tuple.get(lastMessage.content))
-                        .isCreator(Boolean.TRUE.equals(tuple.get(5, Boolean.class)))
-                        .matchingTitle(tuple.get(6, String.class))
-                        .oppositeName(tuple.get(7, String.class))
-                        .oppositeImage(tuple.get(8, String.class))
-                        .oppositeId(tuple.get(9, Long.class))
-                        .unreadCount(tuple.get(10, Long.class))
-                        .userRole(tuple.get(11, String.class))
-                        .build())
+                .map(tuple -> {
+                    String lastMessageContent;
+                    MessageType lastMessageType = tuple.get(lastMessage.type);
+
+                    if (lastMessageType == MessageType.TEXT) {
+                        lastMessageContent = "새 메시지가 있습니다";
+                    } else if (lastMessageType == MessageType.CUSTOM_FORM) {
+                        lastMessageContent = "커스텀 폼이 도착했습니다";
+                    } else if (lastMessageType == MessageType.EMOTICON) {
+                        lastMessageContent = "이모티콘이 도착했습니다";
+                    } else {
+                        lastMessageContent = "새 메시지가 있습니다";
+                    }
+
+                    return ChatRoomResponse.builder()
+                            .roomId(tuple.get(chatRoom.id))
+                            .matchingId(tuple.get(chatRoom.matching.id))
+                            .chatRoomStatus(tuple.get(chatRoom.chatRoomStatus))
+                            .lastMessageTime(tuple.get(chatRoom.lastMessageTime))
+                            .lastMessage(lastMessageContent)
+                            .isCreator(Boolean.TRUE.equals(tuple.get(5, Boolean.class)))
+                            .matchingTitle(tuple.get(6, String.class))
+                            .oppositeName(tuple.get(7, String.class))
+                            .oppositeImage(tuple.get(8, String.class))
+                            .oppositeId(tuple.get(9, Long.class))
+                            .unreadCount(tuple.get(10, Long.class))
+                            .userRole(tuple.get(11, String.class))
+                            .build();
+                })
                 .collect(Collectors.toList());
     }
 
+
 }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/AesGcmEncryptionService.java b/src/main/java/com/mindmate/mindmate_server/chat/service/AesGcmEncryptionService.java
new file mode 100644
index 00000000..e0055519
--- /dev/null
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/AesGcmEncryptionService.java
@@ -0,0 +1,98 @@
+package com.mindmate.mindmate_server.chat.service;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+@Component
+public class AesGcmEncryptionService {
+    private static final String ALGORITHM = "AES/GCM/NoPadding";
+    private static final int TAG_LENGTH_BIT = 128; // 인증 태그 길이
+    private static final int IV_LENGTH_BYTE = 12; // IV (초기화 벡터) 길이
+    private static final int AES_KEY_BIT = 256;
+
+    private final SecretKey secretKey;
+
+    public AesGcmEncryptionService(@Value("${encryption.secret-key}") String encodedKey) {
+        byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
+        this.secretKey = new SecretKeySpec(decodedKey, "AES");
+    }
+
+    /**
+     * 문자열 암호화하고 Base64로 인코딩하여 반환
+     */
+    public String encrypt(String platinText) {
+        try {
+            // 랜덤 IV 생성 -> 매 암호화마다 새로운 IV 사용
+            byte[] iv = new byte[IV_LENGTH_BYTE];
+            new SecureRandom().nextBytes(iv);
+
+            // 암호화 수행
+            Cipher cipher = Cipher.getInstance(ALGORITHM);
+            GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
+
+            byte[] cipherText = cipher.doFinal(platinText.getBytes(StandardCharsets.UTF_8));
+
+            // IV와 암호문 결합
+            ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
+            byteBuffer.put(iv);
+            byteBuffer.put(cipherText);
+
+            return Base64.getEncoder().encodeToString(byteBuffer.array());
+        } catch (Exception e) {
+            throw new RuntimeException("암호화 과정에서 오류가 발생했습니다.", e);
+        }
+    }
+
+    /**
+     * Base64로 인코딩된 암호문을 복호화하여 원본 문자열 반환
+     */
+    public String decrypt(String encryptedText) {
+        try {
+            byte[] cipherMessage = Base64.getDecoder().decode(encryptedText);
+
+            // IV 추출
+            ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
+            byte[] iv = new byte[IV_LENGTH_BYTE];
+            byteBuffer.get(iv);
+
+            // 암호문 추출
+            byte[] cipherText = new byte[byteBuffer.remaining()];
+            byteBuffer.get(cipherText);
+
+            // 복호화 수행
+            Cipher cipher = Cipher.getInstance(ALGORITHM);
+            GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
+            cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
+
+            // 복호화된 텍스트 반환
+            return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new RuntimeException("복호화 과정에서 오류가 발생했습니다.", e);
+        }
+    }
+
+    /**
+     * 새로운 AES 키 생성 (키 교체 시 사용)
+     */
+    public static String generateNewKey() {
+        try {
+            KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
+            keyGenerator.init(AES_KEY_BIT);
+            SecretKey key = keyGenerator.generateKey();
+            return Base64.getEncoder().encodeToString(key.getEncoded());
+        } catch (Exception e) {
+            throw new RuntimeException("키 생성 중 오류가 발생했습니다.", e);
+        }
+    }
+}
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
index d5767f03..182fea8c 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
@@ -18,11 +18,16 @@
 @RequiredArgsConstructor
 public class ChatMessageServiceImpl implements ChatMessageService {
     private final ChatMessageRepository chatMessageRepository;
+    private final AesGcmEncryptionService encryptionService;
 
     @Override
     public ChatMessage findChatMessageById(Long messageId) {
-        return chatMessageRepository.findById(messageId)
+        ChatMessage message = chatMessageRepository.findById(messageId)
                 .orElseThrow(() -> new CustomException(ChatErrorCode.MESSAGE_NOT_FOUND));
+
+        // 암호화된 메시지 복호화
+        decryptMessageIfNeeded(message);
+        return message;
     }
 
     @Override
@@ -32,7 +37,8 @@ public long countMessagesByChatRoomId(Long roomId) {
 
     @Override
     public List<ChatMessage> findAllByChatRoomIdOrderByIdAsc(Long roomId) {
-        return chatMessageRepository.findByChatRoomIdOrderByIdAsc(roomId);
+        List<ChatMessage> messages = chatMessageRepository.findByChatRoomIdOrderByIdAsc(roomId);
+        return decryptMessagesIfNeeded(messages);
     }
 
     @Override
@@ -42,30 +48,35 @@ public Optional<ChatMessage> findLatestMessageByChatRoomId(Long roomId) {
 
     @Override
     public List<ChatMessage> findMessagesBeforeId(Long roomId, Long messageId, int size) {
-        return chatMessageRepository.findMessagesBeforeIdLimited(roomId, messageId, PageRequest.of(0, size));
+        List<ChatMessage> messages = chatMessageRepository.findMessagesBeforeIdLimited(roomId, messageId, PageRequest.of(0, size));
+        return decryptMessagesIfNeeded(messages);
     }
-
     @Override
     public List<ChatMessage> findMessagesAfterOrEqualId(Long roomId, Long messageId) {
-        return chatMessageRepository.findByChatRoomIdAndIdGreaterThanEqualOrderByIdAsc(roomId, messageId);
+        List<ChatMessage> messages = chatMessageRepository.findByChatRoomIdAndIdGreaterThanEqualOrderByIdAsc(roomId, messageId);
+        return decryptMessagesIfNeeded(messages);
     }
 
     @Override
     public List<ChatMessage> findRecentMessages(Long roomId, int size) {
-        List<ChatMessage> tempMessages = chatMessageRepository
-                .findByChatRoomIdOrderByIdDesc(roomId, PageRequest.of(0, size)).getContent();
-        List<ChatMessage> messages = new ArrayList<>(tempMessages);
-        Collections.reverse(messages);
-        return messages;
+        List<ChatMessage> messages = chatMessageRepository.findByChatRoomIdOrderByIdDesc(roomId, PageRequest.of(0, size)).getContent();
+        List<ChatMessage> decryptedMessages = decryptMessagesIfNeeded(messages);
+
+        List<ChatMessage> reversedMessages = new ArrayList<>(decryptedMessages);
+        Collections.reverse(reversedMessages);
+
+        return reversedMessages;
     }
 
     @Override
     public List<ChatMessage> findPreviousMessages(Long roomId, Long messageId, int size) {
-        List<ChatMessage> tempMessages = chatMessageRepository.findByChatRoomIdAndIdLessThanOrderByIdDesc(
-                roomId, messageId, PageRequest.of(0, size)).getContent();
-        List<ChatMessage> messages = new ArrayList<>(tempMessages);
-        Collections.reverse(messages);
-        return messages;
+        List<ChatMessage> messages = chatMessageRepository.findByChatRoomIdAndIdLessThanOrderByIdDesc(roomId, messageId, PageRequest.of(0, size)).getContent();
+        List<ChatMessage> decryptedMessages = decryptMessagesIfNeeded(messages);
+
+        List<ChatMessage> reversedMessages = new ArrayList<>(decryptedMessages);
+        Collections.reverse(reversedMessages);
+
+        return reversedMessages;
     }
 
     @Override
@@ -86,4 +97,23 @@ public List<Long> findMessageIdsByKeyword(Long roomId, String keyword) {
     public List<ChatMessage> findByRoomIdAndIdBetween(Long roomId, Long targetMessageId, Long lastMessageId) {
         return chatMessageRepository.findByRoomIdAndIdBetween(roomId, targetMessageId, lastMessageId);
     }
+
+    // 단일 메시지 확인 시 보고화
+    private void decryptMessageIfNeeded(ChatMessage message) {
+        if (message.isEncrypted()) {
+            try {
+                String decryptedContent = encryptionService.decrypt(message.getContent());
+                message.setDecryptedContent(decryptedContent);
+            } catch (Exception e) {
+            }
+        }
+    }
+
+    // 메서지 목록들 확인 시 복호화
+    private List<ChatMessage> decryptMessagesIfNeeded(List<ChatMessage> messages) {
+        for (ChatMessage message : messages) {
+            decryptMessageIfNeeded(message);
+        }
+        return messages;
+    }
 }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatNotificationConsumer.java b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatNotificationConsumer.java
new file mode 100644
index 00000000..f5031b37
--- /dev/null
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatNotificationConsumer.java
@@ -0,0 +1,70 @@
+package com.mindmate.mindmate_server.chat.service;
+
+import com.mindmate.mindmate_server.chat.domain.MessageType;
+import com.mindmate.mindmate_server.chat.dto.ChatMessageEvent;
+import com.mindmate.mindmate_server.chat.dto.ChatMessageNotificationEvent;
+import com.mindmate.mindmate_server.notification.service.NotificationService;
+import com.mindmate.mindmate_server.user.domain.User;
+import com.mindmate.mindmate_server.user.service.UserService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class ChatNotificationConsumer {
+    private final NotificationService notificationService;
+    private final UserService userService;
+
+    @KafkaListener(
+            topics = "chat-message-topic",
+            groupId = "notification-group",
+            containerFactory = "chatMessageListenerContainerFactory"
+    )
+    public void sendNotification(ConsumerRecord<String, ChatMessageEvent> record) {
+        ChatMessageEvent event = record.value();
+
+        // 필터링된 메시지 처리 x
+        if (event.isFiltered() || event.getMessageId() == null) {
+            return;
+        }
+
+        if (event.isRecipientActive()) {
+            return;
+        }
+
+        try {
+            User sender = userService.findUserById(event.getSenderId());
+            User recipient = userService.findUserById(event.getRecipientId());
+            String notificationContent;
+
+            // todo: 이모티콘은 그냥 이미지만 못 보여주나? 그리고 암호화 여부에 따라 여기서 해당 내용을 보여줄 지 아니면 그냥 일반적인 문구를 보여줄 지 결정
+            if (event.getType() == MessageType.TEXT) {
+                notificationContent = event.getPlainContent();
+            } else if (event.getType() == MessageType.CUSTOM_FORM) {
+                notificationContent = "커스텀폼 메시지가 도착했습니다.";
+            } else if (event.getType() == MessageType.EMOTICON){
+                notificationContent = "이모티콘이 도착했습니다.";
+            } else {
+                notificationContent = "새 메시지가 도착했습니다.";
+            }
+
+            ChatMessageNotificationEvent notificationEvent  = ChatMessageNotificationEvent.builder()
+                    .recipientId(recipient.getId())
+                    .senderId(sender.getId())
+                    .senderName(sender.getProfile().getNickname())
+                    .roomId(event.getRoomId())
+                    .messageContent(notificationContent)
+                    .messageId(event.getMessageId())
+                    .build();
+
+            notificationService.processNotification(notificationEvent);
+            log.info("Notification sent to recipient {} for message {}", recipient.getId(), event.getMessageId());
+        } catch (Exception e) {
+            log.error("Error sending notification", e);
+        }
+    }
+}
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatService.java b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatService.java
index e537f395..a8c42afe 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatService.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatService.java
@@ -9,5 +9,5 @@ public interface ChatService {
 
     int markAsRead(Long userId, Long roomId);
 
-    void publishMessageEvent(ChatMessage savedMessage);
+    void publishMessageEvent(ChatMessage savedMessage, Long recipientId, boolean recipientActive, String plainContent);
 }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatServiceImpl.java b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatServiceImpl.java
index 7052920d..ca9c3a7c 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/ChatServiceImpl.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/ChatServiceImpl.java
@@ -3,11 +3,10 @@
 import com.mindmate.mindmate_server.chat.domain.ChatMessage;
 import com.mindmate.mindmate_server.chat.domain.ChatRoom;
 import com.mindmate.mindmate_server.chat.domain.FilteringWordCategory;
-import com.mindmate.mindmate_server.chat.dto.ChatEventType;
-import com.mindmate.mindmate_server.chat.dto.ChatMessageEvent;
-import com.mindmate.mindmate_server.chat.dto.ChatMessageRequest;
-import com.mindmate.mindmate_server.chat.dto.ChatMessageResponse;
+import com.mindmate.mindmate_server.chat.domain.MessageType;
+import com.mindmate.mindmate_server.chat.dto.*;
 import com.mindmate.mindmate_server.global.util.RedisKeyManager;
+import com.mindmate.mindmate_server.notification.service.NotificationService;
 import com.mindmate.mindmate_server.user.domain.User;
 import com.mindmate.mindmate_server.user.service.UserService;
 import lombok.RequiredArgsConstructor;
@@ -47,7 +46,6 @@ public class ChatServiceImpl implements ChatService {
      *
      */
     @Override
-//    @Transactional(propagation = Propagation.REQUIRED)
     public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) {
         try {
             ChatRoom chatRoom = chatRoomService.findChatRoomById(request.getRoomId());
@@ -55,7 +53,6 @@ public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request)
 
             chatRoomService.validateChatActivity(userId, request.getRoomId());
 
-            // 필터링 동기 처리
             Optional<FilteringWordCategory> filteringCategory =
                     contentFilterService.findFilteringWordCategory(request.getContent());
 
@@ -112,13 +109,16 @@ private ChatMessageResponse handleNormalMessage(ChatRoom chatRoom, User sender,
         ChatMessage savedMessage = chatMessageService.save(chatMessage);
         chatRoom.updateLastMessageTime();
 
-        publishMessageEvent(savedMessage);
+        User recipient = chatRoom.isListener(sender) ? chatRoom.getSpeaker() : chatRoom.getListener();
+        boolean isRecipientActive = chatPresenceService.isUserActiveInRoom(recipient.getId(), chatRoom.getId());
+
+        publishMessageEvent(savedMessage, recipient.getId(), isRecipientActive, request.getContent());
 
         return ChatMessageResponse.from(savedMessage, sender.getId());
     }
 
     @Override
-    public void publishMessageEvent(ChatMessage savedMessage) {
+    public void publishMessageEvent(ChatMessage savedMessage, Long recipientId, boolean recipientActive, String plainContent) {
         ChatMessageEvent event = ChatMessageEvent.builder()
                 .messageId(savedMessage.getId())
                 .roomId(savedMessage.getChatRoom().getId())
@@ -126,6 +126,11 @@ public void publishMessageEvent(ChatMessage savedMessage) {
                 .content(savedMessage.getContent())
                 .type(savedMessage.getType())
                 .timestamp(savedMessage.getCreatedAt())
+                .recipientId(recipientId)
+                .recipientActive(recipientActive)
+                .filtered(false)
+                .encrypted(false)
+                .plainContent(plainContent)
                 .build();
 
         if (TransactionSynchronizationManager.isSynchronizationActive()) {
@@ -151,6 +156,20 @@ private ChatMessageResponse handleFilteredMessage(FilteringWordCategory filterin
                 "[%s 관련 부적절한 내용이 감지되었습니다]",
                 filteringWordCategory.getDescription());
 
+        // 필터링된 메시지도 이벤트 발행 (분석용)
+        ChatMessageEvent event = ChatMessageEvent.builder()
+                .messageId(null) // 저장되지 않음
+                .roomId(chatRoom.getId())
+                .senderId(sender.getId())
+                .content(request.getContent()) // 원본 내용 (분석용)
+                .type(request.getType())
+                .timestamp(LocalDateTime.now())
+                .filtered(true)
+                .filteringWordCategory(filteringWordCategory)
+                .build();
+
+        sendKafkaMessage(event);
+
         ChatMessageResponse response = ChatMessageResponse.filteredResponse(
                 chatRoom.getId(),
                 sender.getId(),
@@ -162,4 +181,35 @@ private ChatMessageResponse handleFilteredMessage(FilteringWordCategory filterin
         eventPublisher.publishChatRoomEvent(chatRoom.getId(), ChatEventType.CONTENT_FILTERED, response);
         return response;
     }
+
+//    private void sendChatNotification(ChatMessage message, User recipient, String originalContent) {
+//        try {
+//            User sender = message.getSender();
+//            ChatRoom chatRoom = message.getChatRoom();
+//
+//            String notificationContent;
+//            // todo: 이모티콘은 그냥 이미지만 못 보여주나? 그리고 암호화 여부에 따라 여기서 해당 내용을 보여줄 지 아니면 그냥 일반적인 문구를 보여줄 지 결정
+//            if (message.getType() == MessageType.TEXT) {
+//                notificationContent = originalContent;
+//            } else if (message.getType() == MessageType.CUSTOM_FORM) {
+//                notificationContent = "커스텀폼 메시지가 도착했습니다.";
+//            } else if (message.getType() == MessageType.EMOTICON){
+//                notificationContent = "이모티콘이 도착했습니다.";
+//            } else {
+//                notificationContent = "새 메시지가 도착했습니다.";
+//            }
+//
+//            ChatMessageNotificationEvent notificationEvent = ChatMessageNotificationEvent.builder()
+//                    .recipientId(recipient.getId())
+//                    .senderId(sender.getId())
+//                    .senderName(sender.getProfile().getNickname())
+//                    .roomId(chatRoom.getId())
+//                    .messageContent(notificationContent)
+//                    .messageId(message.getId())
+//                    .build();
+//
+//            notificationService.processNotification(notificationEvent);
+//        } catch (Exception e) {
+//            log.error("채팅 알림 발송 실패: {}", e.getMessage(), e);
+//        }
 }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/CustomFormService.java b/src/main/java/com/mindmate/mindmate_server/chat/service/CustomFormService.java
index 2013b7f4..2d2e4e2e 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/CustomFormService.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/CustomFormService.java
@@ -29,6 +29,7 @@ public class CustomFormService {
     private final ChatRoomService chatRoomService;
     private final ChatService chatService;
     private final ChatMessageService chatMessageService;
+    private final ChatPresenceService chatPresenceService;
 
 
     @Transactional
@@ -58,7 +59,10 @@ public CustomFormResponse createCustomForm(Long userId, CustomFormRequest reques
         ChatMessage savedMessage = chatMessageService.save(chatMessage);
         chatRoom.updateLastMessageTime();
 
-        chatService.publishMessageEvent(savedMessage);
+        User recipient = chatRoom.isListener(user) ? chatRoom.getSpeaker() : chatRoom.getListener();
+        boolean isRecipientActive = chatPresenceService.isUserActiveInRoom(recipient.getId(), chatRoom.getId());
+
+        chatService.publishMessageEvent(savedMessage, recipient.getId(), isRecipientActive, "커스텀 폼이 생성되었습니다.");
 
         return CustomFormResponse.from(savedForm);
     }
@@ -73,7 +77,6 @@ public CustomForm findCustomFormById(Long formId) {
     public CustomFormResponse respondToCustomForm(Long formId, Long userId, RespondToCustomFormRequest request) {
         CustomForm customForm = findCustomFormById(formId);
         User user = userService.findUserById(userId);
-//        ChatRoom chatRoom = chatRoomService.findChatRoomById(request.getChatRoomId());
 
         chatRoomService.validateChatActivity(userId, request.getChatRoomId());
 
@@ -103,8 +106,12 @@ public CustomFormResponse respondToCustomForm(Long formId, Long userId, RespondT
         ChatMessage savedMessage = chatMessageService.save(responseMessage);
         customForm.getChatRoom().updateLastMessageTime();
 
-        // 저장된 메시지로 이벤트 발행
-        chatService.publishMessageEvent(savedMessage);
+        User recipient = customForm.getChatRoom().isListener(user) ?
+                customForm.getChatRoom().getSpeaker() : customForm.getChatRoom().getListener();
+        boolean isRecipientActive = chatPresenceService.isUserActiveInRoom(
+                recipient.getId(), customForm.getChatRoom().getId());
+
+        chatService.publishMessageEvent(savedMessage, recipient.getId(), isRecipientActive, "커스텀 폼에 응답했습니다.");
 
         return CustomFormResponse.from(updatedForm);
     }
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/MessageEncryptConsumer.java b/src/main/java/com/mindmate/mindmate_server/chat/service/MessageEncryptConsumer.java
new file mode 100644
index 00000000..6a66181f
--- /dev/null
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/MessageEncryptConsumer.java
@@ -0,0 +1,45 @@
+package com.mindmate.mindmate_server.chat.service;
+
+import com.mindmate.mindmate_server.chat.domain.ChatMessage;
+import com.mindmate.mindmate_server.chat.dto.ChatMessageEvent;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+@Transactional
+public class MessageEncryptConsumer {
+    private final AesGcmEncryptionService aesGcmEncryptionService;
+    private final ChatMessageService chatMessageService;
+
+    @KafkaListener(
+            topics = "chat-message-topic",
+            groupId = "message-encryption-group",
+            containerFactory = "chatMessageListenerContainerFactory"
+    )
+    public void encryptMessage(ConsumerRecord<String, ChatMessageEvent> record) {
+        ChatMessageEvent event = record.value();
+
+        if (event.isFiltered() || event.isEncrypted() || event.getMessageId() == null) {
+            return;
+        }
+
+        try {
+            ChatMessage message = chatMessageService.findChatMessageById(event.getMessageId());
+
+            String encryptedContent = aesGcmEncryptionService.encrypt(message.getContent());
+
+            message.updateEncryptedContent(encryptedContent);
+            chatMessageService.save(message);
+
+        } catch (Exception e) {
+            log.error("메시지 암호화 중 오류: {}", e.getMessage(), e);
+        }
+    }
+}
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/NotificationConsumer.java b/src/main/java/com/mindmate/mindmate_server/chat/service/NotificationConsumer.java
deleted file mode 100644
index 30ff959b..00000000
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/NotificationConsumer.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.mindmate.mindmate_server.chat.service;
-
-import com.mindmate.mindmate_server.chat.domain.ChatRoom;
-import com.mindmate.mindmate_server.chat.dto.ChatMessageEvent;
-import com.mindmate.mindmate_server.user.domain.RoleType;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.kafka.clients.consumer.ConsumerRecord;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.kafka.annotation.KafkaListener;
-import org.springframework.stereotype.Service;
-
-import java.util.HashMap;
-import java.util.Map;
-
-//@Service
-//@Slf4j
-//@RequiredArgsConstructor
-//public class NotificationConsumer {
-//    private final ChatRoomService chatRoomService;
-//    private final RedisTemplate<String, Object> redisTemplate;
-//    private final FCMService fcmService;
-//
-//    // todo : 현재는 온라인/오프라인 여부에 따라 알림 결정하는데 현재 채팅을 보냐 안보냐에 따라 결정해야 할 듯
-//    @KafkaListener(
-//            topics = "chat-message-topic",
-//            groupId = "notification-group",
-//            containerFactory = "chatMessageListenerContainerFactory"
-//    )
-//    public void sendNotification(ConsumerRecord<String, ChatMessageEvent> record) {
-//        ChatMessageEvent event = record.value();
-//        log.info("Processing notification for message: {}", event);
-//
-//        try {
-//            ChatRoom chatRoom = chatRoomService.findChatRoomById(event.getRoomId());
-//            Long recipientId;
-//            String recipientNickname;
-//
-//            // 수신자 데이터
-//            if (event.getSenderRole() == RoleType.ROLE_LISTENER) {
-//                recipientId = chatRoom.getSpeaker().getUser().getId();
-//                recipientNickname = chatRoom.getSpeaker().getNickname();
-//            } else {
-//                recipientId = chatRoom.getListener().getUser().getId();
-//                recipientNickname = chatRoom.getListener().getNickname();
-//            }
-//
-//            // 수신자 상태 확인
-//            String statusKey = "user:status:" + recipientId;
-//            Boolean isOnline = (Boolean) redisTemplate.opsForHash().get(statusKey, "online");
-//
-//            if (isOnline == null || !isOnline) {
-//                // todo : fcm 토큰 조회 -> 사용자 정보에서 가져오기
-//                String fcmToken = getFCMToken(recipientId);
-//
-//                if (fcmToken != null) {
-//                    Map<String, String> data = new HashMap<>();
-//                    data.put("roomId", event.getRoomId().toString());
-//                    data.put("senderId", event.getSenderId().toString());
-//                    data.put("senderRole", event.getSenderRole().toString());
-//                    data.put("type", event.getType().toString());
-//
-//                    String messagePreview = event.getContent().length() > 30
-//                            ? event.getContent().substring(0, 27)
-//                            : event.getContent();
-//
-//                    fcmService.sendNotification(
-//                            fcmToken,
-//                            "새 메시지가 도착했습니다",
-//                            messagePreview,
-//                            data
-//                    );
-//                    log.info("Push notification sent to user: {}", recipientId);
-//                } else {
-//                    log.warn("FCM token not found for user: {}", recipientId);
-//                }
-//            }
-//        } catch (Exception e) {
-//            log.error("Error sending notification", e);
-//        }
-//    }
-//
-//    private String getFCMToken(Long userId) {
-//        String tokenKey = "user:fcm:" + userId;
-//        return (String) redisTemplate.opsForValue().get(tokenKey);
-//    }
-//}
diff --git a/src/main/java/com/mindmate/mindmate_server/chat/service/UnreadCountConsumer.java b/src/main/java/com/mindmate/mindmate_server/chat/service/UnreadCountConsumer.java
index 88a865cd..0d2878e5 100644
--- a/src/main/java/com/mindmate/mindmate_server/chat/service/UnreadCountConsumer.java
+++ b/src/main/java/com/mindmate/mindmate_server/chat/service/UnreadCountConsumer.java
@@ -32,25 +32,26 @@ public class UnreadCountConsumer {
     public void updateUnreadCount(ConsumerRecord<String, ChatMessageEvent> record) {
         ChatMessageEvent event = record.value();
 
+        if (event.isFiltered() || event.getMessageId() == null) {
+            return;
+        }
+
         try {
             ChatRoom chatRoom = chatRoomService.findChatRoomById(event.getRoomId());
             User sender = userService.findUserById(event.getSenderId());
+            User recipient = userService.findUserById(event.getRecipientId());
 
             // 1. 발신자의 메시지 읽음 처리
             chatRoom.markAsRead(sender, event.getMessageId());
 
             // 2. 수신자 상태 확인
-            User recipient = chatRoom.isListener(sender) ?
-                    chatRoom.getSpeaker() : chatRoom.getListener();
-            boolean isRecipientOnline = chatPresenceService.isUserActiveInRoom(recipient.getId(), event.getRoomId());
-
-            if (isRecipientOnline) {
+            if (event.isRecipientActive()) {
                 // 수신자가 채팅방에 있는 경우 -> 읽음 처리
                 chatService.markAsRead(recipient.getId(), event.getRoomId());
                 log.info("Marked message as read for recipient {} in room {}", recipient.getId(), event.getRoomId());
             } else {
                 // 수신자가 채팅방에 없는 경우 -> 미읽음 카운트 증가
-                String unreadKey = String.valueOf(chatPresenceService.incrementUnreadCountInRedis(event.getRoomId(), recipient.getId()));
+                chatPresenceService.incrementUnreadCountInRedis(event.getRoomId(), recipient.getId());
 
                 if (chatRoom.isListener(recipient)) {
                     chatRoom.increaseUnreadCountForListener();
@@ -58,17 +59,9 @@ public void updateUnreadCount(ConsumerRecord<String, ChatMessageEvent> record) {
                     chatRoom.increaseUnreadCountForSpeaker();
                 }
 
-                // 변경사항 저장
-//                chatRoomRepository.saveAndFlush(chatRoom);
                 chatRoomService.save(chatRoom);
-
                 log.info("Incremented unread count for recipient {} in room {}", recipient.getId(), event.getRoomId());
             }
-
-            // 변경 후 상태 확인을 위한 로그
-            ChatRoom updatedChatRoom = chatRoomService.findChatRoomById(chatRoom.getId());
-            log.info("After processing: listener unread count={}, speaker unread count={}",
-                    updatedChatRoom.getListenerUnreadCount(), updatedChatRoom.getSpeakerUnreadCount());
         } catch (Exception e) {
             log.error("Error processing message event", e);
         }


Reviews:
user: login: 1-J-1
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T05:34:57Z
user: login: unie12
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T06:33:33Z
user: login: 1-J-1
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T07:10:03Z

Review comments:
diff_hunk: @@ -32,7 +37,8 @@ public long countMessagesByChatRoomId(Long roomId) {
 
     @Override
     public List<ChatMessage> findAllByChatRoomIdOrderByIdAsc(Long roomId) {
path: src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
user: login: 1-J-1
body: 여기서 채팅방 전체 메시지 가져와서 복호화하는 건가요?? 페이징 안 하고 다 가져와서 처리하면 오래 걸리지 않을까용
created_at: 2025-04-17T05:34:51Z
author_association: COLLABORATOR
diff_hunk: @@ -32,7 +37,8 @@ public long countMessagesByChatRoomId(Long roomId) {
 
     @Override
     public List<ChatMessage> findAllByChatRoomIdOrderByIdAsc(Long roomId) {
path: src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
user: login: unie12
body: 근데 해당 메서드를 사용하는 상황이 해당 채팅방에 처음 접속하는 순간이거든요? 그래서 매칭이 성사되고 내가 채팅방에 들어가기 전에 상대방이 채팅을 보내봤자 엄청 많지도 않을거고 이게 카톡 채팅방처럼 처음 채팅방에 들어가면 가장 오래된 메시지부터 최근 메시지까지 쭉 모두 확인할 수 있어야 한다고 생각했어요
created_at: 2025-04-17T06:33:33Z
author_association: COLLABORATOR
diff_hunk: @@ -32,7 +37,8 @@ public long countMessagesByChatRoomId(Long roomId) {
 
     @Override
     public List<ChatMessage> findAllByChatRoomIdOrderByIdAsc(Long roomId) {
path: src/main/java/com/mindmate/mindmate_server/chat/service/ChatMessageServiceImpl.java
user: login: 1-J-1
body: 넵. 이것도 저번 pr때 한 번 말씀 해주신 것 같은데.. 아닌가ㅎㅎ;;
created_at: 2025-04-17T07:09:30Z
author_association: COLLABORATOR

PR comments:
user: login: 1-J-1
created_at: 2025-04-17T05:18:46Z
body: 지금 필터링된 메시지는 db에 저장되지 않는거죠? 
author_association: COLLABORATOR
user: login: 1-J-1
created_at: 2025-04-17T05:22:16Z
body: 그리고 흐름이 
사용자가 메시지 전송 -> 필터링 후 저장
이후에 카프카로 이벤트 발행해서 처리하는 거 맞을까용?
author_association: COLLABORATOR
user: login: 1-J-1
created_at: 2025-04-17T05:25:41Z
body: > 그리고 흐름이 사용자가 메시지 전송 -> 필터링 후 저장 이후에 카프카로 이벤트 발행해서 처리하는 거 맞을까용?

이게 암호화가 비동기적으로 처리되어서 db에 저장된다면 암호화가 되기 전에 db에 저장된 걸 확인할 수 있지 않나요??
author_association: COLLABORATOR
user: login: unie12
created_at: 2025-04-17T06:26:18Z
body: > 지금 필터링된 메시지는 db에 저장되지 않는거죠?

넵 맞습니다!
author_association: COLLABORATOR
user: login: unie12
created_at: 2025-04-17T06:27:45Z
body: > 그리고 흐름이 사용자가 메시지 전송 -> 필터링 후 저장 이후에 카프카로 이벤트 발행해서 처리하는 거 맞을까용?

넵 맞습니다!
메시지 전송 -> 필터링 -> 필터링 걸리면 그냥 끝
                                   -> 없으면 저장 후 redis pub/sub + kafka 이벤트 
                                        발행 
author_association: COLLABORATOR
user: login: unie12
created_at: 2025-04-17T06:31:20Z
body: > > 그리고 흐름이 사용자가 메시지 전송 -> 필터링 후 저장 이후에 카프카로 이벤트 발행해서 처리하는 거 맞을까용?
> 
> 이게 암호화가 비동기적으로 처리되어서 db에 저장된다면 암호화가 되기 전에 db에 저장된 걸 확인할 수 있지 않나요??

저번에 말했는데 이게 암호화까지 동기적으로 처리해버리면 아래와 같은 문제가 발생할 것 같았어요

1. 실시간성 문제 -> 필터링도 해야 하고 암호화 해야 하고 저장까지 해야 함
2. 이게 채팅 내용 전체로 보면 개인적인 정보지만 각각의 메시지 하나에 대해서는 큰 의미가 없다고 생각 -> db에 저장되는 타임을 기다리다가 해킹을 해버리면 확인할 순 있지만, 이게 찰나의 시간이고 계속 기다리면서 확인하는게 아니라면 모든 내용을 파악하는게 어렵지 않을까 생각
author_association: COLLABORATOR
user: login: 1-J-1
created_at: 2025-04-17T06:33:29Z
body: > > > 그리고 흐름이 사용자가 메시지 전송 -> 필터링 후 저장 이후에 카프카로 이벤트 발행해서 처리하는 거 맞을까용?
> > 
> > 
> > 이게 암호화가 비동기적으로 처리되어서 db에 저장된다면 암호화가 되기 전에 db에 저장된 걸 확인할 수 있지 않나요??
> 
> 저번에 말했는데 이게 암호화까지 동기적으로 처리해버리면 아래와 같은 문제가 발생할 것 같았어요
> 
> 1. 실시간성 문제 -> 필터링도 해야 하고 암호화 해야 하고 저장까지 해야 함
> 2. 이게 채팅 내용 전체로 보면 개인적인 정보지만 각각의 메시지 하나에 대해서는 큰 의미가 없다고 생각 -> db에 저장되는 타임을 기다리다가 해킹을 해버리면 확인할 순 있지만, 이게 찰나의 시간이고 계속 기다리면서 확인하는게 아니라면 모든 내용을 파악하는게 어렵지 않을까 생각

아아 기억났습니다ㅏㅎㅎ
author_association: COLLABORATOR
user: login: 1-J-1
created_at: 2025-04-17T06:42:23Z
body: > > 지금 필터링된 메시지는 db에 저장되지 않는거죠?
> 
> 넵 맞습니다!

아직 카프카 이벤트 발행한 건 아직 처리 로직 없는 ㅓㄱ 맞을까요?
author_association: COLLABORATOR
user: login: unie12
created_at: 2025-04-17T06:51:21Z
body: > > > 지금 필터링된 메시지는 db에 저장되지 않는거죠?
> > 
> > 
> > 넵 맞습니다!
> 
> 아직 카프카 이벤트 발행한 건 아직 처리 로직 없는 ㅓㄱ 맞을까요?

맞습니다. 필터링 이후에 카프카 이벤트를 발행해서 어떤걸 어떻게 처리해야 할지 고민중입니다. 그냥 아무것도 안할 수도 있고 다른 기능을 넣을 수도 있습니다
author_association: COLLABORATOR