진행한 프로젝트 성능 테스트 중에 아래와 같은 오류 메시지를 보았습니다.
사용자 인증 메일은 gmail을 사용해서 전송하는데, gmail 사용 약관을 확인해보니 일반 계정은 하루 최대 500개의 전송 제한이 있는 것을 확인했습니다.
이 에러는 보낼 수 있는 메일의 허용 범위를 늘리면 해결이 가능할 것으로 생각됩니다. 하지만 여기서 확인해야 할 문제는 '메일 전송에 실패하면, 회원 가입이 취소가 된다.'입니다.
'인증 메일을 보낸다' 기능은 외부 API에 의존하고 있습니다. 외부 의존성은 제가 관리할 수 있는 요소가 아닙니다. SMTP 서버에 어떤 장애가 발생하거나 한도를 초과해서 요청이 거부될 수 있습니다. 하지만 이러한 문제가 제 서비스에 영향을 주어서는 안됩니다.
1. 고려 사항
1.1 요청이 거부될 경우 예외 처리
'간단하게 메일 전송 예외가 발생하면 따로 로직을 처리하면 되는 것이 아닌가?'라는 해결책이 떠올랐습니다. 하지만 이 방법은 다른 문제를 야기할 수 있습니다.
만약 외부 서비스가 너무 많은 요청이 몰려서 지연이 발생하면 어떻게 될까요? 위 그림 3에서 볼 수 있듯이 응답이 오는 동안 Blocking이 발생합니다. 동기적으로 동작하는 App은 SMTP의 응답이 오기 전까지 그대로 대기하게 됩니다.
하지만 우리가 사용하는 App도 요청 처리 개수가 제한이 있고 타임 아웃 시간이 정해져 있습니다. 즉, 외부 API가 지연이 발생하는 만큼 요청을 점유한 채 대기만 하는 상황이 발생하고 그 동안 다른 작업을 처리할 수 없는 상황이 발생합니다.
결국 근본적으로 문제를 해결할 수 없는 방법입니다.
1.2 비동기 메커니즘 사용하기
SMTP 서버가 요청을 제대로 받아서 처리하는 것은 저의 관심사 밖입니다. 주된 관심사는 '인증번호 전송'과 '인증 번호를 입력해서 인증 회원으로 전환'입니다.
그렇다면 굳이 SMTP 서버의 응답을 기다릴 필요가 있을까요? 요청을 보내기만 하고 다른 작업을 처리하면 대기 시간도 발생하지 않고 다른 요청도 처리할 수가 있습니다.
이처럼 현재 작업(스레드)이 요청 작업에 영향을 받지 않고 실행되는 것을 비동기라고 합니다. 이러한 메커니즘을 사용하기 위해서는 특정 신호를 통해서 처리 완료를 감지하거나 콜백을 통한 처리, 메시지 큐를 통한 처리 방법들이 있습니다.
1.2.1 Spring @Async 사용하기
Spring에서는 AOP를 사용해서 비동기적으로 동작할 수 있는 메커니즘을 제공합니다. 간단하게 기본 스레드, 최대 스레드, 큐 사이즈를 설정해서 비동기적 수행이 가능합니다.
하지만 맨 위 에러 메시지를 확인하면 'Try again later'라는 문구가 있습니다. 즉, SMTP 서버가 작업이 몰려 처리율을 제한하는 경우 어떻게 될까요?(서버에서 잠시 요청을 제한하는 경우. 다시 재시도할 경우 처리될 수 있음) 특별히 재시도에 대한 예외처리를 하지 않는다면 결국 메일 전송은 실패할 것입니다.
사용자가 인증 메일을 확인하는 과정은 즉각적인 반응을 요구하지 않습니다. 하지만 인증 메일은 반드시 재전송이 가능해야 합니다. 즉, SMTP 서버가 잠시 요청을 제한해도 계속해서 재요청이 가능해야 할 것입니다.
위 기능을 구현하기 위해서 @Async 메서드에서 예외가 발생할 경우 이를 처리할 로직이 필요할 것입니다. 하지만 '여러번 재요청이 필요할 경우 보다 효과적인 방법은 없을까?'를 고민해보았습니다.
1.2.2 메시지 큐 사용하기
메일 전송 요청을 큐에 담아두고 이것을 담당해서 처리하는 로직을 만든다면, 굳이 메일 전송 서비스를 복잡하게 설계할 필요가 없습니다. 또한 이 기능이 다른 곳에 필요할 경우 재사용이 가능할 것입니다.
위와 같이 메시지를 통한 프로세스, 프로그램끼리 데이터를 교환해 통신하는 방식을 메시지 큐라고 합니다. 이 개념을 구현한 kafka, RabbitMQ 등이 있습니다.
1인으로 진행하는 프로젝트 상황에서 위 도구를 학습해 적용하는 것은 시간적으로 부족할 뿐더러 제가 원하는 '회원 가입을 하면 메일이 전송된다'와 '메일 전송에 실패하면 재전송한다.', '회원가입 트랜잭션이 실패하면 메일 전송을 하지 않는다.', '메일 전송에 실패해도 회원가입 은 정상 처리 되어야 한다.'라는 기능을 구현하기엔 불필요한 도구라는 생각이 들었습니다.
1.2.2.1 Transactional outbox pattern
그러던 중 '한 포스트'에서 Transactional outbox pattern을 보게 되었습니다. 기존 메시지 큐가 트랜잭션과 함께 사용할 수 없다는 단점을 보안하기 위해 사용하는 패턴인데, 여기서 '데이터베이스 테이블을 하나의 큐로 활용한다.'라는 개념이 '이메일 전송 메시지와 회원 가입을 하나의 트랜잭션으로 관리할 수 있으면서 메시지 릴레이에서 메일 전송 로직을 담당'에 활용이 가능할 것으로 판단했습니다.
위 그림 6에서 볼 수 있듯이, 트랜잭션 안에서 'outbox 테이블'에 데이터를 넣고 'Background Woker(batch)'가 이를 가져와서 이메일 전송으로 넘기는 것이 가능할 것으로 예상했습니다.
최종적으로 outbox pattern을 적용해서 메일 전송 메시지를 발행하고 배치 작업을 통해서 메일을 전송하는 것으로 결정했습니다.
2. 구현
우선 메시지를 따로 규격화된 형태로 사용이 가능하도록 인터페이스와 클래스를 정의했습니다.
public interface Outboxable {
String getAggregateId();
String getAggregateType();
String getPayload();
String getType();
}
@Getter
@Setter
public class Event {
private Long id;
private String aggregateType;
private String aggregateId;
private String type;
private String payload;
private boolean deleted;
// ...
public static Event from(Outboxable outboxable) {
return Event.builder()
.aggregateType(outboxable.getAggregateType())
.aggregateId(outboxable.getAggregateId())
.type(outboxable.getType())
.payload(outboxable.getPayload())
.build();
}
}
public class UserCreatedEvent implements Outboxable {
private static ObjectMapper MAPPER = new ObjectMapper();
// ...
@Override
public String getAggregateId() {
return String.valueOf(userId);
}
@Override
public String getAggregateType() {
return User.class.getName();
}
@Override
public String getPayload() {
try {
return MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
log.error("failed to convert json to object");
throw new RuntimeException("객체 변환에 실패했습니다.");
}
}
@Override
public String getType() {
return this.getClass().getName();
}
}
다른 기능을 추가할 경우 Outboxable을 구현해서 메시지를 발행하면 됩니다.
@Service
public class SessionLoginService implements LoginService {
// ...
@Override
@Transactional
public void register(SignUpRequestDto dto) {
// ...
User user = SignUpRequestDto.toEntity(dto);
userRepository.save(user);
String uuid = UUID.randomUUID().toString();
// 회원 등록 후에 메시지를 발행한다. (register 트랜잭션에 포함됨)
emailVerificationService.sendRegistrationMail(user, uuid);
}
}
@Service
public class EmailVerificationService {
// ...
@Transactional
public void sendRegistrationMail(User user, String token) {
tokenRepository.storeToken(user.getEmail(), token);
eventService.publish(UserCreatedEvent.of(user, token));
}
// ...
}
위와 같이 회원가입과 메일 전송 메시지를 하나의 트랜잭션으로 관리할 수 있습니다.
발행된 메시지를 처리하는 배치 기능은 깃허브에서 자세히 확인할 수 있습니다.
3. 테스트
테스트용 계정으로 메일 전송을 확인하거나 mocking으로 기능을 테스트할 수 있지만, 로컬에 SMTP 서버를 설치해서 동작을 직접 확인하고자 MailHog를 사용하였습니다. MailHog는 Jim 라는 무작위로 요청을 거부하는 기능이 있습니다. 이것을 활용해 요청이 거부될 때 동작하는 것을 확인해 보았습니다.
구현한 기능에서 확인하고 싶은 것은 '회원 가입 후에 일부 요청이 SMTP 서버에서 거부가 되어도 최종적으로 회원가입 수와 인증 메일 수가 동일한가?' 입니다.
실험을 위해서 JMeter를 활용해서 200개의 요청을 보내보았습니다.
요청을 보낸 후 100개의 메일 전송을 확인했고 이후 최종적으로 200개의 메일이 전송되는 것을 확인했습니다.
4. 배포 환경에 적용해서 확인해보기
이제 메일 전송에 문제가 발생해도 회원가입에는 문제가 없도록 구현을 완료했고, 이 기능을 배포 환경에 적용해서 JMeter와 Pinpoint로 결과를 확인해 보았습니다.
많은 요청이 들어와도 SMTP의 서버 문제로 트랜잭션이 실패하지 않는 것을 확인했습니다. 또한 SMTP 서버의 응답을 대기하는 동안 발생하는 timeout 문제도 해결할 수 있었습니다.
5. 추가로 고민해볼 사항들
구현을 마치고 '과연 이 방식이 충분한 방식인가?', '이 방법을 재사용할 가치가 있는가?'를 고민해보았습니다.
우선 테이블이 1개 더 필요하기 때문에 관리할 항목이 증가하는 문제점이 있었습니다. outbox 테이블에도 데이터가 쌓일 것이고 단순히 방치할 경우 문제가 될 수 있다고 생각합니다.
또한 스프링에서 제공하는 @TransactionalEventListener라는 기능을 활용할 수 있습니다. 스프링 기술을 사용하는 것이기 때문에 스프링에서 제공하는 기능을 활용하는 것이 좋을 것이라 생각합니다.
참고자료
Sending Reliable Event Notifications with Transactional Outbox Pattern
ApplicationEventPublisher 기반으로 강결합 및 트랜잭션 문제 해결
'프로젝트 > 끄적끄적' 카테고리의 다른 글
Spring Batch 페이징 쿼리 성능 개선하기 (0) | 2023.01.04 |
---|---|
비밀번호 해시 함수 고민과 선택 (0) | 2022.09.25 |
Scale-Up과 Scale-Out 각 장단점은 무엇일까? (Session Storage, 세션 불일치) (0) | 2022.09.22 |
Jedis vs Lettuce 둘은 어떤 차이가 있을까? (POSIX - Select, File Descriptor) (0) | 2022.09.20 |