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을 작성할 수 있습니다.