메일 전송은 SMTP 프로토콜로 보내고있었습니다.
메일전송 전용서비스(SendGrid, Mailgun), SMTP 프로토콜, AWS SES 등의 방법중 SMTP 프로토콜 방식을 선택한 이유는 아래와 같습니다.
사용자에게 재요청 해달라는 방법은 제외했기 때문에, 실패한 메일을 재전송 해주거나 추가 보조메일 서버를 두어 실패 메일을 보내는 방법을 고려했습니다.
메일서버의 일시적 지연과 큰 장애 두가지 경우를 나눠서 생각했습니다.
일일 메일전송한도가 500개라는점 + 일시적인 지연이 발생하더라도 바로 보조메일 서버로 보내진다는 단점을 고려했을 때, 첫번째 메일서버에서 최대한 재시도 해주는 방법 선택을 선택했습니다.제가 선택한 방법도 물론 단점도 존재합니다. 이는 하단에 언급하겠습니다.
그렇다면, 전송 실패한 메일을 재시도 하기로 했으니 재시도 방법들을 고려해야 했습니다.
1. try-catch로 메일 전송 예외 잡아서 직접 재전송하기
2. 전송 실패한 메일들을 DB나 별도의 큐에 보관하여 스케줄링을 돌며 전송해주기
회원은 요청후 가입메일을 인증을 해야 서비스를 이용할 수 있기때문에 최대한 빠르게 메일을 받아야하는 상황이었습니다.
- 스케줄링서비스 + 메일을 저장하고 재전송해야할 인프라가 추가됩니다.
- 메일전송 실패가 발생할 빈도에 비해 해당 관리비용을 추가할 가치가 있을까?
- 메일전송을 바로 받을 수 있을것인가?
- 스케줄링 주기를 짧게하면 최대한 가능하겠지만 발생 가능성이 적은 일에 비해 낭비처럼 느껴짐
위 내용을 고려했을땐 SpringBoot Retry에 비해 메리트를 느끼지 못했습니다. 조금더 관리비용이 적은 SpringBoot Retry를 선택하게 됩니다.
3. SpringBoot Retry(결정)
2번 방법에 비해 훨씬 간단하고 비용이 적다고 판단했습니다. 재전송 횟수와 텀을 설정할 수있으며, retry 횟수를 소진하고 나면 후순위 작업으로 recover 로직도 작성할 수 있습니다.
https://jungseob86.tistory.com/12
https://aws.amazon.com/ko/blogs/architecture/exponential-backoff-and-jitter/
만약 동시에 요청이 몰린다면 똑같은 지수에 비례한 시간 간격으로 retry가 몰리게되어 문제가 생길수있습니다.
이 때문에 Jitter(지연변이)라는 개념이 등장하는데 간단히 말하면 Exponential Backoff로 인한 대기시간 + 랜덤 대기시간을 추가적으로 더해 동시에 retry가 몰리는것을 방지하고 분산시킬수있습니다.
스프링에선 retry를 편하게 사용할수있게 지원해줍니다.
@Retryable의 value들을 설정하여 Exponential Backoff+ Jitter의 개념을 흉내낼수있게 되었습니다
해당 이벤트에서 메일전송중 CustomMessagingException이 발생한다면 retry가 5번동작할것입니다.
@DisplayName("메일서버에서 예외발생시 retry가 최대 5회 동작한다")
@Test
void retry() {
// given
doThrow(CustomMessagingException.class)
.when(mailService)
.send(any());
// when
mailSentEventHandler.handle(event);
// then
// 조건이 true가 되거나 시간초과에 도달할때까지 기다리며 테스트에서 비동기작업을 처리/확인방법을 제공
await().timeout(TIMEOUT_FOR_TEST, TimeUnit.SECONDS).untilAsserted(() -> verify(mailService, times(5)).send(any()));
}
우리가 테스트해봐야할점은 정말 5번의 retry가 발생하는지, 그리고 retry간의 대기시간이 고정적이고 규칙적인지 확인해야합니다.
Before : 재전송을 위한 간격(s) 1-1-1-1
After : 재전송을 위한 간격(s) 1-3-5-14
Before : 재전송을 위한 간격(s) 1-1-1-1
After : 재전송을 위한 간격(s) 2-2-8-14
테스트는 성공적입니다. retry의 대기시간이 랜덤이고 고정적이지 않습니다. 이로인해 특정시간에 동시에 요청이 몰리더라도 적용하지않았을때보다 비교적 네트워크 트래픽을 줄일수 있게 되었습니다.
현재 retry의 최대시도횟수는 5회로 설정했습니다.만약 5회의 재시도를 시도했음에도 불구하고 메일전송에 실패한다면, 그때는 정말 사용자에게 가입 재요청을 요구해야할까요?
spring에선 이경우 복구로직을 작성할수있는 기능을 제공합니다.
retry가 끝났음에도 CustomMessagingException이 발생한다면 @Recover가 붙은 해당 메서드가 실행됩니다.
큰 장애가 나서 당분간 복구가 안되는 상황이라면, retry자체가 낭비가 될수있으며 쓸데없는 응답시간이 길어지는 상황이 발생할수있습니다. 이 경우를 고려해야한다면 장애 상황을 감지하고 retry를 끊어낼수 있는 방법에대해 학습할 수 있습니다.
Jitter라는 retry의 랜덤 재전송값을 부여하여 순서가 보장되질 않을 수 있습니다. 예를들어 요청1,2가 각각 4시 30분 20, 25초에 메일전송 요청이 들어오고, 요청에 실패해 4시 30분 40초, 4시 30분 37초에 retry될수 있습니다