MyBatis - SQL Mapper
MyBatis가 무엇인지, 내부에서 어떻게 동작하는지, JPA와 어떻게 다른지, 언제 선택하는지 정리한다.
MyBatis란?
SQL을 직접 작성하고, 그 결과를 Java/Kotlin 객체에 매핑해주는 SQL Mapper 라이브러리다.
JPA가 "객체를 저장하면 SQL을 알아서 만들어준다"는 ORM이라면, MyBatis는 "내가 SQL을 짜면 결과를 객체로 변환해준다"는 SQL Mapper다.
동작 원리
[ 애플리케이션 ]
│ Mapper 인터페이스 호출
▼
[ MyBatis ]
│ ├── SQL 파일/애노테이션에서 SQL 로드
│ ├── 파라미터 바인딩 (#{param} → PreparedStatement)
│ ├── SQL 실행
│ └── ResultSet → 객체 매핑 (TypeHandler)
│
▼
[ JDBC / Database ]
SqlSession
JDBC를 직접 쓰면 DB와 통신하려면 이런 과정을 거쳐야 한다.
// JDBC 직접 사용
val connection = dataSource.getConnection() // 연결
val stmt = connection.prepareStatement("SELECT * FROM users WHERE id = ?")
stmt.setLong(1, id) // 파라미터 바인딩
val rs = stmt.executeQuery() // 실행
val user = User(rs.getLong("id"), rs.getString("name")) // 결과 매핑
rs.close(); stmt.close(); connection.close() // 자원 정리
SqlSession은 이 과정을 전부 감싸서 처리한다. DB 연결 + 파라미터 바인딩 + SQL 실행 + 결과 매핑 + 자원 정리를 담당한다.
SqlSessionFactory (애플리케이션 시작 시 1번 생성)
│
│ - mybatis-config.xml 또는 Spring Boot 자동 설정 읽음
│ - DB 연결 정보, Mapper XML 위치, TypeHandler 등록 등 초기화
│ - 커넥션 풀(HikariCP 등)과 연결
│
▼
SqlSession (HTTP 요청마다 생성 → 사용 → 반납)
│
│ - 커넥션 풀에서 Connection 하나를 빌려 사용
│ - SQL 실행, 트랜잭션 범위 관리
│ - 요청 처리 후 Connection을 풀에 반납
│
▼
Mapper 프록시 (SqlSession을 통해 동작)
│
│ - @Mapper 인터페이스의 구현체를 MyBatis가 자동 생성
│ - userMapper.findById(1L) 호출 →
│ SqlSession.selectOne("findById", 1L) 로 위임
@Mapper 인터페이스에 구현 코드가 없는데도 동작하는 이유가 여기 있다. MyBatis가 런타임에 프록시 구현체를 만들어 Spring Bean으로 등록한다. 실제 구현은 SqlSession이 처리한다.
Spring Boot에서는 SqlSessionFactory와 SqlSession 설정이 자동으로 처리된다. @Mapper만 붙이면 된다.
TypeHandler
DB 타입과 Kotlin 타입은 1:1로 대응하지 않는다.
DB 타입 Kotlin 타입
────────────────────────────
VARCHAR → String
INTEGER → Int
TIMESTAMP → LocalDateTime
VARCHAR "ACTIVE" → enum UserStatus.ACTIVE ← 자동 변환?
VARCHAR (JSON) → data class AddressInfo ← 자동 변환?
TypeHandler는 이 변환을 담당한다. SQL 결과를 객체에 넣을 때(읽기), 객체 값을 SQL 파라미터로 바인딩할 때(쓰기) 양방향으로 동작한다.
ResultSet (DB 결과)
│ rs.getString("status") = "ACTIVE"
▼
TypeHandler
│ "ACTIVE" → UserStatus.ACTIVE (enum 변환)
▼
User.status = UserStatus.ACTIVE
String, Int, Long, LocalDateTime, UUID 같은 기본 타입은 MyBatis 내장 TypeHandler가 자동 처리한다. enum도 EnumTypeHandler(이름 기반)와 EnumOrdinalTypeHandler(순서 기반) 중 선택해서 쓸 수 있다.
# application.yml - enum을 이름(문자열)으로 저장
mybatis:
configuration:
default-enum-type-handler: org.apache.ibatis.type.EnumTypeHandler
DB에 "ACTIVE" 로 저장되어 있다면 EnumTypeHandler를, 0, 1 숫자로 저장되어 있다면 EnumOrdinalTypeHandler를 쓴다.
내장 TypeHandler로 처리가 안 되는 경우 직접 만든다. 대표적인 예가 JSON 컬럼이다.
// DB에 JSON 문자열로 저장된 컬럼을 Kotlin 객체로 변환
// DB: '{"city": "서울", "street": "강남대로"}'
// Kotlin: AddressInfo(city = "서울", street = "강남대로")
@MappedTypes(AddressInfo::class)
class JsonTypeHandler : BaseTypeHandler<AddressInfo>() {
private val objectMapper = ObjectMapper()
// Kotlin 객체 → DB (INSERT/UPDATE)
override fun setNonNullParameter(
ps: PreparedStatement, i: Int,
parameter: AddressInfo, jdbcType: JdbcType?
) {
ps.setString(i, objectMapper.writeValueAsString(parameter))
}
// DB → Kotlin 객체 (SELECT)
override fun getNullableResult(rs: ResultSet, columnName: String): AddressInfo? =
rs.getString(columnName)?.let { objectMapper.readValue(it, AddressInfo::class.java) }
override fun getNullableResult(rs: ResultSet, columnIndex: Int): AddressInfo? =
rs.getString(columnIndex)?.let { objectMapper.readValue(it, AddressInfo::class.java) }
override fun getNullableResult(cs: CallableStatement, columnIndex: Int): AddressInfo? =
cs.getString(columnIndex)?.let { objectMapper.readValue(it, AddressInfo::class.java) }
}
# application.yml - TypeHandler 위치 등록
mybatis:
type-handlers-package: com.example.typehandler
이렇게 등록하면 AddressInfo 타입 필드는 자동으로 이 TypeHandler를 거친다.
JPA vs MyBatis
// JPA - SQL을 프레임워크가 생성
@Entity
class User(val name: String, val email: String)
userRepository.findByNameAndStatus(name, status)
// → SELECT * FROM users WHERE name = ? AND status = ? (자동 생성)
// MyBatis - SQL을 직접 작성
@Mapper
interface UserMapper {
@Select("SELECT * FROM users WHERE name = #{name} AND status = #{status}")
fun findByNameAndStatus(name: String, status: String): List<User>
}
핵심 차이는 SQL을 누가 작성하느냐다. JPA는 프레임워크, MyBatis는 개발자가 작성한다.
기본 사용법
설정 (build.gradle.kts)
dependencies {
implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
}
# application.yml
mybatis:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.example.domain
configuration:
map-underscore-to-camel-case: true # DB: user_name → Kotlin: userName
default-fetch-size: 100
default-statement-timeout: 30
애노테이션 방식
@Mapper
interface UserMapper {
@Select("SELECT id, name, email, created_at FROM users WHERE id = #{id}")
fun findById(id: Long): User?
@Insert("INSERT INTO users (name, email) VALUES (#{name}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
fun insert(user: User): Int
@Update("UPDATE users SET name = #{name}, email = #{email} WHERE id = #{id}")
fun update(user: User): Int
@Delete("DELETE FROM users WHERE id = #{id}")
fun delete(id: Long): Int
@Select("SELECT COUNT(*) FROM users")
fun count(): Long
}
XML 방식
복잡한 쿼리는 XML로 분리한다. 애노테이션으로 긴 SQL을 작성하면 가독성이 나쁘다.
<!-- resources/mapper/UserMapper.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.UserMapper">
<!-- resultMap: DB 컬럼과 객체 필드 매핑 명시 -->
<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="findById" parameterType="long" resultMap="userResultMap">
SELECT id, name, email, created_at
FROM users
WHERE id = #{id}
</select>
<!-- 연관 데이터 매핑 -->
<resultMap id="userWithOrdersMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="amount" column="amount"/>
<result property="status" column="order_status"/>
</collection>
</resultMap>
<select id="findWithOrders" resultMap="userWithOrdersMap">
SELECT
u.id, u.name,
o.id as order_id, o.amount, o.status as order_status
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = #{id}
</select>
</mapper>
동적 쿼리
MyBatis XML의 <if>, <where>, <foreach> 태그로 동적 쿼리를 작성한다.
<select id="search" resultMap="userResultMap">
SELECT id, name, email, status, created_at
FROM users
<where>
<!-- name이 null이 아니고 빈 문자열이 아닐 때만 조건 추가 -->
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="statuses != null and statuses.size() > 0">
AND status IN
<foreach collection="statuses" item="s" open="(" separator="," close=")">
#{s}
</foreach>
</if>
</where>
ORDER BY created_at DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<where> 태그는 조건이 하나라도 있으면 WHERE를 자동으로 붙이고, 맨 앞의 AND/OR는 제거한다.
@Mapper
interface UserMapper {
fun search(
@Param("name") name: String?,
@Param("minAge") minAge: Int?,
@Param("statuses") statuses: List<String>?,
@Param("limit") limit: Int,
@Param("offset") offset: Int
): List<User>
}
SQL 재사용
<!-- 공통으로 쓰는 SQL 조각 -->
<sql id="userColumns">
id, name, email, status, created_at
</sql>
<select id="findAll" resultMap="userResultMap">
SELECT <include refid="userColumns"/>
FROM users
ORDER BY created_at DESC
</select>
<select id="findById" resultMap="userResultMap">
SELECT <include refid="userColumns"/>
FROM users
WHERE id = #{id}
</select>
JPA와 함께 쓰기
단순 CRUD는 JPA로, 복잡한 조회 쿼리는 MyBatis로 처리하는 혼용 패턴도 자주 쓴다.
@Service
class UserService(
private val userRepository: UserRepository, // Spring Data JPA
private val userMapper: UserMapper // MyBatis
) {
// 저장/수정/삭제: JPA
@Transactional
fun createUser(request: CreateUserRequest) {
userRepository.save(User(request.name, request.email))
}
// 복잡한 집계 조회: MyBatis
@Transactional(readOnly = true)
fun getMonthlyReport(year: Int, month: Int): List<UserReport> {
return userMapper.findMonthlyReport(year, month)
}
// 복잡한 검색: MyBatis
@Transactional(readOnly = true)
fun search(condition: SearchCondition): List<User> {
return userMapper.search(condition)
}
}
장단점
장점
SQL 완전한 제어: 실행되는 SQL을 정확히 알고 튜닝할 수 있다. JPA가 생성하는 SQL이 예상과 다를 때의 답답함이 없다.
복잡한 쿼리: 여러 테이블 JOIN, 윈도우 함수, 집계 쿼리 등 JPA로 표현하기 어려운 쿼리도 자연스럽게 작성한다.
레거시 DB: 테이블 구조가 객체 구조와 맞지 않아도 resultMap으로 유연하게 매핑한다.
DBA 협업: SQL을 그대로 공유하고 검토할 수 있다.
단점
반복 작업: 단순 CRUD도 SQL을 직접 작성해야 한다. 테이블이 많으면 작성할 게 많다.
타입 안전성 없음: 컬럼명 오타, 타입 불일치는 런타임에서 발견된다.
DB 종속: 특정 DB 문법(MySQL의 LIMIT, Oracle의 ROWNUM 등)을 쓰면 DB 변경이 어렵다.
SQL 관리: SQL이 XML 파일에 흩어지면 관리가 복잡해진다.
언제 선택하는가?
- 복잡한 통계/집계 쿼리가 많은 어드민, 대시보드
- 기존 레거시 DB를 유지하면서 새 기능을 추가하는 경우
- DBA가 SQL을 직접 관리하는 팀 구조
- JPA가 생성하는 SQL을 신뢰하기 어려운 복잡한 도메인
단순 CRUD 중심이라면 JPA가 더 빠르고 편하다.
정리
| 항목 | 내용 |
|---|---|
| 방식 | SQL 직접 작성 → 객체 매핑 |
| 핵심 컴포넌트 | SqlSession, TypeHandler, resultMap |
| 장점 | SQL 제어권, 복잡한 쿼리, 레거시 DB |
| 단점 | 반복적 SQL 작성, 타입 안전성 없음 |
| 적합한 경우 | 복잡한 쿼리, DBA 협업, 레거시 DB |
시리즈: Spring Boot 데이터 접근 기술
- JPA 심화 - N+1, 지연 로딩, Dirty Checking
- QueryDSL - 타입 세이프 동적 쿼리
- MyBatis - SQL Mapper ← 현재 글
- JOOQ - 타입 세이프 SQL
- Kotlin Exposed - Kotlin ORM
- 어떤 걸 선택해야 할까?