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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public enum NotificationType {
false
),

SEMOFEED_EMOJI(
"세모피드에 반응이 달렸어요",
"{actorName}님이 세모피드에 {emojiType} 반응을 남겼어요",
Set.of("actorId", "actorName", "semoFeedId", "emojiType"),
false
),

/**
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
* 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.semosan.api.domain.semofeed.notification.service;

import com.semosan.api.domain.notification.enums.NotificationType;
import com.semosan.api.domain.notification.service.NotificationService;
import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.user.entity.User;
import com.semosan.api.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Map;

@Service
@RequiredArgsConstructor
public class SemoFeedNotificationService {

private final NotificationService notificationService;
private final UserRepository userRepository;

// 세모피드에 새 이모지 반응이 등록되면 작성자에게 알림을 보냅니다.
public void sendEmojiNotification(SemoFeed semoFeed, User reactor, SemoFeedEmojiType emojiType) {
User receiver = semoFeed.getUser();
sendIfEligible(
receiver,
reactor,
Map.of(
"actorId", reactor.getId(),
"actorName", reactor.displayName(),
"semoFeedId", semoFeed.getId(),
"emojiType", emojiType.name()
)
);
}

// 본인 반응과 탈퇴한 작성자는 알림 발송 대상에서 제외합니다.
private void sendIfEligible(User receiver, User reactor, Map<String, Object> params) {
if (receiver.getId().equals(reactor.getId())) {
return;
}
if (!userRepository.existsByIdAndDeletedFalse(receiver.getId())) {
return;
}
Comment on lines +41 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

userRepository.existsByIdAndDeletedFalse(receiver.getId()) 호출로 인해 중복 쿼리가 발생합니다.

NotificationService.send 메서드 내부(46라인)에서도 동일하게 userRepository.existsByIdAndDeletedFalse(receiverId)를 호출하여 수신자의 존재 여부 및 탈퇴 여부를 검증하고 있습니다. 이로 인해 동일한 receiverId에 대해 exists 쿼리가 두 번 연속으로 실행되는 비효율이 존재합니다.

개선 제안:
알림 발송 실패(탈퇴한 사용자 등)가 주 비즈니스 트랜잭션에 영향을 주지 않도록 이벤트를 통해 비동기/분리 처리하거나, NotificationService에 예외를 던지지 않고 무시하는 안전한 발송 메서드(예: sendIfPresent)를 추가하여 중복 쿼리를 제거하는 것을 고려해 주세요.


notificationService.send(receiver.getId(), NotificationType.SEMOFEED_EMOJI, params);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.semofeed.notification.service.SemoFeedNotificationService;
import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository;
import com.semosan.api.domain.semofeed.repository.SemoFeedRepository;
import com.semosan.api.domain.user.entity.User;
Expand All @@ -23,6 +24,7 @@ public class SemoFeedEmojiService {

private final SemoFeedEmojiRepository semoFeedEmojiRepository;
private final SemoFeedRepository semoFeedRepository;
private final SemoFeedNotificationService semoFeedNotificationService;
private final UserReader userReader;

@Transactional
Expand All @@ -36,6 +38,10 @@ public SemoFeedEmojiToggleResponse toggleWithCount(
User user = userReader.findActiveUserById(userId);

boolean reacted = toggle(semoFeed, user, emojiType);
if (reacted) {
// 새 반응 등록일 때만 작성자 알림을 발송합니다.
semoFeedNotificationService.sendEmojiNotification(semoFeed, user, emojiType);
}
Comment on lines +41 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재 SemoFeedEmojiService.toggleWithCount 메서드 내에서 이모지 토글 트랜잭션과 알림 발송(SemoFeedNotificationService.sendEmojiNotification)이 동기적으로 처리되고 있습니다.

이 구조는 다음과 같은 문제를 야기할 수 있습니다:

  1. 트랜잭션 롤백 전파 (Repository Style Guide Rule 29 위반): 알림 저장이나 FCM 토큰 조회 등 알림 발송 과정에서 예외가 발생하면 주 비즈니스 로직인 이모지 반응 등록까지 함께 롤백됩니다. 알림은 부가적인 기능(Side Effect)이므로 실패하더라도 주 비즈니스 로직에 영향을 주지 않아야 합니다.
  2. 성능 저하: 알림 발송을 위한 추가적인 DB 조회 및 저장 작업이 주 트랜잭션 내에서 실행되므로, 커넥션 점유 시간이 길어지고 성능 병목이 발생할 수 있습니다.

개선 제안:
Spring의 ApplicationEventPublisher를 사용하여 이모지 반응 등록 이벤트를 발행하고, 이를 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 수신하여 처리하는 구조로 개선하는 것을 권장합니다. 이렇게 하면 주 트랜잭션이 성공적으로 커밋된 후에만 알림 로직이 실행되므로, 알림 실패가 주 비즈니스 로직에 영향을 주지 않고 성능도 개선됩니다.

References
  1. 알림, 메시징, FCM 등 외부 사이드 이펙트 처리 시 실패가 주 비즈니스 작업을 의도치 않게 롤백하지 않도록 검증해야 합니다. (link)

long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType);

return new SemoFeedEmojiToggleResponse(emojiType, reacted, count);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.semosan.api.domain.semofeed.notification.service;

import com.semosan.api.domain.notification.enums.NotificationType;
import com.semosan.api.domain.notification.service.NotificationService;
import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.user.entity.User;
import com.semosan.api.domain.user.enums.user.DeviceType;
import com.semosan.api.domain.user.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

import java.util.Map;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SemoFeedNotificationServiceTest {

@Mock
private NotificationService notificationService;

@Mock
private UserRepository userRepository;

@InjectMocks
private SemoFeedNotificationService semoFeedNotificationService;

@Test
void semoFeedEmojiNotificationTypeRequiresNavigationPayload() {
assertThatCode(() -> NotificationType.SEMOFEED_EMOJI.validate(Map.of(
"actorId", 2L,
"actorName", "reactor",
"semoFeedId", 10L,
"emojiType", "FIRE"
))).doesNotThrowAnyException();
}

@Test
void sendEmojiNotificationSendsToSemoFeedAuthor() {
User author = user(1L, "author");
User reactor = user(2L, "reactor");
SemoFeed semoFeed = semoFeed(10L, author);

when(userRepository.existsByIdAndDeletedFalse(1L)).thenReturn(true);

semoFeedNotificationService.sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.FIRE);

verify(notificationService).send(
eq(1L),
eq(NotificationType.SEMOFEED_EMOJI),
argThat(params -> params.get("actorId").equals(2L)
&& params.get("actorName").equals("reactor")
&& params.get("semoFeedId").equals(10L)
&& params.get("emojiType").equals("FIRE"))
);
}

@Test
void sendEmojiNotificationSkipsSelfReaction() {
User author = user(1L, "author");
SemoFeed semoFeed = semoFeed(10L, author);

semoFeedNotificationService.sendEmojiNotification(semoFeed, author, SemoFeedEmojiType.HEART);

verify(notificationService, never()).send(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any()
);
}

@Test
void sendEmojiNotificationSkipsInactiveAuthor() {
User author = user(1L, "author");
User reactor = user(2L, "reactor");
SemoFeed semoFeed = semoFeed(10L, author);

when(userRepository.existsByIdAndDeletedFalse(1L)).thenReturn(false);

semoFeedNotificationService.sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.LAUGH);

verify(notificationService, never()).send(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any()
);
}

private User user(Long id, String nickname) {
User user = User.createTestUser(nickname, DeviceType.IOS);
ReflectionTestUtils.setField(user, "id", id);
ReflectionTestUtils.setField(user, "nickname", nickname);
return user;
}

private SemoFeed semoFeed(Long id, User user) {
SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png");
ReflectionTestUtils.setField(semoFeed, "id", id);
return semoFeed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.semofeed.notification.service.SemoFeedNotificationService;
import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository;
import com.semosan.api.domain.semofeed.repository.SemoFeedRepository;
import com.semosan.api.domain.user.entity.User;
Expand All @@ -20,6 +21,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

Expand All @@ -32,6 +34,9 @@ class SemoFeedEmojiServiceTest {
@Mock
private SemoFeedRepository semoFeedRepository;

@Mock
private SemoFeedNotificationService semoFeedNotificationService;

@Mock
private UserReader userReader;

Expand Down Expand Up @@ -63,6 +68,7 @@ void toggleWithCountCreatesEmojiReaction() {
assertThat(result.reacted()).isTrue();
assertThat(result.count()).isEqualTo(1L);
verify(semoFeedEmojiRepository).save(any(SemoFeedEmoji.class));
verify(semoFeedNotificationService).sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.FIRE);
}

@Test
Expand Down Expand Up @@ -90,6 +96,7 @@ void toggleWithCountDeletesExistingEmojiReaction() {
assertThat(result.reacted()).isFalse();
assertThat(result.count()).isZero();
verify(semoFeedEmojiRepository).delete(existing);
verify(semoFeedNotificationService, never()).sendEmojiNotification(any(), any(), any());
}

private User user(Long id, String nickname) {
Expand Down