
[Mybatis] ResultMap 으로 다중 조인 쉽게하기
zl존석동
·2022. 2. 27. 13:27
Servlet 4.0 + MyBatis + JSP + Javascript로 토이 프로젝트를 만들며 다중조인을 적용했던 경험을 기록했다.
개요
간단한 중고책 대여 판매 서비스를 만들어보고자 했었다.
Book 테이블은 Book Open API 기반으로 ISBN 등록 시 들어가는 데이터로 같은 ISBN이면 모두 같은 책이다.
BookShop 은 사용자의 대여 등록 게시물과 같은 테이블로 하나의 책을 등록할 수 있다.
Member 테이블은 사용자로 BookShop에 책을 게시할 수 있게 하였다.
책의 경우 모든 책이 테이블에 다 있다고 가정하고 사용자가 ISBN으로 등록 게시 시도 시 책 정보가 자동으로 insert 되게끔 하였다.
BookShop 이 결국 게시판의 게시글과 같은 형태인데 당연히 책의 정보도 같이 나와야 하고 때로는 책의 정보와 함께 등록자 정보도 나와야 했다.
위의 ERD 이미지에는 없지만 테이블 분리를 하다보니 BookShop 에는 많은 코드 테이블이 연관관계를 가지고 있었다.
현재 거래상태, 거래타입, 카테고리(사용자 입력) 등등..
결국 게시글을 목록 또는 단건으로 조회했을 때 현재 거래 상태, 거래타입, 카테고리, 책 정보, 필요 시 사용자 정보 등등 많은 테이블들을 조인해서 가져와야 했다.
해결책1: view를 만들까
어차피 대부분의 요구에서 필수적으로 같이 조인되어 조회되어야 하는 것들이 보였다.
그래서 어차피 쿼리매퍼인 MyBatis를 쓰는데 이런 데이터베이스적인 것들을 더 활용하고자 해당 조회들에 대해 View를 따로 만들면 어떨까라는 생각을 했다.
테이블에 불필요 데이터는 조회되지 않게끔 데이터베이스 선에서 막아줄 수 있을 것 같기도 했고..
하지만 뷰를 사용하기에는 불확실한 where 절 조건 사용이 너무 많을 것이며 언제 어떻게 요구가 변경될지 확신이 안 섰다.
적절한 쿼리 설계나 인덱싱 없이 뷰를 사용하는 경우 오히려 문제가 발생할 수 있다고 DBA 오픈 카톡에서 봤던 기억이 나 배제하였다.
해결책2: ResultMap으로 조인하기
MyBatis 는 쿼리 매퍼이기 때문에 JPA 마냥 테이블에는 연관관계 ID가 있고 객체에는 해당 연관관계 테이블 정보가 있어 조회 해서 객체를 얻어올 수도 없는 것이고
기본적으로는 정말 테이블에 아이디가 있다면 실제로 테이블 객체에도 ID가 있어야 동작을 한다.
따라서 자동으로 쿼리 매핑이 어려울 경우 사용할 수 있는 ResultMap을 활용하여 조인했다.
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BookShopDTO {
private Long bookshopId;
private Member member;
private Book book;
}
JPA 엔티티마냥 위와 같은 방법으로 객체를 바로 매핑 시킬 수 있지만
해당 부분을 활용하는 기존 로직들의 변경없이 연관관계 조회 기능을 확장하고 싶었고 이왕이면 테이블과 똑같은 형태로 DTO 속성들을 구성하고 싶었다.
public class BookShopVO {
public static class Book {
private BookShopDTO bookShop;
private BookDTO book;
public Book() {
}
public BookShopDTO getBookShop() {
return bookShop;
}
public BookDTO getBook() {
return book;
}
}
public static class BookWithMember extends Book {
private MemberDTO.Info member;
public BookWithMember() {
}
public MemberDTO.Info getMember() {
return member;
}
}
}
조회용 읽기 전용 객체로 분리해 위와 같이 구성했다.
적절하진 않아보이나 나름 구분가능한 네이밍을 준 inner 클래스를 사용하게끔 하기 때문에
사용하는 곳에서도 단순히 BookShop 객체를 이용하는 것 보다는 "아하~ 해당 DTO와 연관하여 조회해오는 객체구나!" 라는 생각을 할 수 있어 좋았던 것 같다.
위의 MemberDTO.Info 처럼 사실 컬럼이 많은 기본 테이블 DTO들도 불필요한 정보가 항상 오고 가며 사용할 수 있는지 없는지 쿼리를 보며 구분해야되는 상황이 싫어 사용처에 맞게 저런식으로 숨겨서 구성했던 것 같다. (로그인 상황이면 Member 에서도 로그인 관련 속성만 개발자가 열람할 수 있다.)
<resultMap id="BookBookShopMap" type="com.dto.bookshop.BookShopVO$Book">
<collection property="book" resultMap="bookMap"/>
<collection property="bookShop" resultMap="bookshopMap"/>
</resultMap>
<resultMap id="BookMemberBookShopMap" type="com.dto.bookshop.BookShopVO$BookWithMember">
<collection property="book" resultMap="bookMap"/>
<collection property="member" resultMap="memberMap"/>
<collection property="bookShop" resultMap="bookshopMap"/>
</resultMap>
그리고 쿼리 xml 파일에 다음과 같이 resultMap 을 활용해 조인에 필요한 자바 클래스 타입들을 매핑해주었다.
collection 의 각 resultMap들도 모두 dto 클래스 속성들을 resultMap으로 등록해주어야 한다.
<select id="findAllByMemberId" resultMap="BookBookShopMap" parameterType="long">
select shop.book_id,
shop.bookshop_id,
shop.created_at,
shop.sell_status_id,
shop.cnt,
shop.selltype_id,
book.book_title,
to_char(to_date(book.publish_time, 'yyyymmdd'), 'yy/mm/dd hh:mi') as publish_time,
to_char(shop.created_at, 'YYYY/MM/DD HH24:MI') as created_at,
to_char(shop.updated_at, 'YYYY/MM/DD') as updated_at
from
bookshop shop
join books book
on shop.book_id = book.book_id
where shop.seller_id = #{sellerId}
order by shop.created_at desc
</select>
resultMap으로 연결한 객체의 id를 활용해 쿼리를 작성하고 dao 에서 사용하면 조인 끝!
기존 bookshop 과 연관된 Member 속성을 객체(dto)로 변경하지 않고 id로 실제 테이블과 같게 유지했기 때문에 조건절에서 쉽게 사용할 수 있었던 것 같다.
코드 테이블들은..??
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BookShopDTO {
private long bookshopId;
private long sellerId;
private String sellerComment;
private String sellerShort;
private long bookId;
private String createdAt;
private String updatedAt;
private String endedAt;
private int cnt;
private BookShopStatusCode sellStatusType;
private TradeCode tradeType;
private CategoryCode categoryType;
//...
}
아이디와 내용 하나만 가진 카테고리, 거래타입, 거래상태 같은 것들은 모두 key(id) 와 임의로 설정한 문자열만 가진 테이블들로 분리하여 설계했었는데
도메인 정책 상 이미 설정되어있는 값들이기 때문에 동적이지 않고 불변하다.
근데 얘네들은 거래 게시글 조회마다 거의 무조건 필요한데 이걸 하나하나 다 조인하면 조인 테이블만 5개가 넘어가기 때문에 코드 실수도 많아지고 조회가 비효율적일 것이라고 판단했다.
누가봐도 Enum 을 사용하기 아주 적절한 상황이다.
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum CategoryCode {
C01("문학,인물"),
C02("유아,아동"),
C03("소설,수필"),
C04("교육,전문"),
C05("역사,문화"),
C06("철학,심리"),
C08("만화,오락"),
C09("영화,음반"),
C10("총류,전집");
private final String name;
CategoryCode(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
이렇게 Enum 클래스로 만들어 주고 EnumTypeHandler 클래스를 사용처에 맞게 만들고 MyBatis 설정 파일에 매핑시켜 주면 된다.
본인의 경우 조회 시 해당 테이블 row의 아이디가 아니라 Enum 객체 자체를 반환하게끔 구성하였다.
물론 연관관계가 있는 테이블에는 id 값만 있기 때문에 insert 할 때는 그냥 아이디를 넣으면 된다.
@MappedTypes(CategoryCode.class)
public class CategoryEnumHandler extends BaseTypeHandler<CategoryCode> {
//...
@Override
public CategoryCode getNullableResult(ResultSet resultSet, String s) throws SQLException {
return CategoryCode.valueOf(resultSet.getString(s));
}
@Override
public CategoryCode getNullableResult(ResultSet resultSet, int i) throws SQLException {
return CategoryCode.valueOf(resultSet.getString(i));
}
@Override
public CategoryCode getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
return CategoryCode.valueOf(callableStatement.getString(i));
}
}
아 그리고 dto 코드에 생략했지만 인자 없는 생성자와 getter 는 필수다.
확인은 안해보았으나 인자 없는 생성자와 getter 로 객체 속성과 쿼리를 매핑하는 원리였던 것 같았다.
결과 확인하기