AWS SES 일일 50,000건 제한 Redis로 대응하기

아연·2025년 4월 10일
1

project

목록 보기
4/10
post-thumbnail

기능 소개

크레용은 메일 발신 기능을 제공한다.

지원자명, 면접 장소, 면접 일자 등 지원자 정보를 템플릿으로 구성해 한 번의 클릭으로 모든 지원자에게 커스텀 메일을 전송할 수 있다.

크레용은 AWS SES를 사용해 메일 전송 기능을 구현했다.

우리는 빈곤💸 대학생팀이기에(ㅎㅎ) 프리티어라는 작은 자원을 소중하게 사용해야 한다.

  • SES 전송량 한도: 24시간당 50,000건

내가 운영했던 동아리(Leets)를 기준으로, 지원자를 약 100명으로 가정해보자. 이 동아리가 나쁜 맘을 먹고 하루에 메일 전송을 500번 해버리면 다른 동아리는 메일 기능을 사용할 수 없다 ㅜㅅㅜ

그래서 나쁜 동아리가 메일 전송 기능을 독점하지 않도록 제한을 두고자 한다.

목표

  • 하루 총 메일 전송량이 50,000건을 초과하지 않도록 제한
  • 하루 동아리 메일 전송량이 300건을 초과하지 않도록 제한
  • 전송 요청마다 현재 남은 횟수를 실시간으로 체크
  • 24시간이 지나면 제한을 자동으로 초기화

기술 스택

  • Java 17, Spring Boot 3.1
  • Redis (Lettuce 클라이언트)
  • RedisTemplate

설계

1.Redis로 전송 횟수 상태 관리

Redis는 DB보다 빠르고 동시성에 강하며 TTL 설정이 가능하기 때문에 전송 가능 횟수를 저장하기에 적합하다고 판단했다.

SET global:email:total 50000 EX 86400
SET global:email:{clubId} 300 EX 86400
  • global:email:{}: 남은 전송 가능 횟수를 저장하는 키
  • TTL을 86400초(24시간)로 설정하여 자동 리셋

2. 요청마다 횟수 감소

Spring에서 RedisTemplate을 활용했다.

@Component
@RequiredArgsConstructor
public class MailRateLimiter {

    private static final String TOTAL_KEY = "global:email:total";
    private static final String CLUB_KEY = "global:email:";
    private static final Map<String, Long> LIMIT = Map.of(
            TOTAL_KEY, 50_000L,
            CLUB_KEY, 300L
    );
    private static final Duration TTL = Duration.ofHours(24);

    private final RedisTemplate<String, String> rateLimitRedisTemplate;

    public boolean isRateLimited(UUID clubId, int requestSize) {
        String clubKey = CLUB_KEY + clubId;

        if (exceededLimit(requestSize, clubKey)) {
            return true;
        }

        rateLimitRedisTemplate.opsForValue().decrement(TOTAL_KEY, requestSize);
        rateLimitRedisTemplate.opsForValue().decrement(clubKey, requestSize);
        return false;
    }

    private boolean exceededLimit(int requestSize, String clubKey) {
        long totalRemaining = getRemaining(TOTAL_KEY, LIMIT.get(TOTAL_KEY));
        long clubRemaining = getRemaining(clubKey, LIMIT.get(CLUB_KEY));

        return totalRemaining < requestSize || clubRemaining < requestSize;
    }

    private long getRemaining(String key, long amount) {
        String value = rateLimitRedisTemplate.opsForValue().get(key);
        if (value != null) {
            return Long.parseLong(value);
        }
        rateLimitRedisTemplate.opsForValue().set(key, String.valueOf(amount), TTL);
        return amount;
    }
}
  1. 키로 남은 전송 가능 횟수를 조회, 키가 없다면(만료되었다면) 리셋
  2. 총 혹은 동아리 한도를 초과했다면 false 반환
  3. 전송 가능하다면 요청 전송량만큼 감소 후 true 반환

마무리

이렇게 Redis를 활용하여 간단하게 요청 제한 기능을 구현할 수 있었다. 원래는 Bucket4j를 사용해서 요청에 대한 Rate Limit을 제한하려 했다. 그러나 일정 시간을 간격으로 요청에 제한을 둔다면, A 동아리가 메일을 전송함으로써 B 동아리가 일정기간 대기해야하는 상황이 발생할 것이라 판단했다.

그리고 아직 해결하지 못한 문제가 있다.

Redis는 단일 스레드 기반이라서 INCR, DECR는 atomic하게 동작하지만,
현재 Check-Then-Act 패턴을 적용하고 있다. 이로 인해 경쟁 상태가 발생할 수 있다.

다음에는 Redis에서 경쟁 상태를 방지할 수 있는 방법을 알아보고 적용해 봐야지

0개의 댓글