목표
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에서 확인할 수 있습니다.