Redis를 활용한 댓글 도배방지 기능
레디스를 사용해 서비스에 새로운 기능을 추가하였습니다.
Redis 간단소개
Redis는 Key-Value 기반의 메모리 데이터베이스이다. NoSQL로 분류되기 때문에 MySQL과는 다르게 비정형 데이터를 저장하거나, 데이터를 JSON, XML로부터 직렬화/역직렬화만 필요한 경우 유용하다.
데이터를 디스크가 아닌 메모리에 저장되기 때문에 접근속도가 빠르다는 장점을 가지고 있다. 하지만 휘발성 저장장치인 메모리에 저장되기 때문에 Redis 서버를 종료하면 데이터가 날아가버린다. 이 단점을 극복하기 위해서 서버의 디스크에 데이터를 저장하는 RDB, AOF와 같은 방식을 제공한다.
Redis 활용목표
- 사용자의 IP주소를 키값으로 하여 1분간 댓글 작성 횟수를 기록한다. 1분에 1개의 댓글만 작성할 수 있도록 제한한다.
댓글 작성횟수 제한
스팸 댓글의 도배를 방지하기 위해 필요한 기능이다. 댓글을 1분에 한번만 작성할 수 있도록 제한한다.
관계형 데이터베이스를 사용해도 구현할 수 있다. 그럼에도 불구하고 Redis를 활용한 이유는 다음과 같다.
- 댓글을 작성할 때마다 호출되는 기능이기 때문에 수정, 등록을 위한 쿼리가 자주 수행된다.
- 디스크에 데이터를 저장하는 관계형 데이터베이스에서 네트워크 I/O가 그만큼 자주 발생하므로 성능이 저하된다. 메모리 기반의 저장소를 사용하면 빠른 속도로 처리할 수 있다.
구현사항
- 댓글 작성 요청 시 Redis 저장소에 카운팅 정보를 조회한다.
- 카운팅 정보가 존재한다면 기준값을 넘는지 확인한다.
- 기준 값 이상이라면 댓글 작성에 실패하며 예외를 던진다.
- 기준 값 이하라면 댓글작성의 다음과정으로 넘어가며 카운팅 값이 증가한다.
- 카운팅 정보가 존재하지 않는다면 새로운 카운팅 정보를 저장하고 댓글 작성의 다음과정으로 넘어간다.
- key는
comment_count:{IP주소}
- value는 1분간 댓글을 작성한 횟수이다.
- key는
RedisRepository vs RedisTemplate
Spring Boot에서 Redis를 사용하는 방법은 두가지가 있다.
- Entity를 정의하고 RedisRepository를 사용하는 방법
- 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
을 사용하고, CommentRepository
는 Comment
엔티티를 관리하는 JPARepository
인터페이스 구현체를 주입받습니다.
create 메서드로 댓글 작성 요청과 사용자의 IP주소가 전달되면 먼저 RedisTemplate으로 값 증가를 요청하고 있습니다. increment 메서드는 다음과 같이 동작합니다.
- 키가 존재하지 않는경우
- Redis는 해당 키를 생성하고 0을 저장합니다.
- 그 다음 값을 1 증가시킵니다.
- 결과적으로 increment 메서드는 1을 반환합니다.
- 키가 존재하는 경우
- 기존 값을 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
}
}
}