목표
1. myBatis를 이해한다.
2. xml 설정파일을 셋팅한다.
3. 간단한 crud를 테스트한다.
ref. MyBatis document_ko
우선 github에서 이전에 했던 프로젝트를 복사해서 freeboard02
프로젝트로 생성할 것이다.
적절한 파일명(이미 freeboard02
로 카피해두어서 예시 작성을 위해 위와 같은 이름을 사용하였다.)을 입력하고 repository를 생성해준다.
카피해 올 디렉토리로 가서 주소를 복사해준다.
copy-example | |
---|---|
생성한 레파지토리로 이동하면 맨 아래에 import code
를 찾을 수 있다. 이를 클릭하고 방금 카피한 Url을 넣어주자.
복사 진행 중 | 복사 완료 |
---|---|
다시 해당 디렉토리로 이동하면 완벽하게 복사가 됐음을 볼 수 있다. 이 때의 복사는 말 그대로 "copy"이며 fork 와는 다르다.
intelliJ에서 복사한 디렉토리 주소를 이용해 프로젝트를 가져왔으면 web directory root 설정을 해줘야한다.
Project structure
에 들어가 Modules에서 main을 우클릭하면 위와 같은 탭이 나온다. 프로젝트 이름이 freeboard01인건 뭐... 신경쓰지 말자🤔
표시한 부분을 바꿔야하는데 맨 위는 web.xml 경로를 고쳐주면 된다. 자동으로 .idea/modules/...
로 설정되어 있을 텐데, 이를 프로젝트에 이미 만들어져있는 경로에 맞춰서 {디렉토리 경로}/src/main/webapp/WEB-INF/web.xml
로 수정한다.
두 번째 경로가 Web directory root 경로이다. 따라서 {디렉토리 경로}/src/main/webapp
까지만 작성해 주도록 하자.
맨 아래 체크박스는 java
와 resources
만 체크해준다.
나머지 설정은 프로젝트 1. 스프링 셋팅하기를 참고
mysql workbench에서 이번 프로젝트에서 사용할 스키마와 테이블을 생성하였다. 테이블은 이전 프로젝트와 동일할 것이기 때문에 데이터까지 모두 복사하였다.
현재 스키마 상태 | 테이블 복사 쿼리 |
---|---|
아래 이미지와 같이 PK와 Auto_Increment 설정은 복사되지 않았으므로 수동으로 설정(UI를 이용해 체크하거나 alter table 구문을 이용하면 된다.)해준다. 이 때 설정하지 않으면 insert시 selectKey
태그를 이용해 값을 추가해 주는 설정이 필요하다.
ALTER TABLE
ALTER TABLE free_board_mybatis.user
CHANGE COLUMN id id BIGINT(20) NOT NULL AUTO_INCREMENT ,
ADD PRIMARY KEY (id);
ALTER TABLE free_board_mybatis.board
CHANGE COLUMN id id BIGINT(20) NOT NULL AUTO_INCREMENT ,
ADD PRIMARY KEY (id);
이어서 본격적으로 설정을 할 것인데, 잘 연결됐는지 확인하면서 JPA의 쿼리를 모두 MyBatis로 옮길 것이므로 JPA와 관련된 설정도 유지하도록 한다.
❗️단, jpaProperties 설정 중 auto update (테이블 검사해서 컬럼 변경 시 자동으로 drop-create 해주는 설정) 설정을 제거하도록 하자.
// https://mvnrepository.com/artifact/org.mybatis/mybatis
compile group: 'org.mybatis', name: 'mybatis', version: '3.5.4'
// https://mvnrepository.com/artifact/org.mybatis/mybatis-spring
compile group: 'org.mybatis', name: 'mybatis-spring', version: '2.0.4'
// https://mvnrepository.com/artifact/org.springframework/spring-jdbc
compile group: 'org.springframework', name: 'spring-jdbc', version: '5.2.2.RELEASE'
myBatis를 사용하기 위해 위의 세가지 종속을 추가해 주어야한다.
myBatis-Spring은 말 그대로 myBatis와 Spring, 두 프레임워크 사이에 접착제 역할을 한다.
우선 applicationContext.xml
을 수정한 다음 Mybatis 설정파일과 mapper을 추가하도록 할 것이다.
이전의 JPA 설정이다. 빨간색 박스로 표시된 EntityManagerFactoryBean
에 대응되는 것이 바로 SqlSessionFactoryBean
이다. SqlSessionFactory는 데이터베이스와 연결 및 SQL 실행에 대한 전반적인 일을 관장하는 객체로써 SqlSessionFactoryBean
이 이를 설정하는 역할을 수행한다.
기본적인 Property로 dataSource, configLocation, mapperLocations를 설정한다.
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<property name="mapperLocations" value="classpath:/mappers/*.xml"/>
</bean>
<bean id="boardMapper" class="org.mybatis.spring.mapper.MapperFactoryBean">
<property name="mapperInterface" value="com.freeboard02.domain.board.BoardMapper"/>
<property name="sqlSessionFactory" ref="sqlSessionFactory"/>
</bean>
sqlSessionFactory의 프로퍼티로 들어가있는 것들은 해당 경로에 파일을 만들어 줄 예정이다.
classpath는 main/resources
를 가리킨다.
boardMapper는 mapperLocations의 value로 가지고 있는 /mappers/*.xml
에 대응되는 mapper설정과 1:1 관계를 가진 매퍼 인터페이스이다. JPA의 repository에 해당하며 <jpa:repositories base-package="com.freeboard02.domain" />
로 자동 빈 설정을 한 것과 다르게 각각 빈으로 추가시켜 줄 것이다.
boardMapper는 MapperFactoryBean을 이용해 MapperFactory로써의 기능을 부여받고, 프로퍼티에 직접 인터페이스를 등록해 주고 있다.
resources
하위에 생성한다.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties>
<property name="id" value="id"/>
<property name="createdAt" value="createdAt"/>
<property name="updatedAt" value="updatedAt"/>
<property name="boardEntity" value="board"/>
<property name="contents" value="contents"/>
<property name="title" value="title"/>
<property name="writerId" value="writerId"/>
<property name="userEntity" value="user"/>
<property name="accountId" value="accountId"/>
<property name="password" value="password"/>
<property name="role" value="role"/>
</properties>
<typeAliases>
<package name="com.freeboard02.domain"/>
</typeAliases>
</configuration>
최상단의 네 줄은 mybatis 설정 파일에 반드시 포함되어야하는 태그이며 도큐먼트에서 똑같이 사용하고 있는 것을 가져왔다.
configuration/properties
내부에는 실제 데이터베이스와 1:1로 매칭되는 값들인데, name은 myBatis에서 사용할 Aliase이고, value는 데이터 베이스에 생성된 테이블과 컬럼이름을 "동일하게" 사용해야한다.
typeAliases는 엔티티 자바 타입에 대한 짧은 이름으로써 클래스 패스를 사용하지 않고 바로 접근 할 수 있도록 만들어준다.
<typeAliases>
<typeAlias alias="Author" type="domain.blog.Author"/>
<typeAlias alias="Blog" type="domain.blog.Blog"/>
<typeAlias alias="Comment" type="domain.blog.Comment"/>
<typeAlias alias="Post" type="domain.blog.Post"/>
<typeAlias alias="Section" type="domain.blog.Section"/>
<typeAlias alias="Tag" type="domain.blog.Tag"/>
</typeAliases>
위처럼 각각 명시할 수도 있으나 package
를 이용해 경로를 써주면 해당 경로에서 빈을 검색하고 @Alias("별칭")
가 없을 경우, 빈 이름의 lowercase의 별칭을 자동으로 채택한다. 예를 들어 BoardEntity
는 boardentity
로 별칭을 가지게 된다.
resources/mappers
하위에 생성한다.
board 엔티티에 접근하기 위한 매퍼이며 추후 생성할 BoardMapper
와 1:1 대응관계를 가진다.
<?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">
<select id="findAllBoardEntity" resultType="com.freeboard02.domain.board.BoardEntity">
SELECT *
FROM free_board_mybatis.board
</select>
</mapper>
namespace는 typeAliases가 설정되어 있으므로 클래스 이름인 BoardEntity
를 모두 소문자로 바꾼 boardentity
를 사용하면 된다.
첫 세 줄은 mybatis-config.xml
에서 사용한 것과 마찬가지로 공식 도큐먼트에서 가져온 태그이다.
mapper의 namespace에 해당 쿼리를 메소드로 선언할 매퍼 인터페이스를 등록한다.
mapper 태그 내부에 실제 사용될 쿼리가 작성되며, select, insert, delete 등 사용할 네이티브 쿼리에 맞게 선언한다. 해당 태그의 id
는 매퍼 인터페이스의 메소드 이름과 동일하다.
resultType
은 쿼리결과가 저장될 클래스가 지정된다.
domain/board
패키지 하위에 생성한다.
@Mapper
public interface BoardMapper {
List<BoardEntity> findAllBoardEntity();
}
board-mapper.xml
에 작성한 select
태그의 id와 동일한 메소드를 작성해준다. 실질적으로 자바 코드 내에서는 이를 이용하여 DB에 접근하게 된다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
@Rollback(value = false)
public class BoardMapperTest {
@Autowired
private BoardMapper boardMapper;
@Autowired
private BoardRepository boardRepository;
@Test
public void mapperTest() {
List<BoardEntity> boardEntityList = boardMapper.findAllBoardEntity();
List<BoardEntity> boardEntityList2 = boardRepository.findAll();
assertThat(boardEntityList.size(), equalTo(boardEntityList2.size()));
}
}
간단한 테스트 코드를 작성하여 잘 실행되는지 확인해보자!
간단한 CRUD를 작성한 뒤 테스트 코드로 확인해 볼 것이다.
❗️NOTE
mapper.xml 에서 네이티브 쿼리에 들어가는 매핑 기호${}
과#{}
는 다르게 작동하니 주의하도록 하자.
${}
를 사용하여 데이터가 주입되지 않아 삽질했다.. 😑
#{}
는 PreparedStatement이며,${}
는 Statement이다.
두 가지 방식의 가장 큰 차이점은 "캐시"의 사용 여부라고 할 수 있다. 쿼리 수행 시 쿼리 문장 분석 - 컴파일 - 실행이라는 단계를 거치는데, PreparedStatement는 쿼리에?
를 사용하여 캐싱해 두었다가, 동일한 작업이 들어오면 해당 위치에 데이터를 바인딩한다. 반면에 Statement는 쿼리에 데이터를 삽입한 채로 수행시키기 때문에 캐시가 사용되지 않고 매번 세 단계를 거치게 된다.INSERT INTO free_board_mybatis.board (createdAt, updatedAt, contents, title, writerId) VALUES (NOW(), NOW(), ${contents}, ${title}, ${writer.id})
이렇게 작성한 구문에서
${contents}
,${title}
부분이 null 로 넘어가서 문제가 되었는데,${}
는 컬럼명이 동적으로 바뀌거나 테이블(클래스)인 경우에 사용한다고 한다.🤔 그래서${writer.id}
는 값이 채워진 듯 하다.
<?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">
<select id="findAllBoardEntity" resultType="boardentity">
SELECT *
FROM free_board_mybatis.board
</select>
<select id="findById" resultType="boardentity">
SELECT *
FROM free_board_mybatis.board
WHERE 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>
위와 같이 간단하게 전체 검색, PK로 검색, 저장, PK로 업데이트, PK로 삭제 다섯가지 CRUD를 구현해보았다.
insert 태그를 보면 selectKey
가 포함되어 있는데 order
파라미터의 값(BEFORE/AFTER)에 따라 다르게 수행된다. 즉, 해당 태그를 감싼 쿼리가 수행되기 전 혹은 후에 어떤 일을 수행한다.
<!-- Example -->
<insert id="save" parameterType="boardentity">
<selectKey resultType="Long" keyProperty="id" order="BEFORE">
SELECT MAX(boardID)+1 FROM board
</selectKey>
INSERT INTO free_board_mybatis.board (id, createdAt, updatedAt, contents, title, writerId)
VALUES (#{id}, NOW(), NOW(), #{contents}, #{title}, #{writer.id})
</insert>
@Mapper
public interface BoardMapper {
List<BoardEntity> findAllBoardEntity();
Optional<BoardEntity> findById(long id);
void save(BoardEntity boardEntity);
void updateById(BoardEntity boardEntity);
void deleteById(long id);
}
BoardMapper
의 메소드 Optional<BoardEntity> findById(long id);
에서 Optional이 사용됐기 때문에 이를 테스트 하기위한 종속을 추가하였다.
// https://mvnrepository.com/artifact/com.github.npathai/hamcrest-optional
testCompile group: 'com.github.npathai', name: 'hamcrest-optional', version: '2.0.0'
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
@Transactional
@Rollback(value = false)
public class BoardMapperTest {
@Autowired
private BoardMapper boardMapper;
@Autowired
private BoardRepository boardRepository;
@Autowired
private UserRepository userRepository;
private UserEntity user;
private BoardEntity targetBoard;
@BeforeEach
public void init() {
user = userRepository.findAll().get(3);
targetBoard = BoardEntity.builder()
.title(randomString())
.contents(randomString())
.writer(user).build();
}
@Test
public void mapperFind() {
List<BoardEntity> boardEntityList = boardMapper.findAllBoardEntity();
List<BoardEntity> boardEntityList2 = boardRepository.findAll();
assertThat(boardEntityList.size(), equalTo(boardEntityList2.size()));
}
@Test
public void mapperInsert() {
assertThat(targetBoard.getId(), equalTo(0L));
boardMapper.save(targetBoard);
assertThat(targetBoard.getId(), not(equalTo(0L)));
}
@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()));
}
@Test
public void mapperDelete() {
boardMapper.save(targetBoard);
long targetId = targetBoard.getId();
assertThat(targetId, not(equalTo(0L)));
Optional<BoardEntity> saved = boardMapper.findById(targetId);
assertThat(saved, OptionalMatchers.isPresent());
boardMapper.deleteById(targetId);
Optional<BoardEntity> deleted = boardMapper.findById(targetId);
assertThat(deleted, OptionalMatchers.isEmpty());
}
@Test
public void mapperUpdate() {
boardMapper.save(targetBoard);
BoardForm form = BoardForm.builder().contents(randomString()).title(randomString()).build();
BoardEntity entity = form.convertBoardEntity(targetBoard.getWriter());
entity.setId(targetBoard.getId());
boardMapper.updateById(entity);
BoardEntity updatedEntity = boardMapper.findById(targetBoard.getId()).get();
assertThat(updatedEntity.getContents(), equalTo(form.getContents()));
assertThat(updatedEntity.getTitle(), equalTo(form.getTitle()));
}
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] 1. 스프링 셋팅하기에서 확인 가능합니다.
이 글에서 사용된 코드는 github에 공개되어 있습니다.