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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ src/main/resources/application.properties

# 로컬 개발 설정
docker-compose.dev.yml
/data/
/db/

# 환경변수
.env
.env
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.ezcode.codetest.application.community.service;

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

import org.ezcode.codetest.application.community.dto.request.VoteRequest;
import org.ezcode.codetest.application.community.dto.response.VoteResponse;
import org.ezcode.codetest.application.notification.event.NotificationCreateEvent;
import org.ezcode.codetest.application.notification.port.NotificationEventService;
import org.ezcode.codetest.application.notification.service.NotificationExecutor;
import org.ezcode.codetest.domain.community.model.entity.Discussion;
import org.ezcode.codetest.domain.community.model.entity.DiscussionVote;
import org.ezcode.codetest.domain.community.service.DiscussionDomainService;
Expand All @@ -15,21 +17,24 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class DiscussionVoteService extends BaseVoteService<DiscussionVote, DiscussionVoteDomainService> {

private final DiscussionDomainService discussionDomainService;
private final NotificationEventService notificationEventService;
private final NotificationExecutor notificationExecutor;

public DiscussionVoteService(
DiscussionVoteDomainService domainService,
UserDomainService userDomainService,
DiscussionDomainService discussionDomainService,
NotificationEventService notificationEventService
NotificationExecutor notificationExecutor
) {
super(domainService, userDomainService);
this.discussionDomainService = discussionDomainService;
this.notificationEventService = notificationEventService;
this.notificationExecutor = notificationExecutor;
}

@Transactional
Expand All @@ -43,10 +48,18 @@ public VoteResponse manageVoteOnDiscussion(Long problemId, Long discussionId, Vo
@Override
protected void afterVote(User voter, Long targetId) {

Discussion discussion = discussionDomainService.getDiscussionById(targetId);
notificationExecutor.execute(() -> {

try {
Discussion discussion = discussionDomainService.getDiscussionById(targetId);

Optional<NotificationCreateEvent> notificationEvent = voteDomainService.createDiscussionVoteNotification(voter, discussion);
Optional<NotificationCreateEvent> notificationEvent = voteDomainService.createDiscussionVoteNotification(voter, discussion);

notificationEvent.ifPresent(notificationEventService::saveAndNotify);
return notificationEvent.map(List::of).orElse(Collections.emptyList());
} catch (Exception ex) {
log.error("토론글 추천 알림 생성 중 에러 발생 : {}", ex.getMessage());
return Collections.emptyList();
}
});
Comment on lines +51 to +63
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

알림 처리 로직의 트랜잭션 경계를 확인해주세요.

새로운 NotificationExecutor 기반 람다 패턴이 도입되었는데, 다음 사항들을 검토해주세요:

  1. 트랜잭션 분리: 람다 내부에서 discussionDomainService.getDiscussionById(targetId)를 호출하는데, 이것이 메인 투표 트랜잭션과 별도의 트랜잭션에서 실행되는지 확인이 필요합니다.
  2. 데이터 일관성: 알림 처리가 별도의 트랜잭션에서 실행될 경우, 투표 완료 후 토론글 데이터 조회 시점에서 일관성 문제가 발생할 수 있습니다.

다음 스크립트로 NotificationExecutor.execute() 메서드의 트랜잭션 처리 방식을 확인해주세요:


🏁 Script executed:

#!/bin/bash
# Description: NotificationExecutor의 트랜잭션 처리 방식 확인

# NotificationExecutor 클래스의 execute 메서드 구현 확인
ast-grep --pattern $'class NotificationExecutor {
  $$$
  execute($$$) {
    $$$
  }
  $$$
}'

# @Transactional 어노테이션 사용 여부 확인
rg -A 10 "class NotificationExecutor"

Length of output: 1545


NotificationExecutor.execute의 트랜잭션 전파가 REQUIRES_NEW임을 확인했습니다.
람다 내부 로직이 메인 투표 트랜잭션과 별도 트랜잭션에서 실행되므로, 아직 커밋되지 않은 투표 반영이 discussionDomainService.getDiscussionById(targetId) 호출 시점에 조회되지 않을 수 있습니다.

• src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java:51-63 –
notificationExecutor.execute 내부의 discussionDomainService.getDiscussionById(targetId)

해결 방안 예시:

  • 메인 트랜잭션 내에서 알림용 토론글을 미리 조회해 람다에 전달
  • 또는 투표 처리 커밋 후 별도 스케줄러/이벤트 리스너로 알림 실행 구조로 재구성
🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java
around lines 51 to 63, the notificationExecutor.execute lambda runs in a
REQUIRES_NEW transaction, causing
discussionDomainService.getDiscussionById(targetId) to potentially read stale
data not yet committed by the main vote transaction. To fix this, fetch the
Discussion entity within the main transaction before calling
notificationExecutor.execute, then pass the fetched Discussion object into the
lambda instead of querying inside it. This ensures the notification logic uses
up-to-date data without transaction isolation issues.

}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package org.ezcode.codetest.application.community.service;

import java.util.Collections;
import java.util.List;

import org.ezcode.codetest.application.community.dto.request.ReplyCreateRequest;
import org.ezcode.codetest.application.community.dto.request.ReplyModifyRequest;
import org.ezcode.codetest.application.community.dto.response.ReplyResponse;
import org.ezcode.codetest.application.notification.event.NotificationCreateEvent;
import org.ezcode.codetest.application.notification.port.NotificationEventService;
import org.ezcode.codetest.application.notification.service.NotificationExecutor;
import org.ezcode.codetest.domain.community.dto.ReplyQueryResult;
import org.ezcode.codetest.domain.community.model.entity.Discussion;
import org.ezcode.codetest.domain.community.model.entity.Reply;
Expand All @@ -20,7 +20,9 @@
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class ReplyService {
Expand All @@ -30,7 +32,7 @@ public class ReplyService {
private final DiscussionDomainService discussionDomainService;
private final UserDomainService userDomainService;

private final NotificationEventService notificationEventService;
private final NotificationExecutor notificationExecutor;

@Transactional
public ReplyResponse createReply(
Expand All @@ -46,14 +48,22 @@ public ReplyResponse createReply(

Reply reply = replyDomainService.createReply(discussion, user, request.parentReplyId(), request.content());

List<User> notificationTargets = reply.generateNotificationTargets();
notificationExecutor.execute(() -> {
try {
List<User> notificationTargets = reply.generateNotificationTargets();

if (!notificationTargets.isEmpty()) {
for (User target : notificationTargets) {
NotificationCreateEvent notificationEvent = replyDomainService.createReplyNotification(target, reply);
notificationEventService.saveAndNotify(notificationEvent);
if (notificationTargets.isEmpty()) {
return Collections.emptyList();
}

return notificationTargets.stream()
.map(target -> replyDomainService.createReplyNotification(target, reply))
.toList();
} catch (Exception ex) {
log.error("댓글 알림 생성 중 에러 발생 : {}", ex.getMessage());
return Collections.emptyList();
}
}
});

return ReplyResponse.fromEntity(reply);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.ezcode.codetest.application.community.service;

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

import org.ezcode.codetest.application.community.dto.request.VoteRequest;
import org.ezcode.codetest.application.community.dto.response.VoteResponse;
import org.ezcode.codetest.application.notification.event.NotificationCreateEvent;
import org.ezcode.codetest.application.notification.port.NotificationEventService;
import org.ezcode.codetest.application.notification.service.NotificationExecutor;
import org.ezcode.codetest.domain.community.model.entity.Reply;
import org.ezcode.codetest.domain.community.model.entity.ReplyVote;
import org.ezcode.codetest.domain.community.service.ReplyDomainService;
Expand All @@ -15,22 +17,25 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class ReplyVoteService extends BaseVoteService<ReplyVote, ReplyVoteDomainService> {

private final ReplyDomainService replyDomainService;
private final NotificationExecutor notificationExecutor;

private final NotificationEventService notificationEventService;

public ReplyVoteService(
ReplyVoteDomainService domainService,
UserDomainService userDomainService,
ReplyDomainService replyDomainService,
NotificationEventService notificationEventService
NotificationExecutor notificationExecutor
) {
super(domainService, userDomainService);
this.replyDomainService = replyDomainService;
this.notificationEventService = notificationEventService;
this.notificationExecutor = notificationExecutor;
}

@Transactional
Expand All @@ -44,10 +49,18 @@ public VoteResponse manageVoteOnReply(Long problemId, Long discussionId, Long re
@Override
protected void afterVote(User voter, Long targetId) {

Reply reply = replyDomainService.getReplyById(targetId);
notificationExecutor.execute(() -> {

try {
Reply reply = replyDomainService.getReplyById(targetId);

Optional<NotificationCreateEvent> notificationEvent = voteDomainService.createReplyVoteNotification(voter, reply);
Optional<NotificationCreateEvent> notificationEvent = voteDomainService.createReplyVoteNotification(voter, reply);

notificationEvent.ifPresent(notificationEventService::saveAndNotify);
return notificationEvent.map(List::of).orElse(Collections.emptyList());
} catch (Exception ex) {
log.error("댓글 추천 알림 생성 중 에러 발생 : {}", ex.getMessage());
return Collections.emptyList();
}
});
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.ezcode.codetest.application.notification.event;

public record NotificationReadEvent(
public record NotificationMarkReadEvent(

String principalName,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
public enum NotificationExceptionCode implements ResponseCode {

NOTIFICATION_CANNOT_FIND_EVENT_TYPE(false, HttpStatus.INTERNAL_SERVER_ERROR, "해당 이벤트 타입의 mapper를 찾을 수 없습니다."),
NOTIFICATION_CONVERT_MESSAGE_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "메시지 변환 과정에서 에러가 발생했습니다."),
NOTIFICATION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 notification 데이터를 찾지 못했습니다")
;

private final boolean success;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import org.ezcode.codetest.application.notification.event.NotificationCreateEvent;
import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent;
import org.ezcode.codetest.application.notification.event.NotificationReadEvent;
import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent;

public interface NotificationEventService {

void saveAndNotify(NotificationCreateEvent dto);
void notify(NotificationCreateEvent dto);

void notifyList(NotificationListRequestEvent dto);

void setRead(NotificationReadEvent dto);
void setRead(NotificationMarkReadEvent dto);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.ezcode.codetest.application.notification.service;

import java.util.List;
import java.util.function.Supplier;

import org.ezcode.codetest.application.notification.event.NotificationCreateEvent;
import org.ezcode.codetest.application.notification.port.NotificationEventService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationExecutor {

private final NotificationEventService notificationEventService;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void execute(Supplier<List<NotificationCreateEvent>> notificationsEventSupplier) {

try {
List<NotificationCreateEvent> events = notificationsEventSupplier.get();

if (events != null && !events.isEmpty()) {
for (NotificationCreateEvent event : events) {
notificationEventService.notify(event);
}
log.info("알림 이벤트 {}개 발행 성공", events.size());
}
} catch (Exception ex) {
log.error("알림 이벤트 발행 실패: {}", ex.getMessage(), ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.ezcode.codetest.application.notification.service;

import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent;
import org.ezcode.codetest.application.notification.event.NotificationReadEvent;
import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent;
import org.ezcode.codetest.application.notification.port.NotificationEventService;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
Expand All @@ -24,7 +24,7 @@ public void getNotificationList(String email, Pageable pageable) {
public void modifyNotificationMarksRead(String email, String notificationId) {

notificationEventService.setRead(
new NotificationReadEvent(email, notificationId)
new NotificationMarkReadEvent(email, notificationId)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
@RequiredArgsConstructor
public enum CommunityExceptionCode implements ResponseCode {

DISCUSSION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 자유글이 존재하지 않습니다."),
DISCUSSION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 토론글이 존재하지 않습니다."),
DISCUSSION_PROBLEM_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 글이 요청된 문제에 속하지 않습니다."),

REPLY_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 댓글이 존재하지 않습니다."),
REPLY_DISCUSSION_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 댓글이 요청된 자유글에 속하지 않습니다."),
REPLY_DISCUSSION_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 댓글이 요청된 토론글에 속하지 않습니다."),

USER_NOT_AUTHOR(false, HttpStatus.FORBIDDEN, "작성자만 수정/삭제할 수 있습니다."),
;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import com.github.benmanes.caffeine.cache.Caffeine;

@Configuration
@EnableCaching
public class CaffeineCacheConfig {

@Primary
@Bean
public CacheManager cacheManager() {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package org.ezcode.codetest.infrastructure.cache.config;

import org.ezcode.codetest.application.chatting.port.cache.ChatRoomCache;
import org.ezcode.codetest.infrastructure.event.dto.NotificationRecord;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand Down Expand Up @@ -39,22 +37,4 @@ public RedisTemplate<String, ChatRoomCache> cacheRedisTemplate(

return template;
}

@Bean
public RedisTemplate<String, NotificationRecord> notificationRedisTemplate(
RedisConnectionFactory factory,
ObjectMapper objectMapper
) {

RedisTemplate<String, NotificationRecord> template = new RedisTemplate<>();
template.setConnectionFactory(factory);

template.setKeySerializer(new StringRedisSerializer());

Jackson2JsonRedisSerializer<NotificationRecord> valueSerializer =
new Jackson2JsonRedisSerializer<>(objectMapper, NotificationRecord.class);
template.setValueSerializer(valueSerializer);

return template;
}
}
Loading