Spring, MyBatis, Domain, DAO, Mapper XML 작성하기

목표

  • 데이터베이스 테이블과 맵핑되는 Domain, DAO, Mapper XML 파일을 작성하여 CRUD를 수행할 수 있다.

사전지식

Domain 클래스 작성

@NoArgsConstructor
@Setter
@ToString
public class User extends Paginator {

    @Getter
    private Long id;

    @Getter
    private String name;

    @Getter
    private Integer age;

    @Getter
    private LocalDateTime createdAt;

    @Getter
    private LocalDateTime updatedAt;

    @Getter
    private LocalDateTime deletedAt;

    @Getter
    private boolean isDeleted;
}

Mapper XML 작성

  • MyBatis가 다른 데이터베이스 관련 라이브러리보다 우위를 점하는 점은 바로 Dynamic SQL를 다룰 수 있는 자유도이다. OOP 세계인 Java 생태계에서 SQL을 자유롭게 다루기란 힘들다. MyBatisDAO 클래스는 그대로 Java의 손에 맡기고 Dynamic SQL을 생성하고 도메인 클래스에 맵핑하는 역할은 Mapper라 불리우는 XML 파일에 위임해버렸다. 이 Mapper 파일에서 로우 레벨의 쿼리 작성이 발생한다.
  • DAO 클래스에서 XML으로 로우 레벨을 위임하는 것은 장점만 가지는 것은 아니다. 많은 부분이 XML에 정의되어 있다 보니 개발자의 오타가 발생할 경우 런타임 단계에서야 발견된다. 컴파일 단계에서는 발견할 수 없다.
  • 아래는 name, age와 기타 공통 컬럼을 가진 users란 테이블의 조회 쿼리를 Mapper 파일로 작성한 예이다. Mapper 파일의 위치는 \src\main\resources\mapper\UserDAO.xml이다. 파일의 이름은 1:1로 대응되는 DAO 클래스와 일치시킨다. 이런 규칙을 지켜야 유지보수가 편리하다.
<?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.jsonobject.example.dao.UserDAO">

    <resultMap type="com.jsonobject.example.domain.User" id="userMap">
        <result property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="age" column="age"/>
        <result property="createdAt" column="created_at"/>
        <result property="updatedAt" column="updated_at"/>
        <result property="deletedAt" column="deleted_at"/>
        <result property="isDeleted" column="is_deleted"/>
    </resultMap>

    <select id="selectUserList" parameterType="com.jsonobject.example.domain.User" resultMap="userMap">
        SELECT id, name, age, created_at, updated_at, deleted_at, is_deleted
        <if test="limit != null and offset != null">
            , COUNT(0) AS total_count
        </if>
        FROM users
        <where>
            <if test="id != null">
                AND id = #{id}
            </if>
            <if test="name != null">
                AND name = #{name}
            </if>
        </where>
        ORDER BY created_at DESC
        <if test="limit != null and offset != null">
            LIMIT #{limit}
            OFFSET #{offset}
        </if>
    </select>

</mapper>
  • users라는 테이블의 각 로우를 user라는 리소스 단위로 생각할 수 있다. 나는 Mapper를 설계할 때 하나의 리소스 만을 조회하는 selectOne 로직은 작성하지 않는다. 리소스의 목록을 조회하는 selectList 메써드 만을 정의한다. 개별 리소스는 해당 메써드에 id 파라메터만 전달하면 획득할 수 있다. 이런 규칙으로 Mapper를 작성하면 중복되는 구문을 최소화할 수 있다.

DAO 클래스 작성

@Repository
@Slf4j
public class ResourceDAO {

    @Autowired
    @Qualifier("sqlSessionTemplate")
    private SqlSession sqlSession;

    public List<User> getUserList(User user) {

        return sqlSession.selectList("com.jsonobject.example.dao.UserDAO.selectUserList", user);
    }

    public User getUser(Long id) throws Exception {

        User params = new User();
        params.setId(id);
        List<User> userList = getUserList(params);
        if (userList.size() == 0) {
            return null;
        }

        return userList.get(0);
    }
}

SELECT: OneToMany

<resultMap type="com.jsonobject.example.domain.User" id="userMap">
    ...
    <collection column="id" property="UserPetList" select="selectUserPetList"/>
</resultMap>
  • @OneToMany는 1:N 관계를 나타낼 때 쓰이는 JPA의 어노테이션이다. 위 예제의 경우 한 명의 User가 여러 개의 Pet(애완동물)을 가질 경우 OneToMany에 해당한다.

  • MyBatis에서는 이러한 관계를 collection 엘러먼트에 관련 내용을 작성하면 된다. 주의할 점은 collection 엘러먼트를 모든 result 엘러먼트를 작성한 후 기입해야 한다. 순서를 지키지 않으면 구문 오류가 발생한다.

  • 해당 쿼리문 실행시 MyBatis는 해당 userid 컬럼을 조회키로 selectUserPetList 메써드를 실행(user_pets 테이블에서 N개 로우를 조회)하여 userMap에 맵핑해준다. 개발자 입장에서는 상당히 편리하다.

  • 성능 관점에서 단일 로우 조회라면 큰 문제가 없다. 하지만 복수 개의 로우를 조회한다면 매 로우보다 collection을 조회하는 쿼리가 추가로 실행되는 N+1 문제가 발생한다. 이 문제를 해소하기 위해 각 데이터가 실제 참조될 때에만 로드하는 Lazy Loading 기법을 사용한다. fetchType="lazy" 애트리뷰트 설정을 추가하면 해당 collection에는 Lazy Loading이 적용된다. 해당 엔티티에서 getter 메써드가 호출될 때 비로소 실제 쿼리가 실행되는 것이다. (기술적으로는 해당 메써드를 감싸는 프록시 기법이 사용된다.)

INSERT

<insert id="insertUser" parameterType="com.jsonobject.example.domain.User">
    INSERT INTO users
    <set>
        name = #{name},
        age = #{age},
    </set>
    <selectKey keyProperty="id" resultType="long" order="AFTER">
        SELECT LAST_INSERT_ID()
    </selectKey>
</insert>
  • MySQL, MariaDBLAST_INSERT_ID() 함수로 현재 연결에서 가장 마지막의 삽입된 PK를 획득할 수 있다. MyBatis에서는 selectKey 엘러먼트에 관련 내용을 작성하면 된다.
  • 위 예제의 경우 INSERT 쿼리문 실행 후 LAST_INSERT_ID() 반환 값을 User 도메인 클래스의 id 프라퍼티에 저장한다.
public long createUser(User user) {

    sqlSession.insert("com.jsonobject.example.dao.UserDAO.insertUser", user);

    return user.getId();
}
  • 앞서 Mapper XML에서 작성한 내용은 Java DAO에서 위와 같이 맵핑하여 작성한다. 명령이 정상적으로 실행되면 새롭게 생성된 users 로우의 id를 반환한다.

DELETE

<delete id="deleteUser" parameterType="com.named.api.domain.User">
    <if test="id != null">
        DELETE FROM users
        WHERE id = #{id}
    </if>
</delete>
  • DELETE 쿼리문은 가장 간단한다. 위 예제의 경우 안전 장치로 id 프라퍼티에 값이 없을 경우 쿼리를 실행하지 않게 조치했다.
public int deleteUserById(String id) {

    User params = new User();
    params.setId(id);

    return sqlSession.delete("com.jsonobject.example.dao.UserDAO.deleteUser", params);
}
  • 명령이 정상적으로 실행되면 삭제된 로우 수를 반환한다. 위 예제의 경우 1을 반환한다.
저작자 표시 비영리 동일 조건 변경 허락
신고