간단하게 오류의 원인을 설명하자면 우선 오타!… assertThat(body).contains("스프링 부트로 시작하는 웹 서비스")로 html body 안에 contains()를 통해서 String 타입 스프링 부트로 시작하는 웹 서비스를 찾고 있었는데 내가 테스트를 스프링부트로 시작하는 웹 서비스로 스프링 띄고 부트가 아니라 스트링부트라고 붙여서 테스트를 하고 있었다… 하…
그리고 oauth2를 통해 with(oauth2Login()))를 추가했어야 했는데 기존 mvc.perform(get("/hello").andExpect(status().isOk()).andExpect(content().string(hello)); 만 사용하고 있던 것.
즉, mvc.perform(get("/hello").with(oauth2Login())).andExpect(status().isOk()).andExpect(content().string(hello)); 라고 수정을 하여 정상적인 테스트를 진행하였다.
또한 test 쪽의 application.yml파일에서 hibernate를 책의 내용과 똑같이 hibernate.dialect=MySQL5InnoDBDialect라고 해놓았던것..
난 H2DB를 사용했기 때문에 hibernate.dialect=H2Dialect가 맞았다. 역시.. 실패를 통해 많은 것들을 배우게 되는거 같다.
HashSet은 Set인터페이스를 구현한 가장 대표적인 클래스이며 Set 인터페이스의 특징대로 HashSet은 중복된 요소를 저장하지 않는다.
HashSet에 새로운 요소를 저장할때는 add 메서드나 addAll 메서드를 사용하는데 만일 HashSet에 이미 저장되어 있는 요소와 중복된 요소를 추가하려고 한다면 이 메소드들은 false를 반환함으로서 중복된 요소리기 때문에 추가에 실패했다는 것을 알린다.
이러한 HashSet의 특징을 이용한다면 컬렉션내의 중복된 요소를 쉽게 제거할 수 있다.
AraayList와 달리 HashSet은 저장순서를 유지하지 않음으로 저장순서를 유지하고자 한다면 LinkedList를 이용해야 한다.
TreeSet
TreeSet은 이진 탐색 트리라는 자료구조 형태로 데이터를 저장하는 컬렉션 클래스이다. 이진 탐색 트리는 정렬, 검색, 범위 검색에 높은 성능을 보이는 자료구조이며 TreeSet 이진 탐색 트리의 성능을 향상 시킨 레드-블랙 트리 형태로 구현되어 있다.
Set 인터페이스를 구현했으므로 중복된 데이터의 저장을 허용하지 않으며 정렬된 위치에 저장하므로 저장순서를 유지하지도 않는다.
이진 트리
이진 트리는 링크드 리스트처럼 여러개의 노드가 서로 연결되어 있는 구조로, 각 노드에 최대 2개의 노드를 연결할 수 있으며, 루트라고 불리는 하나의 노드에서부터 시작해서 계속 확장되어 나갈 수 있다. 이진 탐색 트리는 이진 트리의 한 종류이다.
위 아래로 연결된 두 노드를 부모, 자식 관계에 있다고 하며 위의 노드를 부모, 아래의 노드를 자식 노드라고 한다. 부모 - 자식 관계는 상대적인 것이며 하나의 부모노드 최대 두개의 자식 노드와 연결될 수 있다.
이진 탐색 트리
이진 탐색 트리는 부모노드의 왼쪽에는 부모노드의 값보다 작은 값의 자식노드를 오른쪽에는 큰 값의 자식노드를 저장하는 이진 트리이다.
왼쪽 마지막 값에서부터 오른쪽 값까지 값을 왼쪽 노드 -> 부모 노드 -> 오른쪽 노드 순으로 읽어오면 오름차순으로 정렬된 순서를 얻을 수 있다. TreeSet은 이처럼 정렬된 상태를 유지하기 때문에 단일 값 검색과 범위검색이 매우 빠르다. 저장된 값의 개수에 비례해서 검색시간이 증가하긴 하지만 값의 개수가 10배 증가해도 특정값을 찾는데 필요한 비교 횟수가 3~4번 증가할 정도로 검색 효율이 뛰어난 자료구조이다. 트리는 데이터를 순차적으로 저장하는 것이 아니라 저장하는 위치를 찾아서 저장해야하고 삭제하는 경우에는 트리의 일부를 재구성해야하기때문에 링크드리스트보다 데이터의 추가/삭제 시간은 더 걸린다. 대신 배열이나 링크드 리스트에 비해 검색과 정렬기능이 더 뛰어나다.
즉, 이진 탐색 트리는
모든 노드는 최대 두 개의 자식 노드를 가질 수 있다.
왼쪽 자식노드의 값은 부모 노드의 값보다 작고 오른쪽 자식 노드의 값은 부모노드의 값보다 커야한다.
jsp 프로젝트에서 vue 프로젝트로 마이그레이션 작업 중 사용되지 않는 컬럼들 까지 요청하여 성능상의 문제 소지를 발견하였다.
총 7건의 요청사항에 문제를 발견하였는데, 모두 불필요한 컬럼들까지 요청하고 있었다.
문제 상황 재현 코드
// file: "재현 코드 = 리스트 가져오는 서비스 재현 코드.java"@Service@RequiredArgsConstrutorpublicclassExampleService{privatefinalExUser3JoinTableRepositoryexUser3JoinTableRepository;publicList<ExUser3JoinTable>getList(ReqDTOreqDto){List<ExUser3JoinTable>list=exUser3JoinTableRepository.findAll(Specification.joinAndJoinAndJoinAll(reqDto.getStartRegistDate(),reqDto.getEndRegistDate(),reqDto.getStartModifyDate(),reqDto.getEndModifyDate(),reqDto.getSearchKeyword(),reqDto.getClientComponySn(),reqDto.getAuthoritySn(),reqDto.getUseStatus()),Sort.by(Sort.Direction.DESC,"등록일자"));returnlist;}}
위 Service 로직을 보면 Specification을 이용하여 요청DTO를 통해 검색조건을 만족하는(없으면 Object.isEmpty로 if문 패스) 3개의 join된 테이블로 list를 조회해한다.
하지만 여기서 문제는 바로 List<ExUser3JoinTable> 부분이다.
문제의 조인 테이블
// file: "재현 코드 = 3개의 조인 테이블 재현 코드.java"importjava.io.Serializable;@Entity@Table(name="고객정보테이블")publicclassExUser3JoinTableextendsSuperUserimplementsSerializable{@NotFound(action=NotFoundAction.IGNORE)@JoinColumn(name="authority_sn",insertable=false,update=false,referenceColumnName="authority_sn")@ManyToOne(fetch=FetchType.EAGER)publicAuthorityAuthorityInfo;@NotFound(action=NotFoundAction.IGNORE)@JoinColumn(name="client_company_sn",insertable=false,update=false,referenceColumnName="client_company_sn")@ManyToOne(fetch=FetchType.EAGER)publicClientComponyClientComponyInfo;}
클라이언트에 전송되는 과도한 데이터들
무려 33개의 컬럼의 데이터가 요청되어있던 것이다.
내가 필요한 데이터는 13개의 컬럼 데이터만 필요하다.
무려 20개의 사용되지 않는 컬럼들(물론 조인한 컬럼2개 제외하면 18개이다.)을 DB에서 가져와 Client쪽으로 데이터를 보내주고 있었다.
해결
기존 JPA의 findAll키워드를 사용해서 모든 컬럼을 찾아오는 대신에 QueryDSL로 필요한 컬럼만 조인하여 응답 DTO를 만들어 해결하였다.
데이터 조회를 QueryDSL로
데이터 조회를 QueryDSL로 - BooleanBuilder 사용
// file: "재현 코드 = queryDSL - BooleanBuilder를 사용한 예제.java"importjava.time.LocalDate;importjava.time.LocalDateTime;importjava.time.LocalTime;importjava.time.format.DateTimeFormatter;@Repository@RequiredArgsConstructorclassQClassUserRepositoryimplementsExUserRepository{privatefinalJPAQueryFactoryjPAQueryFactory;@OverridepublicList<UserListDTO>getList(SearchVOsearchVO){QUserqUser=QUser.user;QAuthorityqAuthority=QAuthority.authority;QClientComponyqClientCompony=QClientCompony.clientCompony;BooleanBuilderbuilder=newBooleanBuilder();DateTimeFormatterformatter=DateTimeFormatter.ofPattern("YYYY-MM-dd");if(ObjectUtils.isNotEmpty(searchVO.getRegStrDate())&&ObjectUtils.isNotEmpty(searchVO.getRegEndDate())){LocalDateTimeparseStrDate=LocalDateTime.of(LocalDate.parse(searchVO.getStrDate(),formatter),LocalTime.of(0,0,0));LocalDateTimeparseEndDate=LocalDateTime.of(LocalDate.parse(searchVO.getEndDate(),formatter),LocalTime.of(23,59,59));builder.and(qUser.regDt.between(parseStrDate,parseEndDate));}if(StringUtils.isNotBlank(searchVO.getSearchKeyword())){builder.or(qUser.nm.like("%"+searchVO.getSearchKeyword()+"%"));builder.or(qAuthority.authNm.like("%"+searchVO.getSearchKeyword()+"%"));builder.or(qClientCompony.coNm.like("%"+searchVO.getSearchKeyword()+"%"));}// 등등... returnjPAQueryFactory.select(Projections.filds(UserListDTO.class,qUser.id,qAuthority.auth,qClientCompony.nm// 조회 컬럼..)).from(qUser).leftjoin(qAuthority).on(qUser.userSn.eq(qAuthority.userSn)).leftjoin(qClientCompony).on(qUser.coSn.eq(qClientCompony.coSn)).where(builder).orderBy(qUser.regDt.desc()).fetch();}}
위 예제 코드를 보면 그럭저럭 나쁘지 않은 거 같지만..
사실 해당 QClassUserRepository에 구현 클래스가 하나이면 상관없지만 여러 구현 클래스가 추가로 생기면 문제가 발생하기 시작한다.
예를 들어 매번 검색 조건에 날짜가 들어가게 되면 BooleanBuilder와 DateTimeFormatter을 구현 클래스 안에다가 작성해야 하는 번거로움이 발생하게 된다.
위 방법을 좀 더 편리하게 바꾸려면 BooleanExpression을 사용하여 메서드를 만들어 사용하는 방법으로 리팩토링하면 코드가 훨씬 깔끔해진다.
데이터 조회를 QueryDSL로 - BooleanExpression 사용하여 리팩토링
// file: "재현 코드 = queryDSL - BooleanExpression으로 리팩토링 사용한 예제.java"importjava.time.LocalDate;importjava.time.LocalDateTime;importjava.time.LocalTime;importjava.time.format.DateTimeFormatter;@Repository@RequiredArgsConstructorclassQClassUserRepositoryimplementsExUserRepository{privatefinalJPAQueryFactoryjPAQueryFactory;@OverridepublicList<UserListDTO>getList(SearchVOsearchVO){QUserqUser=QUser.user;QAuthorityqAuthority=QAuthority.authority;QClientComponyqClientCompony=QClientCompony.clientCompony;returnjPAQueryFactory.select(Projections.filds(UserListDTO.class,qUser.id,qAuthority.auth,qClientCompony.nm// 조회 컬럼)).from(qUser).leftjoin(qAuthority).on(qUser.userSn.eq(qAuthority.userSn)).leftjoin(qClientCompony).on(qUser.coSn.eq(qClientCompony.coSn)).where(betWeenRegDt(searchVO.getRegStrDate(),searchVO.getRegEndDate(),EnDateTy.SVC_STR_DT),betWeenModDt(searchVO.getModStrDate(),searchVO.getModEndDate(),EnDateTy.SVC_END_DT),likeSearchKeyword(searchVO.getSearchKeyword())// 등등...).orderBy(qUser.regDt.desc()).fetch();}// 검색조건 등록 날짜privateBooleanExpressionbetWeenRegDt(StringschStrRegDt,StringschEndRegDt,EnDateTyschDateType){returngetBooleanExpression(schStrRegDt,schEndRegDt,schDateType);}// 검색조건 수정 날짜privateBooleanExpressionbetWeenModDt(StringschStrModDt,StringschEndModDt,EnDateTyschDateType){returngetBooleanExpression(schStrModDt,schEndModDt,schDateType);}// 날짜 parsing privateBooleanExpressiongetBooleanExpression(StringstrDt,StringendDt,EnDateTyschDateType){QUserqUser=QUser.user;if(StringUtils.isBlank(strDt)||StringUtils.isBlank(endDt))returnnull;DateTimeFormatterformatter=DateTimeFormatter.ofPattern("YYYY-MM-dd");LocalDateTimeparseStrDate=LocalDateTime.of(LocalDate.parse(searchVO.getStrDate(),formatter),LocalTime.of(0,0,0));LocalDateTimeparseEndDate=LocalDateTime.of(LocalDate.parse(searchVO.getEndDate(),formatter),LocalTime.of(23,59,59));returnschDateType.equals(EnDateTy.SVC_STR_DT)?qUser.regDt.between(parseStrDate,parseEndDate):qUser.modDt.between(parseStrDate,parseEndDate);}// 검색어privateBooleanExpressionlikeSearchKeyword(Stringkeyword){QUserqUser=QUser.user;QAuthorityqAuthority=QAuthority.authority;QClientComponyqClientCompony=QClientCompony.clientCompony;returnkeyword!=null?qUser.nm.like("%"+searchVO.getSearchKeyword()+"%").or(qAuthority.authNm.like("%"+searchVO.getSearchKeyword()+"%")).or(qClientCompony.coNm.like("%"+searchVO.getSearchKeyword()+"%")):null;}// 등등 ...}
위 예시 코드처럼 BooleanExpression을 활용하여 공통적으로 사용하거나 사용할 법한 메서드들을 만들어 사용하였다.
이렇게 되면 매번 구현 클래스를 만들때 일일이 조건문을 주지 않고 사용할 수 있는 편리한 장점이 있다.
JPA findAll 대신 QueryDSL을 활용한 필요한 컬럼 조회
위의 코드로 인해 코드 량도 좀 더 깔끔하게 줄었다.
수정한 List
// file: "재현 코드 = 수정한 테이블의 리스트 재현 코드.java"@Service@RequiredArgsConstrutorpublicclassExampleService{privatefinalExUserRepositoryexUserRepository;publicList<UserListDTO>getList(SearchVOsearchVO){returnexUserRepository.getList();}}
해당 코드를 통해 좀 더 코드가 간편해진 듯하다.
수정한 List Debugging
수정한 코드를 통해 필요한 13개의 컬럼 데이터만 보내주었다.
기존의 데이터(33개의 컬럼 데이터)와 약 20개의 차이가 난다.
기존 vs 수정한 코드의 실행 시간
1번째 페이지 - 약 61% 개선
기존 List 호출 시간 : 0.018sec
수정 List 호출 시간 : 0.007sec
약 61%의 성능 개선
2번째 페이지 - 약 31%, 81% 개선
기존 List 호출 시간 : 0.077sec
수정 List 호출 시간 : 0.053sec
약 31%의 성능 개선
기존 Detail 호출 시간 : 0.027sec
수정 Detail 호출 시간 : 0.005sec
약 81%의 성능 개선
3번째 페이지 - 약 74%, 39% 개선
기존 List 호출 시간 : 0.027sec
수정 List 호출 시간 : 0.007sec
약 74%의 성능 개선
기존 Detail 호출 시간 : 0.023sec
수정 Detail 호출 시간 : 0.014sec
약 39%의 성능 개선
4번째 페이지 - 약 78%, 93% 개선
기존 List 호출 시간 : 0.033sec
수정 List 호출 시간 : 0.007sec
약 78%의 성능 개선
기존 Detail 호출 시간 : 0.105sec
수정 Detail 호출 시간 : 0.009sec
약 93%의 성능 개선
결론
컴퓨터의 성능에 따라 성능 차이는 좀 달라질 수 있다. 하지만, 기존보다 성능이 좋아진 건 사실이다!.
그리고, DB 컬럼의 용량이나 부하가 증가하게 되면 성능 면에선 더욱 큰 차이가 날 수 있다.