네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

'2022/07'에 해당되는 글 10건


반응형

3-2. 첫 페이지 조회 결과 캐시하기

처음 검색 시 조회된 count 결과를 응답 결과로 내려주어 JS에서 이를 캐싱하고 매 페이징 버튼마다 count 결과를 함께 내려주는 것이다.

Repository에서는 요청에 넘어온 항목 중 캐싱된 count 값이 있으면 이를 재사용하고, 없으면 count 쿼리를 수행한다.

 

- 첫 1페이지 요청 시 count 쿼리를 날려서 totalCount를 가져온다. 

- 2페이지 이상 요청 시 JS에서 캐싱한 totalCount를 같이 보내서 count 쿼리를 날리지 않고 기존의 totalCount를 같이 보내서 count 쿼리를 날리지 않는다.

 

이 방식은 다음과 같은 상황에서 도움이 된다.

- 조회 요청이 검색 버튼과 페이지 버튼 모두에서 골고루 발생하고

- 실시간으로 데이터가 적재되지 않고, 마감된 데이터를 사용할 경우

 

물론 JS에서 캐싱하고 있기 때문에 브라우저를 새로고침하게 되면 count는 다시 초기화가 되어 첫 조회시 다시 쿼리가 수행되게 된다.

 

3-2-1. 구현 코드

기존 페이징 쿼리는 동일하다.

public Page<BookPaginationDto> paginationCount(Pageable pageable, String name) {
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
            queryFactory
                    .select(Projections.fields(BookPaginationDto.class,
                            book.id.as("bookId"),
                            book.name,
                            book.bookNo,
                            book.bookType
                    ))
                    .from(book)
                    .where(
                            book.name.like(name + "%")
                    )
                    .orderBy(book.id.desc()));

    List<BookPaginationDto> items = query.fetch(); // 데이터 조회
    long totalCount = query.fetchCount(); // 전체 count
    return new PageImpl<>(items, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

검색 / 페이징 버튼 클릭시 cache 된 count를 사용하도록 개선하기 위해서는 다음의 코드가 추가되어야 한다.

- 프론트 영역에서 넘겨준 count 값이 요청 필드에 포함시킨다.

- Repository에서는 해당 count 값이 있을 경우엔 그대로 페이징 결과에 포함시키고, 없으면 실제 count 쿼리를 실행한다.

 

public Page<BookPaginationDto> paginationCountCache(Long cachedCount, Pageable pageable, String name) {
    
    // cacheCount는 프론트 영역에서 넘겨준 count 값이다.
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
            queryFactory
                    .select(Projections.fields(BookPaginationDto.class,
                            book.id.as("bookId"),
                            book.name,
                            book.bookNo,
                            book.bookType
                    ))
                    .from(book)
                    .where(
                            book.name.like(name + "%")
                    )
                    .orderBy(book.id.desc()));

    List<BookPaginationDto> elements = query.fetch(); // 데이터 조회
    long totalCount = cachedCount != null ? cachedCount : query.fetchCount(); // 전체 count: cacheCount가 없으면 실제 count 쿼리를 수행시킨다.
    return new PageImpl<>(elements, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

3-2-2. 결론

한번 조회된 동일 조건의 count에서는 클라이언트 영역에서 저장 후 요청시마다 재사용하는 방식을 사용하면 추가 쿼리 요청이 최소화된다.

단점은

- 첫번째 페이지 조회가 대부분일 경우 효과가 없다.

- 실시간으로 데이터 수정이 필요해서 페이지 버튼 반영이 필요한 경우 사용할 수 없다.

결국 새로고침(또는 버튼 클릭을 통한 페이지 이동)을 하기 전까지 실시간성이 떨어진다.

 

 

 출처 

https://jojoldu.tistory.com/531

 

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

 

페이징 기능을 구현하는데 있어, 페이징 쿼리 자체를 개선하는 방법도 있지만, 그 외 다른 기능을 개선하는 방법도 함께 할 수 있다.

 

여기서 말하는 기능은 count 쿼리이다.

일반적인 페이징 기능에 있어 데이터 조회와 함께 매번 수행되는 것이 count 쿼리이다. 해당 조건으로 조회되는 총 건수를 알아야만 아래와 같이 pageNo들을 노출시킬 수 있다.

총 몇건인지 확인하기 위해 전체를 확인해야 하기 때문에 데이터 조회만큼 오래 걸린다.

 

이 문제를 개선할 수 있는 방법은 크게 2가지가 있다.

1. 검색 버튼 사용시 페이지 건수 고정하기

2. 첫 페이지 조회 결과 cache 하기

 

3-1. 검색 버튼 사용시 페이지 건수 고정하기

굳이 사용율이 떨어지는 페이지 버튼을 위해 매번 전체 count 쿼리가 수행될 필요가 있을까를 한번 고민해야한다.

 

즉, 다음과 같은 상황에서 이 방법을 고려하는 것이 좋다.

- 대부분의 조회 요청이 검색 버튼(즉, 첫 조회)에서 발생하고

- 페이지 버튼을 통한 조회 요청이 소수일 경우

이럴 경우 검색 버튼을 클릭한 경우에만 Page 수를 고정하는 것이다.

 

즉, 다음 페이지로 이동하기 위해 페이지 버튼을 클릭했을 때만 실제 페이지 count 쿼리를 발생시켜 정확한 페이지 수를 사용하고, 대부분 요청이 발생하는 검색 버튼 클릭시에는 count 쿼리를 발생시키지 않는 것이다.

3-1-1. 구현 코드

아래는 기존 페이징 쿼리다.

public Page<BookPaginationDto> paginationCount(Pageable pageable, String name) {
    
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
                                queryFactory
                                    .select(Projections.fields(BookPaginationDto.class,
                                                                book.id.as("bookId"),
                                                                book.name,
                                                                book.bookNo,
                                                                book.bookType))
                                    .from(book)
                                    .where(book.name.like(name + "%"))
                                    .orderBy(book.id.desc());
                                    
    List<BookPaginationDto> items = query.fetch();
    long totalCount = query.fetchCount(); // 전체 count
    return new PageImpl<>(items, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

이 코드를 검색 버튼 클릭시에는 10개 페이지를 고정으로 노출하도록 개성하기 위해서는 다음의 코드가 추가되어야 한다.

1. 검색 버튼을 클릭한 경우(useSearchBtn) 10개 페이지가 노출되도록 TotalCount (fixedPageCount)를 반환한다.

2. 페이지 버튼을 클릭한 경우 실제 쿼리를 수행해 결과를 반환한다.

3. 페이지 버튼을 클릭하였지만, 전체 건수를 초과한 페이지 번호로 요청이 온 경우에는 마지막 페이지 결과를 반환한다.

 

이를 적용한 코드는 다음과 같다.

public Page<BookPaginationDto> paginationCountSearchBtn(boolean useSearchBtn, Pageable pageable, String name) {

    JPAQuery<BookPaginationDto> query = queryFactory
                                        .select(Projections.fields(BookPaginationDto.class,
                                                book.id.as("bookId"),
                                                book.name,
                                                book.bookNo,
                                                book.bookType))
                                        .from(book)
                                        .where(book.name.like(name + "%"))
                                        .orderBy(book.id.desc())
    
    JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);
    
    if (useSearchBtn) { // 검색 버튼 사용시
        int fixedPageCount = 10 * pageable.getPageSize();
        return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
    }
    
    long totalCount = pagination.fetchCount();
    Pageable pageRequest = exchangePageRequest(pageable, totalCount); // 데이터 건수 초과시 보정
    return new PageImpl<>(pagination.fetch(), pageRequest, totalCount);
}

private Pageable exchangePageRequest(Pageable pageable, long totalCount) {

    int pageNo = pageable.getPageNumber();
    int pageSize = pageable.getPageSize();
    long requestCount = (pageNo - 1) * pageSize;
    
    if (totalCount > requestCount) {
        return pageable;
    }
    
    int requestPageNo = (int) Math.ceil((double)totalCount / pageNo);
    return PageRequest.of(requestPageNo, pageSize);
}

객체 지향적으로 분리하기 위해 별도의 Dto 클래스로 추출할 수 있다.

// DTO
public class FixedPageRequest extends PageRequest {

    protected FixedPageRequest(Pageable pageable, long totalCount) {
        super(getPageNo(pageable, totalCount), pageable.getPageSize(), pageable.getSort());
    }

    private static int getPageNo(Pageable pageable, long totalCount) {
        int pageNo = pageable.getPageNumber();
        int pageSize = pageable.getPageSize();
        long requestCount = pageNo * pageSize; // pageNo:10, pageSize:10 일 경우 requestCount=90

        if (totalCount > requestCount) { // 실제 건수가 요청한 페이지 번호보다 높을 경우
            return pageNo;
        }

        return (int) Math.ceil((double)totalCount/pageNo); // 실제 건수가 부족한 경우 요청 페이지 번호를 가장 높은 번호로 교체
    }
}

// repository
public Page<BookPaginationDto> paginationCountSearchBtn2(boolean useSearchBtn, Pageable pageable, String name) {
    JPAQuery<BookPaginationDto> query = queryFactory
            .select(Projections.fields(BookPaginationDto.class,
                    book.id.as("bookId"),
                    book.name,
                    book.bookNo,
                    book.bookType
            ))
            .from(book)
            .where(
                    book.name.like(name + "%")
            )
            .orderBy(book.id.desc());

    JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);

    if(useSearchBtn) {
        int fixedPageCount = 10 * pageable.getPageSize(); // 10개 페이지 고정
        return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
    }

    long totalCount = pagination.fetchCount();
    Pageable pageRequest = new FixedPageRequest(pageable, totalCount);
    return new PageImpl<>(querydsl().applyPagination(pageRequest, query).fetch(), pageRequest, totalCount);
}

 

 출처 

https://jojoldu.tistory.com/530 

 

3-1. 페이징 성능 개선하기 - 검색 버튼 사용시 페이지 건수 고정하기

모든 코드는 Github에 있습니다. 앞서 포스팅에서 실질 페이징 쿼리 성능을 올리는 방법들을 소개 드렸는데요. 1. 페이징 성능 개선하기 - No Offset 사용하기 2. 페이징 성능 개선하기 - 커버링 인덱

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

2. 커버링 인덱스 사용하기

No Offset 페이징을 사용할 수 없는 상황이라면 커버링 인덱스로 성능을 개선할 수 있다.

커버링 인덱스란, 쿼리를 충족시키는데 필요한 모든 데이터를 갖고 있는 인덱스를 말한다.

즉, select, where, order by, limit, group by 등에서 사용되는 모든 컬럼이 Index 컬럼 안에 다 포함되는 경우다.

 

select 절까지 포함하게 되면 너무 많은 컬럼이 인덱스에 포함되기 때문에 실제론 커버링 인덱스는 select 를 제외한 나머지만 우선적으로 수행한다.

SELECT *
FROM items
WHERE [조건문]
ORDER BY id DESC
OFFSET [페이지 번호]
LIMIT [페이지 사이즈]

위와 같은 페이징 쿼리를 아래처럼 처리한 코드를 얘기한다.

SELECT *
FROM items as i 
JOIN (SELECT id
        FROM items
        WHERE [조건문]
        ORDER BY id DESC
        OFFSET [페이지 번호]
        LIMIT [페이지 사이즈]) as temp ON temp.id = i.id

위 쿼리에서 커버링 인덱스가 사용된 부분이 JOIN에 있는 쿼리이다.

SELECT id
FROM items
WHERE [조건문]
ORDER BY id DESC
OFFSET [페이지 번호]
LIMIT [페이지 사이즈]

select 절을 비롯해 order by, where 등 내 모든 항목이 인덱스 컬럼으로만 이루어지게 하여 인덱스 내부에서 쿼리가 완성될 수 있도록 하는 방식이다.

이렇게 커버링 인덱스로 빠르게 걸러낸 id를 통해 실제 select 절의 항목들을 빠르게 조회하는 방식이다.

2-1. 커버링 인덱스 사용하기

일반적으로 인덱스를 이용해 조회되는 쿼리에서 가장 큰 성능 저하를 일으키는 부분은 인덱스를 검색하고 대상이 되는 row의 나머지 컬럼 값을 데이터 블록에서 읽을 때이다.

- 페이징 쿼리와 무관하게 인덱스를 추가해도 느린 쿼리는 select절 때문이다.

커버링 인덱스를 태우지 않은 일반 조회 쿼리는 order by, offset ~ limit 을 수행할 때도 데이터 블록으로 접근을 하게 된다.

반대로 커버링 인덱스 방식을 사용하면 where, order by, offset ~ limit 을 인덱스 검색으로 빠르게 처리하고, 이미 다 걸러진 몇 개의 row에 대해서만 데이터 블록에 접근하기 때문에 성능의 이점을 얻게 된다.

 

앞서 1. No Offset을 사용한 페이징 성능 개선에서 사용된 페이징 쿼리는 아래와 같다.

SELECT id, book_no, book_type, name
FROM book
WEHRE name LIKE 'a%'
ORDER BY id DESC
LIMIT 10 OFFSET 10000;

SELECT에서 사용된 book_no, book_type은 인덱스 (idx_book_1(name))에 포함하지 않기 때문에 커버링 인덱스가 될 수 없다.

2-2. 구현 코드

 Querydsl 

Querydsl에서 커버링 인덱스를 사용해야 한다면 2개의 쿼리로 분리해서 진행할 수 밖에 없다.

(이유는 Querydsl에서 from 절의 서브 쿼리를 지원하지 않기 때문이다.)

- 커버링 인덱스를 활용해 조회 대상의 PK를 조회

- 해당 PK로 필요한 컬럼 항목들 조회

pubblic List<BookPaginationDto> paginationCoveringIndex(String name, int pageNo, int pageSize) {

    // 1. 커버링 인덱스로 대상 조회 (id만 포함하여 커버링 인덱스를 활용해 빠르게 조회)
    List<Long> ids = queryFactory
                        .select(book.id)
                        .from(book)
                        .where(book.name.like(name + "%"))
                        .orderBy(book.id.desc())
                        .limit(pageSize)
                        .offset(pageNo * pageSize)
                        .fetch();
    
    // 1-1. 대상이 없을 경우 추가 쿼리 수행할 필요 없이 바로 변환
    if (CollectionUtils.isEmpty(ids)) {
        return new ArrayList<>();
    }
    
    // 2. 
    return queryFactory
             .select(Projections.fields(BookPaginationDto.class,
                     book.id.as("bookId"),
                     book.name,
                     book.bookNo,
                     book.bookType))
             .from(book)
             .where(book.id.in(ids))
             .orderBy(book.id.desc()) // where in id 만 있어 결과 정렬이 보장되지 않는다.
             .fetch();
}

JdbcTemplate

문자열 쿼리를 직접 사용하는 방식인데, 이렇게 진행하면 querydsl를 쓸 때처럼 쿼리를 분리할 필요 없이, 커버링 인덱스를 from 절에 그대로 사용하면 된다.

public List<BookPaginationDto> paginationCoveringIndexSql(String name, int pageNo, int pageSize) {
String query =
        "SELECT i.id, book_no, book_type, name " +
        "FROM book as i " +
        "JOIN (SELECT id " +
        "       FROM book " +
        "       WHERE name LIKE '?%' " +
        "       ORDER BY id DESC " +
        "       LIMIT ? " +
        "       OFFSET ?) as temp on temp.id = i.id";

return jdbcTemplate
        .query(query, new BeanPropertyRowMapper<>(BookPaginationDto.class),
                name,
                pageSize,
                pageNo * pageSize);
}

2-3. 단점

커버링 인덱스 방식은 일반적인 페이징 방식에서는 거의 대부분 적용할 수 있는 효과적인 개선 방법이지만 단점이 존재한다.

 

- 너무 많은 인덱스가 필요하다.

결국 쿼리의 모든 항목이 인덱스에 포함되어야 하기 때문에 느린 쿼리가 발생할 때마다 인덱스가 신규 생성될 수도 있다.

- 인덱스 크기가 너무 커진다.

인덱스도 결국 데이터기 때문에 너무 많은 항목이 들어가면 성능 이슈가 발생할 수 있다. 

- 데이터 양이 많아지고, 페이지 번호가 뒤로 갈 수록 No Offset에 비해 느리다.

 

 

 

 출처 

https://jojoldu.tistory.com/529 

 

2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기

2. 커버링 인덱스 사용하기 앞서 1번글 처럼 No Offset 방식으로 개선할 수 있다면 정말 좋겠지만, NoOffset 페이징을 사용할 수 없는 상황이라면 커버링 인덱스로 성능을 개선할 수 있습니다. 커버링

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

 

페이징은 웹에서 흔히 볼 수 있는 기능이다. 하지만, 기초적인 페이징 구현 방식은 서비스가 커지면서 큰 장애를 유발할 수 있다. 적재된 데이터가 많아지면서 페이징 기능이 수십초~ 수분까지 조회가 느려질 수 있다.

 

특히 1억건이 넘는 테이블에서의 페이징은 단순히 인덱스를 추가한다고 해서 성능 문제가 해결되진 않는다.

 

그래서 일반적인 페이징 기능에서 성능을 개선하는 방법을 알아보자.

물론, 인덱스를 이용한 쿼리 튜닝이 되어있다는 가정이고 조회 쿼리에서 인덱스 사용은 필수이다.

 

 [ 참고 ] 효율적인 데이터베이스 인덱스 설정 방법 

https://frogand.tistory.com/144

 

[SQL] 효율적인 DB INDEX(인덱스) 설정

인덱스(INDEX) 검색 속도를 높이기 위한 색인 기술이다. 보통 인덱스는 일반적으로 SELECT 쿼리의 WHERE에 사용할 컬럼에 대해 효율적인 검색을 위해 사용하거나, 다른 테이블과의 JOIN에 사용된다. (

frogand.tistory.com

 

1. No Offset으로 구조 변경하기

기존 페이징에서 No Offset으로 구조를 변경하는 것이다.

기존 페이징 방식은 페이지 번호(offset)와 페이지 사이즈(limit)를 기반으로 한다.

반면에 No Offset은 아래와 같이 페이지 번호(offset)이 없는 더보기(More) 방식을 이야기한다.

 

 

1-1. No Offset은 왜 빠른가?

기존에 사용하는 페이징 쿼리는 일반적으로 다음과 같다.

SELECT * 
FROM items
WHERE [조건]
ORDER BY id DESC
OFFSET [페이지번호]
LIMIT [페이지사이즈]

이와같은 형태의 페이징 쿼리가 뒤로 갈수록 느린 이유는 결국 앞에서 읽었던 행을 다시 읽어야 하기 때문입니다.

예를 들어 offset 10,000이고 limit 20이라 하면 최종적으로 10,020개의 행을 읽어야 합니다.

그리고 이 중의 얖 10,000개 행은 버리게 됩니다.

뒤로 갈 수록 버리지만 읽어야 할 행의 개수가 많아 점점 뒤로 갈수록 느려집니다.

 

 

No Offset 방식은 바로 이 부분에서 조회 시작 부분을 인덱스로 빠르게 찾아 매번 첫 페이지만 읽도록 하는 방식입니다.

(클러스터 인덱스인 PK를 조회 시작 부분 조건문으로 사용했기 때문에 빠르게 조회됩니다.)

SELECT *
FROM items
WHERE [조건문] 
AND id < [마지막 조회 ID] # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT [페이지 사이즈]

이전에 조회된 결과를 한번에 건너뛸 수 있게 마지막 조회 결과의 ID를 조건문에 사용하는 것으로 매번 이전 페이지 전체를 건너 뛴다.

즉, 아무리 페이지가 뒤로 가더라도 처음 페이지를 읽은 것과 동일한 성능을 가지게 된다.

 

1-2. 구현 코드

기존의 페이징 코드 (사용되는 book 엔티티의 인덱스는 idx_boox_1 (name) 이다.)

public List<BookPaginationDto> paginationLegacy(String name, int pageNo, int pageSize) {

    return queryFactory
            .select(Projections.fields(BookPaginationDto.class,
                    book.id.as("bookId"),
                    book.name,
                    book.bookNo))
            .from(book)
            .where(book.name.like(name + "%")) // like 뒤에는 %가 있을 때만 인덱스가 적용된다.
            .orderBy(book.id.desc()) // 최신순으로
            .limit(pageSize) // 지정된 사이즈만큼
            .offset(pageNo * pageSize) // 지정된 페이지 위치에서
            .fetch(); // 조회
}

offset은 pageNo가 사용되는 것이 아니라 몇번째 row부터 시작할지를 나타낸다.

pageNo는 0부터 시작한다고 가정한 상태이다. 1부터 시작할 경우 (pageNo - 1) * pageSize를 사용해야 한다.

 

No Offset 코드 1

public List<BookPaginationDto> paginationNoOffsetBuilder(Long bookId, String name, int pageSize) {

    BooleanBuilder dynamicLtId = new BooleanBuilder();
    
    if (bookId != null) {
        dynamicLtId.and(book.id.lt(bookId));
    }
    
    return queryFactory
        .select(Projections.fields(BookPaginationDto.class,
                book.id.as("bookId"),
                book.name,
                book.bookNo))
        .from(book)
        .where(dynamicLtId
                .and(book.name.like(name + "%")))
        .orderBy(book.id.desc())
        .limit(pageSize)
        .fetch();
}

첫 페이지 조회할 때와 두번째 페이지부터 조회할 때 사용되는 쿼리가 달라 동적 쿼리가 필요하다.

그래서 맨 위와 같은 코드가 사용되었다. (BooleanBuilder 사용) 

 

No Offset 코드 2

public List<BookPaginationDto> paginationNoOffsetBuilder(Long bookId, String name, int pageSize) {
    return queryFactory
        .select(Projections.fields(BookPaginationDto.class,
                book.id.as("bookId"),
                book.name,
                book.bookNo))
        .from(book)
        .where(ltBookId(bookId), book.name.like(name + "%"))
        .orderBy(book.id.desc())
        .limit(pageSize)
        .fetch();
}

private BooleanExpression ltBookId(Long bookId) {
    if (bookId == null) {
        return null; // BooleanExpression 자리에 null이 반환되면 조건문에서 자동으로 삭제된다.
    }
    return book.id.lt(bookId);
}

 

1-3. 테스트 코드로 기능 검증

기존 페이징 기능의 테스트이다.

@Test
void 기존_페이징_방식() throws Exception {
    // given
    String prefixName = "a";
    for (int i = 0; i <= 30; i++) {
        Book book = Book.builder()
                    .name(prefixName + i) 
                    .bookNo(i)
                    .build();
        bookRepository.save(book);
    }
    
    // when
    // 두번째 페이지 조회 (pageNo는 0부터 시작임)
    List<BookPaginationDto> books = bookPaginationRepository.paginationLegacy(prefix, 1, 10);
    
    // then
    assertThat(books).hasSize(10);
    assertThat(books.get(0).getName()).isEqualsTo("a20");
    assertThat(books.get(9).getName()).isEqualsTo("a11");
}

 

NoOffset 테스트

@Test
void nooffset_첫페이지() throws Exception {
    // given
    String prefixName = "a";
    for (int i = 0; i <= 30; i++) {
        Book book = Book.builder()
                    .name(prefixName + i) 
                    .bookNo(i)
                    .build();
        bookRepository.save(book);
    }
    
    // when
    List<BookPaginationDto> books = bookPaginationRepository.paginationNoOffset(null, prefixName, 10);
    
    // then
    assertThat(books).hasSize(10);
    assertThat(books.get(0).getName()).isEqualsTo("a30");
    assertThat(books.get(9).getName()).isEqualsTo("a21");
}

@Test
void nooffset_두번째페이지() throws Exception {
    // given
    String prefixName = "a";
    for (int i = 0; i <= 30; i++) {
        Book book = Book.builder()
                    .name(prefixName + i) 
                    .bookNo(i)
                    .build();
        bookRepository.save(book);
    }
    
    // when
    List<BookPaginationDto> books = bookPaginationRepository.paginationNoOffset(21L, prefixName, 10);
    
    // then
    assertThat(books).hasSize(10);
    assertThat(books.get(0).getName()).isEqualsTo("a20");
    assertThat(books.get(9).getName()).isEqualsTo("a11");
}

 

1-4. 성능 비교

offset 위치 / 인덱스 유무 / DB 성능에 따라 테스트 결과가 다를 수 있지만 1억 row, 5개의 컬럼의 경우 수백배의 성능 차이가 난다.

 

1-5. 단점

- where에 사용되는 기준 Key가 중복이 가능할 경우

group by 등으로 기준을 잡을 key가 중복이 될 경우 정확한 결과를 반환할 수 없다.

- More 버튼이 아니라 페이징 버튼 형식으로 해야할 경우

 

 출처 

https://jojoldu.tistory.com/528

 

1. 페이징 성능 개선하기 - No Offset 사용하기

일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

[Spring] Assert


반응형

Assert는 단순히 if문을 줄이는 역할만 하는 것이 아니다. 프로젝트 규칙을 적용하고 공통을 재사용한다는 것에 큰 의미가 있다. 

 

Spring Assert를 사용하는 목적

Spring Assert는 인수를 검증하고 조건에 맞지 않는 경우 IllegalArgumentException 또는 IllegalStateException를 발생시킨다. 

이 부분은 조건문을 단순화하고 반복적인 코드를 줄이는 역할을 한다.

if (user == null) {
    throw new IllegalArgumentException("사용자 정보가 존재하지 않습니다.");
}
// 위 코드는 아래와 같이 바꿀 수 있다.
Assert.notEmpty(user, "사용자 정보가 존재하지 않습니다.");

 

Assert의 확장

값을 검증하는 방법을 여러 형태로 만들어 낼 수 있다. 또한 exception도 다양하게 사용할 수 있다. 그런데 프로젝트를 하다보면 값을 검증하는 방법을 통합하거나 예외의 사용을 제한할 필요가 있다. 

 

public class JiyoonAssert extends Assert {
 
    private static final String NUMBER_ALPHABET_PATTERN = "^[a-zA-Z\\d]+$";
    private static final String NUMERIC_PATTERN = "^[\\+\\-]{0,1}[\\d]+$";
    private static final String FLOAT_PATTERN = "^[\\+\\-]{0,1}[\\d]+[\\.][0-9]+$";
 
    /**
     * Assert a boolean expression, TRUE가 아닌 경우 사용자가 정의한 예외를 던진다.
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.state(id == null, "The id property must not already be initialized", EbloInvalidRequestException.class);</pre>
     * @param expression a boolean expression
     * @param message the exception message to use if the assertion fails
     * @param exceptionClass
     * @throws IllegalStateException if {@code expression} is {@code false}
     */
    public static void state(boolean expression, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (!expression) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * Assert a boolean expression, TRUE가 아닌 경우 사용자가 정의한 예외를 던진다.
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.isTrue(user != null, "사용자 정보가 존재하지 않습니다.", EbloNotFoundException.class);</pre>
     * @param expression a boolean expression
     * @param message the exception message to use if the assertion fails
     * @param exceptionClass
     * @throws IllegalStateException if {@code expression} is {@code false}
     */
    public static void isTrue(boolean expression, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (!expression) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * Assert that an object is {@code null}. 객체가 null이 아닌 경우 사용자가 정의한 예외를 던진다.
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.isNull(user, "기존 사용자 정보가 존재합니다.", EbloInvalidRequestException.class);</pre>
     * @param expression a boolean expression
     * @param message the exception message to use if the assertion fails
     * @param exceptionClass
     * @throws IllegalStateException if {@code expression} is {@code false}
     */
    public static void isNull(@Nullable Object object, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (object != null) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * null인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.notNull(user, "사용자 정보가 존재하지 않습니다.", EbloNotFoundException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void notNull(@Nullable Object object, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (object == null) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 전달받은 값이 null이 거나 빈값인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.hasLength(value, "전달 받은 값이 빈값입니다.", EbloInvalidRequestException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void hasLength(@Nullable String text, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (!StringUtils.hasLength(text)) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 전달받은 값이 null이 거나 빈값인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.hasText(value, "전달 받은 값이 빈값입니다.", EbloInvalidRequestException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void hasText(@Nullable String text, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (!StringUtils.hasText(text)) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 전달받은 값이 null이 거나 빈값인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.notEmpty(array, "전달 받은 값이 빈값입니다.", EbloInvalidRequestException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void notEmpty(@Nullable Object[] array, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (ObjectUtils.isEmpty(array)) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 전달받은 값이 null이 거나 빈값인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.notEmpty(collection, "전달 받은 값이 빈값입니다.", EbloInvalidRequestException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void notEmpty(@Nullable Collection<?> collection, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (CollectionUtils.isEmpty(collection)) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 전달받은 값이 null이 거나 빈값인 경우 사용자 정의 예외 발생
     * 사용자 정의 예외는 AbstractEbloBaseException.class를 상속 받아 구현해야 한다. 
     * <pre class="code">Assert.notEmpty(map, "전달 받은 값이 빈값입니다.", EbloInvalidRequestException.class);</pre>
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void notEmpty(@Nullable Map<?, ?> map, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if (CollectionUtils.isEmpty(map)) {
            throwException(message, exceptionClass);
        }
    }
 
    private static void throwException(String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        try {
            throw exceptionClass.getDeclaredConstructor( String.class ).newInstance( message );
        } catch (Exception e) {
            e.printStackTrace();
            throw new EbloSystemException("예외 처리 중 오류가 발생했습니다. "+e.getMessage());
        }
    }
    
    /**
     * 값이 영문 알파벳, 숫자가 아닌 경우 예외 발생 
     * @param object
     * @param message
     */
    public static void isAlphaNumber(String object, String message) {
        isMatched(object, NUMBER_ALPHABET_PATTERN, message, EbloInvalidRequestException.class);
    }
 
    /**
     * 값이 영문 알파벳, 숫자가 아닌 경우 사용자 정의 예외 발생 
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void isAlphaNumber(String object, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        isMatched(object, NUMBER_ALPHABET_PATTERN, message, exceptionClass);
    }
    
    /**
     * 값이 숫자가 아닌 경우 예외 발생 
     * @param object
     * @param message
     */
    public static void isNumeric(String object, String message) {
        isMatched(object, NUMERIC_PATTERN, message, EbloInvalidRequestException.class);
    }
    
    /**
     * 값이 숫자가 아닌 경우 사용자 정의 예외 발생 
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void isNumeric(String object, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        isMatched(object, NUMERIC_PATTERN, message, exceptionClass);
    }
    
    /**
     * 값이 float, double이 아닌 경우 예외 발생 
     * @param object
     * @param message
     */
    public static void isFloat(String object, String message) {
        isMatched(object, FLOAT_PATTERN, message, EbloInvalidRequestException.class);
    }
    
    /**
     * 값이 float, double이 아닌 경우 사용자 정의 예외 발생 
     * @param object
     * @param message
     * @param exceptionClass
     */
    public static void isFloat(String object, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        isMatched(object, FLOAT_PATTERN, message, exceptionClass);
    }
    
    /**
     * 패턴 매치, 해당 패턴과 일치 하지 않는 경우 사용자 정의 예외 발생 
     * @param object
     * @param pattern
     * @param message
     * @param exceptionClass
     */
    public static void isMatched(String object, String pattern, String message, final Class<? extends AbstractEbloBaseException> exceptionClass) {
        if(object == null || "".equalsIgnoreCase(object)) return;
        
        if(!object.matches(pattern)) {
            throwException(message, exceptionClass);
        }
    }
 
    /**
     * 패턴 매치, 해당 패턴과 일치 하지 않는 경우 예외 발생 
     * @param object
     * @param pattern
     * @param message
     */
    public static void isMatched(String object, String pattern, String message) {
        if(object == null || "".equalsIgnoreCase(object)) return;
        
        if(!object.matches(pattern)) {
            throwException(message, EbloInvalidRequestException.class);
        }
    }
    
}
@Test
public void test() {
    JiyoonAssert.isTrue( 1 > 0, "test");
}

 

 

 

 

 출처 

https://eblo.tistory.com/63

 

Spring boot - Assert 사용 예제

1. Overview Assert는 단순히 if문을 줄이는 역할만 하는 것은 아닙니다. 프로젝트 규칙을 적용하고 공통을 재사용한다는 것에 큰 의미가 있습니다. 복잡한 기술이 필요한 것도 아니고 누구나 쉽게

eblo.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !