개발자 커뮤니티 플랫폼에서 알림 기능을 설계하면서 좋아요 시스템과의 일관성을 유지하는 것이 중요했다. 자유게시판, 코드게시판, 알고리즘게시판 총 3개의 게시판이 있고 각각 댓글 기능을 가지고 있어서 알림을 발생시키는 대상도 다양했다. 댓글, 답글, 좋아요 등 여러 이벤트에 대해 일관된 방식으로 알림을 처리하고 싶었다.
좋아요 시스템에서 사용한 REFERENCE_TYPE과 REFERENCE_ID 패턴을 알림에도 그대로 적용했다. 알림이 어떤 대상에 대한 것인지 명확하게 표현할 수 있고 확장성도 좋았다.
CREATE TABLE NOTIFICATION (
NOTIFICATION_ID BIGINT PRIMARY KEY AUTO_INCREMENT,
RECIPIENT_ID BIGINT NOT NULL,
SENDER_ID BIGINT,
NOTIFICATION_TYPE VARCHAR(50) NOT NULL,
REFERENCE_TYPE VARCHAR(20) NOT NULL,
REFERENCE_ID BIGINT NOT NULL,
MESSAGE TEXT,
IS_READ BOOLEAN DEFAULT false,
CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT FK_NOTIFICATION_RECIPIENT
FOREIGN KEY (RECIPIENT_ID)
REFERENCES USERS(USER_ID)
ON DELETE CASCADE,
CONSTRAINT FK_NOTIFICATION_SENDER
FOREIGN KEY (SENDER_ID)
REFERENCES USERS(USER_ID)
ON DELETE SET NULL
);
RECIPIENT_ID는 알림을 받을 사용자다. SENDER_ID는 알림을 발생시킨 사용자로 누가 좋아요를 눌렀는지 누가 댓글을 남겼는지 알 수 있다. SENDER_ID가 삭제되어도 알림은 유지되도록 ON DELETE SET NULL로 설정했다.
NOTIFICATION_TYPE은 알림의 종류를 나타낸다. REFERENCE_TYPE과 REFERENCE_ID는 알림이 어떤 게시글이나 댓글과 연관되어 있는지 나타낸다. 사용자가 알림을 클릭하면 해당 게시글이나 댓글로 이동할 수 있다.
알림 타입을 Enum으로 관리하고 각 타입마다 메시지 템플릿을 가지도록 했다.
public enum NotificationType {
POST_COMMENT("님이 게시글에 댓글을 남겼습니다"),
COMMENT_REPLY("님이 댓글에 답글을 남겼습니다"),
POST_LIKE("님이 게시글을 좋아합니다"),
COMMENT_LIKE("님이 댓글을 좋아합니다");
private final String messageTemplate;
NotificationType(String messageTemplate) {
this.messageTemplate = messageTemplate;
}
public String getMessageTemplate() {
return messageTemplate;
}
}
메시지 템플릿을 Enum에 포함시켜서 일관된 메시지 형식을 유지한다. 실제 메시지는 발신자의 닉네임과 템플릿을 조합해서 만든다.
String message = senderNickname + notificationType.getMessageTemplate();
// "홍길동님이 게시글에 댓글을 남겼습니다"
알림 서비스도 모든 타입을 처리할 수 있도록 범용적으로 구현했다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
@Transactional
public void sendNotification(
Long recipientId,
Long senderId,
NotificationType notificationType,
ReferenceType referenceType,
Long referenceId
) {
if (recipientId.equals(senderId)) {
return;
}
Notification notification = Notification.builder()
.recipientId(recipientId)
.senderId(senderId)
.notificationType(notificationType)
.referenceType(referenceType)
.referenceId(referenceId)
.build();
notificationRepository.save(notification);
}
}
자기 자신에게는 알림을 보내지 않도록 recipientId와 senderId를 비교한다. 내가 내 글에 댓글을 달거나 내 글에 좋아요를 누르는 경우다.
게시글에 좋아요를 누를 때 알림까지 함께 발송하는 로직은 다음과 같다.
@Service
@RequiredArgsConstructor
public class PostLikeService {
private final LikeService likeService;
private final NotificationService notificationService;
private final FreeboardRepository freeboardRepository;
private final CodeboardRepository codeboardRepository;
@Transactional
public void togglePostLike(String boardType, Long boardId, Long userId) {
ReferenceType referenceType = "CODEBOARD".equals(boardType)
? ReferenceType.POST_CODEBOARD
: ReferenceType.POST_FREEBOARD;
boolean isNewLike = likeService.toggleLike(userId, referenceType, boardId);
if (isNewLike) {
Long authorId = getBoardAuthorId(boardType, boardId);
notificationService.sendNotification(
authorId,
userId,
NotificationType.POST_LIKE,
referenceType,
boardId
);
}
}
private Long getBoardAuthorId(String boardType, Long boardId) {
if ("CODEBOARD".equals(boardType)) {
return codeboardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다"))
.getUserId();
} else {
return freeboardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다"))
.getUserId();
}
}
}
좋아요를 추가할 때만 알림을 보낸다. 좋아요를 취소할 때는 알림을 보내지 않는다. 게시글 작성자의 ID를 가져와서 알림 수신자로 지정한다.
댓글을 작성할 때도 비슷한 패턴으로 알림을 발송한다.
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final NotificationService notificationService;
private final FreeboardRepository freeboardRepository;
private final CodeboardRepository codeboardRepository;
@Transactional
public void createComment(CommentCreateDto dto, Long userId) {
Comment comment = Comment.builder()
.boardId(dto.getBoardId())
.boardType(dto.getBoardType())
.userId(userId)
.commentContent(dto.getCommentContent())
.parentCommentId(dto.getParentCommentId())
.isDeleted(false)
.build();
commentRepository.save(comment);
if (dto.getParentCommentId() == null) {
// 게시글에 댓글을 남긴 경우
Long authorId = getBoardAuthorId(dto.getBoardType(), dto.getBoardId());
ReferenceType referenceType = getReferenceType(dto.getBoardType());
notificationService.sendNotification(
authorId,
userId,
NotificationType.POST_COMMENT,
referenceType,
dto.getBoardId()
);
} else {
// 댓글에 답글을 남긴 경우
Comment parentComment = commentRepository.findById(dto.getParentCommentId())
.orElseThrow(() -> new IllegalArgumentException("부모 댓글을 찾을 수 없습니다"));
notificationService.sendNotification(
parentComment.getUserId(),
userId,
NotificationType.COMMENT_REPLY,
ReferenceType.COMMENT,
dto.getParentCommentId()
);
}
}
private Long getBoardAuthorId(String boardType, Long boardId) {
if ("CODEBOARD".equals(boardType)) {
return codeboardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다"))
.getUserId();
} else if ("FREEBOARD".equals(boardType)) {
return freeboardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다"))
.getUserId();
} else {
throw new IllegalArgumentException("잘못된 게시판 타입입니다");
}
}
private ReferenceType getReferenceType(String boardType) {
if ("CODEBOARD".equals(boardType)) {
return ReferenceType.POST_CODEBOARD;
} else if ("FREEBOARD".equals(boardType)) {
return ReferenceType.POST_FREEBOARD;
} else {
throw new IllegalArgumentException("잘못된 게시판 타입입니다");
}
}
}
부모 댓글이 없으면 게시글에 대한 댓글이고 부모 댓글이 있으면 답글이다. 각각 다른 알림 타입과 수신자를 지정한다.
사용자가 자신의 알림을 조회하는 기능이다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
public List<NotificationResponseDto> getNotifications(Long userId) {
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId)
.stream()
.map(this::toResponseDto)
.collect(Collectors.toList());
}
@Transactional
public void markAsRead(Long notificationId, Long userId) {
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다"));
if (!notification.getRecipientId().equals(userId)) {
throw new IllegalArgumentException("권한이 없습니다");
}
notification.markAsRead();
notificationRepository.save(notification);
}
public long getUnreadCount(Long userId) {
return notificationRepository.countByRecipientIdAndIsReadFalse(userId);
}
private NotificationResponseDto toResponseDto(Notification notification) {
return NotificationResponseDto.builder()
.notificationId(notification.getNotificationId())
.senderId(notification.getSenderId())
.notificationType(notification.getNotificationType())
.referenceType(notification.getReferenceType())
.referenceId(notification.getReferenceId())
.message(notification.getMessage())
.isRead(notification.getIsRead())
.createdAt(notification.getCreatedAt())
.build();
}
}
읽지 않은 알림 개수를 조회하는 기능도 필요하다. 헤더에 배지를 표시하기 위해서다.
이 설계는 새로운 알림 타입을 추가하기 쉽다. 게시글 수정, 게시글 삭제, 멘션 같은 기능을 추가할 때 NotificationType Enum에 값만 추가하면 된다.
public enum NotificationType {
POST_COMMENT("님이 게시글에 댓글을 남겼습니다"),
COMMENT_REPLY("님이 댓글에 답글을 남겼습니다"),
POST_LIKE("님이 게시글을 좋아합니다"),
COMMENT_LIKE("님이 댓글을 좋아합니다"),
POST_MENTION("님이 회원님을 언급했습니다"),
COMMENT_MENTION("님이 댓글에서 회원님을 언급했습니다");
private final String messageTemplate;
NotificationType(String messageTemplate) {
this.messageTemplate = messageTemplate;
}
public String getMessageTemplate() {
return messageTemplate;
}
}
북마크나 팔로우 같은 기능도 같은 패턴으로 구현할 수 있다.
현재는 데이터베이스에 저장만 하고 있지만 나중에 실시간 알림이 필요하면 WebSocket이나 SSE를 추가할 수 있다. 알림이 생성될 때 데이터베이스에 저장하는 동시에 실시간으로 전송하는 방식이다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
private final SimpMessagingTemplate messagingTemplate;
@Transactional
public void sendNotification(
Long recipientId,
Long senderId,
NotificationType notificationType,
ReferenceType referenceType,
Long referenceId
) {
if (recipientId.equals(senderId)) {
return;
}
Notification notification = Notification.builder()
.recipientId(recipientId)
.senderId(senderId)
.notificationType(notificationType)
.referenceType(referenceType)
.referenceId(referenceId)
.build();
notificationRepository.save(notification);
// WebSocket으로 실시간 전송
messagingTemplate.convertAndSendToUser(
String.valueOf(recipientId),
"/queue/notifications",
toResponseDto(notification)
);
}
}
WebSocket 연결이 끊어진 사용자는 다시 접속했을 때 데이터베이스에서 알림을 조회하면 된다.
알림 시스템은 좋아요 시스템과 같은 REFERENCE_TYPE과 REFERENCE_ID 패턴을 사용해서 일관성을 유지했다. 게시판이나 댓글이 추가되어도 구조를 변경할 필요가 없다. 알림 타입을 Enum으로 관리해서 타입 안정성을 확보했고 메시지 템플릿을 함께 관리해서 일관된 메시지 형식을 유지한다. 실시간 알림이 필요하면 WebSocket을 추가하면 되고 데이터베이스 저장 로직은 그대로 유지할 수 있다.