[재능교환소] 조회수 중복 방지 (Redis, 쿠키)

10000JI·2024년 6월 6일
1

프로젝트

목록 보기
7/14
post-thumbnail

재능교환 게시물, 공지사항 게시물 등을 조회할 때 Hit 이라는 조회수를 올려주는 필드가 존재한다.

조회수 증가 로직을 초기에는 엔티티를 DB에서 불러와 단순히 hit 필드를 증가시켜주고, JPA가 변경된 엔티티 상태를 트랜잭션이 완료될 때 데이터베이스에 반영하였다.

사실 이렇게 하면 비로그인 유저, 로그인 유저 모두 요청을 보내는 족족 계속해서 조회수를 늘린다는 문제점이 있다.

이런 중복 요청을 방지하기 위해 필자는 다음과 같은 방법을 사용하였다.

  1. 비로그인 유저는 쿠키로 조회수 중복 방지
  1. 로그인 유저는 Redis로 조회수 중복 방지

게시물 조회는 로그인한 사용자뿐만 아니라 로그인하지 않은 사용자도 언제든지 접근할 수 있으므로, 두 경우 모두 중복을 방지하는 방법을 설계하였다.

로그인 유저를 위한 고유한 식별자가 존재하지만, 비로그인 유저를 위한 고유한 식별자가 크게 생각나지 않았다.

그래서 비로그인 유저를 위한 조회수 방지를 구현할 때는 자주 사용하는 쿠키를 사용하였다.

로그인 한 유저의 경우에는 Redis를 사용해 조회수 중복을 검사하였다.

이는 사용자의 고유한 식별자(ex> userId)를 키로 사용하고, 값으로 조회한 게시물 id 목록을 저장하였다.

1. 비로그인 유저는 쿠키로 조회수 중복 방지

먼저 비로그인 유저의 경우 쿠키로 조회수 중복 방지를 한 코드를 살펴보자.

현재 Spring Security를 사용하여 로그인 한 유저는 JWT 검증을 거치고, SecurityContextHolder에 인증 정보를 저장하고 있다.

SecurityFilterChain에서 인증 필터를 모두 거치고 나면, Authentication 정보를 꺼내 userId를 반환 받을 수 있다.

SecurityUtil

@Component
@NoArgsConstructor
public class SecurityUtil {

   ...(중략)
    // SecurityContext 에 유저 정보가 저장되는 시점
    // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
    public static String getCurrentMemberUsernameOrNonMember() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null || authentication.getName().equals("anonymousUser")) {
            return "non-Member";
        }

        //authenticaion은 principal을 extends 받은 객체. getName() 메서드는 사용자의 이름을 넘겨주었다.
        //String type의 username (유저의 id) -> UserDetails의 username와 동일
        return authentication.getName();
    }
}

따라서 비로그인 유저는 userId를 받환하지 않고, non-Member라는 문자열을 반환 받을 것이다.

NoticeServiceImpl

/**
 * 재능교환 게시물 조회
 */
@Override
@Transactional
public TalentDto.TalentReadResponse read(Long talentId, HttpServletRequest request, HttpServletResponse response) {
    Talent talent = talentRepository.findWithAllAssociationsById(talentId)
            .orElseThrow(() -> BoardNotFoundException.EXCEPTION);
    String id = securityUtil.getCurrentMemberUsernameOrNonMember();
    if (id.equals("non-Member")) {
        Cookie[] cookies = request.getCookies();
        boolean checkCookie = false;
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                // 이미 조회를 한 경우 체크
                if (cookie.getName().equals(cookieUtil.getCookieName(talentId,talent))) checkCookie = true;
            }
            if (!checkCookie) {
                Cookie newCookie = cookieUtil.createCookieForForNotOverlap(talentId,talent);
                response.addCookie(newCookie);
                talent.updateHit();
            }
        } else {
            Cookie newCookie = cookieUtil.createCookieForForNotOverlap(talentId, talent);
            response.addCookie(newCookie);
            talent.updateHit();
        }
    }
    return new TalentDto.TalentReadResponse(talent);
}

비로그인 조회수 중복 방지는 쿠키로 구현하였다고 앞서 말했다.

1회성으로 끝나는게 아니라, 여러 Service에서 사용되기에 따로 Util 클래스를 만들어 불러와서 사용하였다.

CookieUtil

@Component
public class CookieUtil {

    private final static String VIEWCOOKIENAME = "AlreadyView";

    public Cookie createCookieForForNotOverlap(Long postId, Object reference) {
        Cookie cookie = new Cookie(getCookieName(postId,reference), String.valueOf(postId));
        cookie.setMaxAge(getExpirationInSeconds(24 * 60 * 60)); // 24시간 = 24 * 60 * 60 초
        cookie.setHttpOnly(true); // 서버에서만 조작 가능
        return cookie;
    }

    public int getExpirationInSeconds(int expirationInSeconds) {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expirationTime = now.plusSeconds(expirationInSeconds);
        return (int) now.until(expirationTime, ChronoUnit.SECONDS);
    }

    public String getCookieName(Long postId, Object reference) {
        return VIEWCOOKIENAME + getObjectName(reference) + "-No." + postId;
    }

    public String getObjectName(Object reference) {
        String objectType;
        if (reference instanceof Notice) {
            objectType = "NoticeNum";
        } else if (reference instanceof Talent) {
            objectType = "TalentNum";
        } else {
            objectType = "UnknownNum";
        }
        return objectType;
    }
}

만료시간은 발급일로부터 24시간으로 설정했기 때문에 24시간 후에는 자동으로 삭제된다.

실행해보면 AlreadyViewTalentNum-No.10 키에 값이 10인 쿠키값이 헤더에 설정되고, 같은 번호로 조회했을 때 조회수가 변하지 않는 것을 확인할 수 있다.

2. 로그인 유저는 Redis로 조회수 중복 방지

먼저 Redis를 사용하려면 설치를 해야한다.

필자는 윈도우 PC 환경에서 로컬에 Redis를 설치하였다.

설치과정은 하단 링크로 들어가 참고하길 바란다.

REDIS-📚-Window10-환경에-Redis-설치하기

설치가 완료되었다면 Dependency를 pom.xml에 추가해준다.

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.properties 혹은 application.yml도 추가해준다.

spring
  data:
    redis:
      host: localhost
      port: 6379

다음으로 Config를 작성해준다.

RedisConfig

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

위에서 비로그인 시 쿠키로 구현한 재능교환 게시물 조회 메서드에 추가로 작성해보자.

TalentServiceImpl

@Override
@Transactional
public TalentDto.TalentReadResponse read(Long talentId, HttpServletRequest request, HttpServletResponse response) {
    Talent talent = talentRepository.findWithAllAssociationsById(talentId)
            .orElseThrow(() -> BoardNotFoundException.EXCEPTION);
    String id = securityUtil.getCurrentMemberUsernameOrNonMember();
    if (id.equals("non-Member")) {
        Cookie[] cookies = request.getCookies();
        boolean checkCookie = false;
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                // 이미 조회를 한 경우 체크
                if (cookie.getName().equals(cookieUtil.getCookieName(talentId,talent))) checkCookie = true;
            }
            if (!checkCookie) {
                Cookie newCookie = cookieUtil.createCookieForForNotOverlap(talentId,talent);
                response.addCookie(newCookie);
                talent.updateHit();
            }
        } else {
            Cookie newCookie = cookieUtil.createCookieForForNotOverlap(talentId, talent);
            response.addCookie(newCookie);
            talent.updateHit();
        }
    } else {
        if (redisService.isFirstIpRequest(id, talentId, talent)) {
            increasePostHitCount(talent, talentId, id);
        }
    }
    return new TalentDto.TalentReadResponse(talent);
}

/*
    * 조회수 중복 방지를 위한 Redis 키 생성 메서드
    */
private void increasePostHitCount(Talent talent, Long talentId ,String userId) {
    talent.updateHit();
    redisService.writeClientRequest(userId, talentId, talent);
}

RedisService

@Slf4j
@RequiredArgsConstructor
@Service

public class RedisService {
    private final Long clientAddressPostRequestWriteExpireDurationSec = 86400L;
    private final RedisTemplate<String, Object> redisTemplate;

    public boolean isFirstIpRequest(String clientAddress, Long postId, Object reference) {
        String key = generateKey(clientAddress, postId, reference);
        log.debug("user post request key: {}", key);
        if (redisTemplate.hasKey(key)) {
            return false;
        }
        return true;
    }

    public void writeClientRequest(String userId, Long talentId, Object reference) {
        String key = generateKey(userId, talentId, reference);
        log.debug("user post request key: {}", key);

        redisTemplate.opsForValue().append(key, String.valueOf(talentId));
        redisTemplate.expire(key, clientAddressPostRequestWriteExpireDurationSec, TimeUnit.SECONDS);
    }

    private String generateKey(String userId, Long talentId, Object reference) {
        String objectType;
        if (reference instanceof Notice) {
            objectType = "notice";
        } else if (reference instanceof Talent) {
            objectType = "talent";
        } else {
            objectType = "unknown";
        }
        return userId + "'s " + objectType + "Num - No." + talentId;
    }

}

로그인 후 처음 요청하는 게시물이라면 isFirstIpRequest 메서드가 true를 반환해 increasePostHitCount 메서드에 의해 조회수를 +1 하고, Redis에 게시물과 유저의 정보가 함께 저장된다.

추후에 다시 한번 요청 했을 때는 중복 검증에 걸려 조회하지 않게 구현하였다.

로그인한 사용자가 Postman으로 테스트할 때, Redis에 키가 생성되고 동일한 게시물을 다시 조회 요청하더라도 조회수가 증가하거나 변하지 않는 것을 확인할 수 있다.

출처

스프링+JPA 조회수 증가(중복방지)

https://leezzangmin.tistory.com/39

profile
Velog에 기록 중

0개의 댓글