@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)));
}
}
@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;
}
해당 코드에서 동시성 문제가 발생할 수 있는 부분은 다음과 같다.
테스트 조건
결과
모든 요청들은 전부 성공, 하지만...
1000번의 좋아요 토글일 경우 like_count는 0이 되어야만 하지만, 결과를 보면 -8, -23등의 결과가 나와, 중간의 요청이 무시되는 결과가 나온다.
또한, 사용자의 좋아요 중복을 위해, 사용자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에서 수행하기로 결정하였다.
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로 덮어씌워짐
@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;
}
}
@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;
}
}
@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);
}
}
좋은 글 잘 보고가요! 항상 화이팅 입니다!!👍👍👍🥺