'ORM'에 해당되는 글 1건

  1. 2015.06.30 MyBatis를 ORM 처럼 사용하기
프로그래밍/JAVA2015.06.30 21:59

ORM 솔루션을 사용했을 때 좋은 점은 애플리케이션 개발자로 하여금 SQL 같은 하부 데이터 구조에서 벗어나 보다 객체 중심으로 생각하고 코드를 작성할 수 있게 한다는 것이다. (그렇다고 ORM 솔루션을 사용한다고 해서 SQL을 잘 몰라도 된다는 것은 아니다. 오히려 ORM 솔루션을 통해 자동 생성되는 SQL을 이해하고, 이를 최적화 하기 위해서는 SQL RDBMS에 대한 깊은 이해가 필요하다.)


그러나 과거부터 현재까지 개발된 많은 애플리케이션이 꼭 ORM 솔루션을 사용하고 있는 것은 아니다. 오히려 MyBatis 같은 경량의 SQL Mapper 솔루션을 더욱 많이 사용해 왔고, 가까운 미래에도 이러한 추세가 크게 변하지 않을 것으로 보인다. (물론 이것은 어디까지나 우리나라 개발 환경에 대한 개인적인 판단이다. 해외의 경우 ORM 솔루션이 매우 활발하게 사용되고 있다.)


이 글에서는 우리 나라에서 매우 활발히(?) 사용되고 있는 Spring-MyBatis 조합 하에서 보다 객체중심으로 생각하고, ORM 솔루션을 사용할 때와 비슷한 코드를 작성하기 위한 방법과 제약 사항에 대해 설명할 것이다. MyBatis에 대한 상세한 설정 및 사용법에 대해서는 설명하지 않는다. MyBatis에 대한 상세한 설명은 https://mybatis.github.io/mybatis-3/ko/index.html 를 참고하도록 하자.



1. 객체 중심으로 생각하기

본격적으로 Spring-Mybatis를 활용한 간단한 애플리케이션을 작성하기에 앞서 해당 애플리케이션에서 사용하게 될 음악가, 앨범, 수록곡이라는 3개의 도메인 모델에 대해 생각해보자. 간단한 도메인 규칙은 다음과 같다.


I.      음악가는 0개 이상의 앨범을 출시할 수 있다.

II.     앨범은 반드시 특정 음악가를 지닌다.

III.   앨범은 0개 이상의 수록곡을 지닌다.

IV.   수록곡은 반드시 특정 앨범에 포함된다.


위 규칙을 간단하게 ERD로 그려보면 다음과 같다.



MyBatis를 통해 위 ERD를 반영할 때, SQL 실행 중심으로 생각한다면 각각의 테이블에 매핑되는 Class를 정의하고, 이들 Class의 멤버 속성은 각각 테이블의 컬럼을 1:1로 처리할 수 있도록 선언하면 된다. (@DataLombok 어노테이션으로 Class 멤버 속성에 대해 자동으로 getter/setter를 만들어 준다.)

@Data public class Artist {
    private Long seq;
    private String name;
    private Date debutDate;
}

@Data public class Album {
    private Long seq;
    private long artistSeq;
    private String title;
    private Stock stock;
    private Date issueDate;
}

@Data public class Song {
    private Long seq;
    private long albumSeq;
    private String name;
    private int playtime;
}

위와 같은 도메인 모델의 역할은 매우 단순하다. 도메인 모델은 단지 각각 테이블에 대한 VO(Value Object)로서 DAO, Serivce, View 레이어 사이에서 값을 전달하기 위해 사용된다. 이러한 도메인 모델은 별도의 비즈니스 로직을 포함하지 않는 것이 일반적이며 비즈니스 로직은 Service 레이어에 집중적으로 위치하게 될 것이다.


예를 들어 특정 Album의 모든 수록곡의 총 플레이 시간을 집계하는 기능을 만든다고 해보자. 아래와 비슷한 메소드를 Service 레이어에 추가하게 될 것이다.

@Transactional(readOnly=true)
public int selectTotalPlaytime(Long seq) {
    try {
        Album album = albumRepository.selectAlbumByPrimaryKey(seq);
        if (album != null) {
            List<Song> songs = songRepository.selectSongByAlbumKey(album.getSeq());
            return songs == null || songs.size() == 0 ? 0 : songs.stream().mapToInt(Song::getPlaytime).sum();
        }
        return 0;
    } catch (DataAccessException e) {
        logger.error(e.getMessage(), e);
        throw e;
    }
}

위 방법이 꼭 잘 못되었다고 할 수는 없지만, 한 가지 분명한 것은 개발자로 하여금 객체 중심으로 도메인을 분석하고 애플리케이션을 개발하도록 유도하지 않고 있으며, SQL 실행 중심으로 비즈니스 로직을 바라보게 한다는 것이다. 보다 객체 중심으로 생각하고 이를 애플리케이션 코드에 반영하기 위해서는 앞서 보여준 Class 코드는 다음과 같이 변경되어야 한다. (Artist Class Album 객체 목록을 멤버 속성으로 포함할 수 있지만 여기서는 생략한다.)

@Data public class Artist {
    private Long seq;
    private String name;
    private Date debutDate;
}

@Data public class Album {
    private Long seq;
    private Artist artist;
    private String title;
    private Stock stock;
    private Date issueDate;
    private List<Song> songs;

    public int getTotalPlaytime() {
        return (getSongs() == null || getSongs().size() == 0) ? 0 :
                getSongs().stream().mapToInt(Song::getPlaytime).sum();
    }
}

@Data public class Song {
    private Long seq;
    private Album album;
    private String name;
    private int playtime;
}

이제 Album 객체는 Artist 객체와 has one 관계에 있으며, Song 객체와는 has many 관계로 있다. 이에 따라 Album 객체는 멤버 속성으로 1개의 Artist 객체와, List에 담기는 복수의 Song 객체를 포함한다. 또한 Song 객체 List를 통해 해당 Album의 수록곡들의 총 플레이 시간을 계산할 수 있으므로 getTotalPlaytime() 메소드를 추가할 수 있다. (Service 레이어의 비즈니스 로직이 자연스럽게 도메인 모델로 옮겨져 왔다.) 그리고 Song 객체는 해당 Song 객체를 포함하고 있는 Album 객체를 멤버 속성으로 지닌다.


Hibernate같은 ORM 솔루션을 사용한다면 위와 같은 도메인 모델을 쉽고 자연스럽게 처리할 수 있다. Album 객체 입장에서 Artist 객체는 @ManyToOne 표현할 수 있으며, Song 객체는 @OneToMany로 표현할 수 있다. 이와 유사하게 Song 객체 입장에서 Album 객체는 @ManyToOne으로 표현할 수 있다. 이러한 도메인 모델을 구현하기 위해 올바른 RDBMS 설계만 필요할 뿐 별도의 SQL을 정의하거나 추가할 필요는 없다. 개발자가 SQL에 대한 고민이나 작성의 부담에서 벗어나 자연스럽게 객체들 사이의 관계에만 집중할 수 있도록 돕는 ORM 솔루션의 중요한 기능 중 하나인 것이다.


그렇다면 위와 같이 객체 중심으로 생각하고 도메인 모델을 작성하기 위해서 꼭 ORM 솔루션을 사용해야 하는 것일까? 그렇지 않다. 이 글의 목적이 바로 MyBatis를 사용하면서 위와 같은 도메인 모델을 작성하는 방법을 설명하는 것이다.



2. MyBatis로 작업하기

먼저 도메인 모델을 처리하는 Repository를 정의해 보자. (설명을 위한 최소한의 메소드를 지니 간단한 Repository를 선언한다.)

@Repository
public interface ArtistRepository {
    Artist selectArtistByPrimaryKey(Long seq);
}

@Repository
public interface AlbumRepository {
    Album selectAlbumByPrimaryKey(Long seq);
}

@Repository
public interface SongRepository {
    Song  selectSongByPrimaryKey(Long seq);
    List<Song> selectSongByAlbumKey(Long albumSeq);
}

계속해서 각각의 Repository에 대응되는 MyBatis XML에 대해 알아보자.


먼저 가장 간단한 ArtistRepositoryselectArtistByPrimaryKey 메소드에 대한 매핑 XML은 아래와 같다. Artist 객체 자체는 어떠한 연관 객체를 멤버 속성으로 지니지 않으므로 has one 또는 has many와 같은 연관 관계를 고려할 필요는 없다. (물론 이것은 필요에 따라 Artist 객체의 멤버 속성으로 Album List를 지니도록 수정될 수 있다. 이런 경우 매핑 XML 정의도 달라져야 하는데 이것은 다음에 설명할 AlbumRepository 매핑 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.devop.test.core.repo.artist.ArtistRepository">
    <resultMap id="artistResultMap" type="com.devop.test.core.entity.artist.Artist">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="name" property="name" jdbcType="VARCHAR"/>
        <result column="debut_date" property="debutDate" jdbcType="TIMESTAMP"/>
    </resultMap>

    <select id="selectArtistByPrimaryKey" resultMap="artistResultMap" parameterType="java.lang.Long">
        select
            seq, name, debut_date
        from
            artists
        where
            seq = #{seq, jdbcType=BIGINT}
    </select>
</mapper>

AlbumRepository 매핑 XML은 다음과 같다. Album 객체를 기준으로 has one 관계에 있는 Artist 객체, has many 관계에 있는 Song 객체와 연관 되도록 SQL을 설정한다. 이러한 연관 설정은 MyBatis에서 제공하는 <association><collection>으로 정의할 수 있다.


먼저 has one 관계를 설정하기 위한 <association>에 대해 알아보자. 이것은 아래 매핑 XML에서 확인할 수 있듯이 <assocication> 태그로 정의할 수 있는데, 세부적으로는 또 한 번의 추가 Select 구문을 통해 데이터를 가져오는 방법과, Join을 통해 한번에 데이터를 가져오는 방법을 선택할 수 있다. MyBatis에서는 이들을 Nested Select, Nested Results라 한다. 아래 매핑 XML Album 객체와 has one 관계에 있는 Artist 객체를 추가 Select를 통해 가져오도록 설정한 것이다. 이를 위해 <assocication> 태그에 선언된 ‘select’ attribute를 통해 추가 Select SQL 구문을 가리키게 한다.


has many 관계를 설정하기 위한 <collection>역시 <association>과 마찬가지로 추가 Select 구문을 통해 데이터를 가져오는 방법과, Join을 통해 한번에 데이터를 가져오는 방법 중에 선택할 수 있다. (has many 관계에서 left outer join을 통해 한 번에 데이터를 가져오는 경우 left측의 데이터가 우측에 나타나는 데이터의 수만큼 반복되어 나타나는 문제가 있기 때문에 데이터를 한번 더 정제하기 위한 절차가 필요하다.) 아래 매핑 XML Album 객체와 has many 관계에 있는 Song 객체를 추가 Select를 통해 가져오도록 설정한 것이다. 이를 위해 <collection> 태그에 선언된 ‘select’ attribute를 통해 추가 Select SQL 구문을 가리키게 한다.

<?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.devop.test.core.repo.album.AlbumRepository">
    <resultMap id="songResultMap" type="com.devop.test.core.entity.song.Song">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="name" property="name" jdbcType="VARCHAR"/>
        <result column="playtime" property="playtime" jdbcType="INTEGER"/>
    </resultMap>

    <resultMap id="artistResultMap" type="com.devop.test.core.entity.artist.Artist">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="name" property="name" jdbcType="VARCHAR"/>
        <result column="debut_date" property="debutDate" jdbcType="TIMESTAMP"/>
    </resultMap>

    <resultMap id="albumResultMap" type="com.devop.test.core.entity.album.Album">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="title" property="title" jdbcType="VARCHAR"/>
        <result column="stock" property="stock" jdbcType="INTEGER" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler"/>
        <result column="issueDate" property="issueDate" jdbcType="TIMESTAMP"/>
        <association column="artist_seq" property="artist" select="selectArtistByPrimaryKey"/>
        <collection column="seq" property="songs" select="selectSongByAlbumKey"/>
    </resultMap>

    <select id="selectSongByAlbumKey" resultMap="songResultMap" parameterType="java.lang.Long">
        select
            seq, album_seq, name, playtime
        from
            songs
        where
            album_seq = #{seq, jdbcType=BIGINT}
    </select>

    <select id="selectArtistByPrimaryKey" resultMap="artistResultMap" parameterType="java.lang.Long">
        select
            seq, name, debut_date
        from
            artists
        where
            seq = #{seq, jdbcType=BIGINT}
    </select>

    <select id="selectAlbumByPrimaryKey" resultMap="albumResultMap" parameterType="java.lang.Long">
        select
            seq, artist_seq, title, stock, issue_date
        from
            albums
        where
            seq = #{seq, jdbcType=BIGINT}
    </select>
</mapper>

마지막으로 SongRepository 매핑 XML을 보자. 주의해서 볼 부분은 songResultMap에서 Song 객체와 has one 관계에 있는 Album 객체를 가져오기 위한 <assocication> 태그 부분이다. 2개의 <association> 태그가 정의되어 있는데 하나는 Join을 통해 한번에 데이터를 가져오는 방법 다른 하나는 추가 Select를 통해 데이터를 가져오는 방법을 정의한 것이다. (2개의 <association> 정의를 동시에 사용할 수는 없으므로 1개는 주석처리 해야한다.)


Join을 통해 한번에 데이터를 가져오는 경우 <association> 태그 내에 ‘resultMap’ attribute가 선언되는데 이것은 Join을 사용한 SQL 실행 결과를 어떻게 Song 객체에 바인딩 시킬지 가리키게 된다. 이것은 전체 결과 컬럼 중에서 Albums 테이블 컬럼에 대해 별칭을 부여하고, Song 객체의 Album 객체 멤버 속성에 결과가 바인딩 되도록 처리한 것이다.


추가 Select를 통해 데이터를 가져오는 경우 앞에서와 마찬가지로 <assocication> 태그에 선언된 ‘select’ attribute를 통해 추가 Select SQL 구문을 가리키게 한다.

<?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.devop.test.core.repo.song.SongRepository">
    <resultMap id="albumResultMap" type="com.devop.test.core.entity.album.Album">
        <id column="album_seq" property="seq" jdbcType="BIGINT"/>
        <result column="album_title" property="title" jdbcType="VARCHAR"/>
        <result column="album_stock" property="stock" jdbcType="INTEGER" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler"/>
        <result column="album_issue_date" property="issueDate" jdbcType="TIMESTAMP"/>
    </resultMap>

    <resultMap id="albumResultMap2" type="com.devop.test.core.entity.album.Album">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="title" property="title" jdbcType="VARCHAR"/>
        <result column="stock" property="stock" jdbcType="INTEGER" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler"/>
        <result column="issue_date" property="issueDate" jdbcType="TIMESTAMP"/>
    </resultMap>

    <resultMap id="songResultMap" type="com.devop.test.core.entity.song.Song">
        <id column="seq" property="seq" jdbcType="BIGINT"/>
        <result column="name" property="name" jdbcType="VARCHAR"/>
        <result column="playtime" property="playtime" jdbcType="INTEGER"/>
        <association property="album" resultMap="albumResultMap"/>
        <!--association column="album_seq" property="album" select="selectAlbumByPrimaryKey"/-->
    </resultMap>

    <select id="selectSongByPrimaryKey" resultMap="songResultMap" parameterType="java.lang.Long">
        select
            s.seq, s.album_seq, s.name, s.playtime,
            a.seq as album_seq,
            a.artist_seq as album_artist_seq,
            a.title as album_title,
            a.stock as album_stock,
            a.issue_date as album_issue_date
        from
            songs s left outer join albums a on s.album_seq = a.seq
        where
            s.seq = #{seq, jdbcType=BIGINT}
    </select>

    <select id="selectSongByAlbumKey" resultMap="songResultMap" parameterType="java.lang.Long">
        select
            seq, album_seq, name, playtime
        from
            songs
        where
            album_seq = #{albumSeq, jdbcType=BIGINT}
    </select>

    <select id="selectAlbumByPrimaryKey" resultMap="albumResultMap2" parameterType="java.lang.Long">
        select
            seq, artist_seq, title, stock, issue_date
        from
            albums
        where
            seq = #{seq, jdbcType=BIGINT}
    </select>
</mapper>

이로서 음악가, 앨범, 수록곡에 대한 Repository 작성을 완료 했다. 이제 이들 RepositoryDI 받아 사용할 Service를 정의한다.

@Service
public class ArtistService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Autowired private ArtistRepository artistRepository;

    @Transactional(readOnly=true)
    public Artist selectArtistByPrimaryKey(Long seq) {
        try {
            return artistRepository.selectArtistByPrimaryKey(seq);
        } catch (DataAccessException e) {
            logger.error(e.getMessage(), e);
            throw e;
        }
    }
}

@Service
public class AlbumService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Autowired private AlbumRepository albumRepository;

    @Transactional(readOnly=true)
    public Album selectAlbumByPrimaryKey(Long seq) {
        try {
            return albumRepository.selectAlbumByPrimaryKey(seq);
        } catch (DataAccessException e) {
            logger.error(e.getMessage(), e);
            throw e;
        }
    }
}

@Service
public class SongService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Autowired private SongRepository songRepository;

    @Transactional(readOnly=true)
    public Song selectSongByPrimaryKey(Long seq) {
        try {
            return songRepository.selectSongByPrimaryKey(seq);
        } catch (DataAccessException e) {
            logger.error(e.getMessage(), e);
            throw e;
        }
    }

    @Transactional(readOnly=true)
    public List<Song> selectSongByAlbumKey(Long albumSeq) {
        try {
            return songRepository.selectSongByAlbumKey(albumSeq);
        } catch (DataAccessException e) {
            logger.error(e.getMessage(), e);
            throw e;
        }
    }
}

계속해서 사용자 인터페이스 요청을 처리할 간단한 SpringMVC 컨트롤러를 추가해보자.

@Controller
@RequestMapping(value="album")
public class AlbumController {
    @Autowired private AlbumService albumService;

    @RequestMapping(value="index")
    public ModelAndView selectAlbum(@RequestParam(required=true) Long seq) {
        Album album = albumService.selectAlbumByPrimaryKey(seq);
        ModelAndView mav = new ModelAndView();
        mav.addObject("album", album);
        return mav;
    }
}

AlbumController에 의해 최종적으로 사용자에게 출력될 결과물을 담고 있는 ViewJSP를 사용한다. JSP 페이지에서는 Album 객체와 연관 관계에 있는 Artist 객체와 Song 객체 목록을 사용한다.

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>:: 앨범 ::</title>
</head>
<body>
    <h1>앨범정보</h1>

    <h2>앨범명</h2>
    ${album.title}<br/>

    <h2>재생시간</h2>
    ${album.totalPlaytime}<br/>

    <h2>음악가</h2>
    ${album.artist.name}<br/>
</body>
</html>

출력결과는 다음 그림과 같이 Album 객체를 통해 로드 된 Artist 정보와 Song 목록 정보를 사용해 정상적으로 모든 정보가 출력되고 있는 것을 확인할 수 있다.



위 화면을 출력하기 위해 실행 된 SQL은 총 3개이다. 첫 번째는 Album 정보를 조회하기 위한 SQL이며, 두 번째는 조회한 Album Artist 정보를 조회하기 위한 SQL 그리고 마지막으로 조회한 AlbumSong 정보를 조회하기 위한 SQL이다. (이들 SQL은 앞서 설명한 AlbumReposiroty 매핑 XML에 모두 정의되어 있다.)


그런데 이것으로 모두 끝난 것일까? 그렇지 않다. Album 하나를 조회하는데 총 3번의 SQL이 실행된다는 것은 너무 과하다. 만약 100개의 Album을 동시에 조회하는 페이지가 추가된다면 어떻게 될까? 100개의 Album 정보를 가져오기 위한 SQL1회 실행되고 각각의 Album에 대해 Artist 정보와 Song 정보를 가져오기 위해 2회씩 SQL이 실행되면 총 201번의 SQL이 실행될 것이다. (이것이 바로 유명한 N+1 Select 문제이다.)


 N+1 Select 문제

    I. 레코드의 목록을 가져오기 위해 하나의 SQL 구문을 실행한다. (+1 에 해당)

    II. 리턴된 레코드별로, 각각의 상세 데이터를 로드하기 위해 Select 구문을 실행한다. (N 에 해당)


이 문제를 해결하기 위한 방법은 2가지가 있다.


2.1. 지연 로딩(Lazy Loading)

먼저 간단한 설정을 통해 적용할 수 있는 지연 로딩(Lazy Loading)에 대해 알아보자. 지연 로딩은 어떤 추가 정보가 있을 때 그것을 사용하기 직전에 로드 하는 방법을 의미한다. , 앞서 Album 객체에서 Artist 객체와 Song 객체가 사용되지 않는다면 그것들을 사전에 로드 하지 않고 getAlbum(), getSongs() 메소드를 통해 접근이 발생할 때 로드 함으로써 불필요한 SQL 실행을 방지하는 방법이다. 앞서 AlbumController를 통해 출력되는 JSP 페이지에서 재생시간, 음악가 정보를 출력할 필요가 없다면 지연 로딩 기법을 적용해 N+1 Select 문제를 회피할 수 있다.


Hibernate같은 ORM 솔루션에서 지연 로딩은 객체 생명 주기를 포함하는 영속성 컨텍스트(Persistence Context), OSIV(Open Session In View Filter) 등 많은 개념을 이해하고 조심스럽게 사용해야 한다는 어려움이 있다. 하지만 MyBatis에서는 ORM 솔루션에서처럼 복잡한 개념들을 요구하지 않는다. MyBatis에서 지연 로딩을 적용하는 방법은 간단하게 설정 항목 중 lazyLoadingEnabled를 활성화시키고 aggressiveLazyLoading를 비활성화 시키면 된다. aggressiveLazyLoading를 활성화 시키면 지연 로딩이 제대로 작동하지 않는다. (aggressiveLazyLoading는 객체의 일반 getter 메소드가 실행되어도 해당 객체의 모든 연관 객체가 사용될 것이라고 판단하고 미리 추가 정보를 로드해 버린다.) 이와 같은 설정은 MyBatis 전역에 걸쳐 영향을 끼치게 된다. 만약 3.2.6 이상 버전의 MyBatis를 사용한다면 <association> 태그 내에 ‘fetchType’ attribute를 통해 지연 로딩을 활성화할 수 있다. 이 설정은 전역 lazyLoadingEnabled를 대체한다.


이처럼 MyBatis에서 지연 로딩은 직관적이고 설정도 쉽다. 그러나 지연 로딩이 모든 문제를 해결해주지는 못한다는 것을 이해하고 있어야 한다. 지연 로딩은 불필요한 정보를 사전에 로드 하는 것을 방지하는 것일 뿐 어떤 정보가 반드시 필요하다면 해당 정보는 결국 로드 될 것이다. 예를 들어 앞서 살펴본 JSP 페이지에서 재생시간, 음악가 정보가 반드시 필요하다면 N+1 Select 문제는 지연 로딩으로는 해결할 수 없게 된다.


2.2. 관계를 위한 내포된 결과(Nested Results)

지연 로딩을 대신할 수 있는 다른 방법은 Join을 이용해 필요한 데이터를 한번에 모두 가져오는 것이다. (MyBatis에서 이것을 Nested Results라 한다.) 이 방법은 특히 has one 관계에 놓여 있는 2개의 객체 사이에서 유용하게 사용할 수 있다. 예를 들어 Song 객체와 has one 관계에 있는 Album객체가 여기에 해당된다. Song 정보가 사용될 때 Album정보도 함께 사용된다고 하면 추가 Select를 통해 Album정보를 로드 하는 것보다 Song 정보를 로드 할 때 Album 정보도 같이 한번에 로드 하는 것이 효율적이다. 이것을 위해 Song left측에 두고 left outer join을 걸어 SQL 구문을 작성할 수 있을 것이다. 앞서 살펴본 SongRepository 매핑 XML을 보면 2개의 <association> 태그가 정의되어 있는데 <association> 태그 내에 ‘resultMap’ attribute가 선언되어 있는 것이 바로 Join을 통해 Song 정보와 Album 정보를 한번에 가져오기 위한 방법을 보여준다.


Nested Resultshas many 관계에 있는 객체들 사이에서도 사용이 가능하다. 사용 방법은 <association> 때와 일치한다. 다만, 한 가지 주의할 점은 has many 관계에서 left outer join을 통해 한 번에 데이터를 가져오는 경우 left측의 데이터가 우측에 나타나는 데이터의 수만큼 반복되어 나타난다는 것을 이해하고 있는 것이다. 예를 들어 Album 정보와 Song 정보를 한번에 가져오는 SQL은 다음과 같다.

select 
	a.seq, a.artist_seq, a.title, a.stock, a.issue_date,
	s.seq as songs_seq,
	s.album_seq,
	s.name,
	s.playtime
from 
	albums a left outer join songs s on a.seq = s.album_seq
where
	a.seq = ?

그리고 위 SQL이 실행 된 결과는 아래와 같이 Album 정보가 Row 마다 반복해서 나타나게 되는 것을 확인할 수 있다.



최종적으로 위 실행 결과는 <association> 태그 내에 선언된 ‘resultMap’ attribute가 가리키는 바인딩 전략에 따라 처리되고, 하나의 Album 객체와 List에 담기는 복수의 Song 객체에 바인딩 될 것이다.



3. 제한 사항

지금까지 알아본 것처럼 MyBatis에서 제공하는 <association><collection>을 이해하고 있으면 ORM 솔루션을 사용할 때처럼 객체 중심으로 생각하고 애플리케이션을 개발하는데 큰 도움을 얻을 수 있다. 그러나 근본적으로 MyBatis ORM이 아닌 SQL Mapper이기 때문에 여기에는 몇 가지 제한 사항이 따른다.


3.1. resultMap 및 SQL 정의 중복

AlbumRepository 매핑 XML SongRepository 매핑 XML을 비교해보자. resultMap 정의뿐만 아니라 비슷하거나 혹은 완전히 똑같은 SQL이 반복해서 나타나는 것을 확인할 수 있다. 연관 관계 놓여 있는 객체들 사이의 매핑 XML에서 <association><collection> 정의를 추가하기 위해 resultMap SQL 정의가 중복되는 것은 피할 수 없다. 이것은 결국 데이터베이스 스키마 변경에 따른 매핑 XML 수정 작업을 더욱 복잡하고 어렵게 한다.


3.2. 일관적이지 않은 객체 그래프

MyBatis<association><collection>를 통한 객체 모델링에서 나타날 수 있는 중요한 제약 사항 중 하나로 일관적이지 않은 객체 그래프 문제를 꼽을 수 있다. Album 객체와 Song 객체 사이의 관계를 통해 이 문제를 자세히 알아보자.


Album 객체는 앞서 설명한 대로 has many 관계에 놓여 있는 복수의 Song 객체를 List에 담고 있고, 이 관계는 <collection>를 통해 정의했다. 그리고 Album 객체는 getTotalPlaytime() 이라는 메소드를 통해 해당 Album 객체가 포함하고 있는 Song 객체들의 플레이 시간 총 합을 구할 수 있다. 만약 지연 로딩이 설정 되어 있다면 Song 객체 List에 대한 접근이 최초 발생할 때 추가 Select SQL을 통해 Song 객체 정보를 로드 하게 될 것이고, 결과적으로 getTotalPlaytime() 메소드는 올바른 값을 반환하게 된다그런데 Album 객체가 Song 객체를 통해 로드 되었다면 (앞서 살펴본 것처럼 Song 객체는 has one 관계에 있는 Album 객체에 대해 <association>를 통해 관계를 정의하고 정보를 로드 할 수 있다.) getTotalPlaytime() 메소드는 0을 반환할 것이다. 이것은 실제 Song 객체 List의 플레이 시간 총 합이 0이기 때문이 아니라 Album 객체와 has many 관계에 놓여 있는 Song 객체 List가 올바르게 로드 되지 않았기 때문이다. (이와 유사하게 Song 객체를 통해 로드 된 Album 객체는 Artist 객체를 로드 할 수 없다.)


결과를 통해 알 수 있는 것은 AlbumRepository를 통해 독립적으로 로드 된 Album 객체와 Song 객체를 통해 간접적으로 로드 된 Album 객체는 서로 다른 객체 그래프를 지니고 있다는 사실이다. , Song 객체를 통해 간접적으로 로드 된 Album 객체는 Song 객체 List를 로드 할 수 있는 능력이 없다. (Artist 객체 또한 로드 할 수 없다. 즉 연관 관계에 놓여있는 객체들 모두 로드 할 수 있는 능력이 없다.) 이것은 SongRepository의 매핑 XML에서 albumResultMap 정의를 살펴보면 그 이유를 알 수 있다. (아래 SongRepository의 매핑 XML 내용의 일부는 Join을 통해 Song 객체와 함께 한번에 Album 객체를 로드 할 때 사용되는 albumResultMap과 추가 Select SQL을 통해 Album 객체를 로드 할 때 사용되는 albumResultMap2 정의를 보여준다.)

<resultMap id="albumResultMap" type="com.devop.test.core.entity.album.Album">
        <id column="album_seq" property="seq" jdbcType="BIGINT"/>
        <result column="album_title" property="title" jdbcType="VARCHAR"/>
        <result column="album_stock" property="stock" jdbcType="INTEGER" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler"/>
        <result column="album_issue_date" property="issueDate" jdbcType="TIMESTAMP"/>
</resultMap>

<resultMap id="albumResultMap2" type="com.devop.test.core.entity.album.Album">
    <id column="seq" property="seq" jdbcType="BIGINT"/>
    <result column="title" property="title" jdbcType="VARCHAR"/>
    <result column="stock" property="stock" jdbcType="INTEGER" typeHandler="org.apache.ibatis.type.EnumOrdinalTypeHandler"/>
    <result column="issue_date" property="issueDate" jdbcType="TIMESTAMP"/>
</resultMap>

Join SQL 결과 reusltMap (albumResultMap), 추가 Select SQL resultMap (albumResultMap2) 모두 단순히 Album 객체의 일반 속성 항목에 대한 바인딩 규칙 만 있을 뿐 Album 객체와 has many 관계에 있는 Song 객체에 대한 <collection> 정의는 찾아볼 수 없다. 따라서 당연히 Song 객체를 통해 로드 된 Album 객체는 Song 객체 List에 대한 초기화가 불가능하게 된다.


일관적이지 않은 객체 그래프는 분명 매우 심각한 문제이다. 어떤 Album 객체가 주어졌을 때 Album 객체가 어떻게 로드 되었는지에 따라서 메소드 실행 결과값이 달라진다면 OOP의 원칙을 심각하게 위배하고 있음은 물론이고, 그 어떤 개발자도 올바르게 작동하는 애플리케이션을 개발할 수 없을 것이기 때문이다.


당장 이 문제를 해결하기 위해서는 SongRepository 매핑 XMLalbumResultMap 정의에 Album 객체와 has many 관계에 있는 Song 객체 List를 처리하기 위한 <collection>을 정의를 추가해야 한다하지만 이 방법은 연관 관계에 놓여 있는 모든 객체들의 객체 그래프의 동일함을 보장하기 위해 관련된 모든 매핑 XML 파일에 <assocication> 또는 <collection>정의를 추가해야 한다는 것을 의미한다. 연관 관계에 놓여 있는 객체가 서로 순환 참조하고 있을 때는 매핑 XML 정의를 더욱 복잡하게 만든다. 이것은 결국 앞서 설명한 SQL resultMap 정의 중복문제를 더욱 심각하게 하는 것에 지나지 않는다.


따라서 이 문제를 보다 효율적으로 해결하기 위해서는 다른 접근 방식을 생각해 보아야 한다. 다음과 같은 질문들을 던져본다.


I.       애플리케이션 개발 과정에서 Song 객체를 통해 Album 객체를 로드 하는 패턴이 얼마나 빈번하게 발생하는가?

II.      그러한 접근 패턴이 애플리케이션 성능을 향상 시키고 있는가?

III.    그러한 접근 패턴이 올바른 사용자 경험을 제공하는가?


만약 이러한 질문을 통해 Song 객체가 has one 관계에 놓여 있는 Album 객체를 반드시 포함할 필요가 없다고 판단되면 Song Class 멤버 속성에서 Album 객체 멤버 속성을 제거한다. 어쩔 수 없는 이유로 반드시 포함해야 한다면 객체 그래프의 동일성을 보장하기 위해 SongRepository 매핑 XML을 수정한다. 중복 정의 문제가 발생하는 것은 피할 수 없지만 일관성 없는 객체 그래프로 인하여 발생할 수 있는 버그가 더욱 치명적이다.



4. 결론

MyBatis는 완전한 ORM 솔루션이 아니다. MyBatis에서 제공하는 <association><collection>을 통해 ORM 솔루션을 사용할 때와 유사한 효과를 가져올 수 있으나 여기에는 몇 가지 치명적인 문제점이 존재하고 있음을 확인했다. 이러한 문제점을 완화 시키기 위해 연관 객체들 사이의 상호 참조 및 포함 관계를 제한할 수 있으나 이것이 근본적으로 올바른 해결 방법은 아니다. 이러한 고려 사항 자체가 이미 데이터 구조에서 벗어난 순수한 객체 모델링을 방해하고 있는 셈이기 때문이다그러나 이러한 특징들이 MyBatisORM 솔루션보다 나쁘다는 것을 의미하지는 않는다. MyBatisMyBatis 대로 오랜 시간 동안 충분히 검증된 훌륭한 SQL Mapper 솔루션이며, 그에 걸맞은 사용법이 있기 때문이다. 앞에서 언급한 문제점들은 SQL Mapper 솔루션으로서 MyBatisORM 솔루션처럼 사용하려 했기 때문에 발생했던 부작용들이다.


저작자 표시 비영리 변경 금지
신고
Posted by devop