
목표
1. user 관련 DB 작업을 QueryMapper를 이용하도록 변경한다.
2. board 관련 DB 작업을 QueryMapper를 이용하도록 변경한다.
유저 서비스와 레포지토리를 확인하자. 만들어야하는 쿼리는 총 3개이다. (findByAccountId는 중복)
우선 이 세가지 메소드를 쿼리로 변경하여 작성해보았다.
| 메소드 | 쿼리 |
|---|---|
| save | INSERT INTO user(createdAt, updatedAt, accountId, password, role) VALUES (?,?,?,?,?) |
| findByAccountId | SELECT * FROM user WHERE accountId = ? |
| findAllByAccountIdLike | SELECT * FROM user WHERE accountId like '?' |
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에 빈으로 등록해준다.

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 문자열 연결하기 MySql LIKE CONCAT('%', #{keyword}, '%') Oracle LIKE '%' MsSql LIKE '%' + #{keyword} + '%'
MyBatis는 TypeHandler를 이용하여 데이터 베이스 타입과 Java 타입을 매칭한다. EnumTypeHandler 또한 이미 만들어져있다.
위 코드를 보면 Enum 타입의 Generic E parameter의 name을 가져오고 있는데, 만약 Enum의 "이름"이 아닌 다른 값을 저장하고 싶다면? 커스텀 타입 핸들러를 만들어서 등록해 줄 수 있다.
이를 연습하기 위해서 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을 반환할 수 있을 것이다.
설정파일에 typeHandler 태그를 이용하여 등록하여준다.
handler에는 커스텀 핸들러의 경로를, javaType에는 핸들러를 사용하고자 하는 Enum의 경로를 작성해주면 된다.
세 가지 쿼리에 대한 테스트를 진행한다.
@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;
}
}


user-mapper.xml
<select id="findAll" resultType="userentity">
SELECT *
FROM free_board_mybatis.user
</select>
UserMapper.java
List<UserEntity> findAll();
userRepository로 되어있던 부분들을 userMapper로 변경하고 andExpect와 assertEquals를 이용해 적절한 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-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 파일에서 쿼리를 작성할 때 분기문을 이용하여 동적쿼리로 활용할 수 있다.
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의 메소드에 비하면 꽤 복잡해졌다.
문자열로 바꿔서 전달했다. (if태그의 비교문은 "값" 자체가 들어오므로 Enum을 전달할 수 없다.)getter를 필요로 한다. 당연히 이는 xml 파일 내에서 사용할 수 없다. Page를 전담하는 객체를 직접 만들어 전달하거나 위와 같이 필요한 값을 각각 받는 수 밖에 없다.@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])));
}
🔎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를 가져온다.
@Param("userEntityList")로 정의했으므로 userEntityList 라는 이름으로 가져온다.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을 이용하여 필요한 데이터를 가져오는 것이다. 이 작업은 잠시 뒤로 미루도록 하겠다.
🔎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에서 확인할 수 있습니다.