ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -9,14 +9,14 @@
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
-import reviewme.security.resolver.LoginMemberSession;
-import reviewme.security.resolver.dto.LoginMember;
 import reviewme.reviewgroup.service.ReviewGroupLookupService;
 import reviewme.reviewgroup.service.ReviewGroupService;
 import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest;
 import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupSummaryResponse;
+import reviewme.security.resolver.LoginMemberSession;
+import reviewme.security.resolver.dto.LoginMember;
 
 @RestController
 @RequiredArgsConstructor
@@ -45,9 +45,12 @@ public ResponseEntity<ReviewGroupCreationResponse> createReviewGroup(
 
     @GetMapping("/v2/groups")
     public ResponseEntity<ReviewGroupPageResponse> getMyReviewGroups(
+            @RequestParam(required = false) Long lastReviewGroupId,
+            @RequestParam(required = false) Integer size,
             @LoginMemberSession LoginMember loginMember
     ) {
-        ReviewGroupPageResponse response = reviewGroupLookupService.getMyReviewGroups();
+        ReviewGroupPageResponse response = reviewGroupLookupService.getReviewGroupsByMember(
+                lastReviewGroupId, size, loginMember.id());
         return ResponseEntity.ok(response);
     }
 }

리뷰: 의도가 명확하게 todo로 표시해되어서 잘 이해할 수 있었어요😊
@@ -1,14 +1,31 @@
 package reviewme.reviewgroup.repository;
 
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;
 import reviewme.reviewgroup.domain.ReviewGroup;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 
 @Repository
 public interface ReviewGroupRepository extends JpaRepository<ReviewGroup, Long> {
 
     Optional<ReviewGroup> findByReviewRequestCode(String reviewRequestCode);
 
+    @Query("""
+             SELECT new reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse(
+                rg.id, rg.reviewee, rg.projectName, rg.reviewRequestCode, rg.createdAt, COUNT(r.id)
+             )
+             FROM ReviewGroup rg
+             LEFT JOIN Review r ON rg.id = r.reviewGroupId
+             WHERE rg.memberId = :memberId
+             AND (:lastReviewGroupId IS NULL OR rg.id < :lastReviewGroupId)
+             GROUP BY rg.id
+             ORDER BY rg.createdAt DESC, rg.id DESC
+             LIMIT :limit
+            """)
+    List<ReviewGroupPageElementResponse> findByMemberIdWithLimit(long memberId, Long lastReviewGroupId, int limit);
+
     boolean existsByReviewRequestCode(String reviewRequestCode);
 }

리뷰: 혹시 이 부분에서 왜 lastCreatedAt 이 사용되는지 설명해줄 수 있나요?
제가 이해가 부족하네요😓
@@ -1,12 +1,14 @@
 package reviewme.reviewgroup.service.dto;
 
-import java.time.LocalDate;
+import java.time.LocalDateTime;
 
 public record ReviewGroupPageElementResponse(
+
+        long reviewGroupId,
         String revieweeName,
         String projectName,
-        LocalDate createdAt,
         String reviewRequestCode,
-        int reviewCount
+        LocalDateTime createdAt,
+        long reviewCount
 ) {
 }

리뷰: 이게 있어야 사용자가 클릭했을 때 정보가 전달될 수 있겠네요👍
@@ -1,13 +1,17 @@
 package reviewme.reviewgroup.service;
 
+import java.util.List;
 import lombok.RequiredArgsConstructor;
+import org.springframework.lang.Nullable;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import reviewme.reviewgroup.domain.ReviewGroup;
 import reviewme.reviewgroup.repository.ReviewGroupRepository;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupSummaryResponse;
 import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException;
+import reviewme.util.PageSize;
 
 @Service
 @RequiredArgsConstructor
@@ -24,8 +28,19 @@ public ReviewGroupSummaryResponse getReviewGroupSummary(String reviewRequestCode
                 reviewGroup.getMemberId(), reviewGroup.getReviewee(), reviewGroup.getProjectName());
     }
 
-    public ReviewGroupPageResponse getMyReviewGroups() {
-        // TODO: 생성일자 최신순 정렬
-        return null;
+    @Transactional(readOnly = true)
+    public ReviewGroupPageResponse getReviewGroupsByMember(@Nullable Long lastReviewGroupId, @Nullable Integer size,
+                                                           long memberId) {
+        PageSize pageSize = new PageSize(size);
+        List<ReviewGroupPageElementResponse> elements = reviewGroupRepository.findByMemberIdWithLimit(
+                memberId, lastReviewGroupId, pageSize.getSize() + 1);
+
+        boolean isLastPage = elements.size() <= pageSize.getSize();
+        if (!isLastPage) {
+            elements = elements.subList(0, pageSize.getSize());
+        }
+
+        long newLastReviewGroupId = (!elements.isEmpty()) ? elements.get(elements.size() - 1).reviewGroupId() : 0;
+        return new ReviewGroupPageResponse(newLastReviewGroupId, isLastPage, elements);
     }
 }

리뷰: elements.subList(0, pageSize.size()); 여야 되지 않을까요?
그리고 elements 에 하나 잘린게 재할당되어야 합니다!
@@ -9,14 +9,14 @@
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
-import reviewme.security.resolver.LoginMemberSession;
-import reviewme.security.resolver.dto.LoginMember;
 import reviewme.reviewgroup.service.ReviewGroupLookupService;
 import reviewme.reviewgroup.service.ReviewGroupService;
 import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest;
 import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupSummaryResponse;
+import reviewme.security.resolver.LoginMemberSession;
+import reviewme.security.resolver.dto.LoginMember;
 
 @RestController
 @RequiredArgsConstructor
@@ -45,9 +45,12 @@ public ResponseEntity<ReviewGroupCreationResponse> createReviewGroup(
 
     @GetMapping("/v2/groups")
     public ResponseEntity<ReviewGroupPageResponse> getMyReviewGroups(
+            @RequestParam(required = false) Long lastReviewGroupId,
+            @RequestParam(required = false) Integer size,
             @LoginMemberSession LoginMember loginMember
     ) {
-        ReviewGroupPageResponse response = reviewGroupLookupService.getMyReviewGroups();
+        ReviewGroupPageResponse response = reviewGroupLookupService.getReviewGroupsByMember(
+                lastReviewGroupId, size, loginMember.id());
         return ResponseEntity.ok(response);
     }
 }

리뷰: 리베이스 후 변경 사항 적용했어요~
@@ -8,6 +8,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import java.time.LocalDateTime;
 import lombok.AccessLevel;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -49,6 +50,8 @@ public class ReviewGroup {
     @Embedded
     private GroupAccessCode groupAccessCode;
 
+    @Column(name = "created_at", nullable = false)
+    private LocalDateTime createdAt;
 
     private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, String projectName,
                         String reviewRequestCode, String groupAccessCode) {
@@ -64,6 +67,7 @@ private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, S
         } else {
             this.groupAccessCode = null;
         }
+        this.createdAt = LocalDateTime.now();
     }
 
     public ReviewGroup(Long memberId, long templateId, String reviewee, String projectName, String reviewRequestCode) {

리뷰: 저는 엔티티 필드의 정렬 순서가 어노테이션 기준이 아니라, 사용빈도나 중요도에 따라 논리적인 기준이 되어야 한다고 생각해요. 따라서 생성일은 엔티티의 본질적인 필드가 아니므로 맨 마지막에 배치하는 것이 맞다고 생각해요.

다른 필드들을 봐도 현재 어노테이션 별로 정렬되어 있진 않아요. (Column과 JoinColumn 혼재)
산초는 어노테이션 기준으로 정렬하는 것을 선호하는 이유가 있을까요?
@@ -1,13 +1,17 @@
 package reviewme.reviewgroup.service;
 
+import java.util.List;
 import lombok.RequiredArgsConstructor;
+import org.springframework.lang.Nullable;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import reviewme.reviewgroup.domain.ReviewGroup;
 import reviewme.reviewgroup.repository.ReviewGroupRepository;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupPageResponse;
 import reviewme.reviewgroup.service.dto.ReviewGroupSummaryResponse;
 import reviewme.reviewgroup.service.exception.ReviewGroupNotFoundByReviewRequestCodeException;
+import reviewme.util.PageSize;
 
 @Service
 @RequiredArgsConstructor
@@ -24,8 +28,19 @@ public ReviewGroupSummaryResponse getReviewGroupSummary(String reviewRequestCode
                 reviewGroup.getMemberId(), reviewGroup.getReviewee(), reviewGroup.getProjectName());
     }
 
-    public ReviewGroupPageResponse getMyReviewGroups() {
-        // TODO: 생성일자 최신순 정렬
-        return null;
+    @Transactional(readOnly = true)
+    public ReviewGroupPageResponse getReviewGroupsByMember(@Nullable Long lastReviewGroupId, @Nullable Integer size,
+                                                           long memberId) {
+        PageSize pageSize = new PageSize(size);
+        List<ReviewGroupPageElementResponse> elements = reviewGroupRepository.findByMemberIdWithLimit(
+                memberId, lastReviewGroupId, pageSize.getSize() + 1);
+
+        boolean isLastPage = elements.size() <= pageSize.getSize();
+        if (!isLastPage) {
+            elements = elements.subList(0, pageSize.getSize());
+        }
+
+        long newLastReviewGroupId = (!elements.isEmpty()) ? elements.get(elements.size() - 1).reviewGroupId() : 0;
+        return new ReviewGroupPageResponse(newLastReviewGroupId, isLastPage, elements);
     }
 }

리뷰: 오 큰일날뻔 했네요! 👍👍
@@ -1,14 +1,31 @@
 package reviewme.reviewgroup.repository;
 
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;
 import reviewme.reviewgroup.domain.ReviewGroup;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 
 @Repository
 public interface ReviewGroupRepository extends JpaRepository<ReviewGroup, Long> {
 
     Optional<ReviewGroup> findByReviewRequestCode(String reviewRequestCode);
 
+    @Query("""
+             SELECT new reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse(
+                rg.id, rg.reviewee, rg.projectName, rg.reviewRequestCode, rg.createdAt, COUNT(r.id)
+             )
+             FROM ReviewGroup rg
+             LEFT JOIN Review r ON rg.id = r.reviewGroupId
+             WHERE rg.memberId = :memberId
+             AND (:lastReviewGroupId IS NULL OR rg.id < :lastReviewGroupId)
+             GROUP BY rg.id
+             ORDER BY rg.createdAt DESC, rg.id DESC
+             LIMIT :limit
+            """)
+    List<ReviewGroupPageElementResponse> findByMemberIdWithLimit(long memberId, Long lastReviewGroupId, int limit);
+
     boolean existsByReviewRequestCode(String reviewRequestCode);
 }

리뷰: 지금 응답(노출)에 대한 정렬 기준은 생성일(리뷰 작성일) 기준으로 하고 있는데요,
동시성 상황에서는 더 높은 id가 더 최신의 생성일을 가진다고 보장할 수 없기 때문에 응답을 id 기준으로만 했을 때 의도한대로 동작하지 않을 수 있어요.

**id가 생성일을 보장하지 않는 이유**는
- 생성일은 엔티티가 생성될 때, 생성자에서 설정되고,
- id는 트랜잭션이 커밋되고 영속화 될 때 지정되니
- 여러 요청이 멀티스레 상황에서는 처리하는 순서에 따라 생성일이 뒤바뀔 수 있어요.

따라서 lastCreatedAt도 함께 전달해서 기준을 강화하려는 목적이에요.
하지만 이렇게 사용할 경우, 프론트에서 lastId 뿐만 아니라 lastCreatedAt 까지 같이 전달받아야 해요. (순전히 lastId만 받으면 이전에 응답한 id가 응답될 경우가 존재해요.)

---
쓰다보니 프론트에서 lastCreatedAt을 요청에 포함해달라고 하는 것도 지금 상황에서는 힘들 것 같고, 다른 방법으로 해결을 할 수 있을 것 같아서, 이슈 정리하고 PR 요청할게요. 

**해당 조회 로직에서는 lastId만 사용하는 것으로 변경**하겠습니다!
@@ -8,6 +8,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import java.time.LocalDateTime;
 import lombok.AccessLevel;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -49,6 +50,8 @@ public class ReviewGroup {
     @Embedded
     private GroupAccessCode groupAccessCode;
 
+    @Column(name = "created_at", nullable = false)
+    private LocalDateTime createdAt;
 
     private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, String projectName,
                         String reviewRequestCode, String groupAccessCode) {
@@ -64,6 +67,7 @@ private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, S
         } else {
             this.groupAccessCode = null;
         }
+        this.createdAt = LocalDateTime.now();
     }
 
     public ReviewGroup(Long memberId, long templateId, String reviewee, String projectName, String reviewRequestCode) {

리뷰: > 다른 필드들을 봐도 현재 어노테이션 별로 정렬되어 있진 않아요. (Column과 JoinColumn 혼재)

우리 코드가 그렇게 되어있긴 하네요😅

> 저는 엔티티 필드의 정렬 순서가 어노테이션 기준이 아니라, 사용빈도나 중요도에 따라 논리적인 기준이 되어야 한다고 생각해요. 따라서 생성일은 엔티티의 본질적인 필드가 아니므로 맨 마지막에 배치하는 것이 맞다고 생각해요.

중요도에 따라서 정렬하는 것도 일리가 있다 생각해요👍
그럼 createdAt 의 위치는 지금이 맞겠네요!

---

그와 별개로 테드가 개인적인 선호의 이유를 물어봐서 답하자면, 
Highlight이나 OptionGroup같은 경우 임베디드가 마지막에 있어서 통일하는게 좋겠다고 생각했던 것입니다~
@@ -1,14 +1,31 @@
 package reviewme.reviewgroup.repository;
 
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;
 import reviewme.reviewgroup.domain.ReviewGroup;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 
 @Repository
 public interface ReviewGroupRepository extends JpaRepository<ReviewGroup, Long> {
 
     Optional<ReviewGroup> findByReviewRequestCode(String reviewRequestCode);
 
+    @Query("""
+             SELECT new reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse(
+                rg.id, rg.reviewee, rg.projectName, rg.reviewRequestCode, rg.createdAt, COUNT(r.id)
+             )
+             FROM ReviewGroup rg
+             LEFT JOIN Review r ON rg.id = r.reviewGroupId
+             WHERE rg.memberId = :memberId
+             AND (:lastReviewGroupId IS NULL OR rg.id < :lastReviewGroupId)
+             GROUP BY rg.id
+             ORDER BY rg.createdAt DESC, rg.id DESC
+             LIMIT :limit
+            """)
+    List<ReviewGroupPageElementResponse> findByMemberIdWithLimit(long memberId, Long lastReviewGroupId, int limit);
+
     boolean existsByReviewRequestCode(String reviewRequestCode);
 }

리뷰: > 동시성 상황에서는 더 높은 id가 더 최신의 생성일을 가진다고 보장할 수 없기 때문에

아하 어떤 상황을 대비했는지 이해가 되네요 🙆🏻‍♀️
사실 동시성이 있을 정도의 시간차는 사실 크게 중요하지 않다 생각했어요.
그런데 정확도를 높일 수 있으면 그 방법을 고민해보는것도 좋겠네요 ㅎㅎ

지금도 id 를 기준으로 페이지만 나눌 뿐이지, 실제로 정렬은 created_at 을 첫번째 기준으로 하고 있으니
하나의 페이지 안에 있다면, 반드시 최신 생성일대로 정렬되는게 보장되니 
지금의 구현도 "최신순 정렬"을 충족하고 있다 생각합니다.

찾아보니 inset 쿼리가 준비되는 시점에 created_at 을 채워주는 `@CreatedDate`JPA 어노테이션이 있긴 한데,
inset 쿼리가 생성되는 시점과 DB에 저장되는 시점은 다를 수 있으니 이 경우도 반드시 
"id순 정렬 == 저장 순 정렬" 임을 보장하진 못할 것 같아요.

그래도 생성자에서 now() 로 하는것보다는 더 정확도가 있다 생각해서
이 PR에서 `@CreatedDate` 을 적용해봐도 좋을 것 같습니다!
@@ -8,6 +8,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import java.time.LocalDateTime;
 import lombok.AccessLevel;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -49,6 +50,8 @@ public class ReviewGroup {
     @Embedded
     private GroupAccessCode groupAccessCode;
 
+    @Column(name = "created_at", nullable = false)
+    private LocalDateTime createdAt;
 
     private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, String projectName,
                         String reviewRequestCode, String groupAccessCode) {
@@ -64,6 +67,7 @@ private ReviewGroup(@Nullable Long memberId, long templateId, String reviewee, S
         } else {
             this.groupAccessCode = null;
         }
+        this.createdAt = LocalDateTime.now();
     }
 
     public ReviewGroup(Long memberId, long templateId, String reviewee, String projectName, String reviewRequestCode) {

리뷰: 필드의 정렬은 정해진 것이 아니니 팀의 의견을 취합해서 사용하는 것이 좋다고 생각되어서 타당성을 통해서 결정하려 했어요! 무튼 의견 주셔서 고맙습니다 산초🙏
@@ -1,14 +1,31 @@
 package reviewme.reviewgroup.repository;
 
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;
 import reviewme.reviewgroup.domain.ReviewGroup;
+import reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse;
 
 @Repository
 public interface ReviewGroupRepository extends JpaRepository<ReviewGroup, Long> {
 
     Optional<ReviewGroup> findByReviewRequestCode(String reviewRequestCode);
 
+    @Query("""
+             SELECT new reviewme.reviewgroup.service.dto.ReviewGroupPageElementResponse(
+                rg.id, rg.reviewee, rg.projectName, rg.reviewRequestCode, rg.createdAt, COUNT(r.id)
+             )
+             FROM ReviewGroup rg
+             LEFT JOIN Review r ON rg.id = r.reviewGroupId
+             WHERE rg.memberId = :memberId
+             AND (:lastReviewGroupId IS NULL OR rg.id < :lastReviewGroupId)
+             GROUP BY rg.id
+             ORDER BY rg.createdAt DESC, rg.id DESC
+             LIMIT :limit
+            """)
+    List<ReviewGroupPageElementResponse> findByMemberIdWithLimit(long memberId, Long lastReviewGroupId, int limit);
+
     boolean existsByReviewRequestCode(String reviewRequestCode);
 }

리뷰: 오 의견 감사합니다! `@CreatedDate` 도 같이 고민해볼게요!!