10.2 MyBatis 실전 CRUD 예제
MyBatis를 사용하여 기본적인 데이터 조회뿐만 아니라 삽입, 수정, 삭제(CRUD)를 처리하는 전체 흐름을 상세 예제와 함께 알아봅니다.
1. 도메인 모델 (VO/DTO)
DB 컬럼과 매핑될 자바 객체입니다. application.yml의 map-underscore-to-camel-case: true 설정을 활용하면 편리합니다.
@Getter @Setter
public class PostVo {
private Long id;
private String title;
private String content;
private String author;
private LocalDateTime createdAt;
}
2. Mapper 인터페이스 (@Mapper)
추상 메서드에 SQL을 직접 작성하지 않고, 메서드 이름으로 XML의 SQL ID와 연결합니다.
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface PostMapper {
// 1. 등록 (C)
void insertPost(PostVo post);
// 2. 단건 조회 (R)
PostVo findPostById(Long id);
// 3. 전체 목록 조회 (R)
List<PostVo> findAllPosts();
// 4. 수정 (U)
void updatePost(PostVo post);
// 5. 삭제 (D)
void deletePost(Long id);
}
3. MyBatis XML 매퍼 (PostMapper.xml)
SQL 문을 정의하는 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.example.mapper.PostMapper">
<!-- 1. 등록: useGeneratedKeys를 통해 DB가 생성한 ID를 객체에 다시 넣어줌 -->
<insert id="insertPost" parameterType="PostVo" useGeneratedKeys="true" keyProperty="id">
INSERT INTO posts (title, content, author, created_at)
VALUES (#{title}, #{content}, #{author}, NOW())
</insert>
<!-- 2. 단건 조회 -->
<select id="findPostById" resultType="PostVo">
SELECT * FROM posts WHERE id = #{id}
</select>
<!-- 3. 목록 조회 -->
<select id="findAllPosts" resultType="PostVo">
SELECT * FROM posts ORDER BY id DESC
</select>
<!-- 4. 수정: 동적 쿼리를 사용하면 특정 필드만 골라서 업데이트 가능 -->
<update id="updatePost" parameterType="PostVo">
UPDATE posts
<set>
<if test="title != null">title = #{title},</if>
<if test="content != null">content = #{content},</if>
</set>
WHERE id = #{id}
</update>
<!-- 5. 삭제 -->
<delete id="deletePost">
DELETE FROM posts WHERE id = #{id}
</delete>
</mapper>
4. 고급 옵션 및 태그 활용 예제
실무에서 자주 사용되는 주요 옵션들과 태그들의 구체적인 사용 예시입니다.
1) useGeneratedKeys & keyProperty
데이터를 삽입(INSERT)한 후, DB에서 생성된 자동 증가 ID값을 자바 객체에 즉시 채워줍니다.
<insert id="insertUser" parameterType="UserVo" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (name, email) VALUES (#{name}, #{email})
</insert>
- 결과: 실행 후
userVo.getId()를 호출하면 DB에 저장된 ID값을 바로 확인할 수 있습니다.
2) <selectKey> 활용 (Oracle 등 시퀀스 방식)
자동 증가 컬럼이 없는 DB에서 삽입 전이나 후에 특정 값을 조회하여 필드에 담을 때 사용합니다.
<insert id="insertLog" parameterType="LogVo">
<selectKey keyProperty="id" resultType="long" order="BEFORE">
SELECT log_seq.NEXTVAL FROM DUAL
</selectKey>
INSERT INTO system_logs (id, message) VALUES (#{id}, #{message})
</insert>
- order="BEFORE": 메인 쿼리 실행 전에 시퀀스 번호를 먼저 따서 객체의
id필드에 넣어줍니다.
3) <sql> 과 <include> (코드 중복 제거)
여러 쿼리에서 공통으로 사용되는 컬럼 목록 등을 정의해두고 재사용합니다.
<!-- 공통 SQL 조각 정의 -->
<sql id="postColumns">
id, title, content, author, created_at
</sql>
<select id="findAllPosts" resultType="PostVo">
SELECT <include refid="postColumns" />
FROM posts
</select>
<select id="findActivePosts" resultType="PostVo">
SELECT <include refid="postColumns" />
FROM posts
WHERE status = 'ACTIVE'
</select>
5. Mapper 태그 및 주요 속성 상세 설명
MyBatis XML 매퍼에서 사용되는 주요 태그와 속성들의 의미는 다음과 같습니다.
1) <mapper> 태그
- namespace: 연결할 Mapper 인터페이스의 전체 경로(Full Qualifier)를 지정합니다. 이 경로가 틀리면 인터페이스와 XML이 연결되지 않습니다.
2) 공통 속성 (select, insert, update, delete)
- id: Mapper 인터페이스의 메서드명과 일치해야 합니다.
- parameterType: 파라미터 클래스명 (생략 가능).
<select id="getName" parameterType="long" resultType="string">
SELECT name FROM users WHERE id = #{id}
</select>
3) <select> 전용 속성
- resultType: 결과 행을 매핑할 자바 객체 타입.
- resultMap: 복잡한 매핑이나 조인이 필요할 때 사용.
4) <insert> 전용 속성
- useGeneratedKeys: 자동 증가 키 사용 여부.
- keyProperty: 생성된 키를 담을 필드명.
<insert id="join" useGeneratedKeys="true" keyProperty="no">
INSERT INTO members (name) VALUES (#{name})
</insert>
5) 동적 SQL 태그
<if>: 조건부 SQL 포함.
<where>
<if test="email != null">AND email = #{email}</if>
</where>
<set>: 업데이트 시 콤마 자동 정리.<where>: WHERE 절 및 AND/OR 자동 관리.<foreach>: 리스트나 배열 같은 컬렉션을 순회하며 쿼리를 생성합니다.
<foreach> 실전 예제 (Batch Insert & IN 절)
<!-- 1. 리스트를 이용한 다중 삭제 (IN 절) -->
<delete id="deleteUsers">
DELETE FROM users
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<!-- 2. 다중 데이터 일괄 삽입 (Bulk Insert) -->
<insert id="insertUserList">
INSERT INTO users (name, email)
VALUES
<foreach item="user" collection="list" separator=",">
(#{user.name}, #{user.email})
</foreach>
</insert>
- collection: 전달받은 컬렉션 파라미터명 (
list,array등). - item: 루프 내에서 사용할 변수명.
- separator: 항목 사이의 구분자 (대개 콤마).
- open / close: 전체 문장을 감쌀 시작과 끝 문자.
6. 서비스 로직 연동
컨트롤러나 서비스에서 Mapper를 주입받아 사용합니다.
@Service
@RequiredArgsConstructor
public class PostService {
private final PostMapper postMapper;
@Transactional
public void create(PostVo vo) {
postMapper.insertPost(vo);
System.out.println("생성된 ID: " + vo.getId());
}
public List<PostVo> getList() {
return postMapper.findAllPosts();
}
}
7. MyBatis 페이징 처리 (Pagination)
MyBatis는 JPA처럼 자동 페이징 기능이 없으므로, SQL의 LIMIT와 OFFSET을 직접 다루거나 별도의 플러그인(PageHelper 등)을 사용합니다. 여기서는 가장 표준적인 LIMIT/OFFSET 방식 을 설명합니다.
7.1 Mapper 인터페이스
@Mapper
public interface PostMapper {
// 페이징된 목록 조회
List<PostVo> findPostsWithPaging(@Param("offset") int offset, @Param("size") int size);
// 전체 개수 조회 (페이징 메타데이터 생성용)
int countPosts();
}
7.2 XML 매퍼 (MySQL/PostgreSQL 기준)
<select id="findPostsWithPaging" resultType="PostVo">
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT #{size} OFFSET #{offset}
</select>
<select id="countPosts" resultType="int">
SELECT COUNT(*) FROM posts
</select>
7.3 페이징 공식 및 서비스 로직
offset = (page - 1) * size- 예: 2페이지(page=2)이고 페이지당 10개(size=10)라면
offset = 10(11번째 데이터부터 시작)
@Service
public class PostService {
public Map<String, Object> getPagedPosts(int page, int size) {
int offset = (page - 1) * size;
List<PostVo> posts = postMapper.findPostsWithPaging(offset, size);
int totalCount = postMapper.countPosts();
Map<String, Object> result = new HashMap<>();
result.put("items", posts);
result.put("total", totalCount);
result.put("currentPage", page);
result.put("totalPage", (int) Math.ceil((double) totalCount / size));
return result;
}
}
7.4 PageHelper 라이브러리 활용 (추천)
매번 OFFSET 계산과 COUNT 쿼리를 직접 작성하기 번거롭다면, MyBatis의 가장 유명한 페이징 플러그인인 PageHelper 를 사용하는 것이 좋습니다.
의존성 추가 (build.gradle)
implementation 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'
서비스 로직 구현
SQL을 수정하지 않고도 단 한 줄의 코드로 페이징을 구현할 수 있습니다.
public PageInfo<PostVo> getPostsWithPageHelper(int page, int size) {
// 1. 페이징 시작 설정 (이후 실행되는 첫 번째 조회 SQL에 자동으로 LIMIT/OFFSET이 붙음)
PageHelper.startPage(page, size);
// 2. 일반 목록 조회 (SQL에 LIMIT를 쓸 필요 없음)
List<PostVo> posts = postMapper.findAllPosts();
// 3. PageInfo 객체로 변환 (전체 페이지 수, 전체 데이터 수 등 모든 메타데이터 포함)
return new PageInfo<>(posts);
}
- 장점: SQL을 건드리지 않고 페이징을 처리할 수 있으며, 페이징에 필요한 모든 메타데이터를
PageInfo객체 하나로 응답할 수 있어 매우 편리합니다.
6) 기타 유용한 태그
<selectKey>:insert나update실행 전후에 특정 값을 조회하여 파라미터 객체의 필드에 설정합니다. 주로 Oracle의Sequence를 조회하거나, 방금 삽입된 행의 ID를 가져올 때 사용합니다.order="BEFORE": 메인 쿼리 실행 전에 수행order="AFTER": 메인 쿼리 실행 후에 수행
<sql>&<include>: 반복되는 SQL 조각(예: 테이블 컬럼 목록)을<sql>로 정의하고, 필요한 곳에서<include>로 재사용하여 코드 중복을 줄입니다.
7. 파라미터 바인딩: #{} vs ${}
MyBatis에서 파라미터를 SQL에 전달하는 방법은 두 가지가 있으며, 보안과 성능 면에서 큰 차이가 있습니다.
1) #{parameter} (PreparedStatement 방식)
- 특징: 데이터를 ** 바인딩 변수(?)**로 처리합니다.
- 보안: 값이 자동으로 따옴표(
' ')로 감싸지며, ** SQL Injection 공격을 방어**할 수 있습니다. - 사용 예: 일반적인 사용자 입력값 (이름, 아이디, 게시글 내용 등)
- 생성된 SQL:
SELECT * FROM users WHERE id = ?
2) ${parameter} (Statement 방식)
- 특징: 값을 SQL 문에 ** 문자열 그대로(Literal)**합칩니다.
- 위험성: 따옴표가 자동으로 붙지 않으며, 악의적인 SQL 코드가 포함될 경우 그대로 실행되는 ** SQL Injection 취약점**이 있습니다.
- 용도: 컬럼명이나 테이블명, 또는
ORDER BY절처럼 SQL 문법 자체를 동적으로 바꿔야 할 때 제한적으로 사용합니다. - 생성된 SQL:
SELECT * FROM users WHERE id = 1(숫자일 경우 그대로 대입)
⚠️ 예외 상황 및 주의사항
- 따옴표 누락 에러: 문자열 데이터를
${}로 넘기면 따옴표가 없어 SQL 문법 오류가 발생합니다. (예:WHERE name = User1→ 에러) - 보안 사고: 사용자가 입력한 검색어를
${}에 넣으면,'; DROP TABLE users; --같은 입력을 통해 DB가 파괴될 수 있습니다. - 결론: ** 가급적
#{}사용을 원칙**으로 하고, 정렬 조건 변경 등 특수한 경우에만${}를 쓰되 내부적으로 검증된 값만 허용해야 합니다.
🎯 핵심 요점
- useGeneratedKeys: INSERT 시 자동 생성된 ID값을 자바 객체에 즉시 반영할 때 사용합니다.
- ResultType: 조회 결과를 담을 클래스의 경로를 지정합니다.
- # vs $: 보안을 위해 항상 # 를 최우선으로 사용하세요.
<set>,<if>: 동적 쿼리를 통해 효율적이고 유연한 SQL을 작성할 수 있습니다.
sidebar_position: 1
10.1 MyBatis 개요 및 XML 기법
JPA가 기본 CRUD와 객체 중심의 설계에 매우 강력하다면, MyBatis(마이바티스) 는 아주 복잡한 통계 쿼리를 짜거나, 동적 쿼리(조건에 따라 WHERE 절이나 ORDER BY가 바뀌는 경우), 또는 특정 벤더(PostgreSQL, Oracle 등)에 종속적인 네이티브 SQL 쿼리가 필요할 때 아주 유용하게 쓰이는 SQL 매퍼 프레임워크입니다.
실무 비즈니스 환경에서는 메인 데이터 조작을 JPA로 하고, 관리자 페이지용 복잡한 대시보드 통계 조회 쿼리 등에는 MyBatis를 혼용하여(Sub) 사용하는 전략이 대단히 많습니다.
의존성 추가와 설정
build.gradle에 MyBatis 연동 의존성을 추가합니다.
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
application.yml에 MyBatis 설정과 XML 파일 경로를 잡아줍니다.
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true # DB의 snake_case 컬럼 결과를 자바의 camelCase 필드에 자동 매핑시켜줍니다.
Mapper 인터페이스 설계
JPA와 비슷하게 MyBatis도 인터페이스를 생성합니다. 단, @Mapper 애너테이션을 달아주어 이 인터페이스의 구현체가 XML에 작성된 SQL이라는 것을 스프링에 선언합니다.
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UserMapper {
// 복잡한 조건(이름 일치 여부, 나이 범위 등)을 파라미터로 넘깁니다.
List<UserStatDto> searchUserStats(@Param("keyword") String keyword, @Param("minAge") Integer minAge);
}
MyBatis XML 매퍼 작성
이제 src/main/resources/mapper/UserMapper.xml 같이 XML 파일을 만들어 직접 네이티브 SQL을 꼼꼼하게 작성합니다. 이때 <if> 같은 동적 태그를 쓸 수 있다는 것이 MyBatis의 최대 장점입니다.
<?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">
<!-- 위의 자바 매퍼 인터페이스 경로를 namespace에 정확하게 적어야 합니다. -->
<mapper namespace="com.myapp.repository.UserMapper">
<!-- id 속성은 인터페이스의 메서드명과 일치해야 하며, resultType로 반환할 DTO의 풀 경로를 명시합니다. -->
<select id="searchUserStats" resultType="com.myapp.dto.UserStatDto">
SELECT user_id, user_email, calculated_score AS score
FROM users
WHERE 1=1
<!-- MyBatis의 동적 쿼리: keyword 파라미터가 null이 아니면 이 조건을 덧붙임 -->
<if test="keyword != null and keyword != ''">
AND nickname LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
ORDER BY created_at DESC
</select>
</mapper>
작성이 완료된 뒤 서비스 클래스에서 UserMapper를 주입받아 searchUserStats를 호출하면, 파라미터 조건에 따라 동적으로 조립된 SQL이 데이터베이스로 전송되고 결과가 DTO 리스트로 자동 변환되어 반환됩니다.