jakarta.servlet.ServletException: Request processing failed:
org.springframework.orm.jpa.JpaSystemException: could not execute statement
[Connection is read-only. Queries leading to data modification are not allowed]
[insert into todos (contents,created_at,modified_at,title,user_id,weather) values (?,?,?,?,?,?)]
클래스에 @Transactional(readOnly = true)
설정이 되어있어서 해당 오류가 발생한걸로 판단하고 메서드에 새로 @Transaction
을 붙혀 설정을 덮어씌웠다.
User 엔티티에 nickname 컬럼 추가하기
JWT 토큰에 nickname이 추가되어야 함으로 JwtUtil.class
에서
Jwts.builder().claim("nickname", nickname)
를 추가했고
JwtFilter.class
에선 로컬에 사용될 계정 정보들을
httpRequest.setAttribute("nickname", claims.get("nickname"));
으로 HttpServletRequest
에 저장해뒀다.
weather
과 수정일 기준 기간 검색 기능 추가하기@Query("SELECT t FROM Todo t "+
"LEFT JOIN FETCH t.user "+
"WHERE (:weather IS NULL OR t.weather LIKE CONCAT('%', :weather, '%')) AND "+
"(:start_date IS NULL OR t.modifiedAt >= :start_date) AND "+
"(:end_date IS NULL OR t.modifiedAt <= :end_date ) "+
"ORDER BY t.modifiedAt DESC")
테스트 패키지 org.example.expert.domain.todo.controller
의
todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()
테스트 수정하기
예외 시 예상되는 HttpStatus는 OK
가 아닌 BAD_REQUEST
였기 때문에 이 부분을 수정했다.
AdminAccessLoggingAspect
AOP가 UserAdminController
클래스의 changeUserRole()
메소드가 실행 전 동작할 수 있도록 수정하기@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
UserController.getUser()
함수가 실행 후 동작하는 코드이므로@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
UserAdminController.changeUserRole()
함수가 실행 되기 전 동작하는 코드로 변경했다.
@OneToMany(mappedBy = "todo")
private List<Manager> managers = new ArrayList<>();
public Todo(String title, String contents, String weather, User user) {
...
this.managers.add(new Manager(user, this));
}
@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();
cascade = CascadeType.PERSIST
옵션을 추가해 저장 상태에 대한 영속성 전이를 가능하게 했다.
CommentController
클래스의 getComments()
API를 호출할 때 N+1 문제가 발생하지 않게 수정하기@Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);
User
에 대한 Join만 했는데, 이렇게 불러온 엔티티가 LAZY 설정일 경우 해당 엔티티는 실제 객체가 아닌 프록시 객체만 가져와지므로 엔티티 내부 데이터를 호출 할 시 추가 쿼리가 발생한다.@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
JOIN FETCH
를 걸어줌으로 조회 시 프록시 객체가 아닌 실제 객체를 불러올 수 있도록 수정했다.
findByIdWithUser
를 QueryDSL로 변경하고 N+1 문제 해결하기@Query("SELECT t FROM Todo t " +
"LEFT JOIN t.user " +
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
fetchJoin()
을 붙혀 user 조회 시 추가 발생할 쿼리를 방지했다.public Optional<Todo> findByIdWithUser(Long todoId) {
return Optional.ofNullable(
jpaQueryFactory
.selectFrom(todo)
.leftJoin(todo.user, user).fetchJoin()
.where(todo.id.eq(todoId))
.fetchOne());
}
Filter
와 Argument Resolver
를 사용해 인증/인가하던 코드를 Spring Security로 변경하기@EnableWebSecurity
어노테이션이 붙은 Config를 생성해 Spring Security를 활성화 시키고, 기존 HttpServletRequest
를 통해 전달받던 사용자 정보를 SecurityContextHolder
를 사용하여 파라미터 전달 없이 어디서든 SecurityContextHolder.getContext().getAuthentication()
를 통해 정보를 받아올 수 있게 했다.QueryDSL을 사용해 일정을 검색하는 기능을 만들고, 결과값을 페이징 처리와 Projections
을 활용해 필요한 필드만 반환하도록 하기.
public Page<TodoSearchResponse> searchTodos(
String title,
LocalDate startDate,
LocalDate endDate,
String nickname,
Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if(title != null && !title.isEmpty()) {
builder.and(todo.title.containsIgnoreCase(title));
}
if(startDate != null && endDate != null) {
builder.and(todo.createdAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX)));
} else if(startDate != null) {
builder.and(todo.createdAt.after(startDate.atStartOfDay()));
} else if(endDate != null) {
builder.and(todo.createdAt.before(endDate.atTime(LocalTime.MAX)));
}
if(nickname != null && !nickname.isEmpty()) {
builder.and(todo.user.nickname.containsIgnoreCase(nickname));
}
List<TodoSearchResponse> list = jpaQueryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
todo.managers.size(),
todo.comments.size()
))
.from(todo)
.where(builder)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> totalQuery = jpaQueryFactory
.select(todo.count())
.from(todo);
return PageableExecutionUtils.getPage(list, pageable, totalQuery::fetchOne);
}
@Transaction
을 설정하기@Transactional(propagation = Propagation.REQUIRES_NEW)
처리해서 별개의 동작을 할 수 있도록 설정했다.