From 2ebb62f46e6e6315b05955f092a55463ee4971f4 Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 11:38:33 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20MyBar=20=EB=AA=A9=EB=A1=9D=20AP?= =?UTF-8?q?I=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EB=B0=A9=EC=8B=9D=EC=9D=84?= =?UTF-8?q?=20=EC=98=A4=ED=94=84=EC=85=8B=EC=97=90=EC=84=9C=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답 DTO인 `MyBarListResponseDto`에서 `nextPage` 필드를 제거하고, 대신 `nextCursor` 필드를 추가 --- .../java/com/back/domain/mybar/dto/MyBarListResponseDto.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java b/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java index 040a3806..b4387bf0 100644 --- a/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java +++ b/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java @@ -9,6 +9,6 @@ @AllArgsConstructor public class MyBarListResponseDto { private List items; - private boolean hasNext; // 다음 페이지 존재 여부 - private Integer nextPage; // 다음 페이지 번호(없으면 null) + private boolean hasNext; // 다음 페이지 존재 여부 + private String nextCursor; // 다음 커서(없으면 null) } \ No newline at end of file From c807f75ef9e0cef029b12f5e69fa8a50412273ae Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 11:39:27 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20MyBarRepository=EC=97=90=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MyBarRepository`에 커서 기반 페이징을 위한 새로운 쿼리 메서드 `findSliceByCursor`를 추가 --- .../back/domain/mybar/repository/MyBarRepository.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java index 0ba75c50..b8aa380d 100644 --- a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java +++ b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java @@ -10,12 +10,23 @@ import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; +import java.time.LocalDateTime; @Repository public interface MyBarRepository extends JpaRepository { /** 나만의 bar(킵) 목록: ACTIVE만, id desc */ Page findByUser_IdAndStatusOrderByKeptAtDescIdDesc(Long userId, KeepStatus status, Pageable pageable); + @Query(""" + select m from MyBar m + where m.user.id = :userId + and m.status = :status + and (m.keptAt < :keptAt or (m.keptAt = :keptAt and m.id < :id)) + order by m.keptAt desc, m.id desc + """) + List findSliceByCursor(Long userId, KeepStatus status, LocalDateTime keptAt, Long id, Pageable pageable); + /** 프로필/요약용: ACTIVE 개수 */ long countByUser_IdAndStatus(Long userId, KeepStatus status); From 79b6a5524a433c9213be1b08d1c31f74c2f7a8ea Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 11:40:57 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20MyBarService=EC=97=90=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyBar 목록 조회 API의 페이징 방식을 기존의 오프셋(offset) 기반에서 커서(cursor) 기반으로 변경 - `getMyBar` 메서드의 파라미터를 `page`, `pageSize`에서 `cursor`, `limit`로 수정하여 커서 기반 페이징에 적합하게 변경 - `Cursor` 클래스를 새로 추가하여 커서의 인코딩/디코딩 로직을 구현 커서는 `epochMillis`와 `id`를 조합하여 Base64로 인코딩 - 첫 페이지 요청(`cursor`가 null 또는 빈 문자열)과 다음 페이지 요청(`cursor`가 존재)을 분리하여 처리하는 로직을 추가 --- .../domain/mybar/service/MyBarService.java | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/service/MyBarService.java b/src/main/java/com/back/domain/mybar/service/MyBarService.java index aea90bdc..02619fe2 100644 --- a/src/main/java/com/back/domain/mybar/service/MyBarService.java +++ b/src/main/java/com/back/domain/mybar/service/MyBarService.java @@ -10,10 +10,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -27,17 +29,40 @@ public class MyBarService { private final CocktailRepository cocktailRepository; @Transactional(readOnly = true) - public MyBarListResponseDto getMyBar(Long userId, int page, int pageSize) { - Page myBarPage = myBarRepository.findByUser_IdAndStatusOrderByKeptAtDescIdDesc(userId, KeepStatus.ACTIVE, PageRequest.of(page, pageSize)); + public MyBarListResponseDto getMyBar(Long userId, String cursor, int limit) { + int safeLimit = Math.max(1, Math.min(limit, 100)); + int fetchSize = safeLimit + 1; // hasNext 판별용으로 1개 더 조회 + + List rows; + Pageable pageable = PageRequest.of(0, fetchSize); + + if (cursor == null || cursor.isBlank()) { + Page page0 = myBarRepository + .findByUser_IdAndStatusOrderByKeptAtDescIdDesc(userId, KeepStatus.ACTIVE, pageable); + rows = page0.getContent(); + } else { + // cursor 포맷: epochMillis|id (Base64 URL-safe, padding 없음) + Cursor decoded = Cursor.decode(cursor); + LocalDateTime keptAtCursor = LocalDateTime.ofEpochSecond(decoded.epochMillis / 1000, (int)((decoded.epochMillis % 1000) * 1_000_000), ZoneOffset.UTC); + rows = myBarRepository.findSliceByCursor(userId, KeepStatus.ACTIVE, keptAtCursor, decoded.id, pageable); + } + + boolean hasNext = rows.size() > safeLimit; + if (hasNext) { + rows = rows.subList(0, safeLimit); + } - List myBars = myBarPage.getContent(); List items = new ArrayList<>(); - for (MyBar myBar : myBars) items.add(MyBarItemResponseDto.from(myBar)); + for (MyBar myBar : rows) items.add(MyBarItemResponseDto.from(myBar)); - boolean hasNext = myBarPage.hasNext(); - Integer nextPage = hasNext ? myBarPage.getNumber() + 1 : null; + String nextCursor = null; + if (hasNext && !rows.isEmpty()) { + MyBar last = rows.get(rows.size() - 1); + long epochMillis = last.getKeptAt().toEpochSecond(ZoneOffset.UTC) * 1000L + (last.getKeptAt().getNano() / 1_000_000); + nextCursor = Cursor.encode(epochMillis, last.getId()); + } - return new MyBarListResponseDto(items, hasNext, nextPage); + return new MyBarListResponseDto(items, hasNext, nextCursor); } @Transactional @@ -75,3 +100,33 @@ public void unkeep(Long userId, Long cocktailId) { myBarRepository.softDeleteByUserAndCocktail(userId, cocktailId); } } + +class Cursor { + final long epochMillis; + final long id; + + Cursor(long epochMillis, long id) { + this.epochMillis = epochMillis; + this.id = id; + } + + static String encode(long epochMillis, long id) { + String raw = epochMillis + "|" + id; + return java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + static Cursor decode(String cursor) { + try { + byte[] decoded = java.util.Base64.getUrlDecoder().decode(cursor); + String s = new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + String[] parts = s.split("\\|"); + if (parts.length != 2) throw new com.back.global.exception.ServiceException(400, "invalid-cursor-형식이-올바르지-않습니다"); + long millis = Long.parseLong(parts[0]); + long id = Long.parseLong(parts[1]); + return new Cursor(millis, id); + } catch (Exception e) { + throw new com.back.global.exception.ServiceException(400, "invalid-cursor-디코드에-실패했습니다"); + } + } +} From d38ef64260dd36b8b228bd5e266a7368a45532d5 Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 11:44:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20MyBarController=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이바(MyBar) 목록을 조회하는 GET API의 페이징 방식을 오프셋(offset) 기반에서 커서(cursor) 기반으로 리팩토링 - 기존의 `@RequestParam`인 `page`와 `pageSize`를 `cursor`와 `limit`로 변경 - `cursor`는 `String` 타입으로, 다음 페이지를 식별하는 데 사용 --- .../com/back/domain/mybar/controller/MyBarController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/controller/MyBarController.java b/src/main/java/com/back/domain/mybar/controller/MyBarController.java index 022c3e52..8f918e41 100644 --- a/src/main/java/com/back/domain/mybar/controller/MyBarController.java +++ b/src/main/java/com/back/domain/mybar/controller/MyBarController.java @@ -21,10 +21,10 @@ public class MyBarController { @GetMapping public RsData getMyBarList( @AuthenticationPrincipal(expression = "id") Long userId, - @RequestParam(defaultValue = "0") @Min(0) int page, - @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit ) { - MyBarListResponseDto body = myBarService.getMyBar(userId, page, pageSize); + MyBarListResponseDto body = myBarService.getMyBar(userId, cursor, limit); return RsData.successOf(body); // code=200, message="success" } From c00f36d81422081fd8e59a7651ba171e6bd84a5b Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 11:38:33 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20MyBarListResponseDto=EC=9D=98?= =?UTF-8?q?=20=EC=BB=A4=EC=84=9C=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MyBarListResponseDto`의 `nextCursor` 필드를 제거하고, `nextKeptAt`과 `nextId` 두 개의 필드로 분리 - 기존의 Base64로 인코딩된 문자열 커서 대신, 서버 내부에서 직접 사용할 수 있는 `LocalDateTime`과 `Long` 타입의 필드를 제공 --- .../com/back/domain/mybar/dto/MyBarListResponseDto.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java b/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java index b4387bf0..7c39dc91 100644 --- a/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java +++ b/src/main/java/com/back/domain/mybar/dto/MyBarListResponseDto.java @@ -3,12 +3,14 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.time.LocalDateTime; import java.util.List; @Getter @AllArgsConstructor public class MyBarListResponseDto { private List items; - private boolean hasNext; // 다음 페이지 존재 여부 - private String nextCursor; // 다음 커서(없으면 null) -} \ No newline at end of file + private boolean hasNext; // 다음 페이지 존재 여부 + private LocalDateTime nextKeptAt; // 다음 페이지 시작용 keptAt + private Long nextId; // 다음 페이지 시작용 id +} From 8526fde492991aa4ba93b3dee4930d38a5896708 Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 14:48:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20MyBarService=EC=9D=98=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EB=A1=9C=EC=A7=81=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?(=EA=B0=84=EC=86=8C=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MyBarService`의 `getMyBar` 메서드에서 커서 기반 페이징 로직을 간소화 - 기존의 Base64 인코딩/디코딩 방식을 제거하고, 커서 값을 `lastKeptAt` (LocalDateTime)과 `lastId` (Long) 두 개의 파라미터로 직접 받도록 변경 --- .../domain/mybar/service/MyBarService.java | 55 ++++--------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/service/MyBarService.java b/src/main/java/com/back/domain/mybar/service/MyBarService.java index 02619fe2..fe934d62 100644 --- a/src/main/java/com/back/domain/mybar/service/MyBarService.java +++ b/src/main/java/com/back/domain/mybar/service/MyBarService.java @@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -28,41 +27,38 @@ public class MyBarService { private final UserRepository userRepository; private final CocktailRepository cocktailRepository; + // 커서: lastKeptAt + lastId를 그대로 파라미터로 사용 @Transactional(readOnly = true) - public MyBarListResponseDto getMyBar(Long userId, String cursor, int limit) { + public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); - int fetchSize = safeLimit + 1; // hasNext 판별용으로 1개 더 조회 + int fetchSize = safeLimit + 1; // 다음 페이지 여부 판단용으로 1개 더 조회 List rows; Pageable pageable = PageRequest.of(0, fetchSize); - if (cursor == null || cursor.isBlank()) { + if (lastKeptAt == null || lastId == null) { Page page0 = myBarRepository .findByUser_IdAndStatusOrderByKeptAtDescIdDesc(userId, KeepStatus.ACTIVE, pageable); rows = page0.getContent(); } else { - // cursor 포맷: epochMillis|id (Base64 URL-safe, padding 없음) - Cursor decoded = Cursor.decode(cursor); - LocalDateTime keptAtCursor = LocalDateTime.ofEpochSecond(decoded.epochMillis / 1000, (int)((decoded.epochMillis % 1000) * 1_000_000), ZoneOffset.UTC); - rows = myBarRepository.findSliceByCursor(userId, KeepStatus.ACTIVE, keptAtCursor, decoded.id, pageable); + rows = myBarRepository.findSliceByCursor(userId, KeepStatus.ACTIVE, lastKeptAt, lastId, pageable); } boolean hasNext = rows.size() > safeLimit; - if (hasNext) { - rows = rows.subList(0, safeLimit); - } + if (hasNext) rows = rows.subList(0, safeLimit); List items = new ArrayList<>(); for (MyBar myBar : rows) items.add(MyBarItemResponseDto.from(myBar)); - String nextCursor = null; + LocalDateTime nextKeptAt = null; + Long nextId = null; if (hasNext && !rows.isEmpty()) { MyBar last = rows.get(rows.size() - 1); - long epochMillis = last.getKeptAt().toEpochSecond(ZoneOffset.UTC) * 1000L + (last.getKeptAt().getNano() / 1_000_000); - nextCursor = Cursor.encode(epochMillis, last.getId()); + nextKeptAt = last.getKeptAt(); + nextId = last.getId(); } - return new MyBarListResponseDto(items, hasNext, nextCursor); + return new MyBarListResponseDto(items, hasNext, nextKeptAt, nextId); } @Transactional @@ -101,32 +97,3 @@ public void unkeep(Long userId, Long cocktailId) { } } -class Cursor { - final long epochMillis; - final long id; - - Cursor(long epochMillis, long id) { - this.epochMillis = epochMillis; - this.id = id; - } - - static String encode(long epochMillis, long id) { - String raw = epochMillis + "|" + id; - return java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - static Cursor decode(String cursor) { - try { - byte[] decoded = java.util.Base64.getUrlDecoder().decode(cursor); - String s = new String(decoded, java.nio.charset.StandardCharsets.UTF_8); - String[] parts = s.split("\\|"); - if (parts.length != 2) throw new com.back.global.exception.ServiceException(400, "invalid-cursor-형식이-올바르지-않습니다"); - long millis = Long.parseLong(parts[0]); - long id = Long.parseLong(parts[1]); - return new Cursor(millis, id); - } catch (Exception e) { - throw new com.back.global.exception.ServiceException(400, "invalid-cursor-디코드에-실패했습니다"); - } - } -} From 8a5bd1617dff8a2fc33de59678492512ce1b0de7 Mon Sep 17 00:00:00 2001 From: meohin Date: Mon, 22 Sep 2025 14:49:22 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20MyBar=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=A4=EC=84=9C=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyBar 목록 조회 API의 `@GetMapping` 메서드 파라미터를 커서 기반 페이징에 맞게 변경 - 기존의 단일 `cursor` 문자열 필드 대신, `lastKeptAt` (LocalDateTime)과 `lastId` (Long)를 직접 받도록 수정 - `lastKeptAt` 파라미터에 `@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)`을 적용하여 ISO 8601 형식의 시간 데이터를 자동으로 변환 --- .../com/back/domain/mybar/controller/MyBarController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/mybar/controller/MyBarController.java b/src/main/java/com/back/domain/mybar/controller/MyBarController.java index 8f918e41..65fe4425 100644 --- a/src/main/java/com/back/domain/mybar/controller/MyBarController.java +++ b/src/main/java/com/back/domain/mybar/controller/MyBarController.java @@ -9,6 +9,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; @RestController @RequestMapping("/me/bar") @@ -21,10 +23,12 @@ public class MyBarController { @GetMapping public RsData getMyBarList( @AuthenticationPrincipal(expression = "id") Long userId, - @RequestParam(required = false) String cursor, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastKeptAt, + @RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit ) { - MyBarListResponseDto body = myBarService.getMyBar(userId, cursor, limit); + MyBarListResponseDto body = myBarService.getMyBar(userId, lastKeptAt, lastId, limit); return RsData.successOf(body); // code=200, message="success" }