Redis 집계 알림 트러블슈팅

배고픈귤·2026년 2월 11일

Backend

목록 보기
10/11

1. 문제 상황

좋아요 알림을 구현하는 과정에서 다음과 같은 요구사항이 있었다.

좋아요가 연속적으로 들어올 경우, 매번 알림을 보내지 말고
일정 시간 동안 좋아요 개수를 집계한 뒤
“좋아요 n개가 추가되었습니다” 형태로 하나의 알림만 전송해야 한다.

이를 위해 Redis를 사용해 좋아요 수를 집계하고, 일정 시간 후 알림을 전송하는 구조를 만들었다.


2. Redis

Redis는 In-Memory 기반의 Key-Value 저장소이다.
이번 문제에서는 “일시적 좋아요 집계 저장소”로 활용하였다.

Redis의 특징

•	매우 빠른 읽기/쓰기
•	원자적 연산 지원 (INCR 등)
•	TTL(만료 시간) 설정 가능
•	분산 환경에서 상태 공유에 적합

이번 구조에서의 역할

•	게시글Id를 key로 사용
•	좋아요 발생 시 Redis에서 INCR
•	일정 시간 후 해당 key의 최종 count 조회
•	알림 전송 후 key 삭제

3. 기존 구현 방식

처음에는 @Async와 Thread.sleep()을 사용해 집계 시간을 구현했다.

@Async
public void scheduleLikePush(Long challengeId, Long ownerId) {
    try {
        Thread.sleep(AggregationDelays.CHALLENGE_LIKE_MS);

        Integer likeCount = likeRepo.get(challengeId);
        if (likeCount == null || likeCount <= 0) return;

        notificationService.sendChallengeLikePush(challengeId, ownerId, likeCount);
        likeRepo.clear(challengeId);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

동작 흐름

1.	첫 좋아요 발생
2.	@Async로 별도 스레드 실행
3.	Thread.sleep(5분)
4.	Redis에서 최종 좋아요 수 조회
5.	푸시 전송
6.	Redis key 삭제

4. 기존 방식의 문제점

1) 스레드 점유

Thread.sleep 동안 스레드는 실제로 아무 일도 하지 않고 대기만 한다. 결과적으로 이는 비효율적이고 트래픽이 증가할 경우 여러 스레드들이 대기 상태에 빠져 병목 현상으로 이어질 수 있었다.

2) 운영 안정성

만약 thread가 sleep 중에 서버가 재시작된다면 해당 thread가 종료되어 이후 로직이 제대로 실행되지 않을 수 있다. redis 내부에 집계 데이터는 처리도지 못한채로 남아있기 때문에 위험할 수 있다.


5. 해결-TaskScheduler 기반 지연 실행

Thread.sleep을 제거하고, Spring의 TaskScheduler를 사용해 예약 실행 구조로 변경했다.

Scheduler 설정

@Configuration
public class SchedulerConfig {

    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler ts = new ThreadPoolTaskScheduler();
        ts.setPoolSize(4);
        ts.setThreadNamePrefix("like-agg-");
        ts.initialize();
        return ts;
    }
}

서비스 로직 변경

public void scheduleLikePush(Long challengeId, Long ownerId) {
    Instant runAt = Instant.now()
        .plusMillis(AggregationDelays.CHALLENGE_LIKE_MS);

    taskScheduler.schedule(
        () -> flushLikePush(challengeId, ownerId),
        runAt
    );
}
private void flushLikePush(Long challengeId, Long ownerId) {
    Integer likeCount = likeRepo.get(challengeId);
    if (likeCount == null || likeCount <= 0) return;

    notificationService.sendChallengeLikePush(
        challengeId, ownerId, likeCount
    );
    likeRepo.clear(challengeId);
}

6. 달라진 점

이를 통해 스레드를 특정 시간동안 점유하던 구조에서 실행을 예약만 해두고 해당 시점이 되면 스레드 풀에서 스레드를 가져와 실행하는 방식으로 스레드 점유를 낮추고 리소스 효율을 개선하였다.

0개의 댓글