OrderMapper 에 주문 → 회원(N:1) <association> 을 추가하세요.
Order 는 record 라 setter 가 없으므로 member 필드를 가진 OrderView 클래스를 새로 만들고, (a) <association select="MemberMapper.findById"> 버전과 (b) JOIN + columnPrefix="m_" 버전 findAllView 를 작성하세요.
로그를 켜서 주문 4건 조회 시 SQL 이 각각 몇 번 실행되는지 확인하세요.
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()); }
}<!-- (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>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 에 일괄 적용됩니다.
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 로 빌드해 확인하세요.
# jdbc.properties
db.vendor=h2<!-- mybatis-config.xml -->
<mappers>
<mapper resource="mapper/${db.vendor}/MemberMapper.xml"/>
<mapper resource="mapper/${db.vendor}/OrderMapper.xml"/>
</mappers><!-- 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>// 호출: 첫 페이지 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 참조 오류"는 파싱 단계에서 잡힙니다.
예제 4 의 PageInterceptor 는 1차 캐시와 충돌합니다(페이징된 결과가 캐시되어 같은 조회가 2건을 돌려줌). PageHelper 의 현재 방식대로 Executor.query 를 가로채 새 BoundSql 과 새 CacheKey 를 만들어 이 문제를 해결하세요.
힌트: Executor.query 의 6인자 오버로드 (MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql) 를 가로채고, executor.createCacheKey(ms, param, rowBounds, newBoundSql) 로 키를 다시 만듭니다.
@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); }
}// 검증: 예제 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 가 하는 방식 그대로이며, 이 부분이 라이브러리 코드의 절반을 차지합니다.