diff --git a/project/.DS_Store b/project/.DS_Store deleted file mode 100644 index 5091cb70..00000000 Binary files a/project/.DS_Store and /dev/null differ diff --git a/project/.gitignore b/project/.gitignore index d19fe63b..a63af8a4 100644 --- a/project/.gitignore +++ b/project/.gitignore @@ -41,3 +41,4 @@ out/ load-test.js .env +**/.DS_Store \ No newline at end of file diff --git a/project/build.gradle b/project/build.gradle index a9e8031b..b69c7ecf 100644 --- a/project/build.gradle +++ b/project/build.gradle @@ -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" diff --git a/project/gradle/.DS_Store b/project/gradle/.DS_Store deleted file mode 100644 index cdc6030c..00000000 Binary files a/project/gradle/.DS_Store and /dev/null differ diff --git a/project/src/main/java/com/edison/project/common/response/CursorPageInfo.java b/project/src/main/java/com/edison/project/common/response/CursorPageInfo.java new file mode 100644 index 00000000..2935f838 --- /dev/null +++ b/project/src/main/java/com/edison/project/common/response/CursorPageInfo.java @@ -0,0 +1,7 @@ +package com.edison.project.common.response; + +public record CursorPageInfo( + Long nextCursorId, + boolean hasNext, + int size +) implements Pagination {} diff --git a/project/src/main/java/com/edison/project/common/response/PageInfo.java b/project/src/main/java/com/edison/project/common/response/PageInfo.java index fdf0cb94..194fef9d 100644 --- a/project/src/main/java/com/edison/project/common/response/PageInfo.java +++ b/project/src/main/java/com/edison/project/common/response/PageInfo.java @@ -5,7 +5,7 @@ @Getter @AllArgsConstructor -public class PageInfo { +public class PageInfo implements Pagination{ private Integer page; private Integer size; private Boolean hasNext; diff --git a/project/src/main/java/com/edison/project/common/response/Pagination.java b/project/src/main/java/com/edison/project/common/response/Pagination.java new file mode 100644 index 00000000..c9333ecd --- /dev/null +++ b/project/src/main/java/com/edison/project/common/response/Pagination.java @@ -0,0 +1,5 @@ +package com.edison.project.common.response; + +public interface Pagination { + +} diff --git a/project/src/main/java/com/edison/project/common/response/Response.java b/project/src/main/java/com/edison/project/common/response/Response.java index ac8bd8af..26568be1 100644 --- a/project/src/main/java/com/edison/project/common/response/Response.java +++ b/project/src/main/java/com/edison/project/common/response/Response.java @@ -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; @@ -32,14 +32,22 @@ public static ResponseEntity onSuccess(SuccessStatus status, PageInfo ); } + // 성공한 경우 응답 생성 + public static ResponseEntity onSuccess(SuccessStatus status, CursorPageInfo pageInfo, Object result) { + return new ResponseEntity<>( + new Response(true, status.getCode(), status.getMessage(), pageInfo, result), + status.getHttpStatus() + ); + } + // 성공 - 기본 응답 public static ResponseEntity onSuccess(SuccessStatus status) { - return onSuccess(status, null, null); + return onSuccess(status, (PageInfo) null, null); } // 성공 - 데이터 포함 - public static ResponseEntity onSuccess(SuccessStatus status, Object result) { - return onSuccess(status, null, result); + public static ResponseEntity onSuccess(SuccessStatus status, Object result) { + return onSuccess(status, (PageInfo) null, result); } // 성공 - 페이지네이션 포함 diff --git a/project/src/main/java/com/edison/project/domain/bubble/controller/BubbleRestController.java b/project/src/main/java/com/edison/project/domain/bubble/controller/BubbleRestController.java index a055bf6f..fb9f1e07 100644 --- a/project/src/main/java/com/edison/project/domain/bubble/controller/BubbleRestController.java +++ b/project/src/main/java/com/edison/project/domain/bubble/controller/BubbleRestController.java @@ -40,6 +40,20 @@ public ResponseEntity getBubblesByMember( return response; } + @Operation(summary = "삭제되지 않은 버블 전체 목록 조회(커서페이징기반)", description = "soft delete된 버블을 제외한 전체 목록을 조회하는 기능입니다.(커서페이징기반)") + @GetMapping("v2/space") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getCursorBubblesByMember( + @AuthenticationPrincipal CustomUserPrincipal userPrincipal, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size) { + + // 최신순 정렬 + Pageable pageable = PageRequest.of(0, size); + ResponseEntity response = bubbleService.getCursorBubblesByMember(userPrincipal, cursorId, pageable); + return response; + } + @Operation(summary = "soft delete된 버블 전체 목록 조회", description = "soft delete된 버블 전체 목록을 조회하는 기능입니다.") @GetMapping("/deleted") @PreAuthorize("isAuthenticated()") diff --git a/project/src/main/java/com/edison/project/domain/bubble/repository/BubbleRepository.java b/project/src/main/java/com/edison/project/domain/bubble/repository/BubbleRepository.java index 6c30b2ef..6a995bc8 100644 --- a/project/src/main/java/com/edison/project/domain/bubble/repository/BubbleRepository.java +++ b/project/src/main/java/com/edison/project/domain/bubble/repository/BubbleRepository.java @@ -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; @@ -83,4 +84,6 @@ Page findEmbeddingProjectionsByMemberId( Pageable pageable ); + Slice findByMember_MemberIdAndIsTrashedFalseOrderByBubbleIdDesc(Long memberId, Pageable pageable); + Slice findByMember_MemberIdAndIsTrashedFalseAndBubbleIdLessThanOrderByBubbleIdDesc(Long memberId, Long cusorId, Pageable pageable); } \ No newline at end of file diff --git a/project/src/main/java/com/edison/project/domain/bubble/service/BubbleService.java b/project/src/main/java/com/edison/project/domain/bubble/service/BubbleService.java index f3d67ce3..fbcddee4 100644 --- a/project/src/main/java/com/edison/project/domain/bubble/service/BubbleService.java +++ b/project/src/main/java/com/edison/project/domain/bubble/service/BubbleService.java @@ -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; @@ -43,4 +44,6 @@ public interface BubbleService { * 사용자의 모든 Bubble 2D 임베딩 좌표 조회 */ ResponseEntity getAllBubbleEmbeddings(CustomUserPrincipal userPrincipal, Pageable pageable); + + ResponseEntity getCursorBubblesByMember(CustomUserPrincipal userPrincipal, Long cursorId, Pageable pageable); } diff --git a/project/src/main/java/com/edison/project/domain/bubble/service/BubbleServiceImpl.java b/project/src/main/java/com/edison/project/domain/bubble/service/BubbleServiceImpl.java index cdf39774..7348b5c1 100644 --- a/project/src/main/java/com/edison/project/domain/bubble/service/BubbleServiceImpl.java +++ b/project/src/main/java/com/edison/project/domain/bubble/service/BubbleServiceImpl.java @@ -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; @@ -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; @@ -83,6 +85,32 @@ public ResponseEntity getBubblesByMember(CustomUserPrincipal userPrinc return Response.onSuccess(SuccessStatus._OK, pageInfo, bubbles); } + @Override + public ResponseEntity getCursorBubblesByMember(CustomUserPrincipal userPrincipal, Long cursorId, Pageable pageable){ + Long memberId = userPrincipal.getMemberId(); + Slice bubbleSlice; + + if(cursorId == null){ + bubbleSlice = bubbleRepository.findByMember_MemberIdAndIsTrashedFalseOrderByBubbleIdDesc(memberId, pageable); + } + else{ + bubbleSlice = bubbleRepository.findByMember_MemberIdAndIsTrashedFalseAndBubbleIdLessThanOrderByBubbleIdDesc(memberId, cursorId, pageable); + } + + List 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()); + + return Response.onSuccess(SuccessStatus._OK, pageInfo, bubbles); + } + @Override public ResponseEntity getDeletedBubbles(CustomUserPrincipal userPrincipal, Pageable pageable) { Page bubblePage = bubbleRepository.findByMember_MemberIdAndIsTrashedTrue(userPrincipal.getMemberId(), pageable); @@ -343,7 +371,7 @@ public ResponseEntity vectorizeAllBubbles(CustomUserPrincipal userPrin List 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 results = new ArrayList<>(); diff --git a/project/src/main/resources/application-test.yml b/project/src/main/resources/application-test.yml new file mode 100644 index 00000000..45c88633 --- /dev/null +++ b/project/src/main/resources/application-test.yml @@ -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 \ No newline at end of file diff --git a/project/src/test/java/com/edison/project/ProjectApplicationTests.java b/project/src/test/java/com/edison/project/ProjectApplicationTests.java index c430fcf6..50bd03aa 100644 --- a/project/src/test/java/com/edison/project/ProjectApplicationTests.java +++ b/project/src/test/java/com/edison/project/ProjectApplicationTests.java @@ -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; diff --git a/project/src/test/java/com/edison/project/domain/bubble/BubblePaginationPerformanceTest.java b/project/src/test/java/com/edison/project/domain/bubble/BubblePaginationPerformanceTest.java new file mode 100644 index 00000000..70d98906 --- /dev/null +++ b/project/src/test/java/com/edison/project/domain/bubble/BubblePaginationPerformanceTest.java @@ -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"); + + } +} diff --git a/project/src/test/java/com/edison/project/domain/bubble/H2TestDialect.java b/project/src/test/java/com/edison/project/domain/bubble/H2TestDialect.java new file mode 100644 index 00000000..8e06e906 --- /dev/null +++ b/project/src/test/java/com/edison/project/domain/bubble/H2TestDialect.java @@ -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)); + } +}