Skip to content

Dev

Mybatis 연관관계 맵핑

Mybatis, SpringBoot2 min read

SpringBoot-Mybatis 프로젝트 환경에서 제공해주는 기본옵션들과 Mybatis에서 제공하는 연관관계 맵핑과 관련하여 정리하였다.

전체 소스코드는 깃허브주소에서 확인할 수 있다

1. 프로젝트 의존성 설정
1dependencies {
2 implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'
3}

이렇게 의존성을 추가하면 스프링부트에서는 오토 스캐닝을 지원하여 Mapper 인터페이스(어노테이션)를 찾아 Bean으로 등록해준다.

2. Mapper 설정

Mapper 설정에는 총 2가지 파일 생성이 필요하다. 첫번째는 xml기반의 쿼리문, 객체 맵핑등을 기술한 xml파일과 이 xml파일의 기능들을 제공하기 위한 인터페이스 생성이다.

2-1) 스프링 컨테이너에 등록할 Repository 생성

1@Mapper
2public interface CategoryRepository {
3
4 void save(Category category);
5}

@Mapper 어노테이션이 붙어있으면 해당 인터페이스는 스프링 컨테이너에 bean으로 등록된다.

2-2) mapper.xml 생성
프로젝트구조

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
3 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
4
5<mapper namespace="com.example.practice.category.domain.CategoryRepository">
6
7 <insert id="save" parameterType="category"
8 useGeneratedKeys="true" keyProperty="categoryId" >
9 INSERT INTO categories
10 (categoryCode, categoryName)
11 VALUES
12 (#{categoryCode}, #{categoryName})
13 </insert>
14</mapper>

두 개의 파일을 프로젝트에 생성하였다면, 두 개의 파일을 이어줄 수 있는 경로설정이 필요하다. 왜냐하면 인터페이스를 통해 해당 xml의 쿼리문을 수행해야하는데 이 mapper 인터페이스가 xml 파일위치가 어디에 있는지 알 수 없기 때문이다.

2-3)properties 파일에 mapper 경로설정

1mybatis.mapper-locations=mybatis/mapper/*.xml

이렇게 경로에 대한 설정을 해주면, mapper 인터페이스가 해당 xml을 찾아서 요청하여 mybatis를 사용하게 된다. 기본적으로 mapper-locations는 루트구조가 resources폴더로 되어있기에 resources폴더 하위에 mapper 폴더 경로를 적어주면된다.

이렇게 mapper xml 경로를 설정을 완료하였다면 mapper 인터페이스에서 xml를 사용할 수 있게 된다. 여기서 mapper 인터페이스에 기술된 메서드명과 xml에 쿼리문에 기술된 id값이 일치해야 해당 쿼리문을 수행 할 수 있다.

3. 연관관계 맵핑

3.1) has one 관계
has one 관계인 객체로 맵핑할 경우 <association> 태그를 사용하여 맵핑 할 수 있다.

1public class Product {
2
3 private Long productId;
4 private String productName;
5 private Member seller;
6 private String image;
7 private Money price;
8 private String description;
9 private LocalDateTime registeredDate;
10 private Category category;
11
12 //생략..
13}
14public class Member {
15
16 private Long memberSequence;
17 private String memberId;
18 private String password;
19 private String phoneNumber;
20
21}
1CREATE TABLE `practice`.`members` (
2
3 `memberSequence` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '유저 번호',
4 `memberId` VARCHAR(150) NOT NULL COMMENT '유저 ID',
5 `password` VARCHAR(150) NOT NULL COMMENT '유저 패스워드',
6 `phoneNumber` VARCHAR(150) NOT NULL COMMENT '유저 전화번호',
7 `address` VARCHAR(300) NOT NULL COMMENT '유저 주소',
8 `detailAddress` VARCHAR(300) NOT NULL COMMENT '유저 주소',
9 `zipCode` VARCHAR(50) NOT NULL COMMENT '유저 주소',
10
11 PRIMARY KEY (`memberSequence`));
12
13CREATE TABLE `practice`.`products` (
14
15 `productId` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '상품 번호',
16 `productName` VARCHAR(150) NOT NULL COMMENT '상품 이름',
17 `sellerId` VARCHAR(100) NOT NULL COMMENT '작성자',
18 `image` VARCHAR(200) NOT NULL COMMENT '상품 이미지',
19 `price` BIGINT NOT NULL COMMENT '상품가격',
20 `description` VARCHAR(500) NOT NULL COMMENT '상품설명',
21 `registeredDate` DATE NOT NULL COMMENT '등록일자',
22 `categoryId` BIGINT(20) NOT NULL COMMENT '카테고리 번호',
23
24 PRIMARY KEY (`productId`));

상품을 의미하는 Product 클래스는 판매자를 의미하는 Member 클래스를 값 타입으로 가지고 있다. Product 클래스에 값들을 맵핑시키려면 products 테이블과 members 테이블을 JOIN한 결과값을 Product 클래스에 맵핑하면 된다.

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
3 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
4
5<mapper namespace="com.example.practice.product.domain.ProductRepository">
6
7 <resultMap id="Product" type="com.example.practice.product.domain.Product">
8 <id property="productId" column="productId"/>
9 <result property="productName" column="productName"/>
10 <result property="image" column="image"/>
11 <result property="description" column="description"/>
12 <result property="registeredDate" column="registeredDate"/>
13 <association property="seller" javaType="com.example.practice.member.domain.Member">
14 <id property="memberSequence" column="memberSequence"/>
15 <result property="memberId" column="memberId"/>
16 </association>
17 </resultMap>
18
19 <select id="findById" parameterType="long" resultMap="Product">
20 SELECT
21 p.productId,
22 p.productName,
23 p.image,
24 p.description,
25 p.categoryId,
26 m.memberSequence,
27 m.memberId
28 FROM products p INNER JOIN members m
29 ON p.sellerId = m.memberId
30 WHERE p.productId = #{productId}
31 </select>
32
33</mapper>

<테스트결과> association

3-2) has many 관계(one to many) one to many의 관계에서는 insert와 select 두 가지 과정에 대해 살펴보겠다.

  • INSERT 과정
1public class Order {
2
3 private Long orderId;
4 private Member buyer;
5 private Money totalPrice;
6 private List<OrderLine> orderLines;
7
8 public int countOrderLines() {
9 return orderLines.size();
10 }
11 //생략...
12}
13public class OrderLine {
14
15 private Long orderLineId;
16 private Product product;
17
18}
1CREATE TABLE `practice`.`orders` (
2
3 `orderId` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '구매 번호',
4 `memberId` VARCHAR(150) NOT NULL COMMENT '구매자',
5 `totalPrice` BIGINT(20) NOT NULL COMMENT '구매 총 금액',
6
7 PRIMARY KEY (`orderId`)
8);
9
10CREATE TABLE `practice`.`order_lines` (
11
12 `orderLineId` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '구매목록 번호',
13 `orderId` BIGINT(20) NOT NULL COMMENT '구매 번호',
14 `productId` BIGINT(20) NOT NULL COMMENT '구매자',
15
16 PRIMARY KEY (`orderLineId`)
17);

주문을 의미하는 Order는 1개 이상의 상품을 구매 할 수 있다. 하나의 주문은 여러개의 주문상품을 가질 수 있기때문에 one to many의 관계를 가진다. 여기서 새로운 order가 insert 될 경우, 주문을 의미하는 orders 테이블에 저장이 되야하며, 해당 주문에 포함되어있는 상품목록들을 order_lines 테이블에 저장해야한다.

1@Mapper
2@Repository
3public interface OrderRepository {
4 Long saveOrder(Order order);
5 void saveOrderLines(Order order);
6}
1<insert id="saveOrder" parameterType="com.example.practice.order.domain.Order"
2 useGeneratedKeys="true" keyProperty="orderId" >
3 INSERT INTO orders
4 (orderId, memberId, totalPrice)
5 VALUES
6 (#{orderId}, #{buyer.memberId}, #{totalPrice.price})
7 </insert>
8
9 <insert id="saveOrderLines" parameterType="order">
10 INSERT INTO order_lines
11 (orderId, productId)
12 VALUES
13 <foreach collection="orderLines" item="orderLine" open="(" separator="),(" close=")">
14 #{orderId}, #{orderLine.product.productId}
15 </foreach>
16 </insert>

이렇게 OrderRepository에 order를 insert하는 메서드와 주문목록인 orderLines를 insert를 하는 메서드 두 개를 만들어 테이블에 저장할 수 있다. 하지만, 이런 일반적인 방법은 객체의 연관관계가 아닌 테이블의 연관관계에 따라 Repository를 사용하는 쪽에서 좀 더 구체적인 테이블 정보를 알아야하는 불편함이 있다.

1public class OrderRepositoryTest {
2 @Autowired
3 private OrderRepository orderRepository;
4
5 public void save() {
6 //OrderRepository를 사용하는 쪽에서 두 개의 메서드를 사용해야 정상적으로 두 테이블에 데이터가 저장된다.
7 //순서 또한 지켜줘야한다는 불편함이 있다.
8 orderRepository.saveOrder(order);
9 orderRepository.saveOrderLines(order);
10 }
11}

JPA처럼 루트 도메인격인 Order 객체를 insert하면 연관관계에 있는 하위 도메인도 insert를 하여 좀 더 추상화 될 수 있는 방법이 없을까 고민하다가 스택오버플로우에서 default 메서드를 사용하여 제공해주는 방법을 찾았다.

1@Mapper
2@Repository
3public interface OrderRepository extends OrderBaseSave {
4
5 Long saveOrder(Order order);
6 void saveOrderLines(Order order);
7
8 default void save(Order order) {
9 saveOrder(order);
10 saveOrderLines(order);
11 }
12}

(출처 : https://stackoverflow.com/questions/33028923/mybatis-inserts-one-to-many-relationship)

이렇게 하면 사용하는 쪽에서 save 메서드만 사용해도 두 테이블에 저장할 수 있게 된다.

1public class OrderRepositoryTest {
2 @Autowired
3 private OrderRepository orderRepository;
4
5 public void save() {
6 //이런 방법도 있었구나
7 orderRepository.save(order);
8 }
9}

하지만 saveOrder, saveOrderLines 두 개 메서드가 여전히 공개된 상태이기 때문에 사용하는 쪽에서 해당 메서드를 사용 할 문제가 있다. dfeault메서드를사용한방식

이 두 개의 메서드를 save라는 하나의 메서드로 사용하는 쪽에 제공하려면, 상속 구조를 사용하면 된다.

1public interface OrderBaseSave {
2
3 Long saveOrder(Order order);
4 void saveOrderLines(Order orderLines);
5
6}
7
8@Mapper
9@Repository
10public interface OrderRepository extends OrderBaseSave {
11
12 default void save(Order order) {
13 saveOrder(order);
14 saveOrderLines(order);
15 }
16}

이 방법으로 Repository를 사용하는 쪽에서는 테이블의 연관관계에 상관없이 save 메서드만 사용해서 객체와 연관된 객체들도 저장할 수 있게 된다.

  • SELECT 과정
    one to many의 조회 결과는 테이블간의 Join 쿼리로 결과값에 대해 <collection> 태그를 사용하여 객체에 맵핑 할 수 있다.
1<mapper namespace="com.example.practice.order.domain.OrderRepository">
2 <resultMap id="order" type="order">
3 <id property="orderId" column="orderId"/>
4 <association property="buyer" javaType="com.example.practice.member.domain.Member">
5 <id property="memberSequence" column="memberSequence"/>
6 <result property="memberId" column="buyerId"/>
7 </association>
8 <association property="totalPrice" resultMap="money" />
9 <collection property="orderLines" ofType="com.example.practice.order.domain.OrderLine">
10 <id property="orderLineId" column="orderLineId"/>
11 <association property="product" javaType="product">
12 <id property="productId" column="productId"/>
13 <result property="productName" column="productName"/>
14 </association>
15 </collection>
16 </resultMap>
17
18 <resultMap id="money" type="com.example.practice.product.domain.Money">
19 <constructor>
20 <arg column="totalPrice" javaType="java.math.BigInteger" ></arg>
21 </constructor>
22 </resultMap>
23
24 <select id="findByIdWithOrderLines" parameterType="java.lang.Long" resultMap="order">
25 SELECT
26 o.orderId,
27 o.totalPrice,
28 m.memberSequence,
29 m.memberId AS buyerId,
30 l.orderLineId,
31 p.productId,
32 p.productName
33 FROM orders o
34 INNER JOIN members m ON o.memberId = m.memberId
35 INNER JOIN order_lines l ON o.orderId = l.orderId
36 INNER JOIN products p ON l.productId = p.productId
37 WHERE
38 o.orderId = #{orderId}
39 </select>
40</mapper>

<collection> 태그의 ofType의 속성은 제네릭을 의미한다. List<OrderLine>의 형태이기 때문에 ofType에는 OrderLine 클래스를 입력하였다.

1public class OrderRepositoryTest {
2 @Test
3 public void findByOrderWithOrderLinesTest() {
4 //Given
5 Order order = OrderBuilder.provideOrder(5, seller);
6 orderRepository.save(order);
7
8 //When
9 Order savedOrder = orderRepository.findByIdWithOrderLines(order.getOrderId());
10
11 //Then
12 assertAll(
13 () -> assertThat(order).isEqualTo(savedOrder),
14 () -> assertThat(order.countOrderLines()).isEqualTo(savedOrder.countOrderLines())
15 );
16 }
17}

<테스트 결과> collection결과 orderLines 필드에 값들이 잘 들어온 걸 확인 할 수 있다.

3-3) 생성자를 통한 객체 맵핑
Product 객체 내부에는 Money라는 객체가 있다. 이 Money 객체는 양수의 값만 받을 수 있도록 생성자에 제약사항을 걸어놨다. (Java bean 규약에 따르는 기본생성자가 있어야 Mapping이 가능하여 private 생성자를 추가하였다 )

1public class Product {
2
3 private Long productId;
4 private String productName;
5 private Money price;
6 // 생략..
7}
8
9public class Money {
10
11 private static final int NEGATIVE_OR_ZERO = 1;
12 private BigInteger price;
13
14 private Money(){}
15
16 public Money(final BigInteger price) {
17 if (price.signum() != NEGATIVE_OR_ZERO) {
18 throw new InvalidMoneyPriceException();
19 }
20 this.price = price;
21 }
22
23 public BigInteger getPrice() {
24 return price;
25 }
26}

DB에서 조회 후 해당 값을 Money 객체의 생성자 파라미터로 넘겨주려면 <constructor>태그를 사용하여 인스턴스 생성시에 조회 된 결과값을 파라미터로 넘겨줄 수 있다.

1<resultMap id="Product" type="com.example.practice.product.domain.Product">
2 <id property="productId" column="productId"/>
3 <result property="productName" column="productName"/>
4 <association property="price" javaType="com.example.practice.product.domain.Money">
5 <constructor>
6 <arg column="price" javaType="java.math.BigInteger" ></arg>
7 </constructor>
8 </association>
9 <!-- 생략 -->
10 </resultMap>

<arg>태그를 사용하여 Mapping된 결과값을 생성자 파라미터로 넘겨주면, Money 객체 생성자에 있는 음수값에 대한 유효성검사 를 거치게된다.

만약 데이터베이스에 강제로 실수로 음수값을 insert했다고 가정해보겠다.

음수값 가격이 -1인 상품은 도메인 규칙에 어긋난다. 위의 생성자 태그를 사용하여 가격이 -1인 상품을 가져 올 경우 리플렉션 과정 중 도메인 예외가 발생하여 사전에 불필요한 값을 가져오지 못하도록 미연의 방지를 할 수 있게 된다.

1public class ProductRepositoryTest extends ProductDomainBuilder {
2 @Test
3 public void findByIdThenFail(){
4 assertThatThrownBy(() ->
5 productRepository.findById(46L).get()
6 ).isInstanceOf(MyBatisSystemException.class);
7 }
8}
4. 정리

Mybatis의 연관관계 맵핑에 가장 기본적인 요소들만 정리하였다. Mybatis는 한글 공식문서를 지원하기 때문에 기본적인 맵핑 방식에 대해서는 쉽게 자료를 구할 수 있어, 어렵지 않게 구현할 수 있었다. 다음에는 Enum타입에 대한 맵핑방식과 Lazyloading에 대한 맵핑 방식에 대해서 정리해봐야겠다.

[Refference]