toy/AgileHub

[이슈 #2] 멤버 초대 이메일 발송 설계

EVO. 2024. 12. 23. 01:54

기능 요구사항

  1. 관리자는 이메일 주소를 입력하여 새로운 멤버를 초대할 수 있습니다.
  2. 초대 이메일은 지정된 템플릿 형식으로 발송되어야 합니다.
  3. 초대 링크는 10분간 유효합니다.
  4. 초대 링크는 1회만 사용가능합니다.
  5. 만료되거나 사용된 초대 링크는 더 이상 사용할 수 없습니다.

 

비기능 요구사항

  1. 초대 링크는 추측할 수 없는 안전한 토큰을 사용해야 합니다.
  2. 이메일 발송은 비동기로 처리되어야 합니다.
  3. 시스템은 초대 상태를 추적하고 관리할 수 있어야 합니다.

 

멤버 초대 플로우

프로젝트를 개설한 관리자는 본인 팀의 멤버들을 초대하기 위해 멤버 이메일을 작성하고 전송버튼을 누릅니다. 서버는 해당 이메일 받고, 그 이메일에 대한 초대토큰을 생성한 다음 템플릿에 담고 이메일 서비스를 사용해 해당 유저에게 이메일을 전달합니다. 

 

유저는 이 이메일을 열고, 링크를 누르면 해당 사이트로 이동합니다. 그리고 링크에 담겨있는 토큰을 서버에게 전달해서 아직 유효한지 검사를 받고, 유효할 경우 회원가입 페이지로 이동하여 유저는 가입을 진행한다음 관리자의 프로젝트 멤버로 자동 가입됩니다. 

 


💡 해당 시스템에선 멤버 초대 이메일은 단건 처리가 나을까, 대량 발송 방식이 나을까

이 시스템에서는 멤버 초대 이메일을 단건으로 처리하는 것이 적합하다고 판단했습니다.

 

먼저, 초대 워크플로우를 보면 관리자가 초대할 멤버의 이메일을 입력하고 즉시 초대하는 방식입니다. 대부분의 경우 소규모로 초대가 이루어지며, 초대에 대한 즉각적인 피드백이 중요한 상황이 많습니다.

 

💡 초대 코드 생성 포멧은 뭘로 할까 (토큰 포맷인 JWT vs 랜덤 문자열 생성 방식)

JWT는 토큰 자체에 유효기간, 초대한 프로젝트 ID, 초대된 이메일 등의 정보를 담을 수 있고, 서버에서 DB 조회 없이 토큰 검증이 가능한 장점이 있습니다. 또한, 서명을 통해 토큰의 무결성도 보장할 수 있습니다.

 

하지만 우리 시스템의 요구사항인 일회성 사용을 만족시키기 위해서는 토큰 사용 여부를 DB에 저장해야 합니다. JWT가 자체적으로 유효기간을 가지고 있더라도, 한번 발급된 토큰은 만료 전까지 계속 유효하기 때문에 블랙리스트 관리가 필수적입니다.

 

또한 JWT는 Base64로 인코딩되어 있어 누구나 디코딩이 가능하므로, 토큰이 탈취될 경우 이메일과 프로젝트 ID 같은 정보가 노출되고 해커가 초대받은 이메일로 먼저 가입할 수 있는 보안 위험이 있습니다.

 

반면 랜덤 문자열 방식은 토큰 자체로는 어떤 정보도 유추할 수 없어 보안성이 높습니다. 토큰 검증을 위해 항상 DB 조회가 필요하지만, 어차피 일회성 검증을 위해 DB를 사용해야 하므로 큰 단점이 되지 않습니다. 또한 토큰을 언제든 즉시 무효화할 수 있어 관리가 용이합니다.

 

따라서 JWT는 정보 포함과 무결성 보장이 장점이나 일회성 요구사항과 보안을 고려해 랜덤 문자열 방식을 선택하고 구현했습니다.

 

 

💡 초대 토큰 생성은 뭘로 할까 (UUID.randomUUID() vs SecureRandom)

 

1. SecureRandom 기반 커스텀 토큰

 

SecureRandom은 암호학적으로 안전한 난수를 생성할 수 있는 Java의 기본 클래스입니다. 이를 이용해 길이도 맘대로, 문자구성도 맘대로 커스텀 토큰을 만들 수 있습니다.

 

2. UUID 버전 4

UUID 버전 4는 16바이트의 고정된 길이와 내부적으로 SecureRandom을 사용하지만, 표준화된 형식과 검증된 안정성을 제공합니다. UUID의 경우 중복이 발생할 확률이 10억분의 1이라, 분산환경에서도 안전하게 사용할 수가 있습니다. 

 

 

3. 성능 차이

SecureRandom의 길이를 16,32,36,64로 증가하면서 각각 100만번 돌리면서, UUID를 사용했을 때 충돌개수와 생성시간, 메모리 사용량을 비교했습니다.

 

https://gist.github.com/minsang-alt/c76353772b58d0938ae21c701e046f74

 

UUID와 SecureRandom 성능차이

UUID와 SecureRandom 성능차이. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

그 결과 충돌은 둘 다 일어나지 않았고, 메모리 사용량은 당연히 UUID는 16바이트이기에 고정된 결과가 나오며, 생성시간은 UUID가 압도적으로 빨랐습니다. 

 

 

 

최종 선택: UUID 버전 4

다음과 같은 요구사항을 고려했을 때 UUID가 가장 적합하다고 판단했습니다.

  1. 토큰의 유효기간이 일주일로 짧음
  2. 삭제되는 데이터이므로 저장공간 문제가 크지 않음
  3. 프로젝트 가입에 사용되므로 중복 발생 시 심각한 문제 발생
  4. 특별한 형식이나 커스터마이징이 불필요
  5. 생성시간 짧음 

UUID는 이러한 요구사항을 모두 충족하면서도 다음과 같은 장점을 제공합니다.

  • 구현이 매우 간단
  • 충돌 가능성이 극히 낮음
  • 데이터베이스 인덱싱에 적합한 형식

 

💡 어떤 SMTP 릴레이 서비스를 사용할까

1. Gmail SMTP

프로토타입을 구축했을 땐 Gmail SMTP로 간단하게 구축했습니다. 하지만 사용했을 때 여러가지 문제점이 있었습니다. 

 

첫번째, 하루에 최대 500건씩만 발송할 수 있습니다. 이는 개발단계에선 적절하지만 실제 운영단계에서는 매우 부족한 수치입니다.

 

두번째, Google의 스팸 필터링 정책이 너무 엄격해 보낸 이메일이 쉽게 스팸 메일로 분류가 됩니다. 이는 다음과 같은 기술적 한계 때문입니다

 

  • SPF 레코드나 DKIM 인증이 제한적
  • 커스텀 도메인을 사용할 수 없어 이메일의 신뢰성이 떨어짐
  • IP 평판 관리가 불가능
  • 이메일 전송 상태 모니터링이 제한적

 

마지막으로, 간헐적으로 발송이 10분정도 걸립니다. gmail 끼리는 빨리 오지만, gmail -> naver로 메일이 전송될때는 10분이상 걸리며, 만들었던 템플릿도 다 깨져 나왔습니다.

 

이러한 문제들 때문에 Gmail SMTP는 개발 환경이나 프로토타입 단계에서만 사용하기로 결정하고 다른 기술을 찾아봤습니다.

 

2. AWS SES (Simple Email Service)

AWS Simple Email Service(SES)는 검증된 이메일 전송 서비스입니다. 1000건 당 약 0.10 달러라 매우 싸며, Route53과 통합하여 커스텀 도메인을 만들 수 있습니다.

 

또한, 메일 전송의 신뢰성 측면에서도 강점이 있습니다. ISP는 SES 서비스를 거친 이메일을 신뢰하게 되어 메일 도달 가능성이 높아지며, SPF 및 DKIM과 같은 인증 메커니즘을 지원하여 보안성도 확보됩니다.

 

또한 상세한 이메일 분석과 추적 기능을 제공하여 발송된 이메일의 상태를 모니터링하기 쉽습니다.

 

3. SendGrid

 

SendGrid는 Twilio가 서비스하는 클라우드 기반 이메일 서비스입니다. 개발자 친화적인 API와 직관적인 UI로 인해 많은 개발자들이 선호하는 서비스입니다.

 

주요 장점으로는 우선 직관적인 UI와 쉬운 API 통합을 들 수 있습니다. RESTful API와 잘 정리된 문서를 제공하여 개발자들이 쉽게 구현할 수 있습니다. 또한 풍부한 이메일 템플릿 기능을 제공하여 다양한 디자인의 이메일을 손쉽게 만들 수 있습니다.

 

이메일 발송 방식에서도 유연성이 높습니다. SMTP와 API 방식을 모두 지원하여 프로젝트 상황에 맞는 방식을 선택할 수 있으며, 상세한 이메일 통계와 분석 기능을 제공하여 이메일 캠페인의 성과를 정확하게 측정할 수 있습니다.

 

하지만 몇 가지 단점도 있습니다. AWS SES에 비해 상대적으로 가격이 높으며, 무료 티어는 일일 100건으로 제한됩니다. 또한 스팸 방지를 위한 정책이 다소 엄격하여 초기 설정에 주의가 필요합니다.

 

SendGrid는 다음과 같은 프로젝트에 특히 적합합니다

  • 이메일 마케팅 기능이 함께 필요한 경우
  • 상세한 이메일 분석이 필요한 경우
  • 개발 리소스가 부족하여 빠른 구현이 필요한 경우

 

최종선택: AWS SES

멤버 초대 이메일 서비스로 AWS SES를 선택했습니다.

 

우리 프로젝트의 멤버 초대 이메일은 템플릿이 정형화되어 있고 변경 가능성이 적습니다. SendGrid의 풍부한 템플릿 기능이나 마케팅 도구가 필요하지 않다고 판단했습니다.

 

그리고 AWS SES는 1000건당 약 0.10 달러로, 다른 서비스들에 비해 매우 저렴합니다. 초대 이메일의 특성상 많은 발송이 필요할 수 있어, 비용 측면에서 큰 장점이 있습니다.

 

마지막으로 이메일 신뢰성입니다. AWS SES는 SPF와 DKIM 같은 이메일 인증 메커니즘을 지원하고, ISP에서도 신뢰하는 서비스이기 때문에 초대 이메일이 스팸으로 분류될 가능성이 낮습니다.

 

Gmail SMTP 사용했을 때와 달리 Naver SMTP 쪽으로 보내는 메일도 금방 받을 수 있었습니다.

 

이러한 이유들을 고려했을 때, 단순하지만 안정적이고 비용 효율적인 AWS SES가 우리 프로젝트의 멤버 초대 시스템에 가장 적합한 선택이라고 판단했습니다.

 

💡 초대 코드는 어떤 DB에 저장할까

데이터 특성

초대 코드의 데이터 구조는 매우 명확합니다(초대토큰, 이메일, 만료일 등). 또한 프로젝트-초대-사용자 간의 관계가 있는 데이터입니다.

토큰은 UUID로 생성되어 중복될 확률이 매우 낮습니다. 다만 초대 링크가 1회만 사용 가능해야 한다는 요구사항이 있어, 토큰 사용과 멤버 추가가 하나의 원자적 단위로 처리되어야 합니다.

 

접근 패턴

초대 토큰의 유효성을 확인하는 조회는 빈번하지 않을 것으로 예상됩니다. 또한 초대 토큰을 생성하는 쓰기 작업도 상대적으로 적을 것으로 예상됩니다. 만료된 토큰의 경우 삭제가 필요합니다.

 

확장성

초대 데이터는 10분 후 만료되어 삭제되므로 데이터 증가율이 제한적입니다. 따라서 스케일 아웃의 필요성이 당장은 높지 않습니다.

 

Redis를 최종 선택한 이유

초대 코드의 유효기간이 10분으로 매우 짧기 때문에 Redis의 장점을 활용할 수 있습니다

  1. TTL 기능으로 만료 시간 자동 관리
  2. 인메모리 데이터베이스로 빠른 응답 속도
  3. 만료된 데이터 자동 삭제로 관리가 용이

다만 토큰 사용과 멤버 추가가 하나의 원자적 단위로 처리되어야 하기 때문에, MySQL과의 분산 트랜잭션을 위한 추가 처리가 필요합니다.

 

💡 로컬 캐시에 사용할까 Remote 캐시를 사용할까

초대 토큰은 생성 후 수정 없이 만료될 때까지 유지되는 일회성 데이터입니다. 따라서 네트워크 오버헤드가 없는 로컬 캐시가 성능면에서 유리할 수 있습니다. 하지만 서버가 2대 이상으로 증가하면 한 서버에서 생성된 토큰을 다른 서버와 공유해야 하는 복잡한 문제가 발생합니다. 이러한 데이터 동기화 이슈를 피하기 위해 원격 캐시를 선택했습니다.

 

💡 UUID를 키로 사용하면 너무 크기가 크다

현재 레디스에서는 이메일 초대토큰뿐만 아니라, 이슈 번호를 저장하고 있습니다. 그리고 레디스의 메모리 용량은 80프로 이내로 유지해야하기 때문에(넘어가면 스왑메모리를 사용하는 시도가 일어나고 성능저하가 일어날 것입니다) 최대한 메모리를 아껴야 할 필요가 있습니다.

 

키는 UUID, 값은 해시값인 이메일주소,프로젝트ID,생성시간을 저장하고 있으며 한 행이 추가될때마다 256바이트를 차지하고 있습니다. 그중 UUID는 문자열로 저장하고 있기 때문에 36바이트를 차지합니다. 또한 prefix로 11바이트가 추가되어 키의 총 용량이 47바이트입니다. 

 

따라서, 키를 Base64url로 36바이트인 UUID를 16바이트로 바꾸고 앞에 prefix를 한글자로 줄이고, 값은 불필요한 생성시간과 수신한 이메일을 제거하니 256바이트에서 120바이트로 줄였습니다.

 

💡 ThreadPoolTaskExecutor 설정은 어떤 기준으로 설정했나요

이메일은 오래 걸리는 작업이기도 하기에 비동기처리가 필요했습니다. 이때 Async 어노테이션을 사용했을 때 사용하는 스레드 풀에 대한 설정이 필요합니다.

 

동작방식은 다음과 같습니다.

1. 처음에는 새 작업이 들어올 때마다 corePoolSize까지 새 스레드를 생성합니다
2. corePoolSize에 도달하면, 그 이후의 작업들은 큐에 저장됩니다
3. 큐가 가득 차면(queueCapacity 도달), 추가 스레드를 생성하되 maxPoolSize를 넘지 않게 합니다
4. maxPoolSize까지 스레드가 생성되고 큐도 가득 찬 상태에서 새 작업이 들어오면 거부 정책이 실행됩니다

스레드 생명주기
- core 스레드들은 계속 유지됩니다(특별한 설정이 없다면)
- core 초과 스레드들은 일정 시간 동안 유휴 상태면 종료됩니다

 


1. Core Pool Size (20개)
   - 이메일 전송은 CPU 연산이 거의 없는 I/O 바운드 작업
   - 대부분의 시간이 SMTP 서버와의 네트워크 통신 대기
   - 따라서 CPU 코어 수(예: 8코어)보다 많은 20개의 스레드로 설정하여 동시 처리량 확보

2. Queue Capacity (100개)
   - 큐 크기가 너무 크면 작업이 오래 대기하여 응답 지연 발생
   - 적정 수준의 백로그 유지를 위해 100개로 설정
   - 큐가 가득 차면 MaxPoolSize까지 스레드 증가

3. Max Pool Size (40개)
   - 부하 상황에서 Core Pool Size의 2배까지 스레드 확장
   - 일시적인 트래픽 증가 대응

 

4. 거부정책은 CallerRunsPolicy로 이메일 발송 시, 작업이 버려지는 것을 방지하면서 시스템에 과부하가 걸리는 것을 막을 수 있도록 했습니다. 

5. 성능 검증
- Ngrinder를 통한 부하 테스트 진행

- 초당 10개 → 20개 → 30개로 점진적 증가
- 설정값의 적절성 확인

- 갑자기 많은 요청 (예: 초당 100건) 발생 테스트 (Queue가 가득 찼을 때의 동작 검증)

 

💡 이메일 서비스가 장애 상황일 때 어떻게 대처할까요

 

 

 

이메일 서비스는 외부 서비스이기 때문에 언제든 장애가 발생할 수 있습니다. 특히 사용자 입장에서는 서비스 장애를 인지할 수 없어 메일이 도착하지 않으면 계속해서 재시도를 할 수 있죠.

 

이러한 상황에 대처하기 위해 다음과 같은 방안들을 구현했습니다.

 

1. 상태 추적 시스템 구현

이메일 발송 상태를 명확하게 추적하기 위해 다음과 같은 상태값을 정의했습니다.

 

public enum InvitationStatus {
    PENDING,    // 초기 상태
    SENDING,    // 발송 중
    SENT,       // 발송 완료
    RETRY,      // 재시도 대기
    FAILED      // 최종 실패
}

 

2. Redis를 활용한 상태 관리

이메일 상태는 Redis에 저장하여 관리합니다. 이때 두 가지 키를 사용합니다.

  • i:{token}: 초대 코드 정보 저장
  • status:{email}: 이메일 발송 상태 저장
private void storeInvitationStatus(String email, InvitationStatus status) {
    String statusKey = STATUS_PREFIX + email;
    redisTemplate.opsForValue()
        .set(statusKey, status.name(), EXPIRATION_MINUTES, TimeUnit.MINUTES);
}

 

 

3. 중복 발송 방지

동일한 이메일에 대한 중복 발송을 방지하기 위해 상태를 체크합니다

 

private boolean hasActiveInvitation(String email) {
    String statusKey = STATUS_PREFIX + email;
    String status = (String) redisTemplate.opsForValue().get(statusKey);
    return status != null && (InvitationStatus.isPendingOrSending(status));
}

 

4. 비동기 처리 및 재시도 메커니즘

이메일 발송은 비동기로 처리하며, 실패 시 자동으로 재시도합니다.

 

@Override
@Async("emailExecutor")
@Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 1000)
public CompletableFuture<Void> sendEmail(String subject, Map<String, Object> variables, String... to) {
    return CompletableFuture.runAsync(() -> {
        // 이메일 발송 로직
    });
}

 

 

5. 외부 서비스 호출의 리드타임아웃과 커넥션 타임아웃 설정

외부서비스에서 커넥션을 맺는데 걸리는 시간에 대한 타임아웃과 처리하는데 걸리는 타임아웃을 추가적으로 설정해서 스레드가 오랜 시간동안 물고 있는 것을 방지하여 리소스를 낭비하지 않도록 했습니다.

 

    @Bean
    public AmazonSimpleEmailService amazonSimpleEmailService() {
        BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
        return AmazonSimpleEmailServiceClientBuilder.standard()
            .withRegion(Regions.AP_NORTHEAST_2)
            .withCredentials(new AWSStaticCredentialsProvider(credentials))
            .withClientConfiguration(new ClientConfiguration()
                .withConnectionTimeout(3000)
                .withSocketTimeout(5000)
                .withRequestTimeout(10000))
            .build();
    }

 

💡 @Async와 @Retryable 같이 사용할 때 주의할 점

# 프록시 순서 이해 

 

프록시 순서를 이해해야 합니다. 순서가 잘못되면 에러가 발생 시, 재시도로직이 발생되지 않고 혹은 블로킹이 발생할 수 있습니다.

 

@Async
@Retryable
public CompletableFuture<String> method() { ... }

 

 

이 둘의 순서는 Async가 우선순위가 높기 때문에, @Async 프록시가 먼저 실행되어 새로운 스레드에서 작업이 실행되고 그 다음 @Retryable 프록시가 재시도 로직을 처리합니다. 

 

// AsyncAnnotationBeanPostProcessor의 order
public static final int DEFAULT_ASYNC_ADVISOR_ORDER = Ordered.HIGHEST_PRECEDENCE + 2;

// RetryOperationsInterceptor의 order
private int order = Ordered.LOWEST_PRECEDENCE;

 

 

# 반환 타입 주의하기

 

// 잘못된 예시
@Async
@Retryable
public String wrongMethod() { ... }  // CompletableFuture로 감싸지 않음

// 올바른 예시
@Async
@Retryable
public CompletableFuture<String> correctMethod() { ... }

 

 

@Async 메서드는 CompletableFuture, Future, void 타입으로만 반환해야 합니다. String과 같은 일반 타입을 반환하면 실제 결과값을 기다려야 하므로 비동기 처리가 무의미해집니다.

 

# 예외처리 설계

 

발생한 예외에 대한 복구 로직 혹은 최종로직이 필요합니다. 따라서 @Recover을 사용하던가, 아니면 CompletableFuture을 반환하여 예외처리 로직을 구현해야합니다.

 

smtpService.sendEmail("AgileHub 초대 메일", variables, sendInviteMail.getEmail())
    .thenRun(() -> {
        storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.SENT);
        log.info("이메일 전송 완료");
    })
    .exceptionally(e -> {
        // 여기서는 모든 재시도가 실패한 후의 최종 실패 처리
        log.error("이메일 전송 최종 실패", e);
        storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED);
        return null;
    });