Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
// 인증
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "인증 코드가 유효하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
12 changes: 8 additions & 4 deletions src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import java.time.Duration;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import lombok.RequiredArgsConstructor;
Expand All @@ -15,6 +14,9 @@ public class RedisUtil {

private final StringRedisTemplate redisTemplate;

/**
* 이메일 관련
*/
public String getData(String key) {
return redisTemplate.opsForValue().get(key);
}
Expand Down Expand Up @@ -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);
}

Expand All @@ -62,4 +62,8 @@ public Map<Long, Integer> getAllPostView(String key) {
e -> Integer.parseInt(e.getValue().toString())
));
}

public void clearAll() {
redisTemplate.getConnectionFactory().getConnection().serverCommands().flushAll();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/test/java/com/jiwon/mylog/config/EmbeddedRedisConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}