대규모 트래픽 처리를 위한 Redis 기반 '좋아요' 기능 구현

dev.hyjang·2025년 8월 28일

좋아요 기능에 Redis를 도입한 이유

기존에는 사용자가 '좋아요' 버튼을 클릭할 때마다 RDBMS의 특정 테이블에 UPDATE 쿼리를 실행하는 방식을 사용했습니다.

  • 장점: 구현이 단순하고 직관적
  • 단점:
    1) 좋아요 수를 보여주려면 항상 COUNT(*) 쿼리를 날려야 함
    2) 사용자가 많아질수록 조회 성능 저하
    3) 좋아요 버튼 클릭이 늘어나면 DB에 잦은 쓰기 부하 발생

=> 성능 병목(Performance Bottleneck): 디스크 기반의 RDBMS는 메모리 기반 스토리지에 비해 쓰기 작업의 비용이 높음. 트래픽이 집중될 경우, 잦은 I/O 작업으로 인해 DB에 과도한 부하가 발생하며 이는 전체 애플리케이션의 응답 시간 저하로 이어짐.

=> 확장성 한계: DB 커넥션 풀이 고갈되거나 테이블에 락(Lock)이 발생하여 다른 중요한 트랜잭션 처리를 방해할 수 있음.

이러한 문제를 해결하기 위해, 실시간 데이터 처리를 위한 In-Memory 데이터 저장소인 Redis를 도입하여 시스템 아키텍처를 개선하기로 결정했습니다.


Redis란 무엇인가?

Redis는 In-Memory 기반의 데이터 저장소로, 데이터를 메모리에 올려두기 때문에 DB보다 훨씬 빠른 속도로 읽고 쓸 수 있습니다.

  • Key-Value 형태로 데이터를 저장
  • 다양한 자료구조 지원 (String, List, Set, Sorted Set, Hash 등)
  • 빠른 속도: 밀리초 단위 응답
  • 캐시 & 실시간 데이터 처리에 강점

즉, 빠르게 바뀌고 자주 읽히는 데이터 관리에 특화된 저장소라고 할 수 있습니다.

Redis는 대규모 트래픽을 처리하는 서비스에서 사용하는 매우 효율적이고 표준적인 아키텍처입니다. 사용자가 '좋아요'를 누를 때마다 RDBMS에 직접 쓰기 작업을 하면 부하가 상당한데, 이를 Redis를 통해 비동기적으로 처리하면 다음과 같은 큰 이점을 얻을 수 있습니다.


Redis를 도입하면 좋은 이유?

(1) 빠른 좋아요 수 조회

  • 기존: SELECT COUNT(*) FROM USER_NEWS_LIKE WHERE news_id = ?
  • Redis: SCARD news:like:{newsId} (SET의 크기 조회) => O(1) 연산으로 즉시 결과 제공

(2) 중복 방지 및 상태 관리

순서가 없고, 중복된 데이터를 허용하지 않는 집합인 Set 자료구조를 사용해,

  • 새로운 사용자가 '좋아요'를 누르면: SADD 명령어로 그냥 집합에 원소를 추가
  • 기존 사용자가 '좋아요'를 취소하면: SREM 명령어로 그냥 집합에서 원소를 제거

특정 사용자가 좋아요 했는지 확인도 SISMEMBER로 즉시 확인 가능

(3) DB 부하 감소

  • 모든 요청을 DB까지 보내지 않고, Redis에서 처리
  • Redis는 메모리 기반이라 초당 수만~수십만 요청도 무난히 처리 가능

(4) 확장성과 실시간성

  • 좋아요 수는 실시간으로 변하는 데이터
  • 캐싱을 두지 않으면 매번 DB를 조회해야 해서 느려짐
  • Redis는 실시간 업데이트 + 실시간 조회에 최적화

(5) 장애 대응 전략

  • Redis는 메모리 기반이라 장애 시 데이터 유실 가능성 있음

  • 따라서 USER_NEWS_LIKE 테이블에는 기록을 유지하고,
    주기적으로 Redis의 데이터를 DB like_count와 동기화하는 방식을 채택
    => 안정성과 성능을 모두 확보

  • 응답성 향상: 사용자는 Redis에만 데이터가 기록되므로 '좋아요' 요청에 대한 응답을 즉시 받게 되어 UX가 향상됩니다.

  • DB 부하 감소: 빈번한 쓰기 작업이 DB가 아닌 메모리 기반의 Redis에서 처리되므로, DB는 더 중요한 읽기나 트랜잭션 처리에 집중할 수 있습니다.

  • 확장성: 트래픽이 증가하더라도 Redis 클러스터링 등을 통해 쉽게 확장할 수 있습니다.


Redis 활용한 대규모 트래픽 관리

전체적인 작업 흐름은 다음과 같습니다.

1. Redis 의존성 추가: build.gradle에 Spring Data Redis 라이브러리를 추가
2. Redis 접속 정보 설정: application.yml에 Redis 서버 접속 정보를 추가
3. 데이터 모델 및 매퍼 수정:
4. 서비스 로직 변경: NewsServiceImpl의 toggleLike 메소드가 DB 대신 Redis와 상호작용하도록 수정
5. 배치 스케줄러 생성: 주기적으로 Redis의 '좋아요' 수를 DB에 동기화하는 스케줄러 생성

아키텍처 설계

본 프로젝트는 Redis의 성능적 이점과 RDBMS의 데이터 영속성 및 일관성의 장점을 모두 취하는 하이브리드 아키텍처를 채택했습니다.

이점

  1. 실시간 처리 (Redis): 사용자의 '좋아요' 요청은 즉시 Redis에만 기록됩니다. DB 접근이 없으므로 사용자에게 매우 빠른 응답을 제공할 수 있습니다.

    • Redis의 Set 자료구조를 사용하여 news:like:{뉴스ID} 형태의 Key에 '좋아요'를 누른 사용자ID를 저장합니다. Set은 중복된 원소를 허용하지 않으므로, 한 사용자가 중복으로 '좋아요'를 누르는 것을 원천적으로 방지합니다.
  2. 비동기 동기화 (Scheduler -> RDBMS): Spring Scheduler를 이용한 배치 작업이 주기적으로 실행됩니다.

    • 스케줄러는 Redis에 저장된 모든 '좋아요' 키를 조회하여, SCARD 명령어로 각 뉴스의 최종 '좋아요' 개수를 집계합니다.
    • 집계된 최종 결과만을 RDBMS(PostgreSQL)의 like_count 컬럼에 UPDATE 합니다.
  3. 상태 기반(State-based) : Redis와 스케줄러는 "변경 내역(Delta)을 추적" 하는 것이 아니라, 매번 "전체 상태(Full State)를 기준으로" 동작합니다.

    • 로직이 매우 간단하여 버그가 발생할 확률이 적습니다.
    • 중간에 스케줄러가 실패하더라도 다음 스케줄러 동작에서 동기화가 가능합니다(멱등성 보장)
    • Redis에서 SCARD(전체 개수 세기) 속도가 매우 빨라 성능이 좋습니다.

이를 통해 빈번한 쓰기 작업을 Redis가 흡수하도록 하여 DB 부하를 최소화하고, 스케줄러를 통해 데이터의 최종 일관성을 보장합니다.

Redis (Set)

  • '좋아요'를 누른 사용자 목록을 실시간으로 관리
  • SADD (추가), SREM (삭제), SISMEMBER (멤버 확인)
  • 현재 누가 '좋아요'를 누르고 있는가? (상태)를 확인

스케줄러

  • Redis와 DB를 연결하는 데이터 동기화 파이프라인
  • Redis에서 SCARD (개수 세기), DB에 UPDATE (집계 결과 저장)
  • 특정 시점의 '좋아요' 개수를 집계
  • 이때 스케줄러는 "지난번 동기화 이후에 새로 추가된 데이터만 찾아서 더한다"는 식으로 복잡하게 동작하지 않음
  • 스케줄러가 동작하는 "현재" Redis에서 데이터를 전체 카운팅해서 DB를 덮어쓰는 식으로 진행

RDBMS

  • 집계된 '좋아요' 수를 저장하는 캐시 또는 요약 정보
  • 주요 연산: SELECT (뉴스 목록과 함께 '좋아요' 수를 빠르게 조회)
  • 데이터의 의미: 특정 과거 시점에 집계된 '좋아요' 총 개수

구현 상세

1. 환경 설정: 의존성 및 설정 추가

  • Redis 서버를 도커로 설치하고 실행합니다.

  • build.gradle 파일에 Spring Data Redis 의존성을 추가합니다.
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

다음으로 application.yml에 Redis 서버 접속 정보를 추가합니다.

# application.yml
spring:
  # ... datasource 등 기존 설정
  data:
    redis:
      host: localhost
      port: 6379

2. 데이터베이스 및 모델 수정

RDBMS의 NEWS_INFO 테이블에 '좋아요' 수를 저장할 컬럼을 추가합니다.

ALTER TABLE NEWS_INFO
ADD COLUMN like_count INT NOT NULL DEFAULT 0;

News 엔티티와 프론트엔드로 데이터를 전달할 NewsResponseDto에도 likeCount 필드를 추가합니다.

@Data
@NoArgsConstructor
public class News {
    private int likeCount; // 좋아요 수
}

// NewsResponseDto.java
@Data
public class NewsResponseDto {
    private int likeCount;
}

3. Redis 설정 클래스 추가

RedisTemplate을 프로젝트 전반에서 편리하게 사용하기 위해, Key와 Value를 문자열로 직렬화하는 설정 클래스를 만듭니다.

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }
}

4. MyBatis 매퍼 수정

스케줄러가 DB에 '좋아요' 수를 업데이트할 수 있도록 NewsMapper.xml에 update 구문을 추가하고, 뉴스 목록 조회 시 like_count를 포함하도록 수정합니다.

<mapper namespace="com.ccp.simple.mapper.NewsMapper">
    <update id="updateLikeCount">
        UPDATE NEWS_INFO
        SET like_count = #{likeCount}
        WHERE news_id = #{newsId}
    </update>
</mapper>

NewsMapper.java 인터페이스에도 해당 메소드를 추가합니다.

@Mapper
public interface NewsMapper {
    void updateLikeCount(@Param("newsId") Long newsId, @Param("likeCount") int likeCount);
}

5. 서비스 로직 변경: DB 대신 Redis와 통신

NewsServiceImpl의 toggleLike 메소드가 DB 대신 Redis와 통신하도록 수정합니다.

    @Override
    @Transactional
    public boolean toggleLike(String userId, Long newsId) {
        String likeKey = "news:like:" + newsId;
        Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId);

        if (Boolean.TRUE.equals(isMember)) {
            // Redis 좋아요 취소
            redisTemplate.opsForSet().remove(likeKey, userId);
            newsMapper.deleteLike(userId, newsId);

            String countKey = "news:like_count:" + newsId;
            redisTemplate.opsForValue().decrement(countKey);

            return false;
        } else {
            // Redis 좋아요 추가
            redisTemplate.opsForSet().add(likeKey, userId);
            newsMapper.insertLike(userId, newsId);

            String countKey = "news:like_count:" + newsId;
            redisTemplate.opsForValue().increment(countKey);

            return true;
        }
    }

6. DB 동기화 스케줄러 생성

주기적으로 Redis의 '좋아요' 수를 DB에 동기화할 스케줄러를 만듭니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeCountSyncScheduler {

    private final RedisTemplate<String, String> redisTemplate;
    private final NewsService newsService;

    // 10분마다 실행
    @Scheduled(cron = "0 */10 * * * *")
    public void syncLikeCountsToDb() {
        log.info("Redis '좋아요' 수 DB 동기화 시작");
        Set<String> likeKeys = redisTemplate.keys("news:like:*");

        if (likeKeys == null || likeKeys.isEmpty()) {
            log.info("동기화할 '좋아요' 데이터가 없습니다.");
            return;
        }

        for (String key : likeKeys) {
            try {
                Long newsId = Long.parseLong(key.split(":")[2]);
                Long likeCount = redisTemplate.opsForSet().size(key);
                if (likeCount != null) {
                    newsService.updateLikeCount(newsId, likeCount.intValue());
                }
            } catch (Exception e) {
                log.error("키 '{}' 처리 중 오류 발생: {}", key, e.getMessage());
            }
        }
        log.info("Redis '좋아요' 수 DB 동기화 완료");
    }
}

7. API 컨트롤러 수정

NewsController에서 toggleLike 서비스를 호출하고 인증된 사용자 정보를 넘겨줍니다.

    @PostMapping("/news/{newsId}/like")
    public ResponseEntity<Map<String, Object>> toggleLike(@PathVariable Long newsId, Authentication authentication) {
        String userId = authentication.getName();
        boolean isLiked = newsService.toggleLike(userId, newsId);

        Map<String, Object> response = new HashMap<>();
        response.put("isLiked", isLiked);
        response.put("message", isLiked ? "좋아요를 눌렀습니다." : "좋아요를 취소했습니다.");

        return ResponseEntity.ok(response);
    }

사용자가 좋아요 후 새로고침을 했을 때 Redis의 정보와 DB 불일치를 방지하기 위해 본인의 좋아요 정보를 Redis에서 조회하여 표출합니다.

@Override
    public List<NewsResponseDto> getAllNews() {
        String userId = null;
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated()) {
            userId = authentication.getName();
        }

        List<NewsResponseDto> newsList = newsMapper.getAllNews();

        for (NewsResponseDto news : newsList) {
            String likeKey = "news:like:" + news.getNewsId();

            // Redis에서 실시간 좋아요 개수 가져오기
            Long likeCount = redisTemplate.opsForSet().size(likeKey);
            news.setLikeCount(likeCount != null ? likeCount.intValue() : 0);

            // 현재 사용자가 좋아요를 눌렀는지 확인
            if (userId != null) {
                Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId);
                news.setLiked(Boolean.TRUE.equals(isMember));
            } else {
                news.setLiked(false); // 로그인하지 않은 사용자는 항상 false
            }
        }
        return newsList;
    }

테스트 진행

1. 좋아요 토글 API 테스트

2. Redis 데이터 직접 확인

C:\Users\hyjan>docker ps
CONTAINER ID   IMAGE     COMMAND                   CREATED        STATUS          PORTS                                         NAMES
bd7980a61b9a   redis     "docker-entrypoint.s…"   17 hours ago   Up 11 minutes   0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp   simple-redis

C:\Users\hyjan>docker exec -it simple-redis redis-cli
127.0.0.1:6379> SMEMBERS news:like:1
(empty array)  <= 좋아요 취소 했을 때
127.0.0.1:6379> SMEMBERS news:like:1
1) "aaa" <= 좋아요 했을 때
127.0.0.1:6379> keys *
1) "news:like:1" <= 저장된 모든 키 목록 확인
  • '좋아요'를 했을 때 로그인 사용자의 아이디를 반환
  • '좋아요'를 취소했을 때 로그인 사용자의 아이디가 비어있음(empty)을 출력
  • 저장된 모든 키 목록 확인
    news → 도메인/리소스 이름 (뉴스)
    like → 동작/속성 (좋아요)
    1 → 특정 엔티티의 ID (뉴스 ID = 1)

3. DB 동기화 스케줄러 확인

  • 5개의 아이디로 좋아요를 누른 후 redis 서버에만 집계
  • 디비 UPDATE 스케줄러 동작
  • 디비 UPDATE 된 결과 확인

결론

Redis-스케줄러를 활용한 하이브리드 아키텍처 도입을 통해, 한 번에 많은 요청이 발생하는 '좋아요' 기능을 RDBMS의 부하 없이 안정적이고 확장성 있게 구현할 수 있었습니다.

이 접근 방식은 실시간 랭킹, 조회수 집계 등 유사한 요구사항을 가진 다른 기능에도 동일하게 적용될 수 있으며, 대규모 트래픽을 처리해야 하는 작업에서 유용하게 활용될 수 있습니다.

profile
낭만감자

0개의 댓글