댓글을 작성할 때, 알림이 가도록 하는게 비즈니스 로직임
한 트랜잭션 안에서 댓글 작성 -> DB 저장 -> 알림 발송
까지 하면 너무 오래 스레드를 잡고있음.
또한 후에 서버가 분리될 수도, SSE가 아닌 다른 외부 시스템을 도입할 수도 있음. 만약 외부와 소통(알림발송)하면서 서버가 기다려야 한다면?
언제까지 기다려야하는지 도통 모름.
이걸 그래서 한 트랜잭션에서 하는건 무모한감이 있음.
현재 서비스를 구현하면서 트랜잭션의 분리가 필요했음.
또한 동기적으로 위의 과정이 된다면 문제
따라서 비동기적으로 알림이 가도록함
여러가지 패턴이 있음. Outbox, 메시지큐, Pub/Sub, Saga Pattern 등이 있음. 물론 여러 패턴들도 비동기 패턴! 이러면서 종류로 나오는 것이 아닌, 비동기를 사용할 때 자주 사용되는 패턴들임.
하지만 현 서비스는 서버 1개로 작개 시작.
따라서 알림은 비동기 스레드 풀로 할 예정
: 작업을 호출한 메인 스레드와 분리하여 백그라운드에서 별도의 스레드가 작업을 처리하도록 관리하는 자원 집합소.
알림에서 이걸 사용하는 이유는 크게 두가지.
1. 자원 관리
2. 알림이 지연되더라도 유저에게 빠른 응답을 보내기 위해서임
비동기 스레드 풀은 메인 스레드의 작업을 대기열(큐)에 담아두고, 백그라운드에서 별도의 스레드가 이를 처리하게 함으로써 시스템의 응답속도를 높이는 자원 관리 기법임.
메인 스레드와 비동기 스레드를 나누는게 다른 구역에 저장된다! 이런게 아니라 역할에 따라 나뉘는 것. 메인 스레드라는건 맨 처음 댓글작성을 맡은 스레드를 말하는 것이고, @Async() 어노테이션이 붙어있는 메서드를
자바의 스레드 풀의 기본 설계 원칙
: 스레드 생성 비용이 비싸니 최대한 큐로 버텨라
댓글작성
-> 이벤트 발행 : eventPublisher.publishEvent(event) 를 호출하면 스프링 컨테이너(ApplicationContext)에 이벤트 발행했다고 알려줌
->트랜잭션 동기화 : handleNotificationEvent 에 @TransactionalEventListener(이 메서드는 Event를 받는 리스너라는 뜻) 가 붙어 있으므로, 스프링은 즉시 실행하지 않고 현재 진행 중인 댓글 저장 트랜잭션의 커밋 시점을 기다림
-> 비동기 위임 : 커밋이 완료되면 스프링이 해당 리스너를 깨움. 이 때 @Async("notificationExecutor")가 붙어 있는 것을 확인하고, 작업을 메인 스레드가 아닌 notificationExecutor 스레드 풀의 대기열에 던짐 (notificationExecutor -> 얘는 서버가 실행될 때 미리 만듬 )
-> 메인 스레드 종료 : 작업을 던진 직후 메인 스레드는 즉시 사용자에게 응답 반환
-> : 별도 스레드 실행 : notificationExecurot 소속의 스레드 하나가 작업을 꺼내 notificationService.createAndSend(event)를 실제로 수행
< AsyncConfig >
@Bean(name = "notificationExecutor") -> 스레드 풀의 이름
public TaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); -> 최소한의 스레드 수 2
executor.setMaxPoolSize(4); -> 큐가 찼을 때, 추가로 이용할 수 있는 스레드 수
executor.setQueueCapacity(100); -> 큐의 크기. (스레드가 모두 꽉 찼을 때, 큐에서 대기하는 최대 크기)
executor.setThreadNamePrefix("notification-"); -> 로그에 찍힐 스레드 이름의 접두사
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); => 큐도 차고, 스레드도 찼을 때, 새로운 요청을 버리지 않고, 요청을 보낸 메인 스레드가 직접 알림을 처리하게 하여 작업 유실을 막는 것
executor.initialize();
return executor;
}
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
이 친구는 메인 스레드가 작업해주도록 하기 때문에, 비동기가 아닌 동기방식으로 진행됨. 목적은 작업 유실 방지이며, 부하 조절역시 할 수 있음. 메인 스레드가 알림을 처리하느라 바빠지면, 새로운 API 요청을 받는 속도가 느려짐. 근본적인 해결은 아니긴함.
< CommentService >
if (request.getParentCommentId() == null) { -> 새 댓글
eventPublisher.publishEvent(NotificationEvent.builder()
.senderId(currentUserId)
.receiverIds(List.of(post.getAuthor().getId()))
.type(NotificationType.NEW_COMMENT)
.referenceId(postId)
.content(author.getNickname() + "님이 회원님의 게시글에 댓글을 남겼습니다.")
.build());
} else { -> 대댓글
eventPublisher.publishEvent(NotificationEvent.builder()
.senderId(currentUserId)
.receiverIds(List.of(comment.getParentComment().getAuthor().getId()))
.type(NotificationType.NEW_REPLY)
.referenceId(postId)
.content(author.getNickname() + "님이 회원님의 댓글에 답글을 남겼습니다.")
.build());
}
< NotificationEventListener >
@Async("notificationExecutor") -> notificationExecutor 스레드 풀에게 시킴
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) -> 댓글이 DB에 완전히 저장된 후에만 이 메서드를 실행하라
public void handleNotificationEvent(NotificationEvent event) {
try {
notificationService.createAndSend(event);
} catch (Exception e) {
log.error("알림 처리 실패: type={}, senderId={}", event.getType(), event.getSenderId(), e);
}
}
이벤트 발행
: CommentService가 댓글 저장 로직 중 publishEvent()를 호출.
스프링은 이 이벤트를 즉시 처리하지 않고 현재 트랜잭션의 예약 목록에 담아둠.
커밋 대기 (메인 스레드)
: DB 작업이 모두 끝나고 커밋 될때까지 기다림. (데이터 정합성 보장됨)
핸들러 트리거 (메인 스레드)
: 커밋이 성공하면 스프링이 NotificationEventLinstener를 깨움
비동기 위임 (메인 스레드 -> Executor)
: 핸들러에 @Async 어노테이션이 있음. 메인 스레드는 작업을 notificationExecutor의 대기열에 넣음
메가 응답 (메인 스레드)
: 작업을 던진 직후, 메인 스레드는 자기 할 일을 다했음. 사용자에게 즉시 HTTP 응답을 보냄
백그라운드 처리 (작업 스레드)
: 스레드 풀에 있던 2개의 스레드 중 하나가 대기열에서 작업을 꺼내 실제로 알림을 발송함