1. 문제 상황 파악
- 프론트엔드에서 무한 스크롤 기능을 추가함에 따라 백엔드에서 채팅 조회 시 페이지네이션을 적용함
- 약 1000~2000개의 채팅 추가 후 확인 시 조금씩 느려짐을 확인함
- 데이터가 많아지면 느려지는 문제를 확인해보기 위해 아래의 과정대로 1,000,000개의 데이터를 삽입하고 테스트를 진행함
1. 채팅 메시지 테이블에 1,000,000개의 샘플 데이터를 삽입
2. 가장 마지막 20개의 채팅 조회
⇒ 백만개의 데이터 (생성일 기준 내림차순) 중 가장 마지막에 해당하는 20개를 조회할 때 2.08초가 소요됨
3. 그렇다면 데이터가 1억, 10억개 ... 등 더 많이 존재한다면 조회 시간이 기하급수적으로 늘어날 것이라고 판단해 성능 개선하려고 함
2. 문제 원인 파악
2-1. 기존의 페이지네이션 기법
- offset을 사용함
- JPA가 생성하는 쿼리 예시
// JpaRepository 내의 메서드
Page<ChatMessage> findChatMessagesByChatRoomId(Pageable pageable, Long chatRoomId);
-- Jpa가 자동으로 생성해주는 쿼리
SELECT * FROM chat_message
WHERE chat_room_id = ?
ORDER BY created_at DESC
LIMIT 20 OFFSET ?
2-1-1. offset 사용 페이지네이션 분석
- full-scan 방식에 해당함
- 처음 ~ offset에 해당하는 행까지 데이터를 읽음
- 그 이후 limit 만큼 행을 읽음
- 1번에서 읽은 데이터는 필요없으므로 삭제
문제점 1
만약 첫페이지, 즉 첫 행부터 limit만큼 조회한다면 삭제하는 행 (쓸데없이 조회하는 행)이 없기 때문에 성능에는 차이가 없음
⇒ 반대로 가장 마지막에 있는 페이지의 데이터를 읽게된다면 그 전까지의 많은 데이터는 쓸데없이 조회와 삭제의 작업을 거치게 됨
⇒ 따라서 데이터의 양이 많을수록 성능 저하는 심해질 것!
문제점 2
만약 새로운 행이 삽입되고 (지금 기준) 다음 페이지를 조회하는 경우에는 중복되는 데이터가 발생할 수 있음!
3. 해결 방법 및 결과 검증
3-1. no-offset 구조 페이지네이션 ( = 커서 기반 페이지네이션)
- 기존 방식 (offset 사용) 과는 달리 offset을 사용하지 않는 방식
- 즉, 페이지 번호가 존재하지 않음
- 조회 시작 부분을 인덱스를 기준으로 바로 판단해서 매번 첫 페이지만 읽도록 하는 방식
- (Jpa는 @Id에 해당하는 컬럼 (pk) 를 인덱스로 자동 설정해 줌)
- 예시 sql
-- 위의 예시 쿼리
SELECT * FROM chat_message
WHERE chat_room_id = ?
ORDER BY created_at DESC
LIMIT 20 OFFSET ?
-- no-offset 방식으로 변경한 예시 쿼리
SELECT * FROM chat_message
WHERE chat_room_id = ?
AND chat_message_id < ?
ORDER BY created_at DESC
LIMIT 20;
- 여기서 chat_message_id는 이전 조회 결과에서가장 마지막 채팅의 id를 의미
⇒ 이전 조회 결과의 마지막 채팅을 기준으로 다음 데이터를 조회하기 때문에 매번 이전 데이터를 건너뛸 수 있음
⇒ 따라서 가장 마지막 페이지를 읽는다고 해도 매번 첫 페이지를 읽는 성능과 동일!
3-2. sql을 통한 성능 비교
- 테이블에 삽입한 데이터 양은 1,000,000 건
- 쿼리는 위의 예시와 다소 다를 수 있음
마지막 페이지 조회 기준으로 기존 방식 대비 no-offset 방식으로 약 96.2% 성능 개선 가능!
3-3. 코드 구현
QueryDSL
- Java 기반의 SQL 쿼리 생성 라이브러리
- java 코드에서 SQL 쿼리를 직접 작성할 수 있음!
3-3-1. QueryDSL 의존성 추가
- build.gradle
// QueryDSL 의존성 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
3-3-2. 레포지토리 생성
- QueryDSL 사용하여 SQL을 직접 작성하기 위한 레포지토리를 생성함
- ChatMessageQueryRepository
@Repository
@RequiredArgsConstructor
public class ChatMessageQueryRepository {
// jpa의 EntityManager 의존 주입
private final EntityManager em;
// 특정 채팅방에 대한 채팅 메시지를 no-offset 방식으로 조회하는 메서드
public Page<ChatMessage> findChatMessagesByChatRoomIdUsingNoOffset(Pageable pageable, Long chatRoomId, @Nullable Long index) {
// JPAQueryFactory 생성 => QueryDSL 쿼리 작성 가능
JPAQueryFactory query = new JPAQueryFactory(em);
QChatRoom chatRoom = QChatRoom.chatRoom; // QChatRoom 타입 인스턴스 생성
QChatMessage chatMessage = QChatMessage.chatMessage; // QChatMessage 타입 인스턴스 생성
List<ChatMessage> results =
query.select(chatMessage)
.from(chatMessage)
.join(chatMessage.chatRoom, chatRoom)
.where(chatRoom.id.eq(chatRoomId) // 조인 조건
.and(ltChatMessageId(index))) // no-offset 조건
.orderBy(chatMessage.createdAt.desc())
.limit(20)
.fetch();
return new PageImpl<>(results, pageable, results.size());
}
// 주어진 index 보다 작은 chat_message_id에 대한 조건 생성 메서드
public BooleanExpression ltChatMessageId(@Nullable Long index) {
return index == null ? null : QChatMessage.chatMessage.id.lt(index);
}
}
3-4. api 호출로 성능 개선 결과 확인
마지막 페이지 조회 기준으로 기존 방식 대비 no-offset 방식으로 약 96.0% 성능 개선 확인!
- 기존 조회 api 호출: 2.84s 소요
- no-offset 방식으로 변경한 조회 api 호출: 85ms 소요
3-5. 테스트 코드로 성능 개선 확인
- 동일하게 100만개의 채팅 메세지 삽입 후 테스트 진행함
- ChatMessageRepositoryTest
@SpringBootTest
class ChatMessageRepositoryTest {
@Autowired
private ChatRoomRepository chatRoomRepository;
@Autowired
private ChatMessageRepository chatMessageRepository;
@Autowired
private ChatMessageQueryRepository chatMessageQueryRepository;
@Test
@DisplayName("기존의 방식대로 100만개 중 마지막 페이지를 조회하면 1초 이상이 걸린다.")
public void findChatMessagesLegacy() throws Exception {
//given
int limit = 20;
int offset = 1089900;
PageRequest pageRequest = PageRequest.of(offset / limit, limit,
Sort.by("createdAt").descending());
//when
long startTime = System.nanoTime(); // 시작 시간 기록
Page<ChatMessage> chatMessagesLegacy = chatMessageRepository.findChatMessagesByChatRoomId(pageRequest, 1L);
long endTime = System.nanoTime(); // 종료 시간 기록
System.out.println("성능 개선 전 실행 시간: " + (double) (endTime - startTime)/1000000000 + "s");
//then
assertThat(chatMessagesLegacy).hasSize(20);
List<ChatMessage> legacyMessages = chatMessagesLegacy.getContent();
for (ChatMessage legacyMessage : legacyMessages) {
System.out.println("legacyMessage.getContent() = " + legacyMessage.getContent());
}
}
@Test
@DisplayName("개선한 방식대로 100만개 중 마지막 페이지를 조회하면 1초 미만 (ms단위)으로 걸린다.")
public void findChatMessagesNoOffset() throws Exception {
//given
int limit = 20;
Pageable pageable = Pageable.ofSize(limit);
//when
long startTime = System.nanoTime(); // 시작 시간 기록
Page<ChatMessage> chatMessagesNoOffset = chatMessageQueryRepository.findChatMessagesByChatRoomIdUsingNoOffset(
pageable, 1L, 101L);
long endTime = System.nanoTime(); // 종료 시간 기록
System.out.println("성능 개선 후 실행 시간: " + (double) (endTime - startTime)/1000000000 + "s");
//then
assertThat(chatMessagesNoOffset).hasSize(20);
// 추가: 메시지 내용 비교
List<ChatMessage> noOffsetMessages = chatMessagesNoOffset.getContent();
for (ChatMessage noOffsetMessage : noOffsetMessages) {
System.out.println("noOffsetMessage.getContent() = " + noOffsetMessage.getContent());
}
}
}
- 테스트 결과 확인
테스트 코드로 확인 결과 마지막 페이지 조회 기준으로 약 84.3% 성능 개선 확인!
성능 개선 전 조회 테스트 | 성능 개선 후 조회 테스트 |
1.799s 소요 |
0.283s 소요 |
4. 참고자료
- https://github.com/Engineering-Student-An/rental-inhaee/blob/43c06301b9183c0b4a2d542e102fb0db38439f38/src/main/java/an/rentalinhaee/repository/RentalQueryRepository.java#L18
- https://thalals.tistory.com/349
- https://waterfogsw.tistory.com/50
- https://jojoldu.tistory.com/528
- https://thalals.tistory.com/350
'Trouble Shooting' 카테고리의 다른 글
[Trouble Shooting/성능 개선] Redis로 캐싱 도입해 채팅 전송 및 조회 성능 개선 (1) | 2025.01.03 |
---|---|
[Trouble Shooting] JPA 사용 시 발생한 N+1 문제를 fetch join을 사용한 JPQL을 작성하여 해결 (0) | 2024.12.30 |
[Trouble Shooting] 단위 테스트 작성 시 발생한 JpaAuditingHandler 빈 생성 오류 해결 (1) | 2024.12.30 |