[Spring + Redis] 레디스로 게시글 조회수 중복 카운팅 검증하기

2022. 7. 27. 17:01JAVA

 

개인 프로젝트로 간단한 게시판을 만들고 있습니다.

누구나 하는 주제라서 쉽게 생각하고 덤볐다가 아주 고생하고 있습니다. ㅎㅎ

 

작성한 기능 중, 게시글을 조회하면 조회수가 하나 올라가는 기능이 있습니다. 

public PostResponse findOne(Long postId) {
    Post post = postRepository.findByIdWithUser(postId).
            orElseThrow(() -> new IllegalArgumentException("해당하는 postId가 없습니다. 잘못된 입력"));
    int recommendCount = postRecommendRepository.countByPostId(postId);
    post.increaseHitCount();
    List<Comment> comments = commentService.findByPostId(postId);
    return PostResponse.of(post, comments, recommendCount);
}

postId를 입력받아 간단한 검증을 한 후, JPA의 더티 체킹으로 게시글의 조회수를 하나 상승시킵니다.

이 코드에서는 어느 유저가 몇 번의 요청을 보내든 무한히 조회수를 상승시킬 수 있다는 점이 문제였습니다.

 


 

중복 요청을 어떻게 검증할 것인가 고민을 해봤습니다.

 

1. Java의 자료구조를 사용한다.

 -> Web Application 멀티쓰레드 환경에서는 유저의 요청이 예측할 수 없이 많이, 동시다발적으로 들어오는 경우가 대부분입니다. 

ConcurrentHashMap 같은 자료구조를 사용하면 동시성을 조금 더 안전하게 관리할 수 있겠지만, 개발자가 신경써야할 부분이 너무 늘어난다고 생각했습니다. 그리고, 저장된 후 일정 시간이 지난 데이터를 지워야하는 로직까지 생각하려니 머리가 아파집니다.

 

2. MySQL에 테이블을 생성하고 관리한다.

 -> 예를 들어 PostRequest 테이블을 만들고 유저의 요청을 기록합니다. 요청 때마다 테이블에 중복요청이 있는지 확인하고 조회수를 상승시킬지 말지 결정합니다.

동시성 문제는 MySQL이 알아서 해결해줍니다. 그러나 엄청나게 느린 보조기억장치 I/O를 발생시키게 됩니다. 단순한 게시글 단건조회인데

 

3. 쿠키를 활용한다.

 -> 검색해보니 이 방법이 가장 많이 사용되는 것 같습니다. 그런데 개발 홍대병이 걸렸는지 쓰기 싫었습니다....... 개인 프로젝트니까... !

 

4. 어디선가 막연히 들었던 Redis를 처음 써본다.

 -> 홍대병도 만족되고 동시성 문제도 알아서 해결해줄거고 I/O에 따른 성능문제도 없습니다. 그리고, expire 기한을 정할 수도 있습니다!

memcached 라는 것도 있던데 조금 지난 기술인 것 같아서 처음부터 고려하지 않았습니다.


그렇게, Redis를 사용하기로 답정너했습니다.

 

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'it.ozimov:embedded-redis:0.7.2'

build.gradle에 의존성을 추가하고,

 

https://emflant.tistory.com/235

 

Docker 로 Redis 설치하기

Redis 는 "키-값" 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈소스 기반의 비관계형 데이터베이스 관리 시스템이다. 관심이 생겨 한번 설치를 하려 했는데, docker 로 설치해봤다.(해당 글

emflant.tistory.com

docker로 redis를 설치합니다.

포트설정을 잘 해야 합니다..... 6379:6379

 

@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
    @Value("${spring.redis.host}")
    private String redisHost;
    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

레디스 Config을 작성합니다.

 

 

잘 되는지 Test코드도 작성해봅니다.

@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.AUTO_CONFIGURED) // 실제 DB 사용하고 싶을때 NONE 사용
public class RedisTest {

    @Autowired
    RedisTemplate<String, String> redisTemplate;

    @Test
    void redisConnectionTest() {
        final String key = "a";
        final String data = "1";

        final ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, data);

        final String s = valueOperations.get(key);
        Assertions.assertThat(s).isEqualTo(data);
    }
}

잘 되는거 맞겠지...

 

 

대충 되는 것 같으니 redis를 대충 배우고 바로 써봅니다.

다른 사람들이 쉽다고 했었는데 MySQL보다 진입장벽이 조금 낮긴 한 것 같습니다.

 

기존 service 코드를 수정합니다

public PostResponse findOne(Long postId, String clientAddress) {
    Post post = postRepository.findByIdWithUser(postId).
            orElseThrow(() -> new IllegalArgumentException("해당하는 postId가 없습니다. 잘못된 입력"));
    int recommendCount = postRecommendRepository.countByPostId(postId);
    if (redisService.isFirstIpRequest(clientAddress, postId)) {
        log.debug("same user requests duplicate in 24hours: {}, {}", clientAddress, postId);
        increasePostHitCount(post, clientAddress);
    }
    List<Comment> comments = commentService.findByPostId(postId);
    return PostResponse.of(post, comments, recommendCount);
}

private void increasePostHitCount(Post post, String clientAddress) {
    post.increaseHitCount();
    redisService.writeClientRequest(clientAddress, post.getPostId());
}

무작정 post 조회수를 상승시키지 않고,

redisService에서 중복요청인지 검증을 요청한 후에 조회수를 상승시킵니다.

<Service 레이어끼리의 의존성이 생기는 것에 대한 순환참조 걱정이 있기는 한데, RedisService에서 다른 서비스를 참조할 것 같지는 않아서 일단 이렇게 뒀습니다>

 

RedisService의 코드:

@Slf4j
@RequiredArgsConstructor
@Service
public class RedisService {
    private final Long clientAddressPostRequestWriteExpireDurationSec = 86400L;
    private final RedisTemplate<String, Boolean> redisTemplate;

    public boolean isFirstIpRequest(String clientAddress, Long postId) {
        String key = generateKey(clientAddress, postId);
        log.debug("user post request key: {}", key);
        if (redisTemplate.hasKey(key)) {
            return false;
        }
        return true;
    }

    public void writeClientRequest(String clientAddress, Long postId) {
        String key = generateKey(clientAddress, postId);
        log.debug("user post request key: {}", key);

        // 사실 set 할때 value가 필요없음. 그나마 가장 작은 불린으로 넣긴 했는데 아직 레디스를 잘 몰라서 이렇게 쓰고 있음
        redisTemplate.opsForValue().set(key, true);
        redisTemplate.expire(key, clientAddressPostRequestWriteExpireDurationSec, TimeUnit.SECONDS);
    }

    // key 형식 : 'client Address + postId' ->  '\xac\xed\x00\x05t\x00\x0f127.0.0.1 + 500'
    private String generateKey(String clientAddress, Long postId) {
        return clientAddress + " + " + postId;
    }
}

다시 봐도 참 귀여운 코드지만,,, 미래에 또 같은 문제로 머리를 감쌀 저에게 이 글을 바칩니다....

잘 들어옵니다 대박

 

이제 레디스도 초면은 아니다!