목표
주문 + 배송정보 + 회원을 조회하는 API를 만들자
조회용 샘플 데이터 입력
📌 InitDb.java
package bookbook.shopshop;
import bookbook.shopshop.domain.*;
import bookbook.shopshop.domain.item.Book;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
/**
* * userA
* * JPA1 BOOK
* * JPA2 BOOK
* * userB
* * SPRING1 BOOK
* * SPRING2 BOOK
*/
@Component
@RequiredArgsConstructor
public class InitDb {
private final InitService initService;
@PostConstruct
public void init() {
initService.dbInit1();
initService.dbInit2();
}
@Component
@Transactional
@RequiredArgsConstructor
static class InitService {
private final EntityManager em;
public void dbInit1() {
Member member = createMember("userA", "서울", "1", "1111");
em.persist(member);
Book book1 = createBook("JPA1 BOOK", 10000, 100);
em.persist(book1);
Book book2 = createBook("JPA2 BOOK", 20000, 100);
em.persist(book2);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 2);
Delivery delivery = createDelivery(member);
Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
em.persist(order);
}
private Delivery createDelivery(Member member) {
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
return delivery;
}
public void dbInit2() {
Member member = createMember("userB", "서울", "1", "1111");
em.persist(member);
Book book1 = createBook("JPA1 BOOK", 20000, 200);
em.persist(book1);
Book book2 = createBook("JPA2 BOOK", 40000, 300);
em.persist(book2);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);
Delivery delivery = createDelivery(member);
Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
em.persist(order);
}
private Book createBook(String name, int price, int stockQuantity) {
Book book1 = new Book();
book1.setName(name);
book1.setPrice(price);
book1.setStockQuantity(stockQuantity);
return book1;
}
private Member createMember(String name, String city, String street, String zipcode) {
Member member = new Member();
member.setName(name);
member.setAddress(new Address(city, street, zipcode));
return member;
}
}
}
간단한 조문 조회 V1 : 엔티티를 직접 노출
1. 엔티티를 직접 노출하여 Order 엔티티 조회하기
📌 Order 엔티티
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; // 주문 시간
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER / CANCEL]
// == 연관관계 메서드 ==//
public void changeMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
// == 연관관계 메서드 ==//
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
// == 연관관계 메서드 ==//
public void changeDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
}
📌 OrderSimpleApiController
package bookbook.shopshop.api;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.repository.OrderRepository;
import bookbook.shopshop.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @XxxToOne 관계 최적화
* Order 조회
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
- @XxxToOne 관계 최적화 시키는 방법이다.
- findAllByString(new OrderSearch()) 를 통해 모든 Order를 호출했다.
이렇게 작성하고 Postman으로 조회
API를 호출하여 Order를 조회하면 가지는 Order → Member / Order → Delivery / Order → OrderItem 간에 서로 엔티티를 조회하는 무한 순회가 일어난다.
무한 순회가 일어나는 이유는 Order는 Member / Delivery / OrderItem 과 양방향 관계를 가지고 있기 때문이다.
2. @JsonIgnore로 양방향 무한 순회 해결하기
- 양방향 연관관계를 가지는 엔티티에 @JsonIgnore를 사용하여 한 쪽에서는 Json 객체가 안만들어지도록 해야한다.
📌 Delivery 엔티티
package bookbook.shopshop.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import static javax.persistence.FetchType.LAZY;
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore // @JsonIgnore 적용
@OneToOne(mappedBy = "delivery", fetch = LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; // READY, COMP
}
- Order 엔티티와 Delivery 엔티티는 서로 양방향 관계이므로 @JsonIgnore를 적용했다.
📌 Member 엔티티
package bookbook.shopshop.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@JsonIgnore // @JsonIgnore 적용
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- Order 엔티티와 Member 엔티티는 서로 양방향 관계이므로 @JsonIgnore를 적용했다.
📌 OrderItem 엔티티
package bookbook.shopshop.domain;
import bookbook.shopshop.domain.item.Item;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import static javax.persistence.FetchType.LAZY;
import static lombok.AccessLevel.PROTECTED;
@Entity
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore // @JsonIgnore 적용
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice; // 주문 가격
private int count; // 주문 수량
}
- Order 엔티티와 OrderItem 엔티티는 서로 양방향 관계이므로 @JsonIgnore를 적용햇다.
이제 다시 Postman으로 조회한다.
ByteBuddyInterceptor에서 Type definition error가 발생한다.
- 현재 코드의 로딩 전략은 지연 로딩(LAZY) 전략이다. 그 말은 Member, Delivery 등의 엔티티를 조회하는 시점에서는 실제 객체가 아닌 프록시 객체를 가지고 있다. 그렇기 때문에 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하는 것이다.
3. Hibernate5Module로 Type definition error 해결하기
🔍 Hibernate5Module 의존관계 추가
📌 build.gradle
https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-hibernate5
Hibernate5Module 의존관계 주소 : https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-hibernate5
📌 ShopshopApplication.java
package bookbook.shopshop;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class ShopshopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopshopApplication.class, args);
}
@Bean // Hibernate5Module 빈 등록
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
}
- 메인 설정 클래스에 Hiberate5Module을 빈 등록 한다.
- Hiberate5Module은 기본적으로 초기화된 프록시 객체만 노출된다. 초기화되지 않은 프록시 객체는 노출하지 않는다.
- 프록시 객체 초기화란, 영속성 컨텍스트에 해당 프록시 객체를 조회하여 실제 객체로 바뀌는 것을 의미한다.
Postman으로 api를 조회하면 양방향 관계는 proxy 객체를 사용한다. 해당 프록시 객체를 조회하지 않는 이상 null 값으로 되어 있다.
4. 결론
- 절대 엔티티를 직접 노출하지 말라!
- Hibernate5Module을 사용하여 엔티티를 직접 반환하기보다는 DTO로 변환하여 반환하는 것이 제일 좋다.
- 만약에 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳에 @JsonIgnore 처리를 해야한다. 하지만 절대 엔티티를 직접 노출하지 말라
- 무조건 모든 전략을 지연로딩(LAZY)으로 해라.
- 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.
- 즉시 로딩으로 설정하면 성능 튜닝이 매우 어렵다.
- 항상 지연 로딩을 기본으로 하고 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라
간단한 주문 조회 V2 : 엔티티를 DTO로 변환
1. V2 : 엔티티를 DTO로 변환하기
📌 OrderSimpleApiController
/**
* @XxxToOne 관계
* Order 조회
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/**
* V2 : 엔티티를 조회해서 DTO로 변환 (fetch join 사용x)
* - 단점 : 지연로딩으로 쿼리 N번 호출 (N+1 문제)
*/
@GetMapping("api/v2/simple-orders")
public Result orderV2() {
// ORDER 2개 조회
// 1 + N(2개) 문제 -> 1 + 회원 N + 배송 N
List<Order> orders = orderRepository.findAllByString(new OrderSearch()); // 모든 Order 조회
List<SimpleOrderDto> result = orders.stream()
.map(SimpleOrderDto::new)
// .map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return new Result<>(result);
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// dto가 엔티티를 의존하는 것은 크게 문제가 되지 않는다.
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
}
- lazy 초기화 : 영속성 컨텍스트가 영속성 컨텍스트를 찾아보고 없으면 db를 조회해서 쿼리가 생성된다.
- 여기서 N+1 문제가 발생한다.
- N+1 문제 : 첫 번째 쿼리로 인해 여러 줄의 쿼리문이 생성 되는 것
Postman으로 조회한 결과다. Result<T> 객체를 따로 생성하여 Result<T> 객체 안에 담아서 반환했다. 그게 좋은 방법이다.
2. V2 문제 - N+1 문제
V2에도 단점이 존재한다. 지연 로딩으로 인해 데이터베이스 쿼리가 너무 많이 호출된다.
이전에 미리 조회용 샘플 데이터를 입력했으므로 Order를 조회하면 총 2개의 결과가 출력된다.
order를 출력하기 위해 최악의 경우 1(Order 조회) + 2(Member 조회) + 2(Delivery 조회) 번이 출력된다. 한마디로 N+1 문제가 생기는 것이다. 첫 번째 쿼리의 결과로 N번 만큼의 쿼리가 추가 실행 되는 것을 의미한다.
전체 Order 조회
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id limit ?
- findAllByString() 메서드에 의해서 Order를 조회하는 첫 번째 쿼리문이 나간다.
- 여기서 inner join문은 무시해도 된다.
- new OrderSerach() 때문에 생기는 것이다.
회원1의 Member 조회
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
- Order의 결과가 2개이므로 회원도 2명을 찾아야 한다. 먼저 회원1을 조회한다.
- 지연로딩은 영속성 컨텍스트에서 조회하기 때문에 만약에 2개의 Order 개수가 모두 같은 회원이 시킨 것이라면 회원을 처음 조회할 때 영속성 컨텍스트에서 있는 회원 정보를 계속 재사용할 수 있어서 1 + 2 개의 쿼리가 생성된다.
- 그러나 회원이 같을 가능성이 낮기 때문에 최악의 경우인 회원이 서로 다른 경우를 생각하는 것이 맞다.
- 그러므로 1(첫 번째 쿼리) + 2 (회원1 쿼리)+ 2(회원2 쿼리) 이렇게 생각하는 것이 맞다.
회원1의 Delivery 조회
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
- 회원1의 Delivery 조회 쿼리문이 나간다.
회원2의 Member 조회
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
- 회원1의 조회가 끝났으면 이제 회원2의 조회가 시작된다.
회원2의 Delivery 조회
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
- 회원2의 Delivery 조회 쿼리문이 출력됐다.
🔍 결과
- Order를 조회했더니 결국 5개(1+2+2)의 쿼리문에 생겼다.
- 만약에 Order의 개수가 10개고 서로 다 각기 다른 회원이라면 1(첫 번째 쿼리) + 10(10명의 회원 조회) + 10(10명의 배송 조회) 으로 총 21개의 쿼리가 생겼을 것이다.
- N+1 문제는 V3에서 fetch join으로 해결하도록 하겠다.
간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
1. 페치 조인으로 N+1 문제 해결 및 최적화
📌 OrderRepository (추가 코드)
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();
}
- OrderRepository에 findAllWithMemberDeliveyr()를 추가로 정의햇다.
- fetch join을 사용하여 연관관계의 모든 엔티티를 한꺼번에 조회한다.
- 지연 로딩을 즉시 로딩으로 바꾼 것이다.
📌 OrderSimpleApiController
/**
* @XxxToOne 관계
* Order 조회
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/**
* V3. 엔티티를 조회해서 DTO로 변환 (fetch join 사용)
* - fetch join으로 쿼리 1번만 호출
*/
@GetMapping("api/v3/simple-orders")
public Result orderV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
return new Result<>(result);
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// dto가 엔티티를 의존하는 것은 크게 문제가 되지 않는다.
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
}
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
- 페치 조인으로 order → member / order → delivery 는 이미 조회된 상태이므로 지연로딩이 되지 않는다.
- 이것도 단점이 존재한다.
- 단점 : select 절에서 다 끌고 왔다.
- 이 단점은 V4에서 해결하겠다.
간단한 주문 조회 V4 : JPA에서 DTO로 바로 조회
- V3에서는 엔티티를 조회한 다음에 엔티티를 DTO로 변환하는 작업을 거쳤다. 이런거 필요 없이 바로 JPA에서 DTO로 조회할 수 있다.
- 이렇게 하면 성능 최적화가 가능하다.
- 리포지토리는 되도록이면 순수 엔티티를 조회하는데 쓰일 것
- Dto를 조회하는 로직을 OrderRepository에 만들지 않고 따로 OrderSimpleQueryRepository에 만들었다.
📌 OrderSimpleQueryDto
package bookbook.shopshop.repository.order.simplequery;
import bookbook.shopshop.domain.Address;
import bookbook.shopshop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
- Dto 클래스다.
📌 OrderSimpleQueryRepository
package bookbook.shopshop.repository.order.simplequery;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new bookbook.shopshop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
- 성능 최적화용 쿼리 적용 Repository를 따로 만들었다.
- JPA에서는 엔티티, 값 타입(임베티드 타입) 정도만 조회할 수 있다. DTO를 조회하려면 new 연산자를 써야한다.
- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환한다.
- new 연산자에 dto 패키지명을 다 적어줘야 한다.
- 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회한다.
📌 OrderSimpleApiController
/**
* @XxxToOne 관계
* Order 조회
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
private final OrderSimpleQueryRepository orderSimpleQueryRepository;
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select절에서 원하는 데이터만 선택해서 조회한다.
*/
@GetMapping("/api/v4/simple-orders")
public Result orderV5() {
List<OrderSimpleQueryDto> orderDtos = orderSimpleQueryRepository.findOrderDtos();
return new Result<>(orderDtos);
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
}
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
- V4는 원하는 데이터만 선별적으로 가져와서 V3과 비교했을 때 select 절이 적다.
🔍 정리
- V3 과 V4 누가 더 나은지 가리기 힘들다. 각각의 장단점이 존재한다.
- V3는 재사용성이 높다.
- V4는 해당 dto만 적용할 수 있다. 재사용성이 낮다.
- V4는 리포지토리가 엔티티에 맞춰지지 않고 dto에 맞춰져있다. dto 변경시 리포지토리도 같이 변경해야한다.
- api 스펙이 리포지토리에 들어온 것이다.
- 대부분 상황에서 V3과 V4의 성능차이가 나지 않는다. 그리고 성능을 결정하는 큰 요소는 select절이 아니라 from절과 where절이다.
- 만약에 데이터가 많은 경우에는 서로 성능 테스트를 해보고 사용할 것
결론 : 어느 것을 써야할까?
1. 쿼리 방식 선택 권장 순서
① 우선 엔티티를 DTO로 변환하는 방법을 선택한다 : V2 버전
② 필요하면 페치 조인으로 성능을 최적화한다. → 90퍼의 성능 이슈가 해결된다. : V3 버전
③ 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. : V4 버전
④ 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해서 SQL을 직접 사용한다.
1번부터 적용하고 안되면 다은 번호를 적용할 것.
👀 참고 자료
'[JPA] > 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화' 카테고리의 다른 글
[JPA] API 개발 고급 - 실무 필수 최적화 (0) | 2022.04.13 |
---|---|
[JPA] API 개발 고급 - 컬렉션 조회 최적화 (일대다 관계) (0) | 2022.04.12 |
[JPA] API 개발 기본 (0) | 2022.04.10 |