자세한 코드는 깃허브 주소를 참고 부탁드립니다. (BoardService.java)
https://github.com/GASIP-PROJECT/gasip-service/tree/develop/src/main/java/com/example/gasip/board
1. 배경
조회수 기능을 개발 중입니다.
서비스를 개발하면서, 특정 사용자에 의해 조회수가 제한없이 늘어난다면, 조회수 조작의 위험이 발생할 수 있다고 생각했습니다.
조회수 조작은 크리티컬한 문제는 아니지만, 현재 서비스 요구사항 상 BEST5 게시글을 조회할 때 좋아요 수가 같은 경우 조회수가 많은 게시글에게 우선권을 주고 있습니다.
그러므로 특정 사용자ID로 조회수를 악의적으로 조작하는 것을 방지해야한다고 판단했습니다.
2. 기존 방법
기존에는 Board 엔티티에서 메서드를 작성해 기존 조회수에 +1을 하고 있었습니다.
public void increaseView(Long count) {
clickCount+=count;
}
다만 위와 같은 방법은 동시성 처리도 어렵고 특정 사용자의 악의적인 중복 조회를 막지 못한다는 단점이 있습니다.
동시성 제어 로직은 다음 포스팅을 참고 부탁드리며, 이번 포스팅에서는 사용자의 중복 조회를 방지하기 위해 어떻게 구현했는지 위주로 설명하겠습니다.
3. 어떻게 개선할까..?
1) 쿠키
쿠키의 경우, 브라우저에 저장되는 방식으로 서버 부담도 줄일 수 있으며 이후 서버 스케일 아웃을 진행한다 하더라도 큰 문제가 없습니다. 또한 key-value 형태로 저장할 수 있어 저장 로직을 작성하기에도 안성맞춤입니다.
다만 쿠키의 경우 서버가 아닌 클라이언트 측에 저장되는 특성 상 보안에 취약합니다. 조회수의 경우 중요 개인정보는 아니기 때문에 쿠키의 보안 취약점을 감수하고 사용할 법 하지만, 서비스 요구사항 특성 상 쿠키를 조작하거나 삭제해버리면 조회수 방지 로직을 구현하는 의미가 없으므로 쿠키는 사용하지 않습니다.
2)세션
세션의 경우, 서버에서 정보를 관리하는 방식으로 보안에 장점을 보이는 방식입니다.
다만 쿠키와는 반대로 서버 부담이 있으며 이후 서버 스케일 아웃을 진행한다면 세션 불일치 문제가 발생할 수 있는 위험이 있습니다.
이러한 문제는 세션 서버 자체를 클라이언트와 서버 사이에 별도로 두어 관리할 수 있습니다만 결국 서버 부담이 늘어난다는 점은 변하지 않습니다.
저희는 AWS 프리티어를 최대한 사용하여 인프라를 구축하고자 했기 때문에 서버 부담이 늘어나는 것은 지양해야 했습니다. 그러므로 세션 또한 사용하지 않습니다.
3) Redis
Redis의 경우, key-value 형태로 데이터를 저장하는 DB입니다. 더불어 AWS EC2와는 별개로 엘라스틱캐시를 이용하여 Redis 서버를 구축할 수 있기 때문에, 쿠키의 장점과 세션의 장점을 동시에 활용할 수 있으며, 각 단점은 상쇄할 수 있는 수단입니다.
그렇기에 저는 AWS 엘라스틱캐시를 활용하여 Redis를 구축하여 활용하기로 했습니다.
먼저 key 값으로 사용자Id으로 작성하여 두고, 사용자가 특정 게시글을 최초로 들어갈 때 value 값으로 게시글Id를 연이어 저장하는 방식을 택했습니다.
TTL(Time To Live)은 5분으로 설정했습니다. 해당 key-vlaue 쌍은 생성된지 5분 이후 삭제된다는 뜻입니다.
코드 레벨에서 살펴보면, 먼저 게시글 조회 로직이 실행되면 redis를 살펴본 후 현재 사용자 id value에 게시글id가 저장되어 있는지 확인합니다. 만약 있다면 조회수를 올리지 않고, 없다면 value에 게시글Id를 추가하고 조회수를 1 올리는 방식을 취하고 있습니다.
BoardService.java (일부)
// BoardService 코드 내 게시글 조회 로직
@Transactional
public BoardReadResponse findBoardId(Long postId,MemberDetails memberDetails) {
Member member = memberRepository.getReferenceById(memberDetails.getId());
insertView(postId,member);
Board board = boardRepository.findById(postId).orElseThrow(IllegalArgumentException::new);
return BoardReadResponse.fromEntity(board);
}
@Transactional
public void insertView(Long postId,Member member) {
String key = String.valueOf(member.getMemberId()) + "mem";
//Redis에서 사용자Id(memberId)가 key로 저장되어 있는지 체크
String viewCount = redisViewCountService.getData(key);
Board board = boardRepository.findById(postId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.NOT_FOUND_BOARD));
//만약 저장되어 있지 않다면(== 5분 내로 게시글 자체를 본적이 없음)
if (viewCount == null) {
// 사용자Id로 key 생성하고 조회수 +1
redisViewCountService.setDateExpire(key, postId + "_", calculateTimeOut(5));
redisViewCountService.addViewCountInRedis(postId);
} else {
// 저장되어 있다면, _ 를 기준으로 리스트 생성(조회한 게시글Id로 구성된 리스트)
String[] strArray = viewCount.split("_");
List<String> redisBoardList = Arrays.asList(strArray);
boolean isview = false;
// 만약 redisBoardList가 빈값이 아니고 현재 조회한 게시글Id를 이미 보유하고 있다면! 아무일도 일어나지 않음
if (!redisBoardList.isEmpty()) {
for (String redisPostId : redisBoardList) {
if (String.valueOf(postId).equals(redisPostId)) {
isview = true;
break;
}
}
// 근데 없다면,사용자Id를 key로 가지는 곳에 게시글Id를 추가함 + 게시글 조회수 +1
if (!isview) {
String alreadyView = postId + "_";
redisViewCountService.addBoardId(String.valueOf(member.getMemberId()),alreadyView);
redisViewCountService.addViewCountInRedis(postId);
}
}
}
return board;
}
RedisViewCountService.java (일부)
@Service
@RequiredArgsConstructor
public class RedisViewCountService {
private final StringRedisTemplate stringRedisTemplate;
// redis에서 key에 포함된 데이터 불러오는 로직
public String getData(String key) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.get(key);
}
// 입력받은 key값을 redis에 저장하고 key-value쌍을 생성하는 로직(TTL = 5분)
public void setDateExpire(String key, String value, long duration) {
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
Duration expireDuration = Duration.ofSeconds(duration);
valueOperations.set(key, value, expireDuration);
}
// 기존 memberId 키값에 새로 조회한 게시글 ID 추가하는 로직
public void addBoardId(String key, String value) {
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.append(key, value);
}
// 조회수 +1 하는 로직
public void addViewCountInRedis(Long boardId) {
String key = String.valueOf(boardId)+"board";
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
SetOperations<String, String> setOperations = stringRedisTemplate.opsForSet();
if (getData(key) == null) {
valueOperations.set(key,"0");
setOperations.add("keyList", key.replace("board",""));
}
if (!setOperations.isMember("keyList",getData(key))) {
setOperations.add("keyList",key.replace("board",""));
}
valueOperations.increment(key);
}
}
4. 한계
게시글 조회 중복 방지 로직을 구현했지만 한계점 또한 발견했습니다.
일단, redis에 저장할 때 TTL을 5분으로 설정해뒀는데, 이게 1개의 key값에 value를 계속 추가하는 방식이다보니, TTL로 설정한 시간은 줄어드는 와중에 새로운 값이 추가됩니다. 이렇게 된다면 예를 들어 TTL이 15초 남았는데 새로운 게시글 ID가 들어오게 되면 15초 이후엔 redis가 초기화되어 다시 조회할 수 있게 되는 허점이 있습니다.
다만, 초기 목적 상 조회수 테러 방지는 어느정도 해결 됐다고 판단했기에 현재 로직을 수정하지 않았습니다.
추후 더 좋은 방법이 생각난다면 개선해 나가보고자 합니다.
'Development > Spring&Springboot' 카테고리의 다른 글
[Gasip] queryDSL을 활용하여 교수 평균 평점 구하기 (2) | 2024.05.29 |
---|---|
[Gasip] Redis & Sync Schedule 을 적용해 조회수 동시성 처리 (2) | 2024.04.12 |
스프링 빈이란?? (3) | 2024.02.28 |
스프링 컨테이너 DI와 IoC (2) | 2024.02.28 |
[채팅] 채팅 메세지 전송 속도 개선 (3) | 2024.02.14 |