2022. 8. 23. 20:37ㆍLanguage`/JPA
Parameter로 Member의 id(PK)가 넘어오고 이를 통해서 멤버를 조회하는 일반적인 로직을 작성해보자
// Controller
@RestController
@RequiredArgsConstructor
public class TestController {
private final MemberService memberService;
@GetMapping("/member")
public Member getMember(@RequestParam Long id) {
return memberService.findMember(id);
}
}
// Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
public Member findMember(Long id) {
return memberRepository.findMemberBy(id)
.orElseThrow(IllegalArgumentException::new);
}
}
// Repository
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.id = :memberId")
Optional<Member> findMemberBy(@Param("memberId") Long memberId);
}
일반적으로 Service Layer에서 Entity를 그대로 Controller/View로 노출시키는 행위는 절대로 하지 말아야할 행위이다
왜냐하면 Entity 자체가 Controller/View로 넘어가게 되면 엔티티에 대한 스펙 자체가 외부로 노출되고 이에 따른 원하지 않은 데이터에 대한 변경이 발생할 수 있기 때문이다
그리고 OSIV를 끄게 된다면 영속성 컨텍스트의 범위 자체가 트랜잭션으로 한정되고 트랜잭션은 Service Layer에서 끝나기 때문에 준영속 상태인 Entity를 Controller/View로 노출시키면 대표적인 오류인 LazyInitialaztionException가 발생할 수 있다
위에서는 단지 Spring Data Project에서 제공해주는 "Web 확장 기능" 테스트를 위해서 Entity를 Controller로 노출시키는 것이다<OSIV와 Layer간 Entity노출에 대한 포스팅은 추후에 예정>
이렇게 PathVariable의 PK값을 통해서 DB에서 Member에 대한 정보를 정확하게 가져옴을 확인할 수 있다
위 로직의 과정은 다음과 같다
- @RequestParam을 통해서 Binding받은 Member의 PK를 넘기기
- Controller에서 MemberService에 PK값을 넘기기
- MemberService는 MemberRepository로부터 PK값에 대한 Member를 찾아온다
- MemberRepository로부터 PK값에 대한 Member를 return받은 MemberService는 Member를 그대로 Controller로 return
- Member를 return받은 Controller는 @RestController이므로 Member에 대한 JSON데이터를 그대로 Response Body에 실어서 응답
이렇게 굉장히 긴 로직을 "Spring Data의 Web 확장 기능"을 사용하면 굉장히 간단하게 변경할 수 있다
@EnableSpringDataWebSupport
@SpringBootApplication
@EnableSpringDataWebSupport
public class QueryPracticeApplication {
public static void main(String[] args) {
SpringApplication.run(QueryPracticeApplication.class, args);
}
}
@EnableSpringDataWebSupport 애노테이션을 통해서 [도메인 클래스 컨버터 + 페이징/정렬]을 위한 HandlerMethodArgumentResolver가 스프링 빈으로 등록된다
도메인 클래스 컨버터
도메인 클래스 컨버터는 Parameter로 넘어온 Entity의 ID로 엔티티 객체 자체를 찾아서 Binding해준다
@GetMapping("/member")
public Member getMemberBinding(@RequestParam Member member) {
return member;
}
Request는 "id=1"로 받지만 중간에 도메인 클래스 컨버터가 작동해서 해당 식별자를 통해서 Member라는 Entity를 조회하는 query가 발생하고 그에 따라서 Member를 어떠한 다른 로직없이 바로 찾을 수 있게 된다
@Entity
@Table(name = "member")
public class Member {
...
...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;
}
위에서 도메인 클래스 컨버터를 통해서 Member를 찾을 때 Member와 연관된 Team에 대한 fetchType을 EAGER로 하였다.
이 이유는 다음과 같다
일단 Team에 대한 fetchType을 LAZY로 하게 되면 id(PK)값을 통해서 Repository에서 Member를 가져올때 Member와 연관된 Team은 실제 객체가 아닌 "프록시 객체"로 가져오게 된다
여기서 실제로 Controller에서 @RestController로 Member를 응답할때는 "Jackson"라이브러리를 통해서 [객체 -> JSON]으로 변환하는 과정이 이루어진다
하지만 Jackson 라이브러리는 실제값에 대한 변화만 가능하지 "프록시 객체"를 본인이 가져와서 변환할 수 있는 방법은 존재하지 않는다
따라서 Team에 대한 fetchType을 EAGER로 함에 따라 Member를 조회할 때 Team도 즉시 조회하려고 한 것이다.
만약 Team에 대한 fetchType을 LAZY로 하고 Member에 대한 @RestController JSON Response를 하게 되면 다음과 같은 오류가 발생하게 된다
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: could not initialize proxy [JPA.QueryPractice.domain.Team#1] - no Session; nested exception is com.fasterxml.jackson.databind.JsonMappingException: could not initialize proxy [JPA.QueryPractice.domain.Team#1] - no Session (through reference chain: JPA.QueryPractice.domain.Member["team"]->JPA.QueryPractice.domain.Team$HibernateProxy$reOag3Bu["name"])]
그리고 실제로 @EnableSpringDataWebSupport를 통해서 도메인 클래스 컨버터를 활용하기는 힘들다
이 이유는 OSIV에 있다
<OSIV ON>
일단 query를 통해서 조회한 Entity를 "영속 상태"이다
하지만 OSIV의 특성상 Controller/View에서는 영속성 컨텍스트에 대한 flush를 하지 않는다
따라서 말도 안되지만 Controller단에서 Entity에 대한 modify를 하더라도 flush가 발생하지 않기 때문에 modify한 데이터는 실제 DB에 반영되지 않는다
<OSIV OFF>
OSIV를 끄게되면 조회한 Entity는 Controller/View단에서 조회했기 때문에 "준영속"상태이다 (트랜잭션이 존재하지 않기 때문에)
준영속 상태인 Entity에 대한 modify를 하는 행위는 굉장히 위험한 행위이다
>> 일단 Controller/View단에 실제 Entity를 노출시키는거 자체가 문제이고 Entity는 Service Layer에서 DTO로 반드시 변환을 한 후에 Controller/View단으로 넘겨야 한다.
페이징 & 정렬 기능
@EnableSpringDataWebSupport에 의해서 "HandlerMethodArgumentResolver"가 스프링 빈으로 등록되면 도메인 클래스 컨버터뿐만 아니라 "페이징 & 정렬"에 대한 Pageable을 Controller단에서 편리하게 받을 수 있게 된다
각각 PageableHandlerMethodArgumentResolver/SortHandlerMethodArgumentResolver에서 Binding받는 Paratmer의 defaultName을 나타낸다
기본적으로 queryString에 page/size/sort가 존재하지 않으면 PageRequest.of(0, 20)으로 생성된다
@GetMapping("/paging")
public Pageable pageRequestBinding(Pageable pageable) {
return pageable;
}
(1) queryString X
어떠한 queryString도 붙이지 않는다면 기본적으로 PageRequest.of(0, 20)의 PageRequest객체가 생성된다
(2) page, size에 대한 queryString
[page(0) = 0 ~ 29]이므로 page=1&size=30에서의 offset은 30부터 시작임을 계산을 통해서 확인할 수 있다
(3) page, sort에 대한 queryString
이렇게 정렬 조건도 제시할 수 있다.
여러 정렬 조건을 제시하기 위해서는 [sort=~~]을 제시할 조건대로 붙여주면 되고 "내림차순(DESC) 정렬"일 경우 정렬 기준에 콤마(,)를 통해서 desc를 표기해주면 된다
- 콤마로 표기하지 않으면 정렬에 대한 default 기준은 ASC이다
(4) page, 여러개의 sort에 대한 queryString
(5) page, size, 여러개의 sort에 대한 queryString
※ @Qualifier을 통한 Pageable 구분
여러 Pageable을 동시에 Binding받고 싶다면 각 Pageable을 @Qualifier을 통해서 구분해야 한다
>> 여기서 알 수 있듯이 @Qualifier을 적용하면 각각의 Pageable간에 [@Qualifier지정 prefix + "_" + (page/size/sort)]로 필드가 구성된다
@GetMapping("/pagings")
public Pageable[] pageRequestsBinding(
@Qualifier("member") Pageable memberPageable,
@Qualifier("team") @PageableDefault(page = 1, size = 50) Pageable teamPageable
) {
System.out.println("memberPageable = " + memberPageable);
System.out.println("teamPageable = " + teamPageable);
return new Pageable[]{memberPageable, teamPageable};
}
@Qualifier에서 지정한 지정자대로 [page, size, sort]를 각각 구분 지어서 queryString을 만들면 된다
※ Pageable의 기본값 변경
기본적으로 @EnableSpringDataWebSupport를 활용할 때 Pageable에 대한 Binding 기본값은 [page=0, size=20, sort=X]이다.
이 기준을 변경하기 위해서는 @PageableDefault를 활용해서 변경하면 된다
// 기본값
@GetMapping("/paging")
public Pageable pageRequestBinding(Pageable pageable) {
return pageable;
}
// @PageableDefault 애노테이션 활용 1
@GetMapping("/paging")
public Pageable pageRequestBinding(
@PageableDefault(page = 2, size = 35, sort = {"age"}, direction = Sort.Direction.DESC) Pageable pageable
) {
System.out.println(pageable);
return pageable;
}
// @PageableDefault 애노테이션 활용 2
@GetMapping("/paging")
public Pageable pageRequestBinding(
@PageableDefault(page = 2, size = 35, sort = {"age", "name"}, direction = Sort.Direction.DESC) Pageable pageable
) {
System.out.println(pageable);
return pageable;
}