User Entity에서 입력받은 비밀번호와 DB에 저장된 해시된 비밀번호를 BCrypt 해싱 검증을 통해 비교하여 일치 여부를 확인하도록 하였다.
public boolean checkPassword(String password, PasswordEncoder passwordEncoder){
return passwordEncoder.matches(password, this.password);
}
💡 일정 전체 조회 페이징 + (각 일정의 댓글 개수 필드 추가)
@Query의 사용은 확장성, 자원 소모 등의 이유로 사용하는 것이 좋지 않다고 해서 동적으로 쿼리문을 확장하는 방법이 무엇이 있는지 찾아보다가 Specification을 사용하게 되었다.
유저 아이디가 입력된 경우, user 엔티티의 id의 값이 userId와 같은 것만 조회하도록 한다. ex)userId = 1일 때→ WHERE user.id = 1
//userId가 null이 아닐 때, Task의 user의 id와 userId를 비교하는 조건 추가
if(userId != null){
spec = spec.and(((root, query, cb) ->
cb.equal(root.get("user").get("id"), userId)));
}
같은 방식으로 수정일에 대한 조건도 조회한다. yyyy-mm-dd에 형식으로 비교하기 위해 LocalDate를 사용한다. ex) updatedAt = "2024-04-01"일 때 → WHERE DATE(updatedAt) = '2024-04-01' 조건이 추가됨.
if(updatedAt != null && !updatedAt.isBlank()){
//문자열을 LocalDate로 변환 (YYYY-mm-dd)
//캘린더에서 선택하는 방식을 사용한다는 가정하에 DATE 형식이 맞지 않는다는 가정은 제외함
//아닐 경우 형식 검사 추가 필요
LocalDate date = LocalDate.parse(updatedAt, DateTimeFormatter.ISO_LOCAL_DATE);
//문자열
spec = spec.and((root, query, cb) ->
cb.equal(cb.function("DATE", LocalDate.class, root.get("updatedAt")), date));
}
이 부분이 까다로웠다. 간단한 페이징이었다면 위의 조건에 따라 필터링된 결과를 바로 Page로 반환했을 것이다. 그러나 요구사항에 따라 각 일정에 몇 개의 댓글이 달렸는지 그 개수도 포함해야했다.
@Query의 사용은 별로 권장되지는 않으나 Specification을 사용하면 코드가 오히려 복잡해질 것 같았다. 또 댓글 개수는 일정 전체를 조회할 때 뿐만 아니라 각 일정 별로도 필요했기 때문에 재사용성을 위하여 @Query를 사용해 Repository 계층에 아래와 같은 메서드를 작성해 사용하였다.
@Query("SELECT c.task.id, COUNT(c) FROM Comment c WHERE c.task.id IN :taskIds GROUP BY c.task.id")
List<Object[]> countByTaskIds(@Param("taskIds") List<Long> taskIds);
조회된 일정들의 id를 모은다.
모은 Id 리스트를 넘겨 받아 위의 메서드를 이용해 구한 일정의 개수를 반환받아, Id에 따른 일정의 개수를 매핑한다.
Task와 댓글 개수를 TaskResponseDto로 변환하여 클라이언트에 반환한다.
Page<Task> taskPage = taskRepository.findAll(spec, pageable);
//페이지 내의 일정 id를 모아옴
List<Long> taskIds = taskPage.getContent().stream()
.map(Task::getId)
.toList();
//id에 따른 개수를 매핑
Map<Long, Long> commentCounts = commentRepository.countByTaskIds(taskIds).stream()
.collect(Collectors.toMap(
row -> (Long) row[0],
row -> (Long) row[1]
));
//Task와 count로 TaskRespinseDto 리스트 생성
return taskPage.map(task -> {
Long count = commentCounts.getOrDefault(task.getId(), 0L);
return new TaskResponseDto(task, count);
});
🤷 트러블 슈팅
@Transactional 사용
🤦♀️ 문제 현상
@Transactional을 사용하면 데이터의 변경을 JPA가 자동으로 감지하고 직접 save를 호출하지 않아도 변경사항이 반영되어 DB에 저장된다고 하였다.
그러나 실제로 프로그램 실행 시, 저장이 되지 않았다. save를 직접 호출해도 저장이 되지 않았다.
✏️ 문제 해결
@Transactional을 제거하니 정상적으로 저장되었다. (주말동안 배운 내용들을 점검하며 원인을 파악할 예정)
댓글의 개수를 누가 알고 있어야 할까?
🤦♀️ 고민한 내용
일정 또는 유저를 반환할 때 일정은 일정에 달린 댓글의 개수를, 유저는 자신이 작성한 댓글의 개수 포함해야했다.
그런데 이 댓글의 개수 엔티티가 계속 가지고 있어야 할까? 아니면 댓글의 개수가 필요할때마다 이를 조회 후 반환해야할까?
이 두 부분이 고민이 되었다. 왜냐하면, 두 방법 모두 좋은 방법은 아니라는 생각이 들었기 때문이다.
댓글의 개수를 엔티티가 가지고 있는 것은 엔티티의 책임 혹은 부담이 늘어나는 것 같고, 필요할 때마다 매번 조회 후 반환하는 것은 자원 소모가 심할 것이라는 생각이 들었다.
✏️ 튜터님의 답변
우선 댓글의 개수는 프론트엔드에서 해당 값을 사용해 페이징을 해야하기 때문에 넘겨줄 필요가 있다. 그리고
댓글의 개수는 필요할 때마다 조회하는 것이 자원 소모가 발생하더라도 훨씬 나은 방법이다!
그 이유는 동시성 제어에 있다!
인스타그램에서 2명이 1개의 피드에 동시에 하트를 눌렀다고 해보자. 댓글의 개수를 Entity가 가지고 있다면 동시성 제어가 어려워 0이던 하트의 개수가 2명이 눌렀음에도 본인이 누른 1만 증가한 결과가 보이게 될 수 있다.
Entity에서 이를 다뤄도 동시성 제어를 할 수 있는 방법이 있긴 있으나 매우 복잡하고 어렵다! 차라리 쿼리를 통해 매번 조회하는 것이 더 효율적이다!
📚 마무리 말
JPA를 처음으로 사용하다보니 아직 어설픈 부분이 많지만, 이번 프로젝트를 통해 어느 정도 감을 잡을 수 있었다.
주말 동안 부족한 부분을 한 번 더 복습하고 정리한 후, JWT 등 배웠으나 사용하지 않은 방법들도 직접 실습을 해봐야겠다고 생각했다.
그리고 꼭 Gradle 동기화를 하도록 하자! (ctrl+s를 한다고 반영이 되는 것이 아니다...)
🛠️ 유효성 검사
@Request Body, @RequestParam, @PathVariable로 호출된 API는 @Valid 또는 @Validated을 이용해서 유효성을 검증한다.
✅ @Valid
RequsetBody 앞에 사용한다.
DTO에서 유효성을 검증한다.
@RequestBody 앞에 @Valid를 선언한다.
@PostMapping
public ResponseEntity<TaskResponseDto> createTask(@Valid @RequestBody TaskCreateRequestDto createDto)
해당 DTO 내에서 검증이 필요한 필드의 데이터에 대해 어떤 검증을 할지 설정한다.
@NotBlank(message = "할 일은 필수 설정 값입니다.")
@Size(max = 200, message = "할 일은 최대 200자까지 입력 가능합니다.")
private String content; //할 일
✅ @Validated
@RequestParam, @PathVariable, @RequestHeader에서 사용한다.
컨트롤러와 서비스 계층에서 사용할 수 있다.
클래스 외부에 @Validated를 선언하고 검증하고 싶은 데이터의 Annotation 뒤에 검증을 위한 Annotation 추가한다. 당연히 여러 Annotation 사용할 수 있다.
@RestController // @Controller + @ResponseBody
@Validated
//일정 Id에 따른 일정 조회
@GetMapping("/{id}")
public ResponseEntity<TaskResponseDto> findTaskById(@PathVariable @Min(1) Long id) {
return new ResponseEntity<>(taskService.findTaskById(id), HttpStatus.OK);
}
🟦 @Valid와 @Validation으로 어떤 것들을 검증할 수 있는가?
아래의 표와 같은 Annotation을 이용해 데이터를 검사할 수 있다.
출처 : https://dev-coco.tistory.com/123
💡 NotNull과 NotBlank의 차이
둘의 기능은 유사하지만 조금 다르다. NotBlank가 NotNull보다 더 빡빡한 검증이다.
NotNull은 값이 존재하기만 하면 검증을 통과한다. (공백이면 어때.? 어쨌든 데이터가 있긴 한 거니까 통과!)
NotBlank는 null뿐만 아니라 공백들 까지도 허용하지 않는 검증이다.
NotBlank는 데이터 타입이 String일 때 주로 사용한다.
🔨 검증을 통과하지 못한 데이터 처리
검증에 실패했을 때, Spring이 자동으로 예외를 처리하지만, @ControllerAdvice와 @ExceptionHandler로 커스텀해서 처리할 수도 있다.
💡@ControllerAdvice로
애플리케이션 전역에서 발생하는 예외를 처리한다.
예외 처리할 클래스 위에 @ControllerAdvice를 선언한다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
}
보편적으로 ControllerAdvice를 사용할 때는 ExceptionHandler를 함께 사용한다.
💡@ExceptionHandler로 예외 처리
특정 예외를 처리한다. (try-catch의 catch와 같은 역할)
@ExceptionHandler(예외클래스명. class)
예외 클래스를 여러 개 선언할 수 있다.
✅ @Valid는 검증 실패 시, MethodArgumentNotValidException을 반환한다. ✅ @Validated는 검증 실패 시, ConstraintViolationException을 반환한다.
💡커스텀 예외 처리
예외 클래스만으로 처리하기 어려운 예외를 직접 정의할 수도 있다.
public class CustomException extends RuntimeException {
public CustomException(String message) {
super(message);
}
}
커스텀 예외는 exception 또는 error 패키지를 별도로 만들어 그 안에 선언한다. 커스텀 예외 역시 ExceptionHandler로 ControllerAdvice가 선언된 전역 예외 처리 클래스 안에서 사용할 수 있다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(커스텀예외클래스명.class)
}
🤷 유효성 검사에서 발생한 오류 또는 예외가 여러 개라면?
Map <String, String>을 사용한다. 각 String은 검사에 실패한 필드와 오류 메시지를 저장한다.
챌린지반 코드 리팩토링 시간을 통해 키오스크 과제를 다른 사람들은 어떻게 해결했는지를 알 수 있었다. 해당 코드들이 어떤 면에서 클린하고 좋은 코드인지를 알 수 있어 굉장히 유익했다. 그중에서도 가장 기억에 남는 부분은 빌더 패턴과 유사한 방법의 MenuItem 생성 코드였다.
List<Menu> menu = Arrays.asList(
new Menu("햄버거").addMenuItems(
//햄버거 메뉴들...
),
new Menu("음료").addMenuItems(
//음료 메뉴들...
),
new Menu("사이드").addMenuItems(
//음료 메뉴들...
)
);
//출처 : withong님 github 코드
📌 Builder Pattern
객체 생성 시 생성자의 인자가 많을 때 가독성을 높이고, 객체의 불변성을 유지할 수 있도록 도와주는 디자인 패턴
✅ 가독성 향상: 메서드 체이닝을 활용하여 명확하고 직관적인 객체 생성 ✅ 불변성 유지: final 필드를 사용하여 객체를 변경할 수 없도록 설정 가능 ✅ 유지보수 용이: 생성자 오버로딩을 줄이고, 선택적 매개변수를 쉽게 추가 가능 ✅ 코드의 안정성: 인자의 순서를 실수로 바꿔도 컴파일 타임에서 에러 감지 가능
✏️ 생성자 오버로딩의 한계
public class User {
private String name;
private int age;
private String email;
private String address;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
//생성자의 매개변수가 많아질수록 늘어나는 생성자...
}
생성자의 매개변수가 많아질수록, 변수 개수에 따라 오버로딩을 계속 추가해야 하는 문제가 발생한다. 이름만 받는 생성자 나이만 받는 생성자 이메일 주소만 받는 생성자 이름과 나이만 받는 생성자 등...
인자의 순서를 잘못 입력할 가능성도 높아진다. (이름이 먼저였나? 나이가 먼저였나?)
빌더 패턴을 사용하면 불필요한 오버로딩을 제거할 수 있다.
public class User {
private final String name;
private final int age;
private final String email;
private final String address;
//private 생성자 (Builder만 객체를 생성할 수 있도록 설정)
private User(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
this.address = builder.address;
}
//Builder 클래스 (User의 내부 정적 클래스)
public static class Builder {
private String name;
private int age;
private String email;
private String address;
public Builder(String name, int age) { // 필수 매개변수
this.name = name;
this.age = age;
}
public Builder email(String email) { // 선택적 매개변수
this.email = email;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
public User build() { // 최종 객체 생성
return new User(this);
}
}
}
위와 같이 빌더를 따로 생성하고 이를 이용해 아래와 같이 메서드 체이닝을 활용해 객체를 생성하게 되면, Stream을 사용할 때와 유사한 형태로 코드가 작성되며 원하는 매개변수만 선택적으로 설정할 수 있다.
이때, 메서드 체이닝을 사용하려면 빌더의 각 메서드가 this를 반환해야 한다.
public class Main {
public static void main(String[] args) {
// 빌더 패턴을 사용하여 User 객체 생성
User user = new User.Builder("John Doe", 25)
.email("john@example.com")
.address("New York")
.build();
}
}
📌 4. 빌더 패턴 vs 생성자 vs Setter
방식
빌더 패턴
Setter
생성자
가독성
매우 높음⭐⭐⭐⭐⭐
높음⭐⭐⭐
낮음⭐
불변성 유지
가능✔️
불가능❌
가능✔️
확장성
매우 높음⭐⭐⭐⭐⭐
높음⭐⭐⭐
낮음⭐
매개변수 선택
가능✔️
가능✔️
불가능❌
생성자, Setter와 성능을 비교했을 때 안 쓸 이유가 없어보이는 성능이다.
빌더 패턴과 유사하게 Menu를 생성할 때 MenuItem을 가변인자를 사용해 추가하는 방식도 인상적이었다.
public Menu addMenuItems(MenuItem... menu) {
// 전달된 menu를 리스트로 변환하여 menuItems 리스트에 추가
this.menuItems.addAll(Arrays.asList(menu));
// 현재 Menu 객체 반환
return this;
}
//출처 : withong님 github 코드
📌 가변 인자 Varargs
메서드의 매개변수 개수를 가변적으로 받을 수 있도록 지원하는 기능이다. 배열을 사용하지 않고도 여러 개의 인자를 넘길 수 있다.
✅ 내부 동작
가변 인자는 내부적으로 배열로 변환된다. 따라서, 실제로는매개변수 타입 배열을 선언한 것과 동일한 의미이다.
public void printNumbers(int... numbers) { } // 가변 인자
public void printNumbers(int[] numbers) { } // 배열로 직접 선언
✅ 가변인자 사용법
매우 간단하다! 메서드의 매개변수 타입 뒤에 세 개의 점 ...을 붙여서 사용한다.
public void 메서드명(매개변수 타입... 변수명)
가변 인자를 사용할 때, 가변 인자만 매개변수로 받을 수 있는 것은 아니다. 일반 매개변수도 함께 사용 가능하다.
public void 메서드명(일반 매개변수1, 일반 매개변수2, 매개변수 타입... 변수명)
단, 맨 마지막에 위치해야 한다.
가변인자는 한 메서드에 한 번만 사용할 수 있다.
오버로딩 시, 오류가 발생할 가능성이 있다.
public void printNumbers(int... numbers) { }
public void printNumbers(int num) { } // 가변 인자와 혼동될 수 있어 오류 발생 가능