ํ‹ฐ์Šคํ† ๋ฆฌ ๋ทฐ

1. ๐Ÿซ ๋„์ž…

ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด์„œ ๋™์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•ด์•ผ ํ•  ์ƒํ™ฉ์ด ๋งŽ์•˜๋‹ค. category, memberId, ์ •๋ ฌ ๊ธฐ์ค€ ๋“ฑ ์กฐํšŒ ์กฐ๊ฑด์ด ์ž์ฃผ ๋ฐ”๋€Œ์—ˆ๊ณ , ์ด ์กฐ๊ฑด๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์œ ์—ฐํ•˜๊ฒŒ ์ฟผ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ์‹์ด ํ•„์š”ํ–ˆ๋‹ค. ํŠนํžˆ, ์กฐ๊ฑด์ด ๋นˆ๋ฒˆํ•˜๊ฒŒ ๋ณ€๊ฒฝ๋˜๊ฑฐ๋‚˜ ์ƒํ™ฉ์— ๋”ฐ๋ผ ์ถ”๊ฐ€๋˜๋Š” ๊ฒฝ์šฐ, ์ฝ”๋“œ๊ฐ€ ๋ณต์žกํ•ด์ง€๊ณ  ์œ ์ง€๋ณด์ˆ˜๋„ ์–ด๋ ค์›Œ์งˆ ๊ฐ€๋Šฅ์„ฑ์ด ์ปธ๋‹ค.

์ด๋•Œ Spring Data JPA์˜ Specification ๊ธฐ๋Šฅ์„ ์•Œ๊ฒŒ ๋˜์—ˆ๊ณ , ์ด๋ฅผ ํ†ตํ•ด ๋‹ค์–‘ํ•œ ํ•„ํ„ฐ ์กฐ๊ฑด์„ ์œ ์—ฐํ•˜๊ฒŒ ์ถ”๊ฐ€ํ•˜๊ณ  ์กฐํ•ฉํ•˜์—ฌ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Œ์„ ์•Œ๊ฒŒ๋˜์—ˆ๋‹ค. Specification์„ ํ™œ์šฉํ•˜๋ฉด ์ฝ”๋“œ์˜ ๋ณต์žก์„ฑ์„ ์ค„์ด๊ณ , ์กฐ๊ฑด์ด ๋ณ€ํ•ด๋„ ์ฟผ๋ฆฌ๋ฅผ ์‰ฝ๊ฒŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. 

 

- ๋™์  ์กฐ๊ฑด ์ถ”๊ฐ€: Specification์„ ์‚ฌ์šฉํ•˜๋ฉด ์—ฌ๋Ÿฌ ํ•„ํ„ฐ ์กฐ๊ฑด์„ ์ƒํ™ฉ์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ์กฐํ•ฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, memberId, category, ์ •๋ ฌ ์กฐ๊ฑด ๋“ฑ์„ ํ•„์š”ํ•œ ๋Œ€๋กœ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.

- JPA์™€์˜ ํ†ตํ•ฉ: JpaSpecificationExecutor์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ, Specification์„ ํ†ตํ•ด ๋งŒ๋“ค์–ด์ง„ ๋™์  ์ฟผ๋ฆฌ์™€ ํŽ˜์ด์ง•(page, size)์„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๊ฒฐํ•ฉํ•  ์ˆ˜ ์žˆ๋‹ค.

 

2. ๐Ÿซ ์‚ฌ์šฉ ๊ณผ์ •

public class DiarySpecification {

    public static Specification<DiaryEntity> withCategoryAndIsPublic(Category category) {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) ->
                category != null
                        ? cb.and(cb.isTrue(root.get("isPublic")), cb.equal(root.get("category"), category))
                        : cb.isTrue(root.get("isPublic"));
    }

    public static Specification<DiaryEntity> orderByCreatedAt() {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            query.orderBy(cb.desc(root.get("createdAt")));
            return query.getRestriction();
        };
    }

    public static Specification<DiaryEntity> orderByContentLength() {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            query.orderBy(cb.desc(cb.length(root.get("content"))));
            return query.getRestriction();
        };
    }
}

๐Ÿ’ Specification๊ณผ Query ๊ฐ™์ด ์‚ฌ์šฉ ๋ถˆ๊ฐ€

์ฒ˜์Œ์—” @Query์™€ Specification์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ, @Query๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํŠน์ • ํ•„ํ„ฐ(์˜ˆ: memberId์™€ ๊ธฐ๋ณธ ์กฐ๊ฑด)๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ , ๋‚˜๋จธ์ง€ ์กฐ๊ฑด์€ Specification์„ ํ†ตํ•ด ๋™์ ์œผ๋กœ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค. 

@Query("SELECT d FROM DiaryEntity d JOIN FETCH d.soptMember m " +
            "WHERE m.id = :memberId ")
    Page<DiaryEntity> findByMemberId(
            Long memberId, Specification<DiaryEntity> specification, Pageable pageable
    );

 

๊ทธ๋žฌ๋”๋‹ˆ

Could not create query for public abstract org.springframework.data.domain.Page org.sopt.diary.domain.repository.DiaryRepository.findByMemberId(java.lang.Long,org.springframework.data.jpa.domain.Specification,org.springframework.data.domain.Pageable);
Reason: Using named parameters for method public abstract org.springframework.data.domain.Page org.sopt.diary.domain.repository.DiaryRepository.findByMemberId(java.lang.Long,org.springframework.data.jpa.domain.Specification,org.springframework.data.domain.Pageable) but parameter 'Optional[specification]' not found in annotated query 'SELECT d FROM DiaryEntity d JOIN FETCH d.soptMember m WHERE m.id = :memberId '

 

์ด๋Ÿฐ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋Š”๋ฐ, ์ด๋Š” Spring Data JPA๋Š” @Query์™€ Specification์„ ๋™์‹œ์— ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ์ง€์›ํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, @Query ๋ฐฉ์‹์œผ๋กœ Specification์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†๋‹ค๊ณ  ํ•œ๋‹ค. Specification๊ณผ @Query๋Š” ๋ณ„๋„๋กœ ์ž‘๋™ํ•˜๋ฏ€๋กœ, @Query ๋ฉ”์„œ๋“œ์— Specification ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

 

๊ทธ๋ž˜์„œ @Query๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ๊ทธ๋ƒฅ JPA ๋ฉ”์„œ๋“œ ์ด๋ฆ„ ๊ธฐ๋ฐ˜ ์ฟผ๋ฆฌ๋กœ ์ฒ˜๋ฆฌํ•˜๋„๋ก

Page<DiaryEntity> findBySoptMemberId(
            Long memberId, Specification<DiaryEntity> specification, Pageable pageable
    );

๋กœ ๋ฐ”๊ฟ”์ฃผ์—ˆ๋Š”๋ฐ,

 

ERROR 42274 --- [nio-8080-exec-1] o.s.d.c.advice.GlobalExceptionHandler    : Unhandled exception occurred: At least 2 parameter(s) provided but only 1 parameter(s) present in query

 

๋˜ ์ด๋Ÿฐ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค. ์ด ์—๋Ÿฌ๋Š” ์ฟผ๋ฆฌ์— ์ „๋‹ฌ๋œ ํŒŒ๋ผ๋ฏธํ„ฐ ์ˆ˜๊ฐ€ ์‹ค์ œ ํ•„์š”ํ•œ ํŒŒ๋ผ๋ฏธํ„ฐ ์ˆ˜์™€ ์ผ์น˜ํ•˜์ง€ ์•Š๋‹ค๋Š” ๋ฉ”์„ธ์ง€์˜€๋Š”๋ฐ, ์ด๋Š” findBySoptMemberId ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ Specification์„ ํ•จ๊ป˜ ์ „๋‹ฌํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๊ฒƒ์ด์—ˆ๋‹ค. Specification์„ ๋ณ„๋„๋กœ ์‚ฌ์šฉํ•˜๋ ค๋ฉด findAll ๋ฉ”์„œ๋“œ์™€ ๊ฒฐํ•ฉํ•ด์•ผ ํ•œ๋‹ค๊ณ  ํ•œ๋‹ค.

 

Specification๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ฉ”์„œ๋“œ

JpaSpecificationExecutor ์ธํ„ฐํŽ˜์ด์Šค๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋‹ค์Œ ๋ฉ”์„œ๋“œ๋“ค์—์„œ๋งŒ Specification์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์ œ๊ณตํ•œ๋‹ค:

- findAll(Specification<T> spec): Specification์„ ์‚ฌ์šฉํ•ด ๋ชจ๋“  ์กฐ๊ฑด์„ ํ•„ํ„ฐ๋งํ•˜๊ณ  ์กฐํšŒ

- findAll(Specification<T> spec, Pageable pageable): Specification๊ณผ ํŽ˜์ด์ง•์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ด ์กฐํšŒ

- findOne(Specification<T> spec): ๋‹จ์ผ ๊ฒฐ๊ณผ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์‚ฌ์šฉ

- count(Specification<T> spec): ์กฐ๊ฑด์— ๋งž๋Š” ์—”ํ‹ฐํ‹ฐ์˜ ๊ฐœ์ˆ˜๋ฅผ ์กฐํšŒ

- exists(Specification<T> spec): ์กฐ๊ฑด์— ๋งž๋Š” ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ

 

๋”ฐ๋ผ์„œ Specification์—์„œ memberId์™€ ์ถ”๊ฐ€ ์กฐ๊ฑด์„ ํ•จ๊ป˜ ์ฒ˜๋ฆฌํ•˜๊ณ , findAll ๋ฉ”์„œ๋“œ๋กœ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์‹์„ ์ตœ์ข…์ ์œผ๋กœ ์„ ํƒํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค.

 

//service
Page<DiaryEntity> diaryEntities = diaryRepository.findAll(specification, pageable);
// repository
Page<DiaryEntity> findAll(Specification<DiaryEntity> specification, Pageable pageable);

 

๐Ÿ’ Specification์—์„œ JOIN FETCH๊ฐ€ ์•ˆ๋จ

N+1 ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด soptMember JOIN FETCH ์„ค์ •์„ Specification์— ํ•ด์ฃผ์—ˆ๋Š”๋ฐ,

public static Specification<DiaryEntity> withCategoryAndIsPublic(Category category) {
        return (Root<DiaryEntity> root, CriteriaQuery<?>query, CriteriaBuilder cb) -> {
            root.fetch("soptMember"); // JOIN FETCH ์„ค์ •
            query.distinct(true); // ์ค‘๋ณต ์ œ๊ฑฐ
            return cb.and(
                    cb.equal(root.get("category"), category),
                    cb.isTrue(root.get("isPublic")) // isPublic์ด true์ธ ๊ฒฝ์šฐ๋งŒ
            );
        };
    }
Unhandled exception occurred: org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(org.sopt.diary.domain.DiaryEntity(12).soptMember(13) : soptMember)]

 

์œ„์™€ ๊ฐ™์€ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

@ManyToOne ๊ด€๊ณ„๋ผ ํŽ˜์ด์ง•๊ณผ JOIN FETCH ์กฐํ•ฉ์—์„œ ๋ฌธ์ œ๊ฐ€ ์—†์„ ์ค„ ์•Œ์•˜๋Š”๋ฐ, ์ฐพ์•„๋ณด๋‹ˆ ์ด ์—๋Ÿฌ๋Š” Specification์—์„œ JOIN FETCH๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด์„œ ํŽ˜์ด์ง•์„ ํ•˜๋ ค๊ณ  ํ•  ๋•Œ ๋ฐœ์ƒํ•˜๋Š” Hibernate์˜ ์ œ์•ฝ ์‚ฌํ•ญ ๋•Œ๋ฌธ์— ๋ฐœ์ƒํ•œ๋‹ค๊ณ  ํ•œ๋‹ค.

JOIN FETCH์™€ ํŽ˜์ด์ง•์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด Hibernate๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ํŽ˜์น˜ ์กฐ์ธ์„ ์‚ฌ์šฉํ•œ ์—”ํ‹ฐํ‹ฐ๋ฅผ select ๋Œ€์ƒ์— ํฌํ•จ์‹œํ‚ค์ง€ ๋ชปํ•ด ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค.

@ManyToOne ๊ด€๊ณ„๋ผ๋„ ํŽ˜์ด์ง•๊ณผ Criteria API ๊ธฐ๋ฐ˜์˜ Specification์˜ JOIN FETCH ์กฐํ•ฉ์—์„œ Hibernate์˜ ์ œํ•œ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํ•œ๋‹ค.

๋”ฐ๋ผ์„œ @EntityGraph๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด์ฃผ์—ˆ๋‹ค.

 

๐Ÿ’ @EntityGraph ํ™œ์šฉ

์ผ๊ธฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ,

    // ์ผ๊ธฐ ๋ชฉ๋ก ์กฐํšŒ
    @Transactional(readOnly = true)
    public DiaryListResponse getDiaryList(
            final Category category,
            final String sortBy,
            Pageable pageable
    ) {
        Specification<DiaryEntity> specification = createSpecificationWithSorting(category, null, sortBy);
        pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());

        Page<DiaryEntity> diaryEntities = diaryRepository.findAll(specification, pageable);
        return DiaryListResponse.from(diaryEntities);
    }

์ด๋Ÿฐ์‹์œผ๋กœ Specification ๊ฐ์ฒด๋ฅผ ํ†ตํ•ด ๋™์  ์ฟผ๋ฆฌ๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ , diaryRepository์—์„œ findAll ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์กฐ๊ฑด๊ณผ ํŽ˜์ด์ง•์— ๋งž๋Š” ์ผ๊ธฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋Š”๋ฐ, ์ด ๋•Œ findAll๋กœ ์ผ๊ธฐ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ๋ฉ”์„œ๋“œ์—

    @Override
    @EntityGraph(attributePaths = {"soptMember"})
    Page<DiaryEntity> findAll(Specification<DiaryEntity> specification, Pageable pageable);

 

์ด๋ ‡๊ฒŒ @EntityGraph(attributePaths = {"soptMember"})๋ฅผ ์ ์šฉํ•˜์—ฌ soptMember๋ฅผ ์ฆ‰์‹œ ๋กœ๋”ฉ(fetch join)ํ•˜๋„๋ก ์„ค์ •ํ•˜์˜€๋‹ค.

 

3. ๐Ÿซ ์ตœ์ข… ์ฝ”๋“œ

//Service

    // ๋‚ด ์ผ๊ธฐ ๋ชฉ๋ก ์กฐํšŒ
    @Transactional(readOnly = true)
    public MyDiaryListResponse getMyDiaryList(
            final Long memberId,
            final Category category,
            final String sortBy,
            Pageable pageable
    ) {
        Specification<DiaryEntity> specification = createSpecificationWithSorting(category, memberId, sortBy);
        pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());

        memberService.findById(memberId);
        Page<DiaryEntity> diaryEntities = diaryRepository.findAll(specification, pageable);
        return MyDiaryListResponse.from(diaryEntities);
    }
    
    private Specification<DiaryEntity> createSpecificationWithSorting(Category category, Long memberId, String sortBy) {
        Specification<DiaryEntity> specification;

        if (memberId != null) {
            specification = DiarySpecification.withMemberIdAndCategory(memberId, category);
        } else {
            specification = DiarySpecification.withCategoryAndIsPublic(category);
        }

        // ์ •๋ ฌ ์กฐ๊ฑด ์ถ”๊ฐ€
        if (CREATED_AT.equals(sortBy)) {
            specification = specification.and(DiarySpecification.orderByCreatedAt());
        } else if (CONTENT_LENGTH.equals(sortBy)) {
            specification = specification.and(DiarySpecification.orderByContentLength());
        }

        return specification;
    }

 

// Specification

public class DiarySpecification {

    public static Specification<DiaryEntity> withCategoryAndIsPublic(Category category) {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) ->
                category != null
                        ? cb.and(cb.isTrue(root.get("isPublic")), cb.equal(root.get("category"), category))
                        : cb.isTrue(root.get("isPublic"));
    }

    public static Specification<DiaryEntity> withMemberIdAndCategory(Long memberId, Category category) {
        return (root, query, cb) -> {
            Join<Object, Object> memberJoin = root.join("soptMember");  // soptMember ํ•„๋“œ ์กฐ์ธ
            return category != null
                    ? cb.and(cb.equal(memberJoin.get("id"), memberId), cb.equal(root.get("category"), category))
                    : cb.equal(memberJoin.get("id"), memberId);
        };
    }

    public static Specification<DiaryEntity> orderByCreatedAt() {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            query.orderBy(cb.desc(root.get("createdAt")));
            return query.getRestriction();
        };
    }

    public static Specification<DiaryEntity> orderByContentLength() {
        return (Root<DiaryEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            query.orderBy(cb.desc(cb.length(root.get("content"))));
            return query.getRestriction();
        };
    }
}

 

// repository

public interface DiaryRepository extends JpaRepository<DiaryEntity, Long>, JpaSpecificationExecutor<DiaryEntity> {

    @Override
    @EntityGraph(attributePaths = {"soptMember"})
    Page<DiaryEntity> findAll(Specification<DiaryEntity> specification, Pageable pageable);

}

 

 

์œ„์™€ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ•˜๋ฉด, ๋‹ค์–‘ํ•œ ํ•„ํ„ฐ ์กฐ๊ฑด์„ ๋™์ ์œผ๋กœ ์ถ”๊ฐ€ํ•˜๋ฉด์„œ๋„ ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค.

Specification๊ณผ @EntityGraph๋ฅผ ์กฐํ•ฉํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ์žฅ์ ์„ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค.

  • ์œ ์—ฐํ•œ ์กฐ๊ฑด ์ถ”๊ฐ€: Specification์„ ํ†ตํ•ด ๋‹ค์–‘ํ•œ ํ•„ํ„ฐ ์กฐ๊ฑด์„ ๋™์ ์œผ๋กœ ์กฐํ•ฉํ•˜์—ฌ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.
  • N+1 ๋ฌธ์ œ ํ•ด๊ฒฐ: @EntityGraph๋กœ ํŽ˜์ด์ง•๊ณผ ํ•จ๊ป˜ JOIN FETCH ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ณ , ํ•„์š”ํ•œ ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋‹ค.

๋‹ค์Œ์—” QueryDSL์„ ์ด์šฉํ•˜์—ฌ ๋™์  ์ฟผ๋ฆฌ๋ฅผ ๊ตฌํ˜„ํ•ด๋ณด์•„์•ผ๊ฒ ๋‹ค!