본문 바로가기
프로젝트/FitTrip

[커뮤니티 서비스] 포럼 조회시 페이징 반환 Slice로 처리

by 진꿈청 2024. 7. 18.

커뮤니티 서비스의 포럼 채널

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 객체 응답 형식을 구현한게 마음에 들었다.