iBatis vs MyBatis 완전 비교 – 개념부터 실제 예제까지
들어가며
Java 기반 서버 애플리케이션을 개발하다 보면 데이터베이스 연동 방식에 대한 선택의 기로에 서게 된다. JPA와 같은 ORM이 대세처럼 자리잡은 것처럼 보이지만, 실제 현업에서는 여전히 SQL Mapper 방식이 광범위하게 사용된다. 복잡한 통계 쿼리, 다중 조인, 성능 최적화가 필요한 환경에서는 SQL을 직접 제어할 수 있는 SQL Mapper가 훨씬 현실적인 선택이기 때문이다.
SQL Mapper의 계보를 이야기할 때 빠질 수 없는 것이 바로 iBatis vs MyBatis다. 단순히 “예전 기술 vs 현재 기술”로 치부하기에는, 두 프레임워크의 설계 철학과 구현 방식 사이에 명확한 차이가 존재한다. 이 차이를 제대로 이해해야 레거시 시스템을 유지보수하거나 신규 프로젝트에 적합한 기술을 선택하는 데 있어 근거 있는 판단을 내릴 수 있다.
이 글에서는 iBatis와 MyBatis의 탄생 배경부터 핵심 기능 차이, 실제 코드 예제, 그리고 현업에서의 선택 기준까지 체계적으로 다룬다. 단순한 기능 나열이 아니라, 각 선택이 실제 프로젝트에 미치는 영향을 중심으로 서술한다.
iBatis와 MyBatis의 탄생 배경과 계보
iBatis는 2001년 Clinton Begin이 개발하고 이후 Apache Software Foundation에 기증한 오픈소스 SQL Mapper 프레임워크다. 당시 JDBC를 직접 사용하는 방식이 표준이었던 시대적 배경에서, iBatis는 SQL과 Java 객체 사이의 매핑을 XML로 정의함으로써 반복적인 JDBC 코드를 획기적으로 줄일 수 있는 대안으로 주목받았다.
2010년, iBatis 프로젝트는 Apache에서 Google Code로 이관되면서 이름을 MyBatis로 변경했다. 단순한 이름 변경이 아니라, 내부 아키텍처의 전면적인 재설계와 함께 이루어진 변화였다. 기존 iBatis가 안고 있던 타입 안정성 부재, 동적 SQL 처리의 불편함, IDE 지원 미흡 등의 문제를 근본적으로 해결하는 방향으로 발전이 이루어졌다.
현재 iBatis는 공식적으로 더 이상 유지보수되지 않는 레거시 기술로 분류된다. 보안 패치나 기능 업데이트가 이루어지지 않기 때문에, 신규 프로젝트에서 iBatis를 채택하는 것은 기술 부채를 처음부터 안고 시작하는 것과 마찬가지다. 반면 MyBatis는 현재까지 활발히 유지보수되고 있으며, Spring 생태계와의 통합, Kotlin 지원, 다양한 플러그인 확장 등을 통해 지속적으로 발전하고 있다.
두 프레임워크를 경쟁 관계로 보기보다는, iBatis라는 토대 위에 MyBatis가 진화한 것으로 이해하는 것이 정확하다. 현업에서 iBatis를 알아야 하는 이유는 아직도 많은 금융권, 공공기관 레거시 시스템이 iBatis 기반으로 운영되고 있기 때문이며, 이를 이해해야 점진적인 마이그레이션 전략을 수립할 수 있다.
iBatis의 구조와 핵심 문법
iBatis의 핵심 구조는 크게 세 가지로 나뉜다. SQL이 정의된 XML 매핑 파일, Java에서 SQL을 호출하는 SqlMapClient, 그리고 이 둘을 연결하는 SqlMapConfig.xml 설정 파일이다.
SqlMapConfig.xml 기본 설정:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMapConfig
PUBLIC "-//ibatis.apache.org//DTD SQL Map Config 2.0//EN"
"http://ibatis.apache.org/dtd/sql-map-config-2.dtd">
<sqlMapConfig>
<transactionManager type="JDBC">
<dataSource type="SIMPLE">
<property name="JDBC.Driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="JDBC.ConnectionURL" value="jdbc:mysql://localhost:3306/mydb"/>
<property name="JDBC.Username" value="myuser"/>
<property name="JDBC.Password" value="password"/>
</dataSource>
</transactionManager>
<sqlMap resource="com/example/mapper/UserMapper.xml"/>
</sqlMapConfig>
UserMapper.xml SQL 정의:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap
PUBLIC "-//ibatis.apache.org//DTD SQL Map 2.0//EN"
"http://ibatis.apache.org/dtd/sql-map-2.dtd">
<sqlMap namespace="User">
<!-- 단건 조회 -->
<select id="getUser" parameterClass="int" resultClass="com.example.model.User">
SELECT id, name, email, created_at
FROM users
WHERE id = #id#
</select>
<!-- 목록 조회 -->
<select id="getUserList" resultClass="com.example.model.User">
SELECT id, name, email
FROM users
ORDER BY created_at DESC
</select>
<!-- 등록 -->
<insert id="insertUser" parameterClass="com.example.model.User">
INSERT INTO users (name, email, created_at)
VALUES (#name#, #email#, NOW())
</insert>
<!-- 수정 -->
<update id="updateUser" parameterClass="com.example.model.User">
UPDATE users
SET name = #name#, email = #email#
WHERE id = #id#
</update>
<!-- 삭제 -->
<delete id="deleteUser" parameterClass="int">
DELETE FROM users WHERE id = #id#
</delete>
</sqlMap>
Java 호출 코드:
// SqlMapClient 초기화
Reader reader = Resources.getResourceAsReader("SqlMapConfig.xml");
SqlMapClient sqlMapClient = SqlMapClientBuilder.buildSqlMapClient(reader);
// 단건 조회
User user = (User) sqlMapClient.queryForObject("User.getUser", 1);
// 목록 조회
List<User> userList = (List<User>) sqlMapClient.queryForList("User.getUserList");
// 등록
sqlMapClient.insert("User.insertUser", newUser);
// 수정
sqlMapClient.update("User.updateUser", updatedUser);
// 삭제
sqlMapClient.delete("User.deleteUser", 1);
이 코드에서 눈에 띄는 문제점이 있다. 메서드 호출 시 쿼리 ID를 문자열("User.getUser")로 전달하기 때문에, 오타가 발생해도 컴파일 단계에서는 오류가 잡히지 않는다. 런타임에 예외가 발생해야 비로소 문제를 인식할 수 있는 구조다. 대규모 프로젝트에서 이 문제는 디버깅 비용을 크게 높이는 요인이 된다.
MyBatis의 구조와 핵심 문법
MyBatis는 iBatis와 같은 XML 기반 SQL 정의 방식을 유지하면서도, 인터페이스 기반 Mapper 구조를 도입해 타입 안정성과 코드 가독성을 동시에 확보했다. 설정 방식도 단순화되었으며, Spring과의 통합이 공식적으로 지원된다.
mybatis-config.xml 기본 설정:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="defaultFetchSize" value="100"/>
<setting name="defaultStatementTimeout" value="30"/>
</settings>
<typeAliases>
<package name="com.example.model"/>
</typeAliases>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul"/>
<property name="username" value="myuser"/>
<property name="password" value="password"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="com/example/mapper/UserMapper.xml"/>
</mappers>
</configuration>
UserMapper.xml 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.example.mapper.UserMapper">
<!-- ResultMap: 복잡한 객체 매핑 정의 -->
<resultMap id="UserResultMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="email" column="email"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<!-- 단건 조회 -->
<select id="getUser" parameterType="int" resultMap="UserResultMap">
SELECT id, name, email, created_at
FROM users
WHERE id = #{id}
</select>
<!-- 목록 조회 -->
<select id="getUserList" resultMap="UserResultMap">
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC
</select>
<!-- 등록 (자동 생성 키 반환) -->
<insert id="insertUser" parameterType="User" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (name, email, created_at)
VALUES (#{name}, #{email}, NOW())
</insert>
<!-- 수정 -->
<update id="updateUser" parameterType="User">
UPDATE users
SET name = #{name}, email = #{email}
WHERE id = #{id}
</update>
<!-- 삭제 -->
<delete id="deleteUser" parameterType="int">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>
Mapper 인터페이스 정의:
public interface UserMapper {
User getUser(int id);
List<User> getUserList();
void insertUser(User user);
void updateUser(User user);
void deleteUser(int id);
}
Java 호출 코드:
// SqlSessionFactory 초기화
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
// SqlSession을 통한 Mapper 사용
try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper userMapper = session.getMapper(UserMapper.class);
// 단건 조회
User user = userMapper.getUser(1);
// 목록 조회
List<User> userList = userMapper.getUserList();
// 등록
User newUser = new User("홍길동", "hong@example.com");
userMapper.insertUser(newUser);
session.commit();
}
iBatis와 비교했을 때 가장 직관적인 차이는 userMapper.getUser(1) 이다. 문자열 기반 호출이 아닌 인터페이스 메서드 호출이기 때문에, IDE의 자동완성과 컴파일 타임 타입 체크가 모두 작동한다. 오타나 잘못된 파라미터 타입은 개발 단계에서 즉시 발견된다.
동적 SQL 비교 — iBatis vs MyBatis
동적 SQL은 두 프레임워크의 차이가 가장 극명하게 드러나는 영역이다. 검색 조건이 다양하거나 입력값에 따라 쿼리가 달라지는 경우, 동적 SQL 처리 능력은 개발 생산성과 유지보수성에 직접적인 영향을 미친다.
iBatis의 동적 SQL:
<select id="searchUser" parameterClass="map" resultClass="User">
SELECT id, name, email
FROM users
<dynamic prepend="WHERE">
<isNotNull prepend="AND" property="name">
name LIKE '%$name$%'
</isNotNull>
<isNotNull prepend="AND" property="email">
email = #email#
</isNotNull>
<isGreaterThan prepend="AND" property="age" compareValue="0">
age > #age#
</isGreaterThan>
</dynamic>
ORDER BY created_at DESC
</select>
iBatis의 <dynamic> 태그는 조건이 복잡해질수록 prepend 처리가 뒤엉키고, 태그 종류도 <isNotNull>, <isNotEmpty>, <isGreaterThan>, <isEqual> 등으로 세분화되어 있어 익히는 데 시간이 걸린다. 또한 $name$와 #name#의 차이(문자열 치환 vs 바인딩)를 명확히 구분하지 않으면 SQL Injection에 취약해질 수 있다.
MyBatis의 동적 SQL:
<select id="searchUser" parameterType="UserSearchDto" resultMap="UserResultMap">
SELECT id, name, email, created_at
FROM users
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="email != null and email != ''">
AND email = #{email}
</if>
<if test="age != null and age > 0">
AND age > #{age}
</if>
<if test="startDate != null">
AND created_at >= #{startDate}
</if>
<if test="endDate != null">
AND created_at <= #{endDate}
</if>
</where>
ORDER BY created_at DESC
</select>
MyBatis의 <where> 태그는 내부 조건 중 하나라도 활성화되면 자동으로 WHERE 키워드를 추가하고, 불필요한 AND나 OR를 자동으로 제거한다. 개발자가 WHERE 1=1 같은 트릭을 쓰거나 prepend를 수동으로 관리할 필요가 없다.
MyBatis의 choose-when-otherwise (iBatis의 switch문에 해당):
<select id="getUserOrderBy" parameterType="String" resultMap="UserResultMap">
SELECT id, name, email, created_at
FROM users
ORDER BY
<choose>
<when test="orderBy == 'name'">name ASC</when>
<when test="orderBy == 'email'">email ASC</when>
<otherwise>created_at DESC</otherwise>
</choose>
</select>
MyBatis의 foreach (IN 절 처리):
<select id="getUsersByIds" parameterType="list" resultMap="UserResultMap">
SELECT id, name, email
FROM users
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
</select>
MyBatis의 set 태그 (UPDATE 동적 처리):
<update id="updateUserSelective" parameterType="User">
UPDATE users
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
<if test="age != null">age = #{age},</if>
</set>
WHERE id = #{id}
</update>
<set> 태그는 활성화된 조건만 UPDATE하고, 자동으로 마지막 쉼표를 제거한다. iBatis에서 이와 동일한 기능을 구현하려면 훨씬 많은 수작업이 필요하다.
ResultMap과 연관 객체 매핑
단순 CRUD를 넘어서면 곧 연관 관계 매핑 문제에 부딪히게 된다. 예를 들어 User가 여러 개의 Order를 가지고 있는 1:N 관계, 또는 Order가 하나의 Product를 참조하는 N:1 관계를 다룰 때, iBatis와 MyBatis의 처리 방식에는 명확한 차이가 있다.
iBatis의 연관 객체 매핑:
iBatis에서 복잡한 객체 매핑을 처리하려면 resultMap을 직접 구성하거나, 다중 쿼리를 별도로 실행하고 Java 코드에서 조립하는 방식을 사용해야 했다. 이는 코드가 분산되고 관리가 어려워지는 원인이 된다.
<!-- iBatis: 별도 쿼리로 처리 -->
<select id="getOrdersByUserId" parameterClass="int" resultClass="Order">
SELECT id, product_name, amount, order_date
FROM orders
WHERE user_id = #userId#
</select>
// Java 코드에서 직접 조립
User user = (User) sqlMapClient.queryForObject("User.getUser", userId);
List<Order> orders = (List<Order>) sqlMapClient.queryForList("Order.getOrdersByUserId", userId);
user.setOrders(orders); // 수동 조립
MyBatis의 연관 객체 매핑 — association (N:1):
<resultMap id="OrderResultMap" type="Order">
<id property="id" column="order_id"/>
<result property="amount" column="amount"/>
<result property="orderDate" column="order_date"/>
<association property="user" javaType="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<result property="email" column="email"/>
</association>
</resultMap>
<select id="getOrderWithUser" parameterType="int" resultMap="OrderResultMap">
SELECT
o.id AS order_id,
o.amount,
o.order_date,
u.id AS user_id,
u.name AS user_name,
u.email
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.id = #{id}
</select>
MyBatis의 연관 객체 매핑 — collection (1:N):
<resultMap id="UserWithOrdersResultMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="name"/>
<result property="email" column="email"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="amount" column="amount"/>
<result property="orderDate" column="order_date"/>
</collection>
</resultMap>
<select id="getUserWithOrders" parameterType="int" resultMap="UserWithOrdersResultMap">
SELECT
u.id AS user_id,
u.name,
u.email,
o.id AS order_id,
o.amount,
o.order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id = #{id}
</select>
이 구조에서 MyBatis는 JOIN 결과를 자동으로 User 객체와 그 안의 List<Order>로 분리해 조립한다. 개발자가 Java 코드에서 직접 객체를 조립할 필요가 없다. iBatis 대비 코드량이 줄고, 매핑 로직이 XML 한 곳에 집중되기 때문에 유지보수성이 크게 향상된다.
Spring 통합과 현업 선택 기준
실무에서 MyBatis는 대부분 Spring 또는 Spring Boot와 함께 사용된다. mybatis-spring-boot-starter를 사용하면 SqlSessionFactory, SqlSessionTemplate 등의 빈 설정을 자동화할 수 있어 설정 부담이 크게 줄어든다.
Spring Boot + MyBatis 의존성 설정 (build.gradle):
dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
implementation 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
}
application.yml 설정:
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul
username: myuser
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
default-fetch-size: 100
default-statement-timeout: 30
Mapper 인터페이스 (@Mapper 어노테이션 사용):
@Mapper
public interface UserMapper {
User getUser(int id);
List<User> searchUser(UserSearchDto dto);
void insertUser(User user);
void updateUser(User user);
void deleteUser(int id);
}
Service 레이어에서 사용:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserMapper userMapper;
public User getUser(int id) {
User user = userMapper.getUser(id);
if (user == null) {
throw new UserNotFoundException("존재하지 않는 사용자입니다. id: " + id);
}
return user;
}
public List<User> searchUser(UserSearchDto dto) {
return userMapper.searchUser(dto);
}
@Transactional
public void createUser(User user) {
userMapper.insertUser(user);
}
}
현업에서의 선택 기준 정리:
신규 프로젝트에서 iBatis를 선택할 이유는 사실상 없다. 공식 유지보수가 종료된 프레임워크를 신규 시스템에 도입하는 것은 기술적 위험을 자초하는 것이다. MyBatis와 JPA 중 어느 것을 선택할지는 프로젝트 성격에 따라 달라진다.
SQL에 대한 세밀한 제어가 필요하고, 복잡한 통계 쿼리나 대량 배치 처리가 빈번한 환경이라면 MyBatis가 유리하다. 반면 도메인 모델 중심의 설계를 지향하고, 단순 CRUD가 대부분인 경우라면 JPA가 더 생산적이다. 실제로는 두 가지를 혼용하는 경우도 적지 않으며, Spring Data JPA와 MyBatis를 함께 사용하는 아키텍처도 충분히 현실적인 선택이다.
레거시 iBatis 시스템을 운영하고 있다면, 전체 마이그레이션보다는 신규 기능부터 MyBatis로 점진적으로 전환하는 방식이 리스크를 낮추는 현실적인 접근이다.
나오며: 기술의 계보를 이해하는 것의 의미
이 글을 여기까지 읽었다면 한 가지만 기억하자. 아는 것과 써본 것은 완전히 다르다.
iBatis와 MyBatis의 차이를 머릿속으로 이해하는 것과, 실제로 Mapper 인터페이스를 만들고 동적 SQL을 작성해서 데이터를 꺼내보는 것 사이에는 넘어야 할 벽이 있다. 그 벽은 오직 직접 코드를 작성해봐야만 넘을 수 있다.
지금 당장 할 수 있는 것부터 시작하자.
1단계:mybatis-spring-boot-starter의존성을 추가하고 프로젝트를 세팅한다.
2단계:users테이블 하나를 만들고, Mapper 인터페이스와 XML을 직접 작성해본다.
3단계:<if>,<where>,<foreach>태그를 활용한 동적 쿼리를 하나씩 만들어본다.
4단계:<collection>을 사용해 1:N 관계를 JOIN 쿼리 하나로 처리해본다.
이 네 단계를 직접 손으로 완성하고 나면, 이 글에서 설명한 내용들이 비로소 실력으로 자리잡는다.
개념은 읽는 순간 이해되지만, 기술은 써본 순간 체득된다.
지금 바로 에디터를 열자.
Add your first comment to this post