이번 과제는 저번 과제에 진행했던 Spring 을 이용한 기본적인 CRUD를 구현하고 거기에 SQL을 직접 작성하는게 자바의 객체와 관계형 데이터베이스를 편리하게 매핑해주는 JPA라는 ORM 기술을 이용해보는 것이 추가되었다.
지속적으로 스프링에 대해 깊게 공부하려고 여러 강의를 보면서 진행해서 과제를 하는데에 있어 큰 어려움은 없었고 하면서 배운 것들과 문제가 있었던 것을 기록한다.
Session
로그인 상태를 유지하기 위해 로그인 정보(email, password)를 계속 파라미터로 보내는 건 매우 비효율적이다. 그래서 쿠키라는 것을 이용해 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면 그 뒤 모든 요청에는 쿠키가 자동적으로 포함되어 로그인 상태를 확인할 수 있다.
하지만 쿠키는 브라우저에서 쉽게 수정이 가능해 만약 쿠키값이 유저의 고유 식별 ID와 매핑되어있다면 숫자만 변경해도 다른 유저의 ID로 로그인할 수 있는 상황이 나오는 것이다. 이런 보안문제를 해결하기 위한 것이 `Session`이다.
`Session`은 랜덤으로 생성된 값과 유저 정보를 생성해 세션 저장소에 저장한다. 그리고 랜덤값을 쿠키로 전달해 요청이 왔을 때 이 랜덤값의 쿠키를 세션 저장소에서 조회해 로그인상태를 확인할 수 있다.
`Session`을 만들기 위해선 다음과 같은 과정이 필요하다.
- 추정 불가능한 랜덤 값을 생성
- 세션 저장소에 랜덤값과 보관할 값을 key, value 형태로 저장
- 랜덤값으로 응답 쿠키를 생성해 클라이언트에 전달
과정이 번거롭지만 Spring에선` HttpSession`이 이러한 과정을 다 해결해준다.
public LoginResponseDto login(String email, String password, HttpServletRequest request) {
User user = userRepository.findByEmailOrElseThrow(email);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new PasswordMismatchException();
}
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_USER, user);
return new LoginResponseDto(user.getEmail(), session.getId());
}
request.GetSession() 으로 JSESSIONID 세션을 생성해 쿠키를 클라이언트로 보낸다. Getsession은 기본값으로 true 를 가지는데 요청에 세션이 있으면 기존 세션을, 없으면 새로운 세션을 생성해서 반환한다.
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if(session == null || session.getAttribute(SessionConst.LOGIN_USER) == null) {
log.info("미인증 사용자 요청{}", requestURI);
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그인이 필요합니다.");
}
필터 과정에서 세션을 조회하고 세션이 없으면 로그인하지 않은 사용자의 요청으로 401 상태코드를 반환한다.
예외 처리
- 조회하는 데이터가 없을때 : NoSuchElementException
- 사용자가 조건에 맞지 않는 입력을 했을때 : MethodArgumentNotValidException
예외상황을 공통적으로 처리하기 위해 상황에 따라 위와 같이 Exception을 발생시켰다. 그리고 비밀번호 불일치, 이메일이 이미 존재하는 상황은 커스텀 Exception을 만들어 코드를 좀 더 직관적으로 만들어보았다.
@RestControllerAdvice
public class MyExceptionHandler {
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NoSuchElementException.class)
public ErrorResult handleNoSuchElementException(NoSuchElementException e) {
return new ErrorResult("404 NOT_FOUND", e.getMessage());
}
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(PasswordMismatchException.class)
public ErrorResult handlePasswordMismatchException(PasswordMismatchException e) {
return new ErrorResult("401 UNAUTHORIZED", e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors()
.stream().map(error -> error.getDefaultMessage())
.findFirst()
.orElse(null);
return new ErrorResult("400 BAD_REQUEST", message);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ExistsEmailException.class)
public ErrorResult handleExistsEmailException(ExistsEmailException e) {
return new ErrorResult("400 BAD_REQUEST", e.getMessage());
}
}
@Data
@AllArgsConstructor
public class ErrorResult {
private LocalDateTime timestamp;
private String code;
private String message;
public ErrorResult(String code, String message) {
this.timestamp = LocalDateTime.now();
this.code = code;
this.message = message;
}
}
하나의 클래스에서 공통적으로 처리하였고 ErrorResult 라는 응답 객체를 만들어 예외 상황의 JSON 형식을 통일했다. (지금 보니 500 상태코드를 빼먹었다.)
페이지네이션
그냥 페이지를 Size와 Page 쿼리를 받아 구현하는 것은 스프링 부트와 JPA을 이용하니 정말 쉬웠다.. ( Spring 만든 개발자들이 얼마나 편하게 해주려고 고심했는 지 알 수 있었다.)
Page<Todo> findAllByOrderByUpdatedAtDesc(Pageable pageable);
JpaRepository 에서 메서드명을 이렇게만 작성하면 데이터베이스에선 이 메서드명을 유추해서 쿼리를 작성해서 데이터를 반환해준다.
/* <criteria> */ select
t1_0.id,
t1_0.contents,
t1_0.created_at,
t1_0.title,
t1_0.updated_at,
t1_0.user_id
from
todo t1_0
order by
t1_0.updated_at desc
limit
?
(메서드명만으로 이렇게 쿼리를 짜준다는 것에서 정말 놀랐다.)
근데 과제의 요구사항 중 일정 목록을 조회할 때 댓글 개수 필드라는 조건이 있었다.
SELECT count(*)
FROM comment
GROUP BY todo_id
처음엔 구현한 일정 목록 조회 기능에 댓글 개수 필드만 추가하면 되는 거라 위처럼 일정의 댓글 개수를 리스트로 반환하는 메서드를 작성해 기존 리스트와 댓글 개수 리스트를 합쳐 응답하는 것으로 생각했었는데 막상 작성하려니 너무 복잡하고 수정해야 될 것이 꽤나 많았다. 그래서 처음부터 조건에 맞는 데이터를 조회하는 메서드를 만들어 응답하는게 깔끔할 것 같아서 방법을 찾아봤다.
@Query("SELECT new sparta.todo.dto.TodoPageResponseDto(t, COUNT(c)) " +
"FROM Todo t LEFT JOIN Comment c ON t.id = c.todo.id " +
"GROUP BY t.id, t.title, t.contents, t.user, t.createdAt, t.updatedAt " +
"ORDER BY t.updatedAt DESC")
Page<TodoPageResponseDto> findAllV2(Pageable pageable);
JPQL 이라는 객체지향 쿼리라는 걸 이용해봤다. SQL이 테이블을 대상으로 조회한다면 JQPL은 문법은 SQL과 비슷하지만 대상이 테이블이 아니라 Entity 객체라는 것이다.
이렇게 객체를 대상으로 조회하는 쿼리를 작성하니 원하는대로 일정 목록 객체에 댓글개수를 추가한 TodoPageResponeDto 객체가 반환되었다.
오류 수정 1 - 삭제가 항상 200 상태코드를 출력하던 문제
데이터 삭제를 구현할때 JpaRepository에 DeleteById 라는 메서드가 있길래 사용해봤더니 잘 되서 그대로 사용하였다.
public void deleteTodo(Long id) {
todoRepository.deleteById(id);
}
근데 이렇게만 구현해놓으니 없는 ID 값을 가져와도 200 상태코드가 출력됐다.
해결
public void deleteTodo(Long id) {
Todo todo = todoRepository.findByIdOrElseThrow(id);
todoRepository.delete(todo);
}
먼저 ID값이 있는지 확인하고 데이터를 가져오고 delete 메서드로 그 데이터를 삭제하는 것으로 수정하였다.
오류 수정 2 - Entity 연관관계 설정
하위 Entity 에서 Foreign Key 를 설정할 때 하위 Entity(Many쪽)에 @ManyToOne 만 설정해주면 끝나는 줄 알았다.
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
근데 과제를 끝내고 하나씩 테스트를 하는데 삭제가 되지 않고 다음과 같은 에러가 발생했다.
java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`todo`.`todo`, CONSTRAINT `FK2ft3dfk1d3uw77pas3xqwymm7` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`))
foreign key 제약조건 실패로 상위 행을 업데이트하거나 삭제할 수 없다고 나온다. 즉, 하위 데이터에서 상위 데이터의 Key 를 참조하고 있으니 참조하고 있는 데이터를 모두 수정하거나 삭제하라는 것이다. 그래서 Entity 연관관계 설정하는 걸 다시 살펴보니 누락된 부분이 있었다.
해결
public class User extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userName;
@Column(unique = true)
private String email;
private String password;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Todo> todos = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
상위 Entity(One쪽)에 @OneToMany 어노테이션을 추가했다.
- mappedBy 는 Todo 엔티티의 user 필드를 기준으로 관계를 매핑.
- CascadeType.ALL 은 User가 생성, 삭제될 때 Todo에도 동일한 작업을 한다.
- orphanRemoval 은 User와 연관관계가 끊어진 자식은 자동으로 삭제.
'개발 > 내일배움캠프 TIL' 카테고리의 다른 글
[TIL #38] Spring 심화 주차 과제 Lv 6 기능 개선하기 (0) | 2025.01.06 |
---|---|
Spring 뉴스피드 프로젝트 미니 발제 KPT 회고 (0) | 2024.12.27 |
[TIL #36] Spring 에서의 검증 (0) | 2024.12.13 |
[TIL #35] 빈 생명주기 콜백 (0) | 2024.12.06 |
[TIL #33] 컴포넌트 스캔 탐색 위치와 기본 스캔 대상 (0) | 2024.12.04 |