네로개발일기

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

'전체 글'에 해당되는 글 194건


반응형

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 
도메인에서 사용하는 용어를 코드에 반영하지 않으면
그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
도메인 모델의 Entity나 value에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어, set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면
자연스럽게 cancel이나 changePassword 처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.
- 도메인 주도 개발 시작하기, 최범균-

 

'setter를 지양하라'라는 말을 구체적으로 서술하면

외부에서 필드의 값을 변경하기 위해 접근할 때,

단순하게 setXXX라는 이름의 메서드 하나만 있는 것이 아니라,

필드의 값을 변경하려는 목적을 파악하여

그 목적을 잘 표현하는 메서드를 제공하라.

 

 

예제

'회원' 객체는 회원 이름과 회원 상태 속성을 가지고 있다.

setState 메서드는 회원의 상태를 변경하는 '공개 set 메서드'이다.

public class 회원 {
    
    private String name;
    private String state;
    
    public String getName() {
        return name;
    }
    
    public String getState() {
        return state;
    }
    
    public void setState(String state) {
        this.state = state;
    }
}

회원관리 서비스는 회원의 상태를 조정하여 회원을 차단하거나, vip로 승격한다.

public class 회원관리 {
    
    public void blockMember(회원 회원) {
    
        if (회원.getName().equals("jyjeon")) { // 공통 도메인 규칙
            throw new IllegalArgumentException("닉네임이 jyjeon인 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("vip")) { // 회원 차단 도메인 규칙
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        
        회원.setState("blocked");
    }
    
    public void upgradeMemberToVip(회원 회원) {
        if (회원.getName().equals("jyjeon")) { // 공통 도메인 규칙
            throw new IllegalArgumentException("닉네임이 jyjeon인 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("blocked")) { // vip 상향 도메인 규칙
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        
        회원.setState("vip");
    }
}

회원의 상태(state 필드 값)를 변경하기 위해서는 회원 도메인의 규칙을 지켜야 합니다.

- 특정 닉네임을 가진 회원의 상태는 변경할 수 없다.

- vip는 차단할 수 없다.

- 차단된 회원은 vip가 될 수 없다.

 

공개 set 메서드의 문제점 1) 도메인 로직의 분산

공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고
도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.
도메인 로직이 한 곳에 응집되지 않으므로 
코드를 유지보수할 때에도 분석하고 수정하는 데 더 많은 시간이 필요하다.
- 도메인 주도 개발 시작하기, 최범균 -

"도메인 로직이 응용 영역이나 표현 영역으로 분산된다."의 의미를 알아보자.

 

도메인 모델 패턴에서, 사용자의 요청을 처리하고 사용자에게 정보를 보여주는 표현 영역이라 한다.

MVC 패턴은 http 요청을 받고 응답하는 컨트롤러 클래스들은 표현 영역이라 할 수 있다.

사용자가 요청한 기능을 실행하는 영역을 응용 영역이라 한다. MVC 패턴은 비즈니스 로직을 수행하는 서비스 클래스가 응용 영역이다.

 

특정 도메인을 개념적으로 표현하고, 도메인의 핵심 규칙이 있는 영역을 도메인 영역이라고 한다. 도메인 영역은 도메인 객체 하나를 가리킬 수도 있지만, 연관이 있는 상위 도메인과 하위 도메인들을 한 다발로 묶어 Aggregate(애그리거트)라는 군집을 가리킬 수도 있다.

 

예제에서 '회원의 상태를 변경하기 위해서 회원이 특정한 이름을 가지고 있는지 확인해야 한다.'는 규칙이 있다. 도메인의 속성을 변경하기 위한 핵심 규칙이기 때문에 이 규칙은 도메인 영역에서 구현되어야 한다.

 

하지만, 예제에서는 도메인 규칙을 표현하는 코드가 비즈니스 로직을 수행하는 '회원관리' 클래스에 작성되어 있다. 다시말해, 도메인 로직이 응용 영역으로 분산되어 있다.

 

1. 서비스가 커지면서 회원의 상태를 변경하는 곳이 29억 군데라면? 29억 군데에 규칙을 표현해야 한다.

2. 회원의 상태를 변경하기 위한 규칙이 추가되거나 삭제된다면? 29억 군데의 비즈니스 로직을 전부 수정해야 한다.

 

공개 set 메서드를 사용할 때, 도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지 보수할 때에도 분석하고 수정하는 데 시간이 필요하다. 

 

 

단순히 값을 변경하는 공개 set 메서드가 일으킬 문제에 대해 알아보았다.

그럼 값을 변경하는 것에 더해서 공통 규칙을 set 메서드에 넣어주면 해결이 될까?

// 회원.java
public void setState(String state) {
    if (this.name.equals("jyjeon")) {
        throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
    }
    
    this.state = state;
}
public class 회원관리 {
    
    public void blockMember(회원 회원) {
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        
        회원.setState("blocked"); // 공통 규칙은 set 메서드에 있다.
    }

    public void upgradeMembetToVip(회원 회원) {
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        
        회원.setState("vip"); // 공통 규칙은 set 메서드에 있다.
    }
}

 

하지만, 'vip는 차단할 수 없다.' '차단된 회원은 vip가 될 수 없다.' 라는 규칙도 도메인 규칙이고, 도메인 영역에 있어야 한다. 지금은, 그 책임을 응용 영역에서 해결하고 있다.

 

그렇다면, 특정 조건에 따른 도메인 규칙도 set 메서드를 넣으면 해결이 될까?

public void setState(String state) {
    if (this.name.equals("jyjeon")) {
        throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
    }

    if (state.equals("blocked")) {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
    } // 회원을 차단할 때의 도메인 규칙?

    if (state.equals("vip")) {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
    } // vip로 상향할 때의 도메인 규칙?

    this.state = state;
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.setState("blocked");
    }

    public void upgradeMemberToVip(회원 회원) {
        회원.setState("vip");
    }
}

이런 코드로 해결하는 것은 좋지 않다. 하나의 공간에 너무 많은 책임이 있다.

 

문제점 1 (도메인 로직의 분산)의 해결책

필드의 값을 변경하려는 목적을 제대로 파악하여
그 목적을 잘 표현하는 메서드를 제공해라.
public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }
    
    public String getState() {
        return state;
    }

    public void blockMember() {
        verifyName();
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }

        this.state = "blocked";
    }

    public void upgradeMemberToVip() {
        verifyName();
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }

        this.state = "vip";
    }

    private void verifyName() { // 공통 도메인 규칙
        if (this.name.equals("jyjeon")) {
            throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
        }
    }
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.blockMember(); // 도메인 관련 로직을 도메인 영역에 위임
    }
    
    public void upgradeMemberToVip(회원 회원) {
        회원.upgrageMemberToVip(); // 도메인 관련 로직을 도메인 영역에 위임
    }
}

이 코드가 기존의 문제점을 어떻게 해결해줄까?

1. 공통 도메인 규칙이 응용 영역으로 분산된다.

회원 도메인의 상태를 변경할 때 공통으로 필요한 도메인 규칙을 도메인 영역 안에서 구현하고 지키게함으로써, 도메인 영역 외의 영역들에서는 더이상 공통 도메인 규칙에 대해 신경 쓸 필요가 없다.

헌데, 도메인 영역 안의 메서드라 해도, 이런 식의 코드는 공통 도메인 규칙을 검사하는 것을 깜빡할 수도 있다.

public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }

    public void blockMember() {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }

        setState("blocked");
    }

    public void upgradeMemberToVip() {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }

        setState("vip");
    }

    private void setState(String state) {
        verifyName(); // 공통 도메인 규칙과 필드에 값을 할당하는 로직을 묶어서 메서드로 뺀다.
        this.state = state;
    } 

    private void verifyName() {
        if (this.name.equals("jyjeon")) {
            throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
        }
    }

    public String getState() {
        return state;
    }
}

공통 도메인 규칙과 필드에 값을 할당하는 코드를 setState()라는 메서드로 묶어 추출했다. 필드에 값을 할당하고자 한다면 직접 값을 할당하지 말고 내부적으로 다시 setState()를 호출하도록 만들면, 값 할당과 함께 공통 도메인 규칙을 검사하는 로직이 실행된다.

 

2. 상황별 도메인 규칙이 응용 영역으로 분산된다.

각 상황별로 각각의 공개 메서드를 만들어서 그 안에 도메인 규칙을 구현하고, 그 각각의 메서드를 외부에 제공한다.

응용 영역에서 도메인 영역과 소통할 때, 도메인 모델과 관련된 모든 규칙들은 도메인 모델 안에서 알아서 구현하도록 했기 때문에, 응용 영역은 어떠한 도메인 규칙에 대해서도 신경쓸 필요가 없다. (캡슐화)

 

도메인과 관련된 어떤 처리가 필요할 때는 도메인 영역에서 제공하는 공개 메서드들 중에 목적에 맞는 메서드를 호출하고, 응용영역은 비즈니스 로직에만 집중할 수 있습니다.

 

비즈니스 로직에서 도메인 규칙을 가지고 있음 -> 응용 영역으로 도메인 영역의 책임이 분산된다. -> 응용 영역에 너무 많은 책임을 가지게 된다.

공개 set 메서드의 문제점 2) 잘못 정의한 메시지

Never Trust User (사용자를 믿지 마라)

'객체 지향의 사실과 오해 (조영호)'라는 책에서 객체와 객체 간에 주고 받는 메시지의 역할을 강조한다.

메시지를 수신받은 객체는 우선 자신이 해당하는 메시지를 처리할 수 있는지 확인한다.
메시지를 처리할 수 있다는 이야기는 객체가 해당 메시지에 해당하는 행동을 수행해야 할 책임이 있다는 것을 의미한다.
따라서 근본적으로 메시지의 개념은 책임의 개념과 연결된다.
송신자는 메시지 전송을 통해서만 다른 객체의 책임을 요청할 수 있고, 수신자는 오직 메시지 수신을 통해서만 자신의 책임을 수행할 수 있다.
따라서 객체가 수신할 수 있는 메시지의 모양이 객체가 수행할 책임의 모양을 결정한다.
- 객체지향의 사실과 오해, 조영호 -

객체가 객체에게 어떤 행동을 지시하는 유일한 방법은 메시지를 주고받는 것이다.

set메서드를 가지고 있는데 메시지 상으로 'XXX를 바꾼다'라는 메시지를 받아 수행할 수 있다.

하지만, 실제로는 그 메서드를 실행했을 때 문제가 생길 수 있음에도 실행 권한을 주어 버렸으므로 문제가 발생하는 것이다.

 

문제점 2 (잘못 정의한 메시지) 해결책

도메인 모델의 entity나 value에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어, set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면
자연스럽게 cancel이나 changePassword처럼 의미가 더 잘 드러나는 이름을 사용할 빈도가 높아진다.
public class Post {

    private Long id;
    private String title;
    private String content;
    private Member writer;
}

1. id는 유일한 식별자이다. 통상적으로 식별자 값은 데이터의 무결성과 연관관계 등 고려해야 할 제약조건이 많아 수정하지 않는 것을 권장한다.

2. title을 바꾸기 위해서는 새 title이 '영어 대/소문자, 숫자, 특수문자를 포함하여 10자 이상'을 만족하는지 검사한다.

검사를 마친 후, title를 바꾸도록 메서드를 구현하고 setTitle() 보단 changeTitle()로 실행의도를 분명히 들어낸다.

3. writer의 값을 바꾸는 것은 금지되어 있다. 관련 메서드는 제공하지 않는다.

 

public class Post {
    
    private Long id;
    private String title;
    private String content;
    private Member writer;
    
    public changeTitle(String newTitle) {
        checkTtile(newTitle);
        this.title = newTitle;
    }
    
    private void checkTitle(String title) {
        Pattern titlePattern = Pattern.compile("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&])[A-Za-z[0-9]$@$!%*#?&]{10,}$");
        Matcher matcher = titlePattern.matcher(title);

        if (!matcher.find()) {
            throw new IllegalArgumentException("title의 작성 양식에 맞지 않습니다.");
        }
    }
}

 

 출처  setter 쓰지 말라고만 하고 가버리면 어떡해요 - 여우

https://velog.io/@backfox/setter-%EC%93%B0%EC%A7%80-%EB%A7%90%EB%9D%BC%EA%B3%A0%EB%A7%8C-%ED%95%98%EA%B3%A0-%EA%B0%80%EB%B2%84%EB%A6%AC%EB%A9%B4-%EC%96%B4%EB%96%A1%ED%95%B4%EC%9A%94

 

setter 쓰지 말라고만 하고 가버리면 어떡해요

부트캠프 과정을 잘 견뎌내고 팀프로젝트에서 백엔드를 맡은 엄준식(27)씨.백엔드 커리큘럼을 유난히 즐거워했던 준식씨였기에 자신이 맡은 파트가 꽤 마음에 드는 모양이다.준식씨: 됐다! 게시

velog.io

 

728x90
반응형
blog image

Written by ner.o

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

반응형

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

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