-> 블로그 이전

[Spring Data JPA] Web Binding

2022. 8. 23. 20:37Language`/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에 대한 정보를 정확하게 가져옴을 확인할 수 있다

위 로직의 과정은 다음과 같다

  1. @RequestParam을 통해서 Binding받은 Member의 PK를 넘기기
  2. Controller에서 MemberService에 PK값을 넘기기
  3. MemberService는 MemberRepository로부터 PK값에 대한 Member를 찾아온다
  4. MemberRepository로부터 PK값에 대한 Member를 return받은 MemberService는 Member를 그대로 Controller로 return
  5. 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

각각 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;
}