MyBatis란?
MyBatis는 JdbcTemplate이 제공하는 대부분의 기능을 제공한다. 그러나 JdbcTemplate의 문제점 중 하나인 여러 줄의 String 형 sql을 작성해야 한다는 점을 MyBatis를 이용하여 해결할 수 있다.
MyBatis는 SQL을 xml에 편리하게 작성할 수 있고, xml 내에서 동적 쿼리를 매우 편리하게 작성할 수 있다.
JdbcTemplate은 스프링에 내장된 기능이고, 별도의 설정 없이 사용할 수 있지만, MyBatis는 약간의 설정이 필요하다. 따라서 동적쿼리와 복잡한 쿼리를 많이 사용하는 경우에는 MyBatis를 사용하고, 단순한 쿼리들이 많다면 JdbcTemplate을 사용하는 것이 좋을 것이다.
2024.07.31 추가
단순한 도메인 관련 엔티티를 불러오는 경우 JPA를 사용하는 것을 권장한다.
그러나 JPA를 사용하다 보면 JOIN을 통해 하나의 쿼리로 불러올 수 있는 것들을 어쩔 수 없이 여러 개의 쿼리를 사용해야 하거나, JPQL을 복잡하게 작성하거나, 심지어는 JPQL로 불가한 경우도 있다.
이럴 땐 MyBatis를 사용해서 JOIN 쿼리를 작성해서 해결하는 것이 좋은 것 같다.
사용 설정
build.gradle
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
//스프링부트 3.0 이상
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.1'
application.properties
mybatis.type-aliases-package=hello.board.domain
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.board.repository.mybatis=trace
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package
MyBatis에서 타입 정보를 사용할 때는 패키지 이름을 적어주어야 하는데, 여기에 명시하면 패키지 이름을 생략할 수 있다. 지정한 패키지와 하위 패키지가 인식된다. 여러 위치를 지정하려면 ' ; ' 또는 ' , '으로 구분할 수 있다.
mybatis.configuration.map-underscore-to-camel-case
JdbcTemplate의 BeanPropertyRowMapper에서 처럼 snake_case를 camelCase로 자동 변경해 주는 기능을 활성화한다.
logging.level.hello.board.repository.mybatis=trace
MyBatis에서 실행되는 쿼리 로그를 찍을 수 있다.
2024.07.31 추가
mybatis.mapper-locations=classpath:mapper/*.xml
mapper.xml 파일을 인식할 경로를 지정한다. (아래에서 다시 다룸)
관례의 불일치 문제(trouble shooting)
자바 객체에는 주로 camelCase를 사용하고, 관계형 데이터베이스에서는 주로 snake_case를 사용한다.
따라서 JdbcTemplate의 BeanPropertyRowMapper를 사용하거나, MyBatis에서 mybatis.configuration.map-underscore-to-camel-case=true와 같은 설정을 해줘야 한다. 이 설정을 하면 자바의 writerId와 같은 필드를 관계형 DB의 writer_id로 바꿔주는 등의 기능이 동작한다.
따라서 자바에서 DB를 사용할 경우 웬만하면 camelCase를 사용하는 게 좋겠다.
post 클래스의 create_date , modified_date 필드를 snake_case로 사용했었는데, 오류가 발생했었다. 이 필드를 createDate, modifiedDate로 바꾸니 오류가 없어졌다. 문제를 해결하다 보니 자바 클래스의 필드를 snake_case로 하면 Lombok으로 생성한 getter setter가 getCreate_date와 같이 생성되어, MyBatis의 mapper에서 getCreateDate를 찾지 못하여 발생하는 것인 것 같다.
2024.07.31 추가
요즘엔 chat GPT가 이런 에러는 쉽게 찾아주는 것 같다.
Java에서는 camelCase가 디폴트니까 무조건 camelCase를 쓰고,
mybatis.configuration.map-underscore-to-camel-case=true <- 이 설정을 잊지 말자.
MyBatis 적용
Mapper 인터페이스
@Mapper 애노테이션을 붙여 주어 MyBatis에서 인식할 수 있도록 한다.
이 인터페이스가 xml 파일과 연결되어 DB에 쿼리를 날리는 역할을 수행한다.
@Mapper
public interface PostMapper {
void save(Post post);
Optional<Post> findById(Long id);
List<Post> findAll(Searchform form);
void updatePost(@Param("id") Long id, @Param("updateParam") Post updateParam);
void addView(Long postId);
void deletePost(Long postId);
}
매개변수가 하나인 경우 그냥 써도 되고, 두 개 이상인 경우 @Param 애노테이션을 이용하여 xml파일에 파라미터를 바인딩해 준다.
예를 들어 Long id의 경우 xml에서 #{id}로 사용하고, Post updateParam의 경우 xml에서 #{updateParam.title} 등으로 사용한다.
Mapper를 주입받은 Repository
외부에서 직접 사용하는 Repository 클래스이다.
Repository에서는 위의 Mapper 인터페이스를 주입받아서 사용하면 된다.
@Repository
@RequiredArgsConstructor
public class MyBatisPostRepository implements PostRepository {
private final PostMapper postMapper;
@Override
public Post save(Post post) {/*코드 생략, 아래에서 다룸*/ }
@Override
public Optional<Post> findById(Long id) {/*코드 생략, 아래에서 다룸*/ }
@Override
public List<Post> findAll(Searchform form) {/*코드 생략, 아래에서 다룸*/ }
@Override
public Post updatePost(Long id, Post updateParam) {/*코드 생략, 아래에서 다룸*/ }
@Override
public void addView(Long postId) {/*코드 생략, 아래에서 다룸*/ }
@Override
public void deletePost(Long postId) {/*코드 생략, 아래에서 다룸*/ }
}
Mapper.xml
xml 파일을 생성할 때 가장 중요한 점은 경로를 알맞게 설정하는 것이다.
만약 @Mapper 애노테이션이 붙은 PostMapper.java가 java 폴더아래에 hello.board.repository.mybatis에 존재한다면, 해당 Mapper과 매칭되는 PostMapper.xml 파일은 resources 폴더 아래에 hello.board.repository.mybatis에 만들어야 한다.
2024.07.31 수정
application.properties에 아래와 같이 mapper.xml 파일의 경로를 명시할 수 있다.
이렇게 하면 번거롭게 resources 폴더 아래 동일한 패키지를 만들 필요 없이, 폴더를 하나 만들어서 관리할 수 있다.
resources/mapper 디렉터리에 .xml 파일을 mapper 파일로 지정한다.mybatis.mapper-locations=classpath:mapper/*.xml
mapper.xml 에 interface Mapper 위치 명시
또한 아래 코드의 4번째 줄에 해당 PostMapper.java 인터페이스의 경로를 명시해줘야 한다.
<?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="hello.board.repository.mybatis.PostMapper">
<!-- sql 관련 코드 -->
</mapper>
Code 예제
PostRepository github 링크
https://github.com/HSRyuuu/my_first_board/tree/master/src/main/java/hello/board/repository/mybatis
이제 위에서 생략한 상세 코드를 알아보자.
코드블록에서 위에서부터 Mapper 인터페이스 코드, xml 코드, Repository 코드 순으로 보면 된다.
Repository에서 post객체를 Mapper로 넘겨주면 Mapper에서 해당 데이터를 xml로 보낸 후 MyBatis에서 인식하여 SQL쿼리를 날리는 형식이다.
INSERT / generatedKey
//mapper
void save(Post post);
//xml
<insert id="save" useGeneratedKeys="true" keyProperty="id">
INSERT INTO post( writer_id, title, content, create_date, modified_date, views)
values (#{writerId}, #{title}, #{content}, #{createDate}, #{modifiedDate}, #{views})
</insert>
//repository
@Override
public Post save(Post post) {
post.setCreateDate(LocalDateTime.now());
post.setModifiedDate(LocalDateTime.now());
postMapper.save(post);
return post;
}
- <insert> 태그 내의 id에는 Mapper인터페이스에 설정한 메서드 이름을 지정하면 된다.
- #{ }를 사용하면 Jdbc의 '? '를 치환하는 것처럼 PreparedStatement를 사용하여 파라미터를 바인딩한다.
- useGeneratedKeys는 DB에서 키를 자동 증가시킬 때 사용한다.
- keyProperty는 생성되는 키의 attribute 이름을 지정한다.
MyBatis에서는 Insert가 끝나면 입력받은 post객체의 id 값을 자동으로 적용한다.
따라서 아래와 같이 저장한 후 바로 post를 반환해도 post객체에 id 값이 적용되어 있다.
2024.07.31
요런 간단한 insert의 경우와 아래에서 다룰 update, delete의 경우에는 JPA를 사용하는 게 나은 것 같다.
SELECT
//Mapper
Optional<Post> findById(Long id);
//xml
<select id="findById" resultType="Post">
SELECT id, writer_id, title, content, create_date, modified_date, views
FROM post
where id = #{id}
</select>
//Repository
@Override
public Optional<Post> findById(Long id) {
return postMapper.findById(id);
}
- xml의 resultType은 반환 형을 명시해 준다. 여기서는 Post 객체에 매핑한다.
UPDATE
//Mapper
void updatePost(@Param("id") Long id, @Param("updateParam") Post updateParam);
//xml
<update id="updatePost">
UPDATE post
SET title=#{updateParam.title},
content=#{updateParam.content},
modified_date=#{updateParam.modifiedDate}
WHERE id=#{id}
</update>
//Repository
@Override
public Post updatePost(Long id, Post updateParam) {
updateParam.setModifiedDate(LocalDateTime.now());
postMapper.updatePost(id,updateParam);
return findById(id).get();
}
DELETE
DELETE는 간단하다.
//Mapper
void deletePost(Long postId);
//xml
<delete id="deletePost">
DELETE FROM post
WHERE id=#{postId}
</delete>
//Repository
@Override
public void deletePost(Long postId) {
postMapper.deletePost(postId);
}
SELECT 동적 쿼리
SearchForm은 searchCode와 searchWord로 구성되어 있다.
searchWord가 비어있을 때는 모든 post 조회하고, searchWord를 입력받았을 때는 해당 searchCode와 searchWord에 맞는 모든 post를 조회한다.
List<Post> findAll(Searchform form);
<select id="findAll" resultType="Post">
select id, writer_id, title, content, create_date, modified_date, views
from post
<where>
<if test="searchCode=='title' and searchWord !=null and searchWord !=''">
LOWER(title) LIKE LOWER(concat('%',#{searchWord},'%'))
</if>
<if test="searchCode=='content' and searchWord !=null and searchWord !=''">
LOWER(content) LIKE LOWER(concat('%',#{searchWord},'%'))
</if>
<if test="searchCode=='writerId' and searchWord !=null and searchWord !=''">
LOWER(writer_id)=LOWER(#{searchWord})
</if>
<if test="searchCode=='titleAndContent' and searchWord !=null and searchWord !=''">
LOWER(title) LIKE LOWER(concat('%',#{searchWord},'%'))
or LOWER(content) LIKE LOWER(concat('%',#{searchWord},'%'))
</if>
</where>
</select>
<where> , <if>와 같은 동적 쿼리 문법을 이용하여 편리하게 동적 쿼리를 사용할 수 있다.
<if>는 조건이 만족하면 해당 구문을 추가한다.
위의 코드는 항상 4개의 <if> 중 하나만을 만족하지만, 여러 개의 조건을 and 키워드로 연결하는 경우 if문 안의 SQL을 and로 시작하고, <if> 중 하나라도 성공하면 <where>은 처음 등장하는 and를 where로 바꿔준다.
@Override
public List<Post> findAll(Searchform form) {
return postMapper.findAll(form);
}
Repository에서는 똑같이 Mapper.findAll에 form 객체만 넘겨주면 된다.
복잡한 JOIN SELECT 쿼리
총 4개의 테이블을 JOIN 하는 경우의 예제이다.
//Mapper
List<BlogPostWithTagString> findPostsByMemberId(@Param("memberId") Long memberId, @Param("page") int page, @Param("pageSize") int pageSize);
//xml
<select id="findPostsByMemberId" resultType="BlogPostWithTagString">
SELECT
p.post_id AS postId,
p.member_id AS memberId,
m.profile_image AS profileImage,
m.account_name AS accountName,
p.title AS title,
p.thumbnail AS thumbnail,
p.introduction AS introduction,
p.likes AS likes,
p.views AS views,
p.comments AS comments,
p.registered_at AS registeredAt,
GROUP_CONCAT(t.tag_name ORDER BY t.tag_name ASC SEPARATOR ', ') AS tags
FROM
post p
JOIN
member m
ON p.member_id = m.member_id
LEFT JOIN
post_tag pt
ON p.post_id = pt.post_id
LEFT JOIN
tag t
ON pt.tag_id = t.tag_id
WHERE
p.member_id = #{memberId}
GROUP BY
p.post_id
limit #{page}, #{pageSize}
</select>
//Repository
@Override
public List<BlogPostWithTagString> findByMemberId(Long memberId, int page, int pageSize) {
return blogPostMapper.findPostsByMemberId(memberId,page,pageSize);
}
아래 문제를 해결하기 위해 JPA와 MyBatis를 혼용해서 사용했다.
[Trouble Shooting] - [쿼리 튜닝] JPA와 MyBatis를 혼용하여 쿼리 수를 줄여보자
Configuration
Mapper를 주입받아 Repository를 빈에 등록할 때 매개변수로 넣어주어야 한다.
@Configuration
@RequiredArgsConstructor
public class MyBatisConfig {
private final PostMapper postMapper;
@Bean
public PostRepository postRepository(){
log.info("MyBatisPostRepository Config");
return new MyBatisPostRepository(postMapper);
}
}
2024.07.31 추가
이 부분은 PostRepository interface를 구현한 클래스가 여러 개일 때 직접 Bean 등록을 하는 경우 Config 파일에서 지정하는 부분이다.
구현체가 하나인 경우 MyBatisPostRepository에 @Repository 어노테이션을 붙여서 빈으로 등록하고, 위의 코드는 생략할 수 있다.
'Database > 데이터 접근 기술' 카테고리의 다른 글
[SQL/Mysql, MariaDB] 드라이버 연결 (0) | 2023.07.25 |
---|---|
[MyBatis] 동적쿼리, 기타 문법 (0) | 2023.05.15 |
[Jdbc] JdbcTemplate 사용법 및 적용예제 (0) | 2023.05.04 |
[JDBC] 커넥션 풀, DataSource (0) | 2023.05.01 |
[JDBC] 자바 데이터베이스 표준 (0) | 2023.05.01 |