규칙 한 줄 고칠 때마다 전체 메일을 다시 분류했습니다 (Debounce, Rate Limit, Stale Skip 세 겹)

저장 버튼을 다섯 번 누르면 전체 재분류가 다섯 번 돕니다. 막는 지점을 진입, 발행, 소비 셋으로 나눈 이야기와, 그러고도 남은 경쟁 하나.

[배경 - 저장을 누를 때마다 큐가 부풀었다]

메일상자에는 라벨 규칙이 있습니다. “발신자에 @github.com 이 들어가면 개발 라벨” 같은 조건을 사용자가 직접 만들어요.

규칙을 저장하면 기존 메일에도 적용해야 합니다. 안 그러면 규칙을 만든 시점 이후 메일에만 라벨이 붙으니까요. 그래서 저장할 때마다 그 사용자의 전체 스레드를 다시 분류하는 작업을 큐에 넣었습니다.

문제는 사용자가 규칙을 한 번에 완성하지 않는다는 겁니다. 조건을 넣고 저장하고, 결과를 보고 고치고 저장하고, 또 고치고 저장해요. 1분에 다섯 번 저장하는 건 흔한 일입니다.

그때마다 전체 재분류가 발행됐습니다. 스레드가 3,000개면 배치 메시지가 60개씩(배치 크기 50 기준) 다섯 번, 총 300개가 큐에 쌓여요. 그중 앞의 240개는 이미 낡은 규칙으로 도는 작업입니다.

[문제 상황 분석 - 막을 지점이 하나가 아니었다]

Rate Limit만으로는 안 됩니다

처음 떠오른 건 요청 제한이었어요. 1분에 몇 번 이상 저장하면 막는 겁니다.

두 가지 이유로 부족했습니다.

첫째, 정상 편집을 막습니다. 규칙을 다듬느라 다섯 번 저장하는 건 비정상 사용이 아니에요. 여기서 429를 뱉으면 기능이 불편해집니다.

둘째, 이미 만들어진 작업을 못 지웁니다. 제한에 걸리기 전에 통과한 네 번의 요청은 이미 큐에 들어가 있어요. Rate Limit은 들어오는 걸 막지, 들어간 걸 되돌리지 않습니다.

낭비가 생기는 지점이 셋이었습니다

정리해보니 시점이 세 개였어요.

사용자 저장  ──①──>  발행  ──②──>  큐  ──③──>  컨슈머 처리
             진입              쌓임              소비

① 비정상적으로 잦은 요청이 들어오는 지점 ② 짧은 시간 안의 여러 요청이 각각 별도 작업으로 발행되는 지점 ③ 이미 낡은 작업이 큐에 남아서 처리되는 지점

하나의 장치로 셋을 다 막을 수 없습니다. 그래서 세 개를 따로 뒀어요.

[해결 방법 - 세 겹으로 나눈다]

1층. 진입에서 Rate Limit

비정상적인 반복만 걸러냅니다.

private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT = new DefaultRedisScript<>("""
        local count = redis.call('INCR', KEYS[1])
        if count == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return count
        """, Long.class);

INCR 과 EXPIRE 를 Lua로 묶었어요. 두 명령을 따로 보내면 INCR 직후 프로세스가 죽었을 때 TTL 없는 키가 영원히 남습니다. 그 사용자는 다시는 저장을 못 하게 돼요.

count == 1 일 때만 만료를 거는 것도 의미가 있습니다. 매번 걸면 요청할 때마다 창이 뒤로 밀려서, 계속 요청하는 사용자는 영원히 만료되지 않아요.

한도는 60초에 20회입니다.

private int debounceSeconds = 60;
private int schedulerFixedDelaySeconds = 10;
private int rateLimitWindowSeconds = 60;
private int rateLimitMaxRequests = 20;

20회로 잡은 이유는 정상 편집을 절대 막지 않기 위해서예요. 사람이 1분에 20번 넘게 규칙을 저장하는 건 손으로 하는 조작이 아닙니다. 이 층의 목표는 낭비를 줄이는 게 아니라 비정상 요청만 끊는 거예요.

거절할 때 남은 시간을 같이 줍니다.

if (count > labelDebounceProperties.getRateLimitMaxRequests()) {
    Long ttl = stringRedisTemplate.getExpire(key);
    long retryAfter = (ttl != null && ttl > 0) ? ttl : labelDebounceProperties.getRateLimitWindowSeconds();
    throw new LabelException(LabelErrorCode.LABEL_RECLASSIFY_RATE_LIMITED, retryAfter);
}

“나중에 다시 시도하세요” 만 주면 클라이언트가 언제 다시 시도할지 몰라 폴링합니다. 남은 초를 알려주면 정확히 그때 한 번만 부를 수 있어요.

2층. 발행 전에 Debounce

여기가 실제로 낭비를 줄이는 층입니다.

저장할 때 바로 발행하지 않고 사용자별 대기 작업에 합칩니다.

public void mergePendingJob(UUID userId, UUID labelId) {
    String key = PENDING_KEY_PREFIX + userId;

    String existing = (String) stringRedisTemplate.opsForHash().get(key, FIELD_LABEL_IDS);
    Set<String> labelIdSet = new HashSet<>();
    if (existing != null && !existing.isBlank()) {
        labelIdSet.addAll(Arrays.asList(existing.split(",")));
    }
    labelIdSet.add(labelId.toString());

    String merged = String.join(",", labelIdSet);
    String nowMs = String.valueOf(Instant.now().toEpochMilli());

    stringRedisTemplate.opsForHash().put(key, FIELD_LABEL_IDS, merged);
    stringRedisTemplate.opsForHash().put(key, FIELD_LAST_UPDATED_AT, nowMs);
    stringRedisTemplate.expire(key, PENDING_TTL);
}

핵심은 두 가지예요.

labelIds 는 합집합으로 쌓입니다. 사용자가 라벨 A, B, C를 연달아 고치면 대기 작업 하나에 세 개가 다 담겨요. 나중에 발행할 때 한 번에 처리됩니다.

lastUpdatedAt 은 매번 덮어씁니다. 이게 debounce의 본체예요. 저장할 때마다 타이머가 처음부터 다시 시작합니다.

발사는 스케줄러가 합니다.

@Scheduled(fixedDelayString = "${mailsangja.label.debounce.scheduler-fixed-delay-seconds}000")
public void tick() {
    List<LabelReclassifyPendingJob> jobs =
            pendingJobStore.getPendingJobsReadyToFire(labelDebounceProperties.getDebounceSeconds());
    // ...
}

10초마다 돌면서 마지막 갱신 후 60초가 지난 작업을 찾아 발행합니다.

[사용자 편집 흐름]
  0초   저장 → 대기 작업 생성, lastUpdatedAt = 0
  12초  저장 → 병합, lastUpdatedAt = 12
  25초  저장 → 병합, lastUpdatedAt = 25
  40초  저장 → 병합, lastUpdatedAt = 40
  ...
  100초 스케줄러 tick → 40 + 60 = 100 도달, 발행 (1회)

저장 4회가 발행 1회로 접힙니다. 그리고 발행되는 건 최신 규칙 기준이에요. 중간 상태로 도는 작업이 아예 안 생깁니다.

대가는 지연입니다. 편집을 멈춘 뒤 최대 60초, 스케줄러 주기까지 더하면 70초쯤 뒤에 재분류가 시작돼요. 이걸 받아들일 수 있었던 건 재분류가 즉시성이 필요한 기능이 아니기 때문입니다. 규칙을 저장한 사용자는 새 메일에 규칙이 적용되는 걸 바로 보고, 과거 메일 정리는 조금 늦어도 됩니다.

3층. 소비할 때 Stale Skip

2층까지 했는데도 구멍이 남습니다.

발행은 한 번이지만, 발행된 배치 메시지는 여러 개예요. 스레드 3,000개면 배치 60개입니다. 그 60개가 처리되는 동안 사용자가 또 규칙을 고칠 수 있어요. 그러면 새 작업이 발행되고, 큐에는 낡은 배치와 새 배치가 섞입니다.

그래서 발행할 때 라벨별로 최신 작업 ID를 Redis에 적어둡니다.

public void publish(UUID userId, Set<UUID> labelIds, String jobId) {
    for (UUID labelId : labelIds) {
        stringRedisTemplate.opsForValue().set(
                LATEST_JOB_ID_KEY_PREFIX + labelId,
                jobId,
                LATEST_JOB_ID_TTL
        );
    }
    // ... threadBatchSize 단위로 분할 발행
}

컨슈머는 처리 직전에 이걸 확인해요.

Set<UUID> targetLabelIds = message.labelIds().stream()
        .filter(labelId -> {
            String latestJobId = labelReclassifyJobStore.getLatestJobId(labelId);
            boolean stale = latestJobId != null && !latestJobId.equals(messageJobId);
            if (stale) {
                log.info("Stale reclassify job skipped for labelId={}: jobId={} latestJobId={}",
                        labelId, messageJobId, latestJobId);
            }
            return !stale;
        })
        .collect(Collectors.toSet());

if (targetLabelIds.isEmpty()) {
    log.info("All labels stale, skipping batch: jobId={} userId={}", messageJobId, userId);
    return;
}

메시지의 jobId 가 최신이 아니면 그 라벨은 건너뜁니다. 라벨 전부가 낡았으면 배치 자체를 통째로 넘겨요.

라벨 단위로 판정하는 게 중요합니다. 작업 단위로 하면, 라벨 A와 B를 함께 재분류하다가 A만 다시 수정했을 때 B까지 버려집니다. 라벨별로 보면 B는 그대로 처리돼요.

큐에서 메시지를 지우는 게 아니라 꺼내서 버린다는 점도 짚고 갈 부분입니다. RabbitMQ는 특정 메시지만 골라 삭제하는 기능이 없어요. 그래서 소비 자체는 하되 무거운 작업(DB 조회, 규칙 컴파일, 라벨 적용)을 건너뜁니다.

세 층이 각각 무엇을 막는가

층위치막는 것못 막는 것
Rate Limit진입비정상 반복 요청정상 편집으로 생기는 중복
Debounce발행 전짧은 시간의 여러 요청이 별도 작업이 되는 것발행 후 규칙이 또 바뀌는 경우
Stale Skip소비 시낡은 작업의 무거운 처리메시지 소비 자체

셋 다 필요했던 이유가 이 표에 있습니다. 각각이 못 막는 걸 다음 층이 받아요.

[성과 - 개선 전후 비교]

구조 변화는 이렇습니다.

항목개선 전개선 후
저장 N회 시 발행N회1회 (편집 종료 후)
발행되는 규칙 상태매 저장 시점최신 상태
큐에 남은 낡은 배치전부 정상 처리라벨 단위로 건너뜀
반복 요청 차단없음60초 20회
재분류 시작까지 지연즉시최대 약 70초

숫자로 적을 수 있는 건 여기까지예요. 실제로 큐 적재량이 얼마나 줄었는지는 측정하지 않았습니다.

[결론]

정리하면 이렇습니다.

  • 낭비가 생기는 지점이 여러 개면 장치도 여러 개여야 한다
  • Rate Limit은 비정상 요청을 끊고, Debounce는 정상 요청을 합친다. 역할이 다르다
  • 큐에서 메시지를 지울 수 없으면, 꺼내서 버리는 게 차선이다

한계를 적어둘게요. 첫 번째가 제일 큽니다.

첫째, mergePendingJob 에 경쟁이 있습니다. 같은 파일 안에서 Rate Limit은 Lua로 원자성을 확보해놓고, 정작 병합은 HGET 으로 읽고 HSET 으로 쓰는 구조예요.

저장 A: HGET labelIds → "L1"
저장 B: HGET labelIds → "L1"      ← A가 아직 안 썼다
저장 A: HSET "L1,L2"
저장 B: HSET "L1,L3"              ← L2가 사라졌다

사용자가 두 라벨을 거의 동시에 저장하면 하나가 유실됩니다. 그러면 그 라벨은 재분류가 안 돼요. 조용히 안 되는 종류의 버그입니다. Redis Set 자료구조를 쓰거나(SADD 는 원자적입니다) 병합도 Lua로 옮기면 해결되는데, 아직 안 고쳤습니다.

둘째, 고정 윈도우 Rate Limit이라 경계에서 두 배가 통과합니다. 창의 마지막 1초에 20회, 다음 창의 첫 1초에 20회를 보내면 2초 안에 40회가 통과해요. 슬라이딩 윈도우로 바꾸면 정확해지지만, 이 층의 목적이 “비정상만 끊기” 라 지금은 감수하고 있습니다.

셋째, 스케줄러에 분산 락이 없습니다. 인스턴스가 여러 대가 되면 같은 대기 작업을 동시에 발행할 수 있어요. Stale Skip이 뒤에서 일부를 걸러주긴 하는데, 같은 jobId 가 아니라 서로 다른 jobId 로 발행되니 중복이 그대로 남습니다.

넷째, SCAN 으로 대기 작업을 찾습니다. 10초마다 LabelReclassify:pending:* 패턴을 훑어요. 지금은 대기 작업이 적어서 괜찮지만, 동시 편집 사용자가 늘면 이 비용이 커집니다. 발사 예정 시각을 점수로 하는 ZSET을 쓰면 스캔 없이 꺼낼 수 있는데, 그때 가서 바꾸려고 미뤄뒀어요.

세 겹으로 막았다고 적었지만, 정작 제일 안쪽에서 값을 합치는 코드에 경쟁이 남아 있었습니다. 바깥을 정교하게 만드는 동안 안쪽을 다시 안 봤어요.