[2/13] TIL - 일정 앱 Develop 프로젝트 트러블슈팅
난 무엇을 알고 무엇을 모르는가? 또 뭘 고쳐야하는가?
이번 트러블슈팅은 내가 적었던 코드와 튜터님 코드와 비교하면서 내가 틀렸던 부분 알아야 할 부분들을 정리해보겠다.
1. 디렉토리 구조

이건 튜토님 디렉토리 정리인데, 난 루트에서 dto. controller, entity, service, repository ... 으로 나누고 시작했었다. 이전엔 관리할 엔티티가 많이 없었다보니 이렇게 해도 문제가 없었다. 그러나 이제 관리할 엔티티가 점점 늘어나면서 전의 방식으로 분리하면 controller 패키지 안에서도 어떤 엔티티를 위한 controller인지 헷갈리기 시작한다.
그래서 위 사진과 같이 엔티티별로 패키지를 나누고 공통기능들은 common 패키지로 나눈다.
2. 엔티티의 생성자
엔티티 클래스는 기본생성자가 꼭 필요하다. 나는 이렇게 클래스 안에 직접 코드로 작성했었다.
public User() {}
그러나 @NoArgsConstructor 어노테이션을 이용해서 할 수 있도록 앞으로 습관을 고치자.
3. 테이블 이름을 복수형으로 쓰자
왜 테이블 이름을 복수형으로 써야할까? 스프링 JPA에서 쿼리를 작성하는데 테이블이름이 엔티티 이름과 똑같으면 혹시나 예약어와 헷갈리는 상황이 일어나 오류가 발생할 수 도 있다. 따라서 테이블 이름을 지정할 때는 복수형으로 쓰도록 하자.
@Table(name = "users")
4. Validation은 어디에서?
내가 썼던 코드는 validation을 엔티티 클래스에서 하고 있었다. 딱히 그렇게 하고자 한 이유는 없었다. 오히려 궁금증이 생겼다. Validation은 Dto에서 해야하는 걸까 아니면 Entity에서 해야하는 걸까
일단 스프링 유명강사 김영한 님은 Dto에 하신걸 선호한다 하셨다.

코드가 지저분해지는 것과 너무 체크를 여러번 하면 누락할 수 있다는 것.
그러나 이 블로그 글을 보면 dto 검증 이외에도 entity 필드에서도 검증을 해야한다는 의견도 볼 수 있다.
JPA entity에 validation annotation을 붙인 이유
예시로 사용된 코드는 깃허브를 통해 확인할 수 있습니다. validation annotation을 붙인 이유와 생성자 검증의 맹점에 대해 작성한 글입니다. DB에 있는 데이터들을 100% 신뢰할 수 있다!라고 생각한다
park-algorithm.tistory.com
Entity에서 검증을 하게 되면 데이터가 영속성을 가지게 될 때 검증을 체크함으로 DB 데이터의 신뢰도를 높일 수 있다는 뜻인 것 같다. 또한 생성자에도 검증로직을 추가해 객체가 올바른 필드값을 가지고 있지 않으면 생성하지 못하게 하는 방법도 있다. 그러나 이 블로그의 핵심은 이미 DB에 잘못된 데이터가 들어가버리면 Entity 검증이든, 생성자 검증이든 쿼리를 실행하는 과정에서 그 검증로직들을 거치지 않는다면 아무 소용이 없다는 것이다. 따라서 DB의 데이터를 너무 신뢰하지 말자는 글인 것 같다.
일단 나는 dto에만 검증을 적용하는 방법을 택했다.
그리고 validation 어노테이션 적을 때 검증에서 걸리면 콘솔에 메세지를 남길 수 있게 메세지를 남기는 것도 습관화 하자.
@NotBlank(message = "유저명은 필수 입력값입니다.")
@Size(max = 4, message = "유저명은 4글자 이내여야 합니다.")
private String userName;
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$", message = "유효한 이메일 형식이 아닙니다.")
private String email;
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
private String password;
5. 응답데이터는 어떻게 구성?
요청데이터는 사용자가 입력하는 html 폼을 생각해보면 대충 어떤 필드들이 필요한지 생각 할 수 있었다. 그러나 응답데이터에는 도통 어느걸 넣어줘야 할 지 고민되는 것이 헷갈렸던 부분이다.
튜터님 코드를 보면 password와 같이 암호화 되어야하고 보안적으로 중요한 필드 빼고는 모든 필드를 response에 담았다. 그러나 id 값도 보안상 문제가 되지 않을까? 모든 필드를 응답으로 보내는게 맞을까? 하는 생각이 들었다.
이건 프론트엔드 코드쪽도 같이 보았을 때 함께 논의해보아야할 문제이기도 하다.
CRUD을 할 때 보통은 이렇게 담아서 응답을 보낸다고 한다.
CRUD의 각 상황별 API 응답은 일반적으로 다음과 같이 처리됩니다:
- 생성(Create): 생성된 엔티티의 키(ID)를 응답으로 보냅니다.
- 조회(Read): 요청한 엔티티 또는 해당하는 정보를 응답으로 보냅니다.
- 수정(Update): 변경된 엔티티의 일부 정보 또는 응답 DTO를 보냅니다.
- 삭제(Delete): 특별한 응답이 필요하지 않은 경우가 많으며, 성공/실패 여부만 보내기도 합니다.
또는 CRUD 응답에서 id값만 보내도 된다.
김영한 님의 말로는,
그런데 저의 경우 ID만 보내고 나머지를 다시 조회하는 방식을 선호합니다.
이렇게 하면 데이터 저장과 조회를 깔끔하게 분리할 수 있기 때문이지요.
물론 ID로 데이터를 다시 조회한다면 조금이라도 시스템의 부하가 늘어나는 것은 사실입니다.
그런데 대부분의 시스템은 복잡한 조회 때문에 성능이 떨어지는 것이지 PK 기준으로 데이터를 조회하는 것은 시스템 전체로 보면 아주 미미한 영향을 가지게 됩니다.
또한 수정과 삭제의 경우,
HTTP 상태 코드로 응답을 알 수 있기 때문에 변경, 삭제의 경우 ID를 남기고 남기지 않는 것 또한 선택입니다.
다만 변경, 삭제를 요청한 곳에서 응답에 ID를 받을 수 있으면 코드를 더 편하게 작성할 수 있는 부분들이 있을 수 있습니다.
예를 들어서 삭제를 요청한 클라이언트가 비동기 콜백으로 요청하게 되면 응답에 ID가 없으면 ID를 어딘가에 관리해야 하는데, 응답에 ID를 받을 수 있으면 더 편리하게 코드 작성이 가능해집니다.
이런 API 디자인의 경우 클라이언트의 편리함과 상황도 함께 고려하는것이 좋습니다.
그리고 Entity의 responsedto를 구성할 때에는,
이 부분은 성능 보다는 유지보수 관점에서 고민하는 것이 필요합니다.
API에 필요한 데이터를 모두 노출해두면 이후에 클라이언트가 필요한 기능을 찾아서 사용만 하면 되기 때문에 서버에서 기능을 변경할 일이 줄어듭니다.
반면에 이 방법은 서버에서 데이터를 변경하는 경우, 너무 많은 데이터가 노출되어 있기 때문에 서버쪽 변경을 어렵게 할 수 있습니다. 예를 들어 API에 노출되지 않은 데이터는 서버에서 자유롭게 변경할 수 있지만, 이미 노출한 데이터는 클라이언트에서 정말 사용하는지 일일이 확인한 다음에 변경해야 하기 때문에 서버쪽 데이터 변경을 어렵게 할 수 있습니다.
결국 모든 데이터를 오픈하는 것과 일부 데이터를 오픈하는 것은 트레이드 오프가 있습니다. 이런 트레이드 오프를 고려할 때 다음과 같은 관점을 보시면 도움이 되실거에요.
이런 고민은 크게 2가지 관점으로 보는 것이 주요합니다.
1. API를 호출하는 웹 프론트엔드 코드와 서버 코드를 함께 변경할 수 있다. (한팀이다.)
2. API를 호출하는 클라이언트와 서버 코드를 함께 변경할 수 없다. (다른팀이다.)
웹 프론트엔드와 서버 코드를 함께 유지관리하는 경우 엔티티의 데이터들을 전달해두면, 이후에 화면에 기능이 추가될 때 웹 프론트엔드 코드만 고치면 되기 때문에 유연한 변경이 가능합니다. 물론 서버쪽 코드를 변경하는 경우 웹 프론트엔드 개발자가 이 데이터를 정말 사용하는지 확인하는 과정도 같은 팀이기 때문에 상대적으로 쉬울 수 있습니다.
API를 호출하는 클라이언트가 다른 팀이거나 다른 회사라면, 한번 노출한 API의 데이터는 거의 변경이 불가능합니다. 따라서 꼭 필요한 데이터만 노출하는 것이 좋습니다.
정리하자면 모든 데이터를 응답으로 넘겨주면 프론트코드에서 유지보수 하기가 쉬워지는 점은 맞으나, 백엔드에서 해당 필드를 변경하려하면 사용자가 그 필드를 사용하고 있는지 일일이 확인해야 하기 때문에 변경이 어려워 지는 점이 있다. 따라서 프론트쪽과 소통하기 쉬운 환경에 있다면 노출해도 큰 문제 없지만 그럴 수 없다면 id 값만 보내는 것이 나을 수 도 있다는 것이다. id값만 보낸다면 프론트에서 해당 id를 가지고 다시 조회를 하는 것인데, 그렇게 큰 시스템 부하는 일으키지 않는다고 한다.
일단 나는 프론트를 생각하지 않고 코딩하고 있기 때문에 id값과 더불어 사용자가 필요한 정보들을 응답에 담았다. 후에 프런트와 같이 팀을 이루게 된다면 이러한 부분들을 소통해서 응답데이터를 정해야 할 것이다.
6. 3 layerd architecture 위배
우리는 controller, service, repository로 나누어서 서버를 구성하고 있다. 그리고 각각의 단계에서 controller는 service를 사용하기 위해, service는 repository를 사용하기 위해 생성자 주입을 이용한다. 그런데 만약 controller 에서 service와 repository를 같이 생성자 주입을 해서 두개 모두 다 이용한다면 그건 3 layerd architecture 위배다.
나도 코딩을 하면서 그럴 뻔(?)한 적이 있었던 것 같아 다시 상기시키도록 적어두는 것이다.
확실하게 controller에서는 요청과 응답 전달, service에서는 비즈니스 로직(JPA를 이용해서), repository에서는 DB와의 직접적인 상호작용을 구현해야한다.
7. PutMapping과 PatchMapping의 차이
https://ycyeon.tistory.com/145
멱등성, @PutMapping과 @PatchMapping의 차이
✨ PUT vs PATCHput과 patch 모두 리소스를 수정할 때 사용하는 HTTP Method이다. 둘의 차이점은 put은 리소스의 전체 내용을 수정할 때 사용하고, patch는 리소스의 일부 내용만 수정할 때 사용한다는 정도
ycyeon.tistory.com
결론적으로 둘의 큰 차이는 멱등성이 보장이 되냐, 안되는 거냐의 차이이다.
put은 리소스 전체를 수정할 때 사용하니 요청값으로 수정되어아햘 리소스 전체를 보내야 할 것이고 리소스 전체가
바뀌는 것이니 여러변 요청을 보내도 요청하는 것이 바뀌지 않는 이상 멱등성을 보장한다. 그러나 리소스 전체가 수정되는 것이기 때문에 요청데이터에 빵꾸가 난다면 해당 부분은 null이 되어 널포인터익셉션을 발생할 수 있으니 조심해야하는 부분이다.
그와 반대로 patch는 리소스의 일부분을 수정하는 것이다. username 과 password 필드가 있고 그 중 username 만 수정해야한다면 patchmapping을 이용해야 할 것이다. patch는 요청을 어떻게 보내냐에 따라서 멱등성이 보장이 안될 수도 있다. 만약 요청을 보낼 때마다 age의 값이 증가하도록 요청을 보낸다면 그건 멱등성이 보장이 안되는 것이다.
따라서 '상황에 적절하게' PutMapping과 PatchMapping을 선택해서 써야할 것이다.
8. 로그인
일단은 UserController와 LoginController를 분리하자.
LoginController에서 login이 해야할 일은
1. 너가 정말 DB에 있는 이 유저가 맞느냐? -> email과 password 일치하는 지 검증 (Service의 login 메서드를 통해서)
2. 맞다면 쿠키에 세션값 넣어줄게
3. 그리고 서버에도 이 세션값 메모리에 저장할게
logout이 해야할 일은
1. 요청값에 세션값이 있는지 확인, 만약 없으면 false로 설정
2. 세션값이 만약 있다면 session.invalidate();로 세션 삭제해주기
세세하게 들어가보자면
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequestDto dto, HttpServletRequest request) {
Long userId = userService.handleLogin(dto);
HttpSession session = request.getSession(); // 신규 세션 생성, JSESSIONID 쿠키 발급
session.setAttribute("LOGIN_USER", userId); // 서버 메모리에 세션 저장
return ResponseEntity.ok("로그인 성공");
}
RequestBody로 dto에 사용자가 입력하는 값(검증해야할 값)을 받아온다. 그리고 쿠키에 세션 값을 넣어주기 위해서 Http 요청을 받아와야한다. 그래서 HttpServletRequest request를 받아온다.
응답값은 따로 반환되어야 할 건 없고 로그인이 성공되었다는 메세지를 전달해주기 위해 String으로 해준다.
위에서 말했던 1번
1. 너가 정말 DB에 있는 이 유저가 맞느냐? -> email과 password 일치하는 지 검증 (Service의 login 메서드를 통해서)
을 하기 위해서 service의 handleLogin 메서드를 통해서 이 유저가 등록되어있는 유저인지 확인한다.
그다음 2번을 하기 위해서
2. 맞다면 쿠키에 세션값 넣어줄게
받아온 http request에 .getSession을 이용해 세션값(Set-Cookie : JSESSIONID=45245...)을 발급한다.
3번을 하기 위해서
3. 그리고 서버에도 이 세션값 메모리에 저장할게
아까 만든 세션값을 메모리에 저장할 때 키 값은 "LOGIN_USER"로, 밸류 값은 아까 handleLogin의 응답값인 userId를 넣어준다. 아니 그럼 JSESSIONID를 저장하는게 아니고 이걸 왜 저장하는가? 라는 생각이 드는데 실제 JSESSIONID가 저장되는 구조는 다음과 같다.
Map<JSESSIONID, Map<String, Object>>
아까 우리가 설정했던 키,밸류 값은 JSESSIONID 옆에 저장되는 것이다. 그래서 JSESSIONID를 키값으로 저장하고 밸류에는 그 안의 키,밸류에 값을 저장한다. 따라서 클라이언트 별로 JSESSIONID가 저장되어 클라이언트를 구별하는 유니크한 값이 된다.
그리고
1. 클라이언트에서 최초 접근시 서버에서는 Response Header에 JSESSIONID값을 쿠키에 셋팅
2. 클라이언트에서는 이후 요청부터 Request Header에 JSESSIONID 값을 넣어서(JSESSIONID을 쿠키에 세팅) 보내줌.
3. 서버에서는 Request Header에 JSESSIONID 값이 존재할 경우 그 값을 활용
9. 그렇다면 요청에서 세션값을 어떻게 처리하지? 스프링이 자동으로 처리하나?
일단 인가가 필요한 api에 접근할 때 로그인 필터를 이용해서 세션값이 있는지 없는지 검사할 것이다. 세션값이 없다면 로그인 api로 리다이렉트할 것이고 세션값이 있다면 그 다음 컨트롤러를 부른다.
인가가 필요한 api에 접근 가능한 것이라면 해당 클라이언트는 세션값이 있다는 것이 전제이다.
그럼 컨트롤러 메서드에서 필요하다면 서버에 저장된 세션값을 가져올 수 있다.(@SessionAttribute 사용) 가져온 세션 값의 밸류는 그 전에 우리가 저장한 밸류 값(userId). 그 값으로 findById해서 User객체로 서비스에게 넘겨주어도 되지만 그냥 userId로 넘겨주어도 된다.(세션값은 클라이언트를 구분해주는 유니크한 값이기도 하니까)
@PutMapping("/users/me")
public ResponseEntity<UserResponseDto> update(
@SessionAttribute(name = Const.LOGIN_USER) Long userId,
@RequestBody UserUpdateRequestDto dto
) {
return ResponseEntity.ok(userService.update(userId, dto));
}
이 GetMapping 같은 경우엔 세션값을 꺼내올 필요가 없으니 @SessionAttribute를 사용하지 않는다.
@GetMapping("/users")
public ResponseEntity<List<UserResponseDto>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
그리고 유저객체를 삭제할 때 세션도 함께 삭제 해준다. 아까 보았던 로그아웃 메서드에서도 session.invalidate();가 있지만 유저가 항상 로그아웃 하고 탈퇴하는 것이 아니니 서버에서 세션값을 저장하는 메모리에서 데이터를 삭제해주어야한다.
@DeleteMapping("/users/me")
public void delete(HttpServletRequest request) {
HttpSession session = request.getSession(false);
Long userId = (Long) session.getAttribute("LOGIN_USER");
userService.deleteById(userId); //DB에서 삭제
session.invalidate(); //서버 메모리에서 세션값 삭제
}
10. @Transactional은 뭐고 @Transactional(readOnly = true)는 또 뭔가?
@Transactional 어노테이션을 통한 트랜잭션 관리 (란?/ 활용방법/ 내 경험)
트랜잭션(Transaction)은 데이터베이스와 관련된 작업을 일관성 있게 처리하기 위한 개념입니다. 데이터베이스에서는 여러 개의 작업(예: 데이터 읽기, 쓰기, 업데이트)이 수행될 때 데이터의 일관
velog.io
일단 이 블로그에서 말하는 바는 @Transactional 어노테이션을 사용하는 건
해당 메서드에서 실행되는 쿼리를 하나의 트랜잭션으로 보장하여 ACID 중 일관성을 지키고 트랜잭션 중 오류가 발생하면 롤백할 수 있도록 하는 것이다.
하나의 트랜잭션으로 묶는 다는 것은 데이터베이스 작업의 일관성을 보장하고, 트랜잭션 내에서 실행되는 모든 쿼리가 성공해야만 커밋(저장)되고, 하나라도 실패하면 롤백(취소)되는 것을 의미한다.
DB작업을 하나로 묶어서 일관성을 보장한다는게 단순히 와닿지가 않았다. 일관성이란게 쉬운말로 하자면 지금 클라이언트1이 바라보고있는 DB와 클라이언트2가 바라보고 있는 DB가 똑같아야한다는 보장이다. 만약 서로 보고 있는 DB가 다르다면(DB가 다른 상태라면) 작업의 일관성이 보장되지 않는다.
이러한 문제들은 동시에 여러명의 클라이언트 들이 접속했을 때 발생할 수 있는데 이럴때 DB의 일관성을 보장하기 위해 Transactional을 사용하는 것 같다.
동시에 많은 클라이언트가 모일 때 일관성이 보장되지 않으면 어떤 일이 일어나냐?
다음과 같은 동시성 문제가 일어난다. 그래서 synchronization(동기화 = 서로가 알고있는 정보를 일치시켜주는 것) 해주어야한다. OS시간에 배운 것 같은데 ㅎㅎ.. 많이 까먹었다.
- 경쟁 조건 (Race Condition)
여러 스레드나 프로세스가 공유 데이터나 리소스에 접근하고 수정할 때, 어떤 스레드가 먼저 접근하여 데이터를 변경하면 다른 스레드가 그 변경을 덮어쓰는 상황이 발생할 수 있습니다. 이로 인해 데이터의 무결성이 깨지는 문제가 발생합니다. - 데드락 (Deadlock)
두 개 이상의 프로세스나 스레드가 서로가 가진 리소스를 기다리면서 상호 대기하는 상황입니다. 각각의 프로세스나 스레드는 다른 리소스를 해제하지 않고 대기하므로 프로그램이 더 이상 진행되지 못하고 멈추게 됩니다. - 스레드 간 통신 (Inter-Thread Communication)
여러 스레드가 작업을 나누어 수행할 때, 스레드 간에 정보를 안전하게 전달하고 동기화하는 문제가 발생할 수 있습니다. 이로 인해 잘못된 정보 전달이나 불일치가 발생할 수 있습니다.
내가 썼던 코드에서는 Transactional 어노테이션을 그냥 업데이트 기능을 하는 메서드에서만 썼었다. 사실 이 어노테이션이 뭐하는 지 잘 모르고 그냥 강의 교안에 업데이트하는 메서드에 붙어있길래 아~ 그냥 이런 메서드에 붙이는 건가? 하고 썼었다.
하지만 이제 제대로 알았으니 실제코드에서 어떻게 쓰이는지 알아보자.
@Transactional
public UserResponseDto save(UserSaveRequestDto dto) {
if (userRepository.existsByEmail(dto.getEmail())) {
throw new IllegalArgumentException("해당 이메일은 이미 사용중입니다.");
}
String encodedPassword = passwordEncoder.encode(dto.getPassword());
User user = new User(dto.getUserName(), dto.getEmail(), encodedPassword);
userRepository.save(user);
return new UserResponseDto(
user.getId(),
user.getUserName(),
user.getEmail(),
user.getCreatedAt(),
user.getUpdatedAt()
);
}
위 코드는 리포지토리에서 existsByEmail과 save 메서드를 통해 DB작업 2개를 처리해야한다. 그래서 2개의 작업이 묶여서 진행되어야 한다. 만약 묶이지 않고 진행된다면 동시에 클라이언트가 몰렸을 때 서로 어떠한 쿼리를 실행하는 시점이 달라 일관성이 보장되지 않고 또는 덮어쓰기가 진행될 수 도있다.
그렇다면 @Transactional(readOnly = true)는 무엇인가?
https://hungseong.tistory.com/74
@Transactional(readOnly = true)를 왜 붙여야 하나요
스프링으로 개발하면서 필연적으로 사용하게 되는 @Transactional. 우리는 스프링의 AOP를 통해 @Transactional 어노테이션만으로 손쉽게 Service Layer에서 트랜잭션을 걸 수 있다. 일반적으로, 조회용 메서
hungseong.tistory.com
이는 JPA의 영속성 컨텍스트가 수행하는 더티체킹과 관련있다.
영속성 컨텍스트는 Entity 조회 시 초기 상태에 대한 Snapshot을 저장한다.
트랜잭션이 Commit 될 때, 초기 상태의 정보를 가지는 Snapshot과 Entity의 상태를 비교하여 변경된 내용에 대해 update query를 생성해 쓰기 지연 저장소에 저장한다.
그 후, 일괄적으로 쓰기 지연 저장소에 저장되어 있는 SQL query를 flush 하고 데이터베이스의 트랜잭션을 Commit 함으로써 우리가 update와 같은 메서드를 사용하지 않고도 Entity의 수정이 이루어진다. 이를 변경 감지(Dirty Checking) 라고 한다.
위 블로그 글이 너무 잘 정리되어 있어서 가져왔다.
이 때, readOnly = true를 설정하게 되면 스프링 프레임워크는 JPA의 세션 플러시 모드를 MANUAL로 설정한다.
* MANUAL 모드는 트랜잭션 내에서 사용자가 수동으로 flush를 호출하지 않으면 flush가 자동으로 수행되지 않는 모드이다.
즉, 트랜잭션 내에서 강제로 flush()를 호출하지 않는 한, 수정 내역에 대해 DB에 적용되지 않는다.
이로 인해 트랜잭션 Commit 시 영속성 컨텍스트가 자동으로 flush 되지 않으므로 조회용으로 가져온 Entity의 예상치 못한 수정을 방지할 수 있다.
또한, readOnly = true를 설정하게 되면 JPA는 해당 트랜잭션 내에서 조회하는 Entity는 조회용임을 인식하고 변경 감지를 위한 Snapshot을 따로 보관하지 않으므로 메모리가 절약되는 성능상 이점 역시 존재한다.
즉, 자동으로 flush 되는 것을 막아주어 오직 조회 용임을 보장하게 해주는 것이다. 게다가 스냅샷을 보관하지 않으니 메모리 절약은 덤이다. 또한 데이터베이스를 레플리케이션으로 운영할 때 장애가 없는 슬레이브DB에서 데이터를 조회해오도록 설정된다.
레플리케이션이 무엇이나면,
레플리케이션은 Master-Slave 구조로 복제본 DB를 함께 운용함으로써, Master DB의 장애 발생 시 Slave DB를 Master DB로 승격시켜 장애를 빠르게 복구할 수 있으며, 조회 작업은 Slave DB에서 수행하고 수정 작업은 Master DB에서 수행함으로써 트래픽을 분산할 수 있다는 장점이 있다.
이러한 데이터베이스 구조를 가져갈 때, readOnly = true가 설정되어있는 메서드의 경우 Slave DB에서 데이터를 가져오도록 동작한다. 이를 통해 레플리케이션의 목적에 맞게 트래픽 분산을 온전하게 적용할 수 있다는 추가적인 이점이 존재한다.
그럼 그냥 아예 조회용 메서드에는 Transactional을 안붙이면 되지 않느냐 생각할 수 있겠지만 아예 없다면 Lazy loading을 아예 수행할 수 없다고 하는데 사실 이 부분은 이해가 안간다. 영속성 컨텍스트와 Lazy loading에 대해서 더 공부하고 차이를 알아야겠다.
11. 페이지네이션 API를 따로 따자
난 그냥 일정을 전체조회 할 때 그 메서드를 페이지네이션을 적용해서 바꾸면 되는 줄 알았다. 그러나 튜터님 코드에서는 페이지네이션 API를 따로 땄다.
// 일정 페이지 API
@GetMapping("/schedules/page")
public ResponseEntity<Page<SchedulePageResponseDto>> findAllPage(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size
) {
Page<SchedulePageResponseDto> result = scheduleService.findAllPage(page, size);
return ResponseEntity.ok(result);
}
service의 findAllPage 코드다. 페이지네이션에서 사용하는 조회 서비스 메서드는 그냥 원래 전체 조회에서 사용하던 findAll 메서드를 재사용하는 것이 아니라 페이지네이션을 사용하는 findAllpage 메서드를 따로 만들어주어야한다.
@Transactional(readOnly = true)
public Page<SchedulePageResponseDto> findAllPage(int page, int size) {
// 클라이언트에서 1부터 전달된 페이지 번호를 0 기반으로 조정
int adjustedPage = (page > 0) ? page - 1 : 0;
PageRequest pageable = PageRequest.of(adjustedPage, size, Sort.by("updatedAt").descending());
// 1. Schedule Page 조회
Page<Schedule> schedulePage = scheduleRepository.findAll(pageable);
// 2. 일정 ID 리스트 추출
List<Long> scheduleIds = schedulePage.stream()
.map(Schedule::getId)
.collect(Collectors.toList());
// 3. 별도 쿼리로 댓글 수 조회
List<CommentCountDto> countResults = commentRepository.countByScheduleIds(scheduleIds);
Map<Long, Long> commentCountMap = countResults.stream()
.collect(Collectors.toMap(CommentCountDto::getScheduleId, CommentCountDto::getCount));
// 4. 각 Schedule을 SchedulePageResponseDto로 변환 (댓글 수는 Long을 int로 변환)
return schedulePage.map(schedule -> new SchedulePageResponseDto(
schedule.getId(),
schedule.getTitle(),
schedule.getContent(),
commentCountMap.getOrDefault(schedule.getId(), 0L).intValue(),
schedule.getCreatedAt(),
schedule.getUpdatedAt(),
schedule.getUser().getUserName()
));
}
일단 직관적으로 생각하면 페이지네이션을 통해서 우리가 전달해주어야 하는 데이터는 SchedulePageResponseDto에 담길것이다.
SchedulePageResponseDto는 어떤 필드가 있는가?
id, 제목, 내용, 작성일, 수정일, 유저네임, 댓글 수 를 넘겨주어야 한다.
나는 댓글 수 까지는 생각 못했지만 그려지는 뷰를 생각해보면 게시물 리스트 같은 것을 불러올때 페이지별로 볼 수 있고 해당 게시물에 달려있는 댓글 수도 항상 표시되었던 것 같다. 그런 걸 생각해보면 서버에서는 데이터로 댓글 수를 카운트해서 넘겨주어야하지 프론트에서는 그 데이터로 뷰를 그릴 수 있는 것 같다.
크게 생각하면
1. 페이지 크기만큼 Schedule 조회
2. 조회 된 Schedule 개수만큼 각각 Schedule 별 Id 추출해 ID 리스트 만들기
3. ID리스트를 순회하면서 쿼리로 게시물 Id에 해당하는 댓글 수 카운트
코드를 한줄 씩 뜯어보자.
// 클라이언트에서 1부터 전달된 페이지 번호를 0 기반으로 조정
int adjustedPage = (page > 0) ? page - 1 : 0;
페이지 번호는 0부터 시작하기 때문에 클라이언트에서 넘어오는 값에서 -1을 해주어야 한다.
삼항연산자를 이용해서 0보다 크면 -1을 해주고 0보다 작으면 0으로 설정해준다.
PageRequest pageable = PageRequest.of(adjustedPage, size, Sort.by("updatedAt").descending());
PageRequest를 통해서 페이지번호, 사이즈, 정렬기준을 넘기고 PageRequest타입의 pageable에 할당한다.
쉽게말하면 어떻게 재단할지를 정의한다고 생각하면 된다. 그리고 어떻게 재단할지 정의한 이 pageable을 findAll에 넘겨주어 페이지네이션 할 수 있도록 한다.
// 1. Schedule Page 조회
Page<Schedule> schedulePage = scheduleRepository.findAll(pageable);
위에서 선언했던 pageable 변수를 findAll에 넘겨주면 여러개의 Schedule이 담겨있는 Page가 반환된다.
// 2. 일정 ID 리스트 추출
List<Long> scheduleIds = schedulePage.stream()
.map(Schedule::getId)
.collect(Collectors.toList());
그리고 Schedule 별 댓글 수를 카운트 하기 위해서는 Schedule Id가 담겨있는 리스트를 만들어야 한다.
schedulePage 변수를 stream하면서 getId 함수를 통해서(.map 이용 -> stream 하면서 이걸 적용할 거야 라는 뜻) id를 모은다.(.collect 이용 -> map을 적용한 뒤 나오는 결과물을 모을거야 라는 뜻, 근데 어떻게? 리스트로 = Collectors.toList())
// 3. 별도 쿼리로 댓글 수 조회
List<CommentCountDto> countResults = commentRepository.countByScheduleIds(scheduleIds);
Map<Long, Long> commentCountMap = countResults.stream()
.collect(Collectors.toMap(CommentCountDto::getScheduleId, CommentCountDto::getCount));
commentRepository를 이용해서 countBySchedules를 실행한다. 그러면 아마 쿼리가 scheduleIds리스트를 순회하면서 해당 ScheduleId로 comment 엔티티를 카운트 할 것이다. 그 반환타입은 CommentCountDto(ScheduleId와 count가 필드)로 하고 리스트로 만든다.
그 다음 countResults를 stream하면서 Map 형태의 자료구조를 만들것이다.
그 Map 안에는 countResults 안에 들어있는 CommentCountDto의 getter함수들을 키와 밸류 값을 만든다.
// 4. 각 Schedule을 SchedulePageResponseDto로 변환 (댓글 수는 Long을 int로 변환)
return schedulePage.map(schedule -> new SchedulePageResponseDto(
schedule.getId(),
schedule.getTitle(),
schedule.getContent(),
commentCountMap.getOrDefault(schedule.getId(), 0L).intValue(),
schedule.getCreatedAt(),
schedule.getUpdatedAt(),
schedule.getUser().getUserName()
));
처음에 가져왔었던 여러개의 Schedule이 담겨있는 Page에서 map을 이용해 순회하면서 각 Schedule을 SchedulePageResponseDto로 변환한다.
여기서 getOrDefault함수는 값이 있으면 get해오고 없다면 default를 반환하겠다는 뜻인데 default를 0L로 설정한 것이다. intValue는 int형으로 바꿔주는 것이다.
오늘은 이만 자야해서 여기까지 하겠다... 내일은 exception패키지, advice 패키지에 대해서 공부해야겠다.