[프로젝트2] 2. JPA repository 작업을 QueryMapper로 변경하기

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

목표
1. user 관련 DB 작업을 QueryMapper를 이용하도록 변경한다.
2. board 관련 DB 작업을 QueryMapper를 이용하도록 변경한다.

User 관련 작업 변환하기

만들어야하는 쿼리

유저 서비스와 레포지토리를 확인하자. 만들어야하는 쿼리는 총 3개이다. (findByAccountId는 중복)

  1. save
  2. findByAccountId
  3. findAllByAccountIdLike

우선 이 세가지 메소드를 쿼리로 변경하여 작성해보았다.

메소드쿼리
saveINSERT INTO user(createdAt, updatedAt, accountId, password, role) VALUES (?,?,?,?,?)
findByAccountIdSELECT * FROM user WHERE accountId = ?
findAllByAccountIdLikeSELECT * FROM user WHERE accountId like '?'

user-mapper 작성하기

domain/user/UserMapper

domain/user 패키지 하위에 UserMapper 를 만들어 준 뒤 위에서 정의한 메소드와 동일한 이름의 메소드를 만든다.

package com.freeboard02.domain.user;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserMapper {
    void save(UserEntity userEntity);

    UserEntity findByAccountId(String accountId);

    List<UserEntity> findByAccountIdLike(String target);
}

applicationContext.xml에 빈으로 등록해준다.

user-mapper.xml

resource/mappers 하위에 user-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.user.UserMapper">
    <insert id="save" parameterType="userentity" keyProperty="id" keyColumn="id">
        INSERT INTO free_board_mybatis.user (createdAt, updatedAt, accountId, password, role)
        VALUES (NOW(), NOW(), #{accountId}, #{password}, #{role})
        <selectKey resultType="Long" order="AFTER" keyProperty="id">
            SELECT LAST_INSERT_ID() as id
        </selectKey>
    </insert>

    <select id="findByAccountId" resultType="userentity">
        SELECT *
        FROM free_board_mybatis.user
        WHERE accountId = #{accountId}
    </select>

    <select id="findByAccountIdLike" resultType="userentity">
        SELECT *
        FROM free_board_mybatis.user
        WHERE accountId like CONCAT('%', #{target}, '%')
    </select>
</mapper>

❗️NOTE
쿼리의 like 절을 사용할 때는 사용자가 입력한 값(target)의 앞뒤로 '%'를 붙여야하는데 데이터베이스마다 문자열을 연결하는 방식이 다름에 유의한다.

database문자열 연결하기
MySqlLIKE CONCAT('%', #{keyword}, '%')
OracleLIKE '%'
MsSqlLIKE '%' + #{keyword} + '%'

TypeHandler

MyBatis는 TypeHandler를 이용하여 데이터 베이스 타입과 Java 타입을 매칭한다. EnumTypeHandler 또한 이미 만들어져있다.
위 코드를 보면 Enum 타입의 Generic E parameter의 name을 가져오고 있는데, 만약 Enum의 "이름"이 아닌 다른 값을 저장하고 싶다면? 커스텀 타입 핸들러를 만들어서 등록해 줄 수 있다.

이를 연습하기 위해서 UserRoleTypeHandler를 만들어보도록 하겠다.

UserRoleTypeHandler

util 패키지 하위에 typeHandler라는 패키지를 생성하고, TypeHandler 인터페이스를 구현하는 UserRoleTypeHandler 클래스를 생성한다.

public class UserRoleTypeHandler<E extends Enum<E>> implements TypeHandler<UserRole> {

    private Class<E> type;

    public UserRoleTypeHandler(Class<E> type) {
        this.type = type;
    }

    @Override
    public void setParameter(PreparedStatement ps, int i, UserRole parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.name());
    }

    @Override
    public UserRole getResult(ResultSet rs, String columnName) throws SQLException {
        String role = rs.getString(columnName);
        return getUserRole(role);
    }

    @Override
    public UserRole getResult(ResultSet rs, int columnIndex) throws SQLException {
        String role = rs.getString(columnIndex);
        return getUserRole(role);
    }

    @Override
    public UserRole getResult(CallableStatement cs, int columnIndex) throws SQLException {
        String role = cs.getString(columnIndex);
        return getUserRole(role);
    }

    private UserRole getUserRole(String role) {
        UserRole[] userRoles = (UserRole[]) type.getEnumConstants();
        for (UserRole userRole : userRoles) {
            if (userRole.name().equals(role))
                return userRole;
        }
        throw new IllegalArgumentException("No enum constant " + type.getCanonicalName() + "." + role);
    }
}

TypeHandler의 제네릭 타입을 UserRole로 명시해주었다. 이는 오버라이딩된 각 메소드의 파라미터나 리턴타입으로 치환된다.

setParameter 메소드를 살펴보면 PreparedStatement에 인덱스를 이용하여 ?에 필요한 값을 채워주는 것을 볼 수 있다. 만약 Enum parameter의 이름이 아닌 다른 값을 사용하고 싶다면 parameter.getCode()등을 사용하여 원하는 값으로 데이터베이스에 저장되도록 하면된다.

select 질의 후 Enum으로 변경하는 경우는 그 아래의 세 가지 메소드가 담당한다. 리턴 타입은 위에서 말했듯이 TypeHandler에 정의한 제네릭 타입과 일치한다. getUserRole에서는 데이터 베이스에서 받아온 컬럼값(문자열)을 이용하여 적절한 Enum을 찾아내 반환하는데, for문 내의 if절을 적절히 바꾸면 원하는 Enum을 반환할 수 있을 것이다.

mybatis-config.xml

설정파일에 typeHandler 태그를 이용하여 등록하여준다.
handler에는 커스텀 핸들러의 경로를, javaType에는 핸들러를 사용하고자 하는 Enum의 경로를 작성해주면 된다.

userMapperTest

세 가지 쿼리에 대한 테스트를 진행한다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
public class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void saveTest() {
        String accountId = randomString();
        UserEntity userEntity = UserEntity.builder()
                .accountId(accountId)
                .password(randomString())
                .build();
        assertThat(userEntity.getId(), equalTo(0L));
        userMapper.save(userEntity);
        assertThat(userEntity.getId(), not(equalTo(0L)));
    }

    @Test
    public void findOneTest() {
        String accountId = randomString();
        UserEntity userEntity = UserEntity.builder()
                .accountId(accountId)
                .password(randomString())
                .build();
        userMapper.save(userEntity);

        UserEntity findEntity = userMapper.findByAccountId(accountId);
        assertThat(userEntity.getId(), equalTo(findEntity.getId()));
    }
    
    @Test
    @DisplayName("UserRole Enum이 잘 입력되는지 확인한다.")
    public void enumTypeHandlerTest(){
        String accountId = randomString();
        UserEntity userEntity = UserEntity.builder()
                .accountId(accountId)
                .password(randomString())
                .role(UserRole.NORMAL)
                .build();
        userMapper.save(userEntity);

        UserEntity findEntity = userMapper.findByAccountId(accountId);
        assertThat(userEntity.getId(), equalTo(findEntity.getId()));
        assertThat(findEntity.getRole(), equalTo(UserRole.NORMAL));
    }

    @Test
    public void findAllLikeTest() {
        String time = LocalDateTime.now().toString();
        List<UserEntity> userEntities = new ArrayList<>();
        for (int i = 0; i < 10; ++i) {
            UserEntity userEntity = UserEntity.builder()
                    .accountId(randomString() + time)
                    .password(randomString())
                    .build();
            userMapper.save(userEntity);
            userEntities.add(userEntity);
        }

        List<UserEntity> findEntities = userMapper.findByAccountIdLike(time);

        List<Long> saveEntityIds = userEntities.stream().map(userEntity -> userEntity.getId()).collect(Collectors.toList());
        List<Long> findEntityIds = findEntities.stream().map(userEntity -> userEntity.getId()).collect(Collectors.toList());

        assertEquals(saveEntityIds, findEntityIds);
    }


    private String randomString() {
        String id = "";
        for (int i = 0; i < 20; i++) {
            double dValue = Math.random();
            if (i % 2 == 0) {
                id += (char) ((dValue * 26) + 65);   // 대문자
                continue;
            }
            id += (char) ((dValue * 26) + 97); // 소문자
        }
        return id;
    }

}

서비스에 적용하기

  1. UserRepository가 담당하고 있던 부분을 모두 UserMapper로 변경해준다.
  2. Api test 코드에서 findAll()을 사용하고 있었으므로 이또한 user-mapper에 추가해준다.

user-mapper.xml

    <select id="findAll" resultType="userentity">
        SELECT *
        FROM free_board_mybatis.user
    </select>

UserMapper.java

List<UserEntity> findAll();

UserApiControllerTest

userRepository로 되어있던 부분들을 userMapper로 변경하고 andExpectassertEquals를 이용해 적절한 Exception이 발생했는지 확인하도록 한다.

result에서는 위와 같은 정보가 나열돼있으므로 적절히 활용하면 된다.

전체 테스트 코드는 아래와 같다.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml", "file:src/main/webapp/WEB-INF/dispatcher-servlet.xml"})
@Transactional
@WebAppConfiguration
public class UserApiControllerTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Autowired
    private UserMapper userMapper;

    private MockMvc mvc;

    private ObjectMapper objectMapper;

    @BeforeEach
    public void initMvc() {
        mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
        objectMapper = new ObjectMapper();
    }

    private String randomId(){
        String id = "";
        for(int i = 0; i < 10; i++) {
            double dValue = Math.random();
            if(i%2 == 0) {
                id += (char) ((dValue * 26) + 65);   // 대문자
                continue;
            }
            id += (char)((dValue * 26) + 97); // 소문자
        }
        return id;
    }

    @Test
    @DisplayName("동일한 아이디를 가진 유저가 없으면 가입에 성공한다.")
    public void joinTest1() throws Exception {
        UserForm userForm = UserForm.builder().accountId(randomId()).password("password").build();
        mvc.perform(post("/api/users")
                .content(objectMapper.writeValueAsString(userForm))
                .contentType(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("동일한 아이디를 가진 유저가 있으면 가입에 실패한다.")
    public void joinTest2() throws Exception {
        UserEntity userEntity = userMapper.findAll().get(0);
        UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password("password").build();
        mvc.perform(post("/api/users")
                .content(objectMapper.writeValueAsString(userForm))
                .contentType(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(result -> assertEquals(result.getResolvedException().getClass().getCanonicalName(), FreeBoardException.class.getCanonicalName()))
                .andExpect(result -> assertEquals(result.getResolvedException().getMessage(), UserExceptionType.DUPLICATED_USER.getErrorMessage()))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("비밀번호를 올바르게 입력하면 로그인되고, 세션에 저장된다.")
    public void loginTest1() throws Exception {
        UserEntity userEntity = userMapper.findAll().get(0);
        UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password(userEntity.getPassword()).build();

        mvc.perform(post("/api/users?type=LOGIN")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(userForm)))
                .andExpect(request().sessionAttribute("USER", notNullValue()))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("비밀번호를 올바르게 입력하지 않으면 로그인 거부된다.")
    public void loginTest2() throws Exception {
        UserEntity userEntity = userMapper.findAll().get(0);
        UserForm userForm = UserForm.builder().accountId(userEntity.getAccountId()).password(userEntity.getPassword()+"wrongPass").build();

        mvc.perform(post("/api/users?type=LOGIN")
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(userForm)))
                .andExpect(request().sessionAttribute("USER", nullValue()))
                .andExpect(result -> assertEquals(result.getResolvedException().getClass().getCanonicalName(), FreeBoardException.class.getCanonicalName()))
                .andExpect(result -> assertEquals(result.getResolvedException().getMessage(), UserExceptionType.WRONG_PASSWORD.getErrorMessage()))
                .andExpect(status().isOk());
    }

}

Board 관련 작업 변환하기

Board-mapper는 이전 글에서 MyBatis 테스트를 위해 셋팅을 해두었으니 바로 필요한 쿼리를 추가하도록 하자.

메소드쿼리
List<BoardEntity> findAllByWriterId(long writerId)SELECT * FROM board WHERE writerId = ?
Page<BoardEntity> findAllByWriterIn(List<UserEntity> userEntityList, Pageable pageable)SELECT * FROM board WHERE userId in ? limit ?, ?
Page<BoardEntity> findAll(Specification spec, Pageable pageable)SELECT * FROM board where {spec}

Specification을 사용하는 findAll은 Specification.where(BoardSpecs.hasContents(keyword, type)).or(BoardSpecs.hasTitle(keyword, type))를 이용해 검색에만 사용되고있다.

즉, 검색 타입에 따라서 다른 where절을 가지게 되는데 이를 myBatis에서는 xml 파일에서 쿼리를 작성할 때 분기문을 이용하여 동적쿼리로 활용할 수 있다.

ref. https://sinna94.tistory.com/17

specification이 포함된 findAll

if문을 사용하여 쿼리를 작성해 볼 것이다.
BoardMapper에 메소드를 추가한다.

//BoardMapper
List<BoardEntity> findAll(@Param("searchType") String searchType, @Param("target") String target, @Param("start") int start, @Param("pageSize") int pageSize);

//아래는 위 메소드의 전신인 BoardRepository의 메소드이다.
Page<BoardEntity> findAll(Specification spec, Pageable pageable);

BoardRepository의 메소드에 비하면 꽤 복잡해졌다.

  1. JPA에서 제공하는 인터페이스인 Specification를 사용할 수 없으므로 직접 mapper.xml에서 판단할 수 있도록 searchType을 문자열로 바꿔서 전달했다. (if태그의 비교문은 "값" 자체가 들어오므로 Enum을 전달할 수 없다.)
  2. 1과 같은 연유로 target 또한 파라미터로 받게된다.
  3. Paging을 위해 Pagable을 사용할 수가 없다. Pagable 또한 인터페이스이므로 size나 page를 가져오기 위해선 getter를 필요로 한다. 당연히 이는 xml 파일 내에서 사용할 수 없다. Page를 전담하는 객체를 직접 만들어 전달하거나 위와 같이 필요한 값을 각각 받는 수 밖에 없다.
  4. xml의 query에서는 하나의 파라미터만 받도록 되어있고 여러개의 파라미터를 전달하기 위해선 @Param 어노테이션을 사용하여야 한다. argument로 입력된 문자열로 접근할 수 있다.

BoardMapper.xml

    <select id="findAll" resultType="boardentity">
        SELECT *
        FROM free_board_mybatis.board
        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 createdAt DESC
        LIMIT #{start}, #{pageSize}
    </select>

이전에 만들어두었던 BoardMapperTest에서 테스트해보자. 테스트 코드는 다음과 같다.

    @Test
    public void mapperPaging() {
        // 현재 시간은 유일하다.
        String time = LocalDateTime.now().toString();

        // assertThat에서 비교를 위해 사용할 저장된 id 리스트이다.
        List<Long> savedEntityIds = new ArrayList<>();
        
        // 총 20개의 새로운 데이터를 save 할 것이다.
        for (int i = 0; i < 20; ++i) {
            // 현재시간을 contents로 넣는다.
            BoardEntity boardEntity = BoardEntity.builder().writer(user).contents(time).title("title").build();
            boardMapper.save(boardEntity);
            savedEntityIds.add(boardEntity.getId());
            // 저장 후 할당된 id를 savedEntityIds에 추가한다.
        }

        // PAGE = 0, SIZE = 10
        List<BoardEntity> findEntities = boardMapper.findAll(SearchType.CONTENTS.name(), time, PAGE, SIZE);
        // assertThat에서 비교를 위해 위의 findAll에서 얻어온 엔티티의 id 리스트를 생성한다.
        List<Long> findEntityIds = findEntities.stream().map(boardEntity -> boardEntity.getId()).collect(Collectors.toList());
        
        // 얻어온 엔티티 목록이 SIZE와 일치하는지 확인한다. (limit = paging이 제대로 작동했는지 확인한다.)
        assertThat(findEntities.size(), equalTo(SIZE));
        // 얻어온 엔티티 목록이 방금 save한 데이터 목록의 일부가 맞는지 (20개를 저장하고 10개를 가져왔으므로.) 확인한다.
        assertThat(savedEntityIds, hasItems(findEntityIds.toArray(new Long[SIZE])));
    }

다른 두 개의 메소드도 추가하기

findAllByWriterIn

🔎BoardMapper

List<BoardEntity> findAllByWriterIn(@Param("userEntityList") List<UserEntity> userEntityList, @Param("start") int start, @Param("pageSize") int pageSize);

🔎board-mapper.xml

     <select id="findAllByWriterIn" resultType="boardentity">
        SELECT *
        FROM free_board_mybatis.board
        WHERE writerId IN <foreach collection="userEntityList" item="user" index='i' open="(" close=")" separator=",">#{user.id}</foreach>
        ORDER BY createdAt DESC
        LIMIT #{start}, #{pageSize}
    </select>

foreach를 이용하여 userEntityList를 순환하며 id를 가져온다.

  • collection : 순환할 리스트. BoardMapper에서 @Param("userEntityList")로 정의했으므로 userEntityList 라는 이름으로 가져온다.
  • item : collection의 각각 요소를 지칭하는 이름
  • index : 순환문의 인덱스
  • open : streamString의 처음에 올 단어
  • close : streamString의 마지막에 올 단어
  • separator : foreach 내 각 요소 사이에 올 구분자

위와 같이 설정하였으므로 ( userId1 , userId2 )의 꼴로 스트림이 생성된다.

🔎BoardMapperTest

    @Test
    public void mapperFindAllByWriterIn() {
        List<UserEntity> userEntities = userMapper.findAll();
        List<UserEntity> writers = userEntities.subList(userEntities.size() - 4, userEntities.size() - 1);

        List<Long> savedEntityIds = new ArrayList<>();
        for (int i = 0; i < 20; ++i) {
            // 글 작성자를 번갈아가며 사용하여 새로운 글을 저장한다.
            BoardEntity boardEntity = BoardEntity.builder().writer(writers.get(i % writers.size())).contents("contents").title("title").build();
            boardMapper.save(boardEntity);
            savedEntityIds.add(boardEntity.getId());
            // 저장 후 할당된 id를 savedEntityIds에 추가한다.
        }

        List<BoardEntity> findEntities = boardMapper.findAllByWriterIn(writers, 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])));
    }

총 세 명의 작성자가 사용되었고 아래와 같이 writerId IN (?, ?, ?)구문이 질의되는 것을 확인 할 수 있다.

❗️NOTE
받아온 데이터는 writer 객체를 가지지 못한다. 이는 boardEntity 클래스가 아직 JPA에 적합한 형태로 되어있기 때문인데, queryMapper에서는 자동으로 lazyloding을 해주지 않으므로 연관 관계가 있는 객체를 직접 질의해 줘야한다.
즉, select 쿼리를 한 번 더 요청하거나, join을 이용하여 필요한 데이터를 가져오는 것이다. 이 작업은 잠시 뒤로 미루도록 하겠다.

findAllByWriterId

🔎BoardMapper

List<BoardEntity> findAllByWriterId(long writerId);

🔎board-mapper.xml

    <select id="findAllByWriterId" resultType="boardentity">
        SELECT *
        FROM free_board_mybatis.board
        WHERE writerId = #{writerId}
    </select>

🔎BoardMapperTest

    @Test
    public void mapperFindAllByWriterId() {
        UserEntity userEntity = UserEntity.builder()
                .accountId(randomString())
                .password(randomString())
                .role(UserRole.NORMAL)
                .build();
        // 새로운 유저를 추가한다.
        userMapper.save(userEntity);

        List<Long> savedEntityIds = new ArrayList<>();
        for (int i = 0; i < SIZE; ++i) {
            // 새로 추가한 유저의 이름으로 새로운 글을 작성한다.
            BoardEntity boardEntity = BoardEntity.builder().writer(userEntity).contents("contents").title("title").build();
            boardMapper.save(boardEntity);
            savedEntityIds.add(boardEntity.getId());
            // 저장 후 할당된 id를 savedEntityIds에 추가한다.
        }

        List<BoardEntity> findEntities = boardMapper.findAllByWriterId(userEntity.getId());
        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])));
    }

다음 게시글에선 위에 잠깐 언급한 writer가 null로 채워지는 문제를 해결하도록 하겠다.

ref. 모든 코드는 github에서 확인할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글