[프로젝트2] 3. Join을 이용하여 한 번에 연관객체 가져오기

rin·2020년 5월 23일
3
post-thumbnail

목표
1. ResultMap과 join을 이용하는 방법을 익힌다.
2. Board를 질의할 때 User가 null로 반환되는 문제를 해결한다.
3. JPA관련 어노테이션으로 이뤄진 Entity 클래스 형태를 변경한다.

join과 ResultMap 이용하기

ref. https://mybatis.org/mybatis-3/ko/sqlmap-xml.html

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>
  • resultMap
    • id : 추후 쿼리를 정의하는 태그에서 resultType 대신 사용될 resultMap argument의 value가 될 것이다.
    • type : 객체로 만들 자바 클래스이다. 위는 설정에서 typeAliace를 정의한 경우이고, com.freeboard02.domain.board.BoardEntity 처럼 경로로 명시하여도 된다.
  • constructor
    • 생성자이다. 위 예제에서는 id만 이용하고 있는데 이 경우에는 Blog(int blog_id) 꼴의 생성자를 호출할 것이다.
    • JPA에서 기본 생성자를 생성하고 setter를 이용하여 값을 채워넣는 것을 선호하는 것과는 반대로 MyBatis는 생성자를 이용한 빈 생성을 선호하기 때문에 이러한 기능을 제공한다.
    • 만약 바로 아래에 result로 정의된 title property를 argument로 포함하는 생성자를 빈을 사용하고 싶다면 (setter로 값을 할당하고 싶지 않다면) <arg column="blog_title" javaType="String" name="title" />constructor 태그 사이에 추가하면된다. 이 경우에는 Blog(int id, String title)꼴의 생성자를 호출할 것이다.
  • result
    • 결과값의 column을 자바 클래스의 어떤 멤버 변수에 할당할 것인지 정의한다.
    • result로 정의된 값들은 setter를 이용해서 값을 할당한다.
    • 이 때 column은 aliace로 변경된 값을 지정해주어야한다.
  • association
    • has one일 때 사용하는 태그
    • resultMap이라는 Argument를 사용할 수 있는데 해당 태그 내부에 정의된 result를 다른 resultMap으로 분리하고 이를 참조하는 경우에 쓰인다.
    • 위 이미지에서는 boardResultMap의 association으로 userResultMap을 포함한다.
  • collection
    • has many일 때 사용하는 태그
    • discriminator : switch .. case .. 문처럼 작동한다.
    • 위 이미지에서는 vehicle_type이라는 column의 값을 case value와 비교한 뒤 같은 값인 경우의 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>

위 예제는 다음 처럼 작동한다.

  1. selectBlog의 쿼리 SELECT * FROM BLOG WHERE ID = #{id}가 수행된다.
  2. 결과물을 blogResult에 맵핑한다.
  3. author_id 컬럼 -author 멤버 변수를 제외한 나머지 컬럼과 멤버 변수가 자동으로 매칭된다.
  4. author를 채우기 위해서 select="selectAuthor"를 수행한다.
  5. selectAuthorSELECT * FROM AUTHOR WHERE ID = #{id}가 수행된다. (이 때 아이디로는 blogResult의 association에 명시된 column인 author_id가 자동으로 사용된다.)
  6. 결과값을 이용해 Author 자바 빈을 생성한 뒤 Blog 자바 빈에 set한다.

자세한 내용은 위에 링크한 도큐먼트에서 알아보도록 한다.

적용하기

resultMap 만들기

BoardMapper가 작동할 때 이를 수행할 것이므로 board-mapper.xmlmapper 태그 내부에 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를 대체하였다.

findById 쿼리 변경하기

  1. resultType을 resultMap으로 대체한다.
  2. Join을 이용한 쿼리로 변경한다.
    <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>

이를 테스트 하기위해서 BoardMapperTestmapperFindById테스트 코드를 다음처럼 변경하고 수행해보자.

    @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을 이용하는 코드로 변경하도록 한다.

board-mapper.xml

<?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로 변경

본격적으로 Repository로 수행되던 작업을 Mapper로 변경할 것이다.

paging을 위한 데이터 가공하기

위 이미지에서도 볼 수 있듯이 JPA를 사용할 때는 Pageable를 argument로 사용하고 리턴 타입을 Page 컬렉션을 사용함으로써 자동으로 페이징과 관련된 데이터들을 얻을 수 있었다. MyBatis로 메소드를 변경하면서 Pageable이 아닌 int값의 PageNumber와 PageSize를 직접 입력받고 반환 컬렉션은 List로 변경했다.

따라서

  1. 뷰에서 페이지를 생성하기 위한 totalPages에 대한 정보가 없기 때문에 이를 위해 전페 데이터의 count를 가져오는 쿼리를 추가할 것이다.
  2. pageNumber와 PageSize를 사용해 전체 데이터를 페이징해 가져오는 쿼리를 추가할 것이다.

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.getpageable을 넘길 때 바로 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로 모두 변경해보도록 하자.

전체 코드 변경

BoardService

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

BoardApiController

@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/*.xmlWEB-INF/classes/mappsers/*.xml로 대치되는 것이다.

정확한 이유는 모르겠으나 이런 자동 복사가 일어나지 않아서 테스트 코드가 실행될 때(배포가 되는 것이 아니므로 src/main/resources/mappers/*.xml에서 찾는다.)와 달리 파일을 찾을 수 없다는 메세지가 뜨는 것이다.

기능들이 잘 동작하는지 확인한다.

버그수정

Test 코드 수정

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로 연결된 구간을 고쳐줘야했다.

board-mapper.xml

아래와 같이 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>

BoardMapperTest

제목+내용(위의 캡처에 있는 것)으로 검색하는 것, 제목으로 검색하는 것, 내용으로 검색하는 것으로 총 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));
    }

테스트가 통과하면 톰캣을 키고 검색을 수행해보자

(추가) 글 작성자로 검색한 경우 페이지가 나오지 않음

바로 위에서 추가한 것은 제목이나 내용으로 검색(혹은 둘 다)한 경우이며 작성자로 검색한 경우에는

  1. UserMapper에서 like 쿼리를 사용해질의
  2. 1에서 얻은 결과 리스트를 사용해 in 쿼리를 사용해 BoardMapper에서 질의

라는 과정을 거치기 때문에 이에 맞는 카운팅 쿼리를 작성해주어야한다. 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에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글