특정 비즈니스 로직에 있어서 메일 서비스는 동기적으로 실행을 하는 것보다 비동기적으로 수행되는 경우가 많습니다.
클라이언트에게 화면이 멈춰있는 듯한 경험을 할 수 있기 때문입니다.
예를 들어 간단하게 A작업이 있다면, A작업이 수행되어 화면에 반영되고 부가적으로 메일 발송이 이루어진다고 할 때
동기적으로 작동하게 된다면
1. 클라이언트 A작업을 수행
2. 백엔드 서버에서는 A작업을 수행하면서 동시에 B(메일발송)작업 수행
for(MailModel mailModel : mailList) {
// A 작업 수행
for (User user : userList) {
// B 작업 수행(메일 발송)
}
}
3. B작업이 끝나기 전 까지는 A작업도 계속 멈춰있게 됩니다.
비동기 도입 고려
비동기 로직을 고민할 때는 다음과 같은 경우라고 생각합니다.
- 순서가 중요하지 않을 때
- 예: 여러 가지 배치 작업이나 알림 발송 등, 결과가 순차적으로 처리될 필요가 없고 전체 흐름에 지장이 없는 경우 - 사용자가 즉각적인 확인을 필요로 하지 않을 때
- 예: 로그 기록, 분석용 데이터 적재, 비회원에게 대량 메일을 발송하는 경우 등, 결과가 즉시 화면에 반영될 필요가 없을 때 - 실패해도 서비스 전체 장애로 이어지지 않는 경우
- 예: 일부 데이터 처리나 외부 시스템에 대한 알림 등이 실패해도 재시도(웹훅/큐 재처리 로직)나 추적(로그 분석)으로 보완이 가능할 때
3가지 조건에 부합했고, Spring의 이벤트 기반 비동기 처리를 활용 하기로 하였습니다.
OOOeventExecutor라는 이름으로 ThreadPoolTaskExecutor 빈을 등록
<task:annotation-driven executor=" OOOeventExecutor " />
로 어노테이션 기반 @Async, @EventListener 비동기 로직에서 이 스레드 풀을 사용하도록 지정했습니다.
<task:annotation-driven executor=" OOOeventExecutor " />
수도 코드
/**
* 1) AsyncConfig.java
* - 비동기 처리를 위한 스레드 풀 설정
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "mailEventExecutor")
public ThreadPoolTaskExecutor mailEventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 기존 5분 이상 걸리던 작업을 3초로 단축하기 위해
// 동시 처리 능력을 충분히 확보
executor.setCorePoolSize(지정); // 최소 스레드 수 (동시 처리 기본값)
executor.setMaxPoolSize(지정); // 최대 스레드 수 (트래픽 급증 시 확장)
executor.setQueueCapacity(지정); // 큐 대기 가능 작업 수
executor.setThreadNamePrefix("MailEventThread-");
executor.initialize();
return executor;
}
// @Async 애노테이션에 사용될 스레드 풀 지정
@Bean
public AsyncConfigurer asyncConfigurer() {
return new AsyncConfigurer() {
@Override
public Executor getAsyncExecutor() {
return mailEventExecutor();
}
};
}
}
/**
* 2) MailSendEvent.java
* - 메일 발송에 필요한 정보를 담는 이벤트 클래스
*/
public class MailSendEvent {
private String deptId;
private String subject;
private String message;
// 생성자, Getter/Setter...
public MailSendEvent(String deptId, String subject, String message) {
this.deptId = deptId;
this.subject = subject;
this.message = message;
}
}
/**
* 3) BulkMailService.java
* - 대량 메일 발송 요청 시 이벤트를 발행(Publish)
*/
@Service
public class BulkMailService {
// 스프링의 이벤트 발행기(Publisher) 주입
@Autowired
private ApplicationEventPublisher publisher;
public void sendBulkMail(List<MailRequest> mailRequests) {
// 대량 메일 목록을 순회하며 이벤트 발행
for (MailRequest req : mailRequests) {
// deptId, subject, message 등 필요한 정보를 담아 이벤트 생성
MailSendEvent event = new MailSendEvent(
req.getDeptId(),
req.getSubject(),
req.getMessage()
);
// 이벤트 발행 -> 비동기 리스너가 이를 구독해 실제 메일을 전송
publisher.publishEvent(event);
}
}
}
/**
* 4) MailSendListener.java
* - 이벤트를 구독(Listen)하고, @Async를 통해 비동기로 메일을 전송
*/
@Component
public class MailSendListener {
@Autowired
private MailService mailService; // 실제 메일 전송 로직 담당
@Async("mailEventExecutor") // mailEventExecutor 스레드 풀에서 비동기 실행
@EventListener // MailSendEvent를 구독
public void handleMailEvent(MailSendEvent event) {
// 이벤트에서 정보 꺼내기
String deptId = event.getDeptId();
String subject = event.getSubject();
String message = event.getMessage();
// 메일 전송 로직 수행 (DB 조회, 외부 API 호출 등)
mailService.sendMail(deptId, subject, message);
// 필요한 후속 작업(로그 작성, 예외 처리 등)...
}
}
Spring의 이벤트 발행/구독 구조에서, publisher.publishEvent(객체)를 호출하면
Spring 내부적으로 이벤트 객체의 실제 타입을 검사하여 해당 이벤트 타입을 구독하고 있는 리스너(@EventListener 메서드)를 찾습니다.
즉, publisher 자체가 MailSendEvent를 직접 아는 게 아니라, “이벤트 객체의 클래스”를 기준으로 해당 이벤트를 받을 수 있는 모든 리스너를 자동으로 검색·호출하게 됩니다.
이런 매커니즘 덕분에, 새로운 이벤트 클래스를 추가해도 리스너만 만들어 놓으면 publisher가 별도로 이벤트 타입을 인지하지 않아도 잘 동작하게 됩니다.
corePoolSize
corePoolSize = 3
- 일단 기본적으로 3명의 직원(스레드)를 항상 대기시켜둔다고 생각하시면 됩니다.
- 평소에도 3명이 항상 대기 중이므로, 갑자기 할 일이 생겨도 바로 처리할 수 있습이다. (스레드를 생성하는 데 따로 시간이 걸리지 않음)
- 그래서 일반적인 트래픽(메일 전송 요청)이 들어왔을 때도, 최소 3 명이 동시에 일을 나눠할 수 있으니 병목 현상을 줄일 수 있습니다.
maxPoolSize
maxPoolSize = 10
- 필요하다면 최대 10명까지 직원을 늘릴 수 있다는 의미입니다.
- 갑자기 메일 전송 요청이 폭주하면, 기존 3명이 감당하기 어려울 수 있으니, 최대 10명까지 확장해 일을 나누어 처리할 수 있게 합니다.
- 이렇게 하면 처리 속도가 빠르고, 갑작스러운 트래픽 폭주가 있어도 안정성을 유지할 수 있습니다.
queueCapacity
queueCapacity = 25
- 동시에 처리할 수 있는 직원(스레드)이 전부 바빠서 일을 못 받는 순간에는, 25명까지 대기 줄을 설 수 있다는 개념입니다.
- 만약 한 번에 30건의 메일 전송 요청이 들어온다면, 10명이 동시에 처리하고, 남는 20건은 대기 줄(큐)에 차례로 서 있게 되죠.
- 이 대기 줄이 없다면, 현재 직원들이 이미 바빠서 일을 못 받아요라며 에러가 날 수도 있습니다. 하지만 대기 줄이 25명 분 있으니, 요청이 거부되거나 실패하지 않고 순차적으로 처리할 수 있습니다.
장단점
장점
- 결합도 낮춤
- 가독성과 유지보수성 향상
- 비동기 처리 용이
- 높은 확장성
단점
- 디버깅 복잡도 증가
- 성능/오버헤드 가능성
- 순서 제어 어려움
- 오버엔지니어링 위험
고민해봐야할 이슈
이중화 환경에서의 이벤트 충돌 시나리오
- 동일 이벤트 중복 처리
- 서버가 Active-Active로 동작할 때, 동일한 요청이나 DB 변경으로 인해 서버 A와 서버 B에서 동일 이벤트가 발생하거나, 혹은 외부 트리거를 통해 이벤트가 양쪽 서버 모두로 전달될 수 있습니다.
- 이 경우, 두 서버가 서로 모르는 채 중복 처리를 수행하여, 데이터 불일치나 중복 발송이 일어날 수 있습니다.
- 서버 간 반영 시점 차이(Concurrency)
- 이벤트를 수신한 리스너가 DB 업데이트나 파일 쓰기 등을 진행하는데, 서버 A와 서버 B가 동시에 작업을 시도하면 충돌(Deadlock)이 발생할 가능성이 있습니다.
- EventListener가 각 서버별로 동작
- Spring의 기본 @EventListener는 JVM 메모리 내부의 이벤트 버스를 이용합니다. 서버 A에서 발생한 이벤트가 자동으로 서버 B에 전달되는 구조가 아닙니다.
- 분산 환경에서 동일한 이벤트를 다중 서버가 중복으로 처리할 가능성이 높아집니다.
이벤트를 구축할 때 저의 경우 트래픽이 적고, 단일 인스턴스를 사용했기 때문에 부담 없이 사용할 수 있었습니다.
대응 방안
- 분산 메시징 시스템 도입
- 이벤트를 각 서버 내부에서 처리하는 대신, Kafka, RabbitMQ, ActiveMQ 같은 외부 메시지 브로커를 사용하면 단 한 번만 이벤트를 소비하거나, 메시지 큐 레벨에서 중복 방지를 설정할 수 있습니다.
- 데이터베이스 락, 중복 처리 방지 로직
- DB 테이블에 유니크 키 혹은 상태 플래그를 두어, 이미 처리된 이벤트인지 여부를 확인하는 로직을 구현할 수 있습니다.
- 예: 이벤트 식별자(eventId)를 DB에 기록하고, 이미 존재하면 중복 처리하지 않는다거나, Optimistic Lock(버전 관리) 기법으로 충돌을 방지합니다
- 과거에 제가 쓴 글 ▽
- https://url.kr/p53av8
- 멱등 처리 설계(Idempotent)
- 이벤트 리스너 로직이 같은 이벤트가 여러 번 오더라도 결과가 달라지지 않게 설계하는 방법입니다.
- 예: 메일 발송이라면, 메일 발송 기록(메일 고유 ID나 일정시간 내 재발송 카운트)을 남겨 이미 발송 완료된 상태라면 무시하도록 처리.
- 메시지 브로커
- 이벤트가 전송되는 순서나 처리 순서를 철저하게 관리해야 한다면, 단순히 @EventListener가 아닌 외부 메시지 브로커에 트랜잭션을 적용하는 방식을 고려합니다.
- Spring Cloud Stream, Spring Integration 등 분산 환경을 위한 프레임워크에서 이런 패턴을 쉽게 지원합니다.
참고사이트
https://recently0.tistory.com/17
분산환경에서 SQS 리스너 서버 고려점
0. 서론SQS를 사용하면 이벤트를 처리하는 서버가 한대 있을 때는 별로 걱정할 것이 없으나, 이벤트 처리하는 서버가 다운 타임 없이 SQS를 이용해 이벤트를 처리하고 싶은 경우 고려해야 할 요소
recently0.tistory.com
https://kafka.apache.org/documentation/
Apache Kafka
Apache Kafka: A Distributed Streaming Platform.
kafka.apache.org
RabbitMQ Documentation | RabbitMQ
<!--
www.rabbitmq.com
https://docs.spring.io/spring-cloud-stream/docs/current/reference/html/
Spring Cloud Stream Reference Documentation
The reference documentation consists of the following sections: Overview History, Quick Start, Concepts, Architecture Overview, Binder Abstraction, and Core Features Rabbit MQ Binder Spring Cloud Stream binder reference for Rabbit MQ Apache Kafka Binder Sp
docs.spring.io
'Spring관련 기술 > 서버개발' 카테고리의 다른 글
MQ pubsub 간단 정리 (1) | 2024.09.29 |
---|---|
Kakao 도서 정보 이용 AccessDeniedError 발생 (1) | 2024.08.31 |
개발환경에서 테스트 데이터 넣기 (0) | 2024.08.31 |
MethodArgumentTypeMismatchException (0) | 2024.08.30 |
MissingServletRequestParameterException (0) | 2024.08.30 |
특정 비즈니스 로직에 있어서 메일 서비스는 동기적으로 실행을 하는 것보다 비동기적으로 수행되는 경우가 많습니다.
클라이언트에게 화면이 멈춰있는 듯한 경험을 할 수 있기 때문입니다.
예를 들어 간단하게 A작업이 있다면, A작업이 수행되어 화면에 반영되고 부가적으로 메일 발송이 이루어진다고 할 때
동기적으로 작동하게 된다면
1. 클라이언트 A작업을 수행
2. 백엔드 서버에서는 A작업을 수행하면서 동시에 B(메일발송)작업 수행
for(MailModel mailModel : mailList) {
// A 작업 수행
for (User user : userList) {
// B 작업 수행(메일 발송)
}
}
3. B작업이 끝나기 전 까지는 A작업도 계속 멈춰있게 됩니다.
비동기 도입 고려
비동기 로직을 고민할 때는 다음과 같은 경우라고 생각합니다.
- 순서가 중요하지 않을 때
- 예: 여러 가지 배치 작업이나 알림 발송 등, 결과가 순차적으로 처리될 필요가 없고 전체 흐름에 지장이 없는 경우 - 사용자가 즉각적인 확인을 필요로 하지 않을 때
- 예: 로그 기록, 분석용 데이터 적재, 비회원에게 대량 메일을 발송하는 경우 등, 결과가 즉시 화면에 반영될 필요가 없을 때 - 실패해도 서비스 전체 장애로 이어지지 않는 경우
- 예: 일부 데이터 처리나 외부 시스템에 대한 알림 등이 실패해도 재시도(웹훅/큐 재처리 로직)나 추적(로그 분석)으로 보완이 가능할 때
3가지 조건에 부합했고, Spring의 이벤트 기반 비동기 처리를 활용 하기로 하였습니다.
OOOeventExecutor라는 이름으로 ThreadPoolTaskExecutor 빈을 등록
<task:annotation-driven executor=" OOOeventExecutor " />
로 어노테이션 기반 @Async, @EventListener 비동기 로직에서 이 스레드 풀을 사용하도록 지정했습니다.
<task:annotation-driven executor=" OOOeventExecutor " />
수도 코드
/**
* 1) AsyncConfig.java
* - 비동기 처리를 위한 스레드 풀 설정
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "mailEventExecutor")
public ThreadPoolTaskExecutor mailEventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 기존 5분 이상 걸리던 작업을 3초로 단축하기 위해
// 동시 처리 능력을 충분히 확보
executor.setCorePoolSize(지정); // 최소 스레드 수 (동시 처리 기본값)
executor.setMaxPoolSize(지정); // 최대 스레드 수 (트래픽 급증 시 확장)
executor.setQueueCapacity(지정); // 큐 대기 가능 작업 수
executor.setThreadNamePrefix("MailEventThread-");
executor.initialize();
return executor;
}
// @Async 애노테이션에 사용될 스레드 풀 지정
@Bean
public AsyncConfigurer asyncConfigurer() {
return new AsyncConfigurer() {
@Override
public Executor getAsyncExecutor() {
return mailEventExecutor();
}
};
}
}
/**
* 2) MailSendEvent.java
* - 메일 발송에 필요한 정보를 담는 이벤트 클래스
*/
public class MailSendEvent {
private String deptId;
private String subject;
private String message;
// 생성자, Getter/Setter...
public MailSendEvent(String deptId, String subject, String message) {
this.deptId = deptId;
this.subject = subject;
this.message = message;
}
}
/**
* 3) BulkMailService.java
* - 대량 메일 발송 요청 시 이벤트를 발행(Publish)
*/
@Service
public class BulkMailService {
// 스프링의 이벤트 발행기(Publisher) 주입
@Autowired
private ApplicationEventPublisher publisher;
public void sendBulkMail(List<MailRequest> mailRequests) {
// 대량 메일 목록을 순회하며 이벤트 발행
for (MailRequest req : mailRequests) {
// deptId, subject, message 등 필요한 정보를 담아 이벤트 생성
MailSendEvent event = new MailSendEvent(
req.getDeptId(),
req.getSubject(),
req.getMessage()
);
// 이벤트 발행 -> 비동기 리스너가 이를 구독해 실제 메일을 전송
publisher.publishEvent(event);
}
}
}
/**
* 4) MailSendListener.java
* - 이벤트를 구독(Listen)하고, @Async를 통해 비동기로 메일을 전송
*/
@Component
public class MailSendListener {
@Autowired
private MailService mailService; // 실제 메일 전송 로직 담당
@Async("mailEventExecutor") // mailEventExecutor 스레드 풀에서 비동기 실행
@EventListener // MailSendEvent를 구독
public void handleMailEvent(MailSendEvent event) {
// 이벤트에서 정보 꺼내기
String deptId = event.getDeptId();
String subject = event.getSubject();
String message = event.getMessage();
// 메일 전송 로직 수행 (DB 조회, 외부 API 호출 등)
mailService.sendMail(deptId, subject, message);
// 필요한 후속 작업(로그 작성, 예외 처리 등)...
}
}
Spring의 이벤트 발행/구독 구조에서, publisher.publishEvent(객체)를 호출하면
Spring 내부적으로 이벤트 객체의 실제 타입을 검사하여 해당 이벤트 타입을 구독하고 있는 리스너(@EventListener 메서드)를 찾습니다.
즉, publisher 자체가 MailSendEvent를 직접 아는 게 아니라, “이벤트 객체의 클래스”를 기준으로 해당 이벤트를 받을 수 있는 모든 리스너를 자동으로 검색·호출하게 됩니다.
이런 매커니즘 덕분에, 새로운 이벤트 클래스를 추가해도 리스너만 만들어 놓으면 publisher가 별도로 이벤트 타입을 인지하지 않아도 잘 동작하게 됩니다.
corePoolSize
corePoolSize = 3
- 일단 기본적으로 3명의 직원(스레드)를 항상 대기시켜둔다고 생각하시면 됩니다.
- 평소에도 3명이 항상 대기 중이므로, 갑자기 할 일이 생겨도 바로 처리할 수 있습이다. (스레드를 생성하는 데 따로 시간이 걸리지 않음)
- 그래서 일반적인 트래픽(메일 전송 요청)이 들어왔을 때도, 최소 3 명이 동시에 일을 나눠할 수 있으니 병목 현상을 줄일 수 있습니다.
maxPoolSize
maxPoolSize = 10
- 필요하다면 최대 10명까지 직원을 늘릴 수 있다는 의미입니다.
- 갑자기 메일 전송 요청이 폭주하면, 기존 3명이 감당하기 어려울 수 있으니, 최대 10명까지 확장해 일을 나누어 처리할 수 있게 합니다.
- 이렇게 하면 처리 속도가 빠르고, 갑작스러운 트래픽 폭주가 있어도 안정성을 유지할 수 있습니다.
queueCapacity
queueCapacity = 25
- 동시에 처리할 수 있는 직원(스레드)이 전부 바빠서 일을 못 받는 순간에는, 25명까지 대기 줄을 설 수 있다는 개념입니다.
- 만약 한 번에 30건의 메일 전송 요청이 들어온다면, 10명이 동시에 처리하고, 남는 20건은 대기 줄(큐)에 차례로 서 있게 되죠.
- 이 대기 줄이 없다면, 현재 직원들이 이미 바빠서 일을 못 받아요라며 에러가 날 수도 있습니다. 하지만 대기 줄이 25명 분 있으니, 요청이 거부되거나 실패하지 않고 순차적으로 처리할 수 있습니다.
장단점
장점
- 결합도 낮춤
- 가독성과 유지보수성 향상
- 비동기 처리 용이
- 높은 확장성
단점
- 디버깅 복잡도 증가
- 성능/오버헤드 가능성
- 순서 제어 어려움
- 오버엔지니어링 위험
고민해봐야할 이슈
이중화 환경에서의 이벤트 충돌 시나리오
- 동일 이벤트 중복 처리
- 서버가 Active-Active로 동작할 때, 동일한 요청이나 DB 변경으로 인해 서버 A와 서버 B에서 동일 이벤트가 발생하거나, 혹은 외부 트리거를 통해 이벤트가 양쪽 서버 모두로 전달될 수 있습니다.
- 이 경우, 두 서버가 서로 모르는 채 중복 처리를 수행하여, 데이터 불일치나 중복 발송이 일어날 수 있습니다.
- 서버 간 반영 시점 차이(Concurrency)
- 이벤트를 수신한 리스너가 DB 업데이트나 파일 쓰기 등을 진행하는데, 서버 A와 서버 B가 동시에 작업을 시도하면 충돌(Deadlock)이 발생할 가능성이 있습니다.
- EventListener가 각 서버별로 동작
- Spring의 기본 @EventListener는 JVM 메모리 내부의 이벤트 버스를 이용합니다. 서버 A에서 발생한 이벤트가 자동으로 서버 B에 전달되는 구조가 아닙니다.
- 분산 환경에서 동일한 이벤트를 다중 서버가 중복으로 처리할 가능성이 높아집니다.
이벤트를 구축할 때 저의 경우 트래픽이 적고, 단일 인스턴스를 사용했기 때문에 부담 없이 사용할 수 있었습니다.
대응 방안
- 분산 메시징 시스템 도입
- 이벤트를 각 서버 내부에서 처리하는 대신, Kafka, RabbitMQ, ActiveMQ 같은 외부 메시지 브로커를 사용하면 단 한 번만 이벤트를 소비하거나, 메시지 큐 레벨에서 중복 방지를 설정할 수 있습니다.
- 데이터베이스 락, 중복 처리 방지 로직
- DB 테이블에 유니크 키 혹은 상태 플래그를 두어, 이미 처리된 이벤트인지 여부를 확인하는 로직을 구현할 수 있습니다.
- 예: 이벤트 식별자(eventId)를 DB에 기록하고, 이미 존재하면 중복 처리하지 않는다거나, Optimistic Lock(버전 관리) 기법으로 충돌을 방지합니다
- 과거에 제가 쓴 글 ▽
- https://url.kr/p53av8
- 멱등 처리 설계(Idempotent)
- 이벤트 리스너 로직이 같은 이벤트가 여러 번 오더라도 결과가 달라지지 않게 설계하는 방법입니다.
- 예: 메일 발송이라면, 메일 발송 기록(메일 고유 ID나 일정시간 내 재발송 카운트)을 남겨 이미 발송 완료된 상태라면 무시하도록 처리.
- 메시지 브로커
- 이벤트가 전송되는 순서나 처리 순서를 철저하게 관리해야 한다면, 단순히 @EventListener가 아닌 외부 메시지 브로커에 트랜잭션을 적용하는 방식을 고려합니다.
- Spring Cloud Stream, Spring Integration 등 분산 환경을 위한 프레임워크에서 이런 패턴을 쉽게 지원합니다.
참고사이트
https://recently0.tistory.com/17
분산환경에서 SQS 리스너 서버 고려점
0. 서론SQS를 사용하면 이벤트를 처리하는 서버가 한대 있을 때는 별로 걱정할 것이 없으나, 이벤트 처리하는 서버가 다운 타임 없이 SQS를 이용해 이벤트를 처리하고 싶은 경우 고려해야 할 요소
recently0.tistory.com
https://kafka.apache.org/documentation/
Apache Kafka
Apache Kafka: A Distributed Streaming Platform.
kafka.apache.org
RabbitMQ Documentation | RabbitMQ
<!--
www.rabbitmq.com
https://docs.spring.io/spring-cloud-stream/docs/current/reference/html/
Spring Cloud Stream Reference Documentation
The reference documentation consists of the following sections: Overview History, Quick Start, Concepts, Architecture Overview, Binder Abstraction, and Core Features Rabbit MQ Binder Spring Cloud Stream binder reference for Rabbit MQ Apache Kafka Binder Sp
docs.spring.io
'Spring관련 기술 > 서버개발' 카테고리의 다른 글
MQ pubsub 간단 정리 (1) | 2024.09.29 |
---|---|
Kakao 도서 정보 이용 AccessDeniedError 발생 (1) | 2024.08.31 |
개발환경에서 테스트 데이터 넣기 (0) | 2024.08.31 |
MethodArgumentTypeMismatchException (0) | 2024.08.30 |
MissingServletRequestParameterException (0) | 2024.08.30 |