공공부하자개발 · 영어 학습 노트
자바
실무 확장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 공부하자
홈 › 실무 확장 › 05 / 22

MyBatis 어노테이션 매퍼로 쿼리 연동

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

변형 1: ResultHandler 로 대량 조회 스트리밍

List<Order> 반환은 결과 전체를 메모리에 올립니다. 100만 건 CSV 내보내기라면 행 단위 콜백으로 받아야 합니다.

java
// 매퍼: 반환 타입 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) 로 순회합니다.

변형 2: N+1 을 JOIN 한 방으로 — 평탄 매핑

java
// ❌ 예제 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 입니다.

변형 3: 같은 매퍼를 XML 로 쓰면 — 두 방식의 대응 관계

실무 프로젝트 대부분은 XML 매퍼를 쓰므로 대응 관계를 알아야 합니다. 어노테이션과 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>
java
conf.addMapper(MemberMapper.class);                                  // 인터페이스 등록 시 같은 경로의 MemberMapper.xml 을 자동으로 찾아 함께 읽는다

XML 의 장점은 >= 를 그대로 쓸 수 있고(어노테이션은 &gt;=), 수십 줄짜리 SQL 을 들여쓰기해 읽기 좋고, DBA 가 자바를 몰라도 SQL 을 검토할 수 있다는 것입니다. 단점은 메서드와 XML 을 오가야 하고 id 오타가 실행 시점에 BindingException 으로 터진다는 것입니다. 권장 조합은 짧은 CRUD 는 어노테이션, 20줄 넘는 조회·리포트 SQL 은 XML 입니다.

변형 4: Spring 이 얹는 것 — SqlSessionTemplate 과 @Transactional

이 레슨의 코드를 Spring Boot 로 옮기면 MyBatisConfig 와 OrderService 의 세션 관리가 사라집니다.

java
// 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 의 고전적 함정이 왜 생기는지 이해할 수 있습니다. 둘 다 프록시를 거치지 않기 때문입니다.

응용 변형 예제
  • 변형 1: ResultHandler 로 대량 조회 스트리밍
  • 변형 2: N+1 을 JOIN 한 방으로 — 평탄 매핑
  • 변형 3: 같은 매퍼를 XML 로 쓰면 — 두 방식의 대응 관계
  • 변형 4: Spring 이 얹는 것 — SqlSessionTemplate 과 @Transactional
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)