[프로젝트2] 1. myBatis 설정, CRUD 테스트

rin·2020년 5월 12일
2
post-thumbnail

목표
1. myBatis를 이해한다.
2. xml 설정파일을 셋팅한다.
3. 간단한 crud를 테스트한다.

ref. MyBatis document_ko

1. 프로젝트 시작하기

이전 프로젝트 복사

우선 github에서 이전에 했던 프로젝트를 복사해서 freeboard02 프로젝트로 생성할 것이다.
적절한 파일명(이미 freeboard02로 카피해두어서 예시 작성을 위해 위와 같은 이름을 사용하였다.)을 입력하고 repository를 생성해준다.
카피해 올 디렉토리로 가서 주소를 복사해준다.

copy-example

생성한 레파지토리로 이동하면 맨 아래에 import code를 찾을 수 있다. 이를 클릭하고 방금 카피한 Url을 넣어주자.

복사 진행 중복사 완료

다시 해당 디렉토리로 이동하면 완벽하게 복사가 됐음을 볼 수 있다. 이 때의 복사는 말 그대로 "copy"이며 fork 와는 다르다.

web directory root 설정

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까지만 작성해 주도록 하자.

맨 아래 체크박스는 javaresources만 체크해준다.

나머지 설정은 프로젝트 1. 스프링 셋팅하기를 참고

2. MyBatis 설정 파일 작성하기

스키마, 테이블 만들기

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 해주는 설정) 설정을 제거하도록 하자.

build.gradle

    // 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, 두 프레임워크 사이에 접착제 역할을 한다.

.xml

우선 applicationContext.xml을 수정한 다음 Mybatis 설정파일과 mapper을 추가하도록 할 것이다.
이전의 JPA 설정이다. 빨간색 박스로 표시된 EntityManagerFactoryBean에 대응되는 것이 바로 SqlSessionFactoryBean이다. SqlSessionFactory는 데이터베이스와 연결 및 SQL 실행에 대한 전반적인 일을 관장하는 객체로써 SqlSessionFactoryBean이 이를 설정하는 역할을 수행한다.

기본적인 Property로 dataSource, configLocation, mapperLocations를 설정한다.

  • dataSource : 위의 파란 박스 내의 설정을 그대로 사용할 것이다. DB connection에 필요한 설정이 포함된다.
  • configLocation : 말그대로 MyBatis 설정이 포함된다. 주로 테이블과 컬럼에 대한 내용이다.
  • mapperLocations : QueryMapper, 데이터 베이스에 접근할 때 사용할 메소드에 대한 네이티브 쿼리가 포함된다.

ref. https://mybatis.org/mybatis-3/ko/configuration.html

applicationContext.xml

    <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로써의 기능을 부여받고, 프로퍼티에 직접 인터페이스를 등록해 주고 있다.

mybatis-config.xml

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의 별칭을 자동으로 채택한다. 예를 들어 BoardEntityboardentity로 별칭을 가지게 된다.

3. Mapper 작성하기

board-mapper.xml

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를 사용하면 된다.

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

첫 세 줄은 mybatis-config.xml에서 사용한 것과 마찬가지로 공식 도큐먼트에서 가져온 태그이다.
mapper의 namespace에 해당 쿼리를 메소드로 선언할 매퍼 인터페이스를 등록한다.

mapper 태그 내부에 실제 사용될 쿼리가 작성되며, select, insert, delete 등 사용할 네이티브 쿼리에 맞게 선언한다. 해당 태그의 id는 매퍼 인터페이스의 메소드 이름과 동일하다.
resultType은 쿼리결과가 저장될 클래스가 지정된다.

BoardMapper

domain/board 패키지 하위에 생성한다.

@Mapper
public interface BoardMapper {
    List<BoardEntity> findAllBoardEntity();
}

board-mapper.xml에 작성한 select태그의 id와 동일한 메소드를 작성해준다. 실질적으로 자바 코드 내에서는 이를 이용하여 DB에 접근하게 된다.

BoardMapperTest

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

}

간단한 테스트 코드를 작성하여 잘 실행되는지 확인해보자!

4. CRUD 작성 후 테스트 수행하기

간단한 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}는 값이 채워진 듯 하다.

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">
    <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)에 따라 다르게 수행된다. 즉, 해당 태그를 감싼 쿼리가 수행되기 전 혹은 후에 어떤 일을 수행한다.

  • BEFORE : 쿼리 수행전에 PK를 생성하고, 해당 값을 쿼리에 사용한 뒤 그 값을 그대로 parameterType의 keyProperty에 주입해준다.(이 때 keyProperty는 getter, setter 메소드가 존재해야한다.)
<!-- 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>  
  • AFTER : 외부 쿼리 수행후에 selectKey 쿼리가 수행되고, 결과값이 parameterType의 keyProperty에 주입된다.

BoardMapper

@Mapper
public interface BoardMapper {
    List<BoardEntity> findAllBoardEntity();

    Optional<BoardEntity> findById(long id);

    void save(BoardEntity boardEntity);

    void updateById(BoardEntity boardEntity);

    void deleteById(long id);
}

build.gradle

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'

BoardMapperTest

@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에 공개되어 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글