반응형

이번 시간에는 게시글 목록 조회 API를 만들어보겠습니다.

페이지 번호와 각 페이지의 게시글 개수를 지정하면, 해당하는 페이지의 게시글 목록이 조회됩니다.

이 때 검색 조건도 지정할 수 있어야합니다. 

 

우리는 게시글을 조회할 때, 페이지 옵션뿐만 아니라, 조회하고자하는 카테고리 id와 사용자 id도 쿼리 파라미터로 지정할 수 있도록 하겠습니다.

카테고리 id와 사용자 id는 원하는 개수(0개 이상)만큼 지정할 수 있도록 할 것이고, 모든 조건을 모두 만족하는 게시글이 조회될 것입니다.

 

간단한 예시를 들어보면,

GET /api/posts?page=0&size=10&categoryId=1&categoryId=3&memberId=2&memberId=5

위와 같은 요청을 받았다고 가정해보겠습니다.

1번과 3번 카테고리에 속해있으면서, 2번과 5번 사용자가 작성한 게시글을 조회하되, 한 페이지의 크기가 10인 0번째 페이지를 조회하도록 할 것입니다.

 

즉, 동일한 검색 조건 사이에는 OR 연산이 수행되어야하고, 이렇게 묶인 OR 조건식 사이에는 AND 연산이 수행되어야합니다.

간단한 표현식으로 나타나면, (a1 OR a2 OR a3) AND (b1 OR b2) AND (c1 OR c2) 의 형태입니다.

 

위 요청 URL의 검색 조건에 따른 표현식을 적용하여 나타내보면,

(category_id=1 OR category_id=2) AND (member_id=2 OR member_id=5)

와 같은 형태가 될 것입니다.

 

이러한 동적 쿼리를 작성하기 위해, QueryDSL을 적용해보도록 하겠습니다.

이를 이용하면 자바 코드로 데이터베이스 쿼리를 간결하고 손쉽게 작성할 수 있습니다.

 

QueryDSL을 적용하는 방법은, 별도로 언급하지 않겠습니다.

저는 다음 링크를 참고하였습니다.

http://honeymon.io/tech/2020/07/09/gradle-annotation-processor-with-querydsl.html

 

 

QueryDSL 적용이 끝났으면, 게시글 목록 조회를 위한 DTO를 설계해보겠습니다.

게시글 목록에는 게시글의 모든 정보가 포함될 필요가 없기 때문에, 어떤 게시글인지 확인할 수 있는 최소한의 정보만 보여주도록 하겠습니다.

dto.post.PostSimpleDto를 작성해줍니다.

package kukekyakya.kukemarket.dto.post;

import ...

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostSimpleDto {
    private Long id;
    private String title;
    private String nickname;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdAt;
}

단순히 게시글 id, 제목, 작성자 닉네임, 작성일자만 가지고 있겠습니다.

 

 

검색 조건을 나타내는 dto.post.PostReadCondition도 작성해주겠습니다.

package kukekyakya.kukemarket.dto.post;

import ...

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostReadCondition {
    @NotNull(message = "페이지 번호를 입력해주세요.")
    @PositiveOrZero(message = "올바른 페이지 번호를 입력해주세요. (0 이상)")
    private Integer page;

    @NotNull(message = "페이지 크기를 입력해주세요.")
    @Positive(message = "올바른 페이지 크기를 입력해주세요. (1 이상)")
    private Integer size;

    private List<Long> categoryId = new ArrayList<>();
    private List<Long> memberId = new ArrayList<>();
}

페이지 번호는 0에서 시작하고, 페이지 크기는 1에서 시작되어야한다는 제약 조건을 지정해주었습니다.

카테고리 id와 사용자 id는 여러 개 전달받을 수 있습니다. 이 조건들은 선택적으로 지정할 수 있을 것입니다.

 

 

제약 조건을 바로 테스트해주겠습니다.

test 디렉토리에서 PostReadConditionValidationTest를 작성해줍니다.

package kukekyakya.kukemarket.dto.post;

import ...

class PostReadConditionValidationTest {
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validateTest() {
        // given
        PostReadCondition cond = createPostReadCondition(1, 1);

        // when
        Set<ConstraintViolation<PostReadCondition>> validate = validator.validate(cond);

        // then
        assertThat(validate).isEmpty();
    }

    @Test
    void invalidateByNullPageTest() {
        // given
        Integer invalidValue = null;
        PostReadCondition req = createPostReadCondition(invalidValue, 1);

        // when
        Set<ConstraintViolation<PostReadCondition>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNegativePageTest() {
        // given
        Integer invalidValue = -1;
        PostReadCondition req = createPostReadCondition(invalidValue, 1);

        // when
        Set<ConstraintViolation<PostReadCondition>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNullSizeTest() {
        // given
        Integer invalidValue = null;
        PostReadCondition req = createPostReadCondition(1, invalidValue);

        // when
        Set<ConstraintViolation<PostReadCondition>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNegativeOrZeroPageTest() {
        // given
        Integer invalidValue = 0;
        PostReadCondition req = createPostReadCondition(1, invalidValue);

        // when
        Set<ConstraintViolation<PostReadCondition>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }
}

자세한 설명은 생략하겠습니다.

 

사용된 PostReadConditionFactory는 다음과 같습니다.

package kukekyakya.kukemarket.factory.dto;

import ...

public class PostReadConditionFactory {
    public static PostReadCondition createPostReadCondition(Integer page, Integer size) {
        return new PostReadCondition(page, size, List.of(), List.of());
    }

    public static PostReadCondition createPostReadCondition(Integer page, Integer size, List<Long> categoryIds, List<Long> memberIds) {
        return new PostReadCondition(page, size, categoryIds, memberIds);
    }
}

전달받은 파라미터로 PostReadCondition 인스턴스를 생성해줍니다.

 

 

 

이제 쿼리를 작성해봅시다.

필요한 설정들을 등록하기 위해 config.QuerydslConfig를 작성해줍니다.

package kukekyakya.kukemarket.config;

import ...

@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
    private final EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

JPAQueryFactory를 빈으로 등록해주었습니다. 이를 이용하여 쿼리를 작성할 수 있습니다.

 

 

QueryDSL을 이용하여 쿼리를 작성하기 위해, 커스텀 리포지토리를 만들어줍시다.

repository.post.CustomPostRepository 인터페이스를 정의해줍니다.

package kukekyakya.kukemarket.repository.post;

import ...

public interface CustomPostRepository {
    Page<PostSimpleDto> findAllByCondition(PostReadCondition cond);
}

쿼리를 구현하고자 하는 메소드를 선언해주었습니다.

검색 조건에 대한 정보가 담긴 PostReadCondition을 전달받을 것입니다.

Page로 반환하여 페이징 결과에 대한 각종 정보를 손쉽게 확인할 수 있도록 하겠습니다.

 

 

기존의 PostRepository 인터페이스가 CustomPostRepository를 상속받도록 해줍니다.

package kukekyakya.kukemarket.repository.post;

import ...

public interface PostRepository extends JpaRepository<Post, Long>, CustomPostRepository {
    ...
}

이제 PostRepository에서 CustomPostRepository에 정의한 메소드를 호출할 수 있습니다.

 

 

CustomPostRepository의 구현체를 작성하여 쿼리를 구현해봅시다.

리포지토리 인터페이스 접미어에 Impl이 붙으면, 빈으로 등록된 리포지토리에서 해당 구현체의 메소드를 이용할 수 있도록 해줍니다.

repository.post.CustomPostRepositoryImpl을 작성해주겠습니다.

package kukekyakya.kukemarket.repository.post;

import ...

import static com.querydsl.core.types.Projections.constructor;
import static kukekyakya.kukemarket.entity.post.QPost.post;

@Transactional(readOnly = true) // 1
public class CustomPostRepositoryImpl extends QuerydslRepositorySupport implements CustomPostRepository { // 2

    private final JPAQueryFactory jpaQueryFactory; // 3

    public CustomPostRepositoryImpl(JPAQueryFactory jpaQueryFactory) { // 4
        super(Post.class);
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public Page<PostSimpleDto> findAllByCondition(PostReadCondition cond) { // 5
        Pageable pageable = PageRequest.of(cond.getPage(), cond.getSize());
        Predicate predicate = createPredicate(cond);
        return new PageImpl<>(fetchAll(predicate, pageable), pageable, fetchCount(predicate));
    }

    private List<PostSimpleDto> fetchAll(Predicate predicate, Pageable pageable) { // 6
        return getQuerydsl().applyPagination(
                pageable,
                jpaQueryFactory
                        .select(constructor(PostSimpleDto.class, post.id, post.title, post.member.nickname, post.createdAt))
                        .from(post)
                        .join(post.member)
                        .where(predicate)
                        .orderBy(post.id.desc())
        ).fetch();
    }

    private Long fetchCount(Predicate predicate) { // 7
        return jpaQueryFactory.select(post.count()).from(post).where(predicate).fetchOne();
    }

    private Predicate createPredicate(PostReadCondition cond) { // 8
        return new BooleanBuilder()
                .and(orConditionsByEqCategoryIds(cond.getCategoryId()))
                .and(orConditionsByEqMemberIds(cond.getMemberId()));
    }

    private Predicate orConditionsByEqCategoryIds(List<Long> categoryIds) { // 9
        return orConditions(categoryIds, post.category.id::eq);
    }

    private Predicate orConditionsByEqMemberIds(List<Long> memberIds) { // 10
        return orConditions(memberIds, post.member.id::eq);
    }

    private <T> Predicate orConditions(List<T> values, Function<T, BooleanExpression> term) { // 11
        return values.stream()
                .map(term)
                .reduce(BooleanExpression::or)
                .orElse(null);
    }
}

1. 조회를 수행하므로 readOnly로 설정해주었습니다.

2. 페이징을 간단하게 처리할 수 있도록 QuerydslRepositorySupport를 상속받았습니다. 여기에 정의된 메소드를 이용하면, 빌드된 쿼리에 손쉽게 페이징을 적용할 수 있습니다. 상위 클래스에 이미 @Repository가 정의되어있으므로, 하위 클래스에는 별도로 정의하지않았습니다.

3~4. 쿼리를 빌드하기 위해 빈에 등록해두었던 JPAQueryFactory를 주입받았습니다. 상위 클래스에 생성자가 하나 뿐이라, 직접 생성자를 정의해준 것입니다.

5. 전달받은 PostReadCondition으로 Predicate와 PageRequest를 생성하고, 조회 쿼리와 카운트 쿼리를 수행한 결과를 Page의 구현체로 반환해주었습니다. Page의 사용법은 테스트를 작성하면서 다시 알아보겠습니다.

6. 게시글 목록을 PostSimpleDto로 조회한 결과를 반환해줍니다. 파라미터로 전달받은 Predicate와 Pageable을 이용하여 조건식과 페이징을 적용할 것입니다. 상속받은 QuerydslRepositorySupport에 정의된 getQuerydsl().applyPagination을 이용하여 페이징이 적용된 쿼리를 빌드하였습니다. Projections.constructor를 이용하면 DTO에 즉시 프로젝션된 결과가 조회될 수 있습니다. 사용자 닉네임도 조회해야하므로 Member와 조인해주었습니다.

7. 파라미터로 전달받은 조건식의 count 쿼리를 수행한 결과를 반환해줍니다.

8. 전달받은 PostReadCondition으로 Predicate을 빌드하여 반환해줍니다. 같은 조건 사이에는 OR 연산으로, 이렇게 묶인 다른 조건 사이에는 AND 연산으로 묶이게 되었습니다.

9~10. 전달받은 카테고리 id와 사용자 id로 BooleanExpression을 빌드하여 반환해줍니다. OR 연산으로 묶어낼 value들과 각 항마다 수행할 비교 연산을 orConditions의 인자로 전달해주었습니다.

11. 9~10번 메소드의 중복을 제거하기 위해 정의한 메소드입니다. 전달받은 value들을 OR 연산으로 묶어서 반환해줍니다. 

 

 

 

이에 대한 테스트를 작성하며 Page의 사용법도 익혀봅시다.

test 디렉토리에서 CustomPostRepositoryImplTest를 작성해줍시다.

package kukekyakya.kukemarket.repository.post;

import ...

@DataJpaTest
@Import(QuerydslConfig.class) // 1
class CustomPostRepositoryImplTest {

    @Autowired PostRepository postRepository;
    @Autowired MemberRepository memberRepository;
    @Autowired CategoryRepository categoryRepository;
    @PersistenceContext EntityManager em;


    @Test
    void findAllByConditionTest() { // 2
        // given
        List<Member> members = saveMember(3);
        List<Category> categories = saveCategory(2);

        // 0 - (m0, c0)
        // 1 - (m1, c1)
        // 2 - (m2, c0)
        // 3 - (m0, c1)
        // 4 - (m1, c0)
        // 5 - (m2, c1)
        // 6 - (m0, c0)
        // 7 - (m1, c1)
        // 8 - (m2, c0)
        // 9 - (m0, c1)
        List<Post> posts = IntStream.range(0, 10)
                .mapToObj(i -> postRepository.save(createPost(members.get(i % 3), categories.get(i % 2))))
                .collect(toList());
        clear();

        List<Long> categoryIds = List.of(categories.get(1).getId());
        List<Long> memberIds = List.of(members.get(0).getId(), members.get(2).getId());
        int sizePerPage = 2;
        long expectedTotalElements = 3;

        PostReadCondition page0Cond = createPostReadCondition(0, sizePerPage, categoryIds, memberIds);
        PostReadCondition page1Cond = createPostReadCondition(1, sizePerPage, categoryIds, memberIds);

        // when
        Page<PostSimpleDto> page0 = postRepository.findAllByCondition(page0Cond);
        Page<PostSimpleDto> page1 = postRepository.findAllByCondition(page1Cond);

        // then
        assertThat(page0.getTotalElements()).isEqualTo(expectedTotalElements);
        assertThat(page0.getTotalPages()).isEqualTo((expectedTotalElements + 1) / sizePerPage);

        assertThat(page0.getContent().size()).isEqualTo(2);
        assertThat(page1.getContent().size()).isEqualTo(1);

        // 9 - (m0, c1)
        // 5 - (m2, c1)
        assertThat(page0.getContent().get(0).getId()).isEqualTo(posts.get(9).getId());
        assertThat(page0.getContent().get(1).getId()).isEqualTo(posts.get(5).getId());
        assertThat(page0.hasNext()).isTrue();

        // 3 - (m0, c1)
        assertThat(page1.getContent().get(0).getId()).isEqualTo(posts.get(3).getId());
        assertThat(page1.hasNext()).isFalse();
    }

    private List<Member> saveMember(int size) {
        return IntStream.range(0, size)
                .mapToObj(i -> memberRepository.save(createMember("member" + i, "member" + i, "member" + i, "member" + i)))
                .collect(toList());
    }

    private List<Category> saveCategory(int size) {
        return IntStream.range(0, size)
                .mapToObj(i -> categoryRepository.save(createCategoryWithName("category" + i))).collect(toList());
    }

    private void clear() {
        em.flush();
        em.clear();
    }
}

1. @DataJpaTest에서는 JPA와 관련된 빈들만 등록해주기 때문에, QuerydslConfig에서 직접 정의했던 JPAQueryFactory는 빈으로 등록해주지 않습니다. 따라서 @DataJpaTest로 테스트하는 모든 클래스들은, @Import(QuerydslConfig.class)를 선언해주어야 정상적으로 필요한 빈을 모두 등록해낼 수 있습니다.

2. 임의의 데이터를 삽입하여 조건 검색된 결과를 검증하였습니다. 자세한 내용은 코드를 참고하시길 바랍니다. Page에 정의된 메소드들을 이용하여, 총 페이지 수, 총 게시글 수, 다음 페이지 여부 등을 손쉽게 확인할 수 있습니다.

 

 

 

어떤 쿼리가 전송됐는지, 로그도 확인해보겠습니다.

Hibernate: select post0_.id as col_0_0_, post0_.title as col_1_0_, member1_.nickname as col_2_0_, post0_.created_at as col_3_0_ from post post0_ inner join member member1_ on post0_.member_id=member1_.member_id where post0_.category_id=? and (post0_.member_id=? or post0_.member_id=?) order by post0_.id desc limit ?
Hibernate: select count(post0_.id) as col_0_0_ from post post0_ where post0_.category_id=? and (post0_.member_id=? or post0_.member_id=?)
Hibernate: select post0_.id as col_0_0_, post0_.title as col_1_0_, member1_.nickname as col_2_0_, post0_.created_at as col_3_0_ from post post0_ inner join member member1_ on post0_.member_id=member1_.member_id where post0_.category_id=? and (post0_.member_id=? or post0_.member_id=?) order by post0_.id desc limit ? offset ?
Hibernate: select count(post0_.id) as col_0_0_ from post post0_ where post0_.category_id=? and (post0_.member_id=? or post0_.member_id=?)

limit와 offset을 지정하여 페이징이 수행되고, 같은 조건 사이에는 OR 연산으로, OR 연산으로 묶인 각각의 조건 사이에는 AND 연산으로 쿼리가 생성되었습니다.

우리가 의도했던 쿼리가 완성된 것입니다.

동시에 count 쿼리도 전송된 것을 확인할 수 있습니다.

현재 카운트 쿼리는 pk로 설정된 id로 쿼리를 수행하고 있습니다.

 

***

- 페이징 쿼리 개선

실제로는 위와 같이 단순하게 페이징 처리하면 성능에 문제가 생길 수 있습니다.

이는 인덱스 구조와 관련이 있습니다.

pk 외에 지정하는 보조 인덱스는, 리프 노드에 pk와 인덱스 키로 지정된 컬럼 값들을 가지고 있게 됩니다.

따라서 pk와 인덱스 키 외의 컬럼 데이터를 가져오려면,

보조 인덱스의 리프 노드에 저장된 pk를 가져와서, pk로 만들어진 인덱스(primary index, clustered index) 구조를 다시 seek해야합니다.

 

결국 위와 같이 쿼리를 작성하면,

offset을 통해 해당 페이지 데이터에 접근하기까지, 인덱스 키 컬럼 외에 다른 컬럼들 때문에 수 많은 데이터 블록을 지나치게 됩니다.

따라서 원하는 페이지의 pk 값만 뽑아내는 것은 별도의 서브쿼리로 작성하고,

해당 서브쿼리에서 뽑아낸 키와 조인하여 다른 데이터들을 뽑아내주면 됩니다.

 

예를 들면, 다음과 같은 형태가 될 것입니다.

select * from post as p join (

  select id from post where <조건> order by id desc limit <LIMIT> offset <OFFSET>) as t

on t.id = p.id;

서브 쿼리에서는 post의 id만 추출해줍니다.

보조 인덱스의 리프 노드에는 pk 컬럼과 인덱스 키 컬럼을 가지고 있기 때문에,

데이터 블록에 다시 접근할 필요 없이 보조 인덱스 구조만 seek하며 해당 페이지에 나타나는 id만을 추출해낼 수 있습니다.

그렇게 추출된 id의 임시테이블과 post 테이블을 조인해줍니다.

이 때에는 임시테이블의 크기가 post 테이블의 크기보다 현저히 작기 때문에,

해당하는 id에 대해 다시 클러스터드 인덱스 seek 연산만을 수행하며 데이터를 뽑아낼 수 있습니다.

 

관련된 개념으로는 covered index에 대해 학습해보면 됩니다.

 

추가로, 현재 적용된 인덱스만으로 지금의 검색 방식을 적용하기엔 어려움이 있습니다.

단순화를 위해 별도로 기술하진 않지만, 적절하게 지정하는 작업이 필요할 것입니다.

이에 대해 본문 하단 부에서 가볍게 논하는 내용도, 적당한 선에서 참고바랍니다.

***

 

 

 

이제 service.post.PostService를 작성해줍시다.

// PostService.java
public PostListDto readAll(PostReadCondition cond) {
    return PostListDto.toDto(
            postRepository.findAllByCondition(cond)
    );
}

파라미터로 전달받은 PostReadCondition을 findAllByCondition의 인자로 전달해줍니다.

결과로 반환되는 Page를 PostListDto로 변환해서 다시 반환해줍니다.

 

 

dto.post.PostListDto는 다음과 같습니다.

package kukekyakya.kukemarket.dto.post;

import ...

@Data
@AllArgsConstructor
public class PostListDto {
    private Long totalElements;
    private Integer totalPages;
    private boolean hasNext;
    private List<PostSimpleDto> postList;

    public static PostListDto toDto(Page<PostSimpleDto> page) {
        return new PostListDto(page.getTotalElements(), page.getTotalPages(), page.hasNext(), page.getContent());
    }
}

총 게시글 개수, 총 페이지 수, 다음 페이지가 있는지, 실제 페이지 내역을 가지고 있습니다.

 

 

새롭게 작성된 서비스 로직을 간단하게 테스트해봅시다.

PostServiceTest에 다음 테스트를 추가해줍니다.

// PostServiceTest.java
@Test
void readAllTest() {
    // given
    given(postRepository.findAllByCondition(any())).willReturn(Page.empty());

    // when
    PostListDto postListDto = postService.readAll(createPostReadCondition(1, 1));

    // then
    assertThat(postListDto.getPostList().size()).isZero();
}

특별한 로직이 있는 것은 아니므로, 반환 결과에 대해 간단한 테스트를 작성해주었습니다.

 

 

이제 controller.post.PostController에 게시글 목록을 조회하는 API를 작성해줍시다.

// PostController.java
@ApiOperation(value = "게시글 목록 조회", notes = "게시글 목록을 조회한다.")
@GetMapping("/api/posts")
@ResponseStatus(HttpStatus.OK)
public Response readAll(@Valid PostReadCondition cond) {
    return Response.success(postService.readAll(cond));
}

조회된 결과를 반환해줍니다.

요청 파라미터로 전달된 값들은 PostReadCondition으로 매핑되고, 제약 조건을 검증하게 될 것입니다.

 

이번에는 게시글 목록을 조회하는 GET 요청이므로 별다른 시큐리티 설정은 하지 않아도 되겠습니다.

 

 

컨트롤러도 테스트해봅시다.

PostControllerTest, PostControllerIntegrationTest에 다음과 같은 테스트를 추가해줍니다.

// PostControllerTest.java
@Test
void readAllTest() throws Exception {
    // given
    PostReadCondition cond = createPostReadCondition(0, 1, List.of(1L, 2L), List.of(1L, 2L));

    // when, then
    mockMvc.perform(
            get("/api/posts")
                    .param("page", String.valueOf(cond.getPage())).param("size", String.valueOf(cond.getSize()))
                    .param("categoryId", String.valueOf(cond.getCategoryId().get(0)), String.valueOf(cond.getCategoryId().get(1)))
                    .param("memberId", String.valueOf(cond.getMemberId().get(0)), String.valueOf(cond.getMemberId().get(1))))
            .andExpect(status().isOk());

    verify(postService).readAll(cond);
}
// PostControllerIntegrationTest.java
@Test
void readAllTest() throws Exception {
    // given
    PostReadCondition cond = createPostReadCondition(0, 1);

    // when, then
    mockMvc.perform(
            get("/api/posts")
                    .param("page", String.valueOf(cond.getPage())).param("size", String.valueOf(cond.getSize()))
                    .param("categoryId", String.valueOf(1), String.valueOf(2))
                    .param("memberId", String.valueOf(1), String.valueOf(2)))
            .andExpect(status().isOk());
}

요청 파라미터가 PostReadCondition에 정상적으로 매핑되는지도 검증해주었습니다.

자세한 설명은 생략하겠습니다.

 

모든 테스트 통과

테스트가 모두 깔끔하게 통과되었습니다.

 

 

포스트맨으로 직접 테스트해보기 위해, InitDB 클래스에 게시글을 임의로 초기화해주겠습니다.

package kukekyakya.kukemarket;

import ...

@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local")
public class InitDB {
    ...
    private final PostRepository postRepository;

    @EventListener(ApplicationReadyEvent.class)
    @Transactional
    public void initDB() {
        ...
        initPost();

        log.info("initialized database");
    }

    ...

    private void initPost() {
        Member member = memberRepository.findAll().get(0);
        Category category = categoryRepository.findAll().get(0);
        IntStream.range(0, 100000)
                .forEach(i -> postRepository.save(
                        new Post("title" + i, "content" + i, Long.valueOf(i), member, category, List.of())
                ));
    }

}

 

10만 건의 게시글을 삽입해주었습니다.

 

직접 게시글 목록을 조회해봅시다.

GET /api/categories/1/posts?page=0&size=4&categoryId=1&memberId=1

{
    "success": true,
    "code": 0,
    "result": {
        "data": {
            "totalElements": 100000,
            "totalPages": 25000,
            "hasNext": true,
            "postList": [
                {
                    "id": 100000,
                    "title": "title99999",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:11:10"
                },
                {
                    "id": 99999,
                    "title": "title99998",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:11:10"
                },
                {
                    "id": 99998,
                    "title": "title99997",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:11:10"
                },
                {
                    "id": 99997,
                    "title": "title99996",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:11:10"
                }
            ]
        }
    }
}

첫 페이지에서 4개의 게시글을 조회하였습니다.

 

마지막 페이지인 24999페이지를 조회해보겠습니다.

GET /api/categories/1/posts?page=24999&size=4&categoryId=1&memberId=1

{
    "success": true,
    "code": 0,
    "result": {
        "data": {
            "totalElements": 100000,
            "totalPages": 25000,
            "hasNext": false,
            "postList": [
                {
                    "id": 4,
                    "title": "title3",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:10:43"
                },
                {
                    "id": 3,
                    "title": "title2",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:10:43"
                },
                {
                    "id": 2,
                    "title": "title1",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:10:43"
                },
                {
                    "id": 1,
                    "title": "title0",
                    "nickname": "admin",
                    "createdAt": "2021-12-15T16:10:43"
                }
            ]
        }
    }
}

마지막 게시글들이 조회되고, hasNext가 false로 바뀐 것을 확인할 수 있습니다.

categoryId와 memberId는 선택적으로 지정할 수 있기 때문에, 해당 파라미터가 없어도 요청을 수행할 수 있어야합니다.

 

 

*** 

- "/api/categories/1/posts" vs "/api/posts?categoryId=1"

REST API를 설계함에 있어서 특정한 리소스에 대한 요청 URL을 어떻게 나타내야할지 결정해야했습니다.

처음에는 어떠한 리소스 간에 관계(계층 또는 연관성)가 있다면 전자의 방식으로, 그러한 관계가 없다면 후자의 방식을 취하고자 하였습니다.

특정한 카테고리에 속한 게시글들은 리소스 간에 관계가 있습니다.

또한, query parameter로 전달되는 페이징 옵션들은 게시글과 연관된 것이 아닙니다.

따라서 카테고리에 대한 정보는 전자의 방식으로, 페이징 옵션들에 대한 정보는 후자의 방식으로 나타내고자 한 것입니다.

이러한 규칙을 이용하여, 1번 카테고리에서 페이지 크기가 10인 0페이지의 게시글을 조회한다면,

/api/categories/1/posts?page=0&size=10

와 같은 형태가 되었을 것입니다.

 

하지만 이렇게 단순하게 결정될 문제가 아니었습니다.

 

리소스 간에 관계가 있는 상황에서 두 가지 방식의 차이점을 알아보겠습니다.

이에 대한 예시로, 특정한 카테고리의 게시글도 조회해야하고, 특정한 사용자의 게시글도 조회해야한다고 가정하겠습니다.

 

먼저 전자의 방식입니다.

1번 카테고리의 게시글 조회는 /api/categories/1/posts,

1번 사용자의 게시글 조회는 /api/users/1/posts 와 같을 것입니다.

이에 대한 API를 작성한다면, 각각은 별개의 API로 작성될 수 있을 것입니다.

 

다음으로 후자의 방식입니다.

1번 카테고리의 게시글 조회는 /api/posts?categoryId=1

1번 사용자의 게시글 조회는 /api/posts?userId=1 와 같을 것입니다.

이에 대한 API를 작성한다면, 각각의 query parameter는 선택적으로 전달될 수 있도록 하고, 동적으로 결과를 내려줘야할 것입니다.

 

전자의 방식은 유사한 메소드(또는 쿼리)가 여러 개 작성되겠지만, 각각의 메소드는 단순할 것이고,

후자의 방식은 단일한 메소드(또는 쿼리)로 구현될 수는 있겠지만, 동적으로 쿼리를 생성해야하므로 복잡함이 있을 것입니다.

 

만약 게시글을 조회하는 상황에 새로운 요구사항이 생긴다면,

전자의 방식은 기존의 코드를 건드리지 않고 새로운 API를 작성하면 되지만,

후자의 방식은 기존의 코드에서 새로운 요구사항을 반영할 수 있도록 수정해줘야합니다.

 

코드 측면에서는,

전자의 방식일 때는 하나의 API가 과하게 비대해지지않아서 각 메소드는 단순해질 수 있겠지만, 메소드(또는 쿼리)가 많아지면서 관리해야할 부분이 늘어나고, 각 부분에서는 유사한 로직이 반복될 수 있기 때문에, 수정 측면에서 불리할 수 있습니다.

물론, 중복되는 로직은 코드 레벨에서 어느정도 조절할 수 있겠지만, 불필요한 코드가 생기는 것을 완전히 피하기는 어려울 것입니다.

후자의 방식일 때는 각 조건에 따른 특별한 서비스 로직이 필요하지 않다면, 단순히 동적 조건에 따른 쿼리 작성만 수정해주면 될 것입니다.

 

이러한 관점에서 봤을 때, 차라리 하나의 메소드가 비대해지더라도 한 곳에서 관리해주는 편이 더욱 용이하다고 느껴집니다.

조건에 따른 특별한 로직이 과하게 나타나지 않는 이상, 여러 API로 분할한다면 오히려 중복만 발생할 것이기 때문입니다.

 

다음으로 http 캐시 측면에서도 차이가 있습니다.

예를 들어, 일부 쿼리 파라미터의 순서가 보장되지 않으면 서로 다른 콘텐츠로 인식하기 때문에 http 캐시의 효과를 볼 수 없습니다.

 

전자의 방식에서 각 상황에 조회된 게시글이 캐시되었다고 가정해보겠습니다.

1번 사용자의 게시글이 조회된 캐시는, 1번 사용자가 새롭게 게시글을 작성하지 않는 이상, 계속해서 신선한 캐시를 유지할 수 있을 것입니다.

다른 사용자가 새로운 게시글을 작성하든, 지금 유지되고 있는 캐시와는 상관이 없습니다.

요청 URL이 뒤바뀔 수 있는 쿼리 파라미터가 없기(또는 적기)때문에, 동일한 결과를 다른 콘텐츠로 인식할 일은 없을 것입니다.

 

후자의 방식에서 각 상황에 조회된 게시글이 캐시되었다고 가정해보겠습니다.

동일한 요청에 대해 동일한 URL이 전송된다면, 1번 사용자의 게시글이 조회된 캐시는, 1번 사용자가 새롭게 게시글을 작성하지 않는 이상, 계속해서 신선한 캐시를 유지할 수 있을 것입니다.

하지만 어떠한 사정으로 인해, 너무 많이 노출되어있는 쿼리 파라미터의 순서가 뒤바뀌는 상황이 발생한다면, 신선하지 않은 캐시로 판별될 수 있습니다. 

실제로는 1번 사용자가 새롭게 작성한 게시글이 없어서 아직 신선한 상황임에도 불구하고, 동일한 결과를 다른 콘텐츠를 인식하게 되는 것입니다.

 

물론, 이것도 정상적인 상황은 아닙니다.

대부분의 사용자는 URL을 직접 건드려서 요청을 보내진 않을 것입니다.

코드에서 쿼리 파라미터의 순서만 잘 보장해서 요청을 전송한다면, 위에서 말한 상황은 없을 것입니다.

 

이번에는 가독성 측면에서 살펴보겠습니다.

어떤 리소스가 특정한 자원에 종속되어있음을 나타낼 때는, 확실히 전자의 방식이 가독성이 좋습니다.

1번 카테고리에 작성한 게시글은 /categories/1/posts,

1번 사용자가 작성한 게시글은 /users/1/posts와 같이 나타낼 수 있을 것입니다.

 

후자의 방식을 택할 경우,

1번 카테고리에 작성한 게시글은 /posts?categoryId=1,

1번 사용자가 작성한 게시글은 /posts?userId=1와 같이 나타낼 수 있을 것입니다.

쿼리 파라미터를 보고도 충분히 유추해낼 수 있지만, 페이징 옵션이나 검색 옵션 등의 다양한 쿼리 파라미터가 포함되면서 복잡해질 수 있기 때문에, 여전히 가독성 측면에서는 전자가 낫다고 판단됩니다.

하지만 위 예시는, 1:N 관계이기 때문에 크게 와닿지 않을 수 있습니다.

 

이러한 가독성 측면은, 다음과 같은 N:M 관계에서도 차이가 납니다.

지금 우리가 진행하고 있는 프로젝트에서는, 각 사용자는 여러 개의 권한을 가질 수 있으며, 각 권한은 여러 명의 사용자에게 적용될 수 있습니다.

서로 N:M 관계인 것입니다.

 

전자의 방식으로,

1번 사용자가 가진 모든 권한을 조회한다면 /users/1/roles,

1번 권한을 가진 모든 사용자를 조회한다면 /roles/1/users와 같이 나타낼 수 있을 것입니다.

 

후자의 방식으로,

1번 사용자가 가진 모든 권한을 조회한다면 /roles?userId=1

1번 권한을 가진 모든 사용자를 조회한다면 /users?roleId=1

 

권한과 사용자 사이의 관계는 서로 종속되거나 계층을 이루는 것이 없으므로, 후자의 방식에서는 이러한 관계를 즉시 인지하게 어려울 수 있습니다.

하지만 전자의 방식을 따르면, 상황에 따라 계층적으로 종속되는 관계를 더욱 편리하게 인지할 수 있습니다.

물론, 이것도 사람마다 느껴지는게 다를 수 있습니다.

 

전자의 방식에서 가장 큰 문제는, 동일한 파라미터에 대해 중첩적으로 적용하기 애매하다는 것입니다.

후자의 방식으로 1번과 2번 카테고리의 게시글을 조회한다면,

/posts?categoryId=1,2 (또는 /posts?categoryId=1&categoryId=2)

와 같은 형태로 작성될 수 있을 것입니다.

하지만 전자의 방식으로 이를 표현하고자 한다면, 어떻게 표현할지 애매해지게 됩니다.

 

또, 동일한 깊이에 있는 리소스 간에 계층형을 표현함에 있어서도 단일화된 표현을 도출해내는데 어려움이 있습니다.

만약, 1번 카테고리에서 1번 사용자가 작성한 게시글을 조회해야한다면,

후자의 방식으로는 /posts?categoryId=1&userId=1 과 같은 형태로 작성하면 되지만,

전자의 방식으로는,

/categories/1/users/1/posts

/users/1/categories/1/posts

위 두 형태에서 어떤 것을 선택할지 애매하게 됩니다.

어떤 게시글에 대하여 카테고리와 사용자는 동일한 깊이에 있는 까닭에, 어떤 것을 상위 개념으로 볼 것인지 명확하지 않은 것입니다.

 

정리해보겠습니다.

전자의 방식은 혹시 모를 이상한 사용자의 요청(쿼리 파라미터를 직접 수정한다든지)에 대비하기 위하여, 쿼리 파라미터의 수를 최소한으로 줄임으로써 조금 더 성능(http 캐시 관점)에 이점이 생길 수 있고(실제로 이러한 경우는 잘 없겠지만요), 계층이나 종속 등의 관계를 나타낼 때는 더욱 가독성 좋고 유연한 API를 작성할 수 있습니다.

하지만 이외의 측면에서는 후자의 방식이 더욱 유리해보입니다.

이러한 까닭에 저는 후자의 방식을 택하겠습니다.

 

사실 이러한 구분도 GET 메소드에서만 유용하다고 보고,

POST 또는 PUT 과 같은 요청은, 위에서 서술된 방식들에서 택하는 것 보단, 그냥 요청 바디를 이용하는 것이 더 편리하고 확실한 것 같습니다.

 

* 작성된 내용은 개인적인 의견이 많이 포함되어 있습니다.

***

 

 

***

- Post 조회에서 member_id와 category_id에 따른 인덱스 효과

우리는 검색 조건에 member_id와 category_id가 지정될 수 있도록 구현하였습니다.

이러한 조건은 Post 엔티티에서 외래키로 지정되어있고, 나중에 이전할 MySQL에서는 외래키에 자동으로 인덱스가 생성될 것입니다.

결국 인덱스는, member_id와 category_id에만 생성되어 있는 것입니다.

서로 다른 두 조건은 AND 연산으로 묶여있기 때문에, 하나의 인덱스로만 효과를 보게 될 것입니다.

이상적으로는 두 컬럼 중, 중복도가 더 낮은 member_id로 생성된 인덱스가 더욱 큰 효과를 발휘할 것입니다.

다행스럽게도, 데이터베이스에서는 다양한 통계 정보를 저장하고 있으므로, 중복도가 더 낮은 인덱스를 선택하여 쿼리를 수행해주도록 하거나, 인덱스 힌트를 사용할 수도 있을 것입니다.

그렇다해도 member_id로 조회된 결과에서 category_id가 일치하는 레코드를 찾기 위해 스캔하는 과정이 생기기때문에, 만약 두 컬럼이 묶여서 조회되는 상황이 많다면, (member_id, category_id)의 composite key로 인덱스를 생성해두는 것도 하나의 방법일 것입니다.

 

여러 개의 인덱스를 생성하고 관리하는 것이 부담된다면, composite key로 하나의 인덱스만 생성하여 관리하는 방법도 있을 것입니다.

물론, 첫번째 키로 지정된 조건만 인덱스의 효과를 보게 되고, 두번째 키로 지정된 조건은 인덱스의 효과를 보지 못할 수 있습니다.

이러한 상황을 방지하기 위해 검색되지 않는 첫번째 키의 모든 컬럼도 조건절에 추가하여 인덱스의 효과를 보게 만들 수도 있습니다.

하지만 첫번째 키의 중복도가 낮고 레코드가 많다면, 이에 대해 조건절에 추가하는 작업이 성능에 영향을 끼칠 수도 있을 것입니다.

오라클을 이용하는 경우에는 첫번째 컬럼을 조건절에 직접 추가해줄 필요 없이, 조건절에 두번째 키만 지정되더라도, index skip scan을 통해 원하는 레코드가 있을만한 블록만 검사할 수도 있습니다.

(mysql 8.0이상에서도 index skip scan을 지원하는 것 같습니다.)

 

물론, 현재 적용된 인덱스만으로 지금의 검색 방식을 적용하기엔 어려움이 있습니다.

단순화를 위해 별도로 기술하진 않지만, 적절하게 지정하는 작업이 필요할 것입니다.

위 내용은 적당한 선에서 참고바랍니다.

 

* 오류가 있으면 지적 부탁드립니다.

***

 

 

 

이번 시간에는 게시글 목록 조회 기능을 구현해보았습니다.

다음 시간에는 게시글에 계층형 대댓글을 작성할 수 있도록 해보겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts