오래전 만들어두었던 사이드 프로젝트를 리팩토링하는 작업을 진행중에 소소하게 막힌 부분들 중 간단히 작성할 수 있는 내용을 남긴다.
현재 리팩토링하는 프로젝트는 SpringBoot 3.2.3 버전에 JPA 관련해서는 `spring-boot-starter-data-jpa` 라이브러리를 사용한다. 기본적으로 모든 레파지토리는 `JpaRepository<T, ID>` 인터페이스를 상속하는 형태로 존재한다.
문제가 된 부분은 게시글(BoardImp)을 조회할 때, 대소문자 구분없이 들어온 값이 포함되는 데이터를 조회하는 기능을 작성하는 것이었다. `LOWER()`와 `LIKE %문자열%`를 함께 쓰고 싶었다.
실행되길 원하는 쿼리문은 아래와 같았다.
SELECT b.* from board_imp b
WHERE (LOWER(b.title) LIKE '%string%' OR LOWER(b.content) LIKE '%string%');
처음에는 별도의 쿼리문이 필요하지 않았다. 그저 Jpa에서 제공하는 메서드명에 조건을 달면 되는 형식이었으나, 데이터를 삭제하는 정책을 변경(사용자가 웹 페이지에서 접하는 삭제를 실제 영구 삭제보다는 '삭제됨'과 같은 플래그 형식의 컬럼값을 가지게 하는게 좋을 것 같아 주요 데이터들마다 해당 필드를 가지게 하고, 삭제시 해당 필드 값을 업데이트 시킴)함 따라 쿼리문을 직접 작성해야 했다. (삭제된 상태에 대한 플래그 형식의 컬럼값이 'Y'가 아닌 경우를 조회해야 하기 때문에 `@Query`를 모두 직접 작성했다.)
Page<BoardNotice> findByTitleContainingOrContentContaining(
String title, String content, Pageable pageable);
수정해서 아래와 같이 작성했는데, 이를 Java가 읽어내지 못해서 다른 방안을 찾아야 했다.
public interface BoardNoticeRepository extends JpaRepository<BoardNotice, Long> {
@Query("select b from BoardNotice b" +
" join fetch b.writer m" +
" where lower(b.title) like %lower(:title)% or lower(b.content) like %lower(:content)%" +
" and (b.delYn IS NULL OR b.delYn <> 'Y')")
Page<BoardNotice> findByTitleContainingOrContentContaining(@Param("title") String title, @Param("content") String content, Pageable pageable);
}
Example
그러다 찾은 것이 Example과 ExampleMatcher로, 이들은 원하는 조건에 대한 전략을 만들고 이에 맞는 데이터를 조회할 수 있게 도와준다. 이 글의 서두에 작성한 것과 같이, 기본적으로 레파지토리를 작성할 때 JpaRepository 인터페이스를 상속받는 형태로 구성한다면 별도의 구현 없이 사용할 수 있다.
# 사용 방식
## 1)
Example.of(원하는 값을 넣은 인스턴스)
## 2)
Example.of(원하는 값을 넣은 인스턴스, 전략을 넣은 ExampleMatcher 인스턴스)
`Example` 인스턴스를 생성할 때 `ExampleMatcher`는 별도로 생성하지 않고 `Example.of(인스턴스)`와 같이 생성한다면, `ExampleMatcher`는 기본값으로 생성된다. 즉, `Example.of(인스턴스)` 에서 값을 넣은 '인스턴스'의 필드 중에서 값이 `null`이 아닌 모든 필드를 매칭시킨다. (이 때, 빈 스트링 값("")도 매칭시킨다. 이러면 정확한 데이터 조회가 일어날 수 없으니 인스턴스 생성시 부여되는 기본값들에 주의하자.)
@Table(name = "board_imp")
@Builder
@Entity
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
public class BoardImp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private int views = 0;
private DeleteFlag delYn;
// 생략
}
// 값을 비교할 필드에 값 채워넣기
BoardImp boardParam = BoardImp.builder()
.title(keyword)
.content(keyword)
.build();
// Example 객체 생성
Example<BoardImp> example = Example.of(boardParam);
List<BoardImp> boards = boardImpRepository.findAll(example);
위와 같이 아주 기본적인 형태로 작성하면 아래와 같은 sql문이 실행된다.
select b.* from board_imp b
where b.title='keyword' and b.content='keyword'
ExampleMatcher
조회 전략에는 `matching()`, `matchingAll()`, `matchingAny()`가 있다. `matching()`과 `matchingAll()`은 결국 모두 `matchingAll()`을 호출한다. SQL로 치면 `matchingAll()`은 `AND` 연산자이고, `matchingAny()`는 `OR` 연산자다.
내가 필요한 부분은 title과 content만 대소문자 구분없이 포함하는 경우를 찾고 싶었으므로 아래와 같이 작성했다.
@SpringBootTest
@Transactional
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class BoardImpRepositoryTest {
@Autowired
private BoardImpRepository boardImpRepository;
@Test
void 게시글의_제목_또는_내용으로_검색() {
BoardImp boardParam = BoardImp.builder()
.title(keyword)
.content(keyword)
.delYn(DeleteFlag.N)
.build();
ExampleMatcher matcher = ExampleMatcher.matchingAny()
.withIgnoreCase("title", "content")
.withIgnorePaths("views") // 기본값이 0으로 들어가는 필드를 무시하도록 명령
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING);
Pageable pageable = PageRequest.of(0, 10, Sort.by("regDate").descending());
Example<BoardImp> example = Example.of(boardParam, matcher);
Page<BoardImp> p = boardImpRepository.findAll(example, pageable);
List<BoardImp> l = p.getContent();
}
}
ExampleMatcher.StringMatcher에는 아래와 같은 전략들이 존재한다.
- DEFAULT(`=`를 의미), STARTING, ENDING, CONTAINING, EXACT, REGEX
'Backend > JPA' 카테고리의 다른 글
JPA) 지연 로딩 성능 최적화(N + 1 문제 해결, fetch 조인) (0) | 2024.05.25 |
---|---|
JPA) 영속성 컨텍스트(Persistence Context) (0) | 2024.05.24 |
Springboot + JPA) 페이지네이션(Pagenation)하기 - Pageable, PageRequest, Page (0) | 2024.04.17 |
JPA(Java Persistence API) - 도커에 올린 DB(H2)와 연동하기 (0) | 2023.11.17 |