공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 06 / 22

MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 XML 두 종류와 로딩 순서

파일 역할 실무에서 대체하는 것
mybatis-config.xml 전역 설정: 접속, settings, 별칭, 핸들러, 플러그인, 매퍼 목록 application.yml 의 mybatis.* 키 + 자동 구성
mapper/*Mapper.xml SQL 본문. namespace 하나 = 매퍼 인터페이스 하나 그대로 사용. mybatis.mapper-locations 로 위치만 지정
java
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 는 이런 경우 미완성 요소를 보류해 두고 나중에 다시 시도하므로 순서 걱정은 안 해도 됩니다.

2.2 namespace 와 id — 인터페이스와 XML 의 연결

xml
<mapper namespace="MemberMapper">           <!-- 패키지가 있으면 shop.mapper.MemberMapper -->
    <select id="findById" resultMap="memberMap">   <!-- id = 인터페이스 메서드 이름 -->
java
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줄이면 됩니다).

2.3 resultMap — 결과 매핑의 전부

xml
<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 단위로 켜는 것이 안전합니다.

2.4 동적 SQL 태그

태그 하는 일 예
<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
xml
<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> 로 허용 값을 나열합니다. 인젝션이 구조적으로 불가능합니다.

2.5 XML 이스케이프 — < 와 &

매퍼 XML 안의 SQL 도 XML 텍스트입니다. <, & 는 파서가 태그·엔티티로 해석하므로 그대로 쓸 수 없습니다. > 는 규격상 허용됩니다.

쓰려는 것 방법 1: 엔티티 방법 2: CDATA
a < b a &lt; b <![CDATA[ a < b ]]>
a <= b a &lt;= b <![CDATA[ a <= b ]]>
a >= b 그대로 그대로
`a b` (Oracle 문자열 연결)
a & b a &amp; b <![CDATA[ a & b ]]>
xml
<if test="to != null"><![CDATA[ AND joined_at <= #{to} ]]></if>

비교 연산자가 많은 SQL 은 CDATA 로 문장 전체를 감싸는 것이 읽기 좋습니다. 단, CDATA 안에서는 <if> 같은 동적 태그가 동작하지 않습니다. 동적 태그는 CDATA 밖에 두고, 비교식이 든 조각만 CDATA 로 감쌉니다.

2.6 Oracle 방언과 databaseId

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 가 없는 버전은 기본값이 됩니다.

xml
<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 를 다룹니다.

2.7 페이징 3종과 PageHelper 의 원리

sql
-- ① 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 의 원리는 세 단계입니다.

text
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(); } 를 권하는 이유가 이것입니다.

2.8 플러그인(Interceptor) — MyBatis 의 4개 가로채기 지점

대상 메서드 가로채면 할 수 있는 것
Executor update, query, commit, rollback 실행 시간 측정, 캐시 키 변경, 읽기/쓰기 DB 분기
ParameterHandler setParameters 파라미터 암호화, 감사 필드(등록자·등록일) 자동 주입
ResultSetHandler handleResultSets 결과 복호화, 마스킹
StatementHandler prepare, parameterize, query SQL 문자열 교체(페이징, 테넌트 조건 추가), 슬로우 쿼리 로깅
java
@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 빈으로 등록하면 자동 구성이 붙여 줍니다.

2.9 Spring Boot 가 대신하는 것

yaml
# 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                                      # 범위 밖 페이지 번호를 보정
java
@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 스타터가 인터셉터 등록을 대신한다는 것입니다.

2.10 H2 Oracle 모드 — 폐쇄망에서 Oracle SQL 을 돌리는 방법

properties
jdbc.url=jdbc:h2:mem:xmlshop;MODE=Oracle;DB_CLOSE_DELAY=-1

MODE=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% 검증용"으로 생각하면 정확합니다.

핵심 원리
  • 2.1 XML 두 종류와 로딩 순서
  • 2.2 namespace 와 id — 인터페이스와 XML 의 연결
  • 2.3 resultMap — 결과 매핑의 전부
  • 2.4 동적 SQL 태그
  • 2.5 XML 이스케이프 — < 와 &
  • 2.6 Oracle 방언과 databaseId
  • 2.7 페이징 3종과 PageHelper 의 원리
  • 2.8 플러그인(Interceptor) — MyBatis 의 4개 가로채기 지점
  • 2.9 Spring Boot 가 대신하는 것
  • 2.10 H2 Oracle 모드 — 폐쇄망에서 Oracle SQL 을 돌리는 방법
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제