Skip to content

Commit 13c04a1

Browse files
안훈기안훈기
authored andcommitted
✨Feat: 내가 작성한 게시물 API + 동호회 조회 API (무한스크롤 적용)
1 parent 4cb72ea commit 13c04a1

10 files changed

Lines changed: 168 additions & 45 deletions

File tree

src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,28 @@
2727
@Tag(name = "club", description = "동호회 관련 API")
2828
public class ClubController {
2929

30-
private final ClubServiceImpl clubService;
30+
private final ClubServiceImpl clubService;
3131

32-
@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
33-
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)")
34-
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
35-
@RequestPart("request") @Valid ClubCreateRequest request,
36-
@RequestPart(value = "image", required = false) MultipartFile image,
37-
@AuthenticationPrincipal User user) {
38-
ClubResponse response = clubService.createClub(request, image, user);
39-
return ResponseEntity.status(HttpStatus.CREATED)
40-
.body(BaseResponse.success("동호회 생성 성공", response));
41-
}
32+
@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
33+
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)")
34+
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
35+
@RequestPart("request") @Valid ClubCreateRequest request,
36+
@RequestPart(value = "image", required = false) MultipartFile image,
37+
@AuthenticationPrincipal User user) {
38+
ClubResponse response = clubService.createClub(request, image, user);
39+
return ResponseEntity.status(HttpStatus.CREATED)
40+
.body(BaseResponse.success("동호회 생성 성공", response));
41+
}
42+
@GetMapping("/me")
43+
@Operation(summary = "내가 가입한 동호회 조회(무한스크롤)", description = "로그인한 사용자가 가입한 동호회를 커서 기반 무한스크롤로 조회합니다.")
44+
public ResponseEntity<BaseResponse<ClubScrollResponse>> getMyClubs(
45+
@Parameter(description = "커서(마지막 clubId). 첫 조회는 null") @RequestParam(required = false) Long cursor,
46+
@Parameter(description = "조회 개수", example = "10") @RequestParam(defaultValue = "10") int size,
47+
@AuthenticationPrincipal User user
48+
) {
49+
ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user);
50+
return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response));
51+
}
4252
@GetMapping
4353
@Operation(summary = "모든 동호회 조회 (무한스크롤)",
4454
description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.")
@@ -60,23 +70,24 @@ public ResponseEntity<BaseResponse<ClubDetailResponse>> getClub(
6070
ClubDetailResponse response = clubService.getClub(clubId);
6171
return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response));
6272
}
63-
@PutMapping("/{clubId}")
64-
@Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.")
65-
public ResponseEntity<BaseResponse<ClubResponse>> updateClub(
66-
@PathVariable Long clubId,
67-
@RequestBody @Valid ClubUpdateRequest request,
68-
@AuthenticationPrincipal User user) {
69-
ClubResponse response = clubService.updateClub(clubId, request, user);
70-
return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response));
71-
}
7273

73-
@PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
74-
@Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.")
75-
public ResponseEntity<BaseResponse<ClubImageResponse>> updateClubImage(
76-
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
77-
@RequestPart("image") MultipartFile image,
78-
@AuthenticationPrincipal User user) {
79-
ClubImageResponse response = clubService.updateClubImage(clubId, image, user);
80-
return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response));
81-
}
74+
@PutMapping("/{clubId}")
75+
@Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.")
76+
public ResponseEntity<BaseResponse<ClubResponse>> updateClub(
77+
@PathVariable Long clubId,
78+
@RequestBody @Valid ClubUpdateRequest request,
79+
@AuthenticationPrincipal User user) {
80+
ClubResponse response = clubService.updateClub(clubId, request, user);
81+
return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response));
82+
}
83+
84+
@PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
85+
@Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.")
86+
public ResponseEntity<BaseResponse<ClubImageResponse>> updateClubImage(
87+
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
88+
@RequestPart("image") MultipartFile image,
89+
@AuthenticationPrincipal User user) {
90+
ClubImageResponse response = clubService.updateClubImage(clubId, image, user);
91+
return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response));
92+
}
8293
}

src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ public interface ClubRepository extends JpaRepository<Club, Long> {
1919
WHERE (:cursor IS NULL OR c.id < :cursor)
2020
ORDER BY c.id DESC
2121
""")
22-
List<Club> findClubsByCursor(
23-
@Param("cursor") Long cursor,
24-
Pageable pageable
25-
);
22+
List<Club> findClubsByCursor(@Param("cursor") Long cursor, Pageable pageable);
23+
24+
@Query("""
25+
select c
26+
from ClubMember cm
27+
join cm.club c
28+
where cm.user.id = :userId
29+
and (:cursor is null or c.id < :cursor)
30+
order by c.id desc
31+
""")
32+
List<Club> findMyClubsByCursor(@Param("userId") Long userId, @Param("cursor") Long cursor, Pageable pageable);
2633
}

src/main/java/com/be/sportizebe/domain/club/service/ClubService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ public interface ClubService {
1919
ClubDetailResponse getClub(Long clubId); // 동호회 개별 조회
2020

2121
ClubScrollResponse getClubsByScroll(Long cursor, int size); // 동호회 전체 조회 (무한 스크롤)
22+
23+
ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user); // 내가 가입한 동호회 조회 (무한 스크롤)
24+
2225
}

src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,16 @@ public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User
111111

112112
return ClubImageResponse.from(clubImageUrl);
113113
}
114+
@Override
115+
@Transactional(readOnly = true)
116+
public ClubDetailResponse getClub(Long clubId) {
117+
Club club = clubRepository.findById(clubId)
118+
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));
114119

120+
int memberCount = clubMemberRepository.countByClubId(clubId);
121+
122+
return ClubDetailResponse.from(club, memberCount);
123+
}
115124
@Override
116125
@Transactional(readOnly = true)
117126
public ClubScrollResponse getClubsByScroll(Long cursor, int size) {
@@ -140,15 +149,30 @@ public ClubScrollResponse getClubsByScroll(Long cursor, int size) {
140149

141150
return new ClubScrollResponse(items, nextCursor, hasNext);
142151
}
143-
144152
@Override
145153
@Transactional(readOnly = true)
146-
public ClubDetailResponse getClub(Long clubId) {
147-
Club club = clubRepository.findById(clubId)
148-
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));
154+
public ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user) {
149155

150-
int memberCount = clubMemberRepository.countByClubId(clubId);
156+
Pageable pageable = PageRequest.of(0, size + 1);
151157

152-
return ClubDetailResponse.from(club, memberCount);
158+
List<Club> clubs = clubRepository.findMyClubsByCursor(user.getId(), cursor, pageable);
159+
160+
boolean hasNext = clubs.size() > size;
161+
162+
if (hasNext) {
163+
clubs = clubs.subList(0, size);
164+
}
165+
166+
List<ClubListItemResponse> items = clubs.stream()
167+
.map(club -> {
168+
int memberCount = clubMemberRepository.countByClubId(club.getId());
169+
return ClubListItemResponse.from(club, memberCount);
170+
})
171+
.toList();
172+
173+
Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).clubId();
174+
175+
return new ClubScrollResponse(items, nextCursor, hasNext);
153176
}
177+
154178
}

src/main/java/com/be/sportizebe/domain/post/controller/PostController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.be.sportizebe.domain.post.dto.request.CreatePostRequest;
44
import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest;
5+
import com.be.sportizebe.domain.post.dto.response.CursorPageResponse;
56
import com.be.sportizebe.domain.post.dto.response.PostPageResponse;
67
import com.be.sportizebe.domain.post.dto.response.PostResponse;
78
import com.be.sportizebe.domain.post.entity.PostProperty;
@@ -10,9 +11,11 @@
1011
import com.be.sportizebe.global.response.BaseResponse;
1112
import io.swagger.v3.oas.annotations.Operation;
1213
import io.swagger.v3.oas.annotations.Parameter;
14+
import io.swagger.v3.oas.annotations.Parameters;
1315
import io.swagger.v3.oas.annotations.tags.Tag;
1416
import jakarta.validation.Valid;
1517
import lombok.RequiredArgsConstructor;
18+
import org.springdoc.core.annotations.ParameterObject;
1619
import org.springframework.data.domain.Page;
1720
import org.springframework.data.domain.Pageable;
1821
import org.springframework.data.domain.Sort;
@@ -71,4 +74,15 @@ public ResponseEntity<BaseResponse<PostPageResponse>> getPosts(
7174
PostPageResponse response = postService.getPosts(property, pageable);
7275
return ResponseEntity.ok(BaseResponse.success("게시글 목록 조회 성공", response));
7376
}
77+
@GetMapping("/posts/me")
78+
@Operation(summary = "내 게시글 목록 조회", description = "로그인한 사용자가 작성한 게시글을 최신순으로 무한 스크롤(커서 기반) 조회합니다.")
79+
public ResponseEntity<BaseResponse<CursorPageResponse<PostResponse>>> getMyPosts(
80+
@Parameter(description = "커서(마지막으로 조회된 게시글 ID). 첫 요청은 생략", example = "123")
81+
@RequestParam(required = false) Long cursor,
82+
@AuthenticationPrincipal User user)
83+
{
84+
CursorPageResponse<PostResponse> response = postService.getMyPostsCursor(user, cursor);
85+
return ResponseEntity.ok(BaseResponse.success("내 게시글 목록 조회 성공", response));
86+
}
87+
7488
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.be.sportizebe.domain.post.dto.response;
2+
import io.swagger.v3.oas.annotations.media.Schema;
3+
import java.util.List;
4+
5+
@Schema(description = "커서 기반 무한 스크롤 응답")
6+
public record CursorPageResponse<T>(
7+
8+
@Schema(description = "조회된 데이터 목록")
9+
List<T> items,
10+
11+
@Schema(description = "다음 조회를 위한 커서 값 (없으면 null)", example = "123")
12+
Long nextCursor,
13+
14+
@Schema(description = "다음 페이지 존재 여부", example = "true")
15+
boolean hasNext
16+
) {
17+
public static <T> CursorPageResponse<T> of(List<T> items, Long nextCursor, boolean hasNext) {
18+
return new CursorPageResponse<>(items, nextCursor, hasNext);
19+
}
20+
}

src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
import org.springframework.data.domain.Pageable;
77
import org.springframework.data.jpa.repository.JpaRepository;
88

9+
import java.util.List;
10+
911
public interface PostRepository extends JpaRepository<Post, Long> {
10-
Page<Post> findByProperty(PostProperty property, Pageable pageable);
12+
Page<Post> findByProperty(PostProperty property, Pageable pageable);
13+
14+
// 첫 페이지: 11개(10 + 1)
15+
List<Post> findTop11ByUserIdOrderByIdDesc(Long userId);
16+
17+
// 다음 페이지: 11개(10 + 1)
18+
List<Post> findTop11ByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long cursor);
19+
1120
}

src/main/java/com/be/sportizebe/domain/post/service/PostService.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.be.sportizebe.domain.post.dto.request.CreatePostRequest;
44
import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest;
5+
import com.be.sportizebe.domain.post.dto.response.CursorPageResponse;
56
import com.be.sportizebe.domain.post.dto.response.PostPageResponse;
67
import com.be.sportizebe.domain.post.dto.response.PostResponse;
78
import com.be.sportizebe.domain.post.entity.PostProperty;
@@ -11,11 +12,14 @@
1112
import org.springframework.web.multipart.MultipartFile;
1213

1314
public interface PostService {
14-
PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user); // 게시글 생성
15+
PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user); // 게시글 생성
1516

16-
PostResponse updatePost(Long postId, UpdatePostRequest request, User user); // 게시글 수정
17+
PostResponse updatePost(Long postId, UpdatePostRequest request, User user); // 게시글 수정
1718

18-
void deletePost(Long postId, User user); // 게시글 삭제
19+
void deletePost(Long postId, User user); // 게시글 삭제
1920

20-
PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회
21+
PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회
22+
23+
CursorPageResponse<PostResponse> getMyPostsCursor(User user, Long cursor); // 내가 쓴 게시글 무한 스크롤 조회
2124
}
25+

src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.be.sportizebe.domain.post.service;
22

33
import com.be.sportizebe.domain.post.dto.request.CreatePostRequest;
4+
import com.be.sportizebe.domain.post.dto.response.CursorPageResponse;
45
import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest;
56
import com.be.sportizebe.domain.post.dto.response.PostPageResponse;
67
import com.be.sportizebe.domain.post.dto.response.PostResponse;
@@ -10,6 +11,7 @@
1011
import com.be.sportizebe.domain.post.repository.PostRepository;
1112
import com.be.sportizebe.domain.user.entity.User;
1213
import com.be.sportizebe.global.exception.CustomException;
14+
import com.be.sportizebe.global.exception.GlobalErrorCode;
1315
import com.be.sportizebe.global.s3.enums.PathName;
1416
import com.be.sportizebe.global.s3.service.S3Service;
1517
import jakarta.transaction.Transactional;
@@ -18,17 +20,21 @@
1820
import org.springframework.cache.annotation.CacheEvict;
1921
import org.springframework.cache.annotation.Cacheable;
2022
import org.springframework.data.domain.Page;
23+
import org.springframework.data.domain.PageRequest;
2124
import org.springframework.data.domain.Pageable;
2225
import org.springframework.stereotype.Service;
2326
import org.springframework.web.multipart.MultipartFile;
2427

28+
import java.util.List;
29+
2530
@Slf4j
2631
@Service
2732
@RequiredArgsConstructor
2833
public class PostServiceImpl implements PostService {
2934

3035
private final PostRepository postRepository;
3136
private final S3Service s3Service;
37+
private static final int PAGE_SIZE = 10;
3238

3339
@Override
3440
@CacheEvict(cacheNames = "postList", allEntries = true)
@@ -85,4 +91,28 @@ public PostPageResponse getPosts(PostProperty property, Pageable pageable) {
8591
Page<Post> page = postRepository.findByProperty(property, pageable);
8692
return PostPageResponse.from(page);
8793
}
94+
95+
96+
@Override
97+
public CursorPageResponse<PostResponse> getMyPostsCursor(User user, Long cursor) {
98+
99+
if (user == null) {
100+
throw new CustomException(GlobalErrorCode.UNAUTHORIZED);
101+
}
102+
// cursor 없으면 첫 페이지, cursor 있으면 다음 페이지
103+
List<Post> posts = (cursor == null)
104+
? postRepository.findTop11ByUserIdOrderByIdDesc(user.getId())
105+
: postRepository.findTop11ByUserIdAndIdLessThanOrderByIdDesc(user.getId(), cursor);
106+
107+
boolean hasNext = posts.size() > PAGE_SIZE;
108+
if (hasNext) posts = posts.subList(0, PAGE_SIZE);
109+
110+
Long nextCursor = posts.isEmpty() ? null : posts.get(posts.size() - 1).getId();
111+
112+
return CursorPageResponse.of(
113+
posts.stream().map(PostResponse::from).toList(),
114+
hasNext ? nextCursor : null,
115+
hasNext
116+
);
117+
}
88118
}

src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
public enum GlobalErrorCode implements BaseErrorCode {
1111
INVALID_INPUT_VALUE("G001", "유효하지 않은 입력입니다.", HttpStatus.BAD_REQUEST),
1212
RESOURCE_NOT_FOUND("G002", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
13-
INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
13+
INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
14+
UNAUTHORIZED("G004", "로그인 인증이 필요합니다.", HttpStatus.UNAUTHORIZED);
1415

1516
private final String code;
1617
private final String message;

0 commit comments

Comments
 (0)