좋아요 API의 동시성 문제와 Redis로 해결한 과정

RTUnu12·2025년 3월 15일
2

문제가 되는 좋아요 API

LikeRepository

@Repository
public class LikeRepository {
    @Qualifier("redisBooleanTemplate") // 특정 RedisTemplate을 지정
    private final RedisTemplate<String, Boolean> redisTemplate;

    public LikeRepository(RedisTemplate<String, Boolean> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 유저가 해당 게시글에 좋아요를 눌렀는지가 키가 됨. 값은 좋아요 여부
    private String getLikeKey(String userId, Long retrospectId) {
        return userId + ":" + retrospectId;
    }

    // 좋아요
    public void addLike(String userId, Long retrospectId) {
        redisTemplate.opsForValue().set(getLikeKey(userId, retrospectId), true); // 좋아요 상태 저장
    }

    // 좋아요 취소
    public void removeLike(String userId, Long retrospectId) {
        redisTemplate.delete(getLikeKey(userId, retrospectId)); // 좋아요 상태 삭제
    }

    // 특정 유저가 특정 게시글에 좋아요를 눌렀는지 확인
    public boolean isLiked(String userId, Long retrospectId) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(getLikeKey(userId, retrospectId)));
    }
}

LikeService

    @Transactional
    public boolean toggleLike(String userId, LikeRequestDto dto){
        Long retrospectId = dto.retrospectId();
        Member member = memberService.findById(userId);
        Retrospect retrospect = retrospectService.findById(retrospectId);
        if(likeRepository.isLiked(userId, retrospectId)){
            likeRepository.removeLike(userId, retrospectId);
            retrospectService.updateLikeCnt(retrospectId, false);
            return false;
        }
        likeRepository.addLike(userId, retrospectId);
        retrospectService.updateLikeCnt(retrospectId, true);
        alertService.sendMessage(member.getName() + "님이 " + retrospect.getTitle() + "에 좋아요를 눌렀습니다. [" + retrospectId + "]");
        return true;
    }

해당 코드에서 동시성 문제가 발생할 수 있는 부분은 다음과 같다.

  • 동일 객체인 retrospect의 좋아요 수를 수정한다는 것
  • Redis의 과부하로 인해 토글 기능이 꼬일 수 있다는 것

Jmeter 테스트 결과

  • 테스트 조건

    • 사용자 1000명
    • 같은 사용자가 해당 회고의 좋아요를 1000번 누름, 딜레이 없음
      • 즉, 다른 사용자가 좋아요를 1000번 누르는 것과 같은 효과
  • 결과
    image
    모든 요청들은 전부 성공, 하지만...
    image
    1000번의 좋아요 토글일 경우 like_count는 0이 되어야만 하지만, 결과를 보면 -8, -23등의 결과가 나와, 중간의 요청이 무시되는 결과가 나온다.
    image
    또한, 사용자의 좋아요 중복을 위해, 사용자ID:회고ID의 형식으로 Redis에 true에 저장되고, 좋아요 취소 시 이 값이 삭제되는데, 정상적인 경우라면 1000번이니 없어야하지만, 값이 남아있어 요청이 꼬여버리는 문제가 발생하게 된다.

리펙토링 과정

우선 해당 로직을 순서대로 나열해보면 다음 순서로 진행되는 것을 볼 수 있다.

1. 해당 회고와 유저ID로 이루어진 값(좋아요 여부)가 있는지 확인
2. 없다면 값을 추가하고 retrospect의 like_count 값을 +1한다.
3. 있다면 값을 지우고 retrospect의 like_count 값을 -1한다.
4. SQS로 메시지를 보낸다.

여기서 문제가 발생되는 경우는 DB에 객체를 수정할 때이다.
많은 사용자가 좋아요를 누를 때마다 DB에 UPDATE retrospect SET like_cnt = ?를 실행하면 트래픽이 증가하고, 이때 DB 성능이 저하, 동시에 높은 요청을 처리하기 어려워진다.
그래서 필자는 DB에 저장되어있던 like_count 칼럼을 없애고, 이에 대한 관리는 Redis에서 수행하기로 결정하였다.

왜 Redis인가?

  • DB일 경우에는 해당 경우로 인해 동시성 문제가 발생한다.
1. 두 개의 트랜잭션(TX1, TX2)이 동시에 실행
2. TX1이 SELECT like_count FROM retrospect WHERE id = 1; 실행 → like_count = 10
3. TX2도 같은 SELECT like_count FROM retrospect WHERE id = 1; 실행 → like_count = 10
4. TX1이 UPDATE retrospect SET like_count = 11 WHERE id = 1; 실행
5. TX2도 UPDATE retrospect SET like_count = 11 WHERE id = 1; 실행
>>> TX1과 TX2가 같은 값을 읽었기 때문에, TX2의 업데이트가 TX1을 덮어씌우면서 데이터가 꼬임
실제로는 like_count = 12여야 하지만, 11로 덮어씌워짐
  • Redis일 경우에는 이러한 사례에서 자유로운데, 싱글 스레드원자적 연산을 제공하기 때문이다.
    • 즉, 같은 like_count에 여러 요청이 들어와도, 하나씩 처리되기 때문에 동시성 문제가 발생하지 않는다.

코드

  • 즉, like_count에 대한 관리를 Redis로 옮기고, 원자적 연산을 수행하게 하면 해결되는 문제이다.

RedisConfig

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {

        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(redisProperties.getHost());
        configuration.setPort(redisProperties.getPort());
        configuration.setPassword(redisProperties.getPassword());

        return new LettuceConnectionFactory(configuration);
    }

    // RedisTemplate<String, Boolean> 등록
    @Bean
    public RedisTemplate<String, Boolean> redisBooleanTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Boolean> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }

    // RedisTemplate<String, Integer> 등록
    @Bean
    public RedisTemplate<String, Integer> redisIntegerTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
        template.setHashValueSerializer(new GenericToStringSerializer<>(Integer.class));
        
        template.afterPropertiesSet();
        return template;
    }

    // RedisTemplate<String, String> 등록
    @Bean
    @Primary
    public RedisTemplate<String, String> redisStringTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }
}
  • 유저의 해당 회고에 대한 좋아요 여부를 추가하는 String, Boolean 형식과 JWT 토큰을 저장하는 String, String 형식에 해당 회고에 대한 좋아요 수를 저장할 String, Integer 형식을 추가한다.

LikeRepository

@Repository
public class LikeRepository {
    @Qualifier("redisBooleanTemplate") // 특정 RedisTemplate을 지정
    private final RedisTemplate<String, Boolean> redisTemplate;

    @Qualifier("redisIntegerTemplate")
    private final RedisTemplate<String, Integer> redisCountTemplate; // 좋아요 개수 저장을 위한 RedisTemplate

    private static final String TOGGLE_LIKE_SCRIPT =
            "if redis.call('EXISTS', KEYS[1]) == 1 then " +
                    "   redis.call('DEL', KEYS[1]); " +  // 좋아요 취소
                    "   redis.call('DECR', KEYS[2]); " + // 좋아요 개수 감소
                    "   return 0; " +
                    "else " +
                    "   redis.call('SET', KEYS[1], '1'); " + // 좋아요 추가
                    "   redis.call('INCR', KEYS[2]); " + // 좋아요 개수 증가
                    "   return 1; " +
                    "end";

    public LikeRepository(RedisTemplate<String, Boolean> redisTemplate,
                          RedisTemplate<String, Integer> redisCountTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisCountTemplate = redisCountTemplate;
    }

    // 유저가 특정 회고에 좋아요를 눌렀는지 여부를 저장하는 키
    private String getLikeKey(String userId, Long retrospectId) {
        return "like:" + userId + ":" + retrospectId;
    }

    // 특정 회고의 총 좋아요 개수를 저장하는 키
    private String getLikeCountKey(Long retrospectId) {
        return "like:count:" + retrospectId;
    }

    /**
     * 좋아요 토글 (Lua 스크립트를 이용해 원자적 연산)
     */
    public boolean toggleLike(String userId, Long retrospectId) {
        String likeKey = getLikeKey(userId, retrospectId);
        String countKey = getLikeCountKey(retrospectId);

        Long result = redisTemplate.execute(
                new DefaultRedisScript<>(TOGGLE_LIKE_SCRIPT, Long.class),
                Arrays.asList(likeKey, countKey)
        );

        return result == 1;// 1이면 좋아요 추가, 0이면 좋아요 취소
    }

    /**
     * 특정 사용자가 특정 회고에 좋아요를 눌렀는지 확인
     */
    public boolean isLiked(String userId, Long retrospectId) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(getLikeKey(userId, retrospectId)));
    }

    /**
     * 특정 회고의 좋아요 개수 조회
     */
    public int getLikeCount(Long retrospectId) {
        Integer likeCount = redisCountTemplate.opsForValue().get(getLikeCountKey(retrospectId));
        return likeCount != null ? likeCount : 0;
    }
}
  • TOGGLE_LIKE_SCRIPT에서 볼 수 있듯이, 원자적 연산을 위해 DECR와 INCR을 좋아요 개수 증감에 사용하고, DEL과 SET으로 좋아요 여부를 Lua 스크립트로 관리한다.
  • 회고의 좋아요 수를 관리할 때, 키의 앞에 like:count를 추가하여 다른 충돌이 일어나지 않도록 한다.

LikeService

@Service
@RequiredArgsConstructor
public class LikeService {
    private final LikeRepository likeRepository;
    private final RetrospectService retrospectService;
    private final MemberService memberService;
    private final AlertService alertService;


    // 좋아요 토글 (추가 또는 취소)
    public boolean toggleLike(String userId, LikeRequestDto dto) {
        Long retrospectId = dto.retrospectId();
        Member member = memberService.findById(userId);
        Retrospect retrospect = retrospectService.findById(retrospectId);
        boolean result = likeRepository.toggleLike(userId, retrospectId);
        if(result) alertService.sendMessage(member.getName() + "님이 " + retrospect.getTitle() + "에 좋아요를 눌렀습니다. [" + retrospectId + "]");
        return result;
    }


    // 특정 사용자가 특정 회고에 좋아요를 눌렀는지 확인
    public boolean isLiked(String userId, Long retrospectId) {
        return likeRepository.isLiked(userId, retrospectId);
    }
}
  • 이제, DB에 과부하를 줄 만한 로직은 사라진 채, 모든 관리를 Redis에서 전담하는 방식으로 리펙토링한다.

결과

  • 이제 같은 조건(사용자 1000명)으로 다시 한번 테스트를 수행한다.
    image
    모든 요청은 성공 처리되었으며,
    image
    사용자의 요청 1000번에 맞추어 계속 토글되어 최종 좋아요 수는 정상적으로 0이 나오게 된다.
    image
    또한 1001번일 경우 해당 사용자의 회고에 대한 좋아요 여부가 저장되고, 좋아요 수는 1로 정상적으로 나오게 되어 동시성 문제를 해결할 수 있게 되었다.
    image
    이 좋아요 수는 Retrospect를 반환하는 DTO에서도 정상적으로 나올 수 있게 된다. (좋아요 수는 likeRepository의 getLikeCount()를 사용하였다.)
profile
이제 나도 현실에 부딪힐 것이다.

2개의 댓글

comment-user-thumbnail
2025년 3월 15일

좋은 글 잘 보고가요! 항상 화이팅 입니다!!👍👍👍🥺

1개의 답글