📚
@Transactional(readOnly = true)옵션은 Spring 에서 트랜잭션을 관리할 때 읽기 전용으로 설정하는 옵션으로 조회(SELECT) 성능이 최적화된다.
→ 영속성 컨텍스트가 변경 감지를 하지 않음으로써 불필요한 스냅샷이 줄어든다.
readOnly = true옵션을 적용하여 쓰기 연산(INSERT/UPDATE/DELETE)을 수행하는 경우 예외가 발생한다.- EntityManager가 쓰기 연산을 무시하거나 예외를 던질 수 있다.
- DML 실행 시 오류 발생 가능성이 있다.

@Transactional 우선 순위@Transactional 어노테이션의 Target은 Type(타입, 클래스), Method 이다.

Type(클래스)에 선언하면 클래스 내 모든 메서드에 적용된다.
Method에 선언하면 해당 메서드의 설정이 우선 적용된다.
@Transactional 이 부모 클래스의 @Transactional 보다 우선 적용된다.package org.example.expert.domain.user.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.common.entity.Timestamped;
import org.example.expert.domain.user.enums.UserRole;
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String password;
@Enumerated(EnumType.STRING)
private UserRole userRole;
private String nickname;
public User(String email, String password, UserRole userRole, String nickname) {
this.email = email;
this.password = password;
this.userRole = userRole;
this.nickname = nickname;
}
private User(Long id, String email, UserRole userRole, String nickname) {
this.id = id;
this.email = email;
this.userRole = userRole;
this.nickname = nickname;
}
public static User fromAuthUser(AuthUser authUser) {
return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole(), authUser.getNickname());
}
public void changePassword(String password) {
this.password = password;
}
public void updateRole(UserRole userRole) {
this.userRole = userRole;
}
}
package org.example.expert.domain.common.dto;
import lombok.Getter;
import org.example.expert.domain.user.enums.UserRole;
@Getter
public class AuthUser {
private final Long id;
private final String email;
private final UserRole userRole;
private final String nickname;
public AuthUser(Long id, String email, UserRole userRole, String nickname) {
this.id = id;
this.email = email;
this.userRole = userRole;
this.nickname = nickname;
}
}
public String createToken(Long userId, String email, UserRole userRole, String nickname) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole)
.claim("nickname", nickname)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
httpRequest.setAttribute("nickname", claims.get("nickname"));
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
// JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
// level1 - 2: User nickname 추가
String nickname = (String) request.getAttribute("nickname");
return new AuthUser(userId, email, userRole, nickname);
}

weather 조건 검색수정일 기준으로 기간 검색@GetMapping("/todos")
public ResponseEntity<Page<TodoResponse>> getTodos(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String weather,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") String startDate,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") String endDate
) {
return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate));
}@RequestParam 으로 검색 조건 weather, startDate(기간시작), endDate(기간끝) 요청 데이터를 받는다.public Page<TodoResponse> getTodos(int page, int size, String weather, String startDate, String endDate) {
Pageable pageable = PageRequest.of(page - 1, size);
LocalDateTime startDateTime = StringUtils.isEmpty(startDate) ? null : LocalDate.parse(startDate).atStartOfDay();
LocalDateTime endDateTime = StringUtils.isEmpty(endDate) ? null : LocalDate.parse(endDate).atStartOfDay().plusDays(1);
Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable, weather, startDateTime, endDateTime);
return todos.map(todo -> new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
todo.getCreatedAt(),
todo.getModifiedAt()
));
}@Query("""
SELECT t
FROM Todo t
LEFT JOIN FETCH t.user u
WHERE 1=1
AND (:weather IS NULL OR t.weather LIKE CONCAT('%', :weather, '%'))
AND (:startDateTime IS NULL OR t.modifiedAt >= :startDateTime)
AND (:endDateTime IS NULL OR t.modifiedAt < :endDateTime)
ORDER BY t.modifiedAt DESC
""")
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable,
@Param(value = "weather") String weather,
@Param(value = "startDateTime") LocalDateTime startDateTime,
@Param(value = "endDateTime") LocalDateTime endDateTime);
:param IS NULL 로 OR 뒤 쿼리 무시개선 필요: OR 연산은 성능 저하를 유발하여 지양한다.
날씨(weather) 검색

기간(startDate, endDate) 검색




🚨 Expected : 200(Ok) ≠ Actual: 400 → ****기대값과 결과값이 일치하지 않음
해당 테스트 코드는 todo 데이터가 존재하지 않는 경우 예외가 발생하는지 확인하는 코드로 결과가 예외(400번대)인지 확인해야 한다.
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 1L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new InvalidRequestException("Todo not found"));
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name()))
.andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.message").value("Todo not found"));
}changeUserRole() 메소드 실행 전 동작



execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..)@After → @Before