LostCatBox

(핵심요약)스프링 부트와 JPA 활용2

Word count: 2.7kReading time: 16 min
2023/07/11 Share

왜?

JPA 실전편 2편에서 계속 업그레이드하자..

JPA 지식이 부족하다

회원 등록 API

  • Entity와 DTO를 나누자..당연 (의존성 없애야함. 확장성도 필요)
    • 당연히 Entity를 파라미터로 받아도안된다.

회원 수정 API

  • controller 단
1
2
3
4
5
6
7
8
9
10
@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse saveMemberV2(
@PathVariable("id") Long id,
@RequestBody UpdateMemberRequest request){
//update 쿼리랑, 조회랑 분리한다. 이유는 안정성때문, update 시 return은 id 값 정도는 줄수있지만 엔티티를 줘버리면 또 다른 변경점이가능하다.
// 따라서 이를 다시 그냥 쿼리와 조회를 분리하기위해서 따로 다시 findOne() 메서드를 구현해서 호출하였다.
memberService.update(id, request.getName());
Member one = memberService.findOne(id);
return new UpdateMemberResponse(one.getId(),one.getName());
}
  • memberService 단
1
2
3
4
5
6
7
8
9
@Transactional
public void update(Long id, String name) {
Member one = memberRepository.findOne(id);
one.setName(name);
}

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

쇼핑몰 도메인 모델과 테이블 설계

![스크린샷 2023-11-28 오전 12.02.38](/Users/lostcatbox/Library/Application Support/typora-user-images/스크린샷 2023-11-28 오전 12.02.38.png)

스크린샷 2023-11-28 오전 12.02.38

  • @JsonIgnore 옵션을 한곳에 주어야 한다.
  • 주의: 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다. -> 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3에서 설명)

사전 DB 초기화

  • 참고로, 생성자로 생성후 각 빈에 등록된 서비스들의 메서드를 호출해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@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() {
System.out.println("Init1" + this.getClass());
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 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 Book createBook(String name, int price, int stockQuantity) {
Book book1 = new Book();
book1.setName(name);
book1.setPrice(price);
book1.setStockQuantity(stockQuantity);
return book1;
}

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

ManyToOne 조회

V1

  • entity 직접반환
    • 장점: 간단.
    • 단점: 직접 반환 매우위험, 유지보수 안좋음.

V2

  • DTO로 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}

N+1 문제발생

엔티티를 DTO로 변환하는 일반적인 방법이다.
쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)

order 조회 1번(order 조회 결과 수가 N이 된다.) order -> member 지연 로딩 조회 N 번

order -> delivery 지연 로딩 조회 N 번
예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)

지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

V3

  • fetchJoin()으로 N+1문제 해결 한방쿼리
  • 단점: join을 한번밖에못함.

V4

  • JPA -> entity -> DTO 가 아닌
  • JPA -> DTO 로 바로 가져오기
  • 장점: SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)
  • 단점: 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가
1
2
3
4
5
6
7
8
9
10
11
12
 @Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.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();
} }

쿼리 방식 선택 권장 순서

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

OneToMany, OneToOne 최적화

  • 주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자
  • Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다.
    앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었다. 이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보자.

V1

  • entity 직접 노출

V2

  • entity -> DTO 로 변경

    • 당연하게도 DTO안에 속성도 모두 DTO로 바꿔야함
  • 역시 페치 조인이 아니라서 1+N 이슈있음

V3

  • fetchJoin 사용
  • 1:N fetchJoin == 컬렉션 페치 조인
  • 단점: 데이터 뻥튀기 1:N 기준이면 N개의 레코드 조회됨
    • distinct 사용하면 중복데이터 제거해줌 -> jpa 영속성 기준으로도 중복 제거해줌
  • 단점2: 페이징 불가능
    • 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이 터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다). 자세한 내용은 자바 ORM 표준 JPA 프로그래밍의 페치 조인 부분을 참고하자.(outOfMemony)
    • 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다. 자세한 내용은 자바 ORM 표준 JPA 프로그래밍을 참고하자.
1
2
3
4
5
6
7
8
9
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();
}

V3.1

한계 돌파 (페이징 문제 해결)

1
2
3
4
5
6
7
8
9
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order. class)
.setFirstResult(offset)
.setMaxResults (limit)
.getResultList();
}
1
2
3
4
5
6
7
8
9
10
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
  • 1:N 컬렉션 fetchJoin을 할때는 무조건 데이터가 뻥튀기 되기 떄문에 결국, ToOne 구조들만 fetchJoin을 쓰고 OneToMany는 lazy loading을 해야한다

  • lazy loading을 한다면 1+N 이슈가 존재하므로 다음 batchSize 설정으로 In쿼리를 통해 한방에 쿼리를 내보낼수있다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
  • 장점

    • 쿼리호출수가1+N, 1+1로최적화된다.
    • 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
    • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
    • 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
  • 결론

    • ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
      따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고,
      나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.
    • 항상 트레이드 오프를 생각하자.
      • fetchJoin : 1+N+M+O -> 1개의 쿼리 생성
      • lazyLoading + batchSize (In 쿼리 사용) : 1+N+M+O -> 1+1+1+1 쿼리 생성

참고: default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택 하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으 로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.

V4

  • 아래와 같이 ToOne을 먼저조회, 그후 OneToMany를 나중에 조회하는것은 결국 1+N 문제를 다시 야기함.
    (batchSize로 문제 해결 가능)
  • OrderItemQueryDto : 응용불가능, 쿼리전용 dto
    • 한번에 List<OrderItemQueryDto> 를 가져오자 결국은 1+N 문제를 다시 야기함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class OrderItemQueryDto {

@JsonIgnore
private Long orderId; //주문번호
private String itemName;//상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량

public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {

private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;

public OrderQueryDto(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;
}

public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.orderItems = orderItems;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 컬렉션은 별도로 조회
* Query: 루트 1번, 컬렉션 N 번
* 단건 조회에서 많이 사용하는 방식
*/
public List<OrderQueryDto> findOrderQueryDtos() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();

//루프를 돌면서 컬렉션 추가(추가 쿼리 실행)(1ToN이니까. 분리)(하지만 결국 1+N의 쿼리가나나간다.)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
1
2
3
4
5
6
7
8
9
10
11
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 1:N 관계인 orderItems 조회
*/
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = : orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}

V5

  • 1+N 쿼리를 -> 1+1 쿼리로 구조적변경
  • 메모리를 사용하고, in쿼리를 사용해서 orderItem + item 정보를 모두가져온다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 최적화
* Query: 루트 1번, 컬렉션 1번
* 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
*
*/
public List<OrderQueryDto> findAllByDto_optimization() {

//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();

//orderItem 컬렉션을 MAP 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

return result;
}
1
2
3
4
5
6
7
8
9
10
11
12

/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
1
2
3
4
5
6
7
8
9
10
11
12
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();

return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}

V6

  • 1+1 쿼리말고, 1번의 쿼리로 변경해보자

  • OrderFlatDto : 형태로 바로 쿼리로 가져올꺼임

    • 해당방법은 결국 쿼리후에는 데이터 뻥튀기가 되어있으므로 쿼리후 dto를 다시 정재해줘야함
  • 장점:

    • Query 1번
  • 단점:

    • 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되 므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
    • 애플리케이션에서 추가 작업이 크다.
    • 페이징 불가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

@Data
public class OrderFlatDto {

private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private Address address;
private OrderStatus orderStatus;

private String itemName;//상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량

public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}

}
1
2
3
4
5
6
7
8
9
10
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto.class)
.getResultList();
}
1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
.collect(toList());
}

정리

최적화 순서

  1. 엔티티조회 방식으로 우선접근
    1. 페치조인으로 쿼리 수를 최적화
    2. 컬렉션 최적화
      1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
      2. 페이징 필요X 페치 조인 사용
  2. 엔티티조회 방식으로 해결이 안되면 DTO조회 방식 사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

Version별 각 장단점

  1. DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
  2. V4는코드가단순하다. 특정주문한건만조회하면이방식을사용해도성능이잘나온다.예를들어서조회 한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
  3. (실무 가장 선호) V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사 용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식 으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성 능 차이가 날 수 있다.
  4. V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페 이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.

API 개발 고급 - 실무 필수 최적화

OSIV와 성능 최적화

  • Open Session In View: 하이버네이트
  • Open EntityManager In View: JPA
    • 이를 OSIV라고함

OSIV ON(유지보수성 높음, 성능낮음, 실시간성X)

![image-20231218220257075](/Users/lostcatbox/Library/Application Support/typora-user-images/image-20231218220257075.png)

  • DB connection을 언제 돌려주냐가 중요
    • jpa는 spring.jpa.open-in-view: true 기본값
    • jpa에서는 lazy loading등등을 제공하므로, 세션이 끝날때까지 (받은 요청에 대한 응답이 종료될때까지) DB connection을 물고있다. 당연히 Service계층의 @Transaction이 끝나도 물고있다.
    • OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.
    • 하지만 이 전략은 너무 오랜시간동안 DB connection 리소스를 사용하기때문에, connection이 모자랄수있고, 장애로 이어진다.

OSIV OFF(유지보수성 낮음, 성능높음, 실시간성O)

스크린샷 2023-12-19 오전 1.33.23

  • DB connection을 @Transactional(트랜잭션) 종료까지만 가지고있음.
    커넥션 리소스 낭비 없음. 종료 후 lazy loading은 지원안됨.
  • DB connection을 connection pool 에 반환
  • 따라서 해당 설정으로는 lazy loading은 영속성 컨텍스트 생존 범위에서 써야하고, fetchJoin을 적극 활용해야한다.
  • 단점: service계층안으로 lazy loading한것들을 처리해야함..

커멘드와 쿼리 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.

  • 보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다.
  • 그런데 복잡한 화면을 출력하기 위한 쿼리(조회쿼리)는 화면에 맞추어 성능을 최적화 하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.

크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미 있다.

단순하게 설명해서 다음처럼 분리하는 것이다.

1
2
3
OrderService
- OrderService: 핵심 비즈니스 로직
- OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)
  • 보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수있다.

    참고: 필자는 고객 서비스의 실시간 API는 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켠다.
    참고: OSIV에 관해 더 깊이 알고 싶으면 자바 ORM 표준 JPA 프로그래밍 13장 웹 애플리케이션과 영속성관리를 참고하자.

CATALOG
  1. 1. 왜?
  2. 2. 회원 등록 API
  3. 3. 회원 수정 API
  4. 4. 쇼핑몰 도메인 모델과 테이블 설계
  5. 5. 사전 DB 초기화
  6. 6. ManyToOne 조회
    1. 6.1. V1
    2. 6.2. V2
      1. 6.2.1. N+1 문제발생
    3. 6.3. V3
    4. 6.4. V4
    5. 6.5. 쿼리 방식 선택 권장 순서
  7. 7. OneToMany, OneToOne 최적화
    1. 7.1. V1
    2. 7.2. V2
    3. 7.3. V3
    4. 7.4. V3.1
    5. 7.5. V4
    6. 7.6. V5
    7. 7.7. V6
  8. 8. 정리
    1. 8.1. 최적화 순서
    2. 8.2. Version별 각 장단점
  9. 9. API 개발 고급 - 실무 필수 최적화
    1. 9.1. OSIV와 성능 최적화
    2. 9.2. OSIV ON(유지보수성 높음, 성능낮음, 실시간성X)
    3. 9.3. OSIV OFF(유지보수성 낮음, 성능높음, 실시간성O)
    4. 9.4. 커멘드와 쿼리 분리