Skip to content

Commit

Permalink
feat: 상품의 조회수 기능 및 치팅 방지 기능 구현 (#29)
Browse files Browse the repository at this point in the history
* feat: 조회수를 증가를 위한 Resolver 생성 (쿠키를 통해 조회수 치팅 방지)

* refactor: 조회수 동시성 문제 해결

* refactor: 쿠키 관련 문서화 추가

* refactor: 토큰 추출 로직 인터페이스 분리 및 테스트 추가

* refactor: 변수명 변경

* refactor: 변수명 변경
  • Loading branch information
sosow0212 committed Jan 26, 2024
1 parent e3d6a4c commit e5d2ed1
Show file tree
Hide file tree
Showing 18 changed files with 316 additions and 14 deletions.
1 change: 1 addition & 0 deletions src/docs/asciidoc/product.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ include::{snippets}/product-controller-web-mvc-test/upload_product/http-response
=== Request

include::{snippets}/product-controller-web-mvc-test/find_product_by_id/request-headers.adoc[]
include::{snippets}/product-controller-web-mvc-test/find_product_by_id/request-cookies.adoc[]
include::{snippets}/product-controller-web-mvc-test/find_product_by_id/path-parameters.adoc[]
include::{snippets}/product-controller-web-mvc-test/find_product_by_id/http-request.adoc[]

Expand Down
15 changes: 12 additions & 3 deletions src/main/java/com/market/market/application/ProductService.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,25 @@ public Long uploadProduct(final Long memberId, final Long categoryId, final Prod
return savedProduct.getId();
}

@Transactional(readOnly = true)
public Product findProductById(final Long productId) {
return findProduct(productId);
@Transactional
public Product findProductById(final Long productId, final Boolean canAddViewCount) {
Product product = findBoardWithPessimisticLock(productId);
product.view(canAddViewCount);

return product;
}

private Product findProduct(final Long productId) {
return productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
}

private Product findBoardWithPessimisticLock(final Long productId) {
// TODO : 추후 락 방식 변경 -> 비관적 락 느림
return productRepository.findByIdWithPessimisticLock(productId)
.orElseThrow(ProductNotFoundException::new);
}

@Transactional
public void update(final Long productId, final Long memberId, final ProductUpdateRequest request) {
Product product = findProduct(productId);
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/market/market/config/MarketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.market.market.config;

import com.market.market.ui.support.resolver.ViewCountArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class MarketConfig implements WebMvcConfigurer {

private final ViewCountArgumentResolver viewCountArgumentResolver;

@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(viewCountArgumentResolver);
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/market/market/domain/product/Product.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,8 @@ public void validateOwner(final Long memberId) {
throw new ProductOwnerNotEqualsException();
}
}

public void view(final boolean canAddViewCount) {
this.statisticCount.view(canAddViewCount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public interface ProductRepository {

Optional<Product> findById(final Long productId);

Optional<Product> findByIdWithPessimisticLock(final Long productId);

void deleteProductById(final Long productId);

List<Product> findAllProductsInCategory(final Long categoryId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ public static StatisticCount createDefault() {
return new StatisticCount(DEFAULT_VISITED_COUNT, DEFAULT_CONTACT_COUNT);
}

public void view() {
this.visitedCount++;
public void view(final boolean canAddViewCount) {
if (canAddViewCount) {
this.visitedCount++;
}
}

public void contact() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.market.market.infrastructure.product;

import com.market.market.domain.product.Product;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
Expand All @@ -12,6 +16,10 @@ public interface ProductJpaRepository extends JpaRepository<Product, Long> {

Optional<Product> findById(final Long productId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") final Long id);

void deleteById(final Long productId);

List<Product> findAllByCategoryId(final Long categoryId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public Optional<Product> findById(final Long productId) {
return productJpaRepository.findById(productId);
}

@Override
public Optional<Product> findByIdWithPessimisticLock(final Long productId) {
return productJpaRepository.findByIdWithPessimisticLock(productId);
}

@Override
public void deleteProductById(final Long productId) {
productJpaRepository.deleteById(productId);
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/market/market/ui/ProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.market.market.domain.product.Product;
import com.market.market.ui.dto.ProductResponse;
import com.market.market.ui.dto.ProductsResponse;
import com.market.market.ui.support.ViewCountChecker;
import com.market.member.ui.auth.support.AuthMember;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -47,8 +48,9 @@ public ResponseEntity<Long> uploadProduct(@PathVariable("categoryId") final Long

@GetMapping("/{categoryId}/products/{productId}")
public ResponseEntity<ProductResponse> findProductById(@PathVariable("productId") final Long productId,
@PathVariable("categoryId") final Long categoryId) {
Product product = productService.findProductById(productId);
@PathVariable("categoryId") final Long categoryId,
@ViewCountChecker final Boolean canAddViewCount) {
Product product = productService.findProductById(productId, canAddViewCount);
return ResponseEntity.ok(ProductResponse.from(product));
}

Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/market/market/ui/support/ViewCountChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.market.market.ui.support;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ViewCountChecker {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.market.market.ui.support.resolver;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public interface ProductCookieHelper {

Cookie findCookie(final HttpServletRequest request);

boolean hasAlreadyVisitedProduct(final Cookie cookie, final String productId);

void updateCookie(final HttpServletResponse response, final Cookie cookie, final String productId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.market.market.ui.support.resolver;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Component
public class ProductCookieHelperImpl implements ProductCookieHelper {

private static final String COOKIE_NAME = "productView";
private static final int COOKIE_VALID_TIME = 60 * 60 * 24;

@Override
public Cookie findCookie(final HttpServletRequest request) {
Cookie[] cookies = request.getCookies();

if (cookies != null) {
return Arrays.stream(cookies)
.filter(it -> it.getName().equals(COOKIE_NAME))
.findAny()
.orElseGet(null);
}

return null;
}

@Override
public boolean hasAlreadyVisitedProduct(final Cookie cookie, final String productId) {
return cookie != null && hasProductIdInCookie(cookie, productId);
}

@Override
public void updateCookie(final HttpServletResponse response, final Cookie cookie, final String productId) {
if (cookie != null && !hasProductIdInCookie(cookie, productId)) {
addProductIdInCookie(cookie, productId, response);
}

if (cookie == null) {
createProductIdCookie(productId, response);
}
}

private boolean hasProductIdInCookie(final Cookie cookie, final String productId) {
return cookie.getValue().contains("[" + productId + "]");
}

private void addProductIdInCookie(final Cookie productViewCookie, final String productId, final HttpServletResponse response) {
productViewCookie.setValue(productViewCookie.getValue() + "_[" + productId + "]");
addCookie(productViewCookie, response);
}

private void createProductIdCookie(final String productId, final HttpServletResponse response) {
Cookie newCookie = new Cookie(COOKIE_NAME, "[" + productId + "]");
addCookie(newCookie, response);
}

private void addCookie(final Cookie cookie, final HttpServletResponse response) {
cookie.setPath("/");
cookie.setMaxAge(COOKIE_VALID_TIME);
response.addCookie(cookie);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.market.market.ui.support.resolver;

import com.market.market.ui.support.ViewCountChecker;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@RequiredArgsConstructor
@Component
public class ViewCountArgumentResolver implements HandlerMethodArgumentResolver {

private static final String REQUEST_URL_SEPARATOR = "/";

private final ProductCookieHelper productCookieHelper;

@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(ViewCountChecker.class) &&
parameter.getParameterType().equals(Boolean.class);
}

@Override
public Object resolveArgument(final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory) {
HttpServletResponse response = (HttpServletResponse) webRequest.getNativeResponse();
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
String requestURI = request.getRequestURI();
String productId = requestURI.substring(requestURI.lastIndexOf(REQUEST_URL_SEPARATOR) + 1);

Cookie cookie = productCookieHelper.findCookie(request);

if (productCookieHelper.hasAlreadyVisitedProduct(cookie, productId)) {
return false;
}

productCookieHelper.updateCookie(response, cookie, productId);
return true;
}
}
4 changes: 4 additions & 0 deletions src/test/java/com/market/helper/MockBeanInjection.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.market.community.application.board.LikeService;
import com.market.community.application.comment.CommentService;
import com.market.market.application.ProductService;
import com.market.market.ui.support.resolver.ProductCookieHelperImpl;
import com.market.member.application.auth.AuthService;
import com.market.member.domain.auth.TokenProvider;
import com.market.member.ui.auth.support.AuthenticationContext;
Expand All @@ -19,6 +20,9 @@ public class MockBeanInjection {
@MockBean
protected AuthenticationContext authenticationContext;

@MockBean
protected ProductCookieHelperImpl productCookieProvider;

@MockBean
protected AuthService authService;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,21 @@ void setup() {
Product savedProduct = productRepository.save(상품_생성());

// when
Product found = productService.findProductById(savedProduct.getId());
Product found = productService.findProductById(savedProduct.getId(), true);

// then
assertThat(savedProduct)
.usingRecursiveComparison()
.isEqualTo(found);
assertSoftly(softly -> {
softly.assertThat(savedProduct.getStatisticCount().getVisitedCount()).isEqualTo(1L);
softly.assertThat(savedProduct)
.usingRecursiveComparison()
.isEqualTo(found);
});
}

@Test
void 상품이_존재하지_않으면_예외를_발생시킨다() {
// when & then
assertThatThrownBy(() -> productService.findProductById(-1L))
assertThatThrownBy(() -> productService.findProductById(-1L, true))
.isInstanceOf(ProductNotFoundException.class);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public Optional<Product> findById(final Long productId) {
.findAny();
}

@Override
public Optional<Product> findByIdWithPessimisticLock(final Long productId) {
return map.keySet().stream()
.filter(it -> it.equals(productId))
.map(map::get)
.findAny();
}

@Override
public void deleteProductById(final Long productId) {
map.remove(productId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.market.market.application.dto.ProductCreateRequest;
import com.market.market.application.dto.ProductUpdateRequest;
import com.market.market.domain.product.Product;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Test;
Expand All @@ -19,10 +20,13 @@
import static com.market.helper.RestDocsHelper.customDocument;
import static com.market.market.fixture.ProductFixture.상품_생성;
import static org.apache.http.HttpHeaders.AUTHORIZATION;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName;
import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
Expand Down Expand Up @@ -111,13 +115,17 @@ class ProductControllerWebMvcTest extends MockBeanInjection {
Long categoryId = 1L;
Long productId = 1L;
Product response = 상품_생성();
when(productService.findProductById(eq(productId))).thenReturn(response);
when(productService.findProductById(eq(productId), any())).thenReturn(response);

// when & then
mockMvc.perform(get("/api/categories/{categoryId}/products/{productId}", categoryId, productId)
.header(AUTHORIZATION, "Bearer tokenInfo~"))
.header(AUTHORIZATION, "Bearer tokenInfo~")
.cookie(new Cookie("productView", "[1]")))
.andExpect(status().isOk())
.andDo(customDocument("find_product_by_id",
requestCookies(
cookieWithName("productView").description("방문한 Product Id들 (조회수 체킹용)")
),
requestHeaders(
headerWithName(org.springframework.http.HttpHeaders.AUTHORIZATION).description("인증 토큰")
),
Expand Down

0 comments on commit e5d2ed1

Please sign in to comment.