Querydsl을 사용하여 페이징과 검색을 하기 위해 설정을 해준다.
// Querydsl 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
이후 Qtype을 사용하기 위해 dto와 entity 생성 후
gradle - build - clean
gradle - other - compileJava를 클릭해준다.
entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id", updatable = false)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
@Builder
public Post(String title, String content) {
this.title = title;
this.content = content;
}
public void update(SavePostRequest savePostRequest) {
this.title = savePostRequest.title();
this.content = savePostRequest.content();
}
}
JPA의 변경감지 (Dirty Checking)를 사용해서 글을 업데이트해준다.
기본 생성자를 생성하여 Spring data JPA를 이용할 수 있게해준다.
repository
public interface PostRepository extends JpaRepository<Post, Long> {
}
SavePostRequest
public record SavePostRequest(
@NotNull
String title,
@NotNull
String content
) {
public Post toEntity() {
return Post.builder()
.title(title)
.content(content)
.build();
}
}
searchPostRequest
public record SearchPostRequest(
String title,
String content
) {
}
querydsl을 이용하여 게시글 조회 및 페이징 처리를 위해 사용하는 DTO다.
PostResponse
@Builder
public record PostResponse(
Long id,
String title,
String content,
LocalDateTime createdAt,
LocalDateTime lastModifiedAt
) {
public static PostResponse toDto(Post post) {
return PostResponse.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.createdAt(post.getCreatedAt())
.lastModifiedAt(post.getLastModifiedAt())
.build();
}
}
요청에 대한 응답을 해주는 DTO로 필드의 값을 전달해주며, Entity를 DTO로 만들기 위한 생성자도 작성해줬다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class PostController {
private final PostService postService;
@PostMapping("/post")
public ResponseEntity<PostResponse> savePost(@RequestBody @Valid SavePostRequest savePostRequest) {
PostResponse postResponse = postService.save(savePostRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(postResponse);
}
@GetMapping("/posts")
public ResponseEntity<List<PostResponse>> findAllPosts() {
List<PostResponse> posts = postService.findAll();
return ResponseEntity.status(HttpStatus.OK).body(posts);
}
@GetMapping("/post/{id}")
public ResponseEntity<PostResponse> findPost(@PathVariable("id") Long id) {
PostResponse postResponse = postService.findById(id);
return ResponseEntity.status(HttpStatus.OK).body(postResponse);
}
@DeleteMapping("/post/{id}")
public ResponseEntity<Void> deletePost(@PathVariable("id") Long id) {
postService.deleteById(id);
return ResponseEntity.status(HttpStatus.OK).build();
}
@PatchMapping("/post/{id}")
public ResponseEntity<PostResponse> updatePost(@PathVariable("id") Long id, @RequestBody @Valid SavePostRequest savePostRequest) {
PostResponse postResponse = postService.updatePost(id, savePostRequest);
return ResponseEntity.status(HttpStatus.OK).body(postResponse);
}
// 페이징과 검색
@GetMapping("/paging/post")
public ResponseEntity<Page<PostResponse>> list(@RequestParam(required = false) String title,
@RequestParam(required = false) String content, Pageable pageable) {
SearchPostRequest searchPostRequest = new SearchPostRequest(title, content);
Page<PostResponse> page = postService.searchAndPagePost(searchPostRequest, pageable);
return ResponseEntity.status(HttpStatus.OK).body(page);
}
}
페이징과 검색 기능을 사용할 때 Getmapping으로 사용하기 위해서 검색 정보를 @RequestParam으로 전달받아준다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
private final PostRepositoryCustom postRepositoryCustom;
@Transactional
public PostResponse save(SavePostRequest saveArticleRequest) {
Post post = postRepository.save(saveArticleRequest.toEntity());
return PostResponse.toDto(post);
}
@Transactional
public void deleteAll() {
postRepository.deleteAll();
}
public List<PostResponse> findAll() {
List<Post> posts = postRepository.findAll();
return posts.stream()
.map(PostResponse::toDto)
.collect(Collectors.toList());
}
public Page<PostResponse> searchAndPagePost(SearchPostRequest searchPostRequest, Pageable pageable) {
return postRepositoryCustom.searchPage(searchPostRequest, pageable);
}
public PostResponse findById(Long id) {
Post post = getPost(id);
return PostResponse.toDto(post);
}
@Transactional
public void deleteById(Long id) {
if (getPost(id) != null) {
postRepository.deleteById(id);
}
}
@Transactional
public PostResponse updatePost(Long id, SavePostRequest savePostRequest) {
Post post = getPost(id);
post.update(savePostRequest);
return PostResponse.toDto(post);
}
private Post getPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new NotFoundPostException("not foubd: " + id));
return post;
}
}
@Transactional(readOnly = true)을 이용하여 JPA을 최적화 해준다.
NotFoundPostException
public class NotFoundPostException extends RuntimeException {
public NotFoundPostException() {
super("해당하는 글이 존재하지 않습니다.");
}
public NotFoundPostException(String message) {
super(message);
}
}

data.sql
insert into post(title, content) values ('제목1', '내용1')
insert into post(title, content) values ('제목2', '내용2')
insert into post(title, content) values ('제목3', '내용3')
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
sql:
init:
mode: always
public interface PostRepositoryCustom {
Page<PostResponse> searchPage(SearchPostRequest searchPostRequest, Pageable pageable);
}
@Repository
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<PostResponse> searchPage(SearchPostRequest searchPostRequest, Pageable pageable) {
// where절 조합
List<PostResponse> content = queryFactory
.select(Projections.constructor(PostResponse.class,
post.id,
post.title,
post.content,
post.createdAt,
post.lastModifiedAt))
.from(post)
.where(titleOrContentContains(searchPostRequest.title(), searchPostRequest.content()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(post.count())
.from(post)
.where(titleEq(searchPostRequest.title()),
contentEq(searchPostRequest.content()));
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
private BooleanExpression titleEq(String title) {
return StringUtils.hasText(title) ? post.title.eq(title) : null;
}
private BooleanExpression contentEq(String content) {
return StringUtils.hasText(content) ? post.content.eq(content) : null;
}
private BooleanExpression titleContains(String title) {
return StringUtils.hasText(title) ? post.title.contains(title) : null;
}
private BooleanExpression contentContains(String content) {
return StringUtils.hasText(content) ? post.content.contains(content) : null;
}
/**
* 제목 또는 내용의 부분 일치를 처리하는 메서드
*/
private BooleanExpression titleOrContentContains(String title, String content) {
BooleanExpression titleCondition = titleContains(title);
BooleanExpression contentCondition = contentContains(content);
// 둘 다 null이면 null 반환, 하나라도 조건이 있으면 or로 결합
if (titleCondition == null && contentCondition == null) {
return null;
} else if (titleCondition == null) {
return contentCondition;
} else if (contentCondition == null) {
return titleCondition;
} else {
return titleCondition.or(contentCondition);
}
}
}
기대결과
searchPage(new SearchPostRequest("spring", null), pageable)searchPage(new SearchPostRequest(null, "boot"), pageable)searchPage(new SearchPostRequest("spring", "boot"), pageable)searchPage(new SearchPostRequest(null, null), pageable)save

findAll

searchAndPaging
http://localhost:8080/api/paging/post?title=제목1

실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체를 만들어서 애플리케이션 서버에 배포하지 않고도 스프링 MVC 동작을 재현할 수 있는 클래스를 의미한다.
글 추가
@SpringBootTest
@Transactional
@AutoConfigureMockMvc // MockMvc 생성 및 자동 구성
class PostControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper; // 직렬화, 역직렬화를 위한 클래스
@Autowired
protected WebApplicationContext context;
@Autowired
PostService postService;
@BeforeEach
public void mockMvcSetup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
postService.deleteAll();
}
@Test
@DisplayName("블로그에 글 추가")
public void addPost() throws Exception {
// given
final String url = "/api/post";
final String title = "title";
final String content = "content";
final SavePostRequest savePostRequest = new SavePostRequest(title, content);
// Json으로 직렬화
final String requestBody = objectMapper.writeValueAsString(savePostRequest);
// when
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then
result.andExpect(MockMvcResultMatchers.status().isCreated());
List<PostResponse> posts = postService.findAll();
assertThat(posts.size()).isEqualTo(1);
assertThat(posts.get(0).title()).isEqualTo(title);
assertThat(posts.get(0).content()).isEqualTo(content);
}
}
글 목록 조회
@Test
@DisplayName("글 목록 조회")
public void findAllPosts() throws Exception {
// given
final String url = "/api/posts";
final String title = "title";
final String content = "content";
SavePostRequest savePostRequest = new SavePostRequest(title, content);
postService.save(savePostRequest);
// when
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get(url)
.accept(MediaType.APPLICATION_JSON_VALUE));
// then
result
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$[0].title").value(title))
.andExpect(MockMvcResultMatchers.jsonPath("$[0].content").value(content));
}