Redis 유무에 따른 좋아요API 성능 테스트(redis, k6, spring scheduler)

dongwoo you·2025년 6월 19일
0

myboard-dev-log

목록 보기
7/8
post-thumbnail

Redis 도입 전

기존 게시글 좋아요 API는 RDB(관계형 데이터베이스)에 대한 직접적인 I/O 작업을 통해 좋아요 상태를 토글하는 방식으로 구현되어 있었습니다.

기존 좋아요 API 로직

public void likeArticle(Long articleId, Long userId) {
    Article article = articleRepository.find(articleId)
                .orElseThrow(() -> new EntityNotFoundException("Article not found"));
    boolean exists = articleLikeRepository.existsByArticleIdAndUserId(articleId, userId);

    if (exists) {
        articleLikeRepository.deleteByArticleIdAndUserId(articleId, userId);
        article.unlike();
    } else {
        ArticleLike like = ArticleLike.create(articleId, userId);
        articleLikeRepository.save(like);
        article.like();
    }
}

이 로직은 좋아요의 존재 여부를 확인하고, 존재하면 삭제, 없으면 새로 생성하여 즉시 RDB에 저장합니다. 이러한 방식은 락 경합 및 동시성 이슈 발생 가능성이 높다고 판단했습니다.

기존 로직의 문제점:레이스 컨디션

@Entity
@Table(
	name = "article_likes",
	uniqueConstraints = @UniqueConstraint(
		name = "uk_article_like_article_user",
		columnNames = {"article_id", "user_id"}
	)
)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ArticleLike

DB에 UNIQUE(article_id, user_id) 제약 조건이 설정되어 있더라도, 동시에 두 요청이 들어올 경우 다음과 같은 레이스 컨디션이 발생할 수 있을 것 같았습니다.

  • A, B 두 스레드가 거의 같은 순간에 "해당 좋아요가 존재하지 않는다"고 판단하여 둘 다 삽입 로직을 실행한다면, 이 경우 둘 중 하나가 먼저 DB에 레코드를 넣고, 나머지는 제약 위반으로 예외가 발생하여 테스트에서 실패 케이스로 기록될 수 있습니다.
  • 반대로 이미 좋아요가 있을 때, 동시에 두 스레드가 "존재한다"고 판단하여 둘 다 삭제를 시도할 경우, 하나는 "삭제 대상이 없음"으로 실패할 수 있습니다.

이러한 문제점을 확인하기 위해 K6 테스트 툴을 사용하여 성능을 측정했습니다.
(K6는 JMeter와 유사한 부하 테스트 툴로, 최근 테스트 진영에서 점유율이 증가하고 있어 선택하게 되었습니다.)

테스트 스크립트

export let options = {
    scenarios: {
        baseline: {
            executor: 'constant-arrival-rate',
            rate: 20,
            timeUnit: '1s',
            duration: '10m',
            preAllocatedVUs: 30,
            maxVUs: 100,
        },
        spike: {
            executor: 'constant-arrival-rate',
            rate: 100,
            timeUnit: '1s',
            duration: '1m',
            startTime: '10m',      // baseline 종료 시점
            preAllocatedVUs: 100,
            maxVUs: 200,
        },
    },
};

로그인 인증 및 인가 과정은 제외한 핵심 테스트 구성 인자들입니다.
아래의 테스트들은 해당 스크립트를 통해 진행한 테스트입니다.

Redis 도입 전 성능 테스트 결과

k6 테스트

baseline test: 16:05 ~ 16:15
spike test: 16:15 ~ 16:16

그리 크지 않은 부하의 스파이크 테스트임에도 불구하고 0.01%의 동시성 이슈가 발생하여 실패했습니다.

  • Throughput: 27.3 req/s
  • 최대 지연: 384ms (DB 직행으로 인한 I/O 또는 락 대기 타임이 튀는 구간이 있었을 것으로 예상됩니다.)
  • 에러(0.01%) 동시성 토글 충돌이 남아있어 캐시 계층으로 제거가 필요하다고 판단했습니다.
  • p(95): 14.69ms

스프링 서버 모니터링

  • HikariCP 풀 사이즈(10) 대비 여유가 있었으나, 스파이크 발생 시 순간적으로 8개의 커넥션이 사용되었습니다.
  • 프로세스 평균 CPU 사용률은 0.75%, 피크 시 4.78%를 기록했습니다. 부하 순간에도 CPU는 여유가 있었던 것으로 추정됩니다.

MySQL 모니터링

  • External_lock Handlers 지표에서 락 대기 및 경합 발생량이 많아 동시성 취약점이 확인되었습니다.

  • MySQL Questions 지표는 안정 상태에서 180/s, 스파이크 시 850/s를 기록했습니다.

Redis 도입 후

위에서 확인된 동시성 문제와 RDB 부하를 개선하기 위해 Spring Boot와 RDB 사이에 Redis NoSQL 서버를 도입하기로 결정했습니다.
기존에도 Spring Redis를 사용하고 있었기에, 동일한 프레임워크를 채택하는 것이 더욱 유용하다고 판단했습니다.

좋아요 개수를 레디스에서 따로 뽑아 게시글 상세조회 등 API에 게시글 객체와 더해 반환하도록 수정하였습니다.
따라서 Article 도메인에서 likeCount 필드를 제거하였습니다.

public ArticleDetailResponse find(Long id) {
		Article article = articleQueryService.find(id);
		long likeCount = articleLikeService.getLikeCount(id);

		return ArticleDetailResponse.from(article, likeCount);
	}
}

이후 좋아요를 각 게시글 조회시마다 게시글 ID를 통해 redis에서 따로 조회하여 반환하도록 수정하였습니다.

Redis 도입 후 좋아요 API 로직

public void likeArticle(Long articleId, Long userId) {
    articleRepository.find(articleId)
            .orElseThrow(() -> new EntityNotFoundException("Article not found"));

    String key = KEY_PREFIX + articleId;
    BoundSetOperations<String, String> ops = redisTemplate.boundSetOps(key);

    boolean added = ops.add(userId.toString()) == 1;
    if (!added) {
        ops.remove(userId.toString());
    }
    likeChangeQueue.enqueue(new LikeChange(articleId, userId, added));
}

public long getLikeCount(Long articleId) {
    String key = KEY_PREFIX + articleId;
    return redisTemplate.opsForSet().size(key);
}

즉, 이제 좋아요 API 호출시 RDB에 직접 쓰기 작업을 하지 않고, redis 에 적재한 후, likeChangeQueue 에 저장한 후, 스프링 스케줄러를 통해 정해진 시간마다 db에 batch 쓰기 작업을 진행합니다.

@Component
public class LikeChangeQueue {
	private final BlockingQueue<LikeChange> queue = new LinkedBlockingQueue<>();

	public void enqueue(LikeChange likeChange) {
		queue.add(likeChange);
	}

	public List<LikeChange> drain() {
		List<LikeChange> list = new ArrayList<>();
		queue.drainTo(list);

		return list;
	}
}

위 blockingQueue 에 대한 설명과 drain 메서드 설명 추가

@Component
@RequiredArgsConstructor
public class ArticleLikeFlushJob {
	private final LikeChangeQueue likeChangeQueue;
	private final ArticleLikeRepository articleLikeRepository;

	@Scheduled(fixedDelayString = "${like.flush.interval:60000}")
	@Transactional
	public void flush() {
		for (LikeChange lc : likeChangeQueue.drain()) {
			if (lc.added()) {
				articleLikeRepository.insertIgnore(lc.articleId(), lc.userId());
			} else {
				articleLikeRepository.deleteByArticleIdAndUserId(lc.articleId(), lc.userId());
			}
		}
	}
}

위 스케줄러에 정해진 시간마다 rdb로 flush() 작업 진행

Redis 도입 후 성능 테스트 결과

k6

  • 0.1% 동시성 에러 해결
  • 최대 지연 384 -> 445.44ms 왜늘엇지?
  • p(95): 14.69 -> 13.98ms
  • http reqs : 요청 처리량? 27.35/s 로 비슷

스프링 서버 모니터링

  • HikariCP 풀 사이즈(10) 대비 여유가 있었으며, 스파이크 시에도 커넥션 사용량에 큰 변화가 없었습니다. 왜 없지?

  • CPU 사용률은 평균 1.68%, 피크 시 6.87%로 이전보다 CPU 부하가 약간 증가했습니다. 이는 Redis와의 통신 및 관련 로직 처리로 인한 것으로 예상됩니다.

MySQL 모니터링

  • External_lock Handlers 스파이크 테스트 시 1100에서 400으로 약 60% 감소하여 락 경합이 크게 줄어들었음을 보여줍니다. 왜 감소했지?

  • MySQL Questions는 안정 상태 180/s에서 140/s로, 스파이크 시 850/s에서 700/s로 감소했습니다. 이는 Redis 도입으로 RDB에 대한 직접적인 I/O 작업이 줄어들었음을 의미합니다.

결론

Redis 도입을 통해 좋아요 API의 동시성 문제가 성공적으로 해결할 수 있었고, RDB의 락 경합 및 부하가 상당 부분 완화되었음을 확인할 수 있었습니다. 서비스의 안정성과 성능 향상에 기여할 수 있는 아키텍쳐를 구성하였습니다.

이후에는 비동기 메세징 큐를 도입하여 레디스 서버의 유실 방지 및 스프링 이벤트 드리븐 구조를 도입할 예정입니다

profile
꾸준함 빼면 시체

0개의 댓글