장기 프로젝트 별 헤는 밤에 있는 문제들을 하나씩 해결해보는 시간!
[오늘의 할 일]
검색어, 필터(해쉬태그, 지역)를 적용하여 관측지를 검색할 수 있는 메소드를 수정한다.
별 헤는 밤에는 관측지 검색기능이 존재한다. 검색어와 필터를 통해 검색할 수 있는데 초기 구현 버전은 아래와 같았다.
간단히 설명하면(전혀 간단하지 않게 구현했지만)
- 검색어, 해시태그, 지역이 존재하는 경우를 모두 나누고
- 각각의 결과를 다른 리스트에 담고
- 공통으로 존재하는 결과를 추려서 반환했다
- 페이지 처리도 없이!
(나는 가고 싶었던 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;
}
더보기
매개변수
- Filter
- areaCodeList - 지역변수
- hashTagIdList - 해쉬태그 리스트
- 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;
}
문제점을 정리해보면 다음과 같다.
문제점
- 불필요하게 너무 많이 쿼리를 호출한다.
- 검색조건에 따른 분기를 더 깔끔하게 해결하고 싶다.
- 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 문제를 해결해보았다.
다음 시간에는 복잡한 두 가지 문제를 해결한 방법을 알아보자!
<별 헤는 밤> 구경가기 👇
'별밤 일지 > 개발' 카테고리의 다른 글
[Android] CustomView 를 활용한 효율적인 나만의 View 만들기 (0) | 2023.12.13 |
---|---|
[JPA] 검색 메소드 수정하기 - JPA Specification (0) | 2023.12.07 |
[Android] Viewpager2, Scrollview 터치와 스크롤 분리하기 (0) | 2023.11.29 |
[WebClient] 비동기 아키텍처를 통한 외부 api 콜 성능 개선 (0) | 2023.11.04 |
[Android] 게시글 댓글 기능 구현하기 (0) | 2023.10.18 |