본문으로 건너뛰기

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

2. 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 리스트로 자동 변환되어 반환됩니다.