실행 방법. MyBatis 와 H2 jar 가 필요하므로 lib 을 클래스패스에 넣습니다.
cd java-src\extension\05_mybatis_annotations
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\*;." MainMain 은 10개 섹션을 순서대로 실행합니다. 스키마는 Schema.java 가 H2 인메모리(jdbc:h2:mem:shop;DB_CLOSE_DELAY=-1)에 매번 새로 만듭니다. 테이블은 member, product, orders, payment, settlement, bulk_log 여섯 개입니다.
static void crud(SqlSessionFactory factory) {
try (SqlSession session = factory.openSession(true)) { // autoCommit=true: 문장마다 커밋
MemberMapper mapper = session.getMapper(MemberMapper.class);
Member kim = new Member("김철수", "kim@example.com", Member.Grade.GOLD, LocalDate.of(2024, 1, 15), new BigDecimal("150000.50"));
Member lee = new Member("이영희", "lee@example.com", Member.Grade.SILVER, LocalDate.of(2024, 6, 1), new BigDecimal("20000"));
Member park = new Member("박민수", "park@example.com", Member.Grade.BRONZE, LocalDate.of(2025, 3, 20), BigDecimal.ZERO);
park.setActive(false);
for (Member m : List.of(kim, lee, park)) mapper.insert(m); // @Options(useGeneratedKeys) 가 m.id 를 채운다
System.out.println("insert 후 생성 키: kim.id=" + kim.getId() + ", lee.id=" + lee.getId() + ", park.id=" + park.getId());
System.out.println("findById(3): " + mapper.findById(3));
System.out.println("updateGrade 영향 행: " + mapper.updateGrade(3, Member.Grade.SILVER) + " -> " + mapper.findById(3).getGrade());
System.out.println("findAll: " + mapper.findAll().size() + "명, count=" + mapper.count());
}
}
// 출력:
// insert 후 생성 키: kim.id=1, lee.id=2, park.id=3
// findById(3): Member{id=3, name=박민수, grade=BRONZE, joinedAt=2025-03-20, balance=0.00, active=false}
// updateGrade 영향 행: 1 -> SILVER
// findAll: 3명, count=3Grade enum 은 name() 문자열 'GOLD' 로, LocalDate 는 DATE 로, BigDecimal 은 DECIMAL 로, boolean active 는 YesNoTypeHandler 를 거쳐 'N' 으로 저장됩니다.
findById(3) 의 active=false 가 active_yn='N' 을 읽어 @Result(property="active", column="active_yn") 매핑을 통과한 결과입니다. JDBC 였다면 rs.getString("active_yn").equals("Y") 를 DAO 마다 반복했을 것입니다.
#{} vs ${} — 인젝션 시연과 정렬 컬럼static void bindingVsSubstitution(SqlSessionFactory factory) {
try (SqlSession session = factory.openSession()) {
MemberMapper mapper = session.getMapper(MemberMapper.class);
String input = "' OR '1'='1";
System.out.println("${name} 치환 : " + mapper.countByNameUnsafe(input) + "건 (전원 노출)");
System.out.println("#{name} 바인딩: " + mapper.countByNameSafe(input) + "건");
System.out.println("정렬 ${sortCol}=balance: " + mapper.findSorted("balance", 2).stream().map(Member::getName).toList());
try {
mapper.findSorted("1; DROP TABLE member", 2);
} catch (PersistenceException e) {
System.out.println("정렬 컬럼에 인젝션 시도 -> " + e.getCause().getClass().getSimpleName() + " (화이트리스트 검증 없이는 위험)");
}
}
}
// 출력:
// ${name} 치환 : 3건 (전원 노출)
// #{name} 바인딩: 0건
// 정렬 ${sortCol}=balance: [박민수, 이영희]
// 정렬 컬럼에 인젝션 시도 -> JdbcSQLSyntaxErrorException (화이트리스트 검증 없이는 위험)${name} 에 ' OR '1'='1 이 들어가면 SQL 이 WHERE name = '' OR '1'='1' 이 되어 전원이 나옵니다. #{name} 은 그 문자열을 통째로 값으로 바인딩하니 0건입니다.
정렬 컬럼은 ? 바인딩이 불가능해 ${} 를 쓸 수밖에 없는데, 여기서는 H2 가 세미콜론 구문을 거부해 예외로 끝났지만 DB 와 드라이버에 따라 실제로 실행될 수 있습니다. 예제 4 의 Provider 가 SORTABLE 집합으로 검증하는 것이 올바른 처리입니다.
static void sessionLifecycle(SqlSessionFactory factory) {
Member hong = new Member("홍길동", "hong@example.com", Member.Grade.BRONZE, LocalDate.of(2025, 1, 1), BigDecimal.ZERO);
try (SqlSession s1 = factory.openSession()) { // autoCommit=false
s1.getMapper(MemberMapper.class).insert(hong);
try (SqlSession s2 = factory.openSession()) {
System.out.println("s1 insert 미커밋: s1 count=" + s1.getMapper(MemberMapper.class).count()
+ ", s2 count=" + s2.getMapper(MemberMapper.class).count());
}
} // commit 없이 close → 롤백
try (SqlSession s = factory.openSession()) {
System.out.println("s1 close(미커밋) 후 count=" + s.getMapper(MemberMapper.class).count() + " (롤백됨)");
}
try (SqlSession s = factory.openSession()) {
s.getMapper(MemberMapper.class).insert(hong);
s.commit();
}
try (SqlSession s = factory.openSession()) {
System.out.println("commit 후 count=" + s.getMapper(MemberMapper.class).count() + ", hong.id=" + hong.getId());
}
}
// 출력:
// s1 insert 미커밋: s1 count=4, s2 count=3
// s1 close(미커밋) 후 count=3 (롤백됨)
// commit 후 count=4, hong.id=5s1 자신은 4명을 보지만 s2 는 3명을 봅니다(READ COMMITTED). s1 이 커밋 없이 닫히자 insert 가 사라졌습니다. hong.id=5 인 것은 첫 insert 가 시퀀스 4 를 소비하고 롤백됐기 때문입니다. 생성 키는 롤백되어도 되돌아가지 않습니다. 주문번호가 연속이어야 한다는 요구사항에 DB 시퀀스를 쓰면 안 되는 이유입니다.
<script> 와 Providerstatic void dynamicSql(SqlSessionFactory factory) {
try (SqlSession session = factory.openSession()) {
MemberMapper mapper = session.getMapper(MemberMapper.class);
System.out.println("script 조건 없음 : " + names(mapper.searchScript(null, null, null, null)));
System.out.println("script name=이 : " + names(mapper.searchScript("이", null, null, null)));
System.out.println("script grade IN : " + names(mapper.searchScript(null, List.of(Member.Grade.GOLD, Member.Grade.SILVER), null, null)));
System.out.println("script 2025년 가입 : " + names(mapper.searchScript(null, null, LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31))));
var cond = new MemberSqlProvider.Search(null, null, LocalDate.of(2024, 1, 1), null, "balance", true, 2, 0);
System.out.println("provider SQL: " + new MemberSqlProvider().search(cond).replaceAll("\\s+", " "));
System.out.println("provider page0 (balance desc, 2건): " + names(mapper.search(cond)) + " / 총 " + mapper.countSearch(cond) + "건");
var page1 = new MemberSqlProvider.Search(null, null, LocalDate.of(2024, 1, 1), null, "balance", true, 2, 2);
System.out.println("provider page1: " + names(mapper.search(page1)));
var bad = new MemberSqlProvider.Search(null, null, null, null, "1; DROP TABLE member", false, 2, 0);
System.out.println("provider 정렬 컬럼 검증: " + new MemberSqlProvider().search(bad).replaceAll("\\s+", " "));
}
}
// 출력:
// script 조건 없음 : [1:김철수, 2:이영희, 3:박민수, 5:홍길동]
// script name=이 : [2:이영희]
// script grade IN : [1:김철수, 2:이영희, 3:박민수]
// script 2025년 가입 : [3:박민수, 5:홍길동]
// provider SQL: SELECT * FROM member WHERE (joined_at >= #{from}) ORDER BY balance DESC LIMIT #{limit} OFFSET #{offset}
// provider page0 (balance desc, 2건): [1:김철수, 2:이영희] / 총 4건
// provider page1: [5:홍길동, 3:박민수] ← 둘 다 balance 0 이라 순서는 DB 가 정한다. 안정 정렬이 필요하면 ORDER BY balance DESC, id
// provider 정렬 컬럼 검증: SELECT * FROM member ORDER BY id ASC LIMIT #{limit} OFFSET #{offset}Provider 의 핵심은 두 곳입니다.
private static final Set<String> SORTABLE = Set.of("id", "name", "joined_at", "balance");
String orderBy = SORTABLE.contains(c.sortCol()) ? c.sortCol() : "id"; // ${} 자리는 화이트리스트로
SQL sql = where(new SQL().SELECT("*").FROM("member"), c)
.ORDER_BY(orderBy + (c.desc() ? " DESC" : " ASC"))
.LIMIT("#{limit}").OFFSET("#{offset}"); // 값은 여전히 #{} 바인딩Provider 가 반환한 문자열에 #{from}, #{limit} 가 남아 있는 것에 주목하세요. Provider 는 SQL 의 "모양"만 만들고 값 바인딩은 MyBatis 가 합니다. 그래서 조건 조합을 자바로 짜면서도 인젝션에 안전합니다.
IN 절의 리스트도 #{grades[0]}, #{grades[1]} 처럼 원소별 바인딩으로 풉니다. 잘못된 정렬 컬럼은 예외 대신 기본값 id 로 조용히 대체했는데, 서비스에 따라 400 에러로 거부하는 것이 나을 수도 있습니다.
static void recordAndAssociation(SqlSessionFactory factory) {
try (SqlSession session = factory.openSession(true)) {
OrderMapper orders = session.getMapper(OrderMapper.class);
Map<String, Object> laptop = new HashMap<>(Map.of("name", "노트북", "price", new BigDecimal("1200000"), "stock", 5));
Map<String, Object> mouse = new HashMap<>(Map.of("name", "마우스", "price", new BigDecimal("25000"), "stock", 100));
orders.insertProduct(laptop); orders.insertProduct(mouse);
System.out.println("상품 생성 키: " + laptop.get("id") + ", " + mouse.get("id")); // Map 도 keyProperty 대상
Map<String, Object> o1 = new HashMap<>(Map.of("memberId", 1L, "productId", 2L, "qty", 3, "amount", new BigDecimal("75000"), "status", "PAID"));
Map<String, Object> o2 = new HashMap<>(Map.of("memberId", 2L, "productId", 2L, "qty", 1, "amount", new BigDecimal("25000"), "status", "PAID"));
orders.insertOrder(o1); orders.insertOrder(o2);
System.out.println("record 매핑 findById(1): " + orders.findById(1));
System.out.println("자동 생성자 매핑 findByMemberId(1): " + orders.findByMemberId(1).size() + "건");
System.out.println("@One 연관 조회: " + orders.findAllWithMember() + " <- 주문 1회 + 회원 N회 조회 (N+1)");
}
}
// 출력:
// 상품 생성 키: 1, 2
// record 매핑 findById(1): Order[id=1, memberId=1, productId=2, qty=3, amount=75000.00, status=PAID, orderedAt=2026-09-09T11:02:44.442703]
// 자동 생성자 매핑 findByMemberId(1): 1건
// @One 연관 조회: [Order#1 qty=3 amount=75000.00 by 김철수, Order#2 qty=1 amount=25000.00 by 이영희] <- 주문 1회 + 회원 N회 조회 (N+1)Order 는 record 라 setter 가 없습니다.
findById 는 @ConstructorArgs 로 7개 컬럼을 생성자 인자에 순서대로 대응시켰고, findByMemberId 는 어노테이션 없이 SELECT id, member_id, product_id, qty, amount, status, ordered_at 의 컬럼 순서와 타입이 record 컴포넌트와 일치해서 자동 생성자 매핑이 됐습니다.
후자는 짧지만 컬럼 순서를 바꾸면 조용히 깨지므로, 팀 규칙으로 하나를 정하는 것이 좋습니다.
insert 에 record 를 못 쓰는 이유도 같습니다. useGeneratedKeys 는 생성 키를 파라미터 객체의 keyProperty 에 setter 로 넣어야 하는데 record 는 불변입니다. 그래서 Map 을 넘기고 order.get("id") 로 받았습니다. 실무에서는 insert 용 가변 DTO 와 조회용 record 를 나누기도 합니다.
public long placeOrder(long memberId, long productId, int qty, String payMethod) {
try (SqlSession session = factory.openSession()) { // autoCommit=false. close() 시 미커밋은 롤백
OrderMapper orders = session.getMapper(OrderMapper.class);
BigDecimal price = orders.lockPrice(productId); // SELECT ... FOR UPDATE: 행 잠금
if (price == null) throw new IllegalArgumentException("상품 없음: " + productId);
if (orders.decreaseStock(productId, qty) == 0) // UPDATE ... WHERE stock >= qty. 영향 행 0 = 재고 부족
throw new OutOfStockException("재고 부족: product=" + productId + " qty=" + qty);
BigDecimal amount = price.multiply(BigDecimal.valueOf(qty));
Map<String, Object> order = new HashMap<>(Map.of("memberId", memberId, "productId", productId,
"qty", qty, "amount", amount, "status", "PAID"));
orders.insertOrder(order);
long orderId = ((Number) order.get("id")).longValue();
orders.insertPayment(orderId, amount, payMethod); // FK 위반 등 예외 → 전파 → close() 가 롤백
session.commit();
return orderId;
}
}
// Main.orderTx 출력:
// 초기: stock=5, orders=2
// 주문 성공 id=3 -> stock=3, orders=3
// 주문 실패: 재고 부족: product=1 qty=10 -> stock=3, orders=3
// FK 위반: JdbcSQLIntegrityConstraintViolationException -> stock=3, orders=3 (재고 차감 롤백)미니 프로젝트 3단계의 OrderService 를 MyBatis 로 옮긴 것입니다. 세 번째 호출은 존재하지 않는 회원 999 로 주문해 insertOrder 에서 FK 위반이 났는데, 그 앞에서 실행된 decreaseStock 이 함께 롤백되어 재고가 3 으로 유지됐습니다.
세션 하나 = 트랜잭션 하나이고, catch 를 쓰지 않았기 때문에 close() 가 롤백을 담당한 것입니다. decreaseStock 을 "조회 후 비교 후 UPDATE" 가 아니라 조건부 UPDATE 한 문장으로 만든 것은 동시 주문에서 재고가 음수가 되는 것을 DB 수준에서 막기 위해서입니다.
static void batchExecutor(SqlSessionFactory factory) {
final int N = 20_000;
long simple, batch;
try (SqlSession s = factory.openSession(ExecutorType.SIMPLE)) {
MemberMapper m = s.getMapper(MemberMapper.class);
long t0 = System.nanoTime();
for (int i = 0; i < N; i++) m.insertBulk(i, "row-" + i); // 문장마다 prepare + execute
s.commit();
simple = (System.nanoTime() - t0) / 1_000_000;
m.truncateBulk(); s.commit();
}
try (SqlSession s = factory.openSession(ExecutorType.BATCH)) {
MemberMapper m = s.getMapper(MemberMapper.class);
long t0 = System.nanoTime();
for (int i = 0; i < N; i++) {
m.insertBulk(i, "row-" + i); // 같은 SQL 연속 → 하나의 PreparedStatement 에 addBatch
if ((i + 1) % 1000 == 0) s.flushStatements(); // 1000건마다 executeBatch (메모리 상한)
}
s.commit(); // 남은 배치 flush + commit
batch = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("SIMPLE: %d ms, BATCH: %d ms, 적재 %,d건%n", simple, batch, m.countBulk());
}
}
// 출력:
// SIMPLE: 6113 ms, BATCH: 1908 ms, 적재 20,000건H2 인메모리에서도 3배 차이가 납니다. 네트워크를 건너는 Oracle/MySQL 이면 왕복 횟수가 20,000 → 20 으로 줄어 격차가 훨씬 커집니다. JDBC 레슨 예제 7 의 addBatch/executeBatch 를 MyBatis 가 세션 타입 하나로 감춘 것입니다. flushStatements() 를 1000건마다 부르는 이유는 드라이버가 배치를 메모리에 쌓기 때문입니다. 100만 건을 flush 없이 쌓으면 OOM 입니다.
public Result run(LocalDate settleDate, int chunkSize, int failAtChunk) {
int committed = 0, rolledBack = 0, chunkNo = 0;
long lastId = 0, processed = 0;
try (SqlSession session = factory.openSession(ExecutorType.BATCH)) {
OrderMapper mapper = session.getMapper(OrderMapper.class);
while (true) {
List<Order> chunk = mapper.pageAfter(lastId, chunkSize); // 키셋 페이징. 읽기는 BATCH 세션에서도 즉시 실행
if (chunk.isEmpty()) break;
chunkNo++;
try {
Map<Long, BigDecimal[]> byMember = new TreeMap<>(); // memberId → [cnt, sum]
for (Order o : chunk)
byMember.merge(o.memberId(), new BigDecimal[]{BigDecimal.ONE, o.amount()},
(a, b) -> new BigDecimal[]{a[0].add(b[0]), a[1].add(b[1])});
for (var e : byMember.entrySet())
mapper.upsertSettlement(e.getKey(), settleDate, e.getValue()[0].intValue(), e.getValue()[1]); // MERGE. 아직 DB 로 안 감
if (chunkNo == failAtChunk) throw new IllegalStateException("청크 " + chunkNo + " 검증 실패 시뮬레이션");
session.flushStatements(); // 쌓인 MERGE 를 executeBatch
session.commit(); // 커밋 포인트
committed++; processed += chunk.size();
} catch (RuntimeException e) {
session.rollback(); // 미실행 배치 폐기 + DB 롤백
rolledBack++;
}
lastId = chunk.get(chunk.size() - 1).id(); // 실패해도 다음 청크로 (스킵 정책)
}
}
return new Result(committed, rolledBack, processed);
}
// Main.settlement 출력:
// 주문 총 253건
// 청크 1 커밋 (주문 100건, 회원 4명, last_id=101)
// 청크 2 롤백 (청크 2 검증 실패 시뮬레이션)
// 청크 3 커밋 (주문 53건, 회원 4명, last_id=254)
// 결과: Result[committed=2, rolledBack=1, ordersProcessed=153], settlement 행=4, 합계=1325000.00
// 재실행 (MERGE 라 멱등):
// 청크 1 커밋 (주문 100건, 회원 4명, last_id=101)
// 청크 2 커밋 (주문 100건, 회원 4명, last_id=201)
// 청크 3 커밋 (주문 53건, 회원 4명, last_id=254)
// 결과: Result[committed=3, rolledBack=0, ordersProcessed=253], settlement 행=4, 합계=1325000.00배치 레슨 03(청크)·04(트랜잭션)·05(스킵)와 미니 프로젝트 4단계의 정산 로직이 MyBatis 로 어떻게 표현되는지 보여 주는 예제입니다. 청크 2 가 롤백되어도 청크 1 은 이미 커밋되어 살아 있고, 청크 3 은 계속 진행됩니다. 재실행하면 세 청크가 모두 커밋되고 정산 행은 4개로 유지됩니다. MERGE ... KEY(member_id, settle_date) 가 같은 키의 행을 덮어쓰기 때문입니다.
그런데 합계를 보세요. 주문 253건 × 25,000 원이면 6,325,000 원이어야 하는데 1,325,000 원, 즉 53건 × 25,000 원입니다. 마지막 청크 3 의 회원별 합계가 청크 1 의 값을 덮어썼습니다. 청크마다 "그 청크 안의 합계"를 MERGE 하는 구조는 같은 회원이 여러 청크에 걸치면 이전 청크 값을 잃습니다.
이 결함은 청크 처리 + UPSERT 조합에서 실제로 자주 나오는 버그이고, 청크 크기가 전체보다 크면(테스트 데이터가 작으면) 드러나지 않습니다. 정확한 정산이 되도록 고치는 것이 연습 문제 3 입니다.
@Select("SELECT grade, COUNT(*) AS member_count, SUM(balance) AS total_balance FROM member GROUP BY grade ORDER BY grade")
List<Order.GradeStat> statsByGrade(); // record GradeStat(String grade, long memberCount, BigDecimal totalBalance)
for (Order.GradeStat st : mapper.statsByGrade())
System.out.printf("%-7s %d명 잔액 합계 %s%n", st.grade(), st.memberCount(), st.totalBalance().toPlainString());
// 출력:
// BRONZE 1명 잔액 합계 0.00
// GOLD 1명 잔액 합계 150000.50
// SILVER 2명 잔액 합계 20000.00집계 결과를 받을 클래스를 만들 필요 없이 record 한 줄로 끝납니다. AS member_count 별칭이 mapUnderscoreToCamelCase 로 memberCount 가 되고, 컬럼 순서가 record 컴포넌트 순서와 같아 자동 생성자 매핑이 적용됐습니다. 통계·리포트 조회에서 가장 자주 쓰는 패턴입니다.
static void firstLevelCache(PooledDataSource ds) {
LogFactory.useStdOutLogging(); // SQL 로그를 stdout 으로. 매퍼 등록 시점에 로거가 만들어져 factory 재빌드
SqlSessionFactory logged = MyBatisConfig.build(ds);
try (SqlSession s = logged.openSession()) {
MemberMapper m = s.getMapper(MemberMapper.class);
Member a = m.findById(1);
Member b = m.findById(1); // 같은 세션, 같은 SQL+파라미터 → 캐시 히트. SQL 로그 없음
System.out.println("같은 세션 두 번 조회: 같은 객체? " + (a == b));
s.clearCache();
Member c = m.findById(1);
System.out.println("clearCache 후 조회: 같은 객체? " + (a == c));
}
}
// 출력 (Columns/Row 로그 줄은 생략):
// ==> Preparing: SELECT * FROM member WHERE id = ?
// ==> Parameters: 1(Long)
// <== Total: 1
// 같은 세션 두 번 조회: 같은 객체? true ← 두 번째 findById 는 로그가 없다
// ==> Preparing: SELECT * FROM member WHERE id = ?
// ==> Parameters: 1(Long)
// clearCache 후 조회: 같은 객체? falsea == b 가 true, 즉 같은 인스턴스입니다. a.setName("x") 하면 b 도 바뀝니다. 조회한 객체를 수정해서 화면에 보여 주는 코드가 같은 세션에서 다시 조회하면 수정된 값이 나오는 이유입니다.
LogFactory.useStdOutLogging() 은 개발 중 실행되는 SQL 과 파라미터를 보는 가장 빠른 방법이고, 실무에서는 log4j2 설정으로 매퍼 패키지의 로그 레벨을 DEBUG 로 두면 같은 출력을 얻습니다.