[Spring Boot] 게시글 조회수 (+중복 방지) 구현
게시글의 조회수를 구현하고자 고민을 하였다. 게시글을 불러올 때마다 조회수를 증가시킨다면 무분별한 새로고침으로 조회수 조작이 가능해진다. 이를 막기 위해 하루에 한 번 게시글을 조회할 때 조회수를 증가시키는 방향을 고려하였다.
[Spring Boot] 게시글 조회수 (+중복 방지) 구현
게시글 조회수 구현
- 게시글의 조회수를 구현하고자 고민을 하였다.
- 게시글을 불러올 때마다 조회수를 증가시킨다면 무분별한 새로고침으로 조회수 조작이 가능해진다.
- 이를 막기 위해 하루에 한 번 게시글을 조회할 때 조회수를 증가시키는 방향을 고려하였다.
- 게시글 조회수 기능 요구사항은 대략 아래와 같다.
요구사항
- 게시글을 조회하면 해당 게시글을 마지막으로 조회한 시간과 비교하여 조회수를 올릴지 판별
- 즉 게시글정보에 마지막 조회 시간정보가 같이 붙어있어야 함
방식
이를 구현하기 위해 다음과 같은 방식이 떠올랐다.
1. 세션
- value 값을 {memberId}:{게시글Id}의 형태로 세션에 저장하는 방식을 생각했다.
- 세션의 TTL(Time To Live)기능으로 시간정보를 저장할 필요가 없다는 장점이 있다.
- 하지만 N명의 사용자가 M개의 게시글을 조회한다고 하면 총 N * M개의 세션 데이터를 서버 메모리에서 관리한다.
- 또한 이렇게 되면 게시글을 조회할 때마다 브라우저가 sessionId를 담아 보내야하는 쿠키의 개수도 증가하게된다.
2. Redis
- 메모리 기반의 데이터 저장소인 Redis도 매력적인 선택지였다.
- {memberId}:{게시글Id}로 설정하여 게시글을 조회할 때 해당 키가 있는지 확인하여 조회수 중복 방지를 구현할 수 있다.
- 이 또한 TTL기능이 있어 시간정보를 담을 필요가 없다.
- 하지만 이또한 메모리에서 관리되는 인프라이므로 데이터 양이 많아질수록 서버에 부하가 올 수 있다.
3. 쿠키
- {logId}:{yyyy-MM-dd HH:mm:ss}식의 게시글 조회 정보를 하나의 쿠키에 담아 사용하는 방식을 생각해 보았다.
- {logId1}:{시간1}/{logId2}:{시간2}/ … 이런 식의 키값으로 사용하는 것이다.
- 쿠키는 4KB의 길이 한계가 있지만 부족하면 쿠키를 하나 더 만드는 식으로 늘려나가는 로직도 충분히 가능하다고 생각한다.
- 이는 아무리 많은 사용자가 많은 게시글을 조회해도 서버에 부하가 가지 않는 그나마 나은 선택지라고 판단했다. 시간정보를 담아 데이터의 길이가 길어지는게 너무 아쉬웠다.
- 이에 {yyyy-MM-dd HH:mm:ss}이러한 형식을 {yyyy-MM-dd} 이렇게 줄여 정확히 24시간 후가 아닌 하루 단위로 일괄적으로 초기화되는 방식을 택하는것도 괜찮다고 생각했다. (11:59에 조회 후 12:00에 조회해도 조회수는 둘 다 증가함)
레디스를 사용하는게 간단하게 구현될 것이라 생각했지만 서버의 부담을 줄이고 쿠키만으로도 충분히 구현할 수 있을거라고 생각하였기에 세 가지의 방법 중 쿠키를 사용하여 구현하기로 결정하였다.
쿠키 방식 구현
postView라는 이름의 쿠키에 {게시글Id}:{날짜정보}의 Set을 담아 해당 게시글을 조회해도 되는지 안되는지 판단하는 로직을 작성하였다. 플로우차트는 아래와 같다.
- 쿠키를 읽어들여 해당 게시글의 조회수를 증가시킬지 판단하는 로직을
Service레이어에서 처리하지 않고 별도의ViewCountValidator라는 클래스를 만들었다. Controller나Service가 아닌ViewCountValidator에 조회수 증가 판별 로직 책임을 지움으로써 코드 가독성을 고려하였다.ViewCountValidator는 이미 조회를 했으면(조회수 유지)true를 반환, 조회를 안했으면(조회수 +1)false를 반환한다.- 아래의 코드에 게시글을 Log라고 이름을 지어서 Log == 게시글이라고 보면 될 것 같다. (
logId= 게시글의 Id)
Controller
1
2
3
4
5
6
@Operation(summary = "id로 로그 조회")
@GetMapping("/{log_id}")
public ApiResponse<LogDetailResponseDto> readLog(HttpServletRequest request, HttpServletResponse response, @PathVariable(name = "log_id") String logId) {
boolean hasViewed = viewCountValidator.hasViewedInCoookie(request, response, logId);
return ApiResponse.onSuccess(logService.readLog(logId, hasViewed));
}
Service
1
2
3
4
5
6
public LogDetailResponseDto readLog(String logId, boolean hasViewed) {
// 게시글 조회가 유효하지 않다면 조회수 증가
if(!hasViewed) logRepository.increaseViewCountByLogId(logId);
Log findLog = logRepository.findLogAndMemberById(logId).orElseThrow(() ->
new LogException(ErrorCode.LOG_NOT_EXIST));
return LogDetailResponseDto.of(findLog);
- 조회를 하지 않았다면 (
hasViewed== false)logRepository.increaseViewCountByLodId(logId)를 통해 조회수를 증가시킨다. 그 후 게시글을
repository에서 찾아 반환한다.
ViewCountValidator
postViewed라는 이름의 쿠키가 있는지 판단한다.- 쿠키가 존재하지 않으면,
postViewed:logId:yyyyMMdd라는 쿠키를 생성한다. 쿠키가 존재하면, 쿠키 값 안에 게시글의 Id가 있는지도 판단해야한다.
- 게시글의 Id가 없으면, 기존의 쿠키 값에 조회 정보를 추가한다.
postViewed:{기존 값}/logId:yyyyMMdd이때 {게시글Id}:{날짜정보}간의 구분자는/로 한다. - 게시글의 Id가 존재하면, 기간이 지났는지를 체크한다.
- 게시글의 Id가 존재하지만 기간이 지났다면 index 0부터 해당 게시글Id까지 지운다. (날짜가 오래된 순서대로 쌓이기 때문에 해당 게시글Id의 기간이 지났다면 앞의 게시글Id 보도 기간이 지난 것) + 새로 날짜정보를 업데이트하여 값을 추가한다. { } / { } / {게시글 Id : 날짜정보 } / { }
{ } / { } / {게시글 Id : 날짜정보 }/ { } / {게시글 Id : 새로운 날짜 정보} - 게시글 Id의 기간이 지나지 않았다면 해당 게시글은 조회수를 늘려도 되므로
true를 반환하게 된다. - 이러한 로직을 구현하면 아래와 같다.
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@Component
public class ViewCountValidator {
private static final String COOKIE_NAME = "postViewed";
private static final int COOKIE_MAX_AGE = 24 * 60 * 60; // 1 day in seconds
private static final String DATE_FORMAT = "yyyyMMdd-HHmm";
private final SimpleDateFormat dateFormatter = new SimpleDateFormat(DATE_FORMAT);
// 해당 게시글을 조회한게 유효한지 체크 (조회한 날짜 내에서만 유효)
public boolean hasViewedInCoookie(HttpServletRequest request, HttpServletResponse response, String logId) {
Cookie viewedCookie = getCookie(request);
if (viewedCookie != null) {
if(isLogIdInCookie(viewedCookie, logId)) {
return checkAndUpdateCookie(viewedCookie, response, logId);
} else {
updateCookieWithNewLogId(viewedCookie, response, logId);
return false;
}
} else {
createNewCookie(response, logId);
return false;
}
}
// getCookies()에서 특정 이름의 쿠키를 찾음
private Cookie getCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
return (cookies == null) ? null : Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(COOKIE_NAME))
.findFirst()
.orElse(null);
}
// cookie에 특정 logId가 포함되어있는지 확인
private boolean isLogIdInCookie(Cookie cookie, String logId) {
return Arrays.stream(cookie.getValue().split("/"))
.anyMatch(entry -> entry.startsWith(logId + ":"));
}
// 쿠키 날짜를 확인하고 필요시 업데이트하여 조회수를 막는 로직
private boolean checkAndUpdateCookie(Cookie cookie, HttpServletResponse response, String logId) {
String today = getCurrentDate();
String[] entries = cookie.getValue().split("/");
for (String entry : entries) {
String[] parts = entry.split(":");
if(parts[0].equals(logId)) {
if(parts[1].equals(today)) return true;
else {
updateCookieWithLogId(cookie, response, logId);
return false;
}
}
}
return false;
}
// logId:yyyyMMdd-HHmm/
// 만료된 logId를 없애고 해당 logId 새로 추가하는 로직 (시간 순서대로 정렬되어있으므로 조회하지 않는 logId도 한꺼번에 정리 가능)
private void updateCookieWithLogId(Cookie cookie, HttpServletResponse response, String logId) {
System.out.println(getCurrentDate());
String deletedExpireLogIdInValue = cookie.getValue().split(logId)[1].substring(DATE_FORMAT.length() + 1);
String updateValue = deletedExpireLogIdInValue + "/" + logId + ":" + getCurrentDate();
addCookie(response, updateValue);
}
//새로 logId를 추가하여 쿠키 추가
private void updateCookieWithNewLogId(Cookie cookie, HttpServletResponse response, String logId) {
String updateValue = cookie.getValue() + "/" + logId + ":" + getCurrentDate();
addCookie(response, updateValue);
}
// 새로운 쿠키 생성하는 로직 (logId 처음으로 정의)
private void createNewCookie(HttpServletResponse response, String logId) {
addCookie(response, logId + ":" + getCurrentDate());
}
private void addCookie(HttpServletResponse response, String value) {
Cookie cookie = new Cookie(COOKIE_NAME, value);
cookie.setPath("/");
cookie.setMaxAge(COOKIE_MAX_AGE);
response.addCookie(cookie);
response.setCharacterEncoding("UTF-8");
}
// 현재 날짜(yyyyMMdd) 반환
private String getCurrentDate() {
return dateFormatter.format(new Date());
}
}
- 나름 가독성을 고려하여 깔끔하게 작성하려 노력했지만 잘 읽히는 코드는 아닌거같았다. 더 많은 코드 작성이 필요해보인다.
- 조회수 유효기간을 24시간 단위가 아닌 하루 단위를 사용하였다. 즉 23:59에 조회하여 조회수가 증가하였으면 00:00에도 조회수가 증가한다.
- 이렇게 하면 시간 단위를 yyyyMMdd-HH:mm:ss가 아닌 yyyyMMdd까지 사용 가능하여 쿠키의 용량을 조금이라도 줄일 수 있다.
SimpleDateFormat을 사용하여 시간 단위를 관리하였다.- 최대 유효기간이 24시간이므로 쿠키 자체의 유효시간은 24시간으로 설정하였다.
테스트
- 원래는 게시글의 조회 유효시간을 하루 단위로 설정해야하나 테스트하기 위해 분단위로 설정하였다. (yyyyMMdd-HHmm)

- 게시글Id가 c33a7968~로 시작하는 게시글의 viewCount는 0이다.
- 해당 게시글을 조회하면 쿠키가 생성되어야 한다.
- 위 사진과 같이 {게시글Id}:20241114-1739로 게시글에 대한 시간정보가 쿠키값으로 들어감을 확인할 수 있다.
- db를 refresh해보자
- 다음과 같이 조회수가 증가하였다.
- 1분이 지나기전에 서둘러 다시 한번 더 조회해보자
- 현재 시각과 시간정보가 같으므로 조회수는 증가하지 않고 쿠키값도 변경되지 않는다.
- 여러 게시글을 조회해봤더니 구분자
/를 기준으로 조회정보가 잘 들어갔음을 확인할 수 있었다.
- 위 사진과 같이 기간이 지난 게시글을 조회했다면 해당 게시글 밑으로는 전부 기간이 지났으므로 다 지워지고 새로운 정보로 업데이트된다.
정리
- 이로써 쿠키로 게시글의 조회수를 관리하는 로직을 구현해보았다.
- 아쉬운 점은 쿠키는 조작하여 조회수 조작이 가능하다는 것과, 구현과정에서 쿠키의 크기를 고려하지 않았다는 것이다.
- 조금 더 보완할 점은 쿠키값을 암호화하여 전송하는 로직이다.
- 또한 쿠키값은 4KB이므로 계산해봤을 때 대략 80여개의 게시글 조회 정보가 하나의 쿠키에 담긴다는 것이다. 쿠키의 용량이 4KB를 넘어서면 새로운 쿠키를 생성하여 여러 개의 쿠키를 사용하는 것도 충분히 고려할 수 있다고 생각한다.
원문: Velog
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.
![[Spring Boot] 게시글 조회수 (+중복 방지) 구현](/assets/img/posts/velog/d1b9257ca78cda8e9d18.png)






