Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed project/.DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions project/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ out/
load-test.js

.env
**/.DS_Store
4 changes: 3 additions & 1 deletion project/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

runtimeOnly 'com.h2database:h2'

}
tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform{excludeTags 'ignore'}
}

def querydslDir = "src/main/generated"
Expand Down
Binary file removed project/gradle/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.edison.project.common.response;

public record CursorPageInfo(
Long nextCursorId,
boolean hasNext,
int size
) implements Pagination {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

@Getter
@AllArgsConstructor
public class PageInfo {
public class PageInfo implements Pagination{
private Integer page;
private Integer size;
private Boolean hasNext;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.edison.project.common.response;

public interface Pagination {

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class Response {
private final String message;

@JsonInclude(JsonInclude.Include.NON_NULL)
private final PageInfo pageInfo;
private final Pagination pageInfo;

@JsonInclude(JsonInclude.Include.NON_NULL)
private final Object result;
Expand All @@ -32,14 +32,22 @@ public static ResponseEntity<Response> onSuccess(SuccessStatus status, PageInfo
);
}

// 성공한 경우 응답 생성
public static ResponseEntity<Response> onSuccess(SuccessStatus status, CursorPageInfo pageInfo, Object result) {
return new ResponseEntity<>(
new Response(true, status.getCode(), status.getMessage(), pageInfo, result),
status.getHttpStatus()
);
}

// 성공 - 기본 응답
public static ResponseEntity<Response> onSuccess(SuccessStatus status) {
return onSuccess(status, null, null);
return onSuccess(status, (PageInfo) null, null);
}

// 성공 - 데이터 포함
public static ResponseEntity<Response> onSuccess(SuccessStatus status, Object result) {
return onSuccess(status, null, result);
public static ResponseEntity<Response> onSuccess(SuccessStatus status, Object result) {
return onSuccess(status, (PageInfo) null, result);
}

// 성공 - 페이지네이션 포함
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ public ResponseEntity<Response> getBubblesByMember(
return response;
}

@Operation(summary = "삭제되지 않은 버블 전체 목록 조회(커서페이징기반)", description = "soft delete된 버블을 제외한 전체 목록을 조회하는 기능입니다.(커서페이징기반)")
@GetMapping("v2/space")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Response> getCursorBubblesByMember(
@AuthenticationPrincipal CustomUserPrincipal userPrincipal,
@RequestParam(required = false) Long cursorId,
@RequestParam(defaultValue = "20") int size) {

// 최신순 정렬
Pageable pageable = PageRequest.of(0, size);
ResponseEntity<Response> response = bubbleService.getCursorBubblesByMember(userPrincipal, cursorId, pageable);
return response;
}

@Operation(summary = "soft delete된 버블 전체 목록 조회", description = "soft delete된 버블 전체 목록을 조회하는 기능입니다.")
@GetMapping("/deleted")
@PreAuthorize("isAuthenticated()")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.edison.project.domain.member.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
Expand Down Expand Up @@ -83,4 +84,6 @@ Page<BubbleEmbeddingProjection> findEmbeddingProjectionsByMemberId(
Pageable pageable
);

Slice<Bubble> findByMember_MemberIdAndIsTrashedFalseOrderByBubbleIdDesc(Long memberId, Pageable pageable);
Slice<Bubble> findByMember_MemberIdAndIsTrashedFalseAndBubbleIdLessThanOrderByBubbleIdDesc(Long memberId, Long cusorId, Pageable pageable);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오타낫더요..

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.edison.project.domain.bubble.dto.BubbleRequestDto;
import com.edison.project.domain.bubble.dto.BubbleResponseDto;
import com.edison.project.global.security.CustomUserPrincipal;
import com.edison.project.domain.bubble.entity.Bubble;

import org.springframework.http.ResponseEntity;

Expand Down Expand Up @@ -43,4 +44,6 @@ public interface BubbleService {
* 사용자의 모든 Bubble 2D 임베딩 좌표 조회
*/
ResponseEntity<Response> getAllBubbleEmbeddings(CustomUserPrincipal userPrincipal, Pageable pageable);

ResponseEntity<Response> getCursorBubblesByMember(CustomUserPrincipal userPrincipal, Long cursorId, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.edison.project.common.exception.GeneralException;
import com.edison.project.common.response.Response;
import com.edison.project.common.response.CursorPageInfo;
import com.edison.project.common.response.PageInfo;
import com.edison.project.common.status.ErrorStatus;
import com.edison.project.common.status.SuccessStatus;
Expand All @@ -25,6 +26,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -83,6 +85,32 @@ public ResponseEntity<Response> getBubblesByMember(CustomUserPrincipal userPrinc
return Response.onSuccess(SuccessStatus._OK, pageInfo, bubbles);
}

@Override
public ResponseEntity<Response> getCursorBubblesByMember(CustomUserPrincipal userPrincipal, Long cursorId, Pageable pageable){
Long memberId = userPrincipal.getMemberId();
Slice<Bubble> bubbleSlice;

if(cursorId == null){
bubbleSlice = bubbleRepository.findByMember_MemberIdAndIsTrashedFalseOrderByBubbleIdDesc(memberId, pageable);
}
else{
bubbleSlice = bubbleRepository.findByMember_MemberIdAndIsTrashedFalseAndBubbleIdLessThanOrderByBubbleIdDesc(memberId, cursorId, pageable);
}

List<BubbleResponseDto.SyncResultDto> bubbles = bubbleSlice.getContent().stream()
.map(this::convertToBubbleResponseDto)
.collect(Collectors.toList());

Long nextCursorId = null;
if(!bubbles.isEmpty()){
nextCursorId = bubbleSlice.getContent().get(bubbles.size()-1).getBubbleId();
}

CursorPageInfo pageInfo = new CursorPageInfo(cursorId, bubbleSlice.hasNext(), pageable.getPageSize());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에서 계산한 nextCursorId가 들어가야 할 것 같습니다.

CursorPageInfo pageInfo = new CursorPageInfo(nextCursorId, bubbleSlice.hasNext(), pageable.getPageSize());


return Response.onSuccess(SuccessStatus._OK, pageInfo, bubbles);
}

@Override
public ResponseEntity<Response> getDeletedBubbles(CustomUserPrincipal userPrincipal, Pageable pageable) {
Page<Bubble> bubblePage = bubbleRepository.findByMember_MemberIdAndIsTrashedTrue(userPrincipal.getMemberId(), pageable);
Expand Down Expand Up @@ -343,7 +371,7 @@ public ResponseEntity<Response> vectorizeAllBubbles(CustomUserPrincipal userPrin
List<Bubble> bubbles = bubbleRepository.findByMember_MemberIdAndIsTrashedFalse(member.getMemberId());

if (bubbles.isEmpty()) {
return Response.onSuccess(SuccessStatus._OK, null, "No bubbles found");
return Response.onSuccess(SuccessStatus._OK, (PageInfo)null, "No bubbles found");
}

List<BubbleResponseDto.VectorizeResultDto> results = new ArrayList<>();
Expand Down
27 changes: 27 additions & 0 deletions project/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;REFERENTIAL_INTEGRITY=FALSE
driver-class-name: org.h2.Driver
username: sa
password:

jpa:
database-platform: com.edison.project.domain.bubble.H2TestDialect
hibernate:
ddl-auto: create-drop
properties:
hibernate:
show_sql: true
format_sql: true
highlight_sql: true
sql:
init:
mode: never
AWS_ACCESS_KEY: dummy-access-key
AWS_SECRET_KEY: dummy-secret-key

cloud:
aws:
credentials:
access-key: dummy-access-key
secret-key: dummy-secret-key
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.edison.project;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.edison.project.domain.bubble;

import com.edison.project.domain.bubble.repository.BubbleRepository;
import java.sql.PreparedStatement;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.StopWatch;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Transactional
@ActiveProfiles("test")
@Tag("ignore")
public class BubblePaginationPerformanceTest {

@Autowired
private BubbleRepository bubbleRepository;

@Autowired
private JdbcTemplate jdbcTemplate;

private Long testMemberId = 1L;

@BeforeEach
void setUp() {
System.out.println("데이터 세팅 시작");

jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");

String sql = "INSERT INTO bubble " +
"(member_id, title, content, is_deleted, is_trashed, created_at, updated_at) " +
"VALUES (?, ?, ?, ?, ?, NOW(), NOW())";

jdbcTemplate.batchUpdate(sql,
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws java.sql.SQLException {
ps.setLong(1, testMemberId);
ps.setString(2, "테스트용 버블 제목 " + i);
ps.setString(3, "테스트용 버블 내용 " + i);
ps.setBoolean(4, false);
ps.setBoolean(5, false);
}
@Override
public int getBatchSize() {
return 100000;
}
});

jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE");

System.out.println("10만 건 데이터 세팅");
}

@Test
@DisplayName("오프셋 페이징 vs 커서 페이징 성능 비교")
void comparePaginationPerformance() {
int targetPage = 4900; // 4900번째 페이지 조회
int size = 20;

// 오프셋 페이징 측정
StopWatch offsetStopWatch = new StopWatch();
offsetStopWatch.start();
PageRequest offsetRequest = PageRequest.of(targetPage, size, Sort.by(Sort.Direction.DESC, "bubbleId"));

System.out.println("\n=======================================================");
System.out.println("[오프셋 페이징 쿼리 시작] - 쿼리 2개(데이터+COUNT)");
System.out.println("=======================================================");

bubbleRepository.findByMember_MemberIdAndIsTrashedFalse(testMemberId, offsetRequest);
offsetStopWatch.stop();

// 커서 페이징 측정
// 4900 페이지의 첫 번째 항목 커서 값 임의로 계산
Long cursorId = 2000L;

StopWatch cursorStopWatch = new StopWatch();
cursorStopWatch.start();
PageRequest cursorRequest = PageRequest.of(0, size);

System.out.println("\n=======================================================");
System.out.println("[커서 페이징 쿼리 시작] - 쿼리 1개");
System.out.println("=======================================================");

bubbleRepository.findByMember_MemberIdAndIsTrashedFalseAndBubbleIdLessThanOrderByBubbleIdDesc(
testMemberId, cursorId, cursorRequest);
cursorStopWatch.stop();

System.out.println("=== 테스트 결과 ===");
System.out.println("오프셋 페이징 소요 시간: " + offsetStopWatch.getTotalTimeMillis() + " ms");
System.out.println("커서 페이징 소요 시간: " + cursorStopWatch.getTotalTimeMillis() + " ms");

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.edison.project.domain.bubble;

import org.hibernate.boot.model.TypeContributions;
import org.hibernate.dialect.H2Dialect;
import org.hibernate.service.ServiceRegistry;
import org.hibernate.type.SqlTypes;
import org.hibernate.type.descriptor.sql.internal.DdlTypeImpl;

// 테스트 환경의 H2 DB를 위한 전용 방언(Dialect) 설정
public class H2TestDialect extends H2Dialect {

@Override
public void contributeTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) {
super.contributeTypes(typeContributions, serviceRegistry);

// 핵심: H2가 모르는 VECTOR 타입이 들어오면, 무조건 "varbinary" (이진 데이터) 타입으로 속여서 테이블을 만들라고 지시합니다.
typeContributions.getTypeConfiguration().getDdlTypeRegistry()
.addDescriptor(new DdlTypeImpl(SqlTypes.VECTOR, "varbinary", this));
}
}