본문 바로가기
별밤 일지/개발

[JPA] 검색 메소드 수정하기 - N+1 문제

by 별밤 에디터 2023. 10. 24.

장기 프로젝트 별 헤는 밤에 있는 문제들을 하나씩 해결해보는 시간!

 

[오늘의 할 일]

검색어, 필터(해쉬태그, 지역)를 적용하여 관측지를 검색할 수 있는 메소드를 수정한다.

 

별 헤는 밤에는 관측지 검색기능이 존재한다. 검색어와 필터를 통해 검색할 수 있는데 초기 구현 버전은 아래와 같았다.

간단히 설명하면(전혀 간단하지 않게 구현했지만)

  1. 검색어, 해시태그, 지역이 존재하는 경우를 모두 나누고
  2. 각각의 결과를 다른 리스트에 담고
  3. 공통으로 존재하는 결과를 추려서 반환했다
  4. 페이지 처리도 없이!

(나는 가고 싶었던 N…모 서비스 회사 면접에서 이 코드에 대해 개선점 질문을 받았고 제대로 대답하지 못해 떨어졌다. 이제라도 수정해본다…)

 

기존 코드

더보기
public class Observation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long observationId;

    @Column(nullable = false, unique = true)
    private String observationName;

    @Column
    private String intro;   //한줄소개
		@Column
    private Long areaCode;  //지역코드

		//---생약---//

		@OneToMany(mappedBy = "observation")
    private List<ObserveFee> observeFees=new ArrayList<>();

		@OneToMany(mappedBy = "observation")
    private List<ObserveHashTag> observeHashTags=new ArrayList<>();

    @OneToMany(mappedBy = "observation")
    private List<ObserveImage> observeImages = new ArrayList<>();
}

 

더보기
public class ObserveHashTag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long observeHashTagListId;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "observationId", insertable = false, updatable=false)
    private Observation observation;

    @Column(nullable = false)
    private Long observationId;

    @Column(nullable = false)
    private Long hashTagId;

    @Column(nullable = false)
    private String hashTagName;
}

 

더보기

매개변수

  1. Filter
    • areaCodeList - 지역변수
    • hashTagIdList - 해쉬태그 리스트
  2. searchKey - 검색어
// 생략 //
public List<SearchParams1> getObservationWithFilter(Filter filter, String searchKey) {
        List<Long> areaCodeList = filter.getAreaCodeList();
        List<Long> hashTagIdList= filter.getHashTagIdList();    //필터 해쉬태그 리스트

        List<SearchParams1> resultParams = new ArrayList<>();   //최종결과 param 리스트
        List<Long> hashtagResult = new ArrayList<>();   //해쉬태그 결과
        List<Long> filterIdList = new ArrayList<>();    //필터결과(해쉬태그, 지도 포함)id 리스트
        List<Observation> searchResult = new ArrayList<>(); //필터+검색어 결과 리스트


        if (!hashTagIdList.isEmpty()) {
            for(Long hashTagId : hashTagIdList){
                List<ObserveHashTag> observeHashTags = observeHashTagRepository.findByHashTagId(hashTagId);
                for (ObserveHashTag observeHashTag : observeHashTags) {
                    Long observationId = observeHashTag.getObservationId();
                    if (!hashtagResult.contains(observationId)) { //관광지 중복 제거
                        hashtagResult.add(observationId);
                    }
                }
            }
        }

        if (!areaCodeList.isEmpty()) {
            for (Long areaCode : areaCodeList) {
                List<Observation> observationList = observationRepository.findByAreaCode(areaCode);
                if (hashTagIdList.isEmpty()) {
                    //해쉬태그 없으면 지역결과 전부추가
                    for (Observation observation : observationList) {
                        filterIdList.add(observation.getObservationId());
                    }
                } else {
                    //해쉬태그 있으면 필터 중첩
                    for (Observation observation : observationList) {
                        Long observationId = observation.getObservationId();
                        if (hashtagResult.contains(observationId)) {
                            //해시태그결과에서 지역 있으면 filter최종결과에 포함
                            filterIdList.add(observationId);
                        }
                    }
                }
            }
        } else {
            //area 비어있으면
            filterIdList = hashtagResult;
        }

        if (searchKey != null) {
            List<Observation> keyResult = new ArrayList<>();    //검색결과 받아올 리스트
            searchResult = observationRepository.findByObservationNameContainingOrOutlineContaining(searchKey, searchKey);
            keyResult = observationRepository.findByObservationNameContainingOrOutlineContaining(searchKey, searchKey);

            if (!hashTagIdList.isEmpty() || !areaCodeList.isEmpty()) {
                //필터 받은게 없으면 그냥 검색결과 전달, 있으면 중첩 검색
                for (Observation observation : keyResult) {
                    //전체 검색어 결과 돌면서
                    if (!filterIdList.contains(observation.getObservationId())) {
                        //필터결과에 검색어 결과 없으면 필터+검색어검색결과에서 삭제
                        searchResult.remove(observation);
                    }
                }
            }
        } else {
            for(Long p : filterIdList)
                searchResult.add(getObservation(p));
        }


        //결과 param에 넣음
        for(Observation observation : searchResult){

            if(observation.getObservationId()==999)
                continue;
            SearchParams1 searchParams1 = new SearchParams1();
            searchParams1.setItemId(observation.getObservationId());
            searchParams1.setTitle(observation.getObservationName());
            //주소를 두단어까지 줄임
            String address = observation.getAddress();
            int i = address.indexOf(' ');
            if (i != -1){
                int j = address.indexOf(' ', i+1);
                if(j != -1){
                    searchParams1.setAddress(observation.getAddress().substring(0, j));
                } else{
                    searchParams1.setAddress(observation.getAddress());
                }
            } else{
                searchParams1.setAddress(observation.getAddress());
            }
//            searchParams1.setAddress(observation.getAddress());
            searchParams1.setLatitude(observation.getLatitude());
            searchParams1.setLongitude(observation.getLongitude());
            searchParams1.setIntro(observation.getIntro());
            searchParams1.setContentType(observation.getObserveType());
            searchParams1.setLight(observation.getLight());
            if (!observeImageRepository.findByObservationId(observation.getObservationId()).isEmpty()) {
                ObserveImage observeImage = observeImageRepository.findByObservationId(observation.getObservationId()).get(0);
                searchParams1.setThumbnail(observeImage.getImage());
            } else {
                searchParams1.setThumbnail(null);
            }
            List<ObserveHashTag> hashTagList = observeHashTagRepository.findByObservationId(observation.getObservationId());
            List<String> hashTagNames = new ArrayList<>();
            int k = 0;
            for (ObserveHashTag hashTag : hashTagList){
                if(k>2)
                    break;
                hashTagNames.add(hashTag.getHashTagName());
                k++;
            }
            searchParams1.setHashTagNames(hashTagNames);

            resultParams.add(searchParams1);
        }
        return resultParams;
    }

 

문제점을 정리해보면 다음과 같다.

문제점

  1. 불필요하게 너무 많이 쿼리를 호출한다.
  2. 검색조건에 따른 분기를 더 깔끔하게 해결하고 싶다.
  3. n+1문제 해결하기(이미지, 해시태그, 요금)

오늘은 이 중에 3번 문제를 먼저 해결해보려고 한다.

 

수정

1. N+1문제 해결

N+1문제는 연관 관계가 설정된 엔티티를 조회할 경우(1)에 조회된 데이터 개수(n) 만큼 연관 관계의 조회 쿼리가 추가로 발생하여 n+1만큼의 데이터를 읽어오는 현상이다.

 

별 헤는 밤의 경우에는 관측지에 연관된 관측지 이미지, 해시태그, 관측지 요금 3가지가 모두 n+1문제가 있었다.

 

n+1문제를 해결하기 위해 내가 고려한 방법은 다음과 같다.

  • 방법1 fetch join
    JPA에서 일반조인이 아닌 fetch join을 사용하면 n+1문제를 해결할 수 있다.
    • 일반 조인
      ◦ 연관 엔티티에 join을 하게 되면 Select 대상의 엔티티는 영속화하여 가져오지만, 조인의 대상은 영속화하여 가져오지 않는다.
      ◦ 연관 엔티티가 검색 조건에 포함되고, 조회의 주체가 검색 엔티티뿐일 때 사용하면 좋다.
💡 어떻게 join을 하는데 연관 객체를 영속화하지 않을 수 있을까?
→ JPA에서 연관 객체는 프록시 객체로 가져온다. fetch 전략이 LAZY로 설정되어있는 경우 연관 객체를 프록시 객체로 설정하기 때문이다.

 

  • 패치 조인
    ◦ 연관 엔티티에 fetch join을 하게 되면 select 대상의 엔티티뿐만 아니라 조인의 대상까지 영속화하여 가져온다.
    ◦ 연관 엔티티까지 select의 대상일 때, N+1의 문제를 해결하여 가져올 수 있는 좋은 방법이다.

    -> pagination을 못 쓰고 한 개의 속성에만 적용 가능하다.
    우리의 경우 이미지, 해시태그, 요금 3개의 속성이 일대다이므로 적합하지 않아 사용하지 않았다.
  • 방법2 @BatchSize → 적용
    1대 다 관계에서 ‘다’에 해당하는 many의 모든 값을 한 번에 가져오는 것이 아닌 BatchSize로 지정된 만큼 range로 한 번에 가져온다. 이 경우 연관된 데이터의 수에 따라 한번의 조회로 값을 전부 가져오지는 못할 수 있다.

    → 우리의 경우 각 속성의 특성에 맞게 Batchsize를 지정해 줌으로써 해결하였다.

다음은 BatchSize가 적용된 코드의 일부분이다. 적용 방법은 1대다에서 ‘1’에 해당하는 엔티티에 @BatchSize 어노테이션을 추가하면 된다.

@Table(name="observation")
public class Observation { 		
	...
		@OneToMany(mappedBy = "observation")
    @BatchSize(size = 10)
    private List<ObserveFee> observeFees=new ArrayList<>();

    @OneToMany(mappedBy = "observation")
    @BatchSize(size = 20)
    private List<ObserveHashTag> observeHashTags=new ArrayList<>();

    @OneToMany(mappedBy = "observation")
    @BatchSize(size = 10)
    private List<ObserveImage> observeImages = new ArrayList<>();
}

 

여기까지 3가지 문제점 중 가장 간단한 n+1 문제를 해결해보았다.

다음 시간에는 복잡한 두 가지 문제를 해결한 방법을 알아보자!

 


<별 헤는 밤> 구경가기 👇

 

별 헤는 밤: 밤하늘, 별자리, 여행정보와 날씨예보까지 - Google Play 앱

오늘부터 별잘알! 오늘밤 별자리 정보, 날씨·광공해·월령까지 고려한 '관측적합도', 주변 관측지 검색으로 누구나 쉽게 밤하늘을 즐겨보세요.

play.google.com