공공부하자개발 · 영어 학습 노트
자바
실무 확장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정리‹ 이전다음 ›

6. 연습 문제

문제 1

OrderMapper 에 주문 → 회원(N:1) <association> 을 추가하세요.

Order 는 record 라 setter 가 없으므로 member 필드를 가진 OrderView 클래스를 새로 만들고, (a) <association select="MemberMapper.findById"> 버전과 (b) JOIN + columnPrefix="m_" 버전 findAllView 를 작성하세요.

로그를 켜서 주문 4건 조회 시 SQL 이 각각 몇 번 실행되는지 확인하세요.

정답 보기
java
public class OrderView {
    private long id; private BigDecimal amount; private String status; private Member member;
    // getter/setter 생략
    @Override public String toString() { return "Order#" + id + " " + amount.toPlainString() + " by " + (member == null ? "?" : member.getName()); }
}
xml
<!-- (a) 추가 조회: 주문 4건 → 회원 조회 4회. 같은 회원(1번)이 3번 조회되지만 1차 캐시가 2·3번째를 막아 실제 SQL 은 1 + 2 = 3번 -->
<resultMap id="orderViewNested" type="OrderView">
    <id     property="id"     column="id"/>
    <result property="amount" column="amount"/>
    <result property="status" column="status"/>
    <association property="member" column="member_id" javaType="Member" select="MemberMapper.findById"/>
</resultMap>
<select id="findAllViewNested" resultMap="orderViewNested">
    SELECT id, member_id, amount, status FROM orders ORDER BY id
</select>

<!-- (b) JOIN 한 번 -->
<resultMap id="orderViewJoin" type="OrderView" autoMapping="true">
    <id property="id" column="id"/>
    <association property="member" javaType="Member" columnPrefix="m_" resultMap="MemberMapper.memberMap"/>
</resultMap>
<select id="findAllView" resultMap="orderViewJoin">
    SELECT o.id, o.amount, o.status,
           m.id AS m_id, m.name AS m_name, m.email AS m_email, m.grade AS m_grade,
           m.joined_at AS m_joined_at, m.balance AS m_balance, m.active_yn AS m_active_yn
    FROM orders o JOIN member m ON m.id = o.member_id
    ORDER BY o.id
</select>
java
LogFactory.useStdOutLogging();
System.out.println(om.findAllViewNested());   // 로그: orders 1회 + member 조회 (1차 캐시로 회원 1 은 한 번만) → 총 3회
System.out.println(om.findAllView());         // 로그: 1회
// 출력: [Order#1 75000 by 김철수, Order#2 25000 by 김철수, Order#3 12000 by 김철수, Order#4 50000 by 이영희]  (두 방식 동일)

(a)의 SQL 횟수가 5가 아닌 3인 것이 흥미로운 지점입니다. 같은 세션 안이라 findById(1) 두 번째부터는 1차 캐시가 막습니다. 그래도 회원이 100명 다르면 101번입니다.

MemberMapper.memberMap 은 column="active_yn" 을 명시하고 있는데 columnPrefix="m_" 가 붙으면 m_active_yn 을 찾으므로 SELECT 별칭을 그에 맞춰야 합니다. 접두어는 resultMap 안의 모든 column 에 일괄 적용됩니다.

문제 2

search 에 키셋 페이징(마지막으로 본 id 이후 N건)을 추가하고, 조사한 실무 프로젝트처럼 resources/mapper/{oracle,h2}/MemberMapper.xml 두 벌로 분리해 jdbc.properties 의 db.vendor 값으로 로드되는 폴더가 바뀌게 만드세요.

oracle 폴더의 SQL 은 ROWNUM 을, h2 폴더는 FETCH 를 쓰되 namespace 와 id 는 같아야 합니다. db.vendor=h2 로 실행해 동작을 확인하고, oracle 폴더 XML 은 파싱만 되는지 SqlSessionFactoryBuilder 로 빌드해 확인하세요.

정답 보기
properties
# jdbc.properties
db.vendor=h2
xml
<!-- mybatis-config.xml -->
<mappers>
    <mapper resource="mapper/${db.vendor}/MemberMapper.xml"/>
    <mapper resource="mapper/${db.vendor}/OrderMapper.xml"/>
</mappers>
xml
<!-- mapper/h2/MemberMapper.xml -->
<select id="searchAfter" resultMap="memberMap">
    SELECT <include refid="memberColumns"/> FROM member
    <where>
        <include refid="searchConditions"/>            <!-- WHERE 안쪽 조건만 담은 조각 (searchWhere 에서 <where> 를 뺀 것) -->
        <if test="lastId != null">AND id > #{lastId}</if>
    </where>
    ORDER BY id
    FETCH FIRST #{size} ROWS ONLY
</select>

<!-- mapper/oracle/MemberMapper.xml : 같은 namespace, 같은 id -->
<select id="searchAfter" resultMap="memberMap">
    SELECT * FROM (
        SELECT <include refid="memberColumns"/> FROM member
        <where>
            <include refid="searchConditions"/>
            <if test="lastId != null">AND id > #{lastId}</if>
        </where>
        ORDER BY id
    )
    <![CDATA[ WHERE ROWNUM <= #{size} ]]>
</select>
java
// 호출: 첫 페이지 lastId=null, 이후 마지막 행의 id 를 넘긴다
Map<String, Object> c = new HashMap<>(Map.of("size", 2, "activeOnly", true));
List<Member> p1 = m.searchAfter(c);                              // [1:김철수, 2:이영희]
c.put("lastId", p1.get(p1.size() - 1).getId());
List<Member> p2 = m.searchAfter(c);                              // [4:홍길동]

// oracle 폴더 파싱 확인: 프로퍼티만 바꿔 빌드 (H2 에 접속해도 파싱은 파싱)
Properties p = new Properties(); p.setProperty("db.vendor", "oracle");
try (Reader r = Resources.getResourceAsReader("mybatis-config.xml")) {
    SqlSessionFactory oracleShaped = new SqlSessionFactoryBuilder().build(r, p);   // 두 번째 인자로 프로퍼티 덮어쓰기
    System.out.println(oracleShaped.getConfiguration().hasStatement("MemberMapper.searchAfter"));   // true
}

키셋 페이징은 OFFSET 이 커질수록 느려지는 문제(앞 페이지를 전부 읽고 버림)가 없어 무한 스크롤과 배치 순회(레슨 05 예제 8 의 pageAfter)에 쓰입니다. 대신 "37페이지로 점프"는 불가능합니다.

폴더 분리에서 build(reader, properties) 의 두 번째 인자는 <properties> 를 덮어쓰므로 테스트에서 벤더를 바꿔 XML 이 전부 파싱되는지 확인하는 데 유용합니다. SQL 실행은 못 해도 "XML 문법 오류, id 누락, resultMap 참조 오류"는 파싱 단계에서 잡힙니다.

문제 3

예제 4 의 PageInterceptor 는 1차 캐시와 충돌합니다(페이징된 결과가 캐시되어 같은 조회가 2건을 돌려줌). PageHelper 의 현재 방식대로 Executor.query 를 가로채 새 BoundSql 과 새 CacheKey 를 만들어 이 문제를 해결하세요.

힌트: Executor.query 의 6인자 오버로드 (MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql) 를 가로채고, executor.createCacheKey(ms, param, rowBounds, newBoundSql) 로 키를 다시 만듭니다.

정답 보기
java
@Intercepts(@Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}))
public class PageInterceptor2 implements Interceptor {

    @Override
    public Object intercept(Invocation inv) throws Throwable {
        Page page = Page.CURRENT.get();
        if (page == null) return inv.proceed();
        Page.CURRENT.remove();

        Executor executor = (Executor) inv.getTarget();
        MappedStatement ms = (MappedStatement) inv.getArgs()[0];
        Object param = inv.getArgs()[1];
        RowBounds rowBounds = (RowBounds) inv.getArgs()[2];
        ResultHandler<?> handler = (ResultHandler<?>) inv.getArgs()[3];
        BoundSql original = (BoundSql) inv.getArgs()[5];

        // ① COUNT: 별도 BoundSql 로 실행 (파라미터 매핑은 그대로 재사용)
        BoundSql countSql = new BoundSql(ms.getConfiguration(), "SELECT COUNT(*) FROM (" + original.getSql() + ") page_cnt",
                original.getParameterMappings(), param);
        copyAdditionalParams(original, countSql);
        MappedStatement countMs = countStatement(ms);                                   // resultType long 인 statement 를 복제 생성
        CacheKey countKey = executor.createCacheKey(countMs, param, RowBounds.DEFAULT, countSql);
        List<Object> cnt = executor.query(countMs, param, RowBounds.DEFAULT, null, countKey, countSql);
        page.total(((Number) cnt.get(0)).longValue());

        // ② 페이징 SQL: 새 BoundSql + 새 CacheKey → 원본 조회와 캐시가 분리된다
        BoundSql pageSql = new BoundSql(ms.getConfiguration(),
                original.getSql() + " OFFSET " + page.offset() + " ROWS FETCH NEXT " + page.size + " ROWS ONLY",
                original.getParameterMappings(), param);
        copyAdditionalParams(original, pageSql);
        CacheKey pageKey = executor.createCacheKey(ms, param, rowBounds, pageSql);      // SQL 문자열이 키에 들어가므로 원본과 다른 키
        return executor.query(ms, param, rowBounds, handler, pageKey, pageSql);
    }

    /** <foreach> 등 동적 SQL 이 만든 추가 파라미터(__frch_g_0 …)를 새 BoundSql 로 복사 */
    private static void copyAdditionalParams(BoundSql from, BoundSql to) {
        MetaObject mo = SystemMetaObject.forObject(from);
        @SuppressWarnings("unchecked")
        Map<String, Object> additional = (Map<String, Object>) mo.getValue("additionalParameters");
        additional.forEach(to::setAdditionalParameter);
    }

    /** 원본 statement 의 설정을 물려받되 결과 타입만 long 인 count 용 statement */
    private static MappedStatement countStatement(MappedStatement ms) {
        ResultMap rm = new ResultMap.Builder(ms.getConfiguration(), ms.getId() + "_COUNT_RM", Long.class, List.of()).build();
        return new MappedStatement.Builder(ms.getConfiguration(), ms.getId() + "_COUNT", ms.getSqlSource(), ms.getSqlCommandType())
                .resultMaps(List.of(rm)).build();
    }

    @Override public Object plugin(Object target) { return Plugin.wrap(target, this); }
}
java
// 검증: 예제 4 와 같은 순서, clearCache 없이
Page.start(2, 2);
System.out.println(names(m.findAll()));            // [3:박민수(개명), 4:홍길동]
System.out.println(m.findAll().size());            // 4  ← 캐시 키가 달라 원본 조회가 실행된다

핵심은 executor.createCacheKey(ms, param, rowBounds, pageSql) 입니다. CacheKey 는 statement id, RowBounds, SQL 문자열, 파라미터 값으로 만들어지므로 SQL 이 다르면 키가 다릅니다.

StatementHandler 레벨에서는 키가 이미 계산된 뒤라 손댈 수 없었고, Executor 레벨에서는 키를 만드는 주체가 우리라서 해결됩니다.

copyAdditionalParams 는 <foreach> 가 만든 __frch_g_0 같은 숨은 파라미터를 옮기는 것으로, 이걸 빼먹으면 IN 절이 있는 조회에서 "파라미터를 찾을 수 없음" 오류가 납니다. PageHelper 소스의 ExecutorUtil 이 정확히 이 일을 합니다.

count 용 MappedStatement 복제는 실제 PageHelper 가 하는 방식 그대로이며, 이 부분이 라이브러리 코드의 절반을 차지합니다.

연습 문제
  • 문제 1
  • 문제 2
  • 문제 3
이전 섹션5 자주 하는 실수 (Tip)6 / 7다음 섹션7 정리