List<Order> 반환은 결과 전체를 메모리에 올립니다. 100만 건 CSV 내보내기라면 행 단위 콜백으로 받아야 합니다.
// 매퍼: 반환 타입 void, 마지막 파라미터로 ResultHandler
@Select("SELECT id, member_id, product_id, qty, amount, status, ordered_at FROM orders ORDER BY id")
@Options(fetchSize = 1000) // 드라이버가 1000행씩 가져오게 (JDBC 레슨 변형 2 의 fetchSize)
void streamAll(ResultHandler<Order> handler);
// 호출
try (SqlSession s = factory.openSession();
BufferedWriter bw = Files.newBufferedWriter(Path.of("data/orders.csv"))) {
long[] n = {0};
s.getMapper(OrderMapper.class).streamAll(ctx -> { // ResultContext<Order>
Order o = ctx.getResultObject();
try { bw.write(o.id() + "," + o.memberId() + "," + o.amount().toPlainString()); bw.newLine(); }
catch (IOException e) { throw new UncheckedIOException(e); }
if (++n[0] % 100_000 == 0) System.out.println(n[0] + "행...");
});
System.out.println("내보내기 " + n[0] + "행");
}
// 출력(예): 100000행... 200000행... 내보내기 253행 (예제 데이터는 253건)ResultHandler 가 있으면 MyBatis 는 결과를 리스트에 모으지 않고 행마다 콜백을 부릅니다. 1차 캐시에도 넣지 않습니다.
MySQL 은 fetchSize=Integer.MIN_VALUE, PostgreSQL 은 autoCommit=false 여야 진짜 스트리밍이 되는 등 드라이버별 조건이 있으니 JDBC 레슨의 커서 스트리밍 절을 함께 보세요.
3.4.1+ 에서는 Cursor<Order> 반환 타입도 가능하며, 이때는 세션이 살아 있는 동안 for (Order o : cursor) 로 순회합니다.
// ❌ 예제 5: @One → 주문 N건이면 SQL N+1 번
// ✅ JOIN 으로 한 번에 가져와 중첩 프로퍼티 "member.name" 에 직접 매핑
@Select("""
SELECT o.id, o.qty, o.amount,
m.id AS m_id, m.name AS m_name, m.grade AS m_grade, m.active_yn AS m_active_yn
FROM orders o JOIN member m ON m.id = o.member_id
ORDER BY o.id
""")
@Results({
@Result(property = "id", column = "id", id = true),
@Result(property = "qty", column = "qty"),
@Result(property = "amount", column = "amount"),
@Result(property = "member.id", column = "m_id"), // 점 표기로 중첩 객체 프로퍼티. member 가 null 이면 MyBatis 가 생성
@Result(property = "member.name", column = "m_name"),
@Result(property = "member.grade", column = "m_grade"),
@Result(property = "member.active", column = "m_active_yn")
})
List<Order.WithMember> findAllWithMemberJoin();
// 로그: Preparing: SELECT o.id, ... JOIN member ... ← 딱 1번property = "member.name" 처럼 점 표기를 쓰면 Order.WithMember.member 를 MyBatis 가 new Member() 로 만들고 그 안의 name 을 채웁니다. 별칭 m_ 접두어는 orders.id 와 member.id 충돌을 피하기 위한 것입니다. @One 은 지연 로딩과 조합해 "상세 화면에서 클릭했을 때만 회원 정보를 가져오기"에 쓰고, 목록은 항상 JOIN 입니다.
실무 프로젝트 대부분은 XML 매퍼를 쓰므로 대응 관계를 알아야 합니다. 어노테이션과 XML 은 같은 인터페이스에 섞어 쓸 수 있습니다(같은 메서드에 둘 다는 불가).
<!-- MemberMapper.xml : namespace = 인터페이스 FQN, id = 메서드명 -->
<mapper namespace="MemberMapper">
<resultMap id="memberMap" type="Member"> <!-- @Results(id="memberMap") -->
<id property="id" column="id"/>
<result property="active" column="active_yn"/>
</resultMap>
<select id="findById" resultMap="memberMap"> <!-- @Select + @ResultMap -->
SELECT * FROM member WHERE id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> <!-- @Insert + @Options -->
INSERT INTO member(name, email, grade, joined_at, balance, active_yn)
VALUES (#{name}, #{email}, #{grade}, #{joinedAt}, #{balance}, #{active})
</insert>
<select id="searchScript" resultMap="memberMap"> <!-- <script> 내용 그대로. 이스케이프 불필요 -->
SELECT * FROM member
<where>
<if test="name != null">AND name LIKE '%' || #{name} || '%'</if>
<if test="from != null">AND joined_at >= #{from}</if>
</where>
ORDER BY id
</select>
</mapper>conf.addMapper(MemberMapper.class); // 인터페이스 등록 시 같은 경로의 MemberMapper.xml 을 자동으로 찾아 함께 읽는다XML 의 장점은 >= 를 그대로 쓸 수 있고(어노테이션은 >=), 수십 줄짜리 SQL 을 들여쓰기해 읽기 좋고, DBA 가 자바를 몰라도 SQL 을 검토할 수 있다는 것입니다. 단점은 메서드와 XML 을 오가야 하고 id 오타가 실행 시점에 BindingException 으로 터진다는 것입니다. 권장 조합은 짧은 CRUD 는 어노테이션, 20줄 넘는 조회·리포트 SQL 은 XML 입니다.
이 레슨의 코드를 Spring Boot 로 옮기면 MyBatisConfig 와 OrderService 의 세션 관리가 사라집니다.
// application.yml
// mybatis.configuration.map-underscore-to-camel-case: true
// mybatis.type-handlers-package: shop.mybatis.handler
@MapperScan("shop.mapper") // conf.addMappers("shop.mapper") 에 해당. 매퍼 프록시를 빈으로 등록
@SpringBootApplication
public class App {}
@Service
public class OrderService {
private final OrderMapper orders; // 주입되는 것은 SqlSessionTemplate 을 감싼 프록시. 스레드 안전
public OrderService(OrderMapper orders) { this.orders = orders; }
@Transactional // 메서드 진입 시 세션(커넥션) 확보, 정상 종료 commit, RuntimeException 이면 rollback
public long placeOrder(long memberId, long productId, int qty, String payMethod) {
BigDecimal price = orders.lockPrice(productId);
if (orders.decreaseStock(productId, qty) == 0) throw new OutOfStockException("재고 부족");
Map<String, Object> order = new HashMap<>(Map.of("memberId", memberId, "productId", productId, "qty", qty, "amount", price.multiply(BigDecimal.valueOf(qty)), "status", "PAID"));
orders.insertOrder(order);
long orderId = ((Number) order.get("id")).longValue();
orders.insertPayment(orderId, order.get("amount"), payMethod);
return orderId; // try-with-resources, commit(), close() 가 전부 사라졌다
}
}| 코어 MyBatis (이 레슨) | Spring + mybatis-spring |
|---|---|
factory.openSession() / close() |
SqlSessionTemplate 이 트랜잭션 범위에 맞춰 열고 닫음 |
session.commit() / rollback() |
@Transactional 프록시가 처리. checked 예외는 rollbackFor 지정 필요 |
openSession(ExecutorType.BATCH) |
SqlSessionTemplate(factory, BATCH) 빈을 따로 만들어 배치 매퍼에 주입 |
conf.addMapper |
@MapperScan 또는 @Mapper |
PooledDataSource |
HikariCP 자동 구성 |
@Transactional 이 붙은 메서드 안에서 매퍼를 호출하면 같은 세션(같은 커넥션)을 공유하고, 메서드가 끝날 때 커밋됩니다. 이 레슨에서 손으로 했던 것이 무엇인지 알고 있으면 "@Transactional 을 private 메서드에 붙였는데 안 된다", "같은 클래스 안에서 호출하면 트랜잭션이 안 걸린다" 같은 Spring 의 고전적 함정이 왜 생기는지 이해할 수 있습니다. 둘 다 프록시를 거치지 않기 때문입니다.