서블릿 객체는 쓰레드에 의해 호출되어진다. 그리고 여러 요청을 동시에 처리하기 위한 멀티 쓰레드는 필수적이다.

 

멀티 쓰레드를 지원하는 두 가지 방식이 있다.

 

1. 요청마다 생성

말 그대로 요청이 들어올 때마다 해당 요청을 처리하기 위한  쓰레드를 새로 생성한다.

이 경우 요청이 늘어남에 따라 컨텍스트 스위칭 비용이 무한정으로 늘어날 수 있다는 단점이 있다.

 

2. 쓰레드 풀

WAS 내부에서 사용할 일정 개수의 쓰레드들을 쓰레드 풀에 미리 생성한다.

요청이 들어오면 이 쓰레드 풀에서 쓰레드를 할당받아 요청을 처리한다.

사용이 완료된 쓰레드는 다시 풀에 반납한다.

쓰레드 풀의 쓰레드가 모두 사용중일 경우에 새로 들어오는 요청은 거절하거나 특정 수만큼 대기하도록 할 수 있다.

 

쓰레드 풀 방식을 사용하기 위해 최대 쓰레드 수를 얼마로 설정하는지가 중요하다. (톰캣은 최대 200개의 쓰레드가 디폴트 값이다.)

적절한 CPU 사용률을 유지할 수 있도록 MAX thread를 적절하게 설정해줘야 한다.

 

 

 

 

 

'김영한님 스프링 강의 정리 > 스프링 MVC' 카테고리의 다른 글

Servlet  (0) 2021.08.15
Web Server vs WAS(Web Application Server)  (0) 2021.08.15

서블릿이 없다면?

 

 

서블릿 없이 서버 개발자가 모든 기능을 전부 코딩해야 한다면 다음과 같은 로직들을 전부 구현해야 한다.

1. TCP/IP 대기, 소켓 연결

2. HTTP Request 메세지 받아서 분석

3. 비즈니스 로직 실행

4. DB 처리

5. HTTP Response 메세지 생성

6. TCP/IP를 통해 Response 전달

 

서블릿은 이러한 작업을 대신 처리하여 개발자의 업무 부담을 덜어준다 (3, 4는 직접 해야한다.)

 

서블릿 기능을 지원하는 WAS를 '서블릿 컨테이너'라고 칭한다.

서블릿 컨테이너는 서블릿 객체의 생명주기를 관리해주며 서블릿 객체는 싱글톤 패턴으로 관리된다.

서블릿 컨테이너는 동시 요청을 위한 멀티 쓰레드 처리를 지원한다.

 

WAS와 서블릿의 동작 방식

1. WAS가 HTTP Request를 받는다.

2. 요청 정보를 토대로 HttpServletRequest, HttpServletResponse 객체를 생성하여 서블릿 객체를 호출한다.

3. 서블릿은 비즈니르 로직 수행 후 HttpServletResponse를 적절하게 수정하여 반환한다.

4. WAS는 반환받은 정보를 토대로 HTTP Response를 반환한다.

 

 

 

Web Server

1. HTTP 기반으로 동작

2. 단순 '정적 리소스'를 제공

(ex : apache, nginx)

 

WAS

1. HTTP 기반으로 동작

2. Web Server 기능을 포함 (정적 리소스 제공)

3. 프로그램 코드를 실행하여 애플리케이션 로직 수행 (동적 처리 가능. Servlet, JSP, 스프링 MVC 등)

(ex : tomcat, jetty, undertow)

 

 

Web Server가 동적 기능까지 포함하는 경우도 있기 때문에 둘의 경계는 매우 모호하다.

 

그래도 굳이 정리하자면 

웹서버는 정적 리소스 담당, WAS는 정적 리소스 + 동적 기능 담당 정도로 생각할 수 있다.

 

WAS + DB만으로 시스템을 구성할 수는 있지만 이 경우 WAS의 역할이 너무 많아지고 서버 과부하의 위험이 있다.

정적 리소스 제공 역할을 수행하느라 정작 중요한 애플리케이션 로직의 수행에 어려움을 겪을 수 있다.

또한, WAS가 모든 역할을 맡게 되면 WAS가 죽었을 때 오류 화면을 노출해주는 것도 할 수 없게 된다.

 

이러한 문제들을 해결하기 위해 일반적으로 정적 리소스 처리만을 위한 웹서버를 따로 두고 WAS는 동적 기능에만 집중하는 식의 구성을 취한다.

 

이렇게 역할을 구분해놓으면 효율적인 리소스 관리가 가능하단 장점도 있다.

(정적 리소스 사용이 많으면 웹서버 증설, 애플리케이션 리소스 사용이 많으면 WAS 증설)

 

만약 화면을 띄워줄 필요 없이 API 통신으로 데이터만 넘겨주면 되는 경우라면 WAS만으로 구성하여도 문제될 것이 없다.

 

 

 

 

X to One 방식과 마찬가지로 fetch join을 사용하여 LAZY 로딩 없이 하나의 쿼리로 전부 가져올 수 있다.

 

 

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();

        // order를 dto로 변환하여 반환
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .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;
        // DTO 안에 엔티티가 있는 것도 잘못된 설계
        // 이조차도 이런식으로 전부 DTO로 바꿔줘야 함

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
    }

    @Data
    static class OrderItemDto {

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

        public OrderItemDto(OrderItem orderItem) {
            itemName = orderItem.getItem().getName();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }
    
    .
    .
    .
    
    // OrderRepository 내부
    public List<Order> findAllWithItem() {
        return  em.createQuery(
                // JPQL distinct 기능 두 가지
                // 1. DB에 날리는 쿼리 수준에서 distinct 수행
                // 2. 그래도 걸러지지 않은 것들에 대해서는 o의 참조값을 확인하여 중복 제거
                "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();
    }
    
    
    

 

fetch join을 사용하지 않으면 order, orderItem, item 각각의 개수마다 쿼리가 1개씩 나가게 된다.

 

order를 조회하는 JPQL에서 연관된 모든 엔티티에 fetch join을 걸어주면 한 번의 쿼리만으로 모든 엔티티를 동시에 가져올 수 있다.

 

이때 distinct의 역할이 중요하다.

 

1:N 관계에서 join 쿼리를 수행할경우 row의 개수는 N쪽(여기선 orderItems)으로 맞춰진다. 즉, 1쪽(여기선 order)의 데이터가 중복해서 들어가게 된다.

다시말해서 똑같은 o가 여러 개가 나오는 것.

 

이러한 중복을 막기 위해 distinct를 사욯안다.

 

distinct는 두 가지 기능을 수행한다.

1. 일반적인 쿼리문의 distinct과 똑같은 기능

-> 하지만 위의 경우에서는 order만 같을 뿐 item 등의 정보는 모두 다르기 때문에 distinct에서 걸러지지 않는다 ( distinct는 row의 모든 column이 같을 때만 중복으로 판단 )

 

2. 똑같은 참조값을 가진 객체 중복 제거

-> 여기서 o의 중복이 제거된다.

 

 

 

 

이러한 방식은 성능을 줄여주는 데 큰 도움이 되지만 페이징 기능을 사용할 수 없다는 단점이 있다.

(원인에 대해서는 교재or강의를 참고하자 V3.1 강의의 16:30 ~ 23:30)

 

 

결론 : X To One 관계는 페치 조인을 해도 페이징에 아무런 영향이 없다. 따라서 X To One 관계는 페치 조인으로 한 번에 가져와서 쿼리 수를 줄이고, X To Many 관계는 hibernate: default_batch_fetch_size로 해결하자.

 

 

 

 

 

 

단순 쿼리를 직접 작성할 필요 없이 대신 작성해주는 것은 JPA의 큰 장점이다.

 

하지만 역설적이게도 이러한 장점이 성능 문제를 초래할 수 있다.

테이블을 이것저것 마구잡이로 조회해서 불필요하게 많은 쿼리가 나가는 것이다.

 

이러한 문제를 방지하고 최적의 성능을 이끌어내려면 여러가지 사항들을 신경쓰며 개발해야 한다.

아래는 이에 관하여 알아두면 좋은 내용들을 정리한 것이다.

 

 

 

 

 

(하이버네이트 모듈)

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

@Bean
Hibernate5Module hibernate5Module() {
	return new Hibernate5Module();
}

모든 연관관계에 대하여 LAZY 전략을 사용하는 것이 좋다.

LAZY 로딩을 사용한 객체는 직접적으로 사용하기 전까지는 프록시 객체를 담고 있게 된다.

 

이렇게 프록시 객체를 담고 있는 엔티티를 JSON으로 반환할 경우 제대로 된 값이 들어있지 않아 문제가 발생한다.

 

위 코드처럼 하이버네이트 모듈을 빈으로 등록해  놓는다면 이런 프록시 객체 엔티티에 대해서 모두 null값을 전달하도록 하는 기능을 기본 기능으로서 사용할 수 있다. (LAZY로딩된 엔티티들에 대한 로딩을 강제하는 옵션도 있음)

 

 

(@JsonIgnore)

양방향 연관관계를 가진 엔티티들에 대해 조회가 이뤄질 경우 무한루프를 돌면서 계속 서로를 조회하는 문제가 발생할 수 있다.

이를 방지하기 위해 양방향 연관관계 중 한 곳의 관계를 @JsonIgnore로 끊어서 JSON 전달을 막을 수 있다.

 

 

 

사실 위의 두 방법 (하이버네이트 모듈, @JsonIgnore)는 실무에서는 별로 사용될 일이 없다. 어차피 실무에서는 엔티티를 직접 전달하지 않고 항상 DTO를 통해서만 API 통신이 이뤄지기 때문이다.

 

 

 

 

(DTO를 통한 전달)

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByCriteria(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();  // Member LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();  // Delivery LAZY 초기화
        }
    }

이렇게 inner class로 따로 만들어준 DTO 객체를 반환하면 내가 원하는 정보들만 담을 수 있으므로 위와 같은 상황을  고려하지 않아도 된다.

 

내가 필요한  엔티티에  Member, Delivery에 대해서는 getter를 사용할 때 쿼리가 날아가면서 프록시 객체가 아닌 진짜 객체가 들어간다.

 

하지만 이러한 방식은 order에 대한 쿼리 1번 + member, delivery에 대한 쿼리 각각 1번.  총 3번의 쿼리를 호출하게 되어 성능 저하를 초래할 수 있다.

 

 

 

 

(fetch join을 사용한 성능 최적화)

    // fetch 조인을 사용하여 발생하는 쿼리 수를 대폭 줄임
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> orderV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
    
    .
    .
    .
    
    // OrderRepository 내부
    public List<Order> findAllWithMemberDelivery() {
        // order에 대한 한 번의 쿼리에서 member, delivery 를 한 번에 가져옴
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d", Order.class
        ).getResultList();
    }
    

레포지토리 내부에 페치 조인으로 order들을 조회하는 함수를 생성해 이를 사용한다.

 

fetch join을 사용해 JPQL을 작성하면 order에 대한 member, delivery를 별도의 쿼리로 조회하는 것이 아니라 order를 조회할 때 컬럼만 추가하여 함께 조회한다.

 

즉, order에 대한 쿼리 하나만 나가는 것이다.

 

 

 

 

 

(딱히 JPA에 관련된 내용은 아니지만 JPA 강의에서 배운 내용이라 여기에 적는다)

 

 

@RequestBody, @ResponseBody를 통해 편리하게 API 통신을 할 수 있다.

 

이때, 엔티티 객체를 직접적으로 받고, 반환해줘도 단순히 기능을 구현하는 데는 별 문제가 없다. 

하지만 이러한 방식은 좋지 않다. "반드시" 엔티티를 그대로 사용하지 말고 DTO를 통해 엔티티의 정보를 전달해야한다.

 

DTO를 반드시 사용해야 하는 이유는 다음과 같다.

 

 

1. DTO를 사용하지 않을 경우 엔티티의 변경에 의해 API 스펙이 변경될 수 있다.

 

엔티티와 API가 일대일 대응의 관계를 가진다면 엔티티에 수정이 일어날 때마다 API 스펙을 일일히 변경해줘야한다.

이는 매우 번거로운 작업이며 컴파일 에러로 이를 감지할 수 없기 때문에 에러 원인을 찾기가 어렵다.

(DTO를 사용하면 DTO -> 엔티티 과정에서 컴파일 에러가 발생되므로 엔티티의 변경사항을 반드시 파악할 수 있다)

 

 

2. 하나의 엔티티에 대해서 API는 여러 개가 존재할 수 있다.

 

각각의 API가 요구하는 엔티티에 대한 데이터는 모두 다를 확률이 높다.

이때, 그냥 엔티티의 모든 정보를 넘겨줘버린다면 필요없는 데이터까지 받긴 하지만 필요한 건 전부 받은 셈이니 기능 동작에는 문제가 없을 것이다.

 

하지만 PW처럼 보안상 감추어야 할 정보까지 모두 JSON으로 함께 넘어가기 때문에 보안 문제가 발생할 수 있다.

엔티티 측에서 컬럼에 @JsonIgnore를 사용해 JSON 전달을 막을 수는 있지만 이는 엔티티가 API 스펙에 의존성을 갖게 되므로 좋지 않다.

 

유지보수가 복잡해질 뿐 아니라 다른 API에 대해서는 그떄그떄 또 변경을 해줘야 하는 쓸 데 없는 번거로움이 발생한다.

 

 

3. 엔티티를 그대로 넘겨줄 경우, 엔티티가 가진 정보 외의 것들은 넘겨주지 못한다.

DTO를 사용하면 엔티티의 정보 외에 추가적으로 필요한 정보도 함께 넘겨줄 수 있다.

 

 

 

 

 

 

 

 

 

JPA에서는 update, delete 등의 변경 사항에 대해 따로 쿼리를 작성하지 않아도 JPA가 알아서 DB에 쿼리를 날려주어 해당 내용을 반영시켜준다.

 

하지만 이같은 기능에만 기댔다가는 성능이 매우 떨어지는 상황이 발생할 수도 있다.

만약 특정 조건을 만족하는 모든 행에 대하여 update연산을 처리하고 싶다면?

 

where조건을 넣은 JPQL로 List<Entity>를 가져올 것이고.. 반복문을 돌려가며 이들 하나하나에 전부 변경 사항을 적용하는 코드를 짜야 한다.

 

이럴 경우 JPA가 update에 대한 쿼리를 대신 DB로 보내주는 것은 맞지만 각 row마다 쿼리를 한 번 씩 개별적으로 수행하기 떄문에 대상 List의 크기에 따라 엄청나게 많은 쿼리가 발생할 수도 있게 된다.

 

이러한 불상사를 막기 위해 JPQL로 update, delete 쿼리를 직접 작성하여 DB에 바로 보내버리는 벌크 연산이 사용된다.

(벌크 연산은 update, delete만 지원한다)

 

int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();

(createQuery는 쿼리가 적용된 row의 수를 반환한다)

 

 

주의할 점은, 벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 쿼리를 바로 적용시킨다는 것이다.

이러한 특성 때문에 데이터의 일관성이 깨지는 일이 발생할 수 있다.

(ex : 영속성 컨텍스트에는 age=10으로 되어있음 -> 벌크연산을 통해 age=20으로 수정 -> 이미 영속성 컨텍스트에 존재하는 엔티티 객체가 있기 때문에 age=10인 객체를 그대로 사용하는 문제 발생)

 

이러한 문제를 방지하기 위해 아래와 같은 두 가지 해결책이 존재한다.

 

1. 애플리케이션을 수행하기 전, 아직 영속성 컨텍스트가 비어있을 때, 벌크 연산을 가장 먼저 실행

-> 영속성 컨텍스트엔 아무것도 없기 때문에 이후에 엔티티 객체 필요로 할 시 벌크 연산이 적용된 DB에서 해당 객체 조회하여 사용할 것.

 

2. 벌크 연산 수행 후 영속성 컨텍스트 초기화

-> 근본적으로는 1과 똑같은 방식. 벌크 연산 수행 후 em.clear()로 영속성 컨텍스트 초기화. 이 후에 DB에서 새롭게 객체 조회하여 사용.

(단, em.clear()로 인해 준영속 상태가 된 이전에 사용하던 객체들은 더이상 사용하면 안 됨)

 

 

 

 

수정 예정.

 

지연 로딩 옵션보다 fetch join이 우선시 됨

+ Recent posts