
주요 인터페이스와 클래스
Pageable
페이지 번호와 페이지 크기, 정렬 정보를 담는 인터페이스
PageRequest를 구현체로 사용한다.
주요 메서드, 필드
content: 현재 페이지에 포함된 엔티티 리스트
pageable: page, size, sort 정보를 포함한 객체
total: 전체 요소 수
getContent(): 조회된 엔티티 리스트 반환
getTotalElements(): total값 반환
getTotalPages(): totalElements/pageSize 반환
hasNext() ,hasPrevious(): 현재 페이지와 전체 페이지 수를 비교해 판단
Page
slice
사용 방법
api 요청 예시
GET /api/users?status=ACTIVE&usernameLike=kim&page=1&size=15&
요청 dto
@Getter @Setter
public class UserSearchDto {
private String status;
private String usernameLike;
}
컨트롤러 로직
@GetMapping
public Page<UserDto> searchUsers(
UserSearchDto condition,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC)
Pageable pageable
) {
Page<User> page = userService.searchUsers(condition, pageable);
return page.map(UserDto::fromEntity);
}
서비스 로직
public Page<User> searchUsers(UserSearchDto cond, Pageable pageable) {
return userRepository.searchByCondition(cond, pageable);
}
Repository(QueryDSL)
@Override
public Page<User> searchByCondition(UserSearchDto cond, Pageable pageable) {
QUser u = QUser.user;
// 1) 컨텐츠 조회
List<User> content = queryFactory
.selectFrom(u)
.where(
cond.getStatus() != null ? u.status.eq(cond.getStatus()) : null,
cond.getUsernameLike() != null ? u.username.contains(cond.getUsernameLike()) : null,
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(QuerydslUtils.getOrderSpecifier(u, pageable.getSort()))
.fetch();
// 2) 전체 카운트 조회
long total = queryFactory
.select(u.id)
.from(u)
.where(
cond.getStatus() != null ? u.status.eq(cond.getStatus()) : null,
cond.getUsernameLike() != null ? u.username.contains(cond.getUsernameLike()) : null,
)
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
{
"content":[
{ "id":12, "username":"kim1", "email":"...", "status":"ACTIVE"},
…
],
"pageable":{ … },
"totalElements":123,
"totalPages":9,
"last":false,
"number":1,
"size":15,
"sort":{…},
"first":false,
"numberOfElements":15
}
객체 그래프
엔티티나 도메인 객체들이 서로 참조, 연결되어 이루는 네트워크
현실 세계의 관계를 객체 간 참조로 자연스럽게 표현할 수 있다.
하나의 객체에서 다른 객체로 자유롭게 이동할 수 있어 필요한 데이터를 조회하기 쉽다.
FetchType.EAGER vs FetchType.LAZY
FetchType.EAGER
엔티티 조회 시점에 연관 데이터를 함께 즉시 fetch join하여 한 번에 로딩한다.
연관 데이터를 곧바로 사용할 때 쿼리 없이 사용할 수 있다.
불필요한 데이터까지 항상 조인하기 때문에 대량 조인 발생 시 성능 저하가 발생할 수 있다.
FetchType.LAZY
연관된 데이터를 실제로 사용할 때 데이터베이스에서 조회한다.
불필요한 조인이나 조회를 피할 수 있기 때문에 성능이 향상될 수 있다.
접근 시점에 추가 쿼리가 발생하기 때문에 N+1문제가 발생할 수 있다.
-> 무조건 LAZY를 적용해 N+1을 예방하고, 필요한 곳에 Fetch Join을 적용해 탐색해야 한다.
객체 그래프 탐색 방법
객체 그래프 탐색 시에는 N+1문제가 발생하지 않도록 주의해야 한다.
JPQL로 Fetch Join
JPA의 Entity Graph
@EntityGraph
QueryDSL Fetch Join
1. Page와 Slice가 각각 어떻게 출력값이 나오는 지 알아보기
Page를 적용했을 때
{
"isSuccess": true,
"code": "COMMON200",
"message": "성공입니다.",
"result": {
"reviewList": [
{
"ownerNickname": "string",
"score": 3,
"body": "고생",
"createdAt": "2024-09-27"
},
{
"ownerNickname": "string",
"score": 4,
"body": "많으셨습니다!",
"createdAt": "2024-09-27"
}
],
"listSize": 2,
"totalPage": 1,
"totalElements": 2,
"isFirst": true,
"isLast": true,
"pageNumber": 0,
"pageSize": 10,
"numberOfElements": 2
}
}
-> totalPage, totalElement, pageNumber, pageSize, numberOfElements 등의 정보를 통해 전체 페이지 정보를 파악할 수 있다.
Slice를 적용했을 때
{
"isSuccess": true,
"code": "COMMON200",
"message": "성공입니다.",
"result": {
"reviewList": [
{
"ownerNickname": "string",
"score": 3,
"body": "고생",
"createdAt": "2024-09-27"
},
{
"ownerNickname": "string",
"score": 4,
"body": "많으셨습니다!",
"createdAt": "2024-09-27"
}
],
"listSize": 2,
"isFirst": true,
"isLast": true,
"hasNext": false
}
}
-> 총 개수/총 페이지 정보는 빠지고, 대신 다음 페이지가 있는지 여부만 제공한다.
2. Page, Slice 각각 적용 시 장단점 파악하기
Page를 적용했을 때 장점
totalElements, totalPages, pageNumber, pageSize, numberOfElements 등 페이징에 필요한 모든 정보를 반환한다.
클라이언트에서 총 몇 건, 몇 페이지인지 바로 확인할 수 있어서 페이지 네비게이션 등의 구현이 쉬워진다.
사용자가 임의의 페이지로 건너뛰기 하기가 쉬워진다.
전체 건수를 정확히 표시해야 하는 경우 필수적이다.
Page를 적용했을 때 단점
count(*)를 위한 별도가 항상 실행되기 때문에 데이터 규모가 크거나 복잡한 조인/조건이 많으면 응답 시간이 늘어난다.
트래픽이 많거나 실시간성이 중요할 경우 지연이 발생한다.
Slice를 적용했을 때 장점
전체 count 쿼리가 실행되지 않아 조회 성능이 훨씬 빠르다.
첫 페이지나 반복 호출이 빈번할 때 사용하기 좋다.
hasNext() 정보만 제공하기 때문에 더 불러오기, 무한 스크롤 구현이 쉬워진다.
Slice를 적용했을 때 단점
전체 건수와 페이지를 알 수 없어 마지막 페이지라는 사실만 알 수 있다.
총 X건의 정보를 표기할 수 없다.
사용자가 임의 접근하기가 어렵고, 이전/다음 접근만 할 수 있다.
3. 언제 적용하면 좋을 지 파악하기
Page를 적용하면 좋을 때
전체 건수, 전체 페이지, 현재 페이지 정보가 필요할 때
임의의 페이지로 자유롭게 이동할 수 있어야 할 때
ex) 관리자 대시보드, 검색 결과 페이징
Slice를 적용하면 좋을 때
무한 스크롤, 더 보기 버튼으로 연속 데이터를 로딩할 때
전체 카운트가 필요하지 않고, 즉각저인 응답 속도가 더 중요할 때
대규모 데이터를 대상으로 빈번히 호출될 때
ex) 피드, 무한 스크롤, 더보기
1. for과 stream이 어떻게 작동되는지 파악하기
for의 동작 방식
초기화 → 조건 검사 → 본문 실행 → 증감 → 조건 검사 ... 이 순차적으로 반복된다.
반복문 내부에서 리스트나 외부 변수에 직접 값을 추가·수정한다.
break, continue를 통해 반복을 제어할 수 있다.
stream의 동작 방식
무엇을 할지 파이프라인 형태로 연결하는 선언형 스타일이다.
중간 연산, 최종 연산이 존재한다.
중간 연산
최종 연산이 호출될 때까지 수행을 미룬다. (지연 실행)
ex) filter, map, sorted 등
최종 연산
파이프라인 전체가 실행되어 결과를 만든다.
ex) collect, forEach, count 등
중간 연산을 미루어 최종 연산 시점에 한 번에 실행된다.
중간 연산들은 연결만 된 상태이고, 처리되지 않는다.
최종 연산이 호출될 때 스트림이 순회되면서 중간 연산을 수행하기 시작한다.
병렬 처리를 지원한다.
함수 객체로 표현되며, 람다식이나 메서드 참조가 가능하다.
break, continue 사용이 불가능하다.
두 방식의 속도 비교
약 10만건의 데이터에 대해 모든 요소에 2를 곱한 새 리스트를 만드는 연산을 한다고 하면
stream 방식이 약 28% 느리다.(for: 5.8ms, stream: 7.4ms)
2. for, stream 각각 적용 시 장단점 파악하기
for 반복문의 장점
속도가 빠르고 최적화가 잘 되어있다.
디버깅이 용이하다.
메모리 사용률이 낮다.
break, continue를 사용해 편리하게 반복문을 제어할 수 있다.
리스트 추가, 수정 등의 외부 상태 변경이 쉽다.
for 반복문의 단점
코드 가독성이 상대적으로 좋지 않다. (필터링, 정렬 등의 연산을 일일이 구현해야 해서)
병렬 처리가 복잡하다.
연산 파이프라인이 추가되면 반복문의 구조를 모두 수정해야 한다.
단축 연산이 없어 break, if를 직접 써야 한다.
stream의 장점
filter, map, sorted 등의 연산을 체인으로 간결하게 쓰는 선언형 방식이다.
중간 연산을 자유롭게 조합할 수 있다.
anyMatch, findFirst, limit 등을 사용해 단축 연산을 쉽게 할 수 있다.
.parallelStream() 호출을 통해 편리하게 병렬 연산을 수행할 수 있다.
stream의 단점
for에 비해 상대적으로 속도가 느리다.
메모리 오버헤드가 높다.
람다 내부로 진입하기 어려워 디버깅이 어렵다.
외부 상태 변경을 권장하지 않아 부수 작업이 어렵다.
3. 언제 적용하면 좋을 지 파악하기
for를 사용하면 좋을 경우
많은 데이터를 사용하면서 성능이 중요할 때
반복 중에 외부 컬렉션에 요소를 추가, 수정하거나 카운터가 필요할 때
에러가 발생했을 때 반복 흐름을 단계별로 쉽게 추적해야 할 때
로직이 복잡하지 않고 가독성보다 속도가 중요할 때
stream을 사용하면 좋을 경우
filter → map → sorted → collect 등 여러 단계의 연산을 선언형으로 연결하고 싶을 때
조건 만족 시 바로 중단하거나 일부만 처리(limit)하고 싶을 때
병렬 처리가 필요할 때
코드의 가독성이 중요할 때
(추가 조사) stream의 병렬화 연산은 언제 사용할까?
데이터 사이즈가 매우 클 때(데이터 사이즈가 작으면 오히려 오버헤드가 커서 느려질 수 있다.)
-> 반드시 성능 비교 후에 도입해야 한다.
각 요소에 대해 복잡한 계산(이미지 처리, 암호화, 대규모 수치 연산)을 수행할 때
요소 간에 상태를 공유하거나, 외부 컬렉션을 수정하지 않을 때
I/O bound 작업이 아닐 때
@Documented
@Constraint(validatedBy = PageableIndexValidator.class)
@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPageableIndex {
String message() default "page 파라미터는 1 이상이어야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PageableIndexValidator implements ConstraintValidator<ValidPageableIndex, Pageable> {
@Override
public boolean isValid(Pageable pageable, ConstraintValidatorContext context) {
if (pageable == null) {
return true;
}
int pageNumber = pageable.getPageNumber();
return pageNumber >= 0;
}
}
@Override
public Page<Review> findByMemberReviews(Long memberId, Pageable pageable) {
List<Review> content = jpaQueryFactory
.selectFrom(review)
.where(review.member.id.eq(memberId))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(review.createdAt.desc())
.fetch();
long total = jpaQueryFactory
.selectFrom(review)
.where(review.member.id.eq(memberId))
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
@Override
public Page<ReviewResponseDto.JoinResultDTO> findUserReviews(Long memberId, Pageable pageable) {
Page<Review> reviews = reviewRepository.findByMemberReviews(memberId, pageable);
return reviews.map(ReviewConverter::toJoinResultDTO);
}
@GetMapping("/{memberId}")
public ApiResponse<Page<ReviewResponseDto.JoinResultDTO>> getUserReviews(
@ValidPageableIndex
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable,
@PathVariable("memberId") Long memberId
) {
return ApiResponse.onSuccess(reviewQueryService.findUserReviews(memberId, pageable));
}
@Override
public Page<Mission> findMissionsByStore(
Long storeId,
Pageable pageable
) {
List<Mission> content = jpaQueryFactory
.selectFrom(mission)
.join(mission.store, store).fetchJoin()
.where(
store.id.eq(storeId)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(mission.id.asc())
.fetch();
Long totalCount = jpaQueryFactory
.select(mission.count())
.from(mission)
.join(mission.store, store)
.where(
store.id.eq(storeId)
)
.fetchOne();
long total = (totalCount != null ? totalCount : 0L);
return new PageImpl<>(content, pageable, total);
}
@Override
public Page<MissionResponseDto.JoinResultDTO> findStoreMissions(Long storeId, Pageable pageable) {
Page<Mission> storeMissions = storeRepository.findMissionsByStore(storeId, pageable);
return storeMissions.map(MissionConverter::toJoinResultDTO);
}
@GetMapping("/{storeId}/missions")
public ApiResponse<Page<MissionResponseDto.JoinResultDTO>> getStoreMissions(
@ValidPageableIndex
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable,
@PathVariable("storeId") Long storeId
) {
return ApiResponse.onSuccess(storeQueryService.findStoreMissions(storeId, pageable));
}
@Override
public Page<MemberMission> findMissionsByMember(
Long memberId,
MissionStatus status,
Pageable pageable
) {
List<MemberMission> content = jpaQueryFactory
.selectFrom(memberMission)
.join(memberMission.member).fetchJoin()
.where(
memberMission.id.eq(memberId),
memberMission.status.eq(status)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(memberMission.id.asc())
.fetch();
Long totalCount = jpaQueryFactory
.select(memberMission.count())
.from(memberMission)
.join(memberMission.member)
.where(
member.id.eq(memberId)
)
.fetchOne();
long total = (totalCount != null ? totalCount : 0L);
return new PageImpl<>(content, pageable, total);
}
@Override
public Page<MemberMissionResponseDto.JoinResultDTO> findMemberMissions(Long memberId, MissionStatus status, Pageable pageable) {
Page<MemberMission> memberMissions = memberRepository.findMissionsByMember(memberId, status, pageable);
return memberMissions.map(MemberMissionConverter::toJoinResultDTO);
}
@GetMapping("/{memberId}/missions")
public ApiResponse<Page<MemberMissionResponseDto.JoinResultDTO>> getMemberMissions(
@ValidPageableIndex
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable,
@RequestParam MissionStatus status,
@PathVariable("memberId") Long memberId
) {
return ApiResponse.onSuccess(memberCommandService.findMemberMissions(memberId, status, pageable));
}