실행 방법. . 이 클래스패스에 있어야 mybatis-config.xml 과 mapper/*.xml 을 찾습니다.
cd java-src\extension\06_mybatis_xml
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 -cp "..\..\lib\*" *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 -cp "..\..\lib\*;." MainSqlSessionFactory factory;
try (Reader r = Resources.getResourceAsReader("mybatis-config.xml")) {
factory = new SqlSessionFactoryBuilder().build(r);
}
Configuration conf = factory.getConfiguration();
System.out.println("databaseId = " + conf.getDatabaseId());
System.out.println("등록 매퍼 = " + conf.getMapperRegistry().getMappers());
System.out.println("statement = " + conf.getMappedStatementNames().stream().filter(n -> n.startsWith("MemberMapper.")).sorted().toList());
System.out.println("플러그인 = " + conf.getInterceptors());
// 출력:
// databaseId = h2 (databaseIdProvider 가 H2 → h2 로 매핑)
// 등록 매퍼 = [interface OrderMapper, interface MemberMapper]
// statement = [MemberMapper.count, MemberMapper.countSearch, …, MemberMapper.insert, MemberMapper.insert!selectKey, …, MemberMapper.updateSelective]
// 플러그인 = [PageInterceptor@57bc27f5]insert!selectKey 라는 statement 가 따로 보입니다. <selectKey> 는 내부적으로 별도 statement 로 등록되어 INSERT 전(order="BEFORE")에 실행됩니다. findRecent 는 XML 에 3개(oracle/h2/maria)를 썼지만 목록에 하나만 있습니다.
databaseId 가 맞는 것만 로드됐기 때문입니다. Configuration 객체를 이렇게 들여다보는 것이 "내 XML 이 제대로 읽혔나"를 확인하는 가장 빠른 방법입니다.
<insert id="insert" parameterType="Member">
<selectKey keyProperty="id" resultType="long" order="BEFORE">
SELECT member_seq.NEXTVAL FROM DUAL
</selectKey>
INSERT INTO member (id, name, email, grade, joined_at, balance, active_yn)
VALUES (#{id}, #{name}, #{email}, #{grade}, #{joinedAt}, #{balance}, #{active})
</insert>
<update id="updateSelective" parameterType="Member">
UPDATE member
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
<if test="grade != null">grade = #{grade},</if>
<if test="balance != null">balance = #{balance},</if>
</set>
WHERE id = #{id}
</update>for (Member x : List.of(kim, lee, park, hong)) m.insert(x);
s.commit();
System.out.println("selectKey 로 채워진 id: " + kim.getId() + ", " + lee.getId() + ", " + park.getId() + ", " + hong.getId());
System.out.println("findById(3): " + m.findById(3));
Member patch = new Member(); // 이름·잔액만. grade/email 은 null → <set> 에서 제외
patch.setId(3L); patch.setName("박민수(개명)"); patch.setBalance(new BigDecimal("999"));
System.out.println("updateSelective 영향 행: " + m.updateSelective(patch) + " → " + m.findById(3));
// 출력:
// selectKey 로 채워진 id: 1, 2, 3, 4
// findById(3): Member{id=3, name=박민수, grade=BRONZE, joinedAt=2025-03-20, balance=0, active=false}
// updateSelective 영향 행: 1 → Member{id=3, name=박민수(개명), grade=BRONZE, joinedAt=2025-03-20, balance=999, active=false}
// count=4Oracle 에는 IDENTITY 대신 시퀀스가 있어 <selectKey order="BEFORE"> 로 먼저 번호를 받아 m.id 에 넣고 INSERT 에서 #{id} 로 씁니다. MariaDB 라면 order="AFTER" 로 SELECT LAST_INSERT_ID() 를 하거나 useGeneratedKeys 를 씁니다.
active=false 가 active_yn='N' 으로 저장되고 다시 false 로 읽힌 것은 <typeHandlers> 에 등록한 YesNoTypeHandler 덕분입니다.
부분 갱신에서 patch 의 grade 는 기본값 "BRONZE" 가 아니라 명시적으로 null 이어야 SET 에서 빠지는데, 이 예제의 Member 는 필드 초기값이 "BRONZE" 라 사실 grade 도 갱신됩니다. 결과가 우연히 같아 티가 안 났을 뿐입니다. 부분 갱신용 DTO 는 초기값 없는 별도 클래스로 두는 것이 맞습니다.
System.out.println("조건 없음 : " + names(m.search(Map.of())) + " / count " + m.countSearch(Map.of()));
System.out.println("name LIKE 이 (bind) : " + names(m.search(Map.of("name", "이"))));
System.out.println("grade IN (foreach) : " + names(m.search(Map.of("grades", List.of("GOLD", "SILVER")))));
System.out.println("2025년 가입 (CDATA) : " + names(m.search(Map.of("from", LocalDate.of(2025, 1, 1), "to", LocalDate.of(2025, 12, 31)))));
System.out.println("activeOnly (choose) : " + names(m.search(Map.of("activeOnly", true))));
System.out.println("inactiveOnly : " + names(m.search(Map.of("inactiveOnly", true))));
System.out.println("sort=balance : " + names(m.search(Map.of("sort", "balance"))));
Map<String, Object> combo = new HashMap<>(Map.of("grades", List.of("GOLD", "SILVER", "BRONZE"), "activeOnly", true, "sort", "name"));
System.out.println("복합 조건 : " + names(m.search(combo)) + " / count " + m.countSearch(combo));
// 출력:
// 조건 없음 : [1:김철수, 2:이영희, 3:박민수(개명), 4:홍길동] / count 4
// name LIKE 이 (bind) : [2:이영희]
// grade IN (foreach) : [1:김철수, 2:이영희]
// 2025년 가입 (CDATA) : [3:박민수(개명)]
// activeOnly (choose) : [1:김철수, 2:이영희, 4:홍길동]
// inactiveOnly : [3:박민수(개명)]
// sort=balance : [1:김철수, 2:이영희, 4:홍길동, 3:박민수(개명)]
// 복합 조건 : [1:김철수, 2:이영희, 4:홍길동] / count 3파라미터가 Map 이라 #{name} 은 map.get("name") 입니다. <if test="name != null"> 에서 키가 없으면 null 이라 조건이 빠집니다. "조건 없음"에서 <where> 가 WHERE 절 자체를 생략했고, "복합 조건"에서는 세 조건이 AND 로 이어졌습니다.
목록과 countSearch 가 같은 searchWhere 조각을 쓰므로 count 3 이 목록 3건과 일치합니다. sort=balance 는 <choose> 의 when 에 걸려 balance DESC 가 됐고, 잔액 0 인 박민수가 마지막입니다.
System.out.println("ROWNUM 2페이지(2건) : " + names(m.pageByRownum(2, 4))); // start=2, end=4 → 3,4번째 행
System.out.println("OFFSET 2페이지(2건) : " + names(m.pageByOffset(2, 2)));
Page.start(2, 2); // 다음 한 번의 조회에만 적용
List<Member> page = m.findAll(); // 매퍼 시그니처 그대로. SQL 에 OFFSET/FETCH 가 붙는다
System.out.println("인터셉터 2페이지(2건) : " + names(page) + " / 총 " + Page.lastTotal() + "건 (COUNT 자동 실행)");
System.out.println("clearCache 없이 findAll: " + m.findAll().size() + "건 ← 1차 캐시 함정!");
s.clearCache();
System.out.println("clearCache 후 findAll : " + m.findAll().size() + "건");
s.clearCache();
Page.start(3, 500);
System.out.println("범위 밖 3페이지 : " + names(m.findAll()) + " / 총 " + Page.lastTotal());
// 출력:
// ROWNUM 2페이지(2건) : [3:박민수(개명), 4:홍길동]
// OFFSET 2페이지(2건) : [3:박민수(개명), 4:홍길동]
// 인터셉터 2페이지(2건) : [3:박민수(개명), 4:홍길동] / 총 4건 (COUNT 자동 실행)
// clearCache 없이 findAll: 2건 ← 1차 캐시 함정! 캐시 키는 원본 SQL 로 계산돼 페이징된 결과가 그대로 돌아온다
// clearCache 후 findAll : 4건 (지시는 소비됐으므로 페이징 없음)
// 범위 밖 3페이지 : [] / 총 4세 방식이 같은 결과를 냅니다. 인터셉터 버전은 매퍼 XML 에 페이징 SQL 이 한 줄도 없는데 총건수까지 나왔습니다. 그리고 바로 함정이 나옵니다. 페이징된 findAll 직후 같은 세션에서 findAll 을 다시 부르면 2건이 나옵니다. 1차 캐시 키가 인터셉터가 SQL 을 바꾸기 전의 원본 SQL 로 계산되어, 두 조회가 같은 키를 갖기 때문입니다. clearCache() 를 하면 4건으로 돌아옵니다.
이 레슨을 처음 작성할 때 두 번째 clearCache() 가 없었고, 그러자 "범위 밖 3페이지" 조회가 캐시에서 돌아와 SQL 이 실행되지 않았습니다.
인터셉터가 실행되지 않으니 Page(3, 500) 지시가 소비되지 않고 남아, 다음 섹션의 무관한 조회에 OFFSET 1000 이 붙어 빈 결과가 나왔습니다. PageHelper 사용자가 겪는 "가끔 목록이 비어 있다"는 버그의 정확한 재현입니다.
지시를 두는 코드와 조회 사이에 어떤 이유로든 SQL 이 실행되지 않으면 지시가 다음 조회로 넘어갑니다. try { startPage; query } finally { clearPage } 가 정답입니다.
System.out.println("최근 7일 가입: " + names(m.findRecent(7)));
String sql = factory.getConfiguration().getMappedStatement("MemberMapper.findRecent").getBoundSql(Map.of("days", 7)).getSql();
System.out.println("실제 로드된 SQL: " + sql.replaceAll("\\s+", " ").trim());
// 출력:
// 최근 7일 가입: [4:홍길동]
// 실제 로드된 SQL: SELECT id, name, email, grade, joined_at, balance, active_yn FROM member WHERE joined_at >= DATEADD('DAY', -?, CURRENT_DATE) ORDER BY joined_at DESCXML 에는 findRecent 가 세 벌(oracle: TRUNC(SYSDATE) - #{days}, h2: DATEADD, maria: DATE_SUB) 있지만 H2 에 접속했으므로 h2 버전만 로드됐습니다. 운영에서 Oracle 에 붙이면 코드 변경 없이 oracle 버전이 실행됩니다.
getBoundSql().getSql() 로 실제 SQL 을 꺼내 보는 것은 동적 SQL 이 의도대로 조립됐는지 확인하는 단위 테스트 기법이기도 합니다.
<resultMap id="memberWithOrdersNested" type="Member" extends="memberMap">
<collection property="orders" column="id" ofType="Order" select="OrderMapper.findByMemberId"/>
</resultMap>
<resultMap id="memberWithOrdersJoin" type="Member" extends="memberMap" autoMapping="true">
<collection property="orders" ofType="Order" columnPrefix="o_" resultMap="OrderMapper.orderMap"/>
</resultMap>
<select id="findWithOrdersJoin" resultMap="memberWithOrdersJoin">
SELECT m.id, m.name, m.email, m.grade, m.joined_at, m.balance, m.active_yn,
o.id AS o_id, o.member_id AS o_member_id, o.amount AS o_amount, o.status AS o_status, o.ordered_at AS o_ordered_at
FROM member m LEFT JOIN orders o ON o.member_id = m.id
WHERE m.id = #{id} ORDER BY o.id
</select>LogFactory.useStdOutLogging(); // SQL 로그를 켜서 실행 횟수를 눈으로 확인
System.out.println(m.findWithOrdersNested(1));
Member j = m.findWithOrdersJoin(1);
System.out.println(j + " → " + j.getOrders());
System.out.println("주문 없는 회원 LEFT JOIN: " + m.findWithOrdersJoin(3).getOrders());
// 출력 (로그 요약):
// --- nested select (회원 1회 + 주문 1회 = SQL 2번)
// ==> Preparing: SELECT id, name, … FROM member WHERE id = ?
// ====> Preparing: SELECT id, member_id, amount, status, ordered_at FROM orders WHERE member_id = ? ORDER BY id
// ====> Parameters: 1(BigDecimal) ← column="id" 의 값(NUMBER → BigDecimal) 이 그대로 넘어간다
// Member{id=1, name=김철수, grade=GOLD, joinedAt=2024-01-15, balance=150000.50, active=true, orders=3}
// --- JOIN (SQL 1번, 4행을 회원 1 + 주문 3 으로 접음)
// ==> Preparing: SELECT m.id, m.name, …, o.id AS o_id, … FROM member m LEFT JOIN orders o ON o.member_id = m.id WHERE m.id = ? ORDER BY o.id
// <== Total: 3
// Member{id=1, name=김철수, grade=GOLD, joinedAt=2024-01-15, balance=150000.50, active=true, orders=3} → [Order[id=1, memberId=1, amount=75000, …], Order[id=2, …], Order[id=3, …]]
// --- 주문 없는 회원 LEFT JOIN: []nested select 는 ====> 로 들여쓰기된 두 번째 SQL 이 보입니다. 회원 1명이라 2번이지만 회원 목록 100명이면 101번입니다. JOIN 버전은 SQL 1번에 3행이 왔고, <id column="id"> 가 같은 3행을 회원 1명으로 접으면서 o_ 접두어 컬럼들을 orderMap 으로 넘겨 주문 3개를 만들었습니다.
LEFT JOIN 이라 주문 없는 회원도 조회되고 orders 는 빈 리스트입니다(null 이 아님). JOIN resultMap 의 autoMapping="true" 가 없으면 회원 필드가 전부 null 이 되는 함정은 2.3 절에서 설명했습니다.
<select id="monthlyJoinStats" resultType="map">
SELECT TO_CHAR(joined_at, 'YYYY-MM') AS ym, COUNT(*) AS cnt, NVL(SUM(balance), 0) AS total_balance
FROM member GROUP BY TO_CHAR(joined_at, 'YYYY-MM') ORDER BY ym
</select>
<insert id="mergeSettlement">
MERGE INTO settlement s
USING (SELECT CAST(#{memberId} AS NUMBER(19)) AS member_id, CAST(#{date} AS DATE) AS settle_date,
CAST(#{cnt} AS NUMBER(10)) AS order_cnt, CAST(#{total} AS NUMBER(15,2)) AS total FROM DUAL) v
ON (s.member_id = v.member_id AND s.settle_date = v.settle_date)
WHEN MATCHED THEN UPDATE SET s.order_cnt = s.order_cnt + v.order_cnt, s.total = s.total + v.total
WHEN NOT MATCHED THEN INSERT (member_id, settle_date, order_cnt, total) VALUES (v.member_id, v.settle_date, v.order_cnt, v.total)
</insert>System.out.println("TO_CHAR/NVL 월별 가입 통계: " + m.monthlyJoinStats());
LocalDate d = LocalDate.of(2026, 9, 1);
m.mergeSettlement(1, d, 2, new BigDecimal("100000"));
m.mergeSettlement(1, d, 1, new BigDecimal("12000")); // 같은 키 → MATCHED → 누적
m.mergeSettlement(2, d, 1, new BigDecimal("50000"));
s.commit();
System.out.println("MERGE 3회 후 settlement 행 수: " + m.countSettlement() + ", 회원 1 정산: " + m.settlementOf(1, d));
// 출력:
// TO_CHAR/NVL 월별 가입 통계: [{YM=2024-01, TOTAL_BALANCE=150000.50, CNT=1}, {YM=2024-06, TOTAL_BALANCE=20000, CNT=1}, {YM=2025-03, TOTAL_BALANCE=999, CNT=1}, {YM=2026-09, TOTAL_BALANCE=5000, CNT=1}]
// MERGE 3회 후 settlement 행 수: 2, 회원 1 정산: {TOTAL=112000, ORDER_CNT=3} (2건+1건 누적)resultType="map" 의 키가 대문자(YM, CNT)인 것에 주목하세요. Oracle 은 따옴표 없는 식별자를 대문자로 저장하고 H2 Oracle 모드도 그렇게 동작합니다. 화면에 stat.get("ym") 으로 꺼내면 null 입니다.
실무에서는 Map 대신 DTO 로 받아 mapUnderscoreToCamelCase 에 맡기거나, <setting name="returnInstanceForEmptyRow"> 와는 별개로 Map 키 대소문자를 결정하는 objectWrapperFactory 를 커스텀합니다.
MERGE 는 레슨 05 연습 문제 3 의 "누적 UPSERT" 를 Oracle 표준 문법으로 쓴 것입니다. WHEN MATCHED THEN UPDATE SET … = s.total + v.total 이라 같은 회원·같은 날짜에 대해 몇 번을 실행해도 누적됩니다.
try (SqlSession s = factory.openSession()) {
s.selectOne("MemberMapper.findByld", 1); // id 오타 (I → l). 컴파일은 통과
} catch (PersistenceException e) {
System.out.println("statement id 오타 → " + e.getClass().getSimpleName() + ": " + e.getMessage().strip().lines().findFirst().orElse(""));
}
m.search(Map.of("sort", "name; DROP TABLE member"));
System.out.println("정렬 인젝션 시도 → <choose> 화이트리스트에 없어 otherwise(id) 로 정렬됨. 피해 없음");
// 출력:
// statement id 오타 → PersistenceException: ### Error querying database. Cause: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for MemberMapper.findByld
// 정렬 인젝션 시도 → <choose> 화이트리스트에 없어 otherwise(id) 로 정렬됨. 피해 없음XML id 오타는 실행 시점 오류입니다. 매퍼 인터페이스를 거치면 BindingException, 문자열 id 로 직접 부르면 위처럼 IllegalArgumentException 이 감싸져 옵니다. 두 번째는 정렬 컬럼에 인젝션 문자열을 넣은 것인데, <choose> 가 허용 값 외에는 전부 otherwise 로 보내므로 ${} 를 쓰지 않은 이 설계에서는 어떤 입력도 SQL 이 될 수 없습니다.