# 계획
- 누군가가 회원이 작성한 일기에 좋아요를 누르거나
- 누군가가 회원을 팔로우하거나
- 누군가가 회원을 일기 안에서 태그할 때(태그는 "@" 뒤에 아이디를 추가하는 것으로 하자)
해당 회원에게 알림, 즉 Notification이 가는 기능을 추가하고 싶었다.
1. Notification Entity 설정
알림을 DB에 저장되는 하나의 객체로 만들었다. 알림에 필요해보이는 field들을 생각해보았다.
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Notification extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long notificationId;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "sending_user")
private User sendingUser;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "receiving_user")
private User receivingUser;
@OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "farm_log")
private FarmLog farmLog;
private String message;
@NotNull @Enumerated(EnumType.STRING)
private NotificationCode notificationCode;
private Boolean isRead;
}
- sendingUser, receivingUser : 알림을 일으킨 유저와 알림을 받는 유저이다. 알림 관계에서 보면 둘은 다대다 관계인데 Notification 객체를 따로 뺌으로서 일대다 관계 두개로 나눴다.
- farmLog : 특정 유저를 태그한 일기나, 좋아요를 누른 일기의 정보를 받기 위해 일기 객체를 일대일 관계로 추가했다. 팔로우 알람일 경우 이 필드는 null값이 되므로 엄밀히 말하면 SRP를 위반한 것인데, 현재 프로젝트 단계에서 그정도로 고도화된 OOP 설계원칙을 따를 필요는 없을 것 같아 이대로 두었다.
- NotificationCode : 현재 이 알림이 어떤 종류의 알림인지, 즉 팔로우 / 좋아요 / 태그 셋 중 어떤 알림인지 Enum 타입으로 표시한 필드이다.
- isRead : 알림이 새로 온 알림인지의 여부를 띄웠다. isRead가 false일 경우에 navbar에 특정 기호를 띄우는 기능을 추후 추가할 예정이다. (AOP 이용)
NotificationCode enum 구성이다.
@Getter
@AllArgsConstructor
public enum NotificationCode {
TAG_ALARM("태그 알림"),
LIKE_ALARM("좋아요 알림"),
FOLLOW_ALARM("팔로우 알림");
private final String detail;
}
추가로, NotificationCode의 각 code에 맞는 알림 메시지를 제공하기 위해 Entity 클래스에 getMessage() 코드를 다음과 같이 추가했다. 알림이 뜨면 알림 메시지가 갈 것이다.
@Transient
public String getMessage() {
switch (notificationCode) {
case TAG_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님을 태그하셨습니다.";
}
case LIKE_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님의 일기에 좋아요를 누르셨습니다.";
}
case FOLLOW_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님을 팔로우합니다.";
}
default -> {
throw new GreenFarmException(GreenFarmErrorCode.UNKNOWN_ALARM_ERROR);
}
}
}
그런데 아무리 @Transient를 박았다고 해도 entity 안에 이렇게 method를 추가하는 것은 안좋을 것 같다. message 자체를 필드에서 삭제하고 DTO에 해당 method를 추가하는 것으로 손을 봐야겠다.
당연히, Entity를 만들었으므로 Repository도 함께 작성했다.
@Repository
public interface NotificationRepository extends JpaRepository<Notification,Long> {
@Query("SELECT n FROM Notification n WHERE n.receivingUser = :receivingUser ORDER BY createdAt DESC LIMIT 30")
public Optional<List<Notification>> findByReceivingUser(@Param("receivingUser") User receivingUser);
@Modifying
public void deleteByReceivingUser(User receivingUser);
}
- findByReceivingUser : "알림을 받은 유저"가 알림을 확인하기 위해 findByReceivingUser를 했다. 알림은 보통 최신순으로 정렬되므로 ORDR BY createAt DESC로 정렬했고, 최근 30개만 보여주기 위해 limit를 걸었다. derived query로 이를 표현하기엔 메서드 명이 너무 길어질 것 같아 JPQL을 따로 작성했다.
- deleteByReceivingUser() : 알림을 받은 유저가 자신의 알림을 한번에 삭제할 수 있도록 하는 modifying query이다.
2. Tag 알림
요구사항 : 어떤 사용자가 일기에 @{userId} 형식으로 특정 유저의 ID를 이용해 특정 유저를 태그하면, 태그당한 유저에게 알림이 가도록 한다.
해당 기능을 구현하기 위해서 일기가 post될 때, 일기 안의 tag 목록을 scan하는 과정이 필요하다. NotificationService라는 service component를 추가한 후, 사용자의 일기를 input으로 받아 태그 목록을 스캔하는 searchTagList method를 작성했다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
public List<String> searchTagList(final String logContent) {
Pattern idPattern = Pattern.compile("@[a-zA-Z][a-zA-Z0-9]{0,14}");
return Arrays.stream(logContent.split(" "))
.filter(s -> idPattern.matcher(s).matches())
.map(s -> s.substring(1))
.toList();
}
}
아이디 패턴을 받기 위해 정규 표현식을 이용했다. 태그는 @로 시작하면서, 영어 알파벳과 숫자로 이뤄진 15자 이하의 아이디로 끝나는 단어이다.
유저의 input string -> 공백을 delimiter로 split -> filter로 Pattern 적용 -> 뽑아낸 chunk에서 "@" 지우기(substring) -> toList()로 collecting
위의 과정을 통해 유저의 input에서 tag list를 뽑아 태그 대상이 되는 유저 list를 뽑았다.
다음은 그 리스트에 있는 유저들에게 실제로 알림을 보내는 메서드이다.
@Transactional
public void sendTagNotification(final List<String> taggedIds,
final User taggingUser,
final FarmLog taggingFarmLog) {
for (String id : taggedIds) {
userRepository.findByUserId(id).ifPresent(
user -> {
notificationRepository.save(
Notification.builder()
.notificationCode(NotificationCode.TAG_ALARM)
.sendingUser(taggingUser)
.receivingUser(user)
.farmLog(taggingFarmLog)
.isRead(false)
.build());
});
}
}
- List<String> taggedIds : 바로 위 searchTagList()를 통해 불러온 태그당한 사람들의 id list를 인자로 받았다.
- taggingUser, taggingFarmLog : 태그를 시도한 유저와 태그 목록이 들어있는 일기 객체이다.
- taggedIds 리스트를 iterate하며 해당 id를 가진 유저가 존재하면 그 유저를 receivingUser로 하는 Notification 객체를 빌드한 후 save했다. notificationCode() 메서드의 인자로 TAG_ALARM enum type이 들어간 것을 확인하자.
일기가 작성되는 post 요청이 일어날 때 위 메서드들을 이용해서 알림을 보낼 수 있도록 하겠다.
다음은 일기와 관련된 service 컴포넌트에서 일기가 작성되는 post 요청이 일어날 때 실행되는 메서드이다.
@Transactional
public Long createFarmLogAndReturnFarmLogId(CreateFarmLog.Request request) {
User currentUser = getSessionUser();
FarmLog createdFarmLog = farmLogRepository.save(
FarmLog.builder()
.logContent(request.getLogContent())
.author(currentUser)
.build());
// 주목!
notificationService.sendTagNotification(
notificationService.searchTagList(request.getLogContent()),
currentUser,
createdFarmLog);
return createdFarmLog.getFarmLogId();
}
주목! 처리한 부분을 잘 보자. request로부터 사용자의 input stinrg을 받아 searchTagList() 메소드로 보내준다. 여기서 tag된 id list가 뽑아질 것이다. id list, 현재 일기를 작성하는 currentUser, 방금 생성된 createdFarmLog를 sendTagNotification() 인자로 보내 Notification 객체를 빌드하여 저장할 수 있도록 했다.
3. Follow, 좋아요 알림
나머지 팔로우와 좋아요 알림은 간단하다. NotificationService 컴포넌트에 아래 두 메서드를 작성하기만 하면 된다.
@Transactional
public void sendFollowNotification(final User followingUser,
final User followedUser) {
notificationRepository.save(
Notification.builder()
.sendingUser(followingUser)
.receivingUser(followedUser)
.notificationCode(NotificationCode.FOLLOW_ALARM)
.isRead(false)
.build()
);
}
@Transactional
public void sendLikeNotification(final User sendingUser,
final FarmLog farmLog) {
notificationRepository.save(
Notification.builder()
.sendingUser(sendingUser)
.receivingUser(farmLog.getAuthor())
.farmLog(farmLog)
.notificationCode(NotificationCode.LIKE_ALARM)
.build());
}
역시 notificationCode() 인자로 각각에 맞는 enumType이 들어갔다.
각각의 메서드를 팔로우 할 때, 좋아요를 누를 때 해당 서비스 메서드에서 적절한 인자를 넣어 호출하면 된다.
4. DTO
방금까지는 DB에 저장된 객체를 말하는 것이었고, 이제는 실제로 서비스에서 유저에게 보여지는 알림을 구현하는 DTO 차례이다.
@Getter
@Builder
@AllArgsConstructor
public class NotificationDto {
private Long id;
private String message;
private NotificationCode notificationCode;
private String notificationURL;
private String timePassed;
public static NotificationDto fromEntity(Notification notification) {
return NotificationDto.builder()
.id(notification.getNotificationId())
.timePassed(TimeDuration.generateTimeDuration(notification.getCreatedAt()))
.message(notification.getMessage())
.notificationCode(notification.getNotificationCode())
.notificationURL(findNotificationURL(notification))
.build();
}
private static String findNotificationURL(Notification notification) {
switch (notification.getNotificationCode()) {
case TAG_ALARM -> {
return "farm-log/" + notification.getFarmLog().getFarmLogId();
}
case FOLLOW_ALARM, LIKE_ALARM -> {
return notification.getSendingUser().getUserId();
}
default -> {
throw new GreenFarmException(GreenFarmErrorCode.UNKNOWN_ALARM_ERROR);
}
}
}
}
- static 메서드인 fromEntity()를 통해 특정 Notification 객체를 받아 DTO를 빌드할 수 있도록 했다. message에 기존 Notifcation 엔티티 클래스에서 구현한 getMessage() 메서드가 사용됐다.
- notificationURL : 해당 알림을 클릭하면 이동할 경로를 지정해주는 URL string이다. 태그 알람이나 좋아요 알람일 경우 각각 태그된 일기와 좋아요 눌린 일기 경로를, 팔로우 알람일 경우 팔로우한 사람의 상세정보 경로를 가리킨다. thymeleaf에서 아래 href 형식으로 html 페이지에 전달해줄 것이다.
th:href="${'/'+notification.notificationURL}"
- timePassed : 알림이 현재 시간으로부터 얼마나 전에 일어난 것인지 알려주는 표지이다. TimeDuration.generateTimeDuration이라는 static method를 사용했는데, ChronoUnit을 이용해 현재 시각과 알림 객체의 시간차이를 구한 뒤 문자열을 받아내는 형식으로 구현했다.
public class TimeDuration {
public static String generateTimeDuration(LocalDateTime dateTime) {
LocalDateTime currentTime = LocalDateTime.now();
if (ChronoUnit.HOURS.between(dateTime,currentTime)<1) {
// n분 전
return ChronoUnit.MINUTES.between(dateTime,currentTime) + "분 전";
} else if (ChronoUnit.DAYS.between(dateTime,currentTime)<1) {
// n시간 전
return ChronoUnit.HOURS.between(dateTime,currentTime) + "시간 전";
} else if (ChronoUnit.MONTHS.between(dateTime,currentTime)<1) {
// n일 전
return ChronoUnit.DAYS.between(dateTime,currentTime) + "일 전";
} else {
// yy.MM
return dateTime.format(DateTimeFormatter.ofPattern("yy.MM HH"));
}
}
}
- 1달 이상 된 알림이라면 날짜와 시간만 보여주도록 했다.
5. Notification Controller
사용자에게 온 알림을 확인할 수 있는 notifications 페이지를 위해 컨트롤러를 작성했다.
@Controller
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
private final HttpSession httpSession;
@GetMapping("/notifications")
public String getNotificationPage(Model model) {
model.addAttribute("notifications",
notificationService.getNotificationDtos(
getSessionUser().getUserId()));
return "notifications.html";
}
@DeleteMapping("/notifications")
public String deleteAllNotifications() {
notificationService.deleteAllNotifications(getSessionUser().getUserId());
return "notifications.html";
}
@DeleteMapping("/notification/{notificationId}")
public String deleteNotification(@PathVariable("notificationId")
final Long notificationId) {
notificationService.deleteNotification(notificationId);
return "redirect:/notifications";
}
private SessionUser getSessionUser() {
SessionUser sessionUser = (SessionUser) httpSession.getAttribute("user");
if (sessionUser == null) {
throw new GreenFarmException(GreenFarmErrorCode.NEED_LOGIN);
} else {
return sessionUser;
}
}
}
- getNotificationPage() : getSessionUser()로 현재 로그인한 유저 정보를 가져온 뒤 notificationDto를 뽑아왔다. DTO list 안에는 receivingUser가 현재 로그인된 유저로 설정된 notificationDto객체들이 있을 것이고, 해당 객체들은 notifications model attribute로 HTML에게 전해질 것이다.
- deleteAllNotification(), deleteNotification() : 모든 알림을 한꺼번에 지울 수 있는 기능, 특정 알림을 삭제할 수 있는 기능을 수행한다. 알림은 바로바로 삭제할 수 있도록 하는 것이 좋다 생각해서 delete를 바로 구현했다.
다음은 관련된 service component 코드이다.
@Transactional
public List<NotificationDto> getNotificationDtos(final String userId) {
User currentUser = userRepository.findByUserId(userId)
.orElseThrow(() -> new GreenFarmException(GreenFarmErrorCode.NO_USER_ERROR));
List<Notification> notifications =
notificationRepository
.findByReceivingUser(currentUser)
.orElse(new ArrayList<>());
notificationRepository.deleteExceptFor(
currentUser,
notifications.stream().map(Notification::getNotificationId).toList());
return notifications
.stream().map(NotificationDto::fromEntity).toList();
}
@Transactional
public void deleteNotification(final Long notificationId) {
notificationRepository.deleteById(notificationId);
}
@Transactional
public void deleteAllNotifications(final String userId) {
notificationRepository.deleteByReceivingUser(
userRepository.findByUserId(userId).orElseThrow(
() -> new GreenFarmException(GreenFarmErrorCode.NO_USER_ERROR)));
}
- getNotificationDtos() : 일단 receivingUser로 "최근 30개" 알림을 받아온다. 그 뒤 deleteExceptFor() method를 실행하는데, 이는 현재 보여지는 유저의 최근 30개 알림을 제외한 알림을 삭제하는 쿼리이다. repository에 메소드를 다음과 같이 추가했다. receivingUser가 현재 알림을 확인하려는 유저이면서, 최근 30개의 알림에 들어가지 않는 알림들을 삭제하는 것이다.
@Modifying
@Query("DELETE FROM Notification n " +
"WHERE n.receivingUser = :receivingUser " +
"AND n.notificationId NOT IN :notifications")
public void deleteExceptFor(@Param("receivingUser") User receivingUser,
@Param("notifications") List<Long> notifications);
- JPQL로 SELECT문 이외의 것을 처음 사용해보았다.
- 물론 querydsl을 써서 동적쿼리로 짜는 등의 더 나은 방법이 있을 수도 있겠지만.. 이미 구해진 최근 30개의 알림을 최대한 사용하는 것이 낫다고 판단했다.
- deleteNotification(), deleteAllNotifications() : 코드에서 보이는 그대로이다. 특정 ID를 가진 알림을 삭제하거나, 특정 유저의 모든 알림을 삭제하거나.
추가로, security configuration을 위한 SecurityFilterChain 빈의 HttpSecurity 객체에 다음과 같은 requestMatcher를 추가해서 로그인한 유저만 관련 행위를 할 수 있도록 했다.
.requestMatchers("/notifications").authenticated()
6. ThymeLeaf
notifications.html에서 "notifications" attribute로 DTO를 보낸 결과는 다음과 같이 받아진다. bootstrap의 list-group-flush를 이용했다.
<div class="col-10 col-lg-4">
<div th:if="${#lists.isEmpty(notifications)}"
class="p-4">
<p class="text-muted text-center">알림이 존재하지 않습니다!</p>
</div>
<div th:if="${not #lists.isEmpty(notifications)}"
class="d-flex justify-content-center mb-3">
<form class="d-inline" th:action="@{'/notifications'}" th:method="delete">
<button sec:authorize="isAuthenticated()"
th:text="전체삭제"
class="btn btn-outline-danger"></button>
</form>
</div>
<ul class="list-group list-group-flush">
<a th:each="notification:${notifications}"
class="list-group-item list-group-item-action"
th:href="${'/'+notification.notificationURL}">
<div class="d-flex">
<span th:text="${notification.message}"
class="fw-bold"></span>
<span class="ms-auto">
<form class="d-inline"
th:action="@{${'/notification/'+notification.id}}"
th:method="delete">
<button sec:authorize="isAuthenticated()"
th:text="${' ✕ '}"
class="btn btn-outline-danger"
type="submit"
style="position: relative;"></button>
</form>
</span>
</div>
<div class="d-flex mt-1">
<small class="text-muted" th:text="${notification.timePassed}"></small>
</div>
</a>
</ul>
</div>
- ul 각각의 요소들로 a 태그를 넣은 것을 잘 보자. href로 앞서 언급했던 notificatinoURL을 전달해줬다.
- 특정 일기 삭제와 전체 일기 삭제는 form을 이용해 delete action으로 넘겨주었다.
실제로 태그를 시도해보겠다.

이렇게 일기를 작성하고

일기에 좋아요도 눌렀다. 그 뒤 /notifications 페이지에 접속하면,

성공! 각각의 list를 클릭하면 설정한 경로로 잘 이동하는 것까지 확인했다.
7. 테스트 코드
비루한 테스트코드도 개발단계에서 작성했었다... 버리긴 아까우므로 일단 올려!
@SpringBootTest
class GreenFarmApplicationTests {
@Autowired
NotificationService notificationService;
@Autowired
NotificationRepository notificationRepository;
@Autowired
UserRepository userRepository;
@Autowired
UserService userService;
@Autowired
FarmLogRepository farmLogRepository;
@BeforeEach
public void setDB() {
userRepository.save(User.builder()
.userId("demoId1")
.email("demoEmail1@email.com")
.name("demo1")
.build());
userRepository.save(User.builder()
.userId("demoId2")
.email("demoEmail2@email.com")
.name("demo2")
.build());
}
@Test
@DisplayName("태그 알림")
@Transactional
public void tagNotificationTest() {
User demo1 = userRepository.findByUserId("demoId1").orElseThrow();
User demo2 = userRepository.findByUserId("demoId2").orElseThrow();
String userInput = "demoId1이 @demoId2 그리고 @sh814 에게 알림을 보내려고 한다.";
FarmLog savedFarmLog = farmLogRepository.save(FarmLog.builder().logContent(userInput).author(demo1).build());
notificationService.sendTagNotification(notificationService.searchTagList(userInput),
demo2, savedFarmLog);
Assertions.assertThat(notificationRepository.findByReceivingUser(
userRepository.findByUserId("demoId2").orElseThrow())).isNotEmpty();
for (Notification notification : notificationRepository.findAll()) {
System.out.println(notification.getMessage());
}
}
@Test
@DisplayName("팔로우 알림")
@Transactional
public void followNotificationTest() {
userService.follow("demoId1", "demoId2");
Assertions.assertThat(notificationRepository.findByReceivingUser(
userRepository.findByUserId("demoId2").orElseThrow()
)).isNotEmpty();
}
}
# 계획
- 누군가가 회원이 작성한 일기에 좋아요를 누르거나
- 누군가가 회원을 팔로우하거나
- 누군가가 회원을 일기 안에서 태그할 때(태그는 "@" 뒤에 아이디를 추가하는 것으로 하자)
해당 회원에게 알림, 즉 Notification이 가는 기능을 추가하고 싶었다.
1. Notification Entity 설정
알림을 DB에 저장되는 하나의 객체로 만들었다. 알림에 필요해보이는 field들을 생각해보았다.
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Notification extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long notificationId;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "sending_user")
private User sendingUser;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "receiving_user")
private User receivingUser;
@OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "farm_log")
private FarmLog farmLog;
private String message;
@NotNull @Enumerated(EnumType.STRING)
private NotificationCode notificationCode;
private Boolean isRead;
}
- sendingUser, receivingUser : 알림을 일으킨 유저와 알림을 받는 유저이다. 알림 관계에서 보면 둘은 다대다 관계인데 Notification 객체를 따로 뺌으로서 일대다 관계 두개로 나눴다.
- farmLog : 특정 유저를 태그한 일기나, 좋아요를 누른 일기의 정보를 받기 위해 일기 객체를 일대일 관계로 추가했다. 팔로우 알람일 경우 이 필드는 null값이 되므로 엄밀히 말하면 SRP를 위반한 것인데, 현재 프로젝트 단계에서 그정도로 고도화된 OOP 설계원칙을 따를 필요는 없을 것 같아 이대로 두었다.
- NotificationCode : 현재 이 알림이 어떤 종류의 알림인지, 즉 팔로우 / 좋아요 / 태그 셋 중 어떤 알림인지 Enum 타입으로 표시한 필드이다.
- isRead : 알림이 새로 온 알림인지의 여부를 띄웠다. isRead가 false일 경우에 navbar에 특정 기호를 띄우는 기능을 추후 추가할 예정이다. (AOP 이용)
NotificationCode enum 구성이다.
@Getter
@AllArgsConstructor
public enum NotificationCode {
TAG_ALARM("태그 알림"),
LIKE_ALARM("좋아요 알림"),
FOLLOW_ALARM("팔로우 알림");
private final String detail;
}
추가로, NotificationCode의 각 code에 맞는 알림 메시지를 제공하기 위해 Entity 클래스에 getMessage() 코드를 다음과 같이 추가했다. 알림이 뜨면 알림 메시지가 갈 것이다.
@Transient
public String getMessage() {
switch (notificationCode) {
case TAG_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님을 태그하셨습니다.";
}
case LIKE_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님의 일기에 좋아요를 누르셨습니다.";
}
case FOLLOW_ALARM -> {
return this.sendingUser.getName()
+ "님이 "
+ this.receivingUser.getName()
+ "님을 팔로우합니다.";
}
default -> {
throw new GreenFarmException(GreenFarmErrorCode.UNKNOWN_ALARM_ERROR);
}
}
}
그런데 아무리 @Transient를 박았다고 해도 entity 안에 이렇게 method를 추가하는 것은 안좋을 것 같다. message 자체를 필드에서 삭제하고 DTO에 해당 method를 추가하는 것으로 손을 봐야겠다.
당연히, Entity를 만들었으므로 Repository도 함께 작성했다.
@Repository
public interface NotificationRepository extends JpaRepository<Notification,Long> {
@Query("SELECT n FROM Notification n WHERE n.receivingUser = :receivingUser ORDER BY createdAt DESC LIMIT 30")
public Optional<List<Notification>> findByReceivingUser(@Param("receivingUser") User receivingUser);
@Modifying
public void deleteByReceivingUser(User receivingUser);
}
- findByReceivingUser : "알림을 받은 유저"가 알림을 확인하기 위해 findByReceivingUser를 했다. 알림은 보통 최신순으로 정렬되므로 ORDR BY createAt DESC로 정렬했고, 최근 30개만 보여주기 위해 limit를 걸었다. derived query로 이를 표현하기엔 메서드 명이 너무 길어질 것 같아 JPQL을 따로 작성했다.
- deleteByReceivingUser() : 알림을 받은 유저가 자신의 알림을 한번에 삭제할 수 있도록 하는 modifying query이다.
2. Tag 알림
요구사항 : 어떤 사용자가 일기에 @{userId} 형식으로 특정 유저의 ID를 이용해 특정 유저를 태그하면, 태그당한 유저에게 알림이 가도록 한다.
해당 기능을 구현하기 위해서 일기가 post될 때, 일기 안의 tag 목록을 scan하는 과정이 필요하다. NotificationService라는 service component를 추가한 후, 사용자의 일기를 input으로 받아 태그 목록을 스캔하는 searchTagList method를 작성했다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
public List<String> searchTagList(final String logContent) {
Pattern idPattern = Pattern.compile("@[a-zA-Z][a-zA-Z0-9]{0,14}");
return Arrays.stream(logContent.split(" "))
.filter(s -> idPattern.matcher(s).matches())
.map(s -> s.substring(1))
.toList();
}
}
아이디 패턴을 받기 위해 정규 표현식을 이용했다. 태그는 @로 시작하면서, 영어 알파벳과 숫자로 이뤄진 15자 이하의 아이디로 끝나는 단어이다.
유저의 input string -> 공백을 delimiter로 split -> filter로 Pattern 적용 -> 뽑아낸 chunk에서 "@" 지우기(substring) -> toList()로 collecting
위의 과정을 통해 유저의 input에서 tag list를 뽑아 태그 대상이 되는 유저 list를 뽑았다.
다음은 그 리스트에 있는 유저들에게 실제로 알림을 보내는 메서드이다.
@Transactional
public void sendTagNotification(final List<String> taggedIds,
final User taggingUser,
final FarmLog taggingFarmLog) {
for (String id : taggedIds) {
userRepository.findByUserId(id).ifPresent(
user -> {
notificationRepository.save(
Notification.builder()
.notificationCode(NotificationCode.TAG_ALARM)
.sendingUser(taggingUser)
.receivingUser(user)
.farmLog(taggingFarmLog)
.isRead(false)
.build());
});
}
}
- List<String> taggedIds : 바로 위 searchTagList()를 통해 불러온 태그당한 사람들의 id list를 인자로 받았다.
- taggingUser, taggingFarmLog : 태그를 시도한 유저와 태그 목록이 들어있는 일기 객체이다.
- taggedIds 리스트를 iterate하며 해당 id를 가진 유저가 존재하면 그 유저를 receivingUser로 하는 Notification 객체를 빌드한 후 save했다. notificationCode() 메서드의 인자로 TAG_ALARM enum type이 들어간 것을 확인하자.
일기가 작성되는 post 요청이 일어날 때 위 메서드들을 이용해서 알림을 보낼 수 있도록 하겠다.
다음은 일기와 관련된 service 컴포넌트에서 일기가 작성되는 post 요청이 일어날 때 실행되는 메서드이다.
@Transactional
public Long createFarmLogAndReturnFarmLogId(CreateFarmLog.Request request) {
User currentUser = getSessionUser();
FarmLog createdFarmLog = farmLogRepository.save(
FarmLog.builder()
.logContent(request.getLogContent())
.author(currentUser)
.build());
// 주목!
notificationService.sendTagNotification(
notificationService.searchTagList(request.getLogContent()),
currentUser,
createdFarmLog);
return createdFarmLog.getFarmLogId();
}
주목! 처리한 부분을 잘 보자. request로부터 사용자의 input stinrg을 받아 searchTagList() 메소드로 보내준다. 여기서 tag된 id list가 뽑아질 것이다. id list, 현재 일기를 작성하는 currentUser, 방금 생성된 createdFarmLog를 sendTagNotification() 인자로 보내 Notification 객체를 빌드하여 저장할 수 있도록 했다.
3. Follow, 좋아요 알림
나머지 팔로우와 좋아요 알림은 간단하다. NotificationService 컴포넌트에 아래 두 메서드를 작성하기만 하면 된다.
@Transactional
public void sendFollowNotification(final User followingUser,
final User followedUser) {
notificationRepository.save(
Notification.builder()
.sendingUser(followingUser)
.receivingUser(followedUser)
.notificationCode(NotificationCode.FOLLOW_ALARM)
.isRead(false)
.build()
);
}
@Transactional
public void sendLikeNotification(final User sendingUser,
final FarmLog farmLog) {
notificationRepository.save(
Notification.builder()
.sendingUser(sendingUser)
.receivingUser(farmLog.getAuthor())
.farmLog(farmLog)
.notificationCode(NotificationCode.LIKE_ALARM)
.build());
}
역시 notificationCode() 인자로 각각에 맞는 enumType이 들어갔다.
각각의 메서드를 팔로우 할 때, 좋아요를 누를 때 해당 서비스 메서드에서 적절한 인자를 넣어 호출하면 된다.
4. DTO
방금까지는 DB에 저장된 객체를 말하는 것이었고, 이제는 실제로 서비스에서 유저에게 보여지는 알림을 구현하는 DTO 차례이다.
@Getter
@Builder
@AllArgsConstructor
public class NotificationDto {
private Long id;
private String message;
private NotificationCode notificationCode;
private String notificationURL;
private String timePassed;
public static NotificationDto fromEntity(Notification notification) {
return NotificationDto.builder()
.id(notification.getNotificationId())
.timePassed(TimeDuration.generateTimeDuration(notification.getCreatedAt()))
.message(notification.getMessage())
.notificationCode(notification.getNotificationCode())
.notificationURL(findNotificationURL(notification))
.build();
}
private static String findNotificationURL(Notification notification) {
switch (notification.getNotificationCode()) {
case TAG_ALARM -> {
return "farm-log/" + notification.getFarmLog().getFarmLogId();
}
case FOLLOW_ALARM, LIKE_ALARM -> {
return notification.getSendingUser().getUserId();
}
default -> {
throw new GreenFarmException(GreenFarmErrorCode.UNKNOWN_ALARM_ERROR);
}
}
}
}
- static 메서드인 fromEntity()를 통해 특정 Notification 객체를 받아 DTO를 빌드할 수 있도록 했다. message에 기존 Notifcation 엔티티 클래스에서 구현한 getMessage() 메서드가 사용됐다.
- notificationURL : 해당 알림을 클릭하면 이동할 경로를 지정해주는 URL string이다. 태그 알람이나 좋아요 알람일 경우 각각 태그된 일기와 좋아요 눌린 일기 경로를, 팔로우 알람일 경우 팔로우한 사람의 상세정보 경로를 가리킨다. thymeleaf에서 아래 href 형식으로 html 페이지에 전달해줄 것이다.
th:href="${'/'+notification.notificationURL}"
- timePassed : 알림이 현재 시간으로부터 얼마나 전에 일어난 것인지 알려주는 표지이다. TimeDuration.generateTimeDuration이라는 static method를 사용했는데, ChronoUnit을 이용해 현재 시각과 알림 객체의 시간차이를 구한 뒤 문자열을 받아내는 형식으로 구현했다.
public class TimeDuration {
public static String generateTimeDuration(LocalDateTime dateTime) {
LocalDateTime currentTime = LocalDateTime.now();
if (ChronoUnit.HOURS.between(dateTime,currentTime)<1) {
// n분 전
return ChronoUnit.MINUTES.between(dateTime,currentTime) + "분 전";
} else if (ChronoUnit.DAYS.between(dateTime,currentTime)<1) {
// n시간 전
return ChronoUnit.HOURS.between(dateTime,currentTime) + "시간 전";
} else if (ChronoUnit.MONTHS.between(dateTime,currentTime)<1) {
// n일 전
return ChronoUnit.DAYS.between(dateTime,currentTime) + "일 전";
} else {
// yy.MM
return dateTime.format(DateTimeFormatter.ofPattern("yy.MM HH"));
}
}
}
- 1달 이상 된 알림이라면 날짜와 시간만 보여주도록 했다.
5. Notification Controller
사용자에게 온 알림을 확인할 수 있는 notifications 페이지를 위해 컨트롤러를 작성했다.
@Controller
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
private final HttpSession httpSession;
@GetMapping("/notifications")
public String getNotificationPage(Model model) {
model.addAttribute("notifications",
notificationService.getNotificationDtos(
getSessionUser().getUserId()));
return "notifications.html";
}
@DeleteMapping("/notifications")
public String deleteAllNotifications() {
notificationService.deleteAllNotifications(getSessionUser().getUserId());
return "notifications.html";
}
@DeleteMapping("/notification/{notificationId}")
public String deleteNotification(@PathVariable("notificationId")
final Long notificationId) {
notificationService.deleteNotification(notificationId);
return "redirect:/notifications";
}
private SessionUser getSessionUser() {
SessionUser sessionUser = (SessionUser) httpSession.getAttribute("user");
if (sessionUser == null) {
throw new GreenFarmException(GreenFarmErrorCode.NEED_LOGIN);
} else {
return sessionUser;
}
}
}
- getNotificationPage() : getSessionUser()로 현재 로그인한 유저 정보를 가져온 뒤 notificationDto를 뽑아왔다. DTO list 안에는 receivingUser가 현재 로그인된 유저로 설정된 notificationDto객체들이 있을 것이고, 해당 객체들은 notifications model attribute로 HTML에게 전해질 것이다.
- deleteAllNotification(), deleteNotification() : 모든 알림을 한꺼번에 지울 수 있는 기능, 특정 알림을 삭제할 수 있는 기능을 수행한다. 알림은 바로바로 삭제할 수 있도록 하는 것이 좋다 생각해서 delete를 바로 구현했다.
다음은 관련된 service component 코드이다.
@Transactional
public List<NotificationDto> getNotificationDtos(final String userId) {
User currentUser = userRepository.findByUserId(userId)
.orElseThrow(() -> new GreenFarmException(GreenFarmErrorCode.NO_USER_ERROR));
List<Notification> notifications =
notificationRepository
.findByReceivingUser(currentUser)
.orElse(new ArrayList<>());
notificationRepository.deleteExceptFor(
currentUser,
notifications.stream().map(Notification::getNotificationId).toList());
return notifications
.stream().map(NotificationDto::fromEntity).toList();
}
@Transactional
public void deleteNotification(final Long notificationId) {
notificationRepository.deleteById(notificationId);
}
@Transactional
public void deleteAllNotifications(final String userId) {
notificationRepository.deleteByReceivingUser(
userRepository.findByUserId(userId).orElseThrow(
() -> new GreenFarmException(GreenFarmErrorCode.NO_USER_ERROR)));
}
- getNotificationDtos() : 일단 receivingUser로 "최근 30개" 알림을 받아온다. 그 뒤 deleteExceptFor() method를 실행하는데, 이는 현재 보여지는 유저의 최근 30개 알림을 제외한 알림을 삭제하는 쿼리이다. repository에 메소드를 다음과 같이 추가했다. receivingUser가 현재 알림을 확인하려는 유저이면서, 최근 30개의 알림에 들어가지 않는 알림들을 삭제하는 것이다.
@Modifying
@Query("DELETE FROM Notification n " +
"WHERE n.receivingUser = :receivingUser " +
"AND n.notificationId NOT IN :notifications")
public void deleteExceptFor(@Param("receivingUser") User receivingUser,
@Param("notifications") List<Long> notifications);
- JPQL로 SELECT문 이외의 것을 처음 사용해보았다.
- 물론 querydsl을 써서 동적쿼리로 짜는 등의 더 나은 방법이 있을 수도 있겠지만.. 이미 구해진 최근 30개의 알림을 최대한 사용하는 것이 낫다고 판단했다.
- deleteNotification(), deleteAllNotifications() : 코드에서 보이는 그대로이다. 특정 ID를 가진 알림을 삭제하거나, 특정 유저의 모든 알림을 삭제하거나.
추가로, security configuration을 위한 SecurityFilterChain 빈의 HttpSecurity 객체에 다음과 같은 requestMatcher를 추가해서 로그인한 유저만 관련 행위를 할 수 있도록 했다.
.requestMatchers("/notifications").authenticated()
6. ThymeLeaf
notifications.html에서 "notifications" attribute로 DTO를 보낸 결과는 다음과 같이 받아진다. bootstrap의 list-group-flush를 이용했다.
<div class="col-10 col-lg-4">
<div th:if="${#lists.isEmpty(notifications)}"
class="p-4">
<p class="text-muted text-center">알림이 존재하지 않습니다!</p>
</div>
<div th:if="${not #lists.isEmpty(notifications)}"
class="d-flex justify-content-center mb-3">
<form class="d-inline" th:action="@{'/notifications'}" th:method="delete">
<button sec:authorize="isAuthenticated()"
th:text="전체삭제"
class="btn btn-outline-danger"></button>
</form>
</div>
<ul class="list-group list-group-flush">
<a th:each="notification:${notifications}"
class="list-group-item list-group-item-action"
th:href="${'/'+notification.notificationURL}">
<div class="d-flex">
<span th:text="${notification.message}"
class="fw-bold"></span>
<span class="ms-auto">
<form class="d-inline"
th:action="@{${'/notification/'+notification.id}}"
th:method="delete">
<button sec:authorize="isAuthenticated()"
th:text="${' ✕ '}"
class="btn btn-outline-danger"
type="submit"
style="position: relative;"></button>
</form>
</span>
</div>
<div class="d-flex mt-1">
<small class="text-muted" th:text="${notification.timePassed}"></small>
</div>
</a>
</ul>
</div>
- ul 각각의 요소들로 a 태그를 넣은 것을 잘 보자. href로 앞서 언급했던 notificatinoURL을 전달해줬다.
- 특정 일기 삭제와 전체 일기 삭제는 form을 이용해 delete action으로 넘겨주었다.
실제로 태그를 시도해보겠다.

이렇게 일기를 작성하고

일기에 좋아요도 눌렀다. 그 뒤 /notifications 페이지에 접속하면,

성공! 각각의 list를 클릭하면 설정한 경로로 잘 이동하는 것까지 확인했다.
7. 테스트 코드
비루한 테스트코드도 개발단계에서 작성했었다... 버리긴 아까우므로 일단 올려!
@SpringBootTest
class GreenFarmApplicationTests {
@Autowired
NotificationService notificationService;
@Autowired
NotificationRepository notificationRepository;
@Autowired
UserRepository userRepository;
@Autowired
UserService userService;
@Autowired
FarmLogRepository farmLogRepository;
@BeforeEach
public void setDB() {
userRepository.save(User.builder()
.userId("demoId1")
.email("demoEmail1@email.com")
.name("demo1")
.build());
userRepository.save(User.builder()
.userId("demoId2")
.email("demoEmail2@email.com")
.name("demo2")
.build());
}
@Test
@DisplayName("태그 알림")
@Transactional
public void tagNotificationTest() {
User demo1 = userRepository.findByUserId("demoId1").orElseThrow();
User demo2 = userRepository.findByUserId("demoId2").orElseThrow();
String userInput = "demoId1이 @demoId2 그리고 @sh814 에게 알림을 보내려고 한다.";
FarmLog savedFarmLog = farmLogRepository.save(FarmLog.builder().logContent(userInput).author(demo1).build());
notificationService.sendTagNotification(notificationService.searchTagList(userInput),
demo2, savedFarmLog);
Assertions.assertThat(notificationRepository.findByReceivingUser(
userRepository.findByUserId("demoId2").orElseThrow())).isNotEmpty();
for (Notification notification : notificationRepository.findAll()) {
System.out.println(notification.getMessage());
}
}
@Test
@DisplayName("팔로우 알림")
@Transactional
public void followNotificationTest() {
userService.follow("demoId1", "demoId2");
Assertions.assertThat(notificationRepository.findByReceivingUser(
userRepository.findByUserId("demoId2").orElseThrow()
)).isNotEmpty();
}
}