MyBatis 동작 원리를 이해하고 사용해보자

Belluga·2021년 7월 15일
8

MySQL 테이블 생성

create table MEMBER_INFO (
 id BIGINT AUTO_INCREMENT PRIMARY KEY,
 email VARCHAR(255),
 password VARCHAR(64),
 name VARCHAR(30),
 create_date DATETIME DEFAULT CURRENT_TIMESTAMP,
 role TINYINT DEFAULT 3,
 INDEX (email)
)engine=InnoDB default character set = utf8;

회원정보를 담고있는 MEMBER_INFO 테이블을 생성하도록 하겠습니다.
저는 id를 Primary key로 두고 자동으로 값이 증가할 수 있도록 AUTO_INCREMENT 키워드를 붙여주었습니다.

PK로 비즈니스 의미를 갖는 varchar type email 대신 자동으로 증가하는 big int를 사용한 이유는 아래와 같습니다.

  1. varchar type을 기본키로 사용한다면 데이터를 변경해야할 가능성이 있습니다.
    primary key는 데이터베이스 레코드를 식별할 수 있는 유일한 값이어야합니다.
    따라서 primary key는 다른 항목과 절대로 중복이 되어선 안되기 때문에 변경될 가능성이 높은 속성은 기본키로 선정하지 않는것이 좋습니다.
  • varchar은 white space를 가지기 때문에 사람은 구별할 수 없지만 컴퓨터는 다른 데이터로 인식하기 때문에 수정이 필요할 수 있습니다.
  • 데이터 자체가 부정확하거나 운영팀의 요구로 변경이 필요할 수 있습니다.
  1. 데이터베이스 성능에 이슈가 있을 수 있습니다.
  • primary key는 자동으로 클러스터드 인덱스가 생성됩니다.
    클러스터드 인덱스는 인덱스 컬럼을 기준으로 정렬된 상태를 유지하기 때문에 회원 레코드가 N건 존재하는 상황에서
    "a" email을 가지는 회원 레코드 추가시 N건의 데이터를 밀어내야합니다.

  • 또한 join 연산 시 수행능력이 좋아야 하는데, varchar은 integer에 비해 느립니다.

MyBatis

자바에서 데이터베이스 프로그래밍을 할 때 JDBC API를 사용합니다.
그러나 JDBC를 이용하려면 개발자가 작성해야 할 코드가 너무나도 많습니다.
JDBC를 사용하게 쉽게 만든 Mybatis는 SQL Mapper를 통해 자바의 메서드와 SQL을 맵핑해줍니다.

MyBatis3가 데이터베이스에 액세스하는 흐름은 위와 같습니다.
애플리케이션 시작 시 (1) ~ (3) 프로세스를 수행하며
클라이언트의 각 요청에 대해 (4) ~ (10) 프로세스가 수행됩니다.

애플리케이션 시작 시 SqlSessionFactoryBuilder가 MyBatis 설정 파일을 참고하여 SqlSessionFactory를 생성합니다.
이후 Application단에서 데이터 접근 작업시 SqlSessionFactory는 매 요청마다 SqlSession 객체를 생성합니다.
SqlSession 객체를 통해 DB 작업을 진행하는데, 작업시 수행하는 쿼리는 mapper 파일에 담겨있습니다.

DataSource

JDBC API를 이용하여 데이터베이스 연동을 처리하려면 반드시 데이터베이스로부터 커넥션을 얻어와야 한다.
이를 위해 데이터베이스와의 물리적인 연결을 지원하는 DataSource가 필요합니다.

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/flab?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=baesuyeon
spring.datasource.password=baesuyeon

Spring Boot에서는 Java Config 를 통해 DataSource를 설정할수도 있지만 application.properties 파일 내 spring.datasource.* 패턴으로도 설정이 가능합니다.

위와 같이 실제 접속을 위한 데이터베이스 ip, id, pw만 설정해주면 됩니다.

MySQL, MyBatis 의존성 추가

compile "org.springframework.boot:spring-boot-starter-jdbc"
compile group: 'mysql', name: 'mysql-connector-java'
compile group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-autoconfigure', version: '2.1.4'
compile group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-starter', version: '2.1.4'

먼저 MySQL, MyBatis 의존성을 추가해줍니다.

데이터베이스 연동시 SQL 실행이나 트랜잭션 제어를 위한 SqlSession 등 다양한 설정이 필요합니다.
MyBatis ↔ 스프링 연동 모듈을 지원하는 Spring Boot Starter MyBatis를 사용하면 복잡한 설정이 모두 자동화됩니다.

Mapper Interface

MyBatis3.0 부터 XML 파일(mapper.xml)또는 어노테이션에 기재된 SQL 쿼리 명령을 수행할 수 있는 Mapper 인터페이스를 사용할 수 있습니다.

Mapper 인터페이스 메서드 호출시 구현체가 SqlSession 메서드 호출을 통해 SQL 실행을 요청하면 SqlSession은 SQL을 실행합니다.

@Mapper
public interface MemberMapper {
  
}

Service의 요청을 처리해줄 Member Mapper 인터페이스를 만들어줍니다.

@Mapper 어노테이션을 통해 스프링부트가 해당 클래스를 mapper로 인식할 수 있으며 해당 interface를 MyBatis mapper 빈으로 등록해줄 수 있습니다.

@Mapper
public interface MemberMapper {

    @Insert("INSERT INTO MEMBER_INFO(email, password, name) VALUES(#{member.email}, #{member.password}, #{member.name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int create(@Param("member") Member member);
}

이제 MemberMapper에 Service 에서 호출할 create 메서드를 작성해보겠습니다.

매개변수를 받아올 때는 @Param("")을 통해서 값을 명시하고 #{}을 통해 동적 바인딩 처리를 할 수 있습니다.

Insert 후 Auto Inresement 설정으로 증가된 PK값을 가져오기 위해 @Options 어노테이션을 사용할 수 있습니다.
member 인스턴스 id property에 PK값을 셋팅해줍니다.

@Mapper
public interface MemberMapper {

    @Select("SELECT * FROM MEMBER_INFO WHERE id=#{id}")
    @Results({
        @Result(property="자바 객체 property 명", column="db 테이블 컬럼 명"),
        @Result(property="자바 객체 property 명", column="db 테이블 컬럼 명")
    })  
    Optional<Member> getById(@Param("id") String id);
}

맵핑할 DB 컬럼 명과 자바 필드가 다른 경우
@Results 어노테이션을 지정하고 그 안에 복수개의 컬럼 맵핑관계를 지정할 수 있습니다.

(자바 객체 property 명, db 테이블 컬럼 명)으로 맵핑합니다.

mybatis.configuration.map-underscore-to-camel-case=true

보통 DB 컬럼명은 스네이크 표기법을, 멤버 변수는 카멜케이스 표기법을 사용합니다.
이 경우 맵핑관계를 지정하지 않고 build.gradle 파일의 간단한 설정을 통해 해결할 수 있습니다.

@Mapper
public interface MemberMapper {

    @Select("SELECT * FROM MEMBER_INFO WHERE id=#{id}")
    @Results(id="MemberMap", value={
        @Result(property="자바 객체 property 명", column="db 테이블 컬럼 명"),
        @Result(property="자바 객체 property 명", column="db 테이블 컬럼 명")
    })  
    Optional<Member> getById(@Param("id") String id);
  
    @Select("SELECT * FROM MEMBER_INFO WHERE email=#{email}")
    @ResultMap("MemberMap")
    Optional<Member> getByEmail(@Param("email") String email);
}

반복적으로 동일한 Mapping 관계를 설정해야 하는 경우에는
@Results에 id값을 부여하여 재사용할 수 있습니다.

public class Company {
  
    private int id;
    private String name;
    private String address;
    private List<Employee> employeeList;
}

예시처럼 Company 클래스가 employList를 포함하고 있는 경우

public List<Company> getAll() {
    List<Company> companyList = companyMapper.getAll();
    for(Company company : companyList) {
            company.setEmployeeList(employeeMapper.getByCompanyId(company.getId()));
    }
}

서비스 레이어에서 처리할 수도 있지만

@Select("SELECT * FROM company")
@Results(id="CompanyMap", value={
    @Result(property="name", column="company_name"),
    @Result(property="address", column="company_address"),
    @Result(property="employeeList", column="id", many=@Many(select="com.example.demo.EmployeeMapper.getByCompanyId"))
})
List<Company> gettAll();

@Many 어노테이션 설정을 통해 Company의 id 컬럼을 이용해 서브쿼리를 수행할 수 있습니다.

Groovy Class

그러나 어노테이션의 속성값으로 SQL을 직접 넣어주는 경우 쿼리가 복잡할수록 가독성이 좋지않을 뿐만 아니라 관리가 힘들어집니다.

@Mapper
public interface MemberMapper {

    @InsertProvider(type = MemberSQL.class, method = "create")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int create(@Param("member") Member member);

    @SelectProvider(type = MemberSQL.class, method = "getById")
    Optional<Member> getById(@Param("id") String id);

    @SelectProvider(type = MemberSQL.class, method = "getByEmail")
    Optional<Member> getByEmail(@Param("email") String email);

    @SelectProvider(type = MemberSQL.class, method = "modifyById")
    void modifyById(@Param("id") String id, @Param("member") Member member);
}

따라서 SQL String을 생성하는 클래스를 분리하여 클래스와 메서드를 각각 type, method에 정의하였습니다.

class MemberSQL {

    public String create(@Param("member") Member member) {
        return new SQL() {{
            INSERT_INTO("MEMBER_INFO")
            VALUES("email, password, name", "#{member.email}, #{member.password}, #{member.name}")
        }}
    }

    public String getById(@Param("id") String id) {
        return new SQL() {{
            SELECT("*")
            FROM("MEMBER_INFO")
            WHERE("id=#{id}")
        }}
    }

    public String getByEmail(@Param("email") String email) {
        return new SQL() {{
            SELECT("*")
            FROM("MEMBER_INFO")
            WHERE("email=#{email}")
        }}
    }

    public String modifyById(@Param("id") String id, @Param("member") Member member) {
        return new SQL() {{
            UPDATE("MEMBER_INFO")
            SET("password=#{member.password}")
            SET("name=#{member.name}")
            WHERE("id=#{id}")
        }}
    }
}

정적 SQL의 경우 Multi Line String 기능(''' ''' 기호 사이에 여러 라인의 코드 작성 가능)을 활용할 수 있을것이라 생각하여 groovy Class로 생성하였습니다.
동적 SQL의 경우 생성시 org.apache.ibatis.jdbc.SQL 클래스를 사용하여 직관적으로 쿼리를 파악할 수 있도록 하였습니다.

위와 같이 MemberSQL.groovy 작성 후 아래 절차를 수행합니다.

  1. Groovy SDK 다운로드
    https://groovy.apache.org/download.html

  2. Groovy SDK 등록

plugins {
	id 'org.springframework.boot' version '2.4.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
	id 'groovy'
}
  
sourceSets {
	main {
		groovy {
			// this makes the groovy-compiler compile groovy- as well
			// as java-files.
			// Needed, because java is normally compiled before groovy.
			// Since we are using groovy objects from java, we need it
			// the other way round.
			srcDirs = ['src/main/groovy', 'src/main/java']
		}
		java {
			srcDirs = [] // don't compile Java code twice
		}
	}
}
  
dependencies {
    ...
    compile group: 'org.codehaus.groovy', name: 'groovy-all', version: '3.0.8'
}
  1. Groovy는 JVM에서 실행되는 스크립트 언어로 자바 클래스 파일로 컴파일 됩니다.
    groovy 의존성과 플러그인 추가, 자바 클래스보다 먼저 컴파일 될 수 있도록 설정하기 위해 build.gradle 파일을 수정합니다.

XML

복잡한 쿼리는 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.flab.demo.mapper.ProductMapper">
</mapper>

SQL Mapper.xml 파일은 <mapper>를 루트 엘리먼트로 사용합니다.
namespace는 Mapper Interface 경로와 맵핑합니다.

그리고 <insert> <update> <delete> <select> 엘리먼트를 이용하여 필요한 SQL 구문들을 등록합니다.
이때 id 속성은 필수 속성으로 네임스페이스 내부에서 유일한 아이디를 등록해야 합니다.

XML의 id 값과 Interface 내의 메서드명이 일치하면 자동으로 xml과 메서드를 맵핑되어 id로 등록된 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.flab.demo.mapper.ProductMapper">

    <resultMap type="com.flab.demo.domain.Product" id="product">
        <result property="id" column="id"/>
        <result property="productName" column="product_name"/>
        <result property="fixedPrice" column="fixed_price"/>
        <result property="sellerId" column="seller_id"/>
        <result property="salesYn" column="sales_yn"/>
        <result property="createDate" column="create_date"/>
    </resultMap>

    <select id="getById" parameterType="Long" resultMap="product">
        SELECT *
            FROM PRODUCT
            WHERE id = #{id}
    </select>
<?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.flab.demo.mapper.ProductMapper">

    <select id="getById" parameterType="Long" resultType="com.flab.demo.domain.Product">
        SELECT *
            FROM PRODUCT
            WHERE id = #{id}
    </select>
</mapper>

SELECT SQL 구문이 실행되면 ResultSet이 리턴되며, ResultSet에 저장된 검색 결과를 어떤 자바 객체에 맵핑해야 할지 지정해야 하는데,
이때 resultType, resultMap 속성을 사용할 수 있습니다.

<insert id="create" parameterType="com.flab.demo.domain.Product">
        INSERT INTO PRODUCT(product_name, fixed_price, seller_id)
        VALUES(#{productName}, #{fixedPrice}, #{sellerId})
</insert>

Mapper 파일에 등록된 SQL 실행 시 SQL 실행에 필요한 데이터를 외부로부터 받아야 할 상황이 있을 수 있습니다. 이때 parameterType 속성을 사용할 수 있습니다. 내부적으로 parameter 값을 가져올 수 있는 getter 메서드를 호출합니다.

mybatis.mapper-locations=mapper/*.xml
mybatis.type-aliases-package=패키지 경로

마이바티스 세팅을 위해 application.properties 파일을 수정합니다.

mybatis.mapper-locations : xml 파일이 위치한 경로/*.xml
mybatis mapper.xml 파일이 있는 폴더 경로를 설정해줍니다.
보통 classpath 를 통해 경로 설정을 해주는데, spring boot로 프로젝트 생성시 classpath는 resources 폴더 입니다.

mybatis.mapper-locations : 매퍼용 클래스가 위치한 경로
Mapper에서 resultType을 단순히 클래스 명으로만 적기 위해 필요합니다.
미설정시 전체 패키지 경로와 클래스명을 작성해야 합니다.

References

https://terasolunaorg.github.io/guideline/5.0.x/en/ArchitectureInDetail/DataAccessMyBatis3.html

https://www.youtube.com/watch?v=QzHkJsALmyw&t=1360s

https://devnumgo.tistory.com/entry/primary-key%EB%A5%BC-int%ED%98%95%EC%9C%BC%EB%A1%9C-%ED%95%A0-%EA%B2%83%EC%9D%B8%EA%B0%80-varchar%EB%A1%9C-%ED%95%A0-%EA%B2%83%EC%9D%B8%EA%B0%80

https://devlog-wjdrbs96.tistory.com/200?category=950438

https://jason-moon.tistory.com/130

https://khj93.tistory.com/entry/MyBatis-MyBatis%EB%9E%80-%EA%B0%9C%EB%85%90-%EB%B0%8F-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC

https://atoz-develop.tistory.com/entry/IntelliJ%EC%97%90%EC%84%9C-Groovy-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B3%A0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0

https://galid1.tistory.com/647

https://pangtrue.tistory.com/141

0개의 댓글