본문 바로가기
백엔드 개발

#054. 코프링: spring-event 로 pub-sub 알림 시스템 만들기

by iamjoy 2023. 7. 29.

신규 유저 또는 유저가 작성한 컨텐츠가 있으면 이를 디스코드 알림으로 받고 싶어 자동화 시스템을 구성했다. 이전 에러 알림 자동화 작업(링크)을 하면서 클라우드 플레어 워커가 디스코드 웹훅을 관리하도록 설정했기 때문에 스프링이 클라우드 플레어를 보고 워커가 이를 디스코드 웹훅 모양으로 만들어주도록 했다.

이벤트를 어떻게 발생시킬 지 고민을 했는데,
- 어드민 성격이어서 알림은 핵심적인 로직 또는 실행이 보장 할 필요는 없다.
- 회원가입 로직은 알림을 publish만 하고 이벤트를 받고, 처리하는 로직은 이벤트와 관련된 모듈이 처리한다. (pub-sub구조)
- 알림 보내기를 동기적으로 처리할 필요는 없다.

이 조건들을 만족시키는 적정 기술로 sprinb event가 있어 사용했다.

spring-event란

Spring-event는 pub-sub구조를 가지는 ApplicationContext가 제공하는 기능 중 하나이다. 이를 구현하기 위해서는 아래와 같은 요소들이 필요하다.

- 이벤트 클래스는 ApplicationEvent를 extend 한다 (4.2 버전부터는 사용하지 않아도 된다.)
- 퍼블리셔는 ApplicationEventPublisher 객체를 주입해야 합니다.
- 리스너는 ApplicationListener 인터페이스를 구현해야 합니다.

트랜잭션 단계에 대한 이벤트 바인딩(Transaction-Bound Events):
또 트랜젝션이 성공, 롤백, commit 전 등 다양한 상태일 때 이벤트를 발생시킬 수도 있다. 여기에서는 유저가 생성된 이후에 새로운 유저가 생겼다는 이벤트를 발생시키고 싶은 것이므로 트렌젝션 바인딩 이벤트를 사용한다.

트랜잭션의 상태에 따라 이벤트 리스닝을 설정할 수도 있다.

AFTER_ROLLBACK - 트랜잭션이 롤백된 경우
AFTER_COMPLETION - 트랜잭션이 완료된 경우(AFTER_COMMIT 및 AFTER_ROLLBACK의 별칭)
BEFORE_COMMIT은 트랜잭션 커밋 직전에 이벤트를 실행하는 데 사용됩니다.

여기서는 기본적인 Spring Custom Event 로 처리했지만, applicationContext에서 제공하는 다양한 이벤트 및 Generic 이벤트를 만들수도 있다.
https://www.baeldung.com/spring-context-events

적용

이벤트 객체를 생성한다. 

import org.springframework.context.ApplicationEvent

class UserCreatedEvent( // <--- User가 새로 생성되었을 때 발생되는 이벤트
    val userId: Long,
    source: Any,
) : ApplicationEvent(source)

이벤트를 발생시킨다.

@Service
class AuthService(
    @Autowired private val userRepository: UserRepository,
    @Autowired private val eventPublisher: ApplicationEventPublisher, // <---- eventPublisher 주입
) { 
    @Transactional
    fun createAccountWithGoogle(dto: UserCreationRequest, token: String): UserInfo {
        ...
        val result: User = userRepository.saveAndFlush(user)
        ...
   
       this.eventPublisher.publishEvent( 
       		UserCreatedEvent(result.id, this),  // <--- 원하는 이벤트를 발생시킨다
       )
    }
}

해당 이벤트를 subscribe하는 리스너를 만들고

@Component
class DiscordEventListener(
    private val discordNotificationService: DiscordNotificationService,
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // <--- 리스너라는 어노테이션. 롤백된 이벤트는 리스닝하지 않는다.
    @Async // <--- 핵심적인 비즈니스 로직은 아니기 때문에 비동기적으로 처리했다.
    fun newUserDiscordNotification(event: UserCreatedEvent) {
        discordNotificationService.newUserDiscordNotification(event) <--- 디스코드에 관한 코드는 해당 객체가 처리하도록 함.
    }
}

디스코드와 관련된 로직은 DiscordService로 뺏다.

@Component
class DiscordNotificationService(
    private val userRepository: UserRepository,
) {
    fun newUserDiscordNotification(event: UserCreatedEvent) {
        val totalUser = userRepository.countAllByDeletedAtIsNull()
        val user = userRepository.findByIdOrNull(event.userId) ?: return

        val restTemplate = RestTemplate()
               val url = Constants.NEW_EVENT_DISCORD_CHANNEL_URL

        val headers = HttpHeaders()
        
        headers.contentType = MediaType.APPLICATION_JSON

        val eventData = EventData(
            action = "event",
            description = "$totalUser 번째 유저에요!",
            name = user.name,
            iconUrl = user.profileUri,
        )

        val requestEntity = HttpEntity(eventData, headers)
        restTemplate.postForEntity(url, requestEntity, String::class.java)
    }
}

 

디스코드의 웹훅은 클라우드 플레어 워커에 올려두었다. 아래와 같은 코드를 작성해 유저 생성 이벤트가 발생하면 디스코드에 알림을 보내도록한다.

router.add("POST", "/event", async (request, response) => { 
	const body = await request.body(); //< ---- 호출이 오면 body 부분만 추출해
	await eventReceiver(body); // //< ---- 디스코드 알림 이벤트를 발생시킨다.
	response.send(200);
});

 

async function receiver(payload: unknown) {
	if (isUserEventPayload(payload)) {
		const message = UserEventDiscordPayload.byUser(payload).toObject(); // <--- body를 디스코드 형식에 맞는 message로 바꾼다
		await fetch(DISCORD_CHANNEL_WEBHOOK, {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
			},
			body: JSON.stringify(message),
		});
	}
}

 

Reference

https://www.baeldung.com/spring-events

https://www.baeldung.com/spring-context-events