@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
3. Repository 생성
1. TodoRepository에서 TodoRepositoryQuery를 상속하기
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryQuery {
}
Local Cache 전략을 사용하게 되면 속도가 빠르지만 다중 서버 환경에서는 각 서버마다 중복된 데이터를 보관해야 하고, 동기화되지 않기 때문에 데이터의 정합성 문제가 발생하기 때문에 속도가 Local Cache 보다는 느리지만 정합성과 중복성 문제를 해결하기 위한 Global Cache 방식을 선택!
Redis 선택 이유
다양한 캐시 데이터베이스 중에서도 Redis는 다양한 자료구조를 지원함 -> 이번에 구현해야 할 상위 10개 검색 상품을 저장할 때 ZSet 자료구조를 이용하여 key-value임에도 불구하고 빠르게 정렬해서 조회가 가능
다양한 라이브러리를 지원하기 때문에 spring boot에서도 간단하게 사용할 수 있음
조회수 같은 숫자 데이터는 메모리 부담이 적기 때문에 사용하기 적절하다고 판단!
ZSet (Sorted Set) 자료구조 사용
Key-Value 구조인 Redis에서 정렬이 가능한 자료구조
각 요소에 점수(Score)를 부여하고 이를 오름차순이나 내림차순으로 정렬할 수 있음
O(log N)의 빠른 성능을 가짐
조회수 데이터 저장 : Redis에 조회수 데이터를 ZSet을 이용하여 구현
// 조회수가 없는 경우엔 1로 초기화, 존재하는 경우엔 조회수를 1만큼 증가
public Long addReadCount(Long productId) {
if (redisUtils.notExistsReadCount(productId)){
redisUtils.setReadCount(productId);
return 1L;
}
return redisUtils.addReadCount(productId);
}
조회수같이 자주 조회되고, 정각마다 초기화되는 데이터는 RDB에 저장하는 것보다 캐싱하여 빠르게 조회되고 갱신하는 것이 더 낫다고 판단함
해당 상품의 조회수가 존재하지 않는다면 product:readCount라는 ZSet의 키 이름을저장하고"product: + id"라는 이름의 값에Score를 1로 세팅
// 어뷰징 검증, 24시간 이내에 방문했다면 기존 조회수 조회, 아니라면 조회수 증가
public Long findReadCount(Long productId, Long userId) {
Boolean isNotViewed = redisUtils.isNotViewed(productId, userId);
if (Boolean.TRUE.equals(isNotViewed)) {
return addReadCount(productId);
}
return redisUtils.getReadCount(productId);
}
isNotViewed 메서드를 통해서 사용자가 정각 이전에 1번 이상 해당 상품을 조회했는지 검증한 후에 조회한 적이 있다면 기존 조회수 조회
조회한 적이 없다면 조회수를 1만큼 증가시킴
조회수 상위 10개 상품 랭킹 조회 : Redis에 저장된 상위 10개 상품 id를 RDB에 보내서 조회 후 캐싱
레디스에 상품조회를 할 때마다 조회수를 누적한다 (정각에 초기화)
사용자가 랭킹조회 api를 누르면 레디스에 저장된 조회수 리스트 상위 10개가져옴(상품 키값)
10개의 키값들을 가져와서 그 리스트와 매칭하여 엔티티들을 10개 가져와서 순위와 함께 엔티티 리스트를 보여준다 → in 사용하면 됨, pk조회는 빨라서 부담 없음
해당 리스트를 레디스에 저장하고 ttl이 만료되기 전까지는 레디스에서 조회
// 조회수 상위 10개의 상품 id 리스트 조회
public List<Long> findProductIds() {
Set<Object> objectSet = redisTemplate.opsForZSet().reverseRange("product:readCount",0, 9);
if (objectSet == null) {
return Collections.emptyList();
}
//"product:7" -> ["product", "7"] -> ["7"]로 변환
List<Long> productIdList = new ArrayList<>();
for (Object o : objectSet) {
String value = (String) o;
String[] parts = value.split(":");
productIdList.add(Long.parseLong(parts[1]));
}
return productIdList;
}
만약 댓글 저장 repository를 테스트 할 건데 아래와 같이 코드를 짜면 에러가 난다
comment를 저장하기 위해서는 user와 todo가 저장되어 있어야 하기 때문이다
@DataJpaTest
class CommentRepositoryTest {
@Autowired
private CommentRepository commentRepository;
@Test
public void 댓글이_정상적으로_저장된다() {
// given
User user = new User("yn1013@naver.com", "password", UserRole.USER);
Todo todo = new Todo("title", "contents", "sunny", user);
Comment comment = new Comment("comments", user, todo);
// when
Comment savedComment = commentRepository.save(comment);
// then
assertThat(comment).isSameAs(savedComment);
assertThat(comment.getId()).isSameAs(savedComment.getId());
assertThat(savedComment.getId()).isNotNull();
assertThat(commentRepository.count()).isEqualTo(1);
}
}
TransientPropertyValueException : Spring Data JPA에서 잘못된 데이터 접근 API 사용 시 발생하는 예외
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: Not-null property references a transient value - transient instance must be saved before current operation : org.example.expert.domain.comment.entity.Comment.user -> org.example.expert.domain.user.entity.User
at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:368)
수정된 코드
그래서 아래와 같이 수정해서 짜야 한다! -> user와 todo 또한 @Autowired로 주입한 후에 save를 때려준다
@DataJpaTest
class CommentRepositoryTest {
@Autowired
private CommentRepository commentRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private TodoRepository todoRepository;
@Test
public void 댓글이_정상적으로_저장된다() {
// given
User user = new User("yn1013@naver.com", "password", UserRole.USER);
Todo todo = new Todo("title", "contents", "sunny", user);
Comment comment = new Comment("comments", user, todo);
userRepository.save(user);
todoRepository.save(todo);
// when
Comment savedComment = commentRepository.save(comment);
// then
assertThat(comment).isSameAs(savedComment);
assertThat(comment.getId()).isSameAs(savedComment.getId());
assertThat(savedComment.getId()).isNotNull();
assertThat(commentRepository.count()).isEqualTo(1);
}
}
조인되서 함께 조회가 되는지 테스트 해보는 코드
@DataJpaTest
class TodoRepositoryTest {
@Autowired
private TodoRepository todoRepository;
@Autowired
private UserRepository userRepository;
@Test
public void Todo와_작성자를_함께_조회한다() {
// given
String email = "yn@abc.com";
User user = new User(email, "password", UserRole.USER);
userRepository.save(user);
String weather = "맑음";
Todo todo = new Todo(
"할일 제목",
"할일 내용",
weather,
user
);
Todo savedTodo = todoRepository.save(todo);
// when
Todo foundTodo = todoRepository.findByIdWithUser(savedTodo.getId()).orElse(null);
// then
assertNotNull(foundTodo);
assertEquals("할일 제목", foundTodo.getTitle());
assertEquals("할일 내용", foundTodo.getContents());
assertEquals("yn@abc.com", foundTodo.getUser().getEmail());
}
}
저장, 삭제, 조회 테스트 해보는 코드
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
public void 사용자가_저장된다() {
// given
User user = new User("yn1013@naver.com", "password", UserRole.USER);
// when
User savedUser = userRepository.save(user);
// then
assertThat(user).isSameAs(savedUser);
assertThat(user.getEmail()).isSameAs(savedUser.getEmail());
assertThat(savedUser.getId()).isNotNull();
assertThat(userRepository.count()).isEqualTo(1);
}
@Test
public void 사용자를_id로_조회한다() {
// given
User user1 = new User("yn1013@naver.com", "password", UserRole.USER);
User user2 = new User("yn1122@naver.com", "password", UserRole.USER);
User savedUser1 = userRepository.save(user1);
User savedUser2 = userRepository.save(user2);
// when
User findUser1 = userRepository.findById(savedUser1.getId())
.orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
User findUser2 = userRepository.findById(savedUser2.getId())
.orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
// then
assertThat(userRepository.count()).isEqualTo(2);
assertThat(findUser1.getEmail()).isEqualTo("yn1013@naver.com");
assertThat(findUser2.getEmail()).isEqualTo("yn1122@naver.com");
}
@Test
public void 사용자를_id로_삭제한다() {
// given
User user = new User("yn1013@naver.com", "password", UserRole.USER);
User savedMember = userRepository.save(user);
Long userId = savedMember.getId();
// when
userRepository.deleteById(userId);
// then
assertThat(userRepository.count()).isEqualTo(0);
Optional<User> deletedUser = userRepository.findById(userId);
assertThat(deletedUser).isEmpty();
}
@Test
public void 이메일로_사용자를_조회할_수_있다() {
// given
String email = "yn@abc.com";
User user = new User(email, "password", UserRole.USER);
userRepository.save(user);
// when
User foundUser = userRepository.findByEmail(email).orElse(null);
// then
assertNotNull(foundUser);
assertEquals(email, foundUser.getEmail());
assertEquals(UserRole.USER, foundUser.getUserRole());
}
@Test
public void 이메일이_존재하는지_확인할_수_있다() {
// given
String email = "yn@abc.com";
User user = new User(email, "password", UserRole.USER);
userRepository.save(user);
// when
Boolean emailExists = userRepository.existsByEmail(email);
// then
assertNotNull(emailExists);
assertEquals(true, emailExists);
}
}
Service 테스트 작성 연습
@ExtendWith(MockitoExtension.class) 붙이기
테스트하고자 하는 객체에 @InjectMocks를 붙이기
그 외의 객체에 @Mock을 붙이기
시행착오
테스트 하려는 부분
여기서 passwordEncode를 사용하고 있고, 유저 서비스에서도 이것을 private final로 주입받고 있기 때문에 Mock을 주입 해야 함
작성된 코드
→ 사실 이러면 틀린다!
passwordEncoder.matches()를 모킹하지 않음
실제 passwordEncoder.matches()가 실행되어 예상과 다르게 동작된다
인코딩된 값이 아닌 문자열 자체를 비교해버림!
@Test
public void 비밀번호_변경시에_기존_비밀번호가_유효하지_않으면_InvalidRequestException을_던진다() {
// given
String email = "yn1013@naver.com";
long userId = 1L;
User user = new User(email, "password", UserRole.USER);
ReflectionTestUtils.setField(user, "id", userId);
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
String oldPassword = "oldPassword";
String newPassword = "newPassword";
UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);
// when & then
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
assertEquals("비밀번호가 잘못되었습니다.", exception.getErrorCode().getMessage());
}
그래서 수정된 코드!
전체 서비스 메서드 코드
리포지토리에서 사용자 조회하는 부분
@Test
public void User를_Id로_조회할_수_있다() {
// given
String email = "yn1013@naver.com";
long userId = 1L;
User user = new User(email, "password", UserRole.USER);
ReflectionTestUtils.setField(user, "id", userId);
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
// when
UserResponse userResponse = userService.getUser(userId);
// then
assertThat(userResponse).isNotNull();
assertThat(userResponse.getId()).isEqualTo(userId);
assertThat(userResponse.getEmail()).isEqualTo(email);
}
@Test
public void 존재하지_않는_User_조회시에_InvalidRequestException을_던진다() {
// given
long userId = 1L;
given(userRepository.findById(anyLong())).willReturn(Optional.empty());
// when & then
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.getUser(userId));
assertEquals("유저가 존재하지 않습니다.", exception.getErrorCode().getMessage());
}
첫 번째 예외 처리 부분
@Test
public void 비밀번호_변경시에_기존_비밀번호와_동일하면_InvalidRequestException을_던진다() {
// given
String email = "yn1013@naver.com";
long userId = 1L;
User user = new User(email, "password", UserRole.USER);
ReflectionTestUtils.setField(user, "id", userId);
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
String oldPassword = "oldPassword";
String newPassword = "oldPassword";
UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);
given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(true);
// when & then
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
assertEquals("이미 사용 중인 비밀번호 입니다.", exception.getErrorCode().getMessage());
}
두번째 예외처리 부분
@Test
public void 비밀번호_변경시에_기존_비밀번호가_유효하지_않으면_InvalidRequestException을_던진다() {
// given
String email = "yn1013@naver.com";
long userId = 1L;
User user = new User(email, "password", UserRole.USER);
ReflectionTestUtils.setField(user, "id", userId);
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
String oldPassword = "oldPassword";
String newPassword = "newPassword";
UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);
given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(false);
given(passwordEncoder.matches(request.getOldPassword(), user.getPassword())).willReturn(false);
// when & then
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
assertEquals("비밀번호가 잘못되었습니다.", exception.getErrorCode().getMessage());
}
비밀번호 검증 부분
@Test
public void 비밀번호가_성공적으로_변경된다() {
// given
long userId = 1L;
User user = new User("yn1013@naver.com", "oldPassword", UserRole.USER);
ReflectionTestUtils.setField(user, "id", userId);
String oldPassword = "oldPassword";
String newPassword = "newPassword";
UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);
// 서비스 내부에서 findById 실행
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
// 첫 번째 검증 로직 통과하도록 설정
given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(false);
// 두 번째 검증 로직 통과하도록 설정
given(passwordEncoder.matches(request.getOldPassword(), user.getPassword())).willReturn(true);
// changePassword 안에 들어갈 값 설정
given(passwordEncoder.encode(request.getNewPassword())).willReturn("encodedNewPassword");
// when
userService.changePassword(userId, request);
// then
assertEquals("encodedNewPassword", user.getPassword());
}
이제 잘 돌아간다!
Controller 테스트 작성 연습
1. `@WebMvcTest(XxxController.class)`를 붙인다.
2. @MockBean 혹은 @MockitoBean을 붙인다. (전자는 3.4 이전, 후자는 3.4 이후)
3. API 호출은 아래의 MockMvc를 사용
테스트할 부분
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
public void user_단건_조회() throws Exception {
// given
long userId = 1L;
String email = "yn1013@naver.com";
BDDMockito.given(userService.getUser(userId)).willReturn(new UserResponse(userId, email));
// when & then
mockMvc.perform(get("/users/{userId}", userId))
.andExpect(status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.id").value(userId))
.andExpect(MockMvcResultMatchers.jsonPath("$.email").value(email));
}
}
컨트롤러 에러난 경우 테스트
아래 로그를 보고 똑같이 맞춰주면 된다
@Test
public void user_단건_조회_시_user가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long userId = 1L;
// when
Mockito.when(userService.getUser(userId))
.thenThrow(new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
// then
mockMvc.perform(get("/users/{userId}", userId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) //숫자
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) //문자
.andExpect(jsonPath("$.message").value("유저가 존재하지 않습니다."));
}
리스트 조회 테스트
@Test
void User_목록_조회_빈리스트() throws Exception {
// given
given(userService.getUsers()).willReturn(List.of());
// when & then
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void User_목록_조회() throws Exception {
// given
long userId1 = 1L;
long userId2 = 2L;
String email1 = "user1@a.com";
String email2 = "user2@a.com";
List<UserResponse> userList = List.of(
new UserResponse(userId1, email1),
new UserResponse(userId2, email2)
);
given(userService.getUsers()).willReturn(userList);
// when & then
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].id").value(userId1))
.andExpect(jsonPath("$[0].email").value(email1))
.andExpect(jsonPath("$[1].id").value(userId2))
.andExpect(jsonPath("$[1].email").value(email2));
}
엔티티를 JPA 영속성 컨텍스트에 영속화(persist) 하게 되면, 이후 JPA 영속성 컨텍스트에서 상태를 관리함
Entity Manager Factory
jpa에서 엔티티 메니저를 생성하고 관리하는 팩토리 객체
애플리케이션 실행시 한번만 생성됨, 애플리케이션 전체에서 공유됨.
여러 스트레드가 동시에 객체나 메서드에 접근할 때 데이터의 일관성을 보관할 수 있음
엔티티 메시저 생성, 디비 연결, 트랜젝션 관리
Entity Manager
디비와 상호작용하는 객체, 엔티티의 생명 주기를 관리, 여러 스레드가 동시에 접근할 때 일관성 보장 ㄴㄴ
엔티티 객체의 라이프 사이클과 영속성 관리
엔티티 객체에 대해서 디비 crud 연산 수행
OSIV(Open Session in View)
영속성 컨텍스트를 View 렌더링이 끝날 때까지 개방된 상태로 유지하는 방식
장점
컨트롤러나 뷰에서도 지연 로딩 사용 가능
단점 : 디비 커넥션 점유
컨트롤러와 뷰 렌더링이 끝날 때까지 디비 커넥션 유지
커넥션 보유 시간이 길어져 서버 자원 많이 사용
과도한 db 커넥션 사용으로 인한 성능 저하 가능성
OSIV를 비활성화(false) 하여 사용하는 것이 좋음
spring.jpa.open-in-view=false
Proxy
프록시(Proxy): 실제 엔터티 객체 대신, 그 객체를 감싸고 있는 대리 객체
실제 데이터는 데이터베이스에서 가져오지 않고, 필요한 시점에만 쿼리를 실행해 데이터를 조회하는 방식
불필요한 쿼리의 발생을 방지
이게 바로 프록시 객체!
무비를 조회할 때 연관관계가 맺어진 감독들 객체를 lazy로 조회
감독들을 제외하고 조회하는 경우에 무비 안에 있는 배우는 프록시로 조회됨
-> 불필요한 DB 통신 미사용 및 DB 부하 감소
@Transactional을 사용할 때 private 접근제어자를 사용하면 안 됨
@transactional은 aop로 만들어져 있음
aop도 내부적으로 프록시를 사용하여 만들어져 있음
프록시가 동작을 하려면 내부적으로 상속이든 구현체든 뭐가 되어서 접근을 해야하는데, 메서드가 private으로 되어 있으면 접근 못함
@Transactional
Spring에서 메서드 또는 클래스에 적용하여 트랜잭션을 선언적으로 관리하는 방법
트랜잭션 범위에 대해 Proxy 패턴을 사용하여 트랜잭션 관리
설정 가능한 옵션 제공
종류 : propagation(기본), isolation, timeout, readOnly, rollbackFor, noRollbackFor
readOnly = true로 설정하여 성능상 이점을 가질 수 있음 -> 변경 감지(Dirty Checking)가 비활성화
예시 : rollbackFor - 해당 예외가 발생할 때만 롤백을 수행한다
@Transactional(rollbackFor = {IOException.class, SQLException.class})
public void registerUser(User user) throws IOException, SQLException {
userRepository.save(user);
if (someCondition) {
throw new IOException("IOException 발생!"); // 롤백
}
if (someOtherCondition) {
throw new SQLException("SQLException 발생!"); // 롤백
}
}
Propagation
트랜잭션이 다른 트랜잭션과 어떻게 상호작용할지를 결정하는 방식
서로 다른 서비스에 있는 트랜잭션끼리 어떻게 관리되고 전파할것인지 결정하는 것
종류
REQUIRED (기본값): 이미 진행 중인 트랜잭션이 있으면 그 트랜잭션을 사용하고, 없으면 새 트랜잭션을 시작
REQUIRES_NEW: 항상 새 트랜잭션을 시작하며, 진행 중인 트랜잭션은 잠시 중단
SUPPORTS: 트랜잭션이 이미 존재하면 그 트랜잭션 내에서 실행하고, 없으면 비트랜잭션 실행
MANDATORY: 현재 트랜잭션이 반드시 있어야 하며, 없으면 예외가 발생
NEVER: 트랜잭션 없이 실행되어야 하며, 트랜잭션이 존재하면 예외가 발생
NESTED: 진행 중인 트랜잭션 내부에 또 다른 트랜잭션을 중첩시켜 실행할 수 있으며, 이 경우 Savepoint를 이용
Propagation 예시 (REQUIRES_NEW)
현재 서로 다른 서비스에서 개별적으로 트랜잭션이 관리되고 있음
만약 changeUserRole에서 에러가 발생하면 사용자는 역할을 변경하지 못하고 로그도 찍히지 못할 것이다
기본값이 REQUIRED이기 때문에 saveLog와 changeUserRole은 같은 트랜잭션을 쓰기 때문이다!
@Transactional
public void saveLog(Long userId, LocalDateTime requestTime) {
Log log = new Log(userId, requestTime);
logRepository.save(log);
}
@Transactional
public void changeUserRole(AuthUser authUser, long userId, UserRoleChangeRequest userRoleChangeRequest) {
User requestUser = User.fromAuthUser(authUser);
logService.saveLog(requestUser.getId(), LocalDateTime.now());
User user = userRepository.findById(userId).orElseThrow(() -> new InvalidRequestException("User not found"));
user.updateRole(UserRole.of(userRoleChangeRequest.getRole()));
}
이때 changeUserRole에서 에러가 발생해도 saveLog는 그와 상관없이 독립적으로 수행되고 싶다면 Propagation 설정을 REQUIRES_NEW로 변경해주면 된다!
SecurityContext에 AbstractAuthenticationToken을 set해야 한다
로그인을 하면 → SecurityContext 내부에 Authentication 객체가 저장
필요할 때 SecurityContext에서 현재 로그인한 사용자의 정보를 꺼낼 수 있음
SecurityContextHolder를 통해 SecurityContext에 접근 가능
로그아웃하면 SecurityContext가 초기화
SecurityContextHolder
SecurityContext를 저장하고 관리하는 저장소
📌 "할 일 등록 API" 요청 (/todos)
[ 새로운 SecurityContextHolder 생성 ]
-> 인증 정보 저장 (SecurityContext에 저장됨), 이 SecurityContext를 SecurityContextHolder에 저장
-> 할 일 등록 진행
-> 요청 종료 후 SecurityContext 초기화
이 과정은 각각의 스레드별로 독립적으로 진행됨!
Spring Security 문제점 : 세션 기반으로 사용하려는 것
Spring Security → 2003년에 나옴 → 세션 방식
JWT → 2015년 표준화됨 → 서버는 stateless
즉 JWT는 stateless 하지만 Security는 stateless하지 않다!
UserDetailsServiceImpl의 문제
user 의 정보를 알기 위해서 UserRepository를 통해 디비에 다녀옴 → 세션이랑 다를 게 없음 → 애초에 jwt에 사용자 id, 권한 등등을 넣어 놓는데 이렇게 되면 jwt는 세션키의 역할만 될 뿐! (차라리 세션키 쓰는게 훨 낫다 덜 뚱뚱하기 때문이다)
또한 데이터베이스를 나눠놓는 아키텍쳐인 MSA에도 적용하지 못함 (만약 user db가 죽는다면 서비스는 아예 죽게 됨)
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
UserDetailsServiceImpl Stateless 적용
사용자의 정보를 userRepository가 아닌, 이미 정보를 가지고 있는 JWT 토큰에서 까서 가져오면 된다
헤더에서 JWT 토큰을 가져오고
JWT를 해석(파싱)하여 토큰 내부의 데이터(payload)를 반환한 Claims객체를 반환한다
Claims객체를 이용하여 JwtAuthenticationToken을 생성하고 SecurityContext에 set 해준다
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void 유저_데이터를_100만_건_생성() {
for (long i = 0L; i < 1000000; i++){
String email = "user" + i + "@example.com";
String password = "securePassword123";
UserRole role = UserRole.of("ROLE_USER");
String nickName = UUID.randomUUID().toString().substring(0, 8);
User user = new User(email, password, role, nickName);
userRepository.save(user);
}
}
}
-> NoSuchBeanDefinitionException 발생
EntityManager를 찾을 수 없다고 한다
테스트 패키지 하위에 config 파일을 만들고 해결
@TestConfiguration // 테스트 환경 전용 설정
public class TestQuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
두 번째 시도 : JdbcSQLSyntaxErrorException
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TODOS" not found; SQL statement:
생성된 쿼리를 보니 동일한 테이블을 생성하는 쿼리가 여러번 날라가면서 SyntaxErrorException이 계속 뜬다
원인
기본적으로 설정된 테스트용 h2 데이터베이스
따로 설정해둔 properties 파일이 적용되지 않음
현재 나는 테스트용 데이터베이스를 mysql애 연결하고 싶어서 설정파일을 따로 빼놓았는데 자동으로 h2로 실행하려 해서 mysql과 호환이 안되서 그런 것 같다