새소식

Java

[스프링 부트와 JPA 활용 2] 정리

  • -
728x90
자바 ORM 표준 JPA 활용 2 정리

📌 

김영한님의 강의, 스프링 부트와 JPA 활용 1에 이어 2를 수강하며 들은 내용을 추가로 정리하려고 한다! 실제로 코드를 작성한 내용들은 활용 1 코드에 이어 작성하였고, 누군가 이 글을 볼 때 선행으로 보면 좋을 것 같아 첨부한다.

 

https://seung-seok.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8%EC%99%80-JPA-%ED%99%9C%EC%9A%A9-1-%EC%A0%95%EB%A6%AC

 

[스프링 부트와 JPA 활용 1] 정리

[스프링 부트와 JPA 활용 1] 정리 📌 김영한님의 강의, 스프링 부트와 JPA 활용 1을 수강하며 들은 내용을 정리하려고 한다. 코드 위주의 설명이나 중요한 개념에 대해서는 설명을 하고 넘어가겠다

seung-seok.tistory.com

https://github.com/iamseung/jpaUses1

 

GitHub - iamseung/jpaUses1: 🌿 스프링부트와 JPA 를 활용한 웹 애플리케이션을 설계 및 개발

🌿 스프링부트와 JPA 를 활용한 웹 애플리케이션을 설계 및 개발. Contribute to iamseung/jpaUses1 development by creating an account on GitHub.

github.com

 

 

김영한님의 JPA 활용 1 강의 에서는 주로 JPA 를 활용하여 도메인을 설계하고 그것을 기반으로 Repository 와 Service에 로직을 구현했다. 활용 2에서는 그것을 기반으로 API 를 개발하며 다양한 방법을 가지고 최적화와 Dto 를 적용한다.

 

API 개발 - 회원

MemberApiController

  • @ResponseBody + @Controller = @RestController 를 의미한다.
  • @ResponseBody는 데이터를 바로 xml또는 json형식으로 보낸다 라는 의미를 가지는 어노테이션.
  • @RequestBody -> Json으로 온 body를 Member엔티티에 매핑해준다.
@RestController
@RequiredArgsConstructor
public class MemberApiController {

	private final MemberService memberService;

	@PostMapping("/api/v1/members")
	public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member)
	{
		Long id = memberService.join(member);
		return new CreateMemberResponse(id);
	}
	@Data
	static class CreateMemberRequest {
		private String name; }
	@Data
	static class CreateMemberResponse {
		private Long id;
		public CreateMemberResponse(Long id) {
			this.id = id;
		}
	}
}

 

이렇게 Member객체를 받아와서 ApiController을 구현하였다. 이렇게 엔티티를 직접 받아와서 구현하는 것에는 몇가지 문제점이 있다.

  • 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
  • 엔티티에 API검증을 위한 로직이 들어간다.(@NotEmpty 등)
  • 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요청 요구사항을 담기는 어렵다.

결국, 엔티티가 변경되면 API스펙이 변해 치명적인 오류를 발생시킬 수 있다. 즉,  API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받는게 좋다!  다음은 Member객체를 받지않는 v2코드이다.

 

@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
    Member member = new Member();
    member.setName(request.getName());
    Long id = memberService.join(member);
    return new CreateMemberResponse(id);
}

@Data
static class CreateMemberRequest {
    @NotEmpty
    private String name;
}

 

이렇게 구성하였을 때 장점은 Member에서 어느값이 넘어오는지 모르는 것을 해결할 수 있고, 엔티티가 바뀌어도 API스펙이 바뀌지 않아 유지보수가 쉬워진다. 또한 @NotNull같은경우 상황에 따라 다르게 넣을 수 있다. 정리하자면 아래와 같다.

  • 엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.
  • 엔티티와 API 스펙을 명확하게 분리할 수 있다.
  • 엔티티가 변해도 API스펙이 변하지 않는다.

엔티티를 외부에 노출하지 않고 API스펙에 맞춘 Dto를 만드는게 좋다!

 

회원 수정 API

수정 할때는 가급적 변경감지를 쓰는것이 좋다.(Service 계층에 Transactional 사용)

(참고, MemberService)

@Service
@Transactional(readOnly = true) // 읽기 전용인 경우, 최적화
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    // 회원 가입
    @Transactional // 기본 옵션은 readOnly = false, 쓰기에 주로 지정
    public Long join(Member member) {
        // 중복 회원 검증
        validateDuplicateMember(member);

        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        // Exception
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if(!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

    // 회원 전체 조회
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Member findOne(Long memberId) {
        return memberRepository.findOne(memberId);
    }

    @Transactional
    public void update(Long id, String name) {
        Member member = memberRepository.findOne(id);
        member.setName(name);
    }
}

 

/**
 * 수정 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);
    return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}

@Data
static class UpdateMemberRequest {
    private String name;
}

@Data
@AllArgsConstructor
static class UpdateMemberResponse {
    private Long id;
    private String name;
}

 

오류정정: 회원 수정 API updateMemberV2 은 회원 정보를 부분 업데이트 한다. 여기서 PUT 방식을 사용했는데, PUT은 전체 업데이트를 할 때 사용하는 것이 맞다.

💡 부분 업데이트를 하려면 PATCH를 사용하거나 POST를 사용하는 것이 REST 스타일에 맞다고 합다.

 

회원 조회 API

@GetMapping("/api/v1/members")
    public List<Member> membersV1() {
    	return memberService.findMembers();
 }

 

위에서도 볼 수 있듯, Member엔티티 자체가 노출되어 문제점이 있을 수 있다. 무슨 문제점이 있는지 정리하면 아래와 같다.

  • 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다
  • 기본적으로 엔티티의 모든 값이 노출된다.
  • 응답 스펙을 맞추기 위해 로직이 추가된다.(@JsonIgnore, 별도의 뷰 로직 등등)
  • 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
  • 엔티티가 변경되면 API 스펙이 변한다.
  • 추가로 컬렉션을 직접 반환하면 항후 API 스펙을 변경하기 어렵다.(별도의 Result 클래스 생성으로 해결)

아래는 dto를 사용한 수정된 버전의 코드이다.

 

@GetMapping("/api/v2/members")
	/*
        Dto 를 통해 노출되는 엔티티의 정보를 지정하여 반환한다
        바로 반환하면 배열 형식으로 반환하기 때문에 한번 감싸주는 형태
     */
	public Result membersV2() {
		List<Member> findMembers = memberService.findMembers();
		//엔티티 -> DTO 변환
		List<MemberDto> collect = findMembers.stream()
				.map(m -> new MemberDto(m.getName()))
				.collect(Collectors.toList());
		return new Result(collect);
	}
    
	@Data
	@AllArgsConstructor
	static class Result<T> {
		private T data;
	}
    
	@Data
	@AllArgsConstructor
	static class MemberDto {
		private String name;
	}

 

엔티티를 DTO로 변환하여 반환하고 있다. 이 경우도 역시 엔티티가 변해도 API스펙이 변경되지 않으며, Result 클래스로 컬렉션을 감싸서 반환하기에 향후 필요한 필드를 추가할 수 있습니다. (그냥 반환하면 Array가 오는데에 반해, 이 방식은 JSON 스펙이 무너지지 않음)

 

⭐ API 개발 고급

주문 + 배송정보 + 회원을 조회하는 API를 만들어 보자! 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보겠다. 지금부터 배우는 내용은 정말 중요하다고 한다. 실무에서 JPA를 사용하기 위해서는 현재 내용을 100% 이해해야 한다!

 

간단한 주문 조회 V1: 엔티티를 직접 노출

앞서 말한대로 엔티티를 직접 노출하는 것은 좋지 않다.

  • order -> member와 order -> address는 지연로딩이다. 따라서 실제 엔티티 대신에 프록시가 존재한다.
  • jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.
  • Hibernate5Module을 스프링 빈으로 등록하여 해결한다.
/*
    Entity 전체 노출 하는 버전
    Hibernate 모듈을 기본 설정으로 쓰고 있기 때문에
    Lazy Loading 을 호출해서 프록시가 호출,
    즉, 데이터가 초기화된 데이터들을 Api로 반환하는 것을 목표로 한다.
 */
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());

    // 강제 Lazy Loading
    for (Order order : all) {
        order.getMember().getName();
        order.getDelivery().getAddress();

        // OrerItem 과 Item 도 초기화
        List<OrderItem> orderItems = order.getOrderItems();
        orderItems.stream().forEach(o -> o.getItem().getName());
    }

    return all;
}

 

엔티를 위와 같이 직접 노출할 때에는 양방향 연관관계가 걸린 곳은 꼭 한곳을 @JsonIgnore처리를 해야 한다. 안그러면 양쪽을 서로 호출하면서 무한루프에 빠지게 된다.

정말 간단한 애플리케이션이 아니면 엔티티를 노출하는 것을 정말 좋지 않기 때문에 hibernate5Module을 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.

 

또한 지연로딩(LAZY)를 피하기 위해 즉시 로딩(EAGER)으로 설정하면 절대 안된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 생길 수 있고 성능 튜닝도 매우 어려워 질 수 있기 때문이다.

따라서, 지연 로딩(LAZY)을 기본으로 하고, 성능 최적화가 추가로 필요한 경우 패치 조인(fetch join)을 사용하는게 좋다.

 

간단한 주문 조회 V2: 엔티티를 DTO로 변환

@GetMapping("api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<OrderDto> result = orders.stream()
            .map(OrderDto::new)
            .collect(Collectors.toList());

    return  result;
}

@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto> orderItems = new ArrayList<>();

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); // Lazy 초기화, 조회하고 없으면 디비 조직
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();

        // orderItem Entity 도 전체 노출을 지양하기 때문에 별도에 Dto 로 !
        orderItems = order.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collect(Collectors.toList());
    }
}

 

엔티티를 dto로 변환하는 일반적인 방법입니다. 이때 쿼리는 1+N+N번 실행된다.

  • Order 조회 1번(order 조회 결과 수가 N이다)
  • order-> member 지연 로딩 조회 N번
  • order -> delivery 지연 로딩 조회 N번
  • 4개인경우 1 + 4 + 4번 실행된다 (최악의 경우)
    - 지연 로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.그래서 최악의 경우라고 말하긴 하지만, 이런 경우는 많이 없다.

 

간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

@GetMapping("api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    List<OrderDto> result = orders.stream()
            .map(OrderDto::new)
            .collect(Collectors.toList());

    return  result;
}

// OrderRepository
public List<Order> findAllWithItem() {
    return em.createQuery(
            "select distinct o from Order o" +
                    " join fetch o.member m" +
                    " join fetch o.delivery d" +
                    " join fetch o.orderItems oi" +
                    " join fetch oi.item i" , Order.class)
            .getResultList();
}

 

order_id 가 4인 주문 건에 orderItems 가 2개인 경우 order 자체가 2배로 뻥튀기 되는 이슈가 있다. 이는 1대다 조인이 있으므로 데이터베이스 row가 증가하기 때문이다. distinct 를 추가해서 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러주고 order가 컬렉션 페치 조인 때문에 중복 조회되는 것을 방지한다.

쿼리 한번으로 조회 가능하다는 장점이 있지만 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.

💡 즉, 1대다 조인에서는 페치 조인을 사용하면 안된다.

 

간단한 주문 조회 V3.1: 페치 조인과 페이징이 가능한 API 설계

@GetMapping("api/v3.1/orders")
public List<OrderDto> ordersV3_page(
        @RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit
) {
    // XtoOne 관계는 페치 조인으로 바로 가져온다
    List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);

    // 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고 @BatchSize 로 최적화한다
    // 1대다 관계는 default_batch_fetch_size 를 지정해서 IN 으로 일괄로 가져와서 조회!
    List<OrderDto> result = orders.stream()
            .map(OrderDto::new)
            .collect(Collectors.toList());

    return  result;
}

...
...
public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
            "select o from Order o" +
                    " join fetch o.member m" +
                    " join fetch o.delivery d", Order.class
    ).getResultList();
}

...
...
@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto> orderItems = new ArrayList<>();

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); // Lazy 초기화, 조회하고 없으면 디비 조직
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();

        // orderItem Entity 도 전체 노출을 지양하기 때문에 별도에 Dto 로 !
        orderItems = order.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collect(Collectors.toList());
    }
}

 

ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화한다!

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.