쿼리 하나하나는 빠른데, 왜 전체 API 응답은 느릴까?

MyBatis를 쓰다 보면 개별 SQL은 문제없는데 전체 성능이 기대 이하인 경우를 만납니다. 대부분 캐시 미활용, 불필요한 반복 쿼리(N+1), 한 건씩 Insert하는 패턴에서 병목이 생깁니다. 이 글에서는 MyBatis가 제공하는 성능 최적화 도구들을 하나씩 살펴보겠습니다.

1차 캐시 — SqlSession 범위의 자동 캐시

1차 캐시 는 SqlSession 내부에 자동으로 동작하는 캐시입니다. 같은 SqlSession에서 동일한 쿼리 + 동일한 파라미터로 조회하면, 두 번째부터는 DB에 가지 않고 캐시된 객체를 반환합니다.

JAVA
// 같은 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 간에도 캐시를 공유할 수 있습니다.

설정 방법

XML
<!-- 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
readOnlytrue면 성능 향상, false면 안전한 복사본 반환false

2차 캐시 사용 시 주의사항

  • readOnly="false"일 때 캐시 객체가 직렬화되므로 DTO에 Serializable을 구현 해야 합니다
  • INSERT/UPDATE/DELETE 실행 시 해당 Namespace의 캐시가 전부 비워집니다
  • 다른 Namespace에서 같은 테이블을 수정 하면 캐시 불일치가 발생할 수 있습니다
JAVA
// 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 사용법

JAVA
// 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을 별도로 등록해야 합니다.

JAVA
@Configuration
public class MyBatisBatchConfig {

    @Bean
    public SqlSessionTemplate batchSqlSessionTemplate(SqlSessionFactory factory) {
        // BATCH 모드 전용 SqlSessionTemplate 생성
        return new SqlSessionTemplate(factory, ExecutorType.BATCH);
    }
}
JAVA
@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 레벨에서 여러 행을 한 번에 넣는 방법도 있습니다.

XML
<!-- 한 번의 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 방식을 사용할 때 전형적으로 나타납니다.

문제가 발생하는 패턴

XML
<!-- 주문 목록 조회 — 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으로 단일 쿼리 만들기

XML
<!-- 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 자체를 없애진 못하지만, 실제로 접근할 때만 추가 쿼리를 실행하게 만들 수 있습니다.

XML
<!-- fetchType="lazy"로 지연 로딩 -->
<collection property="items" column="id"
            select="findItemsByOrderId"
            fetchType="lazy"/>

items 필드에 접근하지 않으면 추가 쿼리가 아예 실행되지 않습니다.

Lazy Loading 상세 설정

MyBatis의 Lazy Loading은 프록시 객체를 통해 구현됩니다. 연관 객체에 실제로 접근하는 시점에 쿼리가 실행됩니다.

글로벌 설정

YAML
# application.yml
mybatis:
  configuration:
    lazy-loading-enabled: true           # 지연 로딩 활성화
    aggressive-lazy-loading: false       # false여야 개별 필드 접근 시 로딩

aggressiveLazyLoadingtrue이면 객체의 ** 아무 메서드라도 호출 **되는 순간 모든 Lazy 프로퍼티가 한꺼번에 로딩됩니다. false로 설정해야 실제로 해당 프로퍼티에 접근할 때만 쿼리가 실행됩니다.

개별 설정 — fetchType

글로벌 설정과 별개로, 각 association/collection에서 개별적으로 지정할 수 있습니다.

XML
<!-- 글로벌 설정과 무관하게 이 매핑만 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입니다.

XML
<!-- 대량 조회 시 fetchSize를 크게 설정 -->
<select id="findAllUsers" resultType="User" fetchSize="1000">
  SELECT * FROM users
</select>
YAML
# 글로벌 fetchSize 설정
mybatis:
  configuration:
    default-fetch-size: 100

fetchSize 설정 가이드

상황권장 fetchSize
일반 CRUD기본값 유지
수천~수만 건 조회100~500
배치 처리, 전체 덤프1000~5000
메모리 제한 환경50 이하

fetchSize를 너무 크게 잡으면 한 번에 메모리에 올라오는 데이터가 많아져서 OutOfMemoryError가 발생할 수 있습니다. 데이터 크기와 가용 메모리를 고려해서 조절해야 합니다.

Mapper XML 최적화 팁

1. 필요한 컬럼만 SELECT

XML
<!-- 나쁜 예 — 모든 컬럼 조회 -->
<select id="findUsers" resultType="User">
  SELECT * FROM users
</select>

<!-- 좋은 예 — 필요한 컬럼만 조회 -->
<select id="findUsers" resultType="User">
  SELECT id, name, email FROM users
</select>

2. sql 태그로 재사용 가능한 SQL 조각 분리

XML
<!-- 공통 컬럼 목록 정의 -->
<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 제어

XML
<!-- 이 쿼리는 항상 최신 데이터가 필요 — 캐시 사용 안 함 -->
<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 성능 이슈를 만났을 때 확인할 항목들을 정리했습니다.

  1. **N+1 확인 **: SQL 로그를 켜서 예상보다 많은 쿼리가 나가는지 확인
  2. ** 캐시 전략 **: 읽기 위주 데이터에 2차 캐시 적용 검토
  3. **Batch 처리 **: 대량 INSERT/UPDATE는 ExecutorType.BATCH 활용
  4. **fetchSize 조정 **: 대량 조회 시 적절한 fetchSize 설정
  5. **SELECT 최소화 **: SELECT * 대신 필요한 컬럼만 조회
  6. **인덱스 활용 **: WHERE 절의 조건 컬럼에 인덱스가 걸려 있는지 확인
  7. Lazy Loading: 항상 필요하지 않은 연관 데이터는 지연 로딩 적용
YAML
# SQL 로그 활성화 — 개발 환경에서 필수
logging:
  level:
    com.example.mapper: DEBUG    # Mapper 패키지의 SQL 로그
    org.mybatis: DEBUG           # MyBatis 내부 동작 로그

성능 최적화의 첫 걸음은 SQL 로그를 켜는 것입니다. 어떤 쿼리가 몇 번 실행되는지 눈으로 확인하면 병목 지점이 바로 보입니다.

정리

기법효과적용 대상
1차 캐시같은 트랜잭션 내 중복 쿼리 제거@Transactional 범위
2차 캐시Namespace 단위 조회 캐시읽기 비율 높은 테이블
Batch Insert네트워크 왕복 최소화대량 데이터 입력
Lazy Loading불필요한 쿼리 지연선택적 연관 데이터
fetchSizeResultSet 전송 최적화대량 조회
Join 쿼리N+1 완전 제거항상 함께 쓰는 데이터

MyBatis는 SQL을 직접 제어할 수 있다는 강점이 있지만, 그만큼 성능 최적화도 개발자의 몫입니다. JPA처럼 프레임워크가 알아서 해주는 부분이 적기 때문에, 위의 기법들을 상황에 맞게 조합하는 것이 중요합니다.

댓글 로딩 중...