▶ CH6 Spring 심화 개인 과제
▷ Level 1-2 리펙토링 퀴즈 - 불필요한 if-else 피하기
public String getTodayWeather() {
ResponseEntity<WeatherDto[]> responseEntity =
restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);
WeatherDto[] weatherArray = responseEntity.getBody();
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
} else {
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException("날씨 데이터가 없습니다.");
}
}
String today = getCurrentDate();
for (WeatherDto weatherDto : weatherArray) {
if (today.equals(weatherDto.getDate())) {
return weatherDto.getWeather();
}
}
throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
}
해당 코드에서 아래의 코드를 리펙토링하겠다.
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
} else {
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException("날씨 데이터가 없습니다.");
}
}
해당 코드에서 불필요한 if-else문을 제거하는 것이 이번 문제의 목표이다.
코드를 살펴보면 응답요청에서 가져온 상태코드가 같지 않다면 예외처리를 시도하고,
아닌 경우에는 다른 조건을 비교하고 참일 경우에 그 코드의 예외처리를 시도하는 것이 코드의 구조이다.
저번에도 말했지만 throw를 통해서 예외처리를 할 경우 해당 코드에서의 동작은 멈추고 예외처리를 시도하게 된다.
즉, 조건에 맞게 된다면 예외처리를 시도하기 때문에 따로 else문이 필요가 없다.
public String getTodayWeather() {
ResponseEntity<WeatherDto[]> responseEntity =
restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);
WeatherDto[] weatherArray = responseEntity.getBody();
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
}
//구조상 else문 자체가 불필요하다.
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException("날씨 데이터가 없습니다.");
}
String today = getCurrentDate();
for (WeatherDto weatherDto : weatherArray) {
if (today.equals(weatherDto.getDate())) {
return weatherDto.getWeather();
}
}
throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
}
▷ Level 1-3 코드 개선 문제 - Validation
※ Validation이란?
Spring에서 유효성을 검사하는 방법으로 사용할 때는 @Valid나 @Validation을 사용하여 기능을 쓸 수 있다.
※ 왜 사용하는가?
Validation을 사용하면 보안과 데이터 무결성을 유지하면서 코드의 가독성을 높일 수 있기 때문이다.
그럼 Validation을 사용하여 레벨 1-3을 풀어보자
@Transactional
public void changePassword(long userId, UserChangePasswordRequest userChangePasswordRequest) {
if (userChangePasswordRequest.getNewPassword().length() < 8 ||
!userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
!userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new InvalidRequestException("User not found"));
if (passwordEncoder.matches(userChangePasswordRequest.getNewPassword(), user.getPassword())) {
throw new InvalidRequestException("새 비밀번호는 기존 비밀번호와 같을 수 없습니다.");
}
if (!passwordEncoder.matches(userChangePasswordRequest.getOldPassword(), user.getPassword())) {
throw new InvalidRequestException("잘못된 비밀번호입니다.");
}
user.changePassword(passwordEncoder.encode(userChangePasswordRequest.getNewPassword()));
}
해당 코드에서
if (userChangePasswordRequest.getNewPassword().length() < 8 ||
!userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
!userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
}
이 부분을 해당 API의 요청 DTO에서 처리할 수 있도록 개선하는 것이 이번 문제의 목표이다.
여기서 validation 라이브러리를 활용하는 것이 조건이다.
해당 코드를 해석하자면 요청한 newPassword가 8자 보다 크고, 숫자와 대문자를 포함하고 있어야 예외처리를 실행하지 않고 다음 코드로 넘어가는 구조이다.
다만, 위의 기능을 Validation을 이용하면 위의 코드를 작성할 필요 없이 요청 DTO 내에서 처리할 수 있다.
public class UserChangePasswordRequest {
@NotBlank
private String oldPassword;
@NotBlank
@Pattern(regexp = "^(?=.*\\d)(?=.*[A-Z]).{8,}$", message = "새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.")
private String newPassword;
}
Validation라이브러리 내의 @Pattern을 사용하여 해당 조건을 구현하였다.
이처럼 Validation을 이용하여 전 코드와 기능은 똑같지만 코드의 가독성이 좋아진 것만 아니라 필드 자체에서 검증을 시도하기 때문에 데이터 무결성도 유지한 것을 알 수 있다.
▷ Level 2. N+1 문제
※ @EntityGraph란?
연관된 엔티티를 한 번의 쿼리로 함께 조회하는 기능을 제공한다.
fetch join과 유사하게 동작하며, N+1 문제를 해결하는데 유용하다.
그럼 2번 문제를 풀어보자
public Page<TodoResponse> getTodos(int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size);
Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);
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()
));
}
해당 메서드에서 모든 Todo를 조회할 때, 각 Todo와 연관된 데이터를 개별적으로 가져오는 N+1 문제가 발생할 수 있는 시나리오가 존재한다.
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);
@Query("SELECT t FROM Todo t " +
"LEFT JOIN FETCH t.user " +
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
int countById(Long todoId);
}
이때 JPQL fetch join을 사용하여 N+1문제를 해결하고 있는 TodoRepository를 동일한 동작을 하는 @EntityGraph 기반의 구현으로 수정하는 것이 이번 문제의 목표이다.
이 문제에서 사용하고 있는 fetch join은 "LEFT JOIN FETCH t.user" 이다.
즉, Todo테이블 내에 있는 user와 JOIN하여 값을 가져오는 것을 알 수 있다.
여기서 user는
@Table(name = "todos")
public class Todo extends Timestamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String contents;
private String weather;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();
public Todo(String title, String contents, String weather, User user) {
this.title = title;
this.contents = contents;
this.weather = weather;
this.user = user;
this.managers.add(new Manager(user, this));
}
public void update(String title, String contents) {
this.title = title;
this.contents = contents;
}
}
user_id라는 이름의 컬럼이며 User는
@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;
public User(String email, String password, UserRole userRole) {
this.email = email;
this.password = password;
this.userRole = userRole;
}
private User(Long id, String email, UserRole userRole) {
this.id = id;
this.email = email;
this.userRole = userRole;
}
public static User fromAuthUser(AuthUser authUser) {
return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole());
}
public void changePassword(String password) {
this.password = password;
}
public void updateRole(UserRole userRole) {
this.userRole = userRole;
}
}
이며 "users"란 테이블로 값을 저장한다.
즉, user_id는 users테이블과 join하여 값을 가져오는 것이기에 이를 @EntityGraph 기반으로 구현하게 되면
public interface TodoRepository extends JpaRepository<Todo, Long> {
@EntityGraph(attributePaths = {"users"})
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);
@EntityGraph(attributePaths = {"users"})
@Query("SELECT t FROM Todo t " +
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
int countById(Long todoId);
}
이런 식으로 작성할 수 있다.
@EntityGraph는 fetch join을 대신할 수 있기 때문에 전 코드의 fetch join 대신 @EntityGraph로 구현한 것이다.
'TIL' 카테고리의 다른 글
| Spring 심화주차 개인 과제 TIL (0) | 2025.02.27 |
|---|---|
| 내일배움캠프 TIL 25. (0) | 2025.02.26 |
| 내일배움캠프 TIL 23. (0) | 2025.02.21 |
| 뉴스피드 프로젝트 트러블 슈팅 TIL (0) | 2025.02.20 |
| CH3 일정 관리 앱 Develop 과제 TIL (0) | 2025.02.12 |