본문 바로가기

Backend

JPA (2) - 웹 계층 개발

웹 계층 개발

Validation

  • Form객체나 DTO 객체에 @NotEmpty 등등 Validation 어노테이션을 적용할 수 있다.

  • Controller Parameter 앞에는 @Valid를 표기하여 Validation을 사용할 수 있다.

  • @Valid와 BindingResult를 함께 쓰면 화면으로 Validation 정보를 보낼 수 있다.

Entity와 DTO

  • SSR 템플릿 엔진의 경우 컨트롤러에서 Entity를 반환해도 딱히 문제가 없다.
  • API를 만들 때는 절대로 컨트롤러에서 Entity를 반환하면 안된다.
    • 노출하고싶지 않은 정보를 노출하게 될 수도 있고
    • Entity를 변경할 때 API스펙도 함께 바뀌어버리기 때문

기타

  • IntelliJ에서 Ctrl+E를 누르면 최근 파일들을 조회할 수 있음.
  • IntelliJ에서 Ctrl+Alt+Shift+T를 누르면 Refactoring 옵션이 뜬다.
  • IntelliJ에서 Ctrl+Alt+N을 누르면 Inline Variable로 바뀐다.
  • Thymeleaf에서 for문은 th:each="member : ${members}"로 사용할 수 있다.
  • Thymeleaf에서 th:text="${member.address?.city}"와 같이 ?를 사용하여 NVL 효과를 볼 수 있다.

변경 감지와 병합(Merge)

결론은 merge는 의도치 않은 변경을 일으킬 수 있기 때문에 실무에서 절대로 쓰지 않는다.

또한 컨트롤러단에서 어설프게 Entity를 만드는것도 유지보수를 어렵게 하는 안티패턴이다. Entity와 Setter를 사용하기보다는 파라미터나 DTO를 활용하여 묶어서 업데이트 하는 것이 좋다.

준영속 엔티티

  • 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.
  • 예를 들어 수정화면에서 넘어온 객체는 이미 데이터베이스에 저장되어 식별자를 가지고 있으므로 준영속 엔티티로 볼 수 있다.
  • 준영속 엔티티를 수정하려면 "변경 감지(Dirty Checking)"를 사용하거나, "병합(Merge)"을 사용해야 한다.
    • 변경 감지(Dirty Checking): 식별자로 영속화된 엔티티를 불러와서 변경
    • 병합(Merge): 영속성 컨텍스트에서 식별자로 엔티티를 찾아서 값을 전부 덮어 씀

API 개발 기본

@Controller + @ResponseBody = @RestController

  1. Entity를 컨트롤러 파라미터로 사용해서는 절대 안된다. 엔티티가 바뀌었을 때 API 스펙에 영향을 주기 때문에 API의 파라미터로는 별도의 DTO를 생성하는것이 좋다.
  2. PUT은 멱등하다. 예를들어 NAME을 "ABC"로 10번 바꾼 결과와 1번 바꾼 결과가 동일하다. 등록과 수정은 API 스펙이 다르다. 따라서 DTO도 별도로 만들어준다.
  3. 커맨드와 쿼리를 분리하자. 업데이트를 하고 나서 반환값으로 Entity를 반환하는것은 지양하고 필요 시 ID값만 리턴한다.
  4. 목록 조회 기능에서 리스트를 그대로 반환 시 JSON이 Array로 생성되고, 이로 인해 API 확장이 불가능해진다. 따라서 목록 조회 시에도 Entity를 그대로 반환하지 않는것은 물론이거니와, 아래 클래스와 같은 데이터를 필드로 가지는 객체형태로 반환해야 한다.
@Data
@AllArgsConstructor
static class Result<T> {
    private T data;
}

API 개발 고급

  • 지연 로딩과 조회 성능 최적화
  • 컬렉션 조회 최적화
  • 페이징과 한계 돌파
  • OSIV(OpenSessionInView)의 성능 최적화

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

컨트롤러에서 엔티티를 응답할 경우 양방향 연관관계에서는 반드시 둘 중 하나에 @JsonIgnore 어노테이션을 붙여서 연결을 끊어줘야 한다. 그렇지 않으면 무한 루프를 돌게 된다. 무한 루프를 해결해도 Proxy 관련 클래스(ByteBuddyInterceptor)의 Type Definition 관련 오류가 발생한다.

결국 Hibernate5 모듈 빈 정의를 작성하여 사용해야 하는데 이 모든것을 하는것보다는 Entity를 DTO로 변환하여 작성하는것이 가장 좋은 방법이다.

DTO를 반환해도 N+1 문제가 발생하게 되는데, 그렇다고 fetch 옵션을 EAGER로 바꿔서는 안되고, 모두 LAZY로 유지한 채로 JPQL로 페치조인을 사용하여 쿼리를 개선한다.

쿼리 성능 별 구현방식: V1~V4

// ... 무한루프 발생
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
    List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
    return all;
}

// ... N+1 문제 발생
@GetMapping("/api/v2/simple-orders")
public Result orderV2() {
    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
    List<OrderSimpleQueryDto> collect = orders.stream()
        .map(OrderSimpleQueryDto::new)
        .collect(Collectors.toList());

    return new Result(collect);
}

// 가장 권장되는 방식! 필요 시 fetch join을 사용하여 성능을 최적화 한다.
@GetMapping("/api/v3/simple-orders")
public Result orderV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    List<OrderSimpleQueryDto> collect = orders.stream()
        .map(OrderSimpleQueryDto::new)
        .collect(Collectors.toList());
    return new Result(collect);
}

@GetMapping("/api/v4/simple-orders")
public Result orderV4() {
    // 성능면에서는 좋지만 Repository에서 DTO로 직접 받는 것은 재사용성이 조금 떨어진다
    // 필요할 경우 아래와 같이 Repository를 분리하여 활용한다
    return new Result(orderSimpleQueryRepository.findOrderDtos());
}

페이징과 한계 돌파

application.yml에 default_batch_fetch_size 속성은 LAZY 로딩에서 IN절을 사용하도록 지정하는것이다. 해당 속성이 100이면, IN절 안에 최대 100개의 값을 넣어 쿼리를 반복수행한다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

개별 최적화를 원할 경우 엔티티의 컬렉션 속성 값 @BatchSize 어노테이션을 달아 주면 된다. 컬렉션이 아닌 XToOne 관계는 엔티티 클래스 자체에 지정한다.

이렇게 함으로써 아래와 같은 장점이 생긴다.

  • 이 조건으로 쿼리 호출 수가 [1 + N] :arrow_right: [1 + 1]로 줄어든다.
  • 중복 데이터가 없으므로 DB 데이터 전송량이 최적화 된다.
  • 페이징이 가능해진다.

이렇듯 하이버네이트가 자동으로 처리 해주지만 XToOne 관계는 페이징에 영향을 주지 않기 때문에 fetch join을 적용해주는게 좋다.

데이터베이스 중 IN절의 제한이 있는 경우가 있기 때문에 애플리케이션과 DB의 순간 부하도 고려하여 값은 100~1000 사이로 선택하자. 참고로 JVM 메모리 사용량은 fetch 사이즈와 무관하다.

Repository단에서 DTO를 전달받는 방법도 있다. 이 방법은 필요한 컬럼만 SELECT하기 때문에 전송 데이터 양이 확연히 줄어든다는 장점이 있지만, 그 만큼 코드가 길어진다는 단점이 있다. 따라서 꼭 필요한 경우가 아니라면 fetch join + fetch batch를 사용한 방법이 권장된다.

데이터를 플랫화 시켜서 쿼리 단 한 번만으로 가져오는 방법도 있다. 이 방법 역시 추가작업이 필요하여 코드가 길어지며, 페이징 또한 불가능해진다.

권장 설계 순서

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

Open Session In View(OSIV)

기본적으로 OSIV는 활성화 되어있으며, spring.jpa.open-in-view: false 옵션으로 비활성화 할 수 있다. 참고로 여기서 Session은 하이버네이트의 Session(= JPA의 EntityManager)을 의미한다.

OSIV가 활성화 되어 있을 경우 영속성 컨텍스트의 생존 범위가 넓어 View Template에서 까지도 지연로딩을 사용할 수 있다는 장점이 있다. 하지만 커넥션이 너무 오래 살아있어 커넥션 풀 고갈(=장애)로 이어질 수 있다.

OSIV를 비활성화 하게 되면 영속성 컨텍스트의 생존 범위는 트랜잭션 안으로 제한되며 따라서 모든 지연 로딩을 트랜잭션 안으로 넣어야 된다.

해결 방법

Command와 Query를 서비스 레벨에서 분리한다.

OrderService

  • OrderService: 핵심 비즈니스 로직
  • OrderQueryService: 화면이나 API에 맞춘 서비스(주로 읽기 전용 트랜잭션 사용)

'Backend' 카테고리의 다른 글

Legacy Application을 Executable Jar로 만들자  (0) 2021.08.06
Effective Java (1) - 아이템1, 2  (0) 2021.05.06
JPA (1) - 도메인 개발  (0) 2021.04.11
Spring Cloud 일단 기록하기  (0) 2021.03.01
Spring Core (4) AOP  (0) 2020.07.03