블로그 글 작성

뚜우웅이·2025년 1월 20일

Post

Querydsl

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를 클릭해준다.

domain

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> {
}

api

dto

request

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로 만들기 위한 생성자도 작성해줬다.

controller

@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

@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을 최적화 해준다.

exception

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

CustomRepository

interface

public interface PostRepositoryCustom {
    Page<PostResponse> searchPage(SearchPostRequest searchPostRequest, Pageable pageable);
}

Impl

@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);
        }
    }
}

기대결과

  • 제목에 “spring”이 포함된 게시물 검색:
    searchPage(new SearchPostRequest("spring", null), pageable)
  • 내용에 “boot”이 포함된 게시물 검색:
    searchPage(new SearchPostRequest(null, "boot"), pageable)
  • 제목에 “spring” 또는 내용에 “boot”이 포함된 게시물 검색:
    searchPage(new SearchPostRequest("spring", "boot"), pageable)
  • 제목과 내용이 모두 없는 경우(전체 조회):
    searchPage(new SearchPostRequest(null, null), pageable)

결과 화면

save

findAll

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

Test

MockMvc란?

실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체를 만들어서 애플리케이션 서버에 배포하지 않고도 스프링 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));
    }
profile
공부하는 초보 개발자

0개의 댓글