하위 태스크 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