목표
1. ResultMap과 join을 이용하는 방법을 익힌다.
2. Board를 질의할 때 User가 null로 반환되는 문제를 해결한다.
3. JPA관련 어노테이션으로 이뤄진 Entity 클래스 형태를 변경한다.
BoardEntity 자바 클래스는 UserEntity라는 자바 클래스를 멤버 변수로 가지고 있으며, JPA가 지원하는 어노테이션을 이용하여 n:1
의 관계를 정의하고 외래키로써 데이터 베이스에는 writerId
가 저장된다. JPA는 자동으로 이를 인식하고 Lazy Loading을 이용하여 writerId
를 이용해 UserEntity
를 질의함으로써 자동으로 객체를 가져와주었다.
하지만 MyBatis는 이 기능을 지원하지 않기 때문에 Mapper.xml
에서 명시적으로 lazy loading이나 eager loading(join 구문 이용)를 이용하는 구문을 사용해 주어야한다.
이 때 반환받는 형태와 질의 방식을 정의하기 위해 사용하는 것이 ResultMap
이다.
다음의 코드가 모든 것을 보여준다.
<resultMap id="detailedBlogResultMap" type="Blog">
<constructor>
<idArg column="blog_id" javaType="int"/>
</constructor>
<result property="title" column="blog_title"/>
<association property="author" javaType="Author">
<id property="id" column="author_id"/>
<result property="username" column="author_username"/>
<result property="password" column="author_password"/>
<result property="email" column="author_email"/>
<result property="bio" column="author_bio"/>
<result property="favouriteSection" column="author_favourite_section"/>
</association>
<collection property="posts" ofType="Post">
<id property="id" column="post_id"/>
<result property="subject" column="post_subject"/>
<association property="author" javaType="Author"/>
<collection property="comments" ofType="Comment">
<id property="id" column="comment_id"/>
</collection>
<collection property="tags" ofType="Tag" >
<id property="id" column="tag_id"/>
</collection>
<discriminator javaType="int" column="draft">
<case value="1" resultType="DraftPost"/>
</discriminator>
</collection>
</resultMap>
resultType
대신 사용될 resultMap
argument의 value가 될 것이다. com.freeboard02.domain.board.BoardEntity
처럼 경로로 명시하여도 된다.Blog(int blog_id)
꼴의 생성자를 호출할 것이다.result
로 정의된 title property를 argument로 포함하는 생성자를 빈을 사용하고 싶다면 (setter로 값을 할당하고 싶지 않다면) <arg column="blog_title" javaType="String" name="title" />
을 constructor
태그 사이에 추가하면된다. 이 경우에는 Blog(int id, String title)
꼴의 생성자를 호출할 것이다.resultMap
이라는 Argument를 사용할 수 있는데 해당 태그 내부에 정의된 result를 다른 resultMap으로 분리하고 이를 참조하는 경우에 쓰인다.결론적으로 Join을 이용한 Eager Loding으로 데이터를 한 번에 가져왔으므로 간략하게 Lazy Loding을 사용하는 예제를 가져와보았다.
<resultMap id="blogResult" type="Blog">
<association property="author" column="author_id" javaType="Author" select="selectAuthor"/>
</resultMap>
<select id="selectBlog" resultMap="blogResult">
SELECT * FROM BLOG WHERE ID = #{id}
</select>
<select id="selectAuthor" resultType="Author">
SELECT * FROM AUTHOR WHERE ID = #{id}
</select>
위 예제는 다음 처럼 작동한다.
selectBlog
의 쿼리 SELECT * FROM BLOG WHERE ID = #{id}
가 수행된다.blogResult
에 맵핑한다.author_id
컬럼 -author
멤버 변수를 제외한 나머지 컬럼과 멤버 변수가 자동으로 매칭된다.author
를 채우기 위해서 select="selectAuthor"
를 수행한다.selectAuthor
의 SELECT * FROM AUTHOR WHERE ID = #{id}
가 수행된다. (이 때 아이디로는 blogResult의 association에 명시된 column인 author_id
가 자동으로 사용된다.)Author
자바 빈을 생성한 뒤 Blog
자바 빈에 set한다.자세한 내용은 위에 링크한 도큐먼트에서 알아보도록 한다.
BoardMapper가 작동할 때 이를 수행할 것이므로 board-mapper.xml
의 mapper
태그 내부에 resultMap을 만들었다.
<resultMap id="boardResultMap" type="com.freeboard02.domain.board.BoardEntity">
<id property="id" column="board_id" javaType="long"/>
<result property="title" column="title"/>
<result property="contents" column="contents"/>
<result property="createdAt" column="board_createdAt"/>
<result property="updatedAt" column="board_updatedAt"/>
<association property="writer" column="writerId" javaType="com.freeboard02.domain.user.UserEntity" resultMap="userResultMap" />
</resultMap>
<resultMap id="userResultMap" type="com.freeboard02.domain.user.UserEntity">
<id property="id" column="user_id" javaType="long"/>
<result property="accountId" column="accountId"/>
<result property="password" column="password"/>
<result property="role" column="role"/>
<result property="createdAt" column="user_createdAt"/>
<result property="updatedAt" column="user_updatedAt"/>
</resultMap>
따로 생성자를 사용하지 않았기 때문에 모두 단순 컬럼-프로퍼티 매칭으로 작성하였다. id
, createdAt
, updatedAt
은 동일한 컬럼이므로 쿼리에서 설정한 Aliace로 column
Argument를 대체하였다.
<select id="findById" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE b.id = #{id}
</select>
이를 테스트 하기위해서 BoardMapperTest
의 mapperFindById
테스트 코드를 다음처럼 변경하고 수행해보자.
@Test
public void mapperFindById() {
boardMapper.save(targetBoard);
BoardEntity entity = boardMapper.findById(targetBoard.getId()).get();
assertThat(targetBoard.getTitle(), equalTo(entity.getTitle()));
assertThat(targetBoard.getContents(), equalTo(entity.getContents()));
assertThat(entity.getWriter().getId(), equalTo(user.getId()));
assertThat(entity.getWriter().getRole(), equalTo(user.getRole()));
}
잘 수행되었다면 다른 select 쿼리도 resultMap을 이용하는 코드로 변경하도록 한다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.freeboard02.domain.board.BoardMapper">
<resultMap id="boardResultMap" type="com.freeboard02.domain.board.BoardEntity">
<id property="id" column="board_id" javaType="long"/>
<result property="title" column="title"/>
<result property="contents" column="contents"/>
<result property="createdAt" column="board_createdAt"/>
<result property="updatedAt" column="board_updatedAt"/>
<association property="writer" column="writerId" javaType="com.freeboard02.domain.user.UserEntity" resultMap="userResultMap" />
</resultMap>
<resultMap id="userResultMap" type="com.freeboard02.domain.user.UserEntity">
<id property="id" column="user_id" javaType="long"/>
<result property="accountId" column="accountId"/>
<result property="password" column="password"/>
<result property="role" column="role"/>
<result property="createdAt" column="user_createdAt"/>
<result property="updatedAt" column="user_updatedAt"/>
</resultMap>
<select id="findAll" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE 1=1
<if test="searchType == 'ALL' || searchType == 'CONTENTS'">
AND contents like CONCAT('%', #{target}, '%')
</if>
<if test="searchType == 'ALL' || searchType == 'TITLE'">
AND title like CONCAT('%', #{target}, '%')
</if>
<if test="searchType != 'ALL' and searchType != 'CONTENTS' and searchType != 'TITLE'">
AND 1=0
</if>
ORDER BY b.createdAt DESC
LIMIT #{start}, #{pageSize}
</select>
<select id="findAllByWriterIn" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE writerId IN
<foreach collection="userEntityList" item="user" index='i' open="(" close=")" separator=",">#{user.id}</foreach>
ORDER BY b.createdAt DESC
LIMIT #{start}, #{pageSize}
</select>
<select id="findAllByWriterId" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE writerId = #{writerId}
</select>
<select id="findAllBoardEntity" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
</select>
<select id="findById" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE b.id = #{id}
</select>
<insert id="save" parameterType="boardentity" keyProperty="id" keyColumn="id">
INSERT INTO free_board_mybatis.board (createdAt, updatedAt, contents, title, writerId)
VALUES (NOW(), NOW(), #{contents}, #{title}, #{writer.id})
<selectKey resultType="Long" order="AFTER" keyProperty="id">
SELECT LAST_INSERT_ID() as id
</selectKey>
</insert>
<update id="updateById" parameterType="boardentity">
UPDATE free_board_mybatis.board
SET updatedAt = NOW(),
contents = #{contents},
title = #{title}
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM free_board_mybatis.board
WHERE id = #{id}
</delete>
</mapper>
🔎BoardMapperTest
테스트 코드에 다음처럼 글쓴이를 확인하는 로직 또한 추가해주었다.
본격적으로 Repository로 수행되던 작업을 Mapper로 변경할 것이다.
위 이미지에서도 볼 수 있듯이 JPA를 사용할 때는 Pageable
를 argument로 사용하고 리턴 타입을 Page
컬렉션을 사용함으로써 자동으로 페이징과 관련된 데이터들을 얻을 수 있었다. MyBatis로 메소드를 변경하면서 Pageable이 아닌 int
값의 PageNumber와 PageSize를 직접 입력받고 반환 컬렉션은 List
로 변경했다.
따라서
totalPages
에 대한 정보가 없기 때문에 이를 위해 전페 데이터의 count를 가져오는 쿼리를 추가할 것이다. BoardMapper에 두 개의 메소드를 추가한다.
int findTotalSize(); // 전체 데이터 크기를 가져오는 쿼리를 수행
List<BoardEntity> findAllWithPaging(@Param("start") int start, @Param("pageSize") int pageSize); // 조건 없이 모든 데이터를 페이징해서 가져오는 쿼리를 수행
board-mapper.xml
<select id="findTotalSize" resultType="int">
SELECT COUNT(*)
FROM free_board_mybatis.board
</select>
<select id="findAllWithPaging" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
ORDER BY b.createdAt DESC
LIMIT #{start}, #{pageSize}
</select>
boardService
get
메소드에서 repository 대신 Mapper를 사용하도록 변경한다.
Zero베이스로 변경해주는 작업은 Controller로 옮겨주었다. 변경된 메소드는 다음과 같다.
public int getTotalSize(){
return boardMapper.findTotalSize();
}
public List<BoardEntity> get(Pageable pageable) {
return boardMapper.findAllWithPaging(pageable.getPageNumber()*pageable.getPageSize(), pageable.getPageSize());
}
boardApiController
위에 잠깐 언급했듯이 boardService.get
에 pageable
을 넘길 때 바로 Zero베이스 변경을 수행한다.
@GetMapping
public ResponseEntity<PageDto<BoardDto>> get(@PageableDefault(page = 1, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
// 제로 베이스로 페이지를 변경하여 전달한다.
List<BoardEntity> pageBoardList = boardService.get(PageUtil.convertToZeroBasePageWithSort(pageable));
// 반환받은 Entity를 Dto로 변경한다.
List<BoardDto> boardDtoList = pageBoardList.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
// PageDto로 가공한다.
return ResponseEntity.ok(PageDto.of(boardService.getTotalSize(), PageUtil.convertToZeroBasePageWithSort(pageable), boardDtoList));
}
PageDto에도 다음 메소드를 추가해준다.
public static <T> PageDto of(int totalDataSize, Pageable pageable, List<T> contents) {
int totalPages = (int) Math.ceil(((float) totalDataSize) / pageable.getPageSize());
int nowPage = pageable.getPageNumber() + 1;
int startPage = 1 * VIEWPAGESIZE * (nowPage / VIEWPAGESIZE);
int endPage = startPage + VIEWPAGESIZE - 1;
return new PageDto<>(startPage == 0 ? 1 : startPage, endPage > totalPages ? totalPages : endPage, totalPages, contents);
}
int totalPages = (int) Math.ceil(((float) totalDataSize) / pageable.getPageSize());
부분이 pageable없이 총 페이지 수를 계산하는 수식이다. Math.ceil
은 1의 자리까지 반올림해주는 메소드이다.
page와 관련된 데이터 가공로직은 작성됐으니 repository를 mapper로 모두 변경해보도록 하자.
@Service
@Transactional
public class BoardService {
private BoardMapper boardMapper;
private UserMapper userMapper;
@Autowired
public BoardService(BoardMapper boardMapper, UserMapper userMapper) {
this.boardMapper = boardMapper;
this.userMapper = userMapper;
}
public int getTotalSize(){
return boardMapper.findTotalSize();
}
public List<BoardEntity> get(Pageable pageable) {
return boardMapper.findAllWithPaging(pageable.getPageNumber()*pageable.getPageSize(), pageable.getPageSize());
}
public void post(BoardForm boardForm, UserForm userForm) {
UserEntity user = Optional.of(userMapper.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
boardMapper.save(boardForm.convertBoardEntity(user));
}
public void update(BoardForm boardForm, UserForm userForm, long id) {
UserEntity user = Optional.of(userMapper.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
BoardEntity target = Optional.of(boardMapper.findById(id).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));
if (IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) == false && HaveAdminRoles.confirm(user) == false) {
throw new FreeBoardException(BoardExceptionType.NO_QUALIFICATION_USER);
}
target.update(boardForm.convertBoardEntity(target.getWriter()));
}
public void delete(long id, UserForm userForm) {
UserEntity user = Optional.of(userMapper.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
BoardEntity target = Optional.of(boardMapper.findById(id).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));
if (IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) == false && HaveAdminRoles.confirm(user) == false) {
throw new FreeBoardException(BoardExceptionType.NO_QUALIFICATION_USER);
}
boardMapper.deleteById(id);
}
public List<BoardEntity> search(Pageable pageable, String keyword, SearchType type) {
if (type.equals(SearchType.WRITER)) {
List<UserEntity> userEntityList = userMapper.findByAccountIdLike(keyword);
return boardMapper.findAllByWriterIn(userEntityList, pageable.getPageNumber()*pageable.getPageSize(), pageable.getPageSize());
}
return boardMapper.findAll(type.name(), keyword, pageable.getPageNumber()*pageable.getPageSize(), pageable.getPageSize());
}
}
@RestController
@RequestMapping("/api/boards")
@RequiredArgsConstructor
public class BoardApiController {
private final HttpSession httpSession;
private final BoardService boardService;
@GetMapping
public ResponseEntity<PageDto<BoardDto>> get(@PageableDefault(page = 1, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
List<BoardEntity> pageBoardList = boardService.get(PageUtil.convertToZeroBasePageWithSort(pageable));
List<BoardDto> boardDtoList = pageBoardList.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
return ResponseEntity.ok(PageDto.of(boardService.getTotalSize(), PageUtil.convertToZeroBasePageWithSort(pageable), boardDtoList));
}
@PostMapping
public void post(@RequestBody BoardForm form) {
if (httpSession.getAttribute("USER") == null) {
throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
}
boardService.post(form, (UserForm) httpSession.getAttribute("USER"));
}
@PutMapping("/{id}")
public void update(@RequestBody BoardForm form, @PathVariable long id) {
if (httpSession.getAttribute("USER") == null) {
throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
}
boardService.update(form, (UserForm) httpSession.getAttribute("USER"), id);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable long id) {
if (httpSession.getAttribute("USER") == null) {
throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
}
boardService.delete(id, (UserForm) httpSession.getAttribute("USER"));
}
@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);
}
List<BoardEntity> pageBoardList = boardService.search(PageUtil.convertToZeroBasePageWithSort(pageable), keyword, type);
List<BoardDto> boardDtoList = pageBoardList.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
return ResponseEntity.ok(PageDto.of(boardService.getTotalSize(), PageUtil.convertToZeroBasePageWithSort(pageable), boardDtoList));
}
}
여기까지 진행했으면 톰캣을 실행해보자!!
❗️NOTE
만약 다음처럼 경로를 못 찾는다는 에러가 발생한다면
Failed to convert property value of type 'java.lang.String' to required type 'org.springframework.core.io.Resource[]' for property 'mapperLocations'; nested exception is java.lang.IllegalArgumentException: Could not resolve resource location pattern [classpath:/mappers/*.xml]: class path resource [mappers/] cannot be resolved to URL because it does not exist
이미지와 같이WEB-INF
하위에classes
폴더를 생성해주고 mybatis 관련 xml 파일들을 추가해주자.
src/main/resources
는 자바가 실행될 때 사용되는 정적 파일의 모음인데 이는war
파일로 웹에 배포될 때WEB-INF/classes
파일을 생성하면서 모든 데이터가 복제된다고 한다.
즉classpath:/mappers/*.xml
은WEB-INF/classes/mappsers/*.xml
로 대치되는 것이다.정확한 이유는 모르겠으나 이런 자동 복사가 일어나지 않아서 테스트 코드가 실행될 때(배포가 되는 것이 아니므로
src/main/resources/mappers/*.xml
에서 찾는다.)와 달리 파일을 찾을 수 없다는 메세지가 뜨는 것이다.
기능들이 잘 동작하는지 확인한다.
Repository를 이용하는 모든 테스트를 mapper로 변경해준다.
❗️NOTE
Junit5 Exception test
assertThrow와 assertEquals를 이용하여 어떤 Exception이 발동되었는지 확인 할 수 있다.아래 코드를 보자
@Test() @DisplayName("로그인한 유저와 글을 작성한 유저가 다를 경우 삭제를 진행하지 않는다.") public void delete1() { //실제 글 작성자로 모킹될 유저 UserEntity writer = UserEntity.builder().accountId("mockUser").password("mockPass").build(); //로그인한 유저 UserForm userLoggedIn = UserForm.builder().accountId("wrongUser").password("wrongUser").build(); //모킹될 글 BoardEntity boardEntity = BoardEntity.builder().contents("contents").title("title").writer(writer).build(); /* * 세션에 저장된 유저 정보를 이용해 유저 엔티티를 가져오는 로직*/ given(mockUserMapper.findByAccountId(anyString())).willReturn(userLoggedIn.convertUserEntity()); //지우려는 글을 가져오는 로직 given(mockBoardMapper.findById(anyLong())).willReturn(Optional.of(boardEntity)); /* * delete 메소드가 수행되는 도중 FreeBoardException이 발생하면 테스트 로직을 중단하지 않고 e에 저장한다.*/ Throwable e = assertThrows(FreeBoardException.class, () -> { sut.delete(anyLong(), userLoggedIn); }); //받아온 e가 발생하길 기대했던 exception인지 확인한다. assertEquals(BoardExceptionType.NO_QUALIFICATION_USER.getErrorMessage(), e.getMessage()); //deleteById 메소드가 단 한 번도 수행되지 않았음을 확인한다. verify(mockBoardMapper, never()).deleteById(anyLong()); }
톰캣을 실행해서 이것 저것 테스트해보던 와중에 검색이 제대로 수행되지 않는 것을 확인했다. 테스트코드를 보니 유저 아이디로 검색하는 것만 테스트 코드가 짜여있음을 발견했고 테스트 코드를 우선적으로 작성하도록 하였다.
아니나 다를까 테스트 코드가 실패하였다. 🤔 매퍼 테스트가 실패하는 것을 보니 쿼리가 잘못 작성된 것으로 예상된다.
쿼리는 예상한대로 잘 작성되어있다. 이를 복사해서 테스트해보자. 데이터 베이스에서 직접 쿼리를 수행해도 아무 데이터가 나오지 않는 모습이다. 생각해보니 제목 "또는" 내용 검색이므로 AND
로 연결된 구간을 고쳐줘야했다.
아래와 같이 if문을 변경하였다.
<select id="findAll" resultMap="boardResultMap">
SELECT b.id as board_id, b.createdAt as board_createdAt, b.updatedAt as board_updatedAt, contents, title, writerId,
u.id as user_id, u.createdAt as user_createdAt, u.updatedAt as user_updatedAt, accountId, password, role
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE 1=1
<if test="searchType == 'ALL'">
AND (contents like CONCAT('%', #{target}, '%') OR title like CONCAT('%', #{target}, '%'))
</if>
<if test="searchType == 'CONTENTS'">
AND contents like CONCAT('%', #{target}, '%')
</if>
<if test="searchType == 'TITLE'">
AND title like CONCAT('%', #{target}, '%')
</if>
<if test="searchType != 'ALL' and searchType != 'CONTENTS' and searchType != 'TITLE'">
AND 1=0
</if>
ORDER BY b.createdAt DESC
LIMIT #{start}, #{pageSize}
</select>
제목+내용(위의 캡처에 있는 것)으로 검색하는 것, 제목으로 검색하는 것, 내용으로 검색하는 것으로 총 3개의 테스트가 추가되었다.
코드는 거의 비슷하니 가볍게 보며 이해할 수 있을 것이다.
@Test
@DisplayName("제목+내용으로 검색한다.")
public void mapperSearch() {
UserEntity userEntity = userMapper.findAll().get(0);
String time = LocalDateTime.now().toString();
List<Long> savedEntityIds = new ArrayList<>();
for (int i = 0; i < 20; ++i) {
BoardEntity boardEntity = null;
if (i % 2 == 0) {
boardEntity = BoardEntity.builder().writer(userEntity).contents(time).title("title").build();
} else {
boardEntity = BoardEntity.builder().writer(userEntity).contents("contents").title(time).build();
}
boardMapper.save(boardEntity);
savedEntityIds.add(boardEntity.getId());
}
List<BoardEntity> findEntities = boardMapper.findAll(SearchType.ALL.name(), time, PAGE, SIZE);
List<Long> findEntityIds = findEntities.stream().map(boardEntity -> boardEntity.getId()).collect(Collectors.toList());
assertThat(findEntities.size(), equalTo(SIZE));
assertThat(savedEntityIds, hasItems(findEntityIds.toArray(new Long[SIZE])));
}
@Test
@DisplayName("제목으로 검색한다.")
public void mapperSearch2() {
UserEntity titleSameUser = userMapper.findAll().get(0);
UserEntity contentsSameUser = userMapper.findAll().get(1);
String time = LocalDateTime.now().toString();
List<Long> savedEntityIds = new ArrayList<>();
for (int i = 0; i < 20; ++i) {
BoardEntity boardEntity = null;
if (i % 2 == 0) {
boardEntity = BoardEntity.builder().writer(contentsSameUser).contents(time).title("title").build();
} else {
boardEntity = BoardEntity.builder().writer(titleSameUser).contents("contents").title(time).build();
}
boardMapper.save(boardEntity);
savedEntityIds.add(boardEntity.getId());
}
List<BoardEntity> findEntities = boardMapper.findAll(SearchType.TITLE.name(), time, PAGE, SIZE);
List<Long> findEntityIds = findEntities.stream().map(boardEntity -> boardEntity.getId()).collect(Collectors.toList());
List<Long> writerIds = findEntities.stream().map(boardEntity -> boardEntity.getWriter().getId()).distinct().collect(Collectors.toList());
assertThat(findEntities.size(), equalTo(SIZE));
assertThat(savedEntityIds, hasItems(findEntityIds.toArray(new Long[SIZE])));
assertThat(writerIds.size(), equalTo(1));
assertThat(writerIds.get(0), equalTo(titleSameUser.getId()));
}
@Test
@DisplayName("내용으로 검색한다.")
public void mapperSearch3() {
UserEntity titleSameUser = userMapper.findAll().get(0);
UserEntity contentsSameUser = userMapper.findAll().get(1);
String time = LocalDateTime.now().toString();
List<Long> savedEntityIds = new ArrayList<>();
for (int i = 0; i < 20; ++i) {
BoardEntity boardEntity = null;
if (i % 2 == 0) {
boardEntity = BoardEntity.builder().writer(contentsSameUser).contents(time).title("title").build();
} else {
boardEntity = BoardEntity.builder().writer(titleSameUser).contents("contents").title(time).build();
}
boardMapper.save(boardEntity);
savedEntityIds.add(boardEntity.getId());
}
List<BoardEntity> findEntities = boardMapper.findAll(SearchType.CONTENTS.name(), time, PAGE, SIZE);
List<Long> findEntityIds = findEntities.stream().map(boardEntity -> boardEntity.getId()).collect(Collectors.toList());
List<Long> writerIds = findEntities.stream().map(boardEntity -> boardEntity.getWriter().getId()).distinct().collect(Collectors.toList());
assertThat(findEntities.size(), equalTo(SIZE));
assertThat(savedEntityIds, hasItems(findEntityIds.toArray(new Long[SIZE])));
assertThat(writerIds.size(), equalTo(1));
assertThat(writerIds.get(0), equalTo(contentsSameUser.getId()));
}
테스트가 잘 수행되는 것을 확인했으면 다시 톰캣을 띄워주자.
"my"로 검색 | 1번 글의 내용 |
---|---|
"mY"라는 단어가 포함된 Contents를 가지고 있어 검색에 포함되었다. |
검색은 잘 되는 것을 확인 할 수 있다.
또 다른 문제는 findTotalSize
로 페이지 마커를 생성하고 있는 것이다. 😑
board-mapper.xml에 다음과 같은 쿼리를 추가해준다.
<select id="findTotalSizeForSearch" resultType="int">
SELECT COUNT(*)
FROM free_board_mybatis.board b JOIN free_board_mybatis.user u ON (b.writerId = u.id)
WHERE 1=1
<if test="searchType == 'ALL'">
AND (contents like CONCAT('%', #{target}, '%') OR title like CONCAT('%', #{target}, '%'))
</if>
<if test="searchType == 'CONTENTS'">
AND contents like CONCAT('%', #{target}, '%')
</if>
<if test="searchType == 'TITLE'">
AND title like CONCAT('%', #{target}, '%')
</if>
<if test="searchType != 'ALL' and searchType != 'CONTENTS' and searchType != 'TITLE'">
AND 1=0
</if>
</select>
BoardMapper에 매칭될 메소드를 추가한 뒤,
int findTotalSizeForSearch(@Param("searchType") String searchType, @Param("target") String target);
BoardApiController, BoardService 클래스에서 사용하고 있던 findTotalSize
를 대체해준다.
테스트 코드도 작성해주었다.
@Test
@DisplayName("검색시 토탈페이지 계산을 위한 COUNT 쿼리를 수행한다.")
public void mapperSearch4() {
int insertSize = 20;
UserEntity userEntity = userMapper.findAll().get(0);
String time = LocalDateTime.now().toString();
List<Long> savedEntityIds = new ArrayList<>();
for (int i = 0; i < insertSize; ++i) {
BoardEntity boardEntity = null;
if (i % 2 == 0) {
boardEntity = BoardEntity.builder().writer(userEntity).contents(time).title("title").build();
} else {
boardEntity = BoardEntity.builder().writer(userEntity).contents("contents").title(time).build();
}
boardMapper.save(boardEntity);
savedEntityIds.add(boardEntity.getId());
}
List<BoardEntity> findEntities = boardMapper.findAll(SearchType.ALL.name(), time, PAGE, SIZE);
List<Long> findEntityIds = findEntities.stream().map(boardEntity -> boardEntity.getId()).collect(Collectors.toList());
int totalCount = boardMapper.findTotalSizeForSearch(SearchType.ALL.name(), time);
assertThat(findEntities.size(), equalTo(SIZE));
assertThat(savedEntityIds, hasItems(findEntityIds.toArray(new Long[SIZE])));
assertThat(totalCount, equalTo(insertSize));
}
테스트가 통과하면 톰캣을 키고 검색을 수행해보자
바로 위에서 추가한 것은 제목이나 내용으로 검색(혹은 둘 다)한 경우이며 작성자로 검색한 경우에는
라는 과정을 거치기 때문에 이에 맞는 카운팅 쿼리를 작성해주어야한다. Board 데이터 검색을 위해서 1은 이미 수행하고 있으므로 이를 이용해 카운팅하는 메소드를 추가하였다.
🔎BoardMapper
int findTotalSizeForWriterSearch(List<UserEntity> userEntityList);
🔎board-mapper.xml
<select id="findTotalSizeForWriterSearch" resultType="int" parameterType="userentity">
SELECT COUNT(*)
FROM free_board_mybatis.board
WHERE writerId IN
<foreach collection="list" item="user" index='i' open="(" close=")" separator=",">#{user.id}</foreach>
</select>
🔎BoardService
public PageDto<BoardDto> search(Pageable pageable, String keyword, SearchType type) {
List<BoardEntity> boardEntities = new ArrayList<>();
int totalSize = 0;
if (type.equals(SearchType.WRITER)) {
List<UserEntity> userEntityList = userMapper.findByAccountIdLike(keyword);
if (userEntityList.size() == 0){
return PageDto.of(0, pageable, null);
}
boardEntities = boardMapper.findAllByWriterIn(userEntityList, pageable.getPageNumber() * pageable.getPageSize(), pageable.getPageSize());
totalSize = boardMapper.findTotalSizeForWriterSearch(userEntityList);
} else if (type.equals(SearchType.ALL) || type.equals(SearchType.CONTENTS) || type.equals(SearchType.TITLE)) {
boardEntities = boardMapper.findAll(type.name(), keyword, pageable.getPageNumber() * pageable.getPageSize(), pageable.getPageSize());
totalSize = boardMapper.findTotalSizeForSearch(type.name(), keyword);
}
List<BoardDto> boardDtoList = boardEntities.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
return PageDto.of(totalSize, pageable, boardDtoList);
}
서치 타입에 따라 totalSize를 받아오는 메소드를 각각 수행해야하기 때문에 PageDto로 변환해서 리턴하는 로직으로 변경하였다. 이전 코드와 비교하면 꽤 복잡해진 것처럼 보이나 BoardApiController
는 그만큼 간소화되었다.
🤔WRITER
타입으로 검색하는 경우 내부에 중첩된 if
문이 존재하는데 이는 계정 아이디(accountId
컬럼)가 해당 검색어를 포함하는 유저가 없는 경우에 다음 쿼리를 진행하면 IN
절에 아무런 문자열이 생성되지 않아 쿼리 오류가 발생하기 때문에 추가된 로직이다. 이렇게 foreach
태그가 순회할 데이터가 존재하지 않기 때문에 IN
절 뒤에 있어야할 (data, data, data .. )
가 없어진다.
🔎BoardApiController
@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);
}
return ResponseEntity.ok(boardService.search(PageUtil.convertToZeroBasePageWithSort(pageable), keyword, type));
}
복잡한 로직이 BoardService로 옮겨서 ApiController는 굉장히 간단해졌다.
🙋🏻다시 톰캣을 실행시켜보자!!
검색결과가 한 페이지만 존재하는 경우 | 여러 페이지가 존재하는 경우 |
---|---|
모든 코드는 github에서 확인 할 수 있습니다.