[최적화] Map으로 N+1 문제 해결하기

서예진·2024년 4월 4일
0

SPRING

목록 보기
4/5
post-thumbnail

프로젝트마다 직면하는 N+1 문제에 대해서 이번에는 Map으로 해결해보았다.
해결 결과, N+1개의 쿼리에서 2개의 쿼리로 줄였다.
그 과정을 포스팅하고자 한다.

📖 N+1 문제 직면


내가 직면한 N+1 문제는 아래 링크를 통해 확인할 수 있다.

👉🏻 N+1 문제 직면 - [내일배움캠프 Spring 4기 - 최종 프로젝트] 92일차 TIL : 페이징 구현

📖 N+1 문제 해결 전


  • 이벤트를 조회할 때, 이벤트와 연관된 이벤트 상품의 Id도 같이 조회할 수 있게 조회 기능을 구현했다.

EventResponse.java

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public class EventResponse {
    
        private Long id;
    
        private String title;
    
        private String content;
    
        @JsonSerialize(using = LocalDateTimeSerializer.class)
        @JsonDeserialize(using = LocalDateTimeDeserializer.class)
        private LocalDateTime openAt;
    
        private Long couponId;
        private List<Long> eventProducts;
    
        public static EventResponse from(Event event, List<Long> eventProducts) {
            return new EventResponse(
                event.getId(),
                event.getTitle(),
                event.getContent(),
                event.getOpenAt(),
                event.getCoupon().getId(),
                eventProducts
            );
        }
    }
  • EventResponse를 보면 List 타입으로 EventProduct의 Id를 받아서 같이 내보낸다.
  • 해당 비즈니스 로직은 아래와 같다.

EventServiceImpl.java

        public EventResponse getEvent(Long eventId) {
    
            Event event = findEvent(eventId);
            List<Long> eventProducts = eventQuery.getEventProducts(event.getId());
            return EventResponse.from(event, eventProducts);
        }
        
        public Page<EventResponse> getAllEvents(Pageable pageable) {
    
            return eventRepository.findAll(pageable)
                .map(event -> {
                    List<Long> eventProducts = eventQuery.getEventProducts(event.getId());
                    return EventResponse.from(event, eventProducts);
                });
        }

Entity.java

  • Event와 EventProduct entity는 다음과 같다.

Event.java

        @Getter
        @Entity
        @NoArgsConstructor
        @AllArgsConstructor
        @Table(name = "event")
        @SQLDelete(sql = "UPDATE event SET deleted_at=CURRENT_TIMESTAMP where id=?")
        @SQLRestriction("deleted_at IS NULL")
        public class Event extends BaseEntity {
        
            @Id
            @GeneratedValue(strategy = GenerationType.IDENTITY)
            private Long id;
        
            @Column
            private String title;
        
            @Column
            private String content;
        
            @Column
            @JsonSerialize(using = LocalDateTimeSerializer.class)
            @JsonDeserialize(using = LocalDateTimeDeserializer.class)
            private LocalDateTime openAt;
        
            @ManyToOne(fetch = FetchType.LAZY)
            @JoinColumn(name = "user_id")
            private User user;
        
            @OneToOne
            @JoinColumn(name = "coupon_id")
            private Coupon coupon;
        
            public Event(EventRequest request, User user) {
                this.title = request.getTitle();
                this.content = request.getContent();
                this.openAt = request.getOpenAt();
                this.user = user;
            }
        
            public void update(EventUpdateRequest request) {
                this.title = request.getTitle();
                this.content = request.getContent();
                this.openAt = request.getOpenAt();
            }
        
            public void addCoupon(Coupon coupon) {
                this.coupon = coupon;
            }
        }

EventProduct.java

        @Getter
        @Entity
        @NoArgsConstructor
        @AllArgsConstructor
        public class EventProduct {
        
            @Id
            @GeneratedValue(strategy = GenerationType.IDENTITY)
            private Long id;
        
            @ManyToOne(fetch = FetchType.LAZY)
            @JoinColumn(name = "event_id")
            private Event event;
        
            @ManyToOne(fetch = FetchType.LAZY)
            @JoinColumn(name = "product_id")
            private Product product;
        
            public EventProduct(Event event, Product product) {
                this.event = event;
                this.product = product;
            }
        }

문제점

  • 우선, EventResponse에 EventProducts를 담기 위해 Event에 대한 select 쿼리를 날리고 이 Event와 연관된 EventProduct의 ID를 List로 받아오는 select 쿼리를 날린다.
  • 여기서, 하나의 EventResponse를 만들기 위해 총 2번의 쿼리가 날라가게 된다.
  • 그렇다면 Event를 전체 조회할 때, 5개의 Event가 존재한다고 가정하면 총 6개의 쿼리를 날린다.
  • 이렇게 N+1 문제가 발생한다.

📖 N+1 문제 해결


  • 우선 쿼리를 N+1 보다 줄이는 것이 목표다.
  • 이벤트에 그 이벤트와 연관된 이벤트 상품이 매핑되어 보여줘야하기 때문에 Map을 사용하는 것은 어떨까 싶었다.
  • Map을 사용해서 각 이벤트ID에 이벤트 상품을 매핑하고 싶었다.
  • Map으로 매핑을 하고자 할 때, 이벤트 상품에도 이벤트 정보를 가지고 있어서 이벤트 기준으로 grouping 하면 Map으로 저장할 수 있을 것이라고 생각했다
  • 따라서, List<Long> 으로 eventproducts를 가져오지 말고 List<EventProduct>로 받아오는 것으로 수정했다.

EventProductResponse.java

@Getter
public class EventProductResponse {

	private Long productId;

	public EventProductResponse(EventProduct eventProduct) {
		this.productId = eventProduct.getProduct().getId();
	}
}
  • 수정하기 전에는 EventProductResponse를 별도로 만들지 않고 productId를 그냥 Long으로 가져왔지만(예: List) productId 값을 담을 Dto로 EventProductResponse(예: List)를 만들었다.

EventQueryImpl.java

public List<EventProduct> getEventProducts(List<Long> eventIds) {
        return jpaQueryFactory.select(
                QEventProduct.eventProduct)
            .from(QEventProduct.eventProduct)
            .where(QEventProduct.eventProduct.event.id.in(eventId))
            .fetch();
    }
  • Map으로 매핑할 때, EventProduct 안에 있는 Event의 ID 값을 기준으로 그룹핑 하기 때문에 그 값에 접근하기 위해 EventProduct를 조회해오도록 수정했다.
  • 또한, 모든 eventId를 List로 받아 eventproduct의 event의 id가 eventIds 안에 있는 경우만을 조회해오도록 수정했다.

EventServiceImpl.java

	@Transactional(readOnly = true)
	public Page<EventResponse> getAllEvents(Pageable pageable) {
		Page<Event> events = eventRepository.findAll(pageable);
		List<Long> eventIds = events.stream().map(Event::getId).toList();
		List<EventProduct> eventProducts = eventQuery.getEventProducts(eventIds);

		Map<Long, List<EventProductResponse>> eventProductMap = eventProducts.stream()
			.collect(Collectors.groupingBy(eventProduct -> eventProduct.getEvent().getId(),
				Collectors.mapping(EventProductResponse::new,
					Collectors.toList())));

		List<EventResponse> eventResponses = events.stream().map(event -> EventResponse.from(event,
			eventProductMap.getOrDefault(event.getId(), List.of()))).toList();
		return new PageImpl<>(eventResponses, pageable, events.getTotalElements());
	}
  • Page 타입으로 조회하기 때문에 events의 타입은 Page이다.
  • 우선, 모든 event를 가져온 후, 이 event의 각각의 ID를 List에 저장한다.
  • 이 List를 기반으로 해당되는 eventproduct를 모두 가져온다.
  • 여기서 groupingBy 메서드는 첫 번째 인자로 그룹화할 키를 추출하는 람다 표현식을 받고, 두 번째 인자로는 해당 키에 대한 값을 어떻게 수집할지를 지정하는 컬렉터를 받는다. 따라서, EventProduct 객체를 Event ID에 매핑하는 람다 표현식과 EventProductResponse 객체로 변환한 후 리스트로 수집하는 컬렉터를 사용하여 그룹핑한다.
  • 그 결과, eventProductMapEvent ID를 키로 사용하고, 해당 Event ID에 해당하는 EventProductResponse 객체들의 리스트를 값으로 가지는 map이 된다.

📖 N+1 문제 해결 결과


  • 위와 같이 수정하면 2개의 쿼리로 수정 전과 같은 결과를 얻을 수 있다.
  • 전체 조회 기능에 있어서 N+1 개의 쿼리에서 2개 쿼리로 줄였다.
profile
안녕하세요

0개의 댓글