본문 바로가기
부트캠프 개발일기/Main-Project

108일차: Main-Project Day 15 (주문 정보 조회, FetchType, LazyInitializationException)

by shyun00 2023. 7. 18.

마이페이지 주문 내역은 조회만 하면 되는 부분이라 비교적 간단할거라고 생각했는데 어김없이 에러를 만났다.

 

현재 우리팀의 ERD 구조는 다음과 같다.

Member-Order는 1:N 관계로 구성되어있다.

Order-Product는 N:N 관계로 중간 테이블인 order_product 테이블이 연결해주고있다.

 

이렇게 테이블간의 연관관계가 있을 경우 특정 데이터를 조회할때마다 연관관계가 있는 다른 테이블까지 같이 조회하게되면 불필요한 과정을 거치고 효율이 떨어질 수 있다.

ex. 회원 정보에서 닉네임만 조회하면 되는데 Orders, Cart까지 모두 조회하는 경우

 

이때 적용 가능한 것이 FetchType이다. 크게 두가지로 구분된다.

 

1. FetchType.EAGER

즉시 로딩을 의미한다. 연관된 엔티티를 즉시 데이터베이스에서 로딩한다. 관련된 데이터가 많을 경우 성능 저하의 원인이 될 수 있다.

연관 엔티티를 사용할 가능성이 높고 로딩되는 데이터 양이 적을때 유용하다.

 

2. FetchType.LAZY

지연 로딩을 의미한다. 연관된 엔티티를 실제로 사용하는 시점에 데이터베이스에서 로딩한다. 연관 엔티티를 사용하기 전까지는 데이터베이스 쿼리가 발생하지 않는다. 연관 엔티티를 사용할 가능성이 낮거나 연관 데이터가 많은 경우에 유용하다.

다만 지연 로딩을 사용하면 프록시 객체가 반환되므로 세션을 벗어난 상태에서 연관 엔티티를 로딩하면 예외가 발생할 수 있다.

 

우리는 데이터를 필요할때만 사용할 수 있도록 FetchType.Lazy로 설정해두었다.

그래서 데이터를 가져올 때 실제로 겪었던 부분이 밑줄그은 내용의 에러였다.

member에서 orderList를 가져오는 과정에서 LazyInitializationException이 발생했다.

 

서비스 클래스에서 member를 조회해오고, 이후 mapper를 사용해 member를 responseDto로 바꾸는 과정에서 member안의 List<Orders>를 사용해야했다.

그러나 서비스 클래스의 메서드가 종료되어 세션이 종료된 상태이므로 데이터를 가져올 수 없었다.

 

검색을 해보니 LazyInitializationException을 해결할 수 있는 방법이 다양하게 있었다.

@JsonIgnore, FetchType.EAGER 설정, EntityGraph 설정, fetch join설정 등

 

다른 방법을 적용해볼까도 생각했으나, 우리 데이터는 member -> List<Orders> -> List<OrderProduct> 로 두개의 연관관계가 엮여있고 두개 모두 FetchType.Lazy로 연결되어있어 Transactional 내부에서 데이터를 미리 가져오는게 간편하겠다는 생각을 했다.

 

또한 엔티티에서 프론트엔드에서 필요한 정보만 사용하기 좋은 형태로 전달해주기 위해 DTO 객체를 생성하다보니 mapper도 직접 작성해주는것이 오히려 직관적이었다. (이 부분은 중간 메서드를 명시해서 MapStruct를 통한 자동생성으로도 가능한것같기는 하다.)

 

대략적으로 데이터를 가져오는 흐름을 그려보면 아래와 같다.

프론트쪽에서 필요한 정보만 보내줄 수 있도록 DTO 객체를 정의했고 가지고 있는 정보를 가지고 DTO 객체로 변환하는 매퍼를 정의했다.

해당 내용이 마이페이지에 있어서 Member 패키지에서 작성을 하고 있기는 한데, 기능적인 측면을 고려했을때 이 부분은 Orders와 관련된 내용이므로 패키지 경로를 Orders쪽으로 변경하는게 맞는것같다.

(지금은 혹시 모를 코드 충돌때문에 변경하지 않았으나 나중에 변경해야할것같다.)

 

1. 컨트롤러 클래스

요청 헤더에 JWT를 함께 보내면 해당 토큰 정보에 있는 회원의 구매 목록을 띄워준다.

페이지네이션을 적용하고있어서 페이지, 사이즈 정보를 RequestParam으로 보내게 되면 해당 내용에 맞게 적용된다.

처음에는 memberService.getOrderDetails()에서 member 정보를 가져오고 매퍼를 통해 ResponseDto로 변환하고자 했으나, LazyInitializationException를 해결하기 위해 mapping까지 서비스 클래스에서 완료해주었다.

@GetMapping("/members/orders")
public ResponseEntity orderDetails(@RequestParam(required = false, defaultValue = "1") int page,
                                   @RequestParam(required = false, defaultValue = "5") int size) {
    long authenticatedMemberId = SecurityUtil.getLoginMemberId();
    MemberDto.OrderResponse response = memberService.getOrderDetails(authenticatedMemberId, page, size);
    return new ResponseEntity<>(response, HttpStatus.OK);
}

 

2. 서비스 클래스

각자 역할을 맡아서 하다보니 member 패키지에서 작업하고있으나 실질적 내용은 회원 정보 조회보다는 주문 내역 조회에 가까워서,

나중에는 orders 패키지로 변경해야할것같다.

회원에서 List<Orders>를 가져온 후 mapper를 사용해 적절한 응답 형태로 변경해주고있다.

@Override
public MemberDto.OrderResponse getOrderDetails(long memberId, int page, int size) {
    Member findMember = findVerifiedMember(memberId);
    List<Orders> orders = findMember.getOrderList();
    MemberDto.OrderResponse response = mapper.ordersToOrderResponse(orders, page, size);
    return response;
}

 

3. 매퍼 인터페이스

MapStruct를 사용해 매퍼를 자동으로 생성하고있었으나 데이터 변경이 여러번 거쳐져야하는 상황이라서 default 메서드를 작성했다.

List<OrderProduct>를 List<OrderProductInfo>로, Orders를 OrdersInfo로 변경하는 메서드를 각각 만들고

최종적으로 List<Orders>를 페이지네이션이 적용된 OrderResponse로 변경하는 메서드를 작성했다.

// List<Orders>에 페이지네이션을 적용하고, ResponseDto로 변경하는 메서드
default MemberDto.OrderResponse ordersToOrderResponse(List<Orders> orders, int page, int size) {
    int totalElement = orders.size();
    int totalPage = (totalElement + size - 1) / size;

    List<Orders> paginatedOrders = orders.stream()
            .sorted(Comparator.comparing(Orders::getOrderId).reversed())
            .skip((page - 1) * size)
            .limit(size)
            .collect(Collectors.toList());

    List<MemberDto.OrderInfo> orderInfos = paginatedOrders.stream()
            .map(this::orderToOrderInfo)
            .collect(Collectors.toList());

    PageInfoDto pageInfo = PageInfoDto.builder()
            .page(page)
            .size(size)
            .totalElement(totalElement)
            .totalPage(totalPage).build();

    return MemberDto.OrderResponse.builder()
            .orderInfos(orderInfos)
            .pageInfo(pageInfo)
            .build();
}

// Orders를 OrderInfo로 변경하는 메서드
default MemberDto.OrderInfo orderToOrderInfo(Orders order) {

    if (order == null) {
        return null;
    }

    return MemberDto.OrderInfo.builder()
            .orderId(order.getOrderId())
            .createdAt(order.getCreatedAt())
            .totalPrice(order.getOrderPrice())
            .orderStatus(order.getOrderState())
            .orderAddress(order.getOrderAddress())
            .orderProductInfos(orderProductToOrderProductInfo(order.getOrderProductList()))
            .build();
}

// List<OrderProduct>를 List<OrderProductInfo>로 변경하는 메서드
default List<MemberDto.OrderProductInfo> orderProductToOrderProductInfo(List<OrderProduct> orderProduct) {
    if (orderProduct == null) {
        return null;
    }

    List<MemberDto.OrderProductInfo> list = new ArrayList<>(orderProduct.size());
    for (OrderProduct orderProduct1 : orderProduct) {
        list.add(MemberDto.OrderProductInfo.builder()
                .orderProductId(orderProduct1.getOrderProductId())
                .productId(orderProduct1.getProduct().getProductId())
                .productName(orderProduct1.getProduct().getProductName())
                .productPrice(orderProduct1.getProduct().getProductPrice())
                .productImage(orderProduct1.getOrderProductCustomProductImage())
                .productCount(orderProduct1.getOrderProductCustomProductCount())
                .storeId(orderProduct1.getProduct().getStore().getStoreId())
                .build());
    }

    return list;
}

 

[결과확인]

데이터베이스에 임의의 주문정보를 입력하고 정보를 조회했을때 아래와 같이 주문 정보와 페이지네이션 정보가 잘 전달되고있다.

더보기
{
    "orderInfos": [
        {
            "orderId": 12,
            "createdAt": "2023-07-19T03:08:52",
            "totalPrice": 3,
            "orderStatus": "SUSPENSION",
            "orderAddress": "강원 속초시 장사항해안길 59",
            "orderProductInfos": [
                {
                    "orderProductId": 27,
                    "productId": 4,
                    "productName": "ddd",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/56.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 28,
                    "productId": 5,
                    "productName": "fff",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/36.jpg",
                    "productCount": 2,
                    "storeId": 1
                }
            ]
        },
        {
            "orderId": 11,
            "createdAt": "2023-07-19T00:08:52",
            "totalPrice": 85001,
            "orderStatus": "SUSPENSION",
            "orderAddress": "제주 제주시 구좌읍 번영로 2133-30",
            "orderProductInfos": [
                {
                    "orderProductId": 24,
                    "productId": 4,
                    "productName": "ddd",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/56.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 25,
                    "productId": 7,
                    "productName": "hhh",
                    "productPrice": 50000,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/28.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 26,
                    "productId": 9,
                    "productName": "jjj",
                    "productPrice": 35000,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/15.jpg",
                    "productCount": 1,
                    "storeId": 1
                }
            ]
        },
        {
            "orderId": 10,
            "createdAt": "2023-07-18T19:08:52",
            "totalPrice": 200000,
            "orderStatus": "SUSPENSION",
            "orderAddress": "대전 유성구 엑스포로 488 엑스포코아 지하1층 109호 오늘주스",
            "orderProductInfos": [
                {
                    "orderProductId": 23,
                    "productId": 8,
                    "productName": "iii",
                    "productPrice": 100000,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/78.jpg",
                    "productCount": 2,
                    "storeId": 1
                }
            ]
        },
        {
            "orderId": 9,
            "createdAt": "2023-07-18T16:08:52",
            "totalPrice": 85001,
            "orderStatus": "SUSPENSION",
            "orderAddress": "대구 동구 아양로 29 1층",
            "orderProductInfos": [
                {
                    "orderProductId": 20,
                    "productId": 4,
                    "productName": "ddd",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/56.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 21,
                    "productId": 7,
                    "productName": "hhh",
                    "productPrice": 50000,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/28.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 22,
                    "productId": 9,
                    "productName": "jjj",
                    "productPrice": 35000,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/15.jpg",
                    "productCount": 1,
                    "storeId": 1
                }
            ]
        },
        {
            "orderId": 8,
            "createdAt": "2023-07-18T14:08:52",
            "totalPrice": 2,
            "orderStatus": "SUSPENSION",
            "orderAddress": "제주 제주시 고마로15길 29 1층",
            "orderProductInfos": [
                {
                    "orderProductId": 18,
                    "productId": 4,
                    "productName": "ddd",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/56.jpg",
                    "productCount": 1,
                    "storeId": 1
                },
                {
                    "orderProductId": 19,
                    "productId": 5,
                    "productName": "fff",
                    "productPrice": 1,
                    "productImage": "https://mainproject022.s3.ap-northeast-2.amazonaws.com/product/36.jpg",
                    "productCount": 1,
                    "storeId": 1
                }
            ]
        }
    ],
    "pageInfo": {
        "page": 1,
        "size": 5,
        "totalElement": 7,
        "totalPage": 2
    }
}

 

참고자료

[Spring Data JPA Tutorial] 9. LazyInitializationException 해결하기 1. @ManyToOne

JPA 지연로딩을 사용해야하는 이유, 지연로딩(Lazy)과 즉시로딩(Eager)

[JPA] 즉시 로딩과 지연 로딩(FetchType.Lazy or EAGER)