목표
1. 인터셉터를 추가하여 로그인한 유저인지 판단하도록 한다.
2. 검색 기능을 추가한다.
request를 받은 컨트롤러로 들어가고/나올 때 request
, response
를 인터셉팅하여 처리한다. 필터보다 컨트롤러단에 가깝게 위치해있다. (위 그림에서 가장 왼편의 노란 막대가 필터이다.)
인터셉터를 구현하는 것은 매우 간단하다. 추상 클래스인 HandlerInterceptorAdapter
를 상속받는 클래스를 만든 뒤 preHandle
, postHandle
, afterCompletion
, afterConcurrentHandlingStarted
네 가지 메소드 중 필요한 것만 구현하면 된다.
나는 preHandle을 이용해 세션에 저장된 유저 정보가 없을 경우 로그인 하지 않았다고 판단하고 핸들러 호출을 중단하는 로직을 작성하였다.
util
패키지 하위에 interceptor
패키지를 생성하고 HandlerInterceptorAdapter
를 구현하는 AuthInterceptor
클래스를 만들었다.
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession httpSession = request.getSession();
return Optional.ofNullable(httpSession.getAttribute("USER")).isPresent();
}
}
dispatcher-servlet에 다음과 같이 인터셉터를 추가해주도록 한다.
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**/api/**"/>
<mvc:exclude-mapping path="/api/users/**"/>
<bean class="com.freeboard01.util.interceptor.AuthInterceptor"></bean>
</mvc:interceptor>
</mvc:interceptors>
포스트맨을 이용해 테스트 해보자.
AuthInterceptor가 작동하지 않는 URI | AuthInterceptor가 작동하는 URI |
---|---|
xml 설정파일에 api/users/**
는 인터셉터가 수행되지 않도록 설정하였으므로 바로 컨트롤러로 접근했음을 알 수 있다. 반면에 api/boards
는 인터셉터가 수행되고 (디버깅으로 실행된 톰캣)에서 브레이크 포인트에 걸린 것을 볼 수 있다.
로그인하지 않은 상태이기 때문에 다음처럼 세션이 비어있고,
false를 반환하기 때문에 컨트롤러가 실행되지 않아 아무 일도 일어나지 않는다.
Jpa의 Specifications
을 이용하여 동적 쿼리를 생성하고 검색하는 방법을 익힐 것이다.
ref. https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#specifications
JPA 2에는 프로그래밍 방식으로 쿼리를 작성하는데 사용할 수 있는 api가 도입되었다. 기준(criteria)을 작성하여 도메인 클래스에 대한 쿼리의 where 절을 정의한다. 즉, 이러한 기준은 the JPA criteria API
에서 설명하는 엔티티에 대한 술어로 간주된다.
Spring Data JPA는 Eric Evans의 책인 "Domain Driven Design"에 쓰여진 specification의 개념을 따르고, 그러한 사양을 정의하는 the JPA criteria API
를 이용해 필요한 api를 제공한다. specifications을 지원하기 위해 다음처럼 JpaSpecificationExecutor
인터페이스를 상속받음으로써 레파지토리 인터페이스를 확장 할 수 있다.
public interface CustomerRepository extends CrudRepository<Customer, Long>, JpaSpecificationExecutor {
…
}
추가된 인터페이스에는 다양한 방식으로 specification을 실행할 수 있는 메소드가 존재한다. 예를 들어, findAll 메소드는 specification과 일치하는 모든 엔티티를 반환한다.
List<T> findAll(Specification<T> spec);
Specification
인터페이스는 다음처럼 정의돼있다.
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query,
CriteriaBuilder builder);
}
Specifications은 엔티티 위에 확장가능한 술어 모음을 빌드하기위해 쉽게 사용가능하며, 다음 예제와 같이 필요한 모든 조합에 대해 메소드를 선언할 필요없이 JpaRepository와 결합하여 사용할 수 있다.
public class CustomerSpecs {
public static Specification<Customer> isLongTermCustomer() {
return new Specification<Customer>() {
public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> query,
CriteriaBuilder builder) {
LocalDate date = new LocalDate().minusYears(2);
return builder.lessThan(root.get(Customer_.createdAt), date);
}
};
}
public static Specification<Customer> hasSalesOfMoreThan(MonetaryAmount value) {
return new Specification<Customer>() {
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query,
CriteriaBuilder builder) {
// build query here
}
};
}
}
물론 필요한 상용구의 양에 대한 개선의 여지가 있지만 (아마 Java 8의 종료와 함께 감소될 것이다.), 이 섹션의 뒷부분에서 볼 수 있들이 클라이언트 쪽은 훨씬 더 좋아진다. Customer 타입은 JPA Metamodel 생성기를 사용해 생성된 메타 모델이다. (예제는 Hibernate 구현을 참조하라.) 따라서, `Customer.createdAt`는 Customer가 createdAt이라는 attribute를 가지고 있다고 가정한다. 그 외에도비즈니스 요구사항 추상화 수준에 대한 몇 가지 기준을 표현하고 실행 가능한 Specifications을 만들었다. 따라서 클래이언트는 다음처럼 Specifications을 사용할 수 있다.
List<Customer> customers = customerRepository.findAll(isLongTermCustomer());
이런 종류의 데이터 엑세스에 대한 쿼리를 작성해보아라. 단일 Specification을 사용하는 것은 일반 쿼리 선언에 비해 많은 이점이 있지는 않다. 이들을 결합하여 새로운 Specification을 만들 때 비로소 강력해진다. 다음 예시처럼 표현식을 작성하기 위해 제공하는 Specification의 기본 메소드를 통해 이룰 구현할 수 있다.
MonetaryAmount amount = new MonetaryAmount(200.0, Currencies.DOLLAR);
List<Customer> customers = customerRepository.findAll(
isLongTermCustomer().or(hasSalesOfMoreThan(amount)));
Specification은 Specification 인스턴스를 연결하고 결합하기 위해 일부 "glue-code" 메소드를 기본적으로 제공한다. 이러한 메소드를 사용하면 새로운 Specification 구현체를 만들고 기존 구현체와 결합해 데이터 엑세스 계층을 확장 할 수 있다.
위의 도큐먼트에 나와있는 내용을 따라 동적 쿼리를 생성하였다. 우선, JpaRepository를 상속받던 Repository 인터페이스를 JpaSpecificationExecutor
도 상속받아 Specification을 인자로 사용할 수 있도록 만들었다.
검색 기준에 맞춰 where 절에 필요한 구문을 추가할 것이므로 SearchType
이라는 enum 타입 객체를 domain/board
패키지에 다음과 같이 생성하였다.
public enum SearchType {
ALL, CONTENTS, TITLE, WRITER;
}
Board 엔티티는 글 작성자 정보를 User 엔티티의 pk인 ID를 fk로 가지고 있으므로 작성자로 검색하는 경우에는 User 엔티티에 질의를 해야한다.
또한 이 경우는 기존의 메소드 형식으로 질의가 가능하므로 다음처럼 UserRepository에 메소드(findAllByAccountIdLike
)를 추가하는 것으로 그쳤다.
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByAccountId(String accountId);
List<UserEntity> findAllByAccountIdLike(String keyword);
}
@Repository
public interface BoardRepository extends JpaRepository<BoardEntity, Long>, JpaSpecificationExecutor {
List<BoardEntity> findAllByWriterId(long writerId);
Page<BoardEntity> findAllByWriterIn(List<UserEntity> userEntityList, Pageable pageable);
Page<BoardEntity> findAll(Specification spec, Pageable pageable);
}
findAllByWriterIn
메소드는 UserEntity의 findAllByAccountIdLike
메소드로 찾아낸 유저 목록을 이용하여 해당 유저가 쓴 글을 찾는다.
마지막의 findAll
메소드가 Specification
을 인수로 받는 동적 쿼리를 생성해내는 메소드이다. Pageable을 인수로 추가하여 페이징 또한 한 번에 가능하도록 만들었다.
검색을 위한 메소드를 추가하였다.
@GetMapping(params = {"type", "keyword"})
public ResponseEntity<PageDto<BoardDto>> search(@PageableDefault(page = 1, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
@RequestParam String keyword, @RequestParam SearchType type) {
if (httpSession.getAttribute("USER") == null) {
throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
}
Page<BoardEntity> pageBoardList = boardService.search(pageable, keyword, type);
List<BoardDto> boardDtoList = pageBoardList.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
return ResponseEntity.ok(PageDto.of(pageBoardList, boardDtoList));
}
keyword가 검색어이고, type이 위에서 Enum으로 정의한 SearchType이다.
이전에 벨리데이션을 위해 만든 Specification과 구분하기 위해, domain/board/entity/specs
패키지 하위에 생성하였다.
public class BoardSpecs {
public static Specification<BoardEntity> hasContents(String keyword, SearchType type){
return new Specification<BoardEntity>() {
@Override
public Predicate toPredicate(Root<BoardEntity> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
if(type.equals(SearchType.ALL) || type.equals(SearchType.CONTENTS)){
return criteriaBuilder.like(root.get("contents"), "%"+keyword+"%");
}
return null;
}
};
}
public static Specification<BoardEntity> hasTitle(String keyword, SearchType type){
return new Specification<BoardEntity>() {
@Override
public Predicate toPredicate(Root<BoardEntity> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
if(type.equals(SearchType.ALL) || type.equals(SearchType.TITLE)) {
return criteriaBuilder.like(root.get("title"), "%"+keyword+"%");
}
return null;
}
};
}
}
도큐먼트에 있는 것처럼 이 클래스는 구현체가 아닌 일반 클래스이며 static 메소드를 사용해 즉시 Specification 객체를 반환한다.
각 메소드는 타입에 따라서 null을 반환하기도 하는데, 이런 경우에는 해당 조건문은 제거가 된다.
public Page<BoardEntity> search(Pageable pageable, String keyword, SearchType type) {
if (type.equals(SearchType.WRITER)) {
List<UserEntity> userEntityList = userRepository.findAllByAccountIdLike("%" + keyword + "%");
return boardRepository.findAllByWriterIn(userEntityList, PageUtil.convertToZeroBasePageWithSort(pageable));
}
Specification<BoardEntity> spec = Specification.where(BoardSpecs.hasContents(keyword, type))
.or(BoardSpecs.hasTitle(keyword, type));
return boardRepository.findAll(spec, PageUtil.convertToZeroBasePageWithSort(pageable));
}
첫번째 if문에서 작성자로 검색한 경우에는 단순한 JPA 메소드형 쿼리를 사용하여 값을 반환하도록 하였다. 그렇지 않은 경우에는 아래의 동적쿼리를 이용하여 board entity에 바로 질의 하도록 하였다.
테스트 코드와 디버깅을 이용하여 확인해보자. 🤔
@Test
@DisplayName("게시판 검색 테스트-타이틀")
public void searchTest() throws Exception {
String keyword = "test";
mvc.perform(get("/api/boards?type="+SearchType.TITLE+"&keyword="+keyword)
.session(mockHttpSession))
.andExpect(status().isOk());
}
@Test
@DisplayName("게시판 검색 테스트-글 작성자")
public void searchTest2() throws Exception {
String keyword = "yerin";
mvc.perform(get("/api/boards?type="+SearchType.WRITER+"&keyword="+keyword)
.session(mockHttpSession))
.andExpect(status().isOk());
}
두 개의 테스트 코드를 작성하고 디버깅을 해보았다. 첫 번째 테스트에서 다음과 같이 쿼리가 날라가고 결과적으로 두 개의 Entity를 가져오는 것을 확인할 수 있다.
우선, 글쓰기 버튼 오른편에 위와 같은 검색창을 만들것이다.
HTML
{{#partial "contents"}}
<h1>이곳은 게시판입니다.</h1>
{{#if accountId}}
<div class="row mb-3">
<div class="col">로그인한 계정 : {{accountId}}</div>
<div class="col-8">
<button onclick='location.href="logout"' class='btn btn-secondary btn-sm'>로그아웃</button>
</div>
</div>
{{/if}}
<div id="tableSpace"></div>
<div id="pageMarkerSpace"></div>
<div id="writeModalSpace"></div>
<div class="row" style="width: 50% !important;">
{{#if accountId}}
<div class="col">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#boardModal">글쓰기</button>
</div>
{{/if}}
<div class="col-8">
<div class="row">
<select class="selectpicker" id="searchType">
<option value="all">제목+내용</option>
<option value="contents">내용</option>
<option value="title">제목</option>
<option value="writer">글쓴이</option>
</select>
<div class="col"><input type="text" class="form-control" id="search"></div>
<button type="button" class="btn btn-success" id="searchBtn">검색</button>
</div>
</div>
</div>
{{/partial}}
script
다음처럼 전역변수로 keywordForSearch
와 typeForSearch
를 추가하였다. 이는 검색 후에 페이지를 이동할 때도 검색에 필요한 키워드와 타입을 보존하기 위함이다.
searchBtn을 클릭하면 검색어가 있는지 판단한다. 검색어가 없으면 전체 게시글을 가져오고 검색어가 있으면 searchApi를 요청한다.
$(document).on('click', '#searchBtn', function () {
var keyword = $("#search").val();
var type = $("#searchType option:selected").val().toUpperCase();
if(keyword.trim() == ""){
keywordForSearch = "";
apiRequest(attachBoard, 0);
}
keywordForSearch = keyword;
typeForSearch = type;
search(keyword, type, attachBoard);
})
var search = (keyword, type, callback = null, page = 1) => {
const SIZE = 10;
$.ajax({
method: 'GET',
url: 'api/boards?page=' + page + "&size=" + SIZE + "&keyword=" + keyword + "&type=" + type
}).done(function (response) {
nowBoardList = response.contents;
if (typeof callback != 'undefined') {
callback(response);
}
})
}
이전에 페이지 버튼을 누르면 실행됐던 pageConvert
함수도 검색 후에 페이지를 바꾸는 것인지 판단하는 로직을 추가하였다.
var pageConvert = (page) => {
if(keywordForSearch != ""){
search(keywordForSearch, typeForSearch, attachBoard, page);
}else {
apiRequest(attachBoard, page);
}
}
검색이 잘 되는지 보자.
제목+내용에 test검색 | 빈칸 검색 (초기화) |
---|---|
빈칸을 검색하면 모든 데이터를 받아오는데, 그냥 "전체 보기" 같은 버튼을 추가해도 된다. (사실 HTML 작성하는게 귀찮아서 난 안만들었다.💦)
전체 코드는 github에서 확인 할 수 있습니다.
Spring + JPA + MySql + Gradle 로 구성한 프로젝트 내용은 여기서 끝이다.
이전에 번역하다가 덮어둔 logback을 마저 번역하고, test code를 추가/수정하여 깃헙에 업데이트 할 예정이다.
그 후에는 같은 코드를 재활용하여 JPA를 MyBatis로 변경해보도록 하겠다.🤔
사실 처음부터 다시 만들까 했는데 생각해보니 굳이 그럴 필요도 없을것 같고 이미 있는 코드를 바꾸는 것이 차이점을 이해하는데도 더 좋을거 같다는 생각이 들었기 때문이다. 아무튼 내일 안에 logback 번역을 완료하고 바로 시작하도록 하자!