Post

Redis를 활용한 댓글 도배방지 기능

레디스를 사용해 서비스에 새로운 기능을 추가하였습니다.

Redis 간단소개

Redis는 Key-Value 기반의 메모리 데이터베이스이다. NoSQL로 분류되기 때문에 MySQL과는 다르게 비정형 데이터를 저장하거나, 데이터를 JSON, XML로부터 직렬화/역직렬화만 필요한 경우 유용하다.

데이터를 디스크가 아닌 메모리에 저장되기 때문에 접근속도가 빠르다는 장점을 가지고 있다. 하지만 휘발성 저장장치인 메모리에 저장되기 때문에 Redis 서버를 종료하면 데이터가 날아가버린다. 이 단점을 극복하기 위해서 서버의 디스크에 데이터를 저장하는 RDB, AOF와 같은 방식을 제공한다.

Redis 활용목표

  • 사용자의 IP주소를 키값으로 하여 1분간 댓글 작성 횟수를 기록한다. 1분에 1개의 댓글만 작성할 수 있도록 제한한다.

댓글 작성횟수 제한

스팸 댓글의 도배를 방지하기 위해 필요한 기능이다. 댓글을 1분에 한번만 작성할 수 있도록 제한한다.

관계형 데이터베이스를 사용해도 구현할 수 있다. 그럼에도 불구하고 Redis를 활용한 이유는 다음과 같다.

  • 댓글을 작성할 때마다 호출되는 기능이기 때문에 수정, 등록을 위한 쿼리가 자주 수행된다.
  • 디스크에 데이터를 저장하는 관계형 데이터베이스에서 네트워크 I/O가 그만큼 자주 발생하므로 성능이 저하된다. 메모리 기반의 저장소를 사용하면 빠른 속도로 처리할 수 있다.

구현사항

  1. 댓글 작성 요청 시 Redis 저장소에 카운팅 정보를 조회한다.
  2. 카운팅 정보가 존재한다면 기준값을 넘는지 확인한다.
    • 기준 값 이상이라면 댓글 작성에 실패하며 예외를 던진다.
    • 기준 값 이하라면 댓글작성의 다음과정으로 넘어가며 카운팅 값이 증가한다.
  3. 카운팅 정보가 존재하지 않는다면 새로운 카운팅 정보를 저장하고 댓글 작성의 다음과정으로 넘어간다.
    • key는 comment_count:{IP주소}
    • value는 1분간 댓글을 작성한 횟수이다.

RedisRepository vs RedisTemplate

Spring Boot에서 Redis를 사용하는 방법은 두가지가 있다.

  1. Entity를 정의하고 RedisRepository를 사용하는 방법
  2. RedisTemplate를 활용하는 방법

둘 중, 나는 RedisTemplate를 선택하였다.

RedisRepository를 활용하면 Spring Data가 자동으로 구현체를 생성해주고, 엔티티를 객체로 관리할 수 있다는 장점이 있다. 하지만 엔티티가 직렬화 되어 저장된다. 반면 RedisTemplate을 사용하면 단순 숫자만 저장할 수 있기 때문에 저장공간의 절약 뿐만 아니라 처리 속도면에서도 이점이 있을 것이라 생각했다. 또한 INCR, EXPIRE 등 Redis 명령어를 직접 사용할 수 있다는 특징 때문에 RedisTemplate를 사용하기로 결정하였다.

실제 구현

레디스에 데이터를 저장하기 위해서는 RedisTemplate가 필요합니다. 스프링 부트에서 Auto Configuration 으로 빈으로 등록해주는 StringRedisTemplate을 사용하였습니다.

다음은 댓글 카운트를 레디스에 저장하는 CommentService 클래스와 그 코드 일부입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@Transactional
@RequiredArgsConstructor
public class CommentService {

    private final RedisTemplate<String, String> redisTemplate;
    private final CommentRepository commentRepository;
    private static final String KEY_PREFIX = "comment_count";

    @Value("${comment.count.constant.max-count}")
    private long maxCount;
    @Value("${comment.count.constant.expire-seconds}")
    private long expireSeconds;
    @Value("${comment.count.constant.prefix}")
    private String prefix;

    public void create(CommentRequest request, String address) {

        Long count = redisTemplate.opsForValue().increment(KEY_PREFIX + ":" + address);
        if (count > maxCount) {
            throw new CommentCountExceedException("댓글을 너무 자주 작성할 수 없습니다.");
        } else {
            // 댓글 작성 성공 -> 만료시간 갱신
            Comment newComment = new Comment(request.getName(), request.getContent());
            commentRepository.save(newComment);
            redisTemplate.expire(address, expireSeconds, TimeUnit.SECONDS);
        }
    }
}

RedisTemplate<String, String>CommentRepository를 주입받아 사용합니다. RedisTemplate은 위에서도 말한, 스프링 부트가 등록해준 StringRedisTemplate을 사용하고, CommentRepositoryComment 엔티티를 관리하는 JPARepository 인터페이스 구현체를 주입받습니다.

create 메서드로 댓글 작성 요청과 사용자의 IP주소가 전달되면 먼저 RedisTemplate으로 값 증가를 요청하고 있습니다. increment 메서드는 다음과 같이 동작합니다.

  1. 키가 존재하지 않는경우
    • Redis는 해당 키를 생성하고 0을 저장합니다.
    • 그 다음 값을 1 증가시킵니다.
    • 결과적으로 increment 메서드는 1을 반환합니다.
  2. 키가 존재하는 경우
    • 기존 값을 1증가시킵니다.
    • 증가된 새로운 값을 반환합니다.

얻어온 카운트가 최댓값 보다 크다면 예외를 던지게 되고, 아직 한계에 다다르지 않았다면 댓글을 실제 데이터베이스에 저장하고 만료시간을 설정합니다. 그럼 댓글을 처음 작성했을 때의 동작을 살펴보도록 하겠습니다.

1 이라는 값으로 제대로 저장되는 것을 확인할 수 있습니다. 다음으로 허용된 빈도 이상 댓글을 작성햇을 때의 동작을 살펴보도록 하겠습니다.

의도한대로 예외를 던지는 것을 확인할 수 있습니다.

개선점

현재 코드에서는 여러가지 부분에서 Redis에 의존하고 있습니다. 의존관계 주입받는 RedisTemplate를 서비스 코드에서 직접 사용하고 있고 RedisTemplate에서 발생하는 예외를 처리하기 위해 try-catch문으로 감싸져있습니다. 간단하게 말하면 서비스 클래스가 Redis라는 특정 기술에 의존하고 있는 문제점이 있습니다. 그리고 설정정보도 서비스 클래스에 담겨있습니다. 댓글 카운팅 저장소에 접근하는 Repository 클래스와 설정정보를 담은 클래스를 따로 정의해서 이 문제를 해결할 수 있습니다.

Redis라는 기술에 의존

1
Long count = redisTemplate.opsForValue().increment(KEY_PREFIX + ":" + address);

위 코드는 KEY_PREFIX + ":" + address를 키값으로 하는 값(value)이 숫자가 아니라면 RedisCommandExecutionException예외가 발생한다. 따라서 예외를 try-catch 문으로 처리해주어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void create(CommentRequest request, String address) {
    try {
        Long count = redisTemplate.opsForValue().increment(KEY_PREFIX + ":" + address);
        if (count > maxCount) {
            throw new CommentCountExceedException("댓글을 너무 자주 작성할 수 없습니다.");
        } else {
            // 댓글 작성 성공 -> 만료시간 갱신
            Comment newComment = new Comment(request.getName(), request.getContent());
            commentRepository.save(newComment);
            redisTemplate.expire(address, expireSeconds, TimeUnit.SECONDS);
        }
    } catch (RedisCommandExecutionException e) {
        throw e;
    }
}

하지만 서비스 계층의 코드에 레디스에 의존하는 예외 클래스가 포함되고, 이는 나중에 다른 데이터베이스로 변경하였을 때 서비스 코드까지 수정해야하는 문제가 발생한다. 따라서 댓글 카운팅 기능을 처리하는 레포지토리 인터페이스에만 의존하도록 하고, 필요한 동작은 구현체에 정의하는 것이 좋다.

먼저 댓글 카운팅 기능을 처리하는 레포지토리 인터페이스를 다음과 같이 정의하였다.

1
2
3
4
5
6
7
public interface CommentCountRepository {

    public Long increment(String address);

    public void expire(String address, Long timeValue, TimeUnit timeUnit);

}

값 증가, 그리고 만료라는 두가지 기능을 담고 있는 간단한 인터페이스이다.

인터페이스의 구현체인 CommentCountRedisRepository를 아래와 같이 정의했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Repository
@RequiredArgsConstructor
public class CommentCountRedisRepository implements CommentCountRepository {

    private final RedisTemplate<String, String> redisTemplate;

    @Value("${comment.count.constant.prefix}")
    private String prefix;

    @Override
    public Long increment(String address) {
        Long result;
        try {
            result = redisTemplate.opsForValue().increment(getKey(address));
        } catch (RedisCommandExecutionException ex) {
            throw ex;
        }
        return result;
    }

    @Override
    public void expire(String address, Long timeValue, TimeUnit timeUnit) {
        redisTemplate.expire(getKey(address), timeValue, timeUnit);
    }

    private String getKey(String address) {
        return prefix + ":" + address;
    }

}

그리고 구현체를 CommentService에서 주입받아 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

    private final CommentRepository commentRepository;
    private final CommentCountRepository commentCountRepository;

    @Value("${comment.count.constant.max-count}")
    private long maxCount;
    @Value("${comment.count.constant.expire-seconds}")
    private long expireSeconds;

    public void create(CommentRequest request, String address) {
        Long count = commentCountRepository.increment(address);
        if (count > maxCount) {
            throw new CommentCountExceedException("댓글을 너무 자주 작성할 수 없습니다.");
        } else {
            // 댓글 작성 성공 -> 만료시간 갱신
            Comment newComment = new Comment(request.getName(), request.getContent());
            commentRepository.save(newComment);
            commentCountRepository.expire(address, expireSeconds, TimeUnit.SECONDS);
        }
    }
}

이렇게 Redis라는 특정 기술에 의존하지 않는 코드가 되었다.

  • 의존관계 주입받던 RedisTemplate 필드가 사라졌다.
  • Redis 예외 try-catch 문이 사라졌다.

설정정보 분리

저장 만료 시간, 최대 작성횟수, 접두사 정보는 application.yml에 설정되어 있다. 위의 코드에서는 @Value 애노테이션을 사용해서 필요한 곳에서 직접 값을 가져오고 있다.

공통 카테고리의 설정정보를 정적 필드로 제공하는 클래스를 정의하고

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@ConfigurationProperties(prefix = "comment.count.constant")
public class CommentCountConfig {

    @Value("${prefix}")
    public static String PREFIX;

    @Value("${max-count}")
    public static int MAX_COUNT;

    @Value("${comment.count.constant.expire-seconds}")
    public static long EXPIRE_SECONDS;
}

아래와 같이 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

    //...

    public void create(CommentRequest request, String address) {
        Long count = commentCountRepository.increment(address);
        if (count > MAX_COUNT) { // MAX_COUNT
            throw new CommentCountExceedException("댓글을 너무 자주 작성할 수 없습니다.");
        } else {
            //...
            commentCountRepository.expire(address, EXPIRE_SECONDS, TimeUnit.SECONDS); // EXPIRE_SECONDS
        }
    }
}
This post is licensed under CC BY 4.0 by the author.