[성능개선] 불필요한 정보 노출로 인한 성능 저하

[성능개선] 불필요한 정보 노출로 인한 성능 저하

#목차

문제

  • jsp 프로젝트에서 vue 프로젝트로 마이그레이션 작업 중 사용되지 않는 컬럼들 까지 요청하여 성능상의 문제 소지를 발견하였다.
  • 총 7건의 요청사항에 문제를 발견하였는데, 모두 불필요한 컬럼들까지 요청하고 있었다.

문제 상황 재현 코드

// file: "재현 코드 = 리스트 가져오는 서비스 재현 코드.java"
@Service
@RequiredArgsConstrutor
public class ExampleService {
    private final ExUser3JoinTableRepository  exUser3JoinTableRepository; 
    
    public List<ExUser3JoinTable> getList(ReqDTO reqDto) {
        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, "등록일자")
        );
        return list;
    }
}
  • Service 로직을 보면 Specification을 이용하여 요청DTO를 통해 검색조건을 만족하는(없으면 Object.isEmptyif문 패스) 3개의 join된 테이블로 list를 조회해한다.
  • 하지만 여기서 문제는 바로 List<ExUser3JoinTable> 부분이다.

문제의 조인 테이블

// file: "재현 코드 = 3개의 조인 테이블 재현 코드.java"
import java.io.Serializable;

@Entity
@Table(name = "고객정보테이블")
public class ExUser3JoinTable extends SuperUser implements Serializable {
    @NotFound(action = NotFoundAction.IGNORE)
    @JoinColumn(name = "authority_sn", insertable = false, update = false, referenceColumnName = "authority_sn")
    @ManyToOne(fetch = FetchType.EAGER)
    public Authority AuthorityInfo;

    @NotFound(action = NotFoundAction.IGNORE)
    @JoinColumn(name = "client_company_sn", insertable = false, update = false, referenceColumnName = "client_company_sn")
    @ManyToOne(fetch = FetchType.EAGER)
    public ClientCompony ClientComponyInfo;
}

클라이언트에 전송되는 과도한 데이터들

원인 테이블

  • 무려 33개의 컬럼의 데이터가 요청되어있던 것이다.
  • 내가 필요한 데이터는 13개의 컬럼 데이터만 필요하다.
  • 무려 20개의 사용되지 않는 컬럼들(물론 조인한 컬럼2개 제외하면 18개이다.)을 DB에서 가져와 Client쪽으로 데이터를 보내주고 있었다.

해결

  • 기존 JPAfindAll키워드를 사용해서 모든 컬럼을 찾아오는 대신에 QueryDSL로 필요한 컬럼만 조인하여 응답 DTO를 만들어 해결하였다.

데이터 조회를 QueryDSL로

데이터 조회를 QueryDSL로 - BooleanBuilder 사용

// file: "재현 코드 = queryDSL - BooleanBuilder를 사용한 예제.java"
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

@Repository
@RequiredArgsConstructor
class QClassUserRepository implements ExUserRepository {
    private final JPAQueryFactory jPAQueryFactory;
    @Override
    public List<UserListDTO> getList(SearchVO searchVO) {
        QUser qUser = QUser.user;
        QAuthority qAuthority = QAuthority.authority;
        QClientCompony qClientCompony = QClientCompony.clientCompony;

        BooleanBuilder builder = new BooleanBuilder();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-dd");
        if (ObjectUtils.isNotEmpty(searchVO.getRegStrDate()) && ObjectUtils.isNotEmpty(searchVO.getRegEndDate())) {
            LocalDateTime parseStrDate = LocalDateTime.of(LocalDate.parse(searchVO.getStrDate(), formatter), LocalTime.of(0, 0, 0));
            LocalDateTime parseEndDate = 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() + "%"));
        }
        // 등등... 
        return jPAQueryFactory
                .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에 구현 클래스가 하나이면 상관없지만 여러 구현 클래스가 추가로 생기면 문제가 발생하기 시작한다.
  • 예를 들어 매번 검색 조건에 날짜가 들어가게 되면 BooleanBuilderDateTimeFormatter을 구현 클래스 안에다가 작성해야 하는 번거로움이 발생하게 된다.
  • 위 방법을 좀 더 편리하게 바꾸려면 BooleanExpression을 사용하여 메서드를 만들어 사용하는 방법으로 리팩토링하면 코드가 훨씬 깔끔해진다.

데이터 조회를 QueryDSL로 - BooleanExpression 사용하여 리팩토링

// file: "재현 코드 = queryDSL - BooleanExpression으로 리팩토링 사용한 예제.java"
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

@Repository
@RequiredArgsConstructor
class QClassUserRepository implements ExUserRepository {
    private final JPAQueryFactory jPAQueryFactory;
    @Override
    public List<UserListDTO> getList(SearchVO searchVO) {
        QUser qUser = QUser.user;
        QAuthority qAuthority = QAuthority.authority;
        QClientCompony qClientCompony = QClientCompony.clientCompony;
        
        return jPAQueryFactory
                .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();
    }
    
    // 검색조건 등록 날짜
    private BooleanExpression betWeenRegDt(String schStrRegDt, String schEndRegDt, EnDateTy schDateType) {
        return getBooleanExpression(schStrRegDt, schEndRegDt, schDateType);
    }
    // 검색조건 수정 날짜
    private BooleanExpression betWeenModDt(String schStrModDt, String schEndModDt, EnDateTy schDateType) {
        return getBooleanExpression(schStrModDt, schEndModDt, schDateType);
    }
    // 날짜 parsing 
    private BooleanExpression getBooleanExpression(String strDt, String endDt, EnDateTy schDateType) {
        QUser qUser = QUser.user;
        if (StringUtils.isBlank(strDt) || StringUtils.isBlank(endDt)) return null;
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-dd");
        LocalDateTime parseStrDate = LocalDateTime.of(LocalDate.parse(searchVO.getStrDate(), formatter), LocalTime.of(0, 0, 0));
        LocalDateTime parseEndDate = LocalDateTime.of(LocalDate.parse(searchVO.getEndDate(), formatter), LocalTime.of(23, 59, 59));
        return schDateType.equals(EnDateTy.SVC_STR_DT) ? qUser.regDt.between(parseStrDate, parseEndDate) : qUser.modDt.between(parseStrDate, parseEndDate);
    }
    // 검색어
    private BooleanExpression likeSearchKeyword(String keyword) {
        QUser qUser = QUser.user;
        QAuthority qAuthority = QAuthority.authority;
        QClientCompony qClientCompony = QClientCompony.clientCompony;
        return keyword != null ? qUser.nm.like("%" + searchVO.getSearchKeyword() + "%")
                .or(qAuthority.authNm.like("%" + searchVO.getSearchKeyword() + "%"))
                .or(qClientCompony.coNm.like("%" + searchVO.getSearchKeyword() + "%")) : null;
    }
    // 등등 ...
}
  • 위 예시 코드처럼 BooleanExpression을 활용하여 공통적으로 사용하거나 사용할 법한 메서드들을 만들어 사용하였다.
  • 이렇게 되면 매번 구현 클래스를 만들때 일일이 조건문을 주지 않고 사용할 수 있는 편리한 장점이 있다.

JPA findAll 대신 QueryDSL을 활용한 필요한 컬럼 조회

QueryDSL

  • 위의 코드로 인해 코드 량도 좀 더 깔끔하게 줄었다.

수정한 List

// file: "재현 코드 = 수정한 테이블의 리스트 재현 코드.java"
@Service
@RequiredArgsConstrutor
public class ExampleService {
    private final ExUserRepository  exUserRepository;

    public List<UserListDTO> getList(SearchVO searchVO) {
        return exUserRepository.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 컬럼의 용량이나 부하가 증가하게 되면 성능 면에선 더욱 큰 차이가 날 수 있다.
  • 위 문제 개선을 통해 전체적으로 약 65%의 성능을 개선하게 되었다.