| 파일 | 역할 | 실무에서 대체하는 것 |
|---|---|---|
mybatis-config.xml |
전역 설정: 접속, settings, 별칭, 핸들러, 플러그인, 매퍼 목록 | application.yml 의 mybatis.* 키 + 자동 구성 |
mapper/*Mapper.xml |
SQL 본문. namespace 하나 = 매퍼 인터페이스 하나 | 그대로 사용. mybatis.mapper-locations 로 위치만 지정 |
try (Reader r = Resources.getResourceAsReader("mybatis-config.xml")) { // 클래스패스에서 찾는다 → 실행 시 -cp 에 "." 필수
factory = new SqlSessionFactoryBuilder().build(r);
}mybatis-config.xml 의 자식 요소는 DTD 가 정한 순서를 지켜야 합니다.
properties → settings → typeAliases → typeHandlers → objectFactory → plugins → environments → databaseIdProvider → mappers. 순서가 틀리면 빌드 시점에 예외가 납니다.
매퍼 XML 은 <mappers> 에 나열된 순서로 파싱되는데, 다른 namespace 의 resultMap 을 참조(OrderMapper.orderMap)하면 그 파일이 아직 안 읽혔을 수 있습니다. MyBatis 는 이런 경우 미완성 요소를 보류해 두고 나중에 다시 시도하므로 순서 걱정은 안 해도 됩니다.
<mapper namespace="MemberMapper"> <!-- 패키지가 있으면 shop.mapper.MemberMapper -->
<select id="findById" resultMap="memberMap"> <!-- id = 인터페이스 메서드 이름 -->public interface MemberMapper { // 어노테이션 없음
Member findById(long id);
}session.getMapper(MemberMapper.class) 가 만드는 프록시는 메서드가 호출되면 "MemberMapper.findById" 라는 문자열 키로 statement 를 찾습니다.
그래서 XML 의 id 오타는 컴파일러가 잡지 못하고 실행 시점에 BindingException 이나 "Mapped Statements collection does not contain value" 로 터집니다(예제 8). 메서드 이름을 IDE 로 리팩터링하면 XML 은 따라오지 않습니다.
실무에서는 기동 시 모든 매퍼 메서드를 순회해 statement 존재를 확인하는 테스트를 하나 둡니다(연습 문제에서 다루지는 않지만 Configuration.hasStatement() 로 10줄이면 됩니다).
<resultMap id="memberMap" type="Member">
<id property="id" column="id"/> <!-- 식별자. 1:N 접기(collection)에 필수 -->
<result property="active" column="active_yn"/> <!-- 이름 다른 컬럼만. 같은 이름은 autoMapping 이 채움 -->
</resultMap>
<resultMap id="memberWithOrdersJoin" type="Member" extends="memberMap" autoMapping="true">
<collection property="orders" ofType="Order" columnPrefix="o_" resultMap="OrderMapper.orderMap"/>
</resultMap>
<resultMap id="orderMap" type="Order"> <!-- record: setter 없음 → 생성자 매핑 -->
<constructor>
<idArg column="id" javaType="_long"/>
<arg column="member_id" javaType="_long"/>
...
</constructor>
</resultMap>| 요소 | 용도 |
|---|---|
<id> |
식별자 컬럼. JOIN 결과 여러 행을 한 객체로 접을 때 "같은 객체인지" 판단 기준 |
<result> |
컬럼 → 프로퍼티. typeHandler, jdbcType 개별 지정 가능 |
<constructor> |
setter 없는 record/불변 객체. <arg> 순서 = 생성자 인자 순서. javaType 필수 |
<association> |
N:1 (주문 → 회원). 중첩 select 또는 JOIN 매핑 |
<collection> |
1:N. ofType 이 원소 타입. select= 는 추가 조회, resultMap= 은 JOIN 접기 |
extends |
다른 resultMap 상속. 공통 컬럼 매핑을 한 곳에 |
columnPrefix |
JOIN 에서 o_id, o_amount 처럼 접두어 붙인 컬럼을 하위 매핑에 넘길 때 접두어 제거 |
autoMapping |
이 resultMap 에 자동 매핑 적용 여부 |
가장 자주 당하는 함정이 autoMapping 입니다. 전역 기본값 autoMappingBehavior=PARTIAL 은 "중첩 매핑(association/collection)이 있는 resultMap 에는 자동 매핑을 하지 않는다"는 뜻입니다.
JOIN 용 resultMap 에 <collection> 을 추가하는 순간 회원의 name, joined_at 이 전부 null 로 바뀝니다. 이 레슨의 예제 6 을 처음 작성할 때 실제로 그렇게 됐고, autoMapping="true" 한 줄로 고쳤습니다.
전역으로 FULL 로 바꾸는 방법도 있지만 JOIN 의 동명 컬럼이 엉뚱한 곳에 매핑될 위험이 있어 resultMap 단위로 켜는 것이 안전합니다.
| 태그 | 하는 일 | 예 |
|---|---|---|
<if test=""> |
조건부 포함. test 는 OGNL 표현식 | <if test="name != null and name != ''"> |
<where> |
안에 내용이 있으면 WHERE 를 붙이고, 첫 AND/OR 를 제거 |
조건이 0개면 WHERE 자체가 사라짐 |
<set> |
UPDATE 용. SET 을 붙이고 마지막 , 제거 |
부분 갱신(null 아닌 필드만) |
<trim prefix= suffix= prefixOverrides= suffixOverrides=> |
where/set 의 일반형 | <trim prefix="(" suffix=")" suffixOverrides=","> |
<choose><when><otherwise> |
if / else if / else | 정렬 컬럼 화이트리스트 |
<foreach collection= item= open= separator= close=> |
컬렉션 순회 | IN (#{g}, #{g}, …), 다중 INSERT |
<bind name= value=> |
OGNL 로 변수 생성 | '%' + name + '%' 로 LIKE 패턴. DB 문자열 연결 함수 의존 제거 |
<sql id=> + <include refid=> |
SQL 조각 재사용 | 컬럼 목록, 목록과 COUNT 가 공유하는 WHERE |
<sql id="searchWhere">
<where>
<if test="name != null and name != ''">
<bind name="pattern" value="'%' + name + '%'"/>
AND name LIKE #{pattern}
</if>
<if test="grades != null and !grades.isEmpty()">
AND grade IN <foreach collection="grades" item="g" open="(" separator="," close=")">#{g}</foreach>
</if>
<choose>
<when test="activeOnly != null and activeOnly">AND active_yn = 'Y'</when>
<when test="inactiveOnly != null and inactiveOnly">AND active_yn = 'N'</when>
<otherwise/>
</choose>
</where>
</sql>
<select id="search" resultMap="memberMap">
SELECT <include refid="memberColumns"/> FROM member
<include refid="searchWhere"/> <!-- 목록 -->
ORDER BY <choose><when test="sort == 'name'">name</when><otherwise>id</otherwise></choose>
</select>
<select id="countSearch" resultType="_long">
SELECT COUNT(*) FROM member
<include refid="searchWhere"/> <!-- 건수. 같은 조각이라 조건이 어긋날 수 없다 -->
</select>목록 조회와 COUNT 조회가 같은 WHERE 조각을 include 하는 것이 핵심 패턴입니다. 조건을 하나 추가할 때 한 곳만 고치면 되고, "목록은 10건인데 총건수는 12건" 같은 불일치가 생기지 않습니다. 정렬은 ${} 로 컬럼명을 받는 대신 <choose> 로 허용 값을 나열합니다. 인젝션이 구조적으로 불가능합니다.
< 와 &매퍼 XML 안의 SQL 도 XML 텍스트입니다. <, & 는 파서가 태그·엔티티로 해석하므로 그대로 쓸 수 없습니다. > 는 규격상 허용됩니다.
| 쓰려는 것 | 방법 1: 엔티티 | 방법 2: CDATA |
|---|---|---|
a < b |
a < b |
<![CDATA[ a < b ]]> |
a <= b |
a <= b |
<![CDATA[ a <= b ]]> |
a >= b |
그대로 | 그대로 |
| `a | b` (Oracle 문자열 연결) | |
a & b |
a & b |
<![CDATA[ a & b ]]> |
<if test="to != null"><![CDATA[ AND joined_at <= #{to} ]]></if>비교 연산자가 많은 SQL 은 CDATA 로 문장 전체를 감싸는 것이 읽기 좋습니다. 단, CDATA 안에서는 <if> 같은 동적 태그가 동작하지 않습니다. 동적 태그는 CDATA 밖에 두고, 비교식이 든 조각만 CDATA 로 감쌉니다.
Oracle 은 다른 DB 와 문법이 꽤 다릅니다. 이 레슨의 XML 에 나오는 것들입니다.
| 항목 | Oracle | H2(기본) / MariaDB |
|---|---|---|
| 자동 증가 키 | 시퀀스 + <selectKey order="BEFORE"> 로 NEXTVAL 조회 |
IDENTITY / AUTO_INCREMENT + useGeneratedKeys |
| 페이징 | ROWNUM 3중 서브쿼리 (전통), OFFSET n ROWS FETCH NEXT m ROWS ONLY (12c+) |
LIMIT m OFFSET n |
| 현재 일시 | SYSDATE, SYSTIMESTAMP |
CURRENT_DATE, NOW() |
| 날짜 연산 | TRUNC(SYSDATE) - 7 |
DATEADD('DAY', -7, CURRENT_DATE) / DATE_SUB(...) |
| NULL 대체 | NVL(x, 0) |
COALESCE(x, 0), IFNULL |
| 문자열 → 날짜/날짜 → 문자열 | TO_DATE, TO_CHAR(d, 'YYYY-MM') |
PARSEDATETIME, FORMATDATETIME / DATE_FORMAT |
| UPSERT | MERGE INTO … USING (SELECT … FROM DUAL) … WHEN MATCHED |
H2 MERGE … KEY(), MariaDB ON DUPLICATE KEY UPDATE |
| 더미 테이블 | FROM DUAL 필수 |
FROM 생략 가능 |
| null 바인딩 | jdbcType 없이 setNull 하면 ORA-17004 Invalid column type |
대부분 관대 |
마지막 줄이 실무에서 첫날 만나는 오류입니다. mybatis-config.xml 에 <setting name="jdbcTypeForNull" value="NULL"/> 을 두거나 #{name, jdbcType=VARCHAR} 처럼 개별 지정합니다.
같은 서비스가 여러 DB 를 지원해야 할 때 방법은 둘입니다.
방법 A: databaseIdProvider (이 레슨). 접속한 DB 의 제품명을 짧은 id 로 바꾸고, XML 의 statement 에 databaseId="oracle" 을 붙이면 그 DB 에서만 로드됩니다. 같은 id 로 여러 버전을 두고, databaseId 가 없는 버전은 기본값이 됩니다.
<databaseIdProvider type="DB_VENDOR">
<property name="Oracle" value="oracle"/>
<property name="H2" value="h2"/>
<property name="MariaDB" value="maria"/>
<property name="Tibero" value="oracle"/> <!-- Tibero 는 Oracle 문법 호환 -->
</databaseIdProvider>
<select id="findRecent" resultMap="memberMap" databaseId="oracle">
… WHERE joined_at >= TRUNC(SYSDATE) - #{days}
</select>
<select id="findRecent" resultMap="memberMap" databaseId="h2">
… WHERE joined_at >= DATEADD('DAY', -#{days}, CURRENT_DATE)
</select>방법 B: 방언별 폴더 (조사한 실무 프로젝트). resources/mapper/oracle/, mapper/maria/, mapper/tibero/ 에 같은 이름의 XML 을 두고, 배포 프로필로 mybatis.mapper-locations 를 바꿉니다.
SQL 이 90% 같아도 파일이 3벌이라 중복이 크지만, DBA 가 "Oracle 폴더만" 검토하면 되고 방언 차이가 파일 단위로 격리됩니다. 차이 나는 SQL 이 소수면 A, 대부분이면 B 가 맞습니다. 변형 1 에서 B 를 다룹니다.
-- ① Oracle 전통: ROWNUM 은 "WHERE 를 통과한 순서"에 붙는다. ORDER BY 뒤에 번호를 붙이려면 서브쿼리 3겹
SELECT * FROM (
SELECT t.*, ROWNUM AS rn FROM ( SELECT … FROM member ORDER BY id ) t WHERE ROWNUM <= #{end}
) WHERE rn > #{start}
-- ② Oracle 12c+ / 표준 SQL
SELECT … FROM member ORDER BY id OFFSET #{offset} ROWS FETCH NEXT #{size} ROWS ONLY
-- ③ PageHelper: 매퍼 SQL 은 그대로 두고 플러그인이 ①/② 를 자동으로 감싼다①에서 WHERE ROWNUM <= #{end} 가 안쪽에, rn > #{start} 가 바깥에 있는 이유가 있습니다. ROWNUM > 10 은 항상 거짓입니다. ROWNUM 은 행이 조건을 통과할 때마다 1부터 다시 매겨지므로 첫 행이 1 이고 1 > 10 이 거짓이면 두 번째 행도 1 입니다. 그래서 상한은 ROWNUM 으로, 하한은 별칭 rn 으로 걸어야 합니다.
PageHelper 의 원리는 세 단계입니다.
PageHelper.startPage(2, 10); ① ThreadLocal 에 "다음 한 번의 조회를 2페이지 10건으로" 지시를 둔다
List<Member> list = mapper.findAll(); ② 인터셉터가 지시를 보고: (a) SELECT COUNT(*) FROM (원본 SQL) 실행 → 총건수
(b) 원본 SQL 을 방언에 맞게 감싼다 (Oracle ROWNUM / MySQL LIMIT)
(c) 지시를 지운다 (딱 한 번만 적용)
PageInfo<Member> p = new PageInfo<>(list); ③ list 는 실제로 Page 객체(ArrayList 상속)라 총건수·페이지 수를 갖고 있다이 레슨의 PageInterceptor 가 ②를 30줄로 구현합니다. 유명한 함정도 여기서 나옵니다. startPage 뒤의 조회가 예외로 실행되지 않거나, 1차 캐시에서 돌아와 SQL 이 실행되지 않으면 지시가 남아 다음 무관한 조회에 페이징이 걸립니다. 예제 4 가 이 현상을 재현하고, PageHelper 문서가 finally { PageHelper.clearPage(); } 를 권하는 이유가 이것입니다.
| 대상 | 메서드 | 가로채면 할 수 있는 것 |
|---|---|---|
Executor |
update, query, commit, rollback |
실행 시간 측정, 캐시 키 변경, 읽기/쓰기 DB 분기 |
ParameterHandler |
setParameters |
파라미터 암호화, 감사 필드(등록자·등록일) 자동 주입 |
ResultSetHandler |
handleResultSets |
결과 복호화, 마스킹 |
StatementHandler |
prepare, parameterize, query |
SQL 문자열 교체(페이징, 테넌트 조건 추가), 슬로우 쿼리 로깅 |
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}))
public class PageInterceptor implements Interceptor {
public Object intercept(Invocation inv) throws Throwable {
Page page = Page.CURRENT.get();
if (page == null) return inv.proceed();
Page.CURRENT.remove();
StatementHandler handler = (StatementHandler) inv.getTarget();
String sql = handler.getBoundSql().getSql();
// COUNT: 같은 ? 바인딩을 재사용해 총건수
try (PreparedStatement ps = ((Connection) inv.getArgs()[0]).prepareStatement("SELECT COUNT(*) FROM (" + sql + ") page_cnt")) {
handler.parameterize(ps);
try (ResultSet rs = ps.executeQuery()) { rs.next(); page.total(rs.getLong(1)); }
}
// 원본 SQL 교체: boundSql.sql 은 final 필드라 MetaObject(리플렉션) 로
SystemMetaObject.forObject(handler).setValue("delegate.boundSql.sql", sql + " OFFSET " + page.offset() + " ROWS FETCH NEXT " + page.size + " ROWS ONLY");
return inv.proceed();
}
public Object plugin(Object target) { return Plugin.wrap(target, this); }
}delegate.boundSql.sql 이라는 경로는 RoutingStatementHandler → delegate(PreparedStatementHandler) → boundSql → sql 필드를 따라간 것입니다.
PageHelper 초기 버전이 정확히 이 방식이었고, 현재는 Executor.query 를 가로채 새 BoundSql 과 CacheKey 를 만드는 방식으로 바뀌었습니다(연습 문제 3). 플러그인은 mybatis-config.xml 의 <plugins> 에, Spring 에서는 Interceptor 빈으로 등록하면 자동 구성이 붙여 줍니다.
# application.yml
spring:
datasource:
url: jdbc:oracle:thin:@//db-host:1521/ORCL
username: app
password: ${DB_PASSWORD}
hikari.maximum-pool-size: 20
mybatis:
config-location: classpath:mybatis-config.xml # 전역 설정을 XML 로 유지하거나
configuration: # 또는 yml 에서 직접
map-underscore-to-camel-case: true
jdbc-type-for-null: 'NULL'
mapper-locations: classpath:mapper/${db.vendor}/**/*.xml # 방언 폴더를 프로필로 선택
type-aliases-package: kr.co.shop.model
type-handlers-package: kr.co.shop.handler
pagehelper:
helper-dialect: oracle
reasonable: true # 범위 밖 페이지 번호를 보정@MapperScan("kr.co.shop.mapper") // conf.addMappers() — 매퍼 프록시를 빈으로
@SpringBootApplication
public class App {}
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberMapper mapper; // SqlSessionTemplate 을 감싼 프록시. 스레드 안전
@Transactional(readOnly = true)
public PageInfo<Member> list(MemberSearch cond, int page, int size) {
PageHelper.startPage(page, size);
return new PageInfo<>(mapper.search(cond)); // startPage 직후의 첫 조회에만 적용
}
@Transactional
public void changeGrade(long id, String grade) {
mapper.updateSelective(patch(id, grade)); // 세션 open/commit/close 가 전부 사라졌다
}
}| 코어 MyBatis (이 레슨) | Spring Boot + mybatis-spring |
|---|---|
mybatis-config.xml 의 <environments> |
spring.datasource.* + HikariCP 자동 구성 |
<transactionManager type="JDBC"/> |
SpringManagedTransaction. @Transactional 이 커밋·롤백 |
<mappers> |
mybatis.mapper-locations + @MapperScan |
<typeAliases>, <typeHandlers> |
type-aliases-package, type-handlers-package |
<plugins> |
Interceptor 빈 등록. PageHelper 스타터는 자동 |
factory.openSession() / commit() / close() |
SqlSessionTemplate 이 트랜잭션 범위에 맞춰 관리 |
<databaseIdProvider> |
DatabaseIdProvider 빈 또는 프로필별 mapper-locations |
레슨 05 변형 4 에서 본 것과 같은 그림입니다. 다른 점은 mapper-locations 의 ${db.vendor} 처럼 XML 위치를 프로필로 바꾸는 것이 방언 대응의 실무 표준이라는 것과, PageHelper 스타터가 인터셉터 등록을 대신한다는 것입니다.
jdbc.url=jdbc:h2:mem:xmlshop;MODE=Oracle;DB_CLOSE_DELAY=-1MODE=Oracle 을 켜면 H2 가 NUMBER(19), VARCHAR2(50), DATE(시각 포함), DUAL, ROWNUM, NVL, SYSDATE, seq.NEXTVAL, MERGE USING 을 받아들입니다. 이 레슨의 DDL 과 XML 은 전부 Oracle 문법으로 썼고 H2 에서 그대로 돌아갑니다. 단위 테스트에서 실제 Oracle 없이 매퍼 XML 을 검증하는 데 실무에서도 쓰는 방법입니다.
완벽하지는 않습니다.
databaseIdProvider 는 여전히 제품명 "H2" 를 돌려주므로 databaseId="oracle" statement 는 로드되지 않고 h2 버전이 로드됩니다(예제 5).
USING (SELECT ? AS col FROM DUAL) 처럼 바인드 변수만 있는 인라인 뷰는 H2 가 컬럼 타입을 추론하지 못해 CAST(#{x} AS NUMBER(19)) 로 명시해야 했습니다(Oracle 에서도 유효한 SQL 이니 손해는 없습니다). 분석 함수, 계층 쿼리(CONNECT BY), PL/SQL 은 지원 범위가 좁습니다.
"Oracle 문법 80% 검증용"으로 생각하면 정확합니다.