들어가며

신입 개발자로 첫 프로젝트를 시작하면 데이터베이스와 주고받는 SQL 쿼리를 쓰게 된다. 처음에는 「쿼리가 돌아가는 것」에만 집중하기 쉽다. 그런데 데이터가 늘어나면서 예상하지 못한 성능 문제가 생긴다. 100건일 때는 아무 문제 없던 쿼리가 100만 건이 되면 몇 초씩 걸린다.

나도 최근 개발 중에 시니어 개발자에게서 SQL 성능에 관한 조언을 받았다. 그 경험에서 찾은 신입이 놓치기 쉬운 SQL 성능 문제 5가지를 정리했다. 지금 쓴 코드가 나중에 느려지는 것을 미리 막는 데 쓰려고 남긴다.


이 글의 예제를 읽으려면 먼저 알아야 하는 세 가지

예제는 전부 MyBatis로 쓴 SQL이다. 읽기 전에 세 가지를 깔고 간다.

첫째, 자바 프로그램과 DB는 따로 돈다. DB는 자바 프로그램과 다른 프로그램이고, 보통 다른 컴퓨터에 있다. 자바가 SQL 하나를 보내고 결과를 받아 오는 한 번의 오고 감을 DB 왕복이라고 부른다. 왕복마다 네트워크를 건너는 시간이 든다. 이 글에서 「몇 번 보내느냐」를 자꾸 세는 이유가 이것이다.

둘째, MyBatis는 SQL을 XML 파일에 적고 자바 메서드와 짝을 짓는다. 그래서 자바 글인데 XML 코드가 나온다. 짝 짓는 방식은 Mapper 인터페이스 · Mapper XML 용어 페이지에 있다. 예제에 자주 나오는 것만 짚는다.

<select id="selectOrders" resultType="OrderDto">
    SELECT id, amount FROM orders WHERE status = #{status}
</select>

태그 안의 SQL은 「주문 테이블 orders에서 status 컬럼이 넘겨받은 값과 같은 행만 골라 id와 amount를 가져오라」는 뜻이다. FROM 뒤가 읽을 테이블, WHERE 뒤가 고르는 조건이다.

  • <select ...> — 조회 SELECT 문 하나를 담는 태그다. 넣는 문은 <insert>, 고치는 문은 <update>에 담는다
  • id="selectOrders" — 이 SQL의 이름이다. 자바 쪽 Mapper 인터페이스에 같은 이름의 메서드 selectOrders(...)가 있으면 그 메서드를 부를 때 이 SQL이 나간다. 이 글에 나오는 orderMapper가 그 인터페이스의 객체다
  • resultType="OrderDto" — 결과 행 하나를 담을 클래스다. MyBatis 문서는 「이 문이 돌려줄 타입의 클래스 이름」이라고 적는다. OrderDto는 값만 담아 옮기는 DTO 클래스이고, 이 글에서는 정의를 생략했다. 뒤의 예제 몇 개는 글을 짧게 하려고 이 속성을 지면에서만 뺐다. 실제 매퍼에는 적는다
  • #{status} — 자바에서 넘긴 값이 들어갈 자리다(#{}). MyBatis가 이 자리를 ?로 바꿔 보내고 값은 따로 끼워 넣는다

셋째, 예제 SQL은 MySQL · MariaDB 문법으로 맞췄다. SQL은 DB마다 문법이 조금씩 다르다. 처음 쓴 글은 Oracle(또 다른 DB 제품) 문법(AS NUMBER, 글자를 잇는 ||)과 MySQL 문법(LIMIT, MATCH ... AGAINST)이 섞여 있었는데, 2026-10-19에 MySQL 쪽으로 통일했다. 바꾼 자리는 각 섹션에 적었다.

그리고 이 글에 나오는 「약 30초」「약 0.2초」 같은 시간 수치는 측정 조건(DB 버전, 장비, 데이터 모양)을 적어 두지 않은 어림값이다. 크기를 가늠하는 용도로만 읽는다.


1. 서브쿼리와 N+1 문제: SELECT 내 서브쿼리의 함정

문제 상황

주문 테이블 orders의 status 컬럼에는 「결제 대기」 같은 글자 대신 'P' 같은 짧은 코드가 들어 있다고 하자. 코드와 이름을 짝지은 표는 따로 codes 테이블(흔히 「공통 코드」라고 부른다)에 둔다. 이름이 바뀌어도 코드 표 한 줄만 고치면 되기 때문이다. 이 표 하나에 주문 상태 코드, 결제 수단 코드처럼 여러 종류의 코드를 같이 담기 때문에, 어느 종류의 코드인지 적는 code_type 컬럼이 따로 있다. 주문 상태 코드는 code_type이 'STATUS'인 줄들이다. 그런데 화면에는 'P'가 아니라 「결제 대기」를 보여 줘야 한다. 그래서 주문을 조회하면서 코드를 이름으로 바꿔야 한다.

이때 SELECT 문 안에 서브쿼리를 넣어 바꾸는 코드를 자주 본다.

<!-- 나쁜 예: SELECT 절 안의 서브쿼리 -->
<select id="selectOrders" resultType="OrderDto">
    SELECT
        id,
        amount,
        (SELECT code_name FROM codes WHERE code_type = 'STATUS' AND code = o.status) as statusName
    FROM orders o
</select>

한 줄씩 따라간다.

  • FROM orders o — orders 테이블에서 읽는다. 뒤의 o는 이 쿼리 안에서 orders를 짧게 부르는 테이블 별칭이다
  • id, amount — 주문 행마다 이 두 컬럼을 가져온다
  • (SELECT code_name FROM codes WHERE ... = o.status) — 괄호 안의 SELECT가 서브쿼리다. WHERE 조건은 「codes에서 종류가 'STATUS'이고 코드가 지금 읽고 있는 주문 행의 status와 같은 줄」을 고른다. 그 줄의 code_name(「결제 대기」)을 꺼낸다
  • as statusName — 꺼낸 값에 statusName이라는 이름을 붙인다. OrderDto의 같은 이름 필드에 담긴다

서브쿼리는 몇 번 돌고, 무엇이 비싼가

괄호 안의 SELECT는 o.status, 곧 바깥 행의 값을 쓴다. 그래서 바깥 행이 바뀔 때마다 다시 계산해야 한다. MySQL 문서는 이런 서브쿼리(쿼리 처리 방법을 미리 보여 주는 EXPLAIN 결과에서 DEPENDENT SUBQUERY라고 표시된다)를 「바깥에서 들어오는 값의 서로 다른 조합마다 한 번씩 다시 계산한다」고 적는다(MySQL 8.0 EXPLAIN Output Format). 주문이 1,000건이고 status 값이 전부 다르면 서브쿼리는 1,000번 계산된다. 같은 값이 많으면 그보다 적다.

처음 쓴 글에는 「서브쿼리마다 데이터베이스 왕복이 일어난다」고 적었는데, 이것은 틀렸다. 위 쿼리는 자바가 한 번 보내는 SQL 하나다. 서브쿼리 계산은 전부 DB 안에서 일어나고 네트워크를 다시 건너지 않는다. 그래서 자바가 쿼리를 1 + N번 보내는 진짜 N+1 문제와는 다르다. 「바깥 1번 + 행마다 안쪽 N번」이라는 모양이 닮았을 뿐이다.

그래도 이 글은 JOIN으로 바꾸기를 권한다. 처음 글이 든 이유 중 「왕복」은 빠졌고, 남는 이유는 아래 「JOIN의 장점」에 적은 것이다.

해결 방법: JOIN 사용

<!-- 좋은 예: JOIN을 사용한 조회 -->
<select id="selectOrders" resultType="OrderDto">
    SELECT
        o.id,
        o.amount,
        c.code_name as statusName
    FROM orders o
    LEFT JOIN codes c ON c.code_type = 'STATUS' AND c.code = o.status
</select>
  • LEFT JOIN codes c — orders 옆에 codes를 이어 붙인다(JOIN). c는 codes의 별칭이다
  • ON c.code_type = 'STATUS' AND c.code = o.status — 어떤 주문 행에 어떤 코드 행을 붙일지 정하는 조건이다. 서브쿼리의 WHERE와 같은 조건이다
  • LEFT인 이유 — 코드 표에 없는 status를 가진 주문도 결과에서 빠지지 않게 한다. 그런 주문은 statusName이 비어서(NULL) 나온다(LEFT JOIN)
  • c.code_name as statusName — 붙은 코드 행의 이름을 꺼낸다

JOIN의 장점:

  • 「두 테이블을 이어 붙인다」는 뜻이 SQL에 그대로 드러나서, 두 테이블을 어떤 순서와 방법으로 처리할지 DB의 옵티마이저에게 맡긴다. 서브쿼리 버전보다 실제로 얼마나 더 나은 방법을 고르는지는 확인하지 않았다
  • 성능: 1,000배 이상 향상 (체감상 — 잰 값이 아니다). 처음 글은 이 차이를 「왕복」으로 설명했는데 그 설명이 틀렸으니, 이 체감이 어디서 왔는지는 지금 이 글로 설명하지 못한다

2. WHERE 조건의 형변환: 컬럼을 변환하지 말고 조건을 변환하라

문제 상황

회원 테이블 members에서 phone 컬럼은 숫자 타입이고, birthDate 컬럼은 날짜 타입이라고 하자. 검색 화면에서 들어오는 값은 글자라서, 타입을 맞추려고 컬럼 쪽을 글자로 바꾸는 실수를 자주 한다.

<!-- 나쁜 예: 컬럼을 형변환 -->
<select id="searchMembers">
    SELECT * FROM members
    WHERE CAST(phone AS CHAR) = #{searchPhone}
       OR CAST(birthDate AS CHAR) LIKE #{searchBirth}
</select>
  • SELECT * — *는 모든 컬럼을 가져오라는 뜻이다
  • CAST(phone AS CHAR) — 행마다 phone의 숫자를 글자로 바꾼다(CAST). CHAR는 DB의 글자 타입 이름이다. 처음 쓴 글은 AS VARCHAR였는데, MySQL의 CAST는 VARCHAR를 받지 않고 CHAR로 쓴다(MySQL 8.0 Cast Functions)
  • = #{searchPhone} — 바꾼 글자가 입력값과 같은지 본다
  • OR CAST(birthDate AS CHAR) LIKE #{searchBirth} — 또는 날짜를 글자로 바꾼 것이 입력한 모양과 맞는지 본다(LIKE). 1995년생을 찾으려고 searchBirth에 '1995%'를 넣는 식이다

왜 이것이 문제일까

  • phone과 birthDate에는 인덱스를 만들어 두었다고 하자(만드는 문법은 이 글에서 다루지 않는다). 인덱스는 컬럼의 원래 값을 정렬해 둔 것이다. phone 인덱스는 숫자 순서로 줄 서 있다
  • 그런데 조건은 원래 값이 아니라 바꾼 글자를 묻는다. 숫자 순서와 글자 순서는 다르다. 숫자로는 9가 10보다 작지만, 글자로는 '10'이 '9'보다 앞이다. 그래서 숫자 순서로 줄 선 인덱스를 따라가서는 답을 찾을 수 없다
  • 결국 DB는 모든 행을 하나씩 꺼내 글자로 바꿔 보고 비교한다. 이것이 풀 테이블 스캔이다
  • 데이터가 1,000만 건이면 매번 1,000만 건을 모두 검사해야 한다

해결 방법: 검색 조건을 형변환

<!-- 좋은 예: 검색 조건(입력값)을 형변환 -->
<select id="searchMembers">
    SELECT * FROM members
    WHERE phone = CAST(#{searchPhone} AS UNSIGNED)
       OR (birthDate >= #{birthFrom} AND birthDate < #{birthTo})
</select>
  • phone = CAST(#{searchPhone} AS UNSIGNED) — 컬럼은 그대로 두고 입력값 하나만 숫자로 바꾼다. 처음 쓴 글은 AS NUMBER였는데 MySQL의 CAST에는 NUMBER가 없어서 숫자 타입인 UNSIGNED로 바꿨다(같은 문서). 이제 비교 대상이 숫자라 숫자 순서로 줄 선 인덱스를 따라갈 수 있다
  • birthDate >= #{birthFrom} AND birthDate < #{birthTo} — 「이 날부터 저 날 전까지」로 범위를 묻는다. 예를 들어 1995년생을 찾으려면 birthFrom에 1995-01-01, birthTo에 1996-01-01을 넣는다. MySQL 문서는 인덱스 컬럼을 상수와 >, <, >=, <=로 비교하면 인덱스로 범위를 찾을 수 있다고 적는다(MySQL 8.0 Range Optimization)

처음 쓴 글의 좋은 예는 birthDate LIKE CONCAT(#{searchBirth}, '%')였는데, 이것은 이 섹션의 주장과 어긋났다. LIKE는 글자 비교라서 날짜 컬럼에 쓰면 결국 날짜를 글자로 바꿔 비교하게 된다. 컬럼을 바꾸지 말자는 섹션에서 컬럼을 바꾸는 셈이라 범위 조건으로 고쳤다.

이점:

  • 컬럼은 그대로 두고 입력값만 변환
  • 데이터베이스가 인덱스를 활용할 수 있음
  • 같은 데이터도 10배 이상 빠르게 검색

기억할 것: 「컬럼을 건드리지 말고, 조건을 맞춰라」


3. LIKE 검색의 와일드카드 위치: 인덱스를 살리는 방법

문제 상황

상품 이름 검색 기능을 만들 때, 사용자가 이름의 어느 부분을 칠지 모르니까 검색어 양쪽에 와일드카드 %를 붙인다.

<!-- 나쁜 예: 양쪽에 와일드카드 -->
<select id="searchProducts">
    SELECT * FROM products
    WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>
  • CONCAT('%', #{keyword}, '%') — 글자 세 개를 이어 붙인다. 검색어가 김이면 '%김%'가 된다
  • name LIKE '%김%' — 이름 어디든 「김」이 들어간 상품을 고른다

처음 쓴 글은 '%' || #{keyword} || '%'였다. ||로 글자를 잇는 것은 Oracle 같은 DB의 문법이고, MySQL에서 ||는 기본으로 「또는(OR)」이다. 글자 잇기로 쓰려면 PIPES_AS_CONCAT이라는 설정을 따로 켜야 한다(MySQL 8.0 Logical Operators). 자바의 ||와 같은 뜻으로 읽혀서 헷갈리기도 쉬우니 CONCAT으로 바꿨다.

인덱스와 와일드카드의 관계

DB 인덱스는 책 뒤의 찾아보기처럼 값을 가나다순으로 정렬해 둔 목록이다. 목록의 각 줄에는 「이 이름을 가진 행이 테이블 어디에 있는지」가 함께 적혀 있다.

인덱스 구조 (이름 컬럼 예시):
'김준호' → 테이블에서 이 행이 있는 자리
'김태규' → 테이블에서 이 행이 있는 자리
'박재훈' → 테이블에서 이 행이 있는 자리
'이순신' → 테이블에서 이 행이 있는 자리

정렬돼 있으니 글자의 앞부분을 알면 목록에서 그 구간으로 바로 건너뛸 수 있다. 사전에서 「김」으로 시작하는 단어를 찾을 때 「ㄱ」 쪽을 펴는 것과 같다. 앞부분을 모르면 처음부터 끝까지 넘겨 봐야 한다.

  • name LIKE '김%' — 「김」으로 시작 → 정렬된 목록에서 「김」 구간(위 예에서 첫 두 줄)만 보면 된다. 인덱스를 쓴다
  • name LIKE '%김%' — 어디든 포함 → 앞부분을 모르니 목록 전체를 봐야 한다. 인덱스를 못 쓴다
  • name LIKE '%김' — 「김」으로 끝남 → 역시 앞부분을 모른다. 인덱스를 못 쓴다

MySQL 문서도 같은 것을 적는다. LIKE의 모양이 와일드카드로 시작하지 않는 상수 글자면 인덱스를 쓰고, '%Patrick%'처럼 와일드카드로 시작하면 쓰지 않는다(MySQL 8.0 Comparison of B-Tree and Hash Indexes).

해결 방법 3가지

방법 1: 앞글자 검색으로 제한 (가장 빠름)

<select id="searchProducts">
    SELECT * FROM products
    WHERE name LIKE CONCAT(#{keyword}, '%')
</select>

검색어 뒤에만 %를 붙인다. 검색어가 김이면 '김%'가 되어 인덱스를 쓴다. 대신 「이름 중간에 있는 말」로는 찾을 수 없다. 사용자가 그것을 받아들일 수 있는 화면에서만 쓴다.

방법 2: Elasticsearch 같은 검색 엔진 도입 (복잡한 검색 필요 시)

Elasticsearch는 DB와 따로 띄우는 검색 전용 프로그램이다. 자바에서는 Elasticsearch 라이브러리가 주는 객체(검색 요청을 대신 보내 주는 객체라 「클라이언트」라고 부른다)로 검색 요청을 보내고 결과를 받는다. 대가는 프로그램을 하나 더 운영해야 하고, 검색할 데이터를 DB와 Elasticsearch 양쪽에 넣어 맞춰 둬야 한다는 것이다.

방법 3: 전문 검색(Full Text Search) 사용 (MySQL 예시)

<select id="searchProducts">
    SELECT * FROM products
    WHERE MATCH(name) AGAINST(#{keyword} IN BOOLEAN MODE)
</select>
  • MATCH(name) — name 컬럼을 단어 단위로 찾겠다는 뜻이다. 이 컬럼에 FULLTEXT라는 별도 인덱스를 미리 만들어 둔다(만드는 문법은 이 글에서 다루지 않는다). MySQL 문서에 따르면 이 인덱스는 InnoDB · MyISAM(스토리지 엔진 이름이다) 테이블의 글자 컬럼(CHAR · VARCHAR · TEXT)에만 만들 수 있다(MySQL 8.0 Full-Text Search Functions)
  • AGAINST(#{keyword} ...) — 찾을 검색어다
  • IN BOOLEAN MODE — 검색어를 해석하는 방식 하나를 고른 것이다. 방식마다의 문법은 이 글에서 다루지 않는다

FULLTEXT 인덱스는 글을 단어로 쪼개 「이 단어는 어느 행에 있다」는 목록을 들고 있다(InnoDB Full-Text Indexes). 그래서 단어가 이름 가운데 있어도 처음부터 다 훑지 않는다. LIKE와 달리 글자 조각이 아니라 단어로 찾는다는 점이 다르다. 그래서 '김' 한 글자로 '김태규'가 찾아지는지는 이름을 어떻게 단어로 쪼개느냐에 달렸고, 나는 확인하지 않았다. 방법 3이 '%김%'를 그대로 대신한다고 보지 않는다.

성능 비교 (100만 건 데이터 기준)

  • LIKE '김%': 약 0.1초
  • LIKE '%김%': 약 10초 이상 (100배 느림)

4. 배치 처리: 개별 INSERT의 성능 함정

문제 상황

주문 1,000건처럼 많은 데이터를 저장할 때, 반복문을 돌며 한 건씩 INSERT한다. 여러 건을 한 번에 묶어 처리하는 것을 「배치 처리」라고 부르는데, 이 코드는 그 반대다.

// 나쁜 예: 1,000건을 1,000번 쿼리
for (Order order : orders) {
    orderMapper.insert(order);  // 쿼리 1회
}
  • orders — 저장할 주문 1,000개가 든 리스트다
  • orderMapper.insert(order) — Mapper XML에서 id="insert"인 <insert> SQL을 부른다. 주문 하나를 넣는 INSERT 문 하나가 DB로 나간다

문제점:

  • 데이터베이스와 1,000번 왕복한다. 왕복마다 SQL을 네트워크로 보내고 응답을 기다리는 시간이 붙는다. 이렇게 본래 일(행 넣기) 말고 더 드는 시간을 오버헤드라고 부른다
  • 트랜잭션 로그 처리로 인한 성능 저하 — 처음 쓴 글의 주장이다. 한 건씩 넣을 때 이 처리가 왜 더 느린지는 확인하지 않았다

해결 방법: 배치 INSERT

<!-- MyBatis 배치 INSERT -->
<insert id="insertBatch" parameterType="list">
    INSERT INTO orders (id, amount, status) VALUES
    <foreach collection="list" item="order" separator=",">
        (#{order.id}, #{order.amount}, #{order.status})
    </foreach>
</insert>
  • parameterType="list" — 이 SQL에 넘어오는 값이 리스트라는 표시다. MyBatis 문서는 이 속성을 「넘겨받을 파라미터의 클래스 이름이나 별칭」이라고 적고, 생략해도 된다고 한다
  • INSERT INTO orders (id, amount, status) VALUES — orders 테이블의 세 컬럼에 값을 넣겠다는 앞부분이다
  • <foreach collection="list" item="order" separator=","> — 리스트를 돌며 안쪽 조각을 반복해 붙인다(<foreach>). 리스트 하나만 넘기면 MyBatis가 그 리스트를 list라는 이름으로 부를 수 있게 해 준다(MyBatis 소스 ParamNameResolver에서 확인). item="order"는 한 칸씩 꺼낸 주문을 order로 부르겠다는 뜻이고, separator=","는 조각 사이에 쉼표를 넣으라는 뜻이다
  • (#{order.id}, #{order.amount}, #{order.status}) — 주문 하나의 값 세 개다

주문이 3건이면 만들어지는 SQL은 이런 모양이 된다. ? 자리에 값이 하나씩 들어간다.

INSERT INTO orders (id, amount, status) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)
// Java 코드: 한 번에 처리
List<Order> orders = new ArrayList<>();
// ... 1,000건 추가
orderMapper.insertBatch(orders);  // 쿼리 1회

자바에서는 1,000건을 리스트에 담아 insertBatch를 한 번 부른다. 행 1,000개를 담은 INSERT 문 하나가 한 번의 왕복으로 나간다.

성능 비교

  • 개별 INSERT (1,000건): 약 30초
  • 배치 INSERT (1,000건): 약 1초
  • 성능 향상: 30배

처음 쓴 글은 코드 주석에 「10초 ~ 1분 이상」「1초 미만」을 따로 적어서 이 비교와 숫자가 어긋났다. 주석에서 시간을 빼고 이 비교 하나만 남겼다.

추가: 배치 처리 시 에러 처리 전략

1,000건을 넣다가 몇 건이 실패하는 경우를 미리 정해 둬야 한다. 흔한 예가 이미 있는 id를 또 넣으려 할 때다. id가 행끼리 겹치면 안 되는 PK라서 DB가 그 INSERT를 거부한다. 이것을 「중복 키 에러」라고 부른다. 전략은 두 가지다.

전략 1: All or Nothing (전부 성공 또는 전부 실패)

// 금융 거래, 재고 갱신처럼 정확성이 중요한 경우
@Transactional
public void saveOrdersBatch(List<Order> orders) {
    orderMapper.insertBatch(orders);  // 하나라도 실패하면 전부 롤백
}
  • @Transactional — 이 메서드 전체를 트랜잭션 하나로 묶는다. 도중에 예외가 나면 이 메서드에서 한 DB 작업을 전부 되돌린다(롤백)
  • 그래서 1,000건 중 하나라도 실패하면 한 건도 남지 않는다
  • 아래에서 보듯 배치 INSERT 문 하나가 중복 키로 실패하면 InnoDB가 그 문을 혼자서도 되돌린다. 그래도 @Transactional을 붙이는 이유는, 이 메서드가 재고 갱신처럼 SQL을 더 보내게 되었을 때 그것까지 한 묶음으로 되돌리기 위해서다. 그리고 중복 키가 아닌 에러에서도 문 하나가 통째로 되돌려지는지는 확인하지 않았다

전략 2: 성공/실패 분리 처리 (배치 + 개별 재처리)

// 회원 정보 일괄 업로드처럼 부분 성공을 허용하는 경우
public BatchResult saveOrdersBatchWithPartialSuccess(List<Order> orders) {
    List<Order> successList = new ArrayList<>();
    List<Order> failList = new ArrayList<>();

    int batchSize = 1000;

    // 배치 단위로 처리
    for (int i = 0; i < orders.size(); i += batchSize) {
        int end = Math.min(i + batchSize, orders.size());
        List<Order> batch = orders.subList(i, end);

        try {
            // 배치 INSERT 시도
            orderMapper.insertBatch(batch);
            successList.addAll(batch);
        } catch (Exception e) {
            // 배치 실패 시 개별로 재처리
            for (Order order : batch) {
                try {
                    orderMapper.insert(order);
                    successList.add(order);
                } catch (Exception ex) {
                    // 실패한 데이터만 따로 저장
                    failList.add(order);
                }
            }
        }
    }

    return new BatchResult(successList, failList);
}
  • BatchResult — 성공 목록과 실패 목록 두 개를 담아 돌려주는 작은 클래스다. 정의는 생략했다
  • for (int i = 0; ...; i += batchSize) — 전체를 1,000건씩 잘라 돈다. orders.subList(i, end)가 그 1,000건 묶음이다
  • try { orderMapper.insertBatch(batch); ... } — 묶음 하나를 배치 INSERT로 한 번에 넣는다. 성공하면 묶음 전체를 성공 목록에 담는다
  • catch (Exception e) { for (Order order : batch) ... } — 묶음이 실패하면 그 묶음을 한 건씩 다시 넣는다. 넣어지는 건 성공 목록에, 또 실패하는 건 실패 목록에 담는다
  • 이 메서드에는 @Transactional이 없다. MySQL은 기본으로 autocommit이 켜져 있어서 SQL 문 하나하나가 각자 트랜잭션이 되고, 에러 없이 끝나면 그 자리에서 확정(커밋)된다(MySQL 8.0 autocommit, Commit, and Rollback). 그래서 성공한 묶음과 성공한 한 건은 뒤에서 다른 것이 실패해도 남는다. 이것이 「부분 성공」이다. 스프링 · MyBatis 설정이 이 기본값을 바꾸는 경우가 있는지는 확인하지 않았다

배치가 실패했을 때 묶음의 앞쪽 몇 건은 이미 들어가 있지 않을까? 그러면 한 건씩 다시 넣을 때 두 번 들어간다. MySQL의 InnoDB는 중복 키 에러가 나면 그 SQL 문 하나를 통째로 되돌린다(InnoDB Error Handling). 배치 INSERT는 SQL 문 하나라서, 중복 키로 실패하면 그 묶음의 행은 하나도 남지 않고 다시 넣어도 두 번 들어가지 않는다. 중복 키가 아닌 다른 에러에서도 그런지는 확인하지 않았다.

선택 기준:

  • 금융/재고: All or Nothing. 일부만 들어가면 데이터 정합성이 깨진다(주문은 들어갔는데 재고는 그대로인 식이다)
  • 회원/상품: 부분 성공 허용 (실패 건은 따로 재처리)

5. 동적 쿼리의 OR 조건: 같은 필드라면 IN을 사용하라

문제 상황

검색 화면에서 주문 상태를 여러 개 골라 찾는 기능이다. 사용자가 고른 상태만 조건에 넣어야 하니, 들어온 값에 따라 SQL 모양이 바뀌는 동적 쿼리를 쓴다. MyBatis에서는 <if> 태그로 조건 조각을 넣거나 뺀다. 이때 같은 컬럼에 대해 OR 조건을 여러 개 만드는 코드를 자주 본다.

<!-- 나쁜 예: 같은 필드에 OR 조건 -->
<select id="searchOrders">
    SELECT * FROM orders WHERE 1=1
    <if test="status1 != null">
        OR status = #{status1}
    </if>
    <if test="status2 != null">
        OR status = #{status2}
    </if>
    <if test="status3 != null">
        OR status = #{status3}
    </if>
</select>
  • status1, status2, status3 — 이 SQL을 부를 때 넘긴 검색 조건 객체의 필드다. 사용자가 고른 상태가 하나씩 들어 있고, 안 고른 칸은 null이다
  • <if test="status1 != null"> — status1이 비어 있지 않을 때만 안쪽 조각 OR status = #{status1}을 붙인다
  • WHERE 1=1 — 늘 참인 조건이다. <if>가 하나도 안 붙으면 WHERE 뒤가 비어 문법 에러가 나니, 그걸 막으려고 아무 뜻 없는 조건을 하나 깔아 두는 흔한 습관이다

이 나쁜 예는 느리기 전에 결과부터 틀린다

처음 쓴 글에서 놓친 것이 있다. 사용자가 PENDING 하나를 고르면 만들어지는 SQL은 이렇다.

SELECT * FROM orders WHERE 1=1 OR status = 'PENDING'

1=1이 늘 참이라 OR 뒤가 무엇이든 조건 전체가 참이다. 모든 주문이 나온다. WHERE 1=1 습관은 뒤에 AND를 붙일 때만 맞고, OR와 함께 쓰면 조건이 사라진다.

원래 의도대로 「고른 상태 중 하나」를 찾으려면 WHERE 1=1 대신 MyBatis의 <where> 태그를 쓴다. <where>는 안에 붙은 조각이 있을 때만 WHERE를 달고, 맨 앞 조각의 AND나 OR를 떼어 낸다(MyBatis 소스 WhereSqlNode · TrimSqlNode에서 확인).

<!-- 의도대로 고친 OR 버전 -->
<select id="searchOrders">
    SELECT * FROM orders
    <where>
        <if test="status1 != null">OR status = #{status1}</if>
        <if test="status2 != null">OR status = #{status2}</if>
        <if test="status3 != null">OR status = #{status3}</if>
    </where>
</select>

두 개를 고르면 SELECT * FROM orders WHERE status = ? OR status = ?가 된다. 이제 결과는 맞는다. 남은 질문은 이 OR 모양과 IN 모양 중 무엇을 쓰느냐다.

OR과 IN, DB는 어떻게 처리하나

처음 쓴 글에는 「OR은 조건마다 인덱스를 따로 훑고 결과를 합친 뒤 중복을 지워서 느리고, IN은 조건 하나로 한 번에 훑어서 빠르다」는 그림을 그렸다. MySQL 문서로 확인해 보니 이 설명은 문서와 맞지 않아 그림을 지웠다. 문서는 인덱스 컬럼에 대한 두 모양을 나란히 놓고 같은 것으로 다룬다.

col_name IN(val1, ..., valN) / col_name = val1 OR ... OR col_name = valN — 둘 다 「컬럼이 여러 값 중 하나와 같은가」를 묻는 동등 범위 비교다. (MySQL 8.0 Range Optimization의 Equality Range 절을 옮겼다)

「같다」인데 「범위」라고 부르는 것은, 정렬된 인덱스에서 찾아갈 구간이 값 하나짜리이기 때문이다. 문서도 「여기서 범위는 값 하나다」라고 덧붙인다. 상태 세 개를 고르면 인덱스에서 값 하나짜리 구간 세 개를 찾아가는 것이고, OR로 쓰든 IN으로 쓰든 문서는 같은 비교로 분류한다. 두 모양이 언제나 똑같은 실행 방법을 받는다고까지 적혀 있지는 않다. 하지만 적어도 「OR이라서 합치고 중복을 지운다」는 설명의 근거는 찾지 못했다. 같은 상태 값끼리는 겹칠 수도 없다(한 주문의 상태는 하나다).

해결 방법: IN 절 사용

그래도 IN을 고른다. 이유는 속도가 아니라 쿼리의 모양이다.

  • 고를 수 있는 개수가 정해지지 않는다. OR 버전은 status1~status3처럼 칸 수가 SQL에 박혀서, 네 번째 상태가 생기면 SQL과 검색 조건 객체를 둘 다 고쳐야 한다. IN 버전은 리스트 하나라 몇 개든 그대로다
  • 조건이 한 덩어리다. 상태 조건 전체가 status IN (...) 하나로 묶여서, 다른 조건과 AND로 붙여도 위의 1=1 OR 같은 사고가 나지 않는다
<!-- 좋은 예: IN 절로 한 번에 처리 -->
<select id="searchOrders">
    SELECT * FROM orders WHERE 1=1
    <if test="statusList != null and statusList.size() > 0">
        AND status IN
        <foreach collection="statusList" item="status" open="(" close=")" separator=",">
            #{status}
        </foreach>
    </if>
</select>
  • statusList — status1~status3 세 칸 대신 검색 조건 객체에 둔 리스트 필드다. 사용자가 고른 상태가 몇 개든 여기에 다 담긴다
  • <if test="statusList != null and statusList.size() > 0"> — 리스트가 있고 비어 있지 않을 때만 상태 조건을 붙인다
  • AND status IN — 여기서는 OR가 아니라 AND다. 「1=1 이면서 상태가 목록 중 하나」라는 뜻이라, 상태 조건 전체를 앞 조건에 AND로 하나 붙인 것이다(IN)
  • <foreach ... open="(" close=")" separator=","> — 리스트를 돌며 #{status}를 붙인다. open은 맨 앞에, close는 맨 뒤에, separator는 사이에 붙는다. 상태를 두 개 고르면 status IN (?, ?)가 된다

성능 비교 (100만 건 데이터 기준)

  • OR 조건: 약 2초
  • IN 절: 약 0.2초
  • 성능 향상: 10배

이 수치는 처음 쓴 글의 것을 그대로 두었다. 다만 위에서 본 것처럼 MySQL 문서는 두 모양을 같은 종류의 비교로 다룬다. 그리고 처음 글의 나쁜 예는 1=1 OR 때문에 모든 행을 읽는 쿼리였다. 그래서 이 10배가 OR이라는 모양 때문이라고는 이 글로 말할 수 없다.

다른 필드 간의 OR은?

<!-- 이름 OR 이메일로 검색: 어쩔 수 없이 OR 사용 -->
<select id="searchMembers">
    SELECT * FROM members WHERE 1=1
    <if test="keyword != null">
        AND (name LIKE #{keyword} OR email LIKE #{keyword})
    </if>
</select>

keyword에는 %가 붙어 있지 않다. 이대로면 LIKE가 「같다」와 같은 뜻이 된다. 포함 검색을 하려면 자바에서 %를 붙여 넘기거나 앞에서 본 CONCAT을 써야 한다. 처음 쓴 글의 코드를 그대로 두었다.

이름과 이메일은 서로 다른 컬럼이라 IN 하나로 묶을 수 없다. 괄호로 OR 두 개를 한 덩어리로 감싸고, 그 덩어리를 AND로 붙였다. 인덱스 하나는 컬럼 하나의 순서로 줄 서 있어서, 이름 순서로 줄 선 목록으로는 이메일 조건에 답하지 못한다. 그래서 같은 컬럼 OR처럼 한 인덱스 안의 구간 몇 개로 끝나지 않는다. DB가 두 인덱스를 함께 쓸 수 있는지는 확인하지 않았다. 게다가 포함 검색('%...%')이면 3번에서 본 대로 어느 인덱스도 못 쓴다. 이 경우는 OR을 피할 수 없으므로:


보너스: 페이징은 필수, LIMIT 없는 조회는 금지

마지막으로 당연하지만 놓치기 쉬운 것이다.

<!-- 나쁜 예: 전체 데이터 조회 -->
<select id="getAllMembers">
    SELECT * FROM members
</select>

회원이 100만 명이면 이 쿼리는 100만 행을 전부 돌려준다. MyBatis는 행마다 자바 객체를 하나씩 만들어 리스트에 담으니, 자바 프로그램의 메모리에 객체 100만 개가 한꺼번에 올라간다.

  • 메모리가 모자라면 OutOfMemoryError가 난다
  • 100만 행이 DB에서 자바 프로그램까지 네트워크를 건너오니 전송 시간도 늘어난다
<!-- 좋은 예: 페이징 처리 -->
<select id="getMembersWithPaging">
    SELECT * FROM members
    LIMIT #{offset}, #{pageSize}
</select>
  • LIMIT #{offset}, #{pageSize} — 앞에서 offset개를 건너뛰고 그다음 pageSize개만 가져온다(LIMIT · OFFSET). MySQL 문서에 따르면 쉼표로 두 값을 주면 앞이 건너뛸 개수, 뒤가 가져올 최대 개수이고, 첫 행은 0번이다(MySQL 8.0 SELECT Statement)
  • offset은 자바에서 계산해 넘긴다. 한 페이지에 20개씩 보여 줄 때 3페이지라면 offset = (3 - 1) * 20 = 40이다

이렇게 결과를 페이지 단위로 끊어 가져오는 것이 페이징이다. 직원만 쓰는 관리자 페이지는 「사람이 적게 쓰니 전부 가져와도 된다」고 생각하기 쉽다. 하지만 데이터 양은 쓰는 사람 수와 상관없이 늘어난다. 관리자 페이지라도 반드시 페이징을 적용한다.


실무 적용 체크리스트

마이바티스 쿼리를 쓸 때 이 목록을 확인한다.

  • SELECT 절에 서브쿼리가 있는가? → JOIN으로 변경 검토
  • WHERE 조건에서 컬럼을 형변환하고 있는가? → 입력값 형변환으로 변경
  • LIKE 검색에 양쪽 와일드카드를 붙였는가? → 앞글자 검색으로 변경 또는 검색 엔진 도입
  • 대량 데이터 INSERT를 개별로 처리하는가? → 배치 처리로 변경
  • 같은 필드에 OR 조건이 여러 개 있는가? → IN 절로 변경 (WHERE 1=1 뒤에 OR를 붙이지 않았는지도 본다)
  • LIMIT 없이 전체 데이터를 조회하는가? → 페이징 추가

결론: 작은 습관이 모인다

지금은 데이터가 적어서 성능 문제가 보이지 않을 수 있다. 하지만 6개월 후, 1년 후 데이터가 100배로 늘어나면? 지금 쓴 쿼리가 10배, 100배 느려진다.

신입 때부터 이런 습관을 들이면:

  • 나중에 쿼리를 다시 뜯어고치는(리팩토링) 비용이 줄어든다
  • 성능 문제로 서비스가 멈추는 장애를 미리 막는다

내가 시니어에게 받은 조언처럼, 이 글을 읽는 신입 개발자들도 같은 실수를 피하길 바란다.


마이바티스로 겪은 성능 실수를 댓글로 모은다

마이바티스를 쓰면서 「이거 실수했는데 큰 성능 문제였다」 같은 경험이 있다면 댓글로 남겨 주면 좋겠다. 다른 개발자들에게 도움이 된다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기