Spring Boot 데이터 접근 기술(3/6)
Kotlin/Spring

MyBatis - SQL Mapper

MyBatis가 무엇인지, 내부에서 어떻게 동작하는지, JPA와 어떻게 다른지, 언제 선택하는지 정리한다.

2026-03-29
11 min read
#MyBatis#SQL#Spring Boot#Kotlin#SQL Mapper

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에서는 SqlSessionFactorySqlSession 설정이 자동으로 처리된다. @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 &gt;= #{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 데이터 접근 기술

  1. JPA 심화 - N+1, 지연 로딩, Dirty Checking
  2. QueryDSL - 타입 세이프 동적 쿼리
  3. MyBatis - SQL Mapper ← 현재 글
  4. JOOQ - 타입 세이프 SQL
  5. Kotlin Exposed - Kotlin ORM
  6. 어떤 걸 선택해야 할까?