하위 태스크 1

QueryDSL 의존성/플러그인 확인

hanbit-springboot/querydsl 의존성 및 Q-타입 생성 설정 확인

build.gradle에서 QueryDSL 의존성이 추가된 모습을 확인할 수 있다.

dependencies {
	// ...
 
	// query dsl for jpa
	implementation 'com.querydsl:querydsl-jpa::jakarta'
	annotationProcessor 'com.querydsl:querydsl-apt::jakarta'
	annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
	annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
 
	// ...
}

IntelliJ IDEA의 Gradle 뷰에서 Tasks/build/build를 수행한다. 빌드 산출물을 build 폴더에서 확인할 수 있다.

하위 태스크 2

Q-타입 생성 테스트

Gradle 빌드 후 Q-타입 클래스 생성 여부 확인

다음 경로에 QProduct 클래스가 존재한다.

build/generated/sources/annotationProcessor/java/main/com/example/demo/model/QProduct.java

QProduct.java:

/**
 * QProduct is a Querydsl query type for Product
 */
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QProduct extends EntityPathBase<Product> {
 
    private static final long serialVersionUID = 1927815632L;
 
    public static final QProduct product = new QProduct("product");
 
    public final StringPath description = createString("description");
 
    public final NumberPath<Long> id = createNumber("id", Long.class);
 
    public final StringPath title = createString("title");
 
    public QProduct(String variable) {
        super(Product.class, forVariable(variable));
    }
 
    public QProduct(Path<? extends Product> path) {
        super(path.getType(), path.getMetadata());
    }
 
    public QProduct(PathMetadata metadata) {
        super(Product.class, metadata);
    }
 
}

하위 태스크 3 ~ 5

기본 QueryDSL 쿼리 작성

단순 조건 조회 쿼리(이름/나이 기준) 구현

검색 조건 DTO 정의

이름, 나이 범위, 팀명 등을 포함하는 검색 DTO 작성

동적 where 조건 구현

BooleanBuilder 또는 동적 where를 사용해 검색 기능 구현

Member 모델 클래스를 작성한다.

model/Member.java:

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String username;
    private Integer age;
    private String teamName;
}

MemberQueryDslRepository 인터페이스를 생성하고 이름, 나이 범위, 팀명을 포함하는 DTO를 작성한다.

repository/MemberQueryDslRepository:

public interface MemberQueryDslRepository {
    @Getter
    @Setter
    class MemberSearchCondition {
        private String name;
        private Integer minAge;
        private Integer maxAge;
        private String teamName;
    }
    List<Member> queryByConditions(MemberSearchCondition condition, long offset, long limit);
}
 

MemberQueryDslRepository 인터페이스를 구현하는 MemberQueryDslRepositoryImpl 클래스를 작성한다. BooleanBuilder를 사용해 검색 기능을 구현한다.

repository/MemberQueryDslRepositoryImpl:

@Repository
@RequiredArgsConstructor
public class MemberQueryDslRepositoryImpl implements MemberQueryDslRepository {
    private final JPAQueryFactory jpaQueryFactory;
 
    @Override
    public List<Member> queryByConditions(MemberSearchCondition condition, long offset, long limit) {
        QMember member = QMember.member;
 
        BooleanBuilder builder = new BooleanBuilder();
 
        if (StringUtils.hasText(condition.getName())) {
            builder.and(member.username.contains(condition.getName()));
        }
 
        if (condition.getMinAge() != null) {
            builder.and(member.age.goe(condition.getMinAge()));
        }
 
        if (condition.getMaxAge() != null) {
            builder.and(member.age.loe(condition.getMaxAge()));
        }
 
        if (StringUtils.hasText(condition.getTeamName())) {
            builder.and(member.teamName.eq(condition.getTeamName()));
        }
 
        return jpaQueryFactory
                .selectFrom(member)
                .where(builder)
                .offset(offset)
                .limit(limit)
                .fetch();
    }
}

JpaRepository를 상속하는 ProductRepository 리포지토리 클래스를 생성한다.

repository/ProductRepository:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long>, ProductQueryDslRepository {
    List<Product> findByTitleContaining(String title);
    List<Product> findByDescriptionContaining(String description);
}

ApplicationRunner 인터페이스를 구현한 QueryDslApplication 클래스를 생성하고, 검색 기능 예시를 작성한다.

QueryDslApplication:

@Component
@RequiredArgsConstructor
@Slf4j
public class QueryDslApplication implements ApplicationRunner {
    private final MemberRepository memberRepository;
 
    @Override
    public void run(ApplicationArguments args) throws Exception {
        var m1 = Member.builder().username("홍길동").age(20).teamName("A").build();
        var m2 = Member.builder().username("김성휘").age(25).teamName("A").build();
        var m3 = Member.builder().username("박영훈").age(30).teamName("B").build();
        var m4 = Member.builder().username("김지수").age(35).teamName("C").build();
 
        memberRepository.saveAll(List.of(m1, m2, m3, m4));
 
        MemberQueryDslRepository.MemberSearchCondition condition = new MemberQueryDslRepository.MemberSearchCondition();
 
        condition.setName("김");
        condition.setMinAge(20);
 
        var result = memberRepository.queryByConditions(condition, 0, 10);
 
        log.info("검색 결과 수: {}", result.size());
        result.forEach(m -> log.info("member: {}, age: {}, teamName: {}",
                m.getUsername(), m.getAge(), m.getTeamName()));
    }
}

애플리케이션 실행 결과는 다음과 같다.

검색 결과 수: 2
member: 김철수, age: 20, teamName: A
member: 김태희, age: 35, teamName: C

하위 태스크 6

페이징/정렬 적용

Pageable을 사용해 페이징+정렬 검색 기능 구현

MemberQueryDslRepository 인터페이스의 queryByConditions 메서드가 Page, Pageable을 사용하도록 수정한다.

repository/MemberQueryRepository:

public interface MemberQueryDslRepository {
    // ...
    Page<Member> queryByConditions(MemberSearchCondition condition, Pageable pageable);

MemberQueryDslRepositoryImpl 구현체도 오버라이딩 메서드의 서명과 내부 구현을 수정한다.

@Repository
@RequiredArgsConstructor
public class MemberQueryDslRepositoryImpl implements MemberQueryDslRepository {
    private final JPAQueryFactory jpaQueryFactory;
 
    @Override
    public Page<Member> queryByConditions(MemberSearchCondition condition, Pageable pageable) {
        // ...
 
        List<Member> content = jpaQueryFactory
                .selectFrom(member)
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
 
        Long total = jpaQueryFactory
                .select(member.count())
                .from(member)
                .where(builder)
                .fetchOne();
 
        return new PageImpl<>(content, pageable, total != null ? total : 0L);
    }
}

실행 예제가 담긴 QueryDslApplication 클래스의 구현도 수정한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class QueryDslApplication implements ApplicationRunner {
    private final MemberRepository memberRepository;
 
    @Override
    public void run(ApplicationArguments args) throws Exception {
        // ...
 
        Pageable pageable = PageRequest.of(0, 10, Sort.by("age").ascending());
 
        Page<Member> resultPage = memberRepository.queryByConditions(condition, pageable);
 
        log.info("전체 매칭 데이터 수: {}", resultPage.getTotalElements());
        log.info("현재 페이지 데이터 수: {}", resultPage.getNumberOfElements());
 
        resultPage.getContent().forEach(m -> log.info("member: {}, age: {}, teamName: {}",
                m.getUsername(), m.getAge(), m.getTeamName()));
    }
}

애플리케이션 실행 결과는 다음과 같다.

전체 매칭 데이터 수: 2
현재 페이지 데이터 수: 2
member: 김성휘, age: 25, teamName: A
member: 김지수, age: 35, teamName: C

하위 태스크 7

Mongo 설정 확인

hanbit-springboot/mongodb 의 MongoDB 연결 설정 파악

resources/application.properties에서 MongoDB 연결 설정을 확인할 수 있다.

spring.data.mongodb.uri=mongodb://localhost:27017/lecture
spring.data.mongodb.auto-index-creation=true

하위 태스크 8

Mongo 도메인/Repository 확인

@Document 도메인과 Repository 인터페이스 구조 이해

Models

model/Article.java:

@Document
@Data
@Builder
public class Article {
    @Id
    private String id;
    private String name;
    private String email;
    @TextIndexed(weight = 2)
    private String title;
    @TextIndexed
    private String description;
    @TextScore
    private Float score;
}
  • @Document: 클래스가 MongoDB의 도큐먼트임을 선언한다.
  • @Id: 도큐먼트의 기본 키를 지정한다.
  • @TextIndexed: 해당 필드를 풀 텍스트 인덱스에 포함한다. weight = 2는 검색 결과의 가중치를 설정한 것이다.
  • @TextScore: 텍스트 검색의 유사도 점수를 저장하는 필드로 선언한다.

model/Member.java:

@Document
@Data
@Builder
public class Member {
    @Id
    private String id;
    private String name;
    @Indexed
    private String email;
}
  • @Indexed: 해당 필드에 인덱스를 생성한다.

model/Pharmacy:

@Document
@Data
@Builder
public class Pharmacy {
    @Id
    private String id;
    public String name;
    public String phone;
    public String address;
    @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
    private Point location;
}
  • @GeoSpatialIndexed: 해당 필드를 공간 인덱스로 만든다. type = GeoSpatialIndexType.GEO_2DSPHERE는 인덱스의 방식을 지구를 둥근 구 형태로 보고 계산하는 방식으로 지정한다.

Repositories

reporitory/ArticleRepository:

@Repository
public interface ArticleRepository extends MongoRepository<Article, String> {
    List<Member> findByName(String name);
    List<Member> findByNameAndEmail(String name, String email);
    List<Member> findByNameOrEmail(String name, String email);
    List<Member> findByNameStartingWith(String name);
    List<Member> findByNameEndingWith(String name);
    List<Member> findByNameContaining(String name);
    List<Member> findByNameLike(String name); // containing "name" equals to like "%name%"
 
    List<Article> findBy(TextCriteria criteria, Sort sort);
    List<Article> findBy(TextCriteria criteria, Pageable pageable);
    List<Article> findByOrderByScoreDesc(TextCriteria criteria);
    List<Article> findByEmail(String email, TextCriteria criteria, Sort sort);
 
    @Query("{name: ?0, email: ?1}")
    List<Article> findByAuthor(String name, String email);
 
    @Query("{name: ?0}")
    @Update("{$set: {email: ?1}}")
    int updateEmailByName(String name, String email);
}
  • MongoRepository: 표준 CRUD 메서드를 제공하고 메서드 이름을 규칙에 맞게 지으면 MongoDB 쿼리로 자동 변환한다.
  • @Repository: 해당 인터페이스가 데이터 접근 계층의 구성 요소임을 스프링에 알린다.
  • @Query: MongoDB 전용 질의문을 직접 작성할 때 사용한다.
  • @Update: 데이터 변경 수행 시 사용한다. @Query와 함께 쓰이며 @Query로 수정 대상을 찾고 @Update로 변경 내용을 정의한다.

하위 태스크 9

Mongo CRUD 테스트

문서 저장/조회/삭제 기능 구현 및 검증

ArticleApplication 클래스가 Article에 대한 검색 예제를 포함하도록 수정한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class ArticleApplication implements ApplicationRunner {
    private final ArticleRepository articleRepository;
 
    @Override
    public void run(ApplicationArguments args) throws Exception {
        articleRepository.deleteAll();
 
        var a1 = Article.builder()
                .name("홍길동")
                .email("[email protected]")
                .title("Lorem Ipsum")
                .description("JPA is awesome!")
                .build();
        var a2 = Article.builder()
                .name("김철수")
                .email("[email protected]")
                .title("Morem Jpsum")
                .description("MongoDB is awesome!")
                .build();
        var a3 = Article.builder()
                .name("양찬형")
                .email("[email protected]")
                .title("Norem Kpsum")
                .description("Spring is awesome!")
                .build();
 
		// Article 저장
        articleRepository.saveAll(List.of(a1, a2, a3));
 
		// Article 검색 조회
        TextCriteria criteria = TextCriteria.forDefaultLanguage().matchingAny("Lorem", "Kpsum");
        articleRepository.findByOrderByScoreDesc(criteria)
                .forEach(a -> log.info("제목: {}, 점수: {}", a.getTitle(), a.getScore()));
 
		// Article 수정
        int count = articleRepository.updateEmailByName("홍길동", "[email protected]");
        log.info("수정된 개수: {}", count);
 
		// Article 검색 조회
        articleRepository.findByAuthor("김철수", "[email protected]")
                .forEach(a -> log.info("이름: {}, 이메일: {}", a.getName(), a.getEmail()));
    }
}

하위 태스크 10

Mongo 쿼리 메서드 작성

메서드 이름 기반 커스텀 쿼리 메서드 구현

ArticleRepository 인터페이스에 삭제를 위한 deleteByName 메서드를 선언한다.

@Repository
public interface ArticleRepository extends MongoRepository<Article, String> {
    // ...
    void deleteByName(String name);
}

ArticleApplication 클래스의 run 메서드 끝에 deleteByName 메서드를 테스트하기 위한 코드를 추가한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class ArticleApplication implements ApplicationRunner {
    private final ArticleRepository articleRepository;
 
    @Override
    public void run(ApplicationArguments args) throws Exception {
        // ...
        articleRepository.deleteByName("홍길동");
        articleRepository.findAll().forEach(a ->
                log.info("이름: {}, 제목: {}", a.getName(), a.getTitle())
        );
    }
}

애플리케이션 실행 결과는 다음과 같다. 홍길동이 제거되어 김철수양찬형만 출력되는 것을 볼 수 있다.

이름: 김철수, 제목: Morem Jpsum
이름: 양찬형, 제목: Norem Kpsum