조사한 프로젝트는 databaseId 대신 폴더를 나눕니다.
src/main/resources/mapper/
├── oracle/AT/AuditMapper.xml ← 같은 namespace, 같은 id, SQL 만 다르다
├── maria/AT/AuditMapper.xml
└── tibero/AT/AuditMapper.xml# application-oracle.yml
mybatis.mapper-locations: classpath:mapper/oracle/**/*.xml
# application-maria.yml
mybatis.mapper-locations: classpath:mapper/maria/**/*.xmljava -jar app.jar --spring.profiles.active=prd,oracle코어 MyBatis 로 같은 일을 하려면 mybatis-config.xml 의 <mappers> 를 프로퍼티로 바꿉니다.
<properties resource="jdbc.properties"/> <!-- db.vendor=oracle -->
<mappers>
<mapper resource="mapper/${db.vendor}/MemberMapper.xml"/>
<mapper resource="mapper/${db.vendor}/OrderMapper.xml"/>
</mappers>databaseId (방법 A) |
폴더 분리 (방법 B) | |
|---|---|---|
| 중복 | 다른 SQL 만 여러 벌. 공통 SQL 은 한 벌 | 파일 전체가 벤더 수만큼 |
| 검토 | 한 파일에 벤더별 SQL 이 섞여 DBA 가 읽기 불편 | DBA 가 자기 벤더 폴더만 검토 |
| 실수 | 한 벤더 버전을 빼먹으면 기본(databaseId 없는) 버전이 조용히 실행 | 파일이 없으면 기동 시 statement 누락으로 즉시 실패 |
| 적합 | 방언 차이가 소수 statement | 방언 차이가 넓고, 벤더별 납품 |
두 방법을 섞어, 공통 폴더 + 벤더 폴더를 둘 다 mapper-locations 에 넣고 벤더 폴더의 XML 이 같은 id 를 덮어쓰게 하는 변형도 있습니다. 단, MyBatis 는 같은 namespace.id 가 두 번 등록되면 예외를 내므로 공통 파일에서 해당 id 를 빼야 합니다.
// pom.xml: com.github.pagehelper:pagehelper-spring-boot-starter:1.4.7
// application.yml: pagehelper.helper-dialect=oracle, pagehelper.reasonable=true, pagehelper.support-methods-arguments=true
// ① 기본형
PageHelper.startPage(pageNo, pageSize); // ThreadLocal 지시
List<Member> list = mapper.search(cond); // 바로 다음 조회에만 적용
PageInfo<Member> info = new PageInfo<>(list); // total, pages, hasNextPage, navigatepageNums …
// ② 안전형: 조회 전에 예외가 나도 지시가 남지 않게
try {
PageHelper.startPage(pageNo, pageSize);
return new PageInfo<>(mapper.search(cond));
} finally {
PageHelper.clearPage();
}
// ③ count 쿼리 직접 제공: 자동 생성된 SELECT COUNT(*) FROM (복잡한 SQL) 이 느릴 때
// XML 에 id="search_COUNT" 를 만들면 PageHelper 가 자동 count 대신 이것을 쓴다<select id="search_COUNT" resultType="_long">
SELECT COUNT(*) FROM member <include refid="searchWhere"/>
</select>주의점 세 가지. 첫째, startPage 와 조회 사이에 다른 조회를 넣지 않습니다. 첫 조회에 적용되고 끝입니다. 둘째, startPage 뒤의 조회가 List 를 반환해야 합니다. 단건 selectOne 이나 count 에 걸리면 엉뚱한 결과가 나옵니다.
셋째, 트랜잭션 없는 컨트롤러에서 startPage 를 부르고 서비스 안에서 조회하는 구조는 스레드가 같으므로 동작하지만, 비동기(@Async, CompletableFuture)로 넘어가면 ThreadLocal 이 끊겨 적용되지 않습니다.
예제 4 의 인터셉터가 Page.CURRENT ThreadLocal 로 같은 구조라는 것을 알면 세 주의점이 전부 당연해집니다.
<!-- MariaDB / H2 기본: VALUES (...), (...), (...) -->
<insert id="insertAll" databaseId="maria">
INSERT INTO member (id, name, email, grade, joined_at, balance, active_yn) VALUES
<foreach collection="list" item="m" separator=",">
(#{m.id}, #{m.name}, #{m.email}, #{m.grade}, #{m.joinedAt}, #{m.balance}, #{m.active})
</foreach>
</insert>
<!-- Oracle: 다중 VALUES 문법이 없다. INSERT ALL INTO … SELECT 1 FROM DUAL -->
<insert id="insertAll" databaseId="oracle">
INSERT ALL
<foreach collection="list" item="m">
INTO member (id, name, email, grade, joined_at, balance, active_yn)
VALUES (member_seq.NEXTVAL, #{m.name}, #{m.email}, #{m.grade}, #{m.joinedAt}, #{m.balance}, #{m.active})
</foreach>
SELECT 1 FROM DUAL
</insert>Oracle 의 INSERT ALL 에서 시퀀스 NEXTVAL 은 문장 전체에서 한 번만 평가되어 모든 행이 같은 id 를 받는 함정이 있습니다.
실무에서는 INSERT INTO … SELECT member_seq.NEXTVAL, … FROM (SELECT … FROM DUAL UNION ALL SELECT … FROM DUAL) 형태로 우회하거나, 애플리케이션에서 시퀀스를 미리 받아 #{m.id} 로 넘깁니다. 이 정도 차이가 쌓이면 변형 1 의 폴더 분리가 낫습니다.
어느 쪽이든 1000건 단위로 잘라 실행합니다. Oracle 은 바인드 변수 상한이 있고, 한 문장이 너무 커지면 파싱 시간이 실행 시간을 넘습니다. 대량이면 레슨 05 의 ExecutorType.BATCH 가 정답입니다.
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class SlowQueryInterceptor implements Interceptor {
private final long thresholdMs;
public SlowQueryInterceptor(long thresholdMs) { this.thresholdMs = thresholdMs; }
@Override public Object intercept(Invocation inv) throws Throwable {
long t0 = System.nanoTime();
try {
return inv.proceed();
} finally {
long ms = (System.nanoTime() - t0) / 1_000_000;
if (ms >= thresholdMs) {
MappedStatement ms0 = (MappedStatement) inv.getArgs()[0];
String sql = ms0.getBoundSql(inv.getArgs()[1]).getSql().replaceAll("\\s+", " ");
System.err.printf("[SLOW %d ms] %s : %s%n", ms, ms0.getId(), sql);
}
}
}
@Override public Object plugin(Object target) { return Plugin.wrap(target, this); }
@Override public void setProperties(Properties p) { } // <plugin> 의 <property> 로 threshold 를 받을 수도 있다
}<plugins>
<plugin interceptor="SlowQueryInterceptor"><property name="thresholdMs" value="500"/></plugin>
<plugin interceptor="PageInterceptor"/>
</plugins>Executor 레벨은 statement id(MemberMapper.search)를 알 수 있어 "어느 매퍼의 어느 SQL 이 느린가"를 바로 찍습니다.
조사한 프로젝트는 Logback SiftingAppender 로 app 로그와 batch 로그를 파일로 나누는데, 이 인터셉터의 출력을 별도 로거(slow-sql)로 보내면 운영에서 튜닝 대상을 자동으로 모으는 슬로우 쿼리 로그가 됩니다. 플러그인은 등록 순서의 역순으로 감싸이므로 여러 개를 쓸 때 순서에 의미가 있습니다.