sse 를 이용한 알림 시스템을 구현하며, 코드 개선을 고민하던 중 EventListener를 적용해 개선이 가능하다는 것을 알게 되었습니다. 이 글은 해당 기능 적용기에 대한 포스팅입니다.
1. spring의 @EventListener란?
이벤트 발행/구독 메커니즘
특정 이벤트가 발생했을 때, 해당 이벤트를 구독하고 있는 리스너들에게 알림을 보내 관련 로직을 수행하도록 하는 방식입니다.
제 코드에서 이벤트 발생 위치는 포스팅 생성 메서드가 되겠고, 이벤트를 구독하는 리스너는 알림 시스템이 되겠습니다.
EventListener 원리
이벤트 발행/구독 시스템은 spring에서 제공하는 @EventListener 어노테이션을 이용해 구현이 가능합니다.
EventListener 를 통해 이벤트 발행/구독 동작을 적용했을 때 장점은 다음과 같습니다.
1. 다른 로직을 호출 후 기다리는 방식과 달리, 의존성도 낮아진다.
2. 이벤트가 실패하더라도 롤백시키지 않도록 설정이 가능해진다. ( TransactionalEventListener 사용 필요 )
EventListener를 사용할 때 다음과 같은 용어를 알고 가면 이해가 편합니다.
* Event Publisher (발행자): 이벤트(사건)를 발생시키는 주체. (예: postingService)
* Event (이벤트): 발생한 사건의 정보를 담은 객체. (예: '누가 누구에게 댓글을 달았다'는 정보)
* Event Listener (구독자): 특정 이벤트가 발생하면 이를 감지하고 정해진 동작을 수행하는 주체. (예: NotificationService)
spring boot 에서 이벤트 발행/구독 기능은 다음과 같은 규칙이 있습니다.
- spring 4.2 ( spring boot의 경우 1.3 ) 이전 버전일 경우, 이벤트 클래스가 ApplicationEvent를 상속하도록 구현이 필요
- 이벤트 발행을 하려면, 발행하고자 하는 클래스에서 applicationEventPublisher 를 주입받아 사용하면 된다.
- 이벤트 구독을 하려면, @EventListener 를 통해 이벤트를 처리할 메서드를 지정한다.
이제 위와 같은 동작을 왜, 어떻게 적용했는지 확인해보도록 하겠습니다!!
2. 기존 알림 시스템의 문제점
SNS 프로젝트를 진행하면서, sse 방식을 기반한 실시간 알림 시스템 구현이 필요했습니다.
notificationService에 구현된 알림 메서드를 다른 Service ( ex. 포스팅 service 등 알림이 사용되는 service )에서 호출해서 사용하는 방식이었습니다.
[기존 코드 구조]
/**
* posting 생성
*
* @param requestCreatePostingDto
* @return
*/
@Transactional
override fun createPosting(requestCreatePostingDto: RequestCreatePostingDto): ResponsePostingDto {
logger.debug { "requestCreatePostingDto : $requestCreatePostingDto" }
..... // 포스팅 생성 로직
//친구가 있다면, 알림 발송
notifyForNewPosting(writerId)
logger.debug { "imageUrlList : $imageUrlList" }
return ResponsePostingDto
}
......
/**
* 새 포스팅 등록 알림 메서드
*
* @param writerId
*/
private fun notifyForNewPosting(writerId : Long){
val friends = memberRepository.findFriendsId(writerId, FriendApplyStatusEnum.ACCEPT)
notificationService.createNotification(
RequestCreateNotificationDto(
receiverId = friends,
senderId = writerId,
type = NotificationType.NEW_POST,
message = "친구가 포스팅을 게시했습니다."
)
)
}
위 코드는 postingService ( 포스팅 관련 비즈니스 로직 ) 내에서 notificationService을 통해 알림 로직을 사용하는 모습입니다. ( createNotification 내에서 알림 데이터 insert와 sse 알림 전송이 발생합니다. )
postingService내에서 notifyForNewPosting 함수 호출을 통해 알림을 발생시키는데, 위 코드는 다음과 같은 문제가 있습니다.
1. 강한 결합 : postingService와 notificationService가 서로 강하게 의존하는 형태로 구성됨.
2. 비효율적인 트랜잭션 : 포스팅을 생성하는 createPosting은 transaction이 적용된 상태. 하지만 알림 발송이 실패해도, 포스팅 작성은 되는것이 일반적인 상황이나, 위 코드는 알림 발송이 실패하면 포스팅 작성이 성공했어도 롤백된다.
알림 발송은 포스팅 데이터 insert보다는 우선순위가 낮은 기능으로, 알림으로 인해 포스팅 작성이 실패하는 일은 없어야 했습니다.
새로운 요구사항은 바로 포스팅 작성 동작이 성공적으로 커밋되어야만 알림이 발송되도록 하도록 수정하는 것이 되겠습니다!!
3. 해결책 - Spring Event로 서비스 간 결합 끊어내기
이 문제들을 해결하기 위해 Spring에서 제공하는 이벤트 발행/구독 메커니즘을 도입했습니다.
Step 1: 이벤트 객체 정의하기
먼저 이벤트에 담을 정보를 담은 NotificationEvent 클래스를 만듭니다.
/**
* 알림 이벤트 클래스
* 알림 발송에 필요한 데이터를 의미
*
* @property receiverId
* @property senderId
* @property type
* @property message
* @property friendId
*/
data class NotificationEvent(
val receiverId: List<Long>,
val senderId: Long?,
val type: NotificationType,
val message: String,
val friendId: Long? = null
)
Step 2: 이벤트 리스너 구현
@Component
class NotificationEventListener(
private val notificationSender: NotificationSender,
private val notificationRepository: NotificationRepository,
private val memberRepository: MemberRepository
) {
private val logger = KotlinLogging.logger {}
/**
* 비동기로 알림 발송 처리
* 트랜잭션 커밋 후에 실행되도록 설정
*
* @param event
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional
fun handleNotificationEvent(event: NotificationEvent) {
logger.info { "Notification event received: $event" }
..... //알림 데이터 생성
notificationRepository.saveAll(notifications)
notificationSender.sendNotificationsAsync(publishDtos)
}
}
NotificationEventListener 클래스를 생성 후, 기존에 알림을 발생시키던 notificationService 내 로직을 여기로 이관했습니다. 이벤트 리스너로 지정해, 이벤트 발생 시 동작을 정의합니다.
여기서 @EventListener가 아닌 @TransactionalEventListener 어노테이션을 사용해 이벤트 리스너 기능을 적용했고,async 어노테이션을 통해 이벤트 실행이 비동기적으로 실행되도록 설정하여 포스팅 작성 로직이 이벤트 발행 후 이벤트 종료를 기다리지 않도록 했습니다.
** TransactionalEventListener는 이벤트가 발생하는 메서드가 트랜잭션으로 묶여서 처리될 경우, 해당 트랜잭션의 상태에 따라 이벤트 발행 동작 여부를 결정할 수 있도록 설정이 가능한 어노테이션입니다. 해당 어노테이션을 통해, 포스팅 작성 트랜잭션이 성공했을 경우에만 해당 이벤트가 발행되도록 설정한 것입니다.
Step 3: 기존 서비스에서, 이벤트 발행하는 방식 적용 (Publisher)
이제 postingService 내 createPosting 메서드에서 알림 발생 코드를 변경하겠습니다.
/**
* posting 관련 비즈니스 로직
*
* @property postingRepository
* @property postingMapper
*/
@Service
class PostingServiceImpl(
private val postingRepository: PostingRepository,
private val memberRepository: MemberRepository,
private val hashtagRepository: HashtagRepository,
private val postingHashtagRepository: PostingHashtagRepository,
private val eventPublisher: ApplicationEventPublisher, //새로 추가된 eventPublisher
private val fileStorageService: FileStorageService,
private val imageService: ImageService,
private val jwtUtil: JwtUtil
) : PostingService {
.....
/**
* posting 생성
*
* @param requestCreatePostingDto
* @return
*/
@Transactional
override fun createPosting(requestCreatePostingDto: RequestCreatePostingDto): ResponsePostingDto {
logger.debug { "requestCreatePostingDto : $requestCreatePostingDto" }
...... //포스팅 insert 로직
notifyForNewPosting(writerId)
logger.debug { "imageUrlList : $imageUrlList" }
return ResponsePostingDto
}
/**
* 새 포스팅 등록 알림 메서드
*
* @param writerId
*/
private fun notifyForNewPosting(writerId : Long){
val friends = memberRepository.findFriendsId(writerId, FriendApplyStatusEnum.ACCEPT)
if (friends.isNotEmpty()) {
eventPublisher.publishEvent( //eventPublisher를 통해 publishEvent 실행하도록 변경
NotificationEvent(
receiverId = friends,
senderId = writerId,
type = NotificationType.NEW_POST,
message = "친구가 포스팅을 게시했습니다."
)
)
}
}
ApplicationEventPublisher 를 주입받고, publishEvent 를 호출합니다. 발행 시 위에서 설정한 eventListener로 지정된 메서드가 실행됩니다.
postingService에서 알림 호출 시 설령 알림 동작이 실패해도 포스팅 insert 과정이 롤백되지는 않게 되었습니다. 앞에서 설명한 것처럼 포스팅 삽입 로직 트랜잭션이 성공해야만 알림이 동작하게 구현되었기 때문입니다.
이제 postingService는 알림이 어떻게 처리되는지 전혀 알 필요가 없어졌습니다! 실제로 알림 로직 성공을 기다리지 않고 이벤트만 발행한 후, 오직 "포스팅이 생성되었다"는 사실만 외부에 알릴 뿐이기에 코드가 훨씬 깔끔해지고 책임이 명확해진 것 같습니다.
3. 결론 및 요약
지금까지 Spring의 이벤트 메커니즘을 활용해 알림 시스템을 개선하는 과정을 소개했습니다.
단순히 NotificationService를 직접 호출하던 방식에서 벗어나 이벤트를 사용함으로써,
* 서비스 간의 결합도를 낮추기 성공
* @TransactionalEventListener를 통해 트랜잭션 종속성 문제 해결
* @Async를 더해 사용자 응답 성능 향상.
등 코드 개선 효과를 얻을 수 있었습니다. 이번 포스팅은 이것으로 마무리하도록 하겠습니다~
'Project > 개인 프로젝트' 카테고리의 다른 글
| spring boot 사이드 프로젝트 : 테스트 시나리오를 세우고 jmeter를 통해 부하 테스트 해보기 (0) | 2025.02.20 |
|---|---|
| spring boot 사이드 프로젝트 : 채팅기능을 위한 stomp 적용 (0) | 2024.02.20 |