미니 프로젝트/이노베이션 7주차

이노베이션 7주차 4 - 페이지네이션, JSON 기본생성자

구너드 2023. 7. 26. 02:10

1. 페이지네이션

페이지네이션 코드를 작성하면서 프론트엔드측에서 전체 페이지 개수가 계속 1개로 내려온다는 피드백을 받았다. 페이지 정보를 같이 내려주기위해서 PageImpl을 반환하고 있었는데 전체 페이지의 개수가 2개임에도 불구하고 1개로 내려오는 이유를 찾고 이를 수정하고자 했다. 

 

<문제의 페이지네이션 코드>

    @Transactional(readOnly = true)
    public Page<StoryResponseDto> findAllStory(int page, int size) {

        Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id"));
        Page<Story> storyList = storyRepository.findAll(pageable);

        List<StoryResponseDto> result = storyList
                .stream()
                .map(story -> new StoryResponseDto().All(story))
                .collect(Collectors.toList());


        long total = storyList.getTotalPages();

        return new PageImpl<>(result, pageable, total);
    }

로직 자체는 큰 문제가 없어보였다. 어떤 부분에서 전체 페이지 개수가 제대로 계산되지 않는지 파악하기 위해서 PageImpl쪽을 좀 더 자세히 찾아봤다. 

 

PageImpl 반환 시 주의사항

페이징 처리를 위해 전체 데이터 리스트가 필요하다. 특히 전체 데이터의 크기를 알아야 페이징 처리를 올바르게 처리할 수 있는데, 기존의 코드에서는 전체 데이터의 개수가 아닌 전체 페이지의 개수자체를 넘겼다. 때문에 pageable에 담긴 페이징 관련 데이터를 바탕으로 계속해서 1만 나오게 된 것. 따라서 getTotalPages가 아닌 getTotalElements를 써야 올바른 페이징 처리가 가능하다.

 

<수정한 코드>

        long total = storyList.getTotalElements();

        return new PageImpl<>(result, pageable, total);

 

2.페이지 관련 URI

page에 대한 정보를 클라이언트에게서 받았을 때 쿼리파리미터? or PathVariable?

    @GetMapping
    public ApiResponse<?> readAllStory(int page, int size) {
        return ResponseUtils.ok(storyService.findAllStory(page, size));
    }

 

해당 부분을 작성하면서 의문이 들었다. 처음에는 쿼리파라미터 방식을 이용해 /story?page=1의 방식으로 오프셋을 넘겨받는 게 당연하다고 생각했는데 문득 경로변수가 좀 더 RESTFul한 Api가 아닐까 하는 고민이 되었다. 리소스를 표현하고 이러한 리소스에 대한 상태를 주고 받는 것이 중요하기 때문에 하나의 /로 구분하여 페이지 정보를 표현하는 게 더 좋지 않을까 싶었다. 이 부분에 대해서 찾아본 결과, 경로변수는 리소스를 식별하는 데 사용하는 것이 적합한데, 페이지 정보는 리소스의 식별자를 나타내는 것이 아니기 때문이다. 즉 해당 데이터의 페이징 정보를 자원을 식별하고, 상태를 변경하는 쪽으로 생각한다면 경로변수를 사용하는 것이 적절하나, 그렇지 않다면 쿼리파라미터 방식을 사용하는 게 적절할 수 있다. 따라서 해당 리소스의 페이지가 리소스에 대해 얼마나 직접적인지에 따라 이를 URI에 적용하면 좀 더 RESTFul한 Api를 설계할 수 있다.

 

다만 내 개인적인 생각으로 페이징 정보는 특정 리소스에 대한 클라이언트의 특정 요청이고, 리소스의 상태를 변경하는 것이 아닌 추가적인 리소스의 정보를 요청하는 것이기 때문에 쿼리 파라미터 방식으로 페이징 정보를 넘기는 게 좀 더 적합하다고 생각한다. 또 쿼리파라미터 방식은 가독성과 유연성 측면에서 경로변수보다 좀 더 명확한 특성을 띄고 있고, 경로의 길이에 제한이 있을 때 이를 회피할 수 있는 장점이 있기 때문에 이번 프로젝트에서는 쿼리파라미터를 이용하는 방식으로 페이지네이션을 작성하였다. 다만 이 부분은 좀 더 같이 공부하는 팀원분들과 이야기를 해봐야겠다는 생각이 들었다.


3. JSON과 기본 생성자

포스트맨으로 데이터 수정을 테스트하는 과정에서 변경된 사항이 DB에는 정상적으로 반영되나, response는 아무런 응답값을 반환하지 않아서 해당 문제를 같이 살펴봐달라는 팀원의 요청을 받았다. DB에 수정사항이 반영되었다는 것은 해당 데이터를 입력받고, DB로 변경하는 로직까지는 정상적이나 이를 응답하는 쪽에서 문제가 발생하였다고 판단하였고 아마 JSON 변환 과정에서 에러가 발생한 게 아닐까라는 대략적인 추측을 할 수 있었다.

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class StoryResponseDto {

    private Long id;
    private String title;
    private String content;
    private long liked;
    private String username;
    private String image;
    private LocalDateTime createdAt;
    private List<CommentResponseDto> commentList;
    private long viewCount;
    private boolean userLikes;

NoArgsContructor는 정상적으로 있었기에 어떤 부분에서 JSON 오류가 발생했는지 헷갈렸다. 그러다 문득 접근제어자 Protected로 설정되어있음을 확인하였고 해당 StoryResponseDto를 반환하는 곳이 같은 패키지가 아님을 확인했다. 즉 접근제어자 Protected는 같은 패키지 및 다른 패키지의 자식 클래스에서 사용이 가능하도록 설정되어 있는데 현재 JSON을 반환하는 곳은 두 가지 조건을 모두 충족시키지 못하여 기본 생성자를 정상적으로 사용할 수 없게된 것이었다. 때문에 해당 오류가 발생하였고 접근제어자를 삭제하고 포스트맨으로 테스트를 해본 결과 정상적으로 반환값이 나오는 것을 확인할 수 있었다.

 

JSON은 데이터를 교환하는데 널리 사용되는 경량화된 데이터 형식이다. 기본적으로 객체 형태로 표현되며 객체의 속성과 값들을 key-value 형태로 띄고 있다. JSON 데이터를 Java 객체로 변환하는데 기본생성자가 필요한 이유는 다음과 같다.

 

1.객체 직렬화와 역직렬화

JSON 데이터를 Java 객체로 변환하는 것을 역직렬화라고 한다. 이를 위해서는 JSON 데이터를 Java 객체로 매핑해야하는데 이 때 기본생성자를 이용해서 객체를 생성하고 매핑한다.

 

2.Java Reflection

역직렬화를 수행할 때 대부분의 JSON 라이브러리들은 Java Reflection을 사용한다. Reflection은 런타임에서 클래스의 정보를 분석하고 수정할 수 있는 기능을 제공한다. 기본 생성자가 없다면 Reflection을 통해 객체를 생성할 수 없기 때문에 역직렬화가 불가능하다.

 

3.객체 초기화

기본 생성자를 이용하여 객체를 초기화할 수 있다. 이는 JSON 데이터를 객체로 변환할 때 해당 객체를 생성하고 필드에 값을 설정하기 위해 기본 생성자를 호출하여 초기화하는 방식으로 사용된다.

 

코드 리팩토링을 하다보니 벌써 7주차도 막바지를 향하는 것 같다.