커뮤니티 서비스의 포럼 채널
FitTrip의 커뮤니티 서비스에는 서버가 존재한다.
서버 안에는 또 여러 개의 채널들이 존재한다.
채널
- 채팅 채널
- 음성 채널
- 포럼 채널
여기서, 포럼 채널은 사용자의 일상, 챌린지 등 다양한 사진과 함께 간단 포스팅이 가능한 채널이다.
(서버에 접속한 사람들끼리의 채팅도 가능하다.)
인스타그램, 페이스북 포스팅이라고 생각하면 간단할 것이다.
포럼 채널 페이징
포럼 채널에는 카테고리마다 수많은 포럼이 존재할 수 있기 때문에 굉장히 많은 포럼들이 존재한다.
따라서, 커뮤니티 서비스는 해당 포럼 채널을 적절히 페이징 처리를 해서 보내줘야 한다.
- 페이징을 해야하는 이유
- 수많은 데이터가 DB에 있을 때 페이징 처리를 하지 않으면 데이터들을 DB로 가져오는데 너무 많은 비용과 시간 소모
- 많은 데이터를 들고 있는 것도 메모리에도 큰 부담
- 사용자들이 그 많은 데이터를 한번에 다 보게하는 것도 적절하지 않음
페이징 처리
Spring Data JPA에서 우리는 PageRequest 객체를 통해 동적 페이징 처리가 매우 쉬워졌다.
그리고 PageRequest 객체를 통해 페이징을 할 때 반환형인 Page와 Slice가 사용된다.
커뮤니티 서비스는 Page와 Slice 중 하나의 반환형을 선택해서 페이징 처리를 진행해줘야 했다.
물론, 포스트 제목에 나와있듯이 커뮤니티 서비스는 Slice를 선택해서 페이징 처리를 했다.
이유는 다음과 같다.
Slice 사용 이점
- Slice는 카운트 쿼리가 나가지 않고 다음 Slice가 존재하는지 여부만 확인할 수 있다.
- 따라서, 데이터 양이 많으면 많을수록 Slice를 사용하는 것이 성능상 유리하다.
- Slice는 총 데이터 개수가 필요없는 환경에서 유용하다.(무한 스크롤)
커뮤니티 서비스는 전체 데이터 개수가 필요없다.
아까, 인스타그램과 페이스북을 예시로 들었듯이 우리가 인스타그램, 페이스북을 사용할 때,
총 데이터 개수가 궁금한가? 그렇지 않다.
커뮤니티 서비스도 같은 서버에 있는 사람들이 올린 포럼 글을 몇 개씩만 보고 추가로 필요할 때마다 화면에 보여지는
무한 스크롤 방식이 좀 더 효율적이다.
또한, 커뮤니티 서비스는 수많은 포럼을 가지고 있기에 데이터 양이 많을수록 이점을 갖는 Slice 방식이
커뮤니티 서비스에 더 적합하다.
(참고) Slice와 Page 비교
Slice의 경우:
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
order by
member0_.age asc limit ? offset ?
Page의 경우:
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
order by
member0_.age asc limit ? offset ?
# 카운트쿼리
select
count(member0_.member_id) as col_0_0_
from
member member0_
Page를 사용할 경우 카운트 쿼리가 하나 더 나가는 것을 볼 수 있다.
무조건적으로 Slice가 좋은 건 아니다. Page는 게시판과 같이 총 데이터가 필요한 환경일 경우
좀 더 효율적인 반환 방식이 될 수도 있다.
포럼 채널 페이징 구현
지금까지 Slice가 무엇인지와 커뮤니티 서비스에서 Slice를 활용한 이유에 관해 알아보았다.
그렇다면 이제는 커뮤니티 서비스에서 어떻게 구현이 되었는지 알아보도록 하자.
ChannelQueryController
@GetMapping("/{channelId}/{userId}")
public DataResponseDto read(@PathVariable("channelId") Long channelId,
@PathVariable("userId") Long userId,
@RequestParam(required = false, defaultValue = "0", value = "page") int pageNo,
@RequestParam(required = false, value = "title") String title){
SliceResponseDto response = channelQueryService.read(channelId, userId, pageNo, title);
return DataResponseDto.of(response);
}
Controller에서는 특별한 것 없이 페이지 번호, 제목을 전달받거나 전달받지 않는 방식으로 구현되어 있다.
ChannelQueryService
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ChannelQueryService {
private final ChannelCommandService channelCommandService;
private final ChannelRepository channelRepository;
private final ForumRepository forumRepository;
private final ChatServiceClient chatServiceClient;
public SliceResponseDto read(Long channelId, Long userId, int pageNo, String title) {
Channel findChannel = validateExsitsChannel(channelId);
if (validateForumChannel(findChannel)) {
channelCommandService.sendUserLocEvent(
userId,
findChannel.getServer().getId(),
channelId
);
int page = pageNo == 0 ? 0 : pageNo - 1;
int pageLimit = 15;
Pageable pageable = PageRequest.of(page, pageLimit, Sort.Direction.DESC, "createdAt");
Slice<Forum> forums = getForums(title, channelId, pageable);
List<Long> forumIds = getForumIds(forums);
// Forum 댓글 개수 읽어오기 로직
ForumChannelResponseDto forumsMessageCount = chatServiceClient.getForumsMessageCount(forumIds);
Slice<ForumResponse> forumResponseSlice = getForumResponseDtos(forums, pageable, forumsMessageCount);
return new SliceResponseDto<>(forumResponseSlice);
}
return null;
}
private List<Long> getForumIds(Slice<Forum> forums) {
return forums.getContent().stream()
.map(Forum::getId)
.toList();
}
private Slice<ForumResponse> getForumResponseDtos(Slice<Forum> forums, Pageable pageable, ForumChannelResponseDto forumsMessageCount) {
List<ForumResponse> forumList = forums.getContent()
.stream()
.map(forum ->
ForumResponse.of(forum,
forumsMessageCount.getForumsMessageCount()
.get(
forum.getId()
)
)
)
.collect(Collectors.toList());
return new SliceImpl<>(
forumList, pageable, forums.hasNext());
}
private Slice<Forum> getForums(String title, Long channelId, Pageable pageable) {
Slice<Forum> forums;
if(title == null || title.trim().isEmpty()){
forums = forumRepository.findForumsWithChannelId(channelId, pageable);
} else{
forums = forumRepository.findForumsByTitleWithChannelId(title, channelId, pageable);
}
return forums;
}
private boolean validateForumChannel(Channel channel) {
return channel.
getChannelType().
equals(ChannelType.FORUM);
}
private Channel validateExsitsChannel(Long channelId){
return channelRepository.findById(channelId)
.orElseThrow(() -> new ChannelException(
Code.NOT_FOUND, "Not Found Channel"
));
}
}
중점적으로 볼 것은 read() 메소드이다.
read()
- validateExistsChannel()
- 존재하는 채널인지 검사한다.
- validateForumChannel()
- 채널 읽기는 포럼 채널인 경우에만 가능하기 때문에
- 포럼 채널인지를 확인한다
- sendUserLocEvent()
- 유저의 최근 채널 위치를 상태관리 서비스에 업데이트 해줘야 한다.
- 따라서, Kafka Producer로 메시지를 전송한다.
- getForums()
- 클라이언트로부터 타이틀 조건을 전달받았는지를 확인하여 그것에 맞는 쿼리를 적용해,
- Slice 객체를 반환한다.
- Pageable
- 15개씩 포럼을 반환하도록 설정했다.(pageLimit = 15)
- getForumIds()
- 해당 포럼에 담긴 채팅의 개수를 알아내려면 채팅 서비스에 알기를 원하는 포럼의 ID를 전달해주어야 한다.
- 따라서, 포럼 ID를 Long 타입의 리스트로 추출한다.
- getForumMessageCounts()
- 채팅 서비스로부터 메시지의 개수를 읽어온다.
- getForumResponseDtos()
- 포럼 엔티티를 그냥 반환하면 안됨과 동시에 포럼 메시지의 개수도 반환해야 한다.
- 따라서, 포럼 엔티티를 DTO로 변환하고 메시지 개수를 포함한 Slice 객체로 변환하기 위한 메소드이다.
사용된 클래스
ForumResponse
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ForumResponse {
private Long forumId;
private Long channelId;
private String title;
private String writer;
private String content;
private ForumCategory forumCategory;
private LocalDateTime createAt;
private LocalDateTime updateAt;
private List<FileResponse> files;
private Long forumMessageCount;
public static ForumResponse of(Forum forum){
return ForumResponse.builder()
.forumId(forum.getId())
.channelId(forum.getChannelId())
.title(forum.getTitle())
.writer(forum.getUser().getName())
.content(forum.getContent())
.forumCategory(forum.getCategory())
.createAt(forum.getCreatedAt())
.updateAt(forum.getUpdatedAt())
.files(forum.getFiles()
.stream()
.map(FileResponse::of)
.toList()
)
.build();
}
public static ForumResponse of(Forum forum, Long forumMessageCount){
return ForumResponse.builder()
.forumId(forum.getId())
.channelId(forum.getChannelId())
.title(forum.getTitle())
.writer(forum.getUser().getName())
.content(forum.getContent())
.forumCategory(forum.getCategory())
.createAt(forum.getCreatedAt())
.updateAt(forum.getUpdatedAt())
.files(forum.getFiles()
.stream()
.map(FileResponse::of)
.toList()
)
.forumMessageCount(forumMessageCount)
.build();
}
}
포럼 엔티티를 DTO 형식으로 반환하기 위한 클래스이다.
ForumChannelResponseDto
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ForumChannelResponseDto {
private Map<Long, Long> forumsMessageCount;
}
채널 서비스로부터 포럼의 메시지 개수를 받아오기 위해 사용하는 공통 DTO이다.
SliceResponseDto
@Getter
public class SliceResponseDto<T> {
private final List<T> content;
private final SortResponse sort;
private final int currentPage;
private final int size;
private final boolean first;
private final boolean last;
public SliceResponseDto(Slice<T> sliceContent){
this.content = sliceContent.getContent();
this.sort = SortResponse.of(sliceContent.getSort());
this.currentPage = sliceContent.getNumber();
this.size = sliceContent.getSize();
this.first = sliceContent.isFirst();
this.last = sliceContent.isLast();
}
}
Slice 객체를 공통된 응답 형식을 통해 프론트로 전달하기 위한 ResponseDto이다.
ForumRepository
@Query("select f from Forum f join fetch f.user where f.title like CONCAT('%', :title, '%') and f.channelId = :channelId and f.deleted = false")
Slice<Forum> findForumsByTitleWithChannelId(String title, Long channelId, Pageable pageable);
@Query("select f from Forum f join fetch f.user where f.channelId = :channelId and f.deleted = false")
Slice<Forum> findForumsWithChannelId(Long channelId, Pageable pageable);
- findForumsByTitleWithChannelId()
- 사용자로부터 타이틀을 받아온 경우 사용할 JPQL이다.
- 특정 포럼 채널에 속해있는 포럼만을 가져와야 하기 때문에 channelId를 고정해주어야 한다.
- forumForumsWithChannelId()
- 특별한 타이틀을 입력받지 않았을 경우 실행된다.
API 응답
최종적인 API 응답은 다음과 같다.
{
"success": true,
"code": 0,
"message": "Ok",
"data": {
"content": [
{
"forumId": 10,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-11T19:45:33.102408",
"updateAt": "2024-05-11T19:45:33.102408",
"files": [
{
"fileId": 21,
"fileUrl": "http://image.png"
},
{
"fileId": 22,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 8,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:51.189937",
"updateAt": "2024-05-09T13:19:51.189937",
"files": [
{
"fileId": 15,
"fileUrl": "http://image.png"
},
{
"fileId": 16,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 7,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:50.629682",
"updateAt": "2024-05-09T13:19:50.629682",
"files": [
{
"fileId": 13,
"fileUrl": "http://image.png"
},
{
"fileId": 14,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 6,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:50.1436",
"updateAt": "2024-05-09T13:19:50.1436",
"files": [
{
"fileId": 11,
"fileUrl": "http://image.png"
},
{
"fileId": 12,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 5,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:49.710342",
"updateAt": "2024-05-09T13:19:49.710342",
"files": [
{
"fileId": 9,
"fileUrl": "http://image.png"
},
{
"fileId": 10,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 4,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:49.223886",
"updateAt": "2024-05-09T13:19:49.223886",
"files": [
{
"fileId": 7,
"fileUrl": "http://image.png"
},
{
"fileId": 8,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 3,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:48.758317",
"updateAt": "2024-05-09T13:19:48.758317",
"files": [
{
"fileId": 5,
"fileUrl": "http://image.png"
},
{
"fileId": 6,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
},
{
"forumId": 2,
"channelId": 3,
"title": "테스트 포럼3",
"writer": "abc",
"content": "테스트 포럼3",
"forumCategory": "CHALLENGE66",
"createAt": "2024-05-09T13:19:48.156112",
"updateAt": "2024-05-09T13:19:48.156112",
"files": [
{
"fileId": 3,
"fileUrl": "http://image.png"
},
{
"fileId": 4,
"fileUrl": "http://image.png"
}
],
"forumMessageCount" : 1
}
],
"sort": {
"sorted": true,
"direction": "DESC",
"orderProperty": "createdAt"
},
"currentPage": 0,
"size": 15,
"first": true,
"last": true
}
}
마치며
이번 포스팅에서는 커뮤니티 서비스에서 포럼 채널 읽기 요청시 사용한 Slice 페이징 처리에 관해 알아보았다.
보통, Page 반환형으로만 페이징 처리를 했었는데 Slice로 처리해본 것은 처음인 것 같다.
그리고 SliceResponseDto를 사용해서 공통된 Slice 객체 응답 형식을 구현한게 마음에 들었다.
'프로젝트 > FitTrip' 카테고리의 다른 글
[커뮤니티 서비스] 커뮤니티 서비스와 다른 서비스의 통신 (0) | 2024.08.07 |
---|---|
[MSA] FitTrip에서 MSA 설계 With Spring Cloud (0) | 2024.07.14 |
[커뮤니티 서비스] Redis를 활용한 초대코드 구현 (0) | 2024.07.05 |
[FitTrip] DELETE IN을 사용한 배치 처리로 얻을 수 있는 성능 향상 (0) | 2024.07.02 |
[커뮤니티 서비스] 커뮤니티 서비스 기능정리 (0) | 2024.06.29 |