조회수 어뷰징은 어떻게 막아야 할까?
어뷰징 (Abusing) : 의도적인 조작을 통해 조회수나 클릭수를 높이기 위한 일련의 행위 CRUD 커뮤니티 프로젝트를 하면서 고민했던 주제입니다. 다른 프로젝트에서는 커뮤니티 부분을 개발하지 않
ssdragon.tistory.com
조회수 어뷰징은 어떻게 막아야 할까? 라는 의문에서 시작된 조회수 증가 로직 구현하기입니다.
제일 간단하면서도 생각보다 많은 처리를 할 수 있는 쿠키 기반으로 구현해봅니다.
다음과 같은 전제조건이 있습니다.
- 쿠키 기반
- 하루에 1번 조회수 증가
- 비회원도 조회수 증가
구글에 검색해본 결과 하루에 1번 조회수 증가하는 로직을 살펴보았는데 막연히 현재 시간에서 24시간동안만 유지하는 쿠키부터해서 다른 게시글을 들어갈때마다 쿠키 유지시간이 다시 증가하는 로직도 있었습니다. 따라서 저는 하루에 1번으로 지정하여 다른 게시글을 들어간다고 해도 (24시-현재시간)만큼만 유지되므로 쿠키 유지시간이 늘어나는 경우는 없도록 하였습니다.
그럼 먼저 시간을 구해보도록 하겠습니다.
시간 구하기
자바 8부터 지원하는 Time API를 사용하여 오늘 하루의 끝 시간과 현재시간을 구하여 지정해봅니다.
- LocalDate : 날짜
- LocalTime : 시간
- LocalDateTime : 날짜 + 시간
- ZonedDateTime : 날짜 + 시간 + 시간대
// 현재 하루의 종료 시간, 2022-08-20T23:59:59.9999999
LocalDateTime todayEndTime = LocalDate.now().atTime(LocalTime.max);
// 현재 시간, 2022-08-20T19:39:10.936
LocalDateTime currentTime = LocalDateTime.now();
// 하루 종료 시간을 시간초로 변환
long todayEndSecond = todayEndTime.toEpochSecond(ZoneOffset.UTC);
// 현재 시간을 시간초로 변환
long currentSecond = currentTime.toEpochSecond(ZoneOffset.UTC);
// 하루 종료까지 남은 시간초
long remainingTime = todayEndSecond - currentSecond;
에포크 타임(EPOCH TIME, 1970-01-01 00:00:00 UTC)부터 지정한 시간까지 시간초로 변환하는 메소드가 toEpochSecond입니다. 오라클 DB의 타임스탬프처럼 밀리초 단위를 필요로 하는 경우에는 toEpochMilli()도 있습니다.
예전에는 GMT를 기준으로 시간을 사용했지만 현재는 UTC라는 국제 표준시를 권장하고 있습니다.
참고로 toEpochSecond()를 사용하려면 시간대가 설정된 ZonedDateTime 클래스에서 사용해야하므로 다음과 같이 사용할 수 있습니다.
LocalDateTime.now().atZone(ZoneId.of("UTC")).toEpochSecond();
// 또는
LocalDateTIme.now().toEpochSecond(ZoneOffset.UTC);
이렇게 오늘 하루 자정까지 남은 시간을 구할 수 있습니다.
쿠키 생성 및 검증하기
- HttpServletRequest : HTTP 요청 메시지를 파싱해서 객체 필드에 저장해둔 객체이다. 이걸로 HTTP 헤더 및 데이터를 쉽게 읽을 수 있음
- HttpServletResponse : HTTP 응답 코드를 지정해주고 헤더와 바디를 생성한다.
게시글을 조회하면 조회수가 증가하므로 다음 컨트롤러부터 시작하게 됩니다.
혹시 스프링을 처음 접하시는 분들도 있을 것 같아서 컨트롤러 안에서 메소드를 만든 것으로 예시를 들겠습니다.
또한 데이터 접근 기술은 JPA를 사용하여 PostRepository를 통해 엔티티를 조회하였습니다.
먼저 Post 클래스입니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
@Id
@Column(name = "POST_ID")
@GeneratedValue
private Long id;
// ...
@Column(name = "VIEW_COUNT")
private int viewCount;
public void addViewCount() {
this.viewCount++;
}
}
그 다음 컨트롤러입니다.
@Controller
@RequiredArgsConstructor
public class PostController {
private final PostRepository postRepository;
// ...
@GetMapping("/posts/{postId}")
public String postInfo(@PathVariable("postId") Long postId,
HttpServletRequest request,
HttpServletResponse response,
Model model) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotExistException("게시글이 존재하지 않습니다."));
viewCountValidation(post, request, response);
return "post/post-info";
}
private void viewCountValidation(Post post, HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
Cookie cookie = null;
boolean isCookie = false;
// request에 쿠키들이 있을 때
for (int i = 0; cookies != null && i < cookies.length; i++) {
// postView 쿠키가 있을 때
if (cookies[i].getName().equals("postView")) {
// cookie 변수에 저장
cookie = cookies[i];
// 만약 cookie 값에 현재 게시글 번호가 없을 때
if (!cookie.getValue().contains("[" + post.getId() + "]")) {
// 해당 게시글 조회수를 증가시키고, 쿠키 값에 해당 게시글 번호를 추가
post.addViewCount();
cookie.setValue(cookie.getValue() + "[" + post.getId() + "]");
}
isCookie = true;
break;
}
}
// 만약 postView라는 쿠키가 없으면 처음 접속한 것이므로 새로 생성
if (!isCookie) {
post.addViewCount();
cookie = new Cookie("postView", "[" + post.getId() + "]"); // oldCookie에 새 쿠키 생성
}
// 쿠키 유지시간을 오늘 하루 자정까지로 설정
long todayEndSecond = LocalDate.now().atTime(LocalTime.MAX).toEpochSecond(ZoneOffset.UTC);
long currentSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
cookie.setPath("/"); // 모든 경로에서 접근 가능
cookie.setMaxAge((int) (todayEndSecond - currentSecond));
response.addCookie(cookie);
}
}
viewCountValidation() 메서드를 Optional과 Stream을 활용하여 리팩토링하면 아래와 같습니다.
private void viewCountValidation(Post post, HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = Optional.ofNullable(request.getCookies()).orElseGet(() -> new Cookie[0]);
// 쿠키가 있다면 cookie에 넣고, 없다면 조회수 증가 및 쿠키 생성
Cookie cookie = Arrays.stream(cookies)
.filter(c -> c.getName().equals("postView"))
.findFirst()
.orElseGet(() -> {
post.addViewCount();
return new Cookie("postView", "[" + post.getId() + "]");
});
// 쿠키가 없다면 조회수 증가 및 쿠키 생성
if (!cookie.getValue().contains("[" + post.getId() + "]")) {
post.addViewCount();
cookie.setValue(cookie.getValue() + "[" + post.getId() + "]");
}
long todayEndSecond = LocalDate.now().atTime(LocalTime.MAX).toEpochSecond(ZoneOffset.UTC);
long currentSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
cookie.setPath("/"); // 모든 경로에서 접근 가능
cookie.setMaxAge((int) (todayEndSecond - currentSecond)); // 오늘 하루 자정까지 남은 시간초 설정
response.addCookie(cookie);
}
만약 다른 방법의 코드도 보고싶다면 이쪽을 참고하시면 되겠습니다.
실행화면
위 화면에서 게시글을 선택합니다.
게시글 상세 조회 페이지로 이동 후 쿠키가 생성된 것을 볼 수 있습니다.
이 때 Expires / Max-Age가 쿠키 만료시간인데 이상한게 오늘은 2022-08-20이고 끝나는 시간은 23:59:59가 되어야하는데 14:59:59가 되어있습니다.
구글 크롬(Chrome) 브라우저는 UTC 기준으로 보여주고 있기 때문에 발생한 현상입니다.
따라서 현재 보여지는 시간에서 +9시간을 하여야 서울 기준 시간입니다.
즉, 9시간을 더하면 23:59:59로 잘 설정되어 있음을 알 수 있습니다.
조회수 또한 재접속 및 새로고침을 해도 조회수는 증가되지 않는 것을 알 수 있습니다.
참고
https://github.com/Sangyong-Jeon/SpringBoot_Basic/issues/7
https://github.com/Sangyong-Jeon/SpringBoot_Basic/issues/8
https://github.com/Sangyong-Jeon/SpringBoot_Basic/issues/6
'Spring > Spring' 카테고리의 다른 글
포스트맨으로 url 요청했는데 405 에러 (0) | 2023.02.13 |
---|---|
스프링 부트 의존관계 주입 에러 (0) | 2023.02.13 |
[SpringBoot] 이미지 파일 다운로드 (0) | 2022.07.06 |
[스프링시큐리티] Spring Security 5.7 (WebSecurityConfigurerAdapter 에러해결방법) (0) | 2022.07.03 |
스프링에서 파일저장하기 (0) | 2022.05.11 |