MyBatis 성능 최적화 — 캐시, 배치, N+1 해결 전략
쿼리 하나하나는 빠른데, 왜 전체 API 응답은 느릴까?
MyBatis를 쓰다 보면 개별 SQL은 문제없는데 전체 성능이 기대 이하인 경우를 만납니다. 대부분 캐시 미활용, 불필요한 반복 쿼리(N+1), 한 건씩 Insert하는 패턴에서 병목이 생깁니다. 이 글에서는 MyBatis가 제공하는 성능 최적화 도구들을 하나씩 살펴보겠습니다.
1차 캐시 — SqlSession 범위의 자동 캐시
1차 캐시 는 SqlSession 내부에 자동으로 동작하는 캐시입니다. 같은 SqlSession에서 동일한 쿼리 + 동일한 파라미터로 조회하면, 두 번째부터는 DB에 가지 않고 캐시된 객체를 반환합니다.
// 같은 SqlSession 내에서 동일 쿼리는 캐시 히트
try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User first = mapper.findById(1L); // DB 조회
User second = mapper.findById(1L); // 캐시 반환 (SQL 실행 안 함)
// first == second (같은 객체 참조)
}
1차 캐시의 특징
- **범위 **: SqlSession 단위. 세션이 닫히면 캐시도 사라집니다
- ** 자동 활성화 **: 별도 설정 없이 기본 동작합니다
- ** 무효화 조건 **: INSERT, UPDATE, DELETE가 실행되면 해당 세션의 1차 캐시가 전부 초기화됩니다
Spring 환경에서의 주의점
Spring과 MyBatis를 함께 쓰면 SqlSession의 생명주기가 달라집니다.
@Transactional이 ** 없는** 경우: 매 쿼리마다 새 SqlSession이 열리고 닫힙니다. 1차 캐시가 사실상 동작하지 않습니다@Transactional이 ** 있는** 경우: 트랜잭션 동안 같은 SqlSession을 공유합니다. 이때 1차 캐시가 제대로 동작합니다
1차 캐시의 효과를 보려면
@Transactional범위 안에서 같은 쿼리가 반복되는지 확인해보세요. 트랜잭션 없이 호출하면 매번 새 세션이 열려서 캐시가 의미 없습니다.
2차 캐시 — Namespace 범위의 공유 캐시
1차 캐시가 SqlSession에 묶여 있다면, 2차 캐시 는 Mapper Namespace 단위로 동작합니다. 서로 다른 SqlSession 간에도 캐시를 공유할 수 있습니다.
설정 방법
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<!-- 2차 캐시 활성화 -->
<cache
eviction="LRU"
flushInterval="60000"
size="512"
readOnly="true"/>
<select id="findById" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
</mapper>
각 속성의 의미를 정리하면 이렇습니다.
| 속성 | 설명 | 기본값 |
|---|---|---|
eviction | 캐시 제거 정책 (LRU, FIFO, SOFT, WEAK) | LRU |
flushInterval | 캐시 갱신 주기 (밀리초) | 없음 (수동) |
size | 캐시에 저장할 최대 객체 수 | 1024 |
readOnly | true면 성능 향상, false면 안전한 복사본 반환 | false |
2차 캐시 사용 시 주의사항
readOnly="false"일 때 캐시 객체가 직렬화되므로 DTO에Serializable을 구현 해야 합니다- INSERT/UPDATE/DELETE 실행 시 해당 Namespace의 캐시가 전부 비워집니다
- 다른 Namespace에서 같은 테이블을 수정 하면 캐시 불일치가 발생할 수 있습니다
// 2차 캐시 대상 DTO — Serializable 필수
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name;
private String email;
}
2차 캐시는 읽기 비율이 높고 데이터 변경이 적은 테이블에 적합합니다. 자주 변경되는 테이블에 적용하면 캐시 무효화가 빈번해져서 오히려 성능이 떨어집니다.
Batch Insert — 대량 데이터를 효율적으로 넣기
1만 건의 데이터를 Insert할 때, 한 건씩 실행하면 1만 번의 네트워크 왕복이 발생합니다.
ExecutorType.BATCH를 사용하면 SQL을 모아서 한 번에 실행할 수 있습니다.
기본 Batch 사용법
// Batch 모드로 SqlSession 열기
try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
UserMapper mapper = session.getMapper(UserMapper.class);
for (int i = 0; i < 10000; i++) {
mapper.insert(new User("user" + i, "user" + i + "@example.com"));
// 500건마다 flush하여 메모리 부담 줄이기
if (i % 500 == 0) {
session.flushStatements();
}
}
session.flushStatements(); // 남은 배치 실행
session.commit(); // 커밋
}
Spring Boot에서 Batch 사용
Spring Boot에서는 SqlSessionTemplate이 기본으로 SIMPLE Executor를 사용합니다.
Batch 전용 SqlSessionTemplate을 별도로 등록해야 합니다.
@Configuration
public class MyBatisBatchConfig {
@Bean
public SqlSessionTemplate batchSqlSessionTemplate(SqlSessionFactory factory) {
// BATCH 모드 전용 SqlSessionTemplate 생성
return new SqlSessionTemplate(factory, ExecutorType.BATCH);
}
}
@Service
@RequiredArgsConstructor
public class UserBatchService {
private final SqlSessionTemplate batchSqlSessionTemplate;
@Transactional
public void batchInsert(List<User> users) {
UserMapper mapper = batchSqlSessionTemplate.getMapper(UserMapper.class);
for (int i = 0; i < users.size(); i++) {
mapper.insert(users.get(i));
// 주기적으로 flush
if (i % 500 == 0) {
batchSqlSessionTemplate.flushStatements();
}
}
batchSqlSessionTemplate.flushStatements();
}
}
foreach를 활용한 Multi-row Insert
Batch Executor 대신 SQL 레벨에서 여러 행을 한 번에 넣는 방법도 있습니다.
<!-- 한 번의 INSERT로 여러 행 삽입 -->
<insert id="batchInsert" parameterType="list">
INSERT INTO users (name, email) VALUES
<foreach collection="list" item="user" separator=",">
(#{user.name}, #{user.email})
</foreach>
</insert>
ExecutorType.BATCH는 JDBC의addBatch/executeBatch를 활용하고,foreach는 하나의 긴 SQL을 만듭니다. 데이터가 매우 많으면 foreach는 SQL 길이 제한에 걸릴 수 있으니 적절히 나눠서 실행해야 합니다.
N+1 문제 — 쿼리 하나가 수백 개로 불어나는 이유
N+1 문제는 MyBatis에서도 자주 발생합니다.
association이나 collection에서 nested select 방식을 사용할 때 전형적으로 나타납니다.
문제가 발생하는 패턴
<!-- 주문 목록 조회 — 1번의 쿼리 -->
<select id="findAllOrders" resultMap="orderResultMap">
SELECT * FROM orders
</select>
<!-- 각 주문의 상품 조회 — N번의 추가 쿼리 -->
<resultMap id="orderResultMap" type="Order">
<id property="id" column="id"/>
<collection property="items" column="id"
select="findItemsByOrderId"/>
</resultMap>
<select id="findItemsByOrderId" resultType="OrderItem">
SELECT * FROM order_items WHERE order_id = #{orderId}
</select>
주문이 100건이면 findAllOrders 1번 + findItemsByOrderId 100번 = 총 101번 의 쿼리가 실행됩니다.
해결 방법 1 — Join으로 단일 쿼리 만들기
<!-- Join으로 한 번에 조회 -->
<select id="findAllOrdersWithItems" resultMap="orderWithItemsMap">
SELECT o.id as order_id, o.total_amount,
i.id as item_id, i.product_name, i.quantity
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id
</select>
<resultMap id="orderWithItemsMap" type="Order">
<id property="id" column="order_id"/>
<result property="totalAmount" column="total_amount"/>
<collection property="items" ofType="OrderItem">
<id property="id" column="item_id"/>
<result property="productName" column="product_name"/>
<result property="quantity" column="quantity"/>
</collection>
</resultMap>
Join 방식은 쿼리가 1번만 실행되므로 N+1이 완전히 해결됩니다. 다만 결과 행이 많아지면 중복 데이터가 늘어나는 단점이 있습니다.
해결 방법 2 — Lazy Loading 적용
N+1 자체를 없애진 못하지만, 실제로 접근할 때만 추가 쿼리를 실행하게 만들 수 있습니다.
<!-- fetchType="lazy"로 지연 로딩 -->
<collection property="items" column="id"
select="findItemsByOrderId"
fetchType="lazy"/>
items 필드에 접근하지 않으면 추가 쿼리가 아예 실행되지 않습니다.
Lazy Loading 상세 설정
MyBatis의 Lazy Loading은 프록시 객체를 통해 구현됩니다. 연관 객체에 실제로 접근하는 시점에 쿼리가 실행됩니다.
글로벌 설정
# application.yml
mybatis:
configuration:
lazy-loading-enabled: true # 지연 로딩 활성화
aggressive-lazy-loading: false # false여야 개별 필드 접근 시 로딩
aggressiveLazyLoading이 true이면 객체의 ** 아무 메서드라도 호출 **되는 순간 모든 Lazy 프로퍼티가 한꺼번에 로딩됩니다.
false로 설정해야 실제로 해당 프로퍼티에 접근할 때만 쿼리가 실행됩니다.
개별 설정 — fetchType
글로벌 설정과 별개로, 각 association/collection에서 개별적으로 지정할 수 있습니다.
<!-- 글로벌 설정과 무관하게 이 매핑만 lazy -->
<association property="author" column="author_id"
select="findAuthorById"
fetchType="lazy"/>
<!-- 이 매핑은 즉시 로딩 -->
<collection property="comments" column="post_id"
select="findCommentsByPostId"
fetchType="eager"/>
fetchType은 글로벌 설정보다 우선합니다. 필요한 곳에서만 선택적으로 Lazy/Eager를 적용할 수 있어서 유연합니다.
fetchSize — 대량 조회 성능 튜닝
fetchSize는 JDBC 드라이버가 ResultSet에서 ** 한 번에 가져오는 행 수 **를 지정합니다.
기본값은 드라이버마다 다르지만 대체로 10입니다.
<!-- 대량 조회 시 fetchSize를 크게 설정 -->
<select id="findAllUsers" resultType="User" fetchSize="1000">
SELECT * FROM users
</select>
# 글로벌 fetchSize 설정
mybatis:
configuration:
default-fetch-size: 100
fetchSize 설정 가이드
| 상황 | 권장 fetchSize |
|---|---|
| 일반 CRUD | 기본값 유지 |
| 수천~수만 건 조회 | 100~500 |
| 배치 처리, 전체 덤프 | 1000~5000 |
| 메모리 제한 환경 | 50 이하 |
fetchSize를 너무 크게 잡으면 한 번에 메모리에 올라오는 데이터가 많아져서 OutOfMemoryError가 발생할 수 있습니다. 데이터 크기와 가용 메모리를 고려해서 조절해야 합니다.
Mapper XML 최적화 팁
1. 필요한 컬럼만 SELECT
<!-- 나쁜 예 — 모든 컬럼 조회 -->
<select id="findUsers" resultType="User">
SELECT * FROM users
</select>
<!-- 좋은 예 — 필요한 컬럼만 조회 -->
<select id="findUsers" resultType="User">
SELECT id, name, email FROM users
</select>
2. sql 태그로 재사용 가능한 SQL 조각 분리
<!-- 공통 컬럼 목록 정의 -->
<sql id="userColumns">
id, name, email, created_at
</sql>
<select id="findById" resultType="User">
SELECT <include refid="userColumns"/>
FROM users WHERE id = #{id}
</select>
<select id="findByEmail" resultType="User">
SELECT <include refid="userColumns"/>
FROM users WHERE email = #{email}
</select>
3. flushCache와 useCache 제어
<!-- 이 쿼리는 항상 최신 데이터가 필요 — 캐시 사용 안 함 -->
<select id="findCurrentBalance" resultType="BigDecimal"
useCache="false" flushCache="true">
SELECT balance FROM accounts WHERE id = #{id}
</select>
useCache="false": 이 쿼리 결과를 2차 캐시에 저장하지 않습니다flushCache="true": 이 쿼리 실행 전에 캐시를 비웁니다
4. 불필요한 resultMap 중첩 피하기
간단한 1:1 매핑은 resultType으로 충분합니다.
복잡한 연관관계가 없는데 resultMap을 만들면 오히려 유지보수가 번거로워집니다.
성능 최적화 체크리스트
실무에서 MyBatis 성능 이슈를 만났을 때 확인할 항목들을 정리했습니다.
- **N+1 확인 **: SQL 로그를 켜서 예상보다 많은 쿼리가 나가는지 확인
- ** 캐시 전략 **: 읽기 위주 데이터에 2차 캐시 적용 검토
- **Batch 처리 **: 대량 INSERT/UPDATE는
ExecutorType.BATCH활용 - **fetchSize 조정 **: 대량 조회 시 적절한 fetchSize 설정
- **SELECT 최소화 **:
SELECT *대신 필요한 컬럼만 조회 - **인덱스 활용 **: WHERE 절의 조건 컬럼에 인덱스가 걸려 있는지 확인
- Lazy Loading: 항상 필요하지 않은 연관 데이터는 지연 로딩 적용
# SQL 로그 활성화 — 개발 환경에서 필수
logging:
level:
com.example.mapper: DEBUG # Mapper 패키지의 SQL 로그
org.mybatis: DEBUG # MyBatis 내부 동작 로그
성능 최적화의 첫 걸음은 SQL 로그를 켜는 것입니다. 어떤 쿼리가 몇 번 실행되는지 눈으로 확인하면 병목 지점이 바로 보입니다.
정리
| 기법 | 효과 | 적용 대상 |
|---|---|---|
| 1차 캐시 | 같은 트랜잭션 내 중복 쿼리 제거 | @Transactional 범위 |
| 2차 캐시 | Namespace 단위 조회 캐시 | 읽기 비율 높은 테이블 |
| Batch Insert | 네트워크 왕복 최소화 | 대량 데이터 입력 |
| Lazy Loading | 불필요한 쿼리 지연 | 선택적 연관 데이터 |
| fetchSize | ResultSet 전송 최적화 | 대량 조회 |
| Join 쿼리 | N+1 완전 제거 | 항상 함께 쓰는 데이터 |
MyBatis는 SQL을 직접 제어할 수 있다는 강점이 있지만, 그만큼 성능 최적화도 개발자의 몫입니다. JPA처럼 프레임워크가 알아서 해주는 부분이 적기 때문에, 위의 기법들을 상황에 맞게 조합하는 것이 중요합니다.