diff --git a/build.gradle b/build.gradle index 7c34745..2e46b99 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'com.github.codemonstur:embedded-redis:1.4.3' testImplementation 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java b/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java index 0f18923..cbc63c8 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java +++ b/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java @@ -3,6 +3,7 @@ import com.jiwon.mylog.global.redis.RedisUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service @@ -25,7 +26,7 @@ public int incrementPostView(Long postId, int view, String userKey) { return getPostView(postId, view); } - private int getPostView(Long postId, int view) { + public int getPostView(Long postId, int view) { return redisUtil.getPostView(VIEW_COUNT_KEY_PREFIX, postId.toString(), view); } } diff --git a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java index 219ef1a..530f344 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java @@ -1,9 +1,9 @@ package com.jiwon.mylog.global.common.config; +import com.jiwon.mylog.global.common.error.ExceptionHandlerFilter; import com.jiwon.mylog.global.oauth.CustomOAuth2UserService; import com.jiwon.mylog.global.oauth.OAuth2Properties; import com.jiwon.mylog.global.oauth.OAuth2SuccessHandler; -import com.jiwon.mylog.global.security.auth.user.CustomUserDetailsService; import com.jiwon.mylog.global.security.jwt.JwtService; import com.jiwon.mylog.global.security.jwt.JwtTokenAuthenticationFilter; import com.jiwon.mylog.global.security.token.sevice.TokenService; @@ -12,7 +12,6 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -103,8 +102,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService) ); http + .addFilterBefore(new ExceptionHandlerFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtTokenAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class); - http .exceptionHandling(exceptions -> exceptions // 인증 diff --git a/src/main/java/com/jiwon/mylog/global/common/error/ErrorCode.java b/src/main/java/com/jiwon/mylog/global/common/error/ErrorCode.java index 27ea8f8..4bcf56c 100644 --- a/src/main/java/com/jiwon/mylog/global/common/error/ErrorCode.java +++ b/src/main/java/com/jiwon/mylog/global/common/error/ErrorCode.java @@ -11,6 +11,7 @@ public enum ErrorCode { INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), INVALID_ACCOUNT_ID_OR_PASSWORD(HttpStatus.UNAUTHORIZED, "잘못된 아이디 또는 비밀번호입니다."), INVALID_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), FAIlED_MAIL_SEND(HttpStatus.INTERNAL_SERVER_ERROR, "메일 전송에 실패했습니다."), INVALID_MAIL_CODE(HttpStatus.BAD_REQUEST, "인증 코드가 유효하지 않습니다."), diff --git a/src/main/java/com/jiwon/mylog/global/common/error/ExceptionHandlerFilter.java b/src/main/java/com/jiwon/mylog/global/common/error/ExceptionHandlerFilter.java new file mode 100644 index 0000000..6cc9ff8 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/common/error/ExceptionHandlerFilter.java @@ -0,0 +1,37 @@ +package com.jiwon.mylog.global.common.error; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class ExceptionHandlerFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (ExpiredJwtException e) { + setErrorResponse(response, ErrorCode.EXPIRED_TOKEN); + } + } + + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) { + ObjectMapper objectMapper = new ObjectMapper(); + response.setStatus(errorCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + ErrorResponse errorResponse = ErrorResponse.builder() + .status(errorCode.getStatus().value()) + .message(errorCode.getMessage()) + .build(); + try { + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java b/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java index 8e7284c..128dbc1 100644 --- a/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java +++ b/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java @@ -2,7 +2,6 @@ import java.time.Duration; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -15,6 +14,9 @@ public class RedisUtil { private final StringRedisTemplate redisTemplate; + /** + * 이메일 관련 + */ public String getData(String key) { return redisTemplate.opsForValue().get(key); } @@ -44,9 +46,7 @@ public void addPostViewUser(String key, String value) { } public Long increasePostView(String key, String hashKey, String view) { - if (!redisTemplate.opsForHash().hasKey(key, hashKey)) { - redisTemplate.opsForHash().put(key, hashKey, view); - } + redisTemplate.opsForHash().putIfAbsent(key, hashKey, view); return redisTemplate.opsForHash().increment(key, hashKey, 1); } @@ -62,4 +62,8 @@ public Map getAllPostView(String key) { e -> Integer.parseInt(e.getValue().toString()) )); } + + public void clearAll() { + redisTemplate.getConnectionFactory().getConnection().serverCommands().flushAll(); + } } diff --git a/src/main/java/com/jiwon/mylog/global/security/jwt/JwtService.java b/src/main/java/com/jiwon/mylog/global/security/jwt/JwtService.java index d61b689..edf3888 100644 --- a/src/main/java/com/jiwon/mylog/global/security/jwt/JwtService.java +++ b/src/main/java/com/jiwon/mylog/global/security/jwt/JwtService.java @@ -82,6 +82,7 @@ public boolean validateToken(String token) { return true; } catch (ExpiredJwtException e) { log.error("Expired JWT token: {}", e.getMessage()); + throw e; } catch (MalformedJwtException e) { log.error("Invalid JWT token format: {}", e.getMessage()); } catch (UnsupportedJwtException e) { diff --git a/src/main/java/com/jiwon/mylog/global/security/jwt/JwtTokenAuthenticationFilter.java b/src/main/java/com/jiwon/mylog/global/security/jwt/JwtTokenAuthenticationFilter.java index bda2de8..2f6a69c 100644 --- a/src/main/java/com/jiwon/mylog/global/security/jwt/JwtTokenAuthenticationFilter.java +++ b/src/main/java/com/jiwon/mylog/global/security/jwt/JwtTokenAuthenticationFilter.java @@ -1,6 +1,10 @@ package com.jiwon.mylog.global.security.jwt; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jiwon.mylog.global.common.error.ErrorCode; +import com.jiwon.mylog.global.common.error.ErrorResponse; import com.jiwon.mylog.global.security.auth.user.JwtUserDetails; +import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -33,19 +37,23 @@ protected void doFilterInternal( String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); String token = jwtService.getAccessToken(authorizationHeader); - if (token != null && jwtService.validateToken(token)) { - Long userId = jwtService.getUserId(token); - String accountId = jwtService.getAccountId(token); - String role = jwtService.getUserRole(token); + try { + if (token != null && jwtService.validateToken(token)) { + Long userId = jwtService.getUserId(token); + String accountId = jwtService.getAccountId(token); + String role = jwtService.getUserRole(token); - JwtUserDetails userDetails = new JwtUserDetails( - userId, - accountId, - List.of(new SimpleGrantedAuthority(role)) - ); - Authentication authToken = getAuthentication(userDetails); + JwtUserDetails userDetails = new JwtUserDetails( + userId, + accountId, + List.of(new SimpleGrantedAuthority(role)) + ); + Authentication authToken = getAuthentication(userDetails); - SecurityContextHolder.getContext().setAuthentication(authToken); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (ExpiredJwtException e) { + throw e; } filterChain.doFilter(request, response); diff --git a/src/test/java/com/jiwon/mylog/config/EmbeddedRedisConfig.java b/src/test/java/com/jiwon/mylog/config/EmbeddedRedisConfig.java new file mode 100644 index 0000000..8178b93 --- /dev/null +++ b/src/test/java/com/jiwon/mylog/config/EmbeddedRedisConfig.java @@ -0,0 +1,27 @@ +package com.jiwon.mylog.config; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private RedisServer redisServer; + + @PostConstruct + public void startRedis() throws IOException { + redisServer = new RedisServer(6380); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() throws IOException { + if (redisServer != null) { + redisServer.stop(); + } + } +} diff --git a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java index bf151ad..574e5fc 100644 --- a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java +++ b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java @@ -117,7 +117,7 @@ void deletePost() { postService.deletePost(userId, postId); // then - assertThrows(NotFoundException.class, () -> postService.getPost(postId); + assertThrows(NotFoundException.class, () -> postService.getPost(postId)); verify(postRepository, times(2)).findPostDetail(postId); } diff --git a/src/test/java/com/jiwon/mylog/domain/post/service/PostViewServiceTest.java b/src/test/java/com/jiwon/mylog/domain/post/service/PostViewServiceTest.java new file mode 100644 index 0000000..4dfaabe --- /dev/null +++ b/src/test/java/com/jiwon/mylog/domain/post/service/PostViewServiceTest.java @@ -0,0 +1,155 @@ +package com.jiwon.mylog.domain.post.service; + +import com.jiwon.mylog.TestDataFactory; +import com.jiwon.mylog.config.EmbeddedRedisConfig; +import com.jiwon.mylog.domain.post.entity.Post; +import com.jiwon.mylog.domain.post.repository.PostRepository; +import com.jiwon.mylog.domain.user.entity.User; +import com.jiwon.mylog.domain.user.repository.UserRepository; +import com.jiwon.mylog.global.redis.RedisUtil; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@Transactional +@Import(EmbeddedRedisConfig.class) +@ActiveProfiles("test") +@SpringBootTest +class PostViewServiceTest { + + @PersistenceContext + private EntityManager em; + + @Autowired + private PostViewService postViewService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RedisUtil redisUtil; + + private Long postId; + + @BeforeEach + void setUp() { + redisUtil.clearAll(); + userRepository.deleteAll(); + postRepository.deleteAll(); + + User user = userRepository.save(TestDataFactory.createUser("email", "accountId", " name")); + Post post = postRepository.save(TestDataFactory.createPost("title", "content", user, null)); + postId = post.getId(); + } + + @DisplayName("조회수 증가 테스트 (동시성)") + @Test + void incrementPostView() throws InterruptedException { + int numberOfThreads = 1000; + + for (int i = 0; i < 10; i++) { + executeConcurrentViewTest(numberOfThreads); + int view = postViewService.getPostView(postId, 0); + System.out.println(i+1 + "round = expected view: " + numberOfThreads + ", actual: " + view); + assertThat(view) + .withFailMessage("Round %d failed: expected %d but was %d", + i + 1, numberOfThreads, view) + .isEqualTo(numberOfThreads); + redisUtil.clearAll(); + } + } + + private void executeConcurrentViewTest(int numberOfThreads) throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads); + CyclicBarrier cyclicBarrier = new CyclicBarrier(numberOfThreads); + + for (int i = 0; i < numberOfThreads; i++) { + final int idx = i; + executorService.submit(() -> { + try { + cyclicBarrier.await(); + String userKey = "user:" + idx; + postViewService.incrementPostView(postId, 0, userKey); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + executorService.shutdown(); + } + + @DisplayName("조회수 증가 테스트 (동시성 및 DB 반영)") + @Test + void incrementPostView_DB() throws InterruptedException { + int first = 500; + int second = 600; + ExecutorService executorService = Executors.newFixedThreadPool(first + second); + + CountDownLatch latch1 = new CountDownLatch(first); + for (int i = 0; i < first; i++) { + final int idx = i; + executorService.submit(() -> { + try { + String userKey = "user:" + idx; + postViewService.incrementPostView(postId, 0, userKey); + } finally { + latch1.countDown(); + } + }); + } + latch1.await(); + int firstSavedView = postViewService.getPostView(postId, 0); + postRepository.updatePostView(postId, firstSavedView); + em.flush(); + em.clear(); + + int firstUpdatedView = postRepository.findById(postId).get().getViews(); + assertThat(firstUpdatedView).isEqualTo(first); + + CountDownLatch latch2 = new CountDownLatch(second); + for (int i = first; i < first + second; i++) { + final int idx = i; + executorService.submit(() -> { + try { + String userKey = "user:" + idx; + postViewService.incrementPostView(postId, 0, userKey); + } finally { + latch2.countDown(); + } + }); + } + latch2.await(); + executorService.shutdown(); + int secondSavedView = postViewService.getPostView(postId, 0); + postRepository.updatePostView(postId, secondSavedView); + em.flush(); + em.clear(); + + int redisView = postViewService.getPostView(postId, 0); + int dbView = postRepository.findById(postId).get().getViews(); + + assertThat(redisView).isEqualTo(dbView).isEqualTo(first + second); + } +} \ No newline at end of file