본문 바로가기
Spring Boot Project/Plming

[Plming] 게시판 기능 코드 리뷰 6편 (알림 기능)

by slchoi 2022. 4. 21.
728x90
SMALL

저번 글에서는 댓글과 관련된 코드를 살펴보았다. 이번 글에서는 알림 기능과 관련된 코드를 리뷰해볼 것이다. 알림 기능은 이 블로그를 참고해서 구현했다.

알림 기능과 관련된 코드의 폴더 구조이다. 이번에도 entity 패키지와 dto 패키지의 코드를 먼저 살펴본 뒤 repository 패키지, service 패키지, controller 패키지의 코드를 살펴볼 것이다.

폴더 구조

 

1. Entity 패키지

1.1. NotificationContent

더보기
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class NotificationContent {

    private static final int MAX_LENGTH = 50;

    @Column(nullable = false, length = MAX_LENGTH)
    private String content;

    public NotificationContent(String content) {
        if (isNotValidNotificationContent(content)) {
            throw new InvalidNotificationContentException();
        }
        this.content = content;
    }

    private boolean isNotValidNotificationContent(String content) {

        return Objects.isNull(content) || content.isBlank() ||
                content.length() > MAX_LENGTH;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof NotificationContent)) return false;
        NotificationContent that = (NotificationContent) o;
        return getContent().equals(that.getContent());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getContent());
    }
}

 

isNotValidNotificationContent 알림 내용은 최대 50자로 제한했다.
알림 내용이 비어있거나 글자 수가 50자가 넘어갈 경우 true, 아닐 경우 false를 반환한다.
NotificationContent 매개변수로 알림 내용이 들어오면, isNotValiedNotificationContent 메서드를 호출해 알림 내용이 적합한지 판단하고, 적합하지 않을 경우 예외를 발생시키고, 적합할 경우 NotificationContent 를 생성한다.

 

1.2. RelatedURL

더보기
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RelatedURL {

    private static final int MAX_LENGTH = 255;

    @Column(nullable = false, length = MAX_LENGTH)
    private String url;

    public RelatedURL(String url) {
        if(isNotValidRelatedURL(url)) {
            throw new InvalidRelatedURLException();
        }
        this.url = url;
    }

    private boolean isNotValidRelatedURL(String url) {
        return Objects.isNull(url) || url.isBlank() ||
                url.length() > MAX_LENGTH;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof RelatedURL)) return false;
        RelatedURL that = (RelatedURL) o;
        return getUrl().equals(that.getUrl());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getUrl());
    }
}

 

isNotValidRelatedURL 주소 길이는 최대 255자로 제한한다.

url 값이 비어있지 않은지 확인하고 255자 이하이면 false, url 값이 비어있거나 255자를 초과하면 true를 반환한다.
RelatedURL isNotValidRelatedURL 메서드를 사용해 url 값이 유효한지 확인하고, 유효할 경우 RelatedURL을 생성하고, 유효하지 않을 경우 예외를 발생시킨다.

 

1.3. Notification

더보기
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
@Table(name = "notice")
public class Notification extends EntityDate {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private NotificationContent content;

    @Embedded
    private RelatedURL url;

    @Column(nullable = false)
    private Boolean isRead;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private NotificationType notificationType;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User receiver;

    @Builder
    public Notification(User receiver, NotificationType notificationType, String content, String url, Boolean isRead) {

        this.receiver = receiver;
        this.notificationType = notificationType;
        this.content = new NotificationContent(content);
        this.url = new RelatedURL(url);
        this.isRead = isRead;
    }

    public void read() {
        isRead = true;
    }

    public String getContent() {
        return content.getContent();
    }

    public String getUrl() {
        return url.getUrl();
    }
}

 

@EqualsAndHashCode 룸북 라이브러리에서 제공하는 애노테이션

자바 bean에서 동등성 비교를 위해 equals와 hashcode 메소드를 오버라이딩해서 사용하는데, @EqualsAndHashCode 애노테이션을 사용하면 자동으로 이 메서드를 생성할 수 있다. callSuper 속성을 통해 equals와 hashCode 메서드 자동 생성 시 부모 클래스의 필드까지 감안할지의 여부를 설정할 수 있다.
of 속성으로 꼭 필요한 필드만 비교하도록 처리할 수 있다.
@Embedded 임베디드 타입을 적용하기 위한 애노테이션이다. (관련 설명은 아래 쪽의 참고 자료 참고)
@Enumerated enum 값을 Entity 필드 값으로 사용하기 위해 사용하는 애노테이션이다.

@Enumerated 애노테이션은 두 가지 기능을 제공한다.
첫 번째로 ORDINAL은 enum의 선언된 순서를 Integer 값으로 변환하여 DB 컬럼에 꽂아준다. 두 번째로 STRING은 enum의 선언된 상수의 이름을 String 클래스 타입으로 변환하여 DB에 꽂아준다. 즉, DB 클래스의 타입은 String이다.
Notification Notification 생성자이다.
read 사용자가 알림을 확인했을 경우, notice 테이블의 isRead 컬럼 값을 true로 변경해주는 메서드이다.
getContent Notification Entitiy에서 알림 내용을 가져오는 메서드이다.
getUrl Notification Entity에서 알림 관련 url을 가져오는 메서드이다.

 

1.4. NotificationType

더보기
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public enum NotificationType {

    apply("게시글에 참여 신청이 왔습니다.", "/posts/"),
    accept("게시글에 참여가 승인되었습니다.", "/posts/"),
    reject("게시글에 참여가 거절되었습니다.", "/posts/"),
    comment("에 댓글이 달렸습니다.", "/posts/"),
    recomment("에 대댓글이 달렸습니다.", "/posts/"),
    message(" 회원에게 메세지가 도착했습니다.", "/messages");

    private String content;
    private String url;

    NotificationType(String content, String url) {
        this.content = content;
        this.url = url;
    }

    public String makeContent(String title) {
        return "'" + title + "'" + content;
    }

    public String makeUrl(Long id) {
        return url + id;
    }
    public String getUrl() {
        return url;
    }
}

 

NotificationType 알림 타입을 지정한 enum 클래스이다. 각각의 타입은 알림 내용(content)과 알림과 관련된 주소 값(url)을 가진다.

알림 타입은 apply, accept, reject, comment, recomment, message 타입이 있다.
makeContent 알림 타입의 내용을 설정해주는 메서드이다.

기존에 지정해둔 content 앞에 알림이 온 게시글의 제목 또는 사용자의 nickname을 붙여준다.
makeUrl(Long id) 알림 타입의 url을 설정해주는 메서드이다.

기존에 지정해둔 url 뒤에 매개변수로 들어온 id 값을 붙여준다.
makeUrl() 알림 타입의 url 값을 가져오는 메서드이다.

 

2. dto 패키지

2.1. NotificationDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class NotificationDto {

    private Long id;
    private String content;
    private String url;

    public static NotificationDto create(Notification notification) {
        return new NotificationDto(notification.getId(), notification.getContent(),
                notification.getUrl());
    }
}
  • NotificationDto 객체는 알림 id, 알림 내용, 알림 관련 url 정보를 포함한다.
  • create 메서드는 매개변수로 Notification Entity가 들어오면 Entity 값을 통해 새로운 NotificationDto 객체를 생성하고 반환한다.

 

2.2. NotificationRequestDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class NotificationRequestDto {
    private User user;
    private NotificationType notificationType;
    private String content;
    private String url;
}

 

  • NotificationRequestDto 객체는 알림을 받을 사용자 정보, 알림 타입, 알림 내용, 알림과 관련된 url 정보를 포함한다.

 

2.3. NotificationResponseDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class NotificationResponseDto {

    private Long id;
    private String content;
    private String url;

    public static NotificationResponseDto create(NotificationDto notificationDto) {
        return new NotificationResponseDto(notificationDto.getId(), notificationDto.getContent()
                , notificationDto.getUrl());
    }
}
  • NotificationResponseDto 객체는 알림 id, 알림 내용, 알림과 관련된 url 정보를 포함한다.
  • create 메서드는 매개변수로 NotificationDto Entity 객체가 들어오면 Entity 객체 정보를 통해 새로운 NotificationResponseDto 객체를 생성하고 반환한다.

 

3. repository 패키지

3.1. NotificationRepository

public interface NotificationRepository extends JpaRepository<Notification, Long>, NotificationCustomRepository {

}
  • NotificationRepository는 JPARepository와 NotificationCustomRepository 클래스를 상속받는다.

 

3.2. NotificationCustomRepository

public interface NotificationCustomRepository {

    List<Notification> findAllByUserId(@Param("userId") Long userId);

    Long countUnReadNotification(@Param("userId") Long userId);

    void deleteAllByUserId(@Param("userId") Long userId);
}
  • NotificationCustomRepository 인터페이스는 아래 메서드를 포함한다.
    • userId를 사용해 알림을 조회하는 findAllByUserId 메서드
    • 읽지 않은 알림 개수를 조회하는 countUnReadNotification 메서드
    • userId를 사용해 사용자에게 온 알림을 모두 삭제하는 deleteAllByUserId 메서드

 

3.3. NotificationCustomRepositoryImpl

@Repository
public class NotificationCustomRepositoryImpl implements NotificationCustomRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public NotificationCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {

        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public List<Notification> findAllByUserId(Long userId) {
        return jpaQueryFactory.selectFrom(notification)
                .leftJoin(notification.receiver, user)
                .fetchJoin()
                .where(notification.receiver.id.eq(userId))
                .orderBy(notification.id.desc())
                .fetch();
    }

    @Override
    public Long countUnReadNotification(Long userId) {
        return jpaQueryFactory.selectFrom(notification)
                .leftJoin(notification.receiver, user)
                .fetchJoin()
                .where(notification.receiver.id.eq(userId), notification.isRead.eq(false))
                .orderBy(notification.id.desc())
                .stream().count();
    }

    @Override
    public void deleteAllByUserId(Long userId) {

        jpaQueryFactory.delete(notification)
                .where(notification.receiver.id.eq(userId))
                .execute();
    }
}
NotificationCustomRepositoryImpl Querydsl 사용을 위해 JPAQueryFactory를 주입한다.
findAllByUserId notification 테이블에서 매개변수로 받아온 userId와 receiver.id가 일치하는 알림을 가져와 id 순으로 내림차순 정렬하고 반환한다.
countUnReadNotification notification 테이블에서 매개변수로 받아온 userId와 receiver.id가 일치하고,  isRead 값이 false인 알림을 가져와 count 연산한 값을 반환한다.
deeleteAllByUserId notification 테이블에서 매개변수로 받아온 userId와 receiver.id가 일치하는 알림을 모두 삭제한다.
🏭 Refactoring
countUnReadNotification 메서드에서는 id 순서 정렬을 해줄 필요가 없으므로 orderBy 코드를 제거해야겠다.

 

3.4. EmitterRepository

public interface EmitterRepository {

    SseEmitter save(String emitterId, SseEmitter sseEmitter);

    void saveEventCache(String eventCacheId, Object event);

    Map<String, SseEmitter> findAllEmitterStartWithByUserId(String userId);

    Map<String, Object> findAllEventCacheStartWithByUserId(String userId);

    void deleteById(String id);

    void deleteAllEmitterStartWithId(String userId);

    void deleteAllEventCacheStartWithId(String userId);

}
  • EmitterRepository 인터페이스는 아래 메서드를 포함한다.
    • SSE Emitter를 저장하는 save 메서드
    • Event Cache를 저장하는 saveEventCache 메서드
    • 사용자 id를 사용해 Emitter를 찾는 findAllEmitterStartWithByUserId 메서드
    • 사용자 id를 사용해서 Event Cache를 찾는 findAllEventCacheStartWithByUserId 메서드
    • 저장된 SSE Emitter의 id로 SSE Emitter을 삭제하는 deleteById 메서드
    • 사용자 id를 사용해 SSE Emitter을 삭제하는 deleteAllEmitterStartWithId 메서드
    • 사용자 id를 사용해 Event Cache를 삭제하는 deleteAllEventCacheStartWithId 메서드

 

3.5. EmitterRepositoryImpl

더보기
@Repository
@NoArgsConstructor
public class EmitterRepositoryImpl implements EmitterRepository {

    private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
    private final Map<String, Object> eventCache = new ConcurrentHashMap<>();

    @Override
    public SseEmitter save(String emitterId, SseEmitter sseEmitter) {
        emitters.put(emitterId, sseEmitter);
        return sseEmitter;
    }

    @Override
    public void saveEventCache(String eventCacheId, Object event) {
        eventCache.put(eventCacheId, event);
    }

    @Override
    public Map<String, SseEmitter> findAllEmitterStartWithByUserId(String userId) {
        return emitters.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(userId))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    @Override
    public Map<String, Object> findAllEventCacheStartWithByUserId(String userId) {
        return eventCache.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(userId))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    @Override
    public void deleteById(String id) {
        emitters.remove(id);
    }

    @Override
    public void deleteAllEmitterStartWithId(String userId) {
        emitters.forEach((key, emitter) -> {
            if(key.startsWith(userId)) {
                emitters.remove(key);
            }
        });
    }

    @Override
    public void deleteAllEventCacheStartWithId(String userId) {
        eventCache.forEach((key, emitter) -> {
            if(key.startsWith(userId)) {
                eventCache.remove(key);
            }
        });
    }
}

 

emitters, eventCache SseEmitter 객체를 저장하기 위한 ConcurrentHashMap과 Event Cache 정보를 저장하기 위한 ConcurrentHashMap이다.
save 매개변수로 emitterId와 SseEmitter 객체를 받아와 emitterId를 키 값으로, SseEmitter 객체를 값으로 지정해 emitters에 저장한다.
saveEventCache 매개변수로 eventCacheId와 event 객체를 받아와 eventCacheId를 키 값으로, event 객체를 값으로 지정해 eventCache에 저장한다.
findAllEmitterStartWithByUserId 매개변수로 사용자 id를 받아온다.

emitters에서 key 값이 사용자 id로 시작하는 SseEmitter 객체를 가져와 Map 형식으로 변환한 결과를 반환한다.
findAllEventCacheStartWithByUserId 매개변수로 사용자 id를 받아온다.

eventCache에서 key 값이 사용자 id로 시작하는 event 객체를 가져와 Map 형식으로 변환한 결과를 반환한다.
deleteById 매개변수로 SseEmitter 객체의 id를 받아온다.

emitters에서 키 값과 매개변수로 받아온 id의 값이 일치하는 정보를 삭제한다.
deleteAllEmitterStartWithId 매개변수로 사용자 id를 받아온다.

emitters에서 키 값 중에 사용자 id로 시작하는 값을 모두 삭제한다.
deleteAllEventCacheStartWithId 매개변수로 사용자 id를 받아온다.

eventCache에서 키 값 중에 사용자 id로 시작하는 값을 모두 삭제한다.

 

공부해야 할 부분 (JAVA
HashMap과 ConcurrentHashMap

ConcurrentHashMap은 Multi-Thread 환경에서 사용하는 HashMap이라는 건 알고 있지만, 기본적인 HashMap의 동작 원리도 확실하기 잘 모르기 때문에 HashMap과 ConcurrentHashMap의 동작원리와 차이점 등을 추가로 공부해야 한다.
공부해야 할 부분 (NETWORK)
웹 소켓 통신과 SSE 통신

웹 소켓은 서버와 클라이언트 간의 양방향 통신을 할 때 사용하고, SSE 통신은 클라이언트가 서버에서 정보를 받아오는 단방향 통신을 할 때 사용하는 통신 방법이라고 한다. 이번 알림 기능을 구현하기 위해 자료를 찾아보다 알게 된 통신 방법이라 자세히는 알지 못하기 때문에 웹 소켓, SSE 통신에 대해 어떻게 동작하고, 각각의 통신 방법의 장단점, 언제 어떤 통신 방법을 사용하는지에 대해 공부해봐야겠다.

 

4. service 패키지

더보기
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {

    @Value("${spring.sse.time}")
    private Long timeout;
    private final NotificationRepository notificationRepository;
    private final EmitterRepository emitterRepository;

    public SseEmitter subscribe(Long userId, String lastEventId) {

        String emitterId = makeTimeIncludeId(userId);
        SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(timeout));
        emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
        emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));

        // 503 에러를 방지하기 위한 더미 이벤트 저송
        String eventId = makeTimeIncludeId(userId);
        sendNotification(emitter, eventId, emitterId, "EventStream Created/ [userId=" + userId + "]");

        // 클라이언트가 미수신한 Event 목록이 존재할 경우 정송하여 Event 유실을 예방
        if(hasLostData(lastEventId)) {
            sendLostData(lastEventId, userId, emitterId, emitter);
        }

        return emitter;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void send(User receiver, NotificationType notificationType, String content, String url) {

        Notification notification = notificationRepository.save(createNotification(receiver, notificationType, content, url));

        String receiverId = String.valueOf(receiver.getId());
        String eventId = receiverId + "_" + System.currentTimeMillis();
        Map<String, SseEmitter> emitters = emitterRepository.findAllEmitterStartWithByUserId(receiverId);
        emitters.forEach(
                (key, emitter) -> {
                    emitterRepository.saveEventCache(key, notification);
                    sendNotification(emitter, eventId, key, NotificationDto.create(notification));
                }
        );
    }

    @Transactional
    public List<NotificationDto> findAllNotifications(Long userId) {

        List<Notification> notifications = notificationRepository.findAllByUserId(userId);
        notifications.stream().forEach(notification -> notification.read());

        return notifications.stream().map(NotificationDto::create).collect(Collectors.toList());
    }

    public Long countUnReadNotifications(Long userId) {

        return notificationRepository.countUnReadNotification(userId);
    }

    /**
     * 알림 하나 삭제
     */
    @Transactional
    public void deleteNotification(Long notificationId) {

        notificationRepository.deleteById(notificationId);
    }

    /**
     * 전체 알림 삭제
     */
    @Transactional
    public void deleteAllByUserId(Long userId) {

        notificationRepository.deleteAllByUserId(userId);
    }

    private String makeTimeIncludeId(Long userId) {

        return userId + "_" + System.currentTimeMillis();
    }

    private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) {

        try {
            emitter.send(SseEmitter.event().id(eventId).data(data));
        } catch (IOException exception) {
            emitterRepository.deleteById(emitterId);
        }
    }

    private boolean hasLostData(String lastEventId) {

        return !lastEventId.isEmpty();
    }

    private void sendLostData(String lastEventId, Long userId, String emitterId, SseEmitter emitter) {

        Map<String, Object> eventCaches = emitterRepository.findAllEventCacheStartWithByUserId(String.valueOf(userId));
        eventCaches.entrySet().stream()
                .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
                .forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue()));
    }

    private Notification createNotification(User receiver, NotificationType notificationType, String content, String url) {
        return Notification.builder()
                .receiver(receiver)
                .notificationType(notificationType)
                .content(content)
                .url(url)
                .isRead(false)
                .build();
    }

}

 

subscribe 알림 구독을 수행하는 메서드이다.

id를 생성한 뒤 생성한 id를 기반으로 Emitter를 새로 생성해 저장해준다. 시간이 만료된 경우에 자동으로 레포지토리에서 삭제 처리해줄 수 있는 콜백을 등록한다.

만약 등록을 진행한 뒤, SseEmitter의 유효 시간동안 어느 데이터도 전송되지 않는다면 503 Error를 발생시키므로, 가장 처음 연결을 진행한다면 Dummy 데이터를 보내 Error가 발생하는 것을 방지한다.

클라이언트가 미수신한 Event 목록이 존재할 경우 전송해 Event 유실을 예방한다.

위의 순서로 진행한 다음 생성한 Emitter를 리턴한다.
sendNotification Event Id에 대해 시간으로 구분한다. Last-Event-ID로 마지막 전송받은 이벤트 Id가 무엇인지 알고 받지 못한 데이터 정보에 대해 인지할 수 있어야 하기 때문에 구분자로 id 뒷 부분에 시간을 넣어준 것이다.
hasLostData lastEventId가 존재하는지 확인하는 메서드이다.

Last-Event-Id가 존재한다는 것은 받지 못한 데이터가 있다는 것을 의미한다. Last-Event-Id 값은 프론트에서 알아서 보내준다.
sendLostData 받지 못한 데이터가 있는 경우, Last-Event-Id를 기준으로 그 뒤의 데이터를 추출해 알림을 보내주면 된다.
sent 실제 다른 사용자가 알림을 보낼 수 있는 기능이다.

알림을 구성하고 해당 알림에 대한 이벤트를 발생시킨다. 이벤트를 발생시키기 전에 어떤 회원에게 알림을 보낼지에 대해 찾고 알림을 받을 회원의 Emitter들을 모두 찾아 해당 Emitter로 알림을 보내면 된다.
findAllNotification 사용자에게 온 모든 알림을 조회하는 메서드이다.

NotificationRepository의 findAllByUserId 메서드를 호출해 사용자 id에 해당하는 모든 알림을 반환받아 notifications 변수에 저장한다. notifications 변수에 저장된 모든 알림에 대해 Notification Entity에 선언해둔 read 메서드를 실행시킨다. (알림을 조회했다는 것은 알림을 읽었다는 의미)

notifications 변수에 저장된 Notification Entity 객체를 NotificationDto 객체로 변환한 뒤 반환한다.
deleteNotification 알림 하나를 삭제하는 메서드이다.
deleteAllByUserId 사용자에게 온 모든 알림을 삭제하는 메서드이다.
createNotification 매개변수로 알림을 받을 사용자, 알림 타입, 알림 내용, 알림과 관련된 url을 받아와 Notification Entity를 생성해주는 메서드이다.

 

5. controller 패키지

더보기
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/notifications")
public class NotificationController {

    private final NotificationService notificationService;
    private final ResponseService responseService;
    private final JwtTokenProvider jwtTokenProvider;

    @GetMapping(value = "/subscribe", produces = "text/event-stream")
    @ResponseStatus(HttpStatus.OK)
    public SseEmitter subscribe(@CookieValue final String token,
                                @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId) {

        return notificationService.subscribe(jwtTokenProvider.getUserId(token), lastEventId);
    }

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public MultipleResult<NotificationResponseDto> findAllNotification(@CookieValue final String token) {

        List<NotificationDto> notifications = notificationService.findAllNotifications(jwtTokenProvider.getUserId(token));
        List<NotificationResponseDto> responseDto = responseService.convertToControllerDto(notifications, NotificationResponseDto::create);
        return responseService.getMultipleResult(responseDto);
    }

    @GetMapping("/count")
    @ResponseStatus(HttpStatus.OK)
    public SingleResult<Long> countUnReadNotifications(@CookieValue final String token) {

        Long count = notificationService.countUnReadNotifications(jwtTokenProvider.getUserId(token));
        return responseService.getSingleResult(count);
    }

    @DeleteMapping()
    @ResponseStatus(HttpStatus.OK)
    public void deleteNotification(@Nullable @RequestParam final Long id, @NotNull @CookieValue final String token) {

        if(id == null) {
            notificationService.deleteAllByUserId(jwtTokenProvider.getUserId(token));
        } else {
            notificationService.deleteNotification(id);
        }
    }
}

 

subscribe GET "/notifications/subscribe" 요청이 들어오면 알림 구독 로직을 수행한다.

Cookie에서 token 값을 가져와 사용자 id를 추출하고, Request Header에서 Last-Event-ID 값을 가져온다.

사용자 id 값과 Last-Event-ID 값을 NotificationService의 subscribe 메서드의 매개변수로 전달한다.
findAllNotification GET "/notifications" 요청이 들어오면 알림 조회 로직을 수행한다.

Cookie에서 token 값을 가져와 사용자 id를 추출하고, NotificationService의 findAllNotifications 메서드의 매개변수로 전달한고, 이 메서드를 호출해 반환받은 값을 notifications 변수에 저장한다.

NotificationService의 convertToControllerDto 메서드의 매개변수로 notifications 변수와 NotificationResponseDto,create 메서드를 전달해, 이 메서드를 실행하고, NotificationResponseDto 객체 리스트를 반환받아 responseDto 변수에 저장한다.

ResponseService의 getMultipleResult 메서드의 매개변수로 responseDto 변수를 전달해 이 메서드를 실행하고 반환받은 값을 200 코드와 함께 반환한다,
countUnReadNotifications GET "/notifications/count" 요청이 들어오면 읽지 않은 알림 개수를 조회하는 로직을 수행한다.

Cookie에서 token 값을 가져와 사용자 id를 추출하고, NotificationService의 countUnReadNotifications 메서드에 전달한다. 이 메서드를 실행하고 반환받은 값을 count 변수에 저장한다.

count 변수를 ResponseService의 getSingleResult 메서드의 매개변수로 전달하고, 이 메서드를 실행해 반환받은 값을 200 코드와 함께 반환한다.
deleteNotification DELETE "/notifications" 요청이 들어오면 알림을 삭제하는 로직을 수행한다.

쿼리 파라미터로 알림 id를 받아오고, Cookie에서 token 값을 가져와 사용자 id를 추출한다. 

알림 id의 값이 null 인 경우, NotificationService의 deleteAllByUserId 메서드의 매개변수에 사용자 id를 전달하고, 이 메서드를 실행해 로그인된 사용자에게 온 모든 알림을 삭제한다.

알림 id의 값이 null이 아닌 경우, NotificationService의 deleteNotification 메서드에 알림 id를 전달하고, 이 메서드를 실행해 특정 알림을 삭제한다.

 

6. 알림 Event 발생시키기

알림이 보내지는 경우는 아래 5가지이다.

  1. 작성한 게시글에 댓글이 달리는 경우
  2. 작성한 댓글에 대댓글이 달리는 경우
  3. 참여 신청한 게시글 참여가 승인된 경우
  4. 참여 신청한 게시글 참여가 거절된 경우
  5. 다른 사용자에게 쪽지가 온 경우

각 경우들에 대해 해당하는 NotificationType를 지정해주고 알림 제목과 내용을 생성해준 뒤 알림을 보내면 된다. 이 글에서는 "작성한 게시글에 댓글이 달리는 경우"에 알림 Event을 발생시키는 코드를 리뷰해볼 것이다. (나머지 경우에 대해 NotificationType만 다를 뿐 나머지 코드는 같은 구조를 가진다.)

먼저 event 패키지를 생성한 뒤 아래 CommentCreateEvent 클래스를 생성한다.

@Getter
public class CommentCreateEvent {

    private final Comment comment;
    private final NotificationType notificationType;
    private final User receiver;

    public CommentCreateEvent(Comment comment, User receiver, NotificationType notificationType) {
        this.comment = comment;
        this.receiver = receiver;
        this.notificationType = notificationType;
    }
}
  • CommentCreateEvent 클래스는 사용자가 작성한 게시글에 댓글이나 대댓글이 달렸을 경우 이벤트를 생성해주기 위한 클래스이다.
  • CommentCreateEvent 객체는 Comment 객체와 NotificationType, 알림을 받을 사용자 정보를 가진다.

 

다음으로 CommentService에 이벤트를 발생시키는 ApplicationEventPublisher를 주입하고, 댓글을 등록하는 registerComment 메서드에서 ApplicationEventPublisher의 publishEvent 메서드를 사용해 Event를 발생시킨다. 

private final ApplicationEventPublisher eventPublisher;

@Transactional
public Long registerComment(final Long boardId, final Long userId, final CommentRequestDto params) {

    Board board = boardRepository.findById(boardId)
            .orElseThrow(() -> new CustomException(ErrorCode.POSTS_NOT_FOUND));
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USERS_NOT_FOUND));
    Comment entity = params.toEntity(board, user);
    commentRepository.save(entity);

    if(commentRepository.getById(entity.getId()).getParentId() == null) {
        eventPublisher.publishEvent(new CommentCreateEvent(entity, entity.getBoard().getUser(), NotificationType.comment));
    } else {
        Comment comment = commentRepository.getById(entity.getParentId());
        eventPublisher.publishEvent(new CommentCreateEvent(entity, comment.getUser(), NotificationType.recomment));
    }

    return entity.getId();
}
  • Comment 객체의 parentId 값을 검사해 null일 경우, NotificationType을 comment로 지정하고 CommentCreateEvent 객체를 생성해 Event를 발생시킨다.
  • Comment 객체의 parentId 값을 검사해 null이 아닌 경우, NotificationType을 recomment로 지정하고 CommentCreateEvent 객체를 생성해 Event를 발생시킨다.

 

마지막으로 Event를 Listening 하는 Listener를 생성한다.

@Component
@RequiredArgsConstructor
public class NotificationEventListener {

    private final NotificationService notificationService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void handleCommentCreateEvent(CommentCreateEvent event) {

        notificationService.send(event.getReceiver(), event.getNotificationType(),
                event.getNotificationType().makeContent(event.getComment().getBoard().getTitle()),
                event.getNotificationType().makeUrl(event.getComment().getBoard().getId()));
    }    event.getNotificationType().getUrl());
}
  • CommentCreateEvent가 발생하면 handleCommentCreateEvent 메서드에 의해 Event가 처리된다.
  • handleCommentCreateEvent 메서드는 CommentCreateEvent 객체를 매개변수로 받아와 이 정보를 사용해 알림을 보내는 NotificationService의 send 메서드를 호출한다.

 

공부해야 할 부분 (SPRING)
Event 처리 흐름

알림 기능에서 Event를 사용한 이유는 알림 기능을 만들며 참고한 블로그에서
" Service 클래스에서 다른 Service 클래스를 주입받아 적용할 수 있지만, 이럴 경우 서비스 간의 의존성이 추가되고 결합도가 높아지기 때문에 사이드 이펙트가 발생할 가능성이 커진다. 따라서 서비스에 대한 결합을 끊는 방법으로 이벤트를 활용해보려 한다."
는 설명을 봤는데 이 부분에 동의하기 때문에 알림 기능을 구현하는 데 있어 Event를 사용했다. 
하지만, Event를 발생시켰을 때 Event Listener가 이를 받아 처리한다는 개념만 알 뿐, 내부적으로 어떻게 동작하는지에 대해서는 잘 알지 못하기 때문에 ApplicationEventPublisher가 어떻게 동작하는지에 대해 공부해봐야겠다.
공부해야 할 부분 (SPRING)
@TransactionalEventListener 애노테이션

@EventListener는 동기적으로 처리를 진행한다. 댓글 생성이 완료된 후에 알림을 보내는 방식은 댓글이 DB에 저장된 뒤에야 알림을 보내기 시작한다는 의미이다. 이렇게 진행될 경우 엄청 오래 걸리는 작업이 존재하면 알림도 늦게 전송될 수 있고, 댓글 생성되는 부분에서 예외가 발생하는 작업이 있다면 Service 계층의 경우 @Transactional로 인해 롤백 처리로 되돌아갈 수 있지만, 알림은 이미 발송된 상태가 돼버리기 때문에 이를 해결하기 위해 트랜잭션의 흐름에 따라 이벤트를 제어하기 위해 @TransactionalEventListener를 사용했다.
아직 Transaction에 대해 잘 알지 못해서 이 부분의 동작 과정을 완전히 이해하지 못했다. Transaction에 대해 먼저 공부를 진행한 뒤에 이 애노테이션에 대해서도 공부해봐야겠다.
🏭 Refactoring
Service 클래스에서 다른 Service 클래스를 주입받아 사용하는 코드가 대부분이다. 이 기능들을 구현할 때는 서비스 간의 의존성이 추가되면 결합도가 높아져 사이드 이펙트가 발생할 가능성이 높아진다는 점까지 생각하지 못했다... 😅
이제 이 부분들에서 Service에 대한 결합을 끊을 수 있는 방법을 생각해보고 리팩터링 해봐야겠다.

 

🔍 참고 자료
JPA Entity 클래스에 Enum 타입 사용기
[Spring JPA] @Embedded, @Embeddable
@Data, @EqualsAndHashCode 어노테이션
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!
 

JPA Entity 클래스에 Enum 타입 사용기 @Enumerated, AttributeConverter 활용(기본)

예전에 프로젝트를 했을 때, JPA에 String 클래스 타입으로 enum의 값을 넣었던것 같다. 코드로 예를 들면, // JPA Entity 클래스 ... @Entity public class Study { ... @Column(name = "study_type") private St..

sas-study.tistory.com

 

[Spring JPA] @Embedded, @Embeddable

임베디드 타입 - 임베디드 타입은 복합 값 타입으로 불리며 새로운 값 타입을 직접 정의해서 사용하는 JPA의 방법을 의미한다.

velog.io

 

알림 기능을 구현해보자 - SSE(Server-Sent-Events)!

시작하기에 앞서 이번에 개발을 진행하면서 알림에 대한 요구사항을 만족시켜야하는 상황이 발생했다. 여기서 말하는 알림이 무엇인지 자세하게 살펴보자. A라는 사람이 스터디를 생성했고 B라

gilssang97.tistory.com

 

@Data, @EqualsAndHashCode 어노테이션

Spring @Data , @EqualsAndHashCode 이번에 확인해볼 부분은 Lombok 라이브러리에서 제공하는 어노테이션이다. @Data, @EqualsAndHashCode를 보기전에 우선, @Getter 와 @Setter는 각각 접근자와 설정자 메소드를..

n1tjrgns.tistory.com

 

728x90
LIST

댓글