API 개발 고급 - 지연 로딩과 조회 성능 최적화

2023. 7. 1. 22:44·JPA/2편- 실전! 스프링 부트와 JPA 활용

사전 준비 - 조회용 샘플 데이터 입력


jpabook::jpashop::InitDb

/**
 * 총 주문 2개
 * 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);
        }

        public void dbInit2() {
            Member member = createMember("userB","진주", "2", "2222");
            em.persist(member);

            Book book1 = createBook("SPRING1 BOOK", 20000, 200);
            em.persist(book1);

            Book book2 = createBook("SPRING2 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 static 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;
        }

        private static Delivery createDelivery(Member member) {
            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());
            return delivery;
        }

        private static Book createBook(String name, int price, int stockQuantity) {
            Book book1 = new Book();
            book1.setName(name);
            book1.setPrice(price);
            book1.setStockQuantity(stockQuantity);
            return book1;
        }
    }
}
※ 참고

IntelliJ shortcut
ctrl+ alt + m : duplicate code refactor(중복된 코드들을 하나의 메서드로 추출해준다.)
ctrl+ alt + v : method 반환 값에 따라 변수 선언을 해준다.
  • 주문 + 배송정보 + 회원을 조회하는 API를 여러 방법으로 만들어 볼 것이다.
  • 그러면서 지연로딩 때문에 발생하는 성능 문제를 단계적으로 해결할 것이다.

(Impotant) 실무에서 중요한 내용들을 다루기에 이 챕터는 모두 복습 철저히 하도록 하자.

 

 

 

이번 장에서는 xxToOne(ManyToOne, OneToOne) 관계에 있는 엔티티와의 성능 최적화에 집중하여 해결한다.

 

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


V1. 엔티티 직접 노출

  • Hibernate5Module 모듈 등록, LAZY=null 처리
  • 양방향 관계 문제 발생 -> @JsonIgnore
package jpabook.jpashop.api;

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;


/**
 * xToOne(ManyToOne, OneToOne) 관계 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RequiredArgsConstructor
@RestController
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;
    }
}
  • 우선 엔티티를 직접 노출(반환) 하는것은 좋지 않다.(저번 챕터에서도 언급)
  • 해당 API를 호출하면 Order ▶ Member와 Order ▶ Delivery간에 서로의 엔티티를 조회하는 것이 무한순회를 하게되어 에러가 발생하고 제대로된 Response가 오지 못한다.

Order ->&nbsp; Delivery 간 무한참조 결과

 

@JsonIgnore

양방향 연관관계 부분에 @JsonIgnore 을 추가해주면 Json Response를 생성할 때 연관관계 반대 쪽으로는 Ignore 하므로 무한 루프 에러는 해결된다.

 

하지만, 다음 에러가 발생한다.

 

에러 메세지를 보면 bytebuddy.ByteBuddyInterceptor에서 Type defnition error가 발생한것을 볼 수 있다. 어째서 해당 에러가 났을까?

 

현재 코드의 로딩 전략은 지연로딩(LAZY)전략을 사용한다. 그 말은 Member, Delivery등의 엔티티들을 조회하는 시점에서는 실제 객체가 아닌 프록시 객체를 가지고 있다.

※ 참고

요즘에는 프록시 기술을 쓸 때 bytebuddy라이브러리를 많이 사용한다. 그래서 ByteBuddyInterceptor클래스가 사실 Member member = new ByteBuddyInterceptor(); 처럼 프록시 객체가 대신 들어가 있는 것이다.

여기서 문제점은 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하는 것이다.

 

 

해결책

스프링 부트를 사용 중이라면 Hibernate5Module 을 스프링 Bean으로 등록하면 해결된다.

/*build.gradle*/
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

/*JpashopApplication.java*/
@Bean
Hibernate5Module hibernate5Module(){
	return new Hibernate5Module();
}
  • gradle에 implentation 할 때 version을 따로 입력해주지 않으면 Gradle에서 자동으로 현재 버전과 잘 맞는 버전을 import해서 관리해준다.

아래와 같이 hibernate5Module설정을 통해 강제로 지연 로딩도 사용 가능하다.

@Bean
Hibernate5Module hibernate5Module(){
	Hibernate5Module hibernate5Module = new Hibernate5Module();
    //강제 지연 로딩 설정
	hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
	return hibernate5Module;
}
  • 이 옵션을 키면 order ▶ member, member ▶orders 양방향 연관관계 무한루프가 돌기 때문에 @JsonIgnore를 설정해줘야 한다.
※ 주의

스프링 부트 3.0 이상이면 Hibernate5Module 대신에 Hibernate5JakartaModule 을 사용해야한다
※ 주의 

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

하지만 사실 Lazy Loading 때문에 Hibernate 모듈을 직접 등록해서 사용하는 것은 애매한 부분이 있다.

그래서 만약 위 설정 없이 원하는 결과를 반환하고 싶다면 다음과 같이 할 수도 있다.

@GetMapping("/api/v1/simple-orders")
 public List<Order> ordersV1() {
     List<Order> all = orderRepository.findAllByString(new OrderSearch());
     for (Order order : all) {
         order.getMember().getName(); //Lazy 강제 초기화
         order.getDelivery().getAddress(); //Lazy 강제 초기환
     }
     return all;
 }

하지만 이렇게 만든 엔티티를 직접 노출한 API는 너무 정보가 많고 고정되어 있다. 만약 Client 해당 API를 사용하여 이미 구현을 해뒀으면 나중에 API 스팩에 따른 변경이 너~무 힘들다.

 

(중요!!) 앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다.

따라서 Hibernate5Module을 이용하는 방법은 알아만 두고 실무에서는 DTO로 변환해서 반환하는것이 더 좋은 방법이다.

※ 주의

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

항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해자!!
(V3에
서 설명)

 

 

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


package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;


/**
 * xToOne(ManyToOne, OneToOne
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RequiredArgsConstructor
@RestController
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2(){
        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 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); //LAZY 초기화
        }
    }
}
  • 엔티티를 DTO로 변환하는 일반적인 방법이다.
    • DTO가 엔티티를 파라미터로 직접 받는 것은 큰 문제가 되지 않는다. 왜냐하면 별로 중요하지 않은 곳에서 엔티티를 의존하는 것뿐이기 때문에 문제가 되지 않는다.

 

V1, V2의 공통된 문제점 ( N + 1 문제 )

  • 쿼리가 총 1 + N + N 번 실행된다.(v1과 쿼리수 결과는 같다.)
    • order 조회 1번(order 조회 결과 수가 N이 된다.)
    • order → member 지연 로딩 조회 N번
    • order → delivery 지연 로딩 조회 N번
    • Ex: order의 결과가 2개면 최악의 경우 1 + 2 + 2번 실행된다.(최악의 경우)
      • 왜 최악의 경우라도 따로 언급을 했냐면...
        → 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
        → 즉, 만약 영속성 컨텍스트에 같은 @Id를 갖는 엔티티가 있으면 쿼리가 생략된다는 것이다.

 

V2버전으로 API호출했을 때 실행되는 쿼리

  • 쿼리가 1 + N + N 번 실행된 것을 볼 수 있다.
    • 물론, 여기서 member가 동일할 경우 처음 조회 이후 다시 조회 하지 않기 때문에 1번의 쿼리수행이 더 줄어들 수 있지만, 흔한 케이스가 아니기에 고려하지 않는다.
  • Order 조회가 많아질수록 기하급수적으로 쿼리수행이 많아지고 성능저하가 일어날 수 있다

.

만약 로딩 전략은 EAGER로 한다면 해결될까? 쿼리는 어떻게 실행될까?? 쿼리가 최적화 될까?

  • Order와 Delivery 쿼리 외에도 아래와 같은 전혀 예상하지 못한 쿼리가 실행된다.

 

 

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


V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)

  •  fetch join으로 쿼리 1번 호출

 

참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)

 

12. 객체지향 쿼리 언어2 - 중급 문법

경로 표현식 .(점)을 찍어 객체 그래프를 탐색하는 것 select m.username -> 상태 필드 from Member m join m.team t -> 단일 값 연관 필드 join m.orders o -> 컬렉션 값 연관 필드 where t.name ='팀A' 경로 표현식 용어

s-y-130.tistory.com

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;


/**
 * xToOne(ManyToOne, OneToOne
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RequiredArgsConstructor
@RestController
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();

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

    }

    @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 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); //LAZY 초기화
        }
    }
}
...
/*orderRepository.findAllWithMemberDelivery*/
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();
}
  • 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
  • 페치 조인으로 order → member, order→ delivery 는 이미 조회 된 상태 이므로 지연로딩 X

 

V3버전으로 API호출했을 때 실행되는 쿼리

 

 

V3까지 최적화를 통해 N+1 쿼리를 1건 쿼리로 최적화를 진행해봤다. 하지만 여기서도 쿼리를 보시다시피 엔티티를 이용하여 쿼리가 실행되고 있다.

 

V4에서는 이를 최적화 하는 방법을 학습할 것이다.

 

간단한 주문 조회 V4: JPA에서 DTO로 바로 조회


Controller

/**
 * V4. JPA에서 DTO로 바로 조회
 * - 쿼리1번 호출
 * - select 절에서 원하는 데이터만 선택해서 조회 
 */
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
	return orderSimpleQueryRepository.findOrderDtos(); 
}
  • 방법에 따라서 원하는 값을 선택해서 조회하는 경우 엔티티를 조회하는 리포지토리(OrderRepository)가 아니라 따로 쿼리용 리포지토리를 만들면 구분이 쉽다.
  • 또한 실무에서는 통계용 쿼리, 통계용 API 등 복잡한 join 쿼리를 갖고 DTO를 바로 뽑는 경우도 많기 때문에 패키지를 따로 만들어 가는게 유지보수에도 좋다.

 

OrderSimpleQueryRepository 조회 전용 리포지토리

package jpabook.jpashop.repository.order.simplequery;

import jpabook.jpashop.repository.OrderSimpleQueryDto;
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 jpabook.jpashop.repository.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();
    }
}
  • jpa는 기본적으로 엔티티나 value Object만 반환할 수 있다.
  • 또한 new 연산자 방법은 엔티티를 바로 넘기는 것은 안된다. 엔티티를 바로 넘겨버리면 식별자만 넘어간다.
  • new 를 통해 객체를 생성해줄 때 full package path를 입력해줘야 한다.

OrderSimpleQueryDto

package jpabook.jpashop.repository.order.simplequery;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.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(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); //LAZY 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); //LAZY 초기화
    }
		/* OrderSimpleQueryRepository::findOrderDtos메서드에서 사용할 생성자(Constructor)*/
    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;
    }

}
  • 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL이 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → application 네트워크 용량 최적화(생각보단 미비하다)
  • 리포지토리 재사용성이 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

 

v3, v4 쿼리 비교

각각의 장단점이 있지만 우선 쿼리만 보면 확실히 원하는 것만 가져오는 DTO 방식이 더 짧은 것을 확인할 수 있다.

 

그러나 과연 성능 차이가 얼마나 날까?? 

  • 정확히는 성능 테스트를 해봐야 알겠지만 대부분의 경우는 두 방식의 성능차이는 거의 나지 않는다.
  • 우선 네트워크가 많이 발전했다.
  • 그리고 대부분의 성능은 join 문과 연관이 있다. 즉, select 절에서 필드가 몇개 추가된다고 해서 성능 차이는 그렇게 없다.
    • 또한 대부분 쿼리 성능은 인덱스를 잘못 걸었다든지 그런 부분이다.
  • 물론 데이터 사이즈가 너무 클 때는 얘기가 다르다. select 필드가 20~30개가 되면 api 호출 횟수에 따라 select 문 최적화도 고민은 해볼 수 있다.

 

정리

  • 엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘 중 상황에 따라서 더 나은 방법을 선택하면 된다.
  • 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.

 

 

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. => 대부분의 성능 이슈가 해결된다(95%이상 해결 가능)
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. => V4 방법
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다

 

 

 

 

 

 

 

 

'JPA/2편- 실전! 스프링 부트와 JPA 활용' 카테고리의 다른 글
  • OSIV와 성능 최적화
  • API 개발 고급 - 컬렉션 조회 최적화
  • API 개발 기본
s_y_130
s_y_130
  • s_y_130
    About SY
    s_y_130
  • 전체
    오늘
    어제
    • 분류 전체보기 (437) N
      • JAVA (54)
        • 더 자바 8 (0)
        • JAVA (41)
        • JAVA (JVM) (13)
      • Computer Science (86)
        • CS Basic (7)
        • OOP (11)
        • Design Pattern (16)
        • Network (8)
        • HTTP (6)
        • WEB (22)
        • OS (16)
      • DataBase (29)
        • DB theory (15)
        • MySQL (14)
        • Redis (0)
      • Collection Framework (1)
        • 구현 (1)
      • Data Structure (14)
        • Linear (9)
        • Non-Linear (5)
      • Algorithm (19)
        • Basic (12)
        • 응용 (2)
        • 완전 탐색(Brute Force) (1)
        • 다익스트라 (1)
        • Algorithm Problem (3)
      • Spring (104)
        • 스프링 핵심 원리 - 기본편 (9)
        • 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (7)
        • 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (11)
        • 스프링 DB 1편 - 데이터 접근 핵심 원리 (6)
        • 스프링 DB 2편 - 데이터 접근 활용 기술 (10)
        • 스프링 핵심 원리 - 고급편 (13)
        • 스프링 부트 - 핵심 원리와 활용 (9)
        • Spring Security 6.x (2)
        • Spring Batch (2)
        • Spring Cloud로 개발하는 MSA (1)
        • 재고시스템으로 알아보는 동시성이슈 해결방법 (4)
        • 개념 (27)
        • 테스트 (0)
        • Annotation (1)
        • Error Log (2)
      • TEST (0)
        • 부하 테스트 (0)
        • Practical Testing: 실용적인 테스트.. (0)
      • JPA (40)
        • 자바 ORM 표준 JPA 프로그래밍 (12)
        • 1편- 실전! 스프링 부트와 JPA 활용 (7)
        • 2편- 실전! 스프링 부트와 JPA 활용 (4)
        • 실전! 스프링 데이터 JPA (6)
        • 실전! Querydsl (6)
        • 개념 (5)
      • 백엔드 부트캠프[사전캠프] (35)
        • TIL (12)
        • 문제풀이 (23)
      • 백엔드 부트캠프 (5)
        • Calculator (3)
        • Kiosk (2)
      • Open Source (0)
      • Book Study (1)
        • Morden Java in Action (1)
        • Real MySQL 8.0 Vol.1 (0)
        • TDD : By Example (0)
      • AWS (0)
        • EC2 (0)
      • git (2)
      • AI (22)
        • Machine Learning (17)
        • Deep Learning (0)
        • TensorFlow (1)
        • PyTorch (1)
        • YOLO (1)
        • Data Analysis (0)
        • Ai code Error (1)
        • Numpy (1)
      • MY (0)
      • WEB (15)
        • Django (3)
        • WEB 개념 (1)
        • React (1)
        • Maven (10)
      • Python (6)
      • 기초수학 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
s_y_130
API 개발 고급 - 지연 로딩과 조회 성능 최적화
상단으로

티스토리툴바