비치컴바인 프로젝트에서 Top 10을 준비하는 과정에서 리팩토링을 거치며 알림 기능을 추가하기로 결정했다.
주어진 기한이 매우 짧아 푸시알림까진 넣지 않고 페이지를 리프레쉬했을 때만 보이게 처리해주는 선까지만 구현하기로 했다. 이를 위해선 sse나 소켓같은건 사용할 필요 없고, db처리만 해주면 된다.
notification이란 테이블을 만들고 알림 메시지들을 저장하는 것이다.
우리는 알림 읽음 처리나 알림 삭제 같은 세부 기능들은 제외하고 가장 중요한 알림 목록 보여주는 기능만 만들기로 해서 db가 저 형태지만, 혹시 알림 읽음 처리도 추가하고 싶다면, 별 다를 거 없이 notification 테이블에 boolean 값을 저장하는 읽음 여부 필드도 추가해주면 된다.
물론 단순 db처리만 해주진 않았고, 어쨌든 알림은 특정 이벤트이기에 이벤트 등록을 해주고, 비동기 처리를 해주었다. 해당 과정을 아래 정리하겠다.
예시는 사용자가 청소 완료 후 포인트를 받으면 포인트를 받았단 알림을 띄어주는 이벤트다. 전체 코드는 아래 링크에서 확인할 수 있다.
Spring에서 알림 기능 구현(비동기 처리)
1. 비동기 설정
•
backend/common/config/AsyncConfig.java
@Configuration
@EnableAsync // 비동기처리
@Slf4j
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor(){ // 스레드 풀 직접 지정
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int processors = Runtime.getRuntime().availableProcessors();
log.info("processor count {}", processors);
executor.setCorePoolSize(processors); // 1) 코어 개수: if(러닝 쓰레드 개수 < 코어 개수) 코어 개수 다다르기까지 남은 쓰레드 사용;
executor.setMaxPoolSize(processors); // 3) 맥스 개수: if(러닝 쓰레드 개수 >= 큐 용량) 맥스 개수 다다르기까지 새로운 쓰레드 만들어 처리;
executor.setQueueCapacity(50); // 2) 큐 용량: if(러닝 쓰레드 개수 >= 코어 개수) 큐 용량 다쓸때까지 큐에 쌓음;
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("AsyncExecutor-");
executor.initialize();
return executor;
}
}
Java
복사
2. 알림 메시지 enum 생성
•
backend/event/NotificationCode.java
@Getter
@AllArgsConstructor
public enum NotificationCode {
CLEANING_AND_TRASH_DISPOSAL("청소 완료",
"청소 완료로 100포인트가 지급되었습니다.",
"청소 완료를 축하 드립니다."),
CLEANING_WITHOUT_TRASH_DISPOSAL("청소 완료",
"청소 완료로 30포인트가 지급되었습니다.",
"청소 완료를 축하 드립니다. 쓰레기통이 인증되면 추가적인 70포인트가 지급됩니다.");
private final String title;
private final String message;
private final String details;
}
Java
복사
3. 알림 인프라 구축 및 테스트
(1) 이벤트 생성
Member 클래스와 관련한 이벤트라 MemberEvent라 이름붙였다.
•
backend/event/MemberEvent.java
@Getter
@RequiredArgsConstructor
public class MemberEvent {
private final Member member;
private final NotificationCode notificationCode;
}
Java
복사
•
backend/event/MemberEventListener.java
@Slf4j
@Async
@Transactional(readOnly = true)
@Component
public class MemberEventListener {
@EventListener
public void handleMemberEvent(MemberEvent memberEvent){
Member member = memberEvent.getMember();
NotificationCode notificationCode = memberEvent.getNotificationCode();
}
}
Java
복사
(2) 테스트
•
backend/event/MemberEventListener.java
@Slf4j
@Async
@Transactional(readOnly = true)
@Component
public class MemberEventListener {
@EventListener
public void handleMemberEvent(MemberEvent memberEvent){
Member member = memberEvent.getMember();
NotificationCode notificationCode = memberEvent.getNotificationCode();
// 테스트용으로 로그 찍어보는 코드 추가함
log.info(member.getNickname() + " get point.");
}
}
Java
복사
•
backend/service/MemberService.java
// 포인트 받기
public void updateMemberPoint(Long memberId, int option) {
Member findMember = getMemberOrThrow(memberId);
if (!findMember.updateMemberPoint(option)) {
throw new CustomException(ErrorCode.BAD_REQUEST_OPTION_VALUE);
}
// 알림 생성 코드 추가함
if (option == 0) { // 기존 등록된 쓰레기통
eventPublisher.publishEvent(new MemberEvent(findMember, NotificationCode.CLEANING_AND_TRASH_DISPOSAL));
}
if (option == 1) {
eventPublisher.publishEvent(new MemberEvent(findMember, NotificationCode.CLEANING_WITHOUT_TRASH_DISPOSAL));
}
}
Java
복사
◦
핵심은 알림 생성 코드를 method의 끝부분에 써주는 것이다. return문이 있다면 그 바로 위에 써준다.
•
포인트 받는 api를 실행해보면 아래와 같은 로그가 뜨는 걸 확인할 수 있다.
4. DB에 알림 저장
•
알림 이벤트가 잘 동작하는 걸 확인했으니, 이제 이벤트가 동작할 때 로그를 찍어주는 대신, notification 테이블에 알림 메시지를 저장하게만 해주면 된다.
(1) 도메인 추가
•
backend/domain/Notification.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Notification extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "notification_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
private String title;
private String message;
private String details;
}
Java
복사
◦
중간에 extends BaseEntity 부분은 우리 플젝이 base entity를 사용해 수정 시간이랑 생성 시간을 저장해서 그렇다. 없는 플젝은 안써도 좋다.
•
backend/repository/NotificationRepository.java
public interface NotificationRepository extends JpaRepository<Notification, Long> {
}
Java
복사
(2) 알림 메시지 DB에 저장
•
backend/event/MemberEventListener.java
@Slf4j
@Async
@Transactional(readOnly = true)
@Component
public class MemberEventListener {
@EventListener
public void handleMemberEvent(MemberEvent memberEvent){
Member member = memberEvent.getMember();
NotificationCode notificationCode = memberEvent.getNotificationCode();
// 로그 찍는 코드 대신 db 저장하는 코드 추가함
saveNotification(member, notificationCode);
}
public void saveNotification(Member member, NotificationCode notificationCode){
Notification notification = Notification.builder()
.member(member)
.title(notificationCode.getTitle())
.message(notificationCode.getMessage())
.details(notificationCode.getDetails())
.build();
notificationRepository.save(notification);
}
}
Java
복사
•
결과
◦
포인트를 받는 api를 실행했을 때, db에 알림 메시지가 제대로 저장된다.
•
에러) 혹시 아래 에러가 뜬다면! 아마 높은 확률로 당신의 application.yml의 ddl-auto 값이 none으로 되어 있을 것이다. 즉, notification db가 생성되지 않았거나 db와 entity가 맞지 않아서 뜨는 에러다.
Caused by: org.hibernate.exception.SQLGrammarException: could not execute statement
◦
해결 1) 만일 토이프로젝트라면, 그냥 ddl-auto 값을 create이나 update로 바꿔준 후 재실행하면 notification db가 자동으로 생성되어 에러가 뜨지 않게 된다. 대신 db에 이전에 저장해둔 값들이 전부 날라가니 매우매우매우매우매우 매우 주의하자.
◦
해결 2) 토이프로젝트가 아니거나 db의 이전 값들이 날라가면 안되는 상황이면, 직접 sql문을 날려 테이블을 추가해주자! 필자는 notification db를 아래 sql문을 날려 생성했다.
▪
성공한 SQL문
create table notification (
notification_id bigint auto_increment not null,
created_date datetime(6), # base entity 관련 코드
modified_date datetime(6), # base entty 관련 코드
title varchar(255),
message varchar(255),
details varchar(255),
member_id bigint,
primary key(notification_id),
foreign key(member_id) references member(member_id)
);
SQL
복사
▪
실패했던 SQL문
create table notification (
notification_id bigint not null,
created_date datetime(6),
modified_date datetime(6),
title varchar(255),
message varchar(255),
details varchar(255),
member_id bigint not null,
primary key(notification_id),
foreign key(member_id) references member(member_id)
);
SQL
복사
•
실패 원인) db와 entity가 맞지 않았음. member_id는 not null인데 null 값을 insert하려 했고, pk에 auto_increment 설정이 안되어 있어 null값이 계속 들어갔음
다른 알림 추가하기 (재사용성이 좋은 코드다)
자, 이제 멤버 엔티티와 관련된 다른 알림 이벤트를 추가해보자.
굉장히 간단한다. 코드 재사용성이 높게 설계됐단 걸 여기서 확인할 수 있다.
열거형 NotificationCode에 새로운 메시지 내용을 추가해주고, service단에 이벤트를 호출해주는 코드 한 줄만 추가해주면 된다.
•
backend/event/Notification.code
@Getter
@AllArgsConstructor
public enum NotificationCode {
CLEANING_AND_TRASH_DISPOSAL("청소 완료",
"청소 완료로 100포인트가 지급되었습니다.",
"청소 완료를 축하 드립니다."),
CLEANING_WITHOUT_TRASH_DISPOSAL("청소 완료",
"청소 완료로 30포인트가 지급되었습니다.",
"청소 완료를 축하 드립니다. 쓰레기통이 인증되면 추가적인 70포인트가 지급됩니다."),
// 하단에 메시지 추가함
TRASHCAN_CREDENTIAL("쓰레기통 인증 완료",
"추가적인 70포인트가 지급되었습니다.",
"신고한 쓰레기통이 인증 되었습니다.");
private final String title;
private final String message;
private final String details;
}
Java
복사
•
backend/service/TrashcanService.java
// 쓰레기통 인증하기
public void certifyTrashcan(Long memberId, Long trashcanId) {
... // 마지막에 아래 코드 한 줄만 추가해주면 된다.
eventPublisher.publishEvent(new MemberEvent(findMember, NotificationCode.TRASHCAN_CREDENTIAL));
}
Java
복사