스프링부트와 JPA 활용2

권영태·2023년 5월 24일
1

스프링

목록 보기
12/18

스프링부트와 JPA를 활용해 웹 애플리케이션을 개발한다.
다음 코드 및 진행 방법은 인프런 김영한 강사님의 유로 강의 내용을 발췌한 내용이다.
강의 : <실전! 스프링 부트와 JPA 활용 1 - 웹 애플리케이션 개발>

출처 : https://inf.run/zzKt

강의를 들으며 추가로 궁금한 내용과 제대로 이해되지 않은 부분을 정리한다.

이번 강의 간 '프레젠테이션 계층'이 자주 등장 한다.
프레젠테이션 계층이란 클라이언트의 요청/응답을 처리하는 계층으로 Controller가 이 계층에 속한다.

⚙️ 회원 등록 API

@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {   
// @RequestBody는 json으로 온 body를 멤버로 바꿔줌
    Long id = memberService.join(member);
    return new CreateMemberResponse(id);
}
  • 위 방법은 엔티티에 API 검증을 위한 로직(@NotEmpty 등)이 들어간다.
    이는 여러 요청사항이 들어오는 실무에서 좋지 않으며, 엔티티가 변경되면 API 스펙도 변해 좋지 않다.
  • API 스펙에 맞춰 별도의 DTO를 만들고 이를 이용하면 해결할 수 있다.
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
    Member member = new Member();
    member.setName(request.getName());      
    // Entity user -> username으로 변경하면 member.setName이 컴파일 단계에서 오류나서 Api는 영향 받지 않음.

    Long id = memberService.join(member);
    return new CreateMemberResponse(id);
}

@Data
static class CreateMemberRequest {

    @NotEmpty       // Entity가 아닌 여기에 선언 시 api 스펙에 딱 맞출 수 있어 유지보수가 수월해짐
    private String name;
}
  • @RequsetBody를 DTO(CreateMemberRequset)에 매핑하면 엔티티와 프레젠테이션 계층을 분리할 수 있게 되고, 이는 엔티티 변경이 API 스펙에 영향을 끼치지 않는다는 것을 의미한다.
@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(
        @PathVariable("id") Long id,
        @RequestBody @Valid UpdateMemberRequest request){

    memberService.update(id, request.getName());
    Member findMember = memberService.findOne(id);      
    // 커맨드와 쿼리를 분리해 memberService.update()에서 id를 return 받지않고 조회 후 객체를 만들어 그 값을 리턴함
    return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}

@Data
static class UpdateMemberRequest{		// 별도 DTO
    private String name;

}
@Data
@AllArgsConstructor
static class UpdateMemberResponse{	// Id
    private Long id;

    private String name;
}
  • memberService.update (변경 감지로직)을 이용해 값을 변경한다.

  • 회원 조회 API도 등록 API처럼 Entity를 노출하면 안된다.
    또한 각 요청이 필요로하는 값들이 다르기 때문에 @JsonIgnore를 사용할 수 없다. API 스펙에 맞춰 별도의 DTO로 감싸서 반환하면 위 문제들을 해결할 수 다.

🔄️ 지연로딩과 조회 성능 최적화

@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());
    for (Order order : all) {
        order.getMember().getName();    // .getMember()까지는 프록시 객체 상태, .getName()은 실제 값을 끌고 와야됨 -> LAZY 강제 초기화
        order.getDelivery().getAddress();   // 똑같이 LAZY 강제 초기화
    }
    return all;
}
  • XToOne(Fetch = LAZY) 지연로딩은 실제 MemberEntity 대신 ProxyMember 객체를 갖고 온다. * 실제 DB 쿼리 X
    • ProxyMember를 초기화해주면 실제 값(객쳬)를 가져온다!
    • Hibernate5JakartaModule를 등록/설정하면 강제 지연 로딩을 설정할 수 있다.

      @Bean
      Hibernate5Module hibernate5Module() {		
      		Hibernate5Module hibernate5Module = new Hibernate5Module();
       //강제 지연 로딩 설정
      hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
      return hibernate5Module; 
      }
    • 이 옵션을 키면 양방향 연관관계가 무한 루프가 걸려 한 쪽에 @JsonIgnore를 지정해야 되고, 외부로 노출되기 때문에 DTO로 변환하는것이 더 좋다.
    • LAZY를 피하기 위해 EGGER로 변경하는건 옳지 않다!

❓ N+1 문제

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2() {
    //ORDER 2개
    //N + 1 -> 1 + 회원 N + 배송 N
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());

    List<SimpleOrderDto> result = orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());

    return result;
}

@Data
static class SimpleOrderDto{
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();     // LAZY 초기화  * 영속성 컨텍스트가 DB에 쿼리를 날려 데이터를 가져옴
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();     // LAZY 초기화  * 영속성 컨텍스트가 DB에 쿼리를 날려 데이터를 가져옴
    }
}
  • 위 방식은 Entity를 외부에 노출하지 않고, 좀 더 적은 Query만 보내 최적화 할 수 있다.

  • 다만 order -> member / order -> delivery는 지연 로딩, 즉 지연 로딩 조회가 최악의 경우 1+ 2 + 2번(N+1문제) 발생한다.
    최악의 경우라고 하는 이유는 지연로딩은 영속성 컨텍스트에서 조회하는데,
    한번 조회된 쿼리는 영속성 컨텍스트에 있는 값을 가져오기 때문에 조회하지 않아서 최악의 경우(최대)의 상황을 가정한다.

    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> orderV3() {
        List<Order> orders = orderRepository.findAllWithMemeberDelivery();      // LAZY 상관 없이 실제 값을 끌고 온다.
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
    
        return result;
    
    }
    
    public List<Order> findAllWithMemeberDelivery() {       // 재사용성이 좋음
        return em.createQuery(
                "select o from Order o" +       // order와 member, order와 delivery를 join 후 한번에 다 select
                        " join fetch o.member m" +      // LAZY를 무시하고 프록시가 아닌 실제 값을 가져옴
                        " join fetch o.delivery d", Order.class
        ).getResultList();
    }
  • orderV3같이 fetch join을 이용하면 N+1 문제를 해결할 수 있다!

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryReopository {

private final EntityManager em;

public List<OrderSimpleQueryDto> findOrderDtos() {      // 해당 Dtos를 쓸 때만 사용 가능..단 V3보다 성능차이가 좀 더 좋음
    return em.createQuery(
                    "select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
                            " from Order o" +       // 매핑하기 위해선 new 연산자 필수 사용
                            " join o.member m" +
                            " join o.delivery d", OrderSimpleQueryDto.class)    // o가 Dto와 매핑 될 수 없음
            .getResultList();

	}
}
  • JPA를 이용해 DTO에서 바로 조회 하는 방식은 원하는 값을 선택해서 조회해 원하는 값만 Qurery를 날려 조금 더 최적화시킬 수 있다.
    하지만 API 스펙에 맞춘 코드가 Repo에 들어간다는 점과 재사용성이 떨어진다는 단점들이 있다.

결론적으로 Entity가 아닌 API 스펙에 맞춘 별도 DTO들을 생성해 사용하고 필요하면 Fetch Join으로 성능을 최적화한다!

🤔 궁금한 것들

  • @RequiredArgsConstructor : final 또는 @Notnull이 붙은 필드의 생성자를 자동 생성

  • @ResponseBody : 데이터를 JSON 객체로 반환

  • @RestController : ResponseBody + controller → 데이터를 JSON 형태로 객체 반환한다. * @RestController를 명시하면 @ResponseBody를 지정해주지 않아도 된다.

  • @JsonIgnore : 해당 어노테이션이 붙은 값은 Json 데이터에 Null 지정된다(데이터 값 무시)

  • @PostConstruct : 의존성 주입이 완료된 시점 이후에 실행되어야 되는 method에 붙임

    호출 순서

      1. 생성자 호출 2. 의존성 주입 3. @PostConstruct
    • 해당 어노테이션을 사용하면 빈이 초기화 됨과 동시에 의존성 확인 가능.

    추가로 컬렉션을 직접 반환하면 항후 API 스펙을 변경하기 어렵다.(별도의 Result 클래스 생성으로 해결)

profile
GitHub : https://github.com/dudxo

0개의 댓글