들어가며
신입 개발자로 첫 프로젝트를 시작하면 데이터베이스와 상호작용하는 SQL 쿼리를 작성하게 됩니다. 초기에는 “쿼리가 작동하는 것”에만 집중하기 쉽지만, 데이터가 늘어나면서 예상하지 못한 성능 문제가 발생합니다.
필자도 최근 개발 중 시니어 개발자로부터 SQL 성능 관련 조언을 받았습니다. 그 경험을 통해 발견한 신입이 놓치기 쉬운 SQL 성능 문제 5가지를 정리했습니다. 이 글이 당신의 코드가 나중에 느려지는 것을 미리 방지하는 데 도움이 되길 바랍니다.
1. 서브쿼리와 N+1 문제: SELECT 내 서브쿼리의 함정
문제 상황
공통 코드(코드 테이블)를 사용할 때, 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>
이것이 왜 문제일까요?
- 주문 데이터가 1,000건이라면, 서브쿼리는 1,000번 실행됩니다
- 각각의 서브쿼리마다 데이터베이스 왕복이 일어납니다
- 결과: N+1 쿼리 문제 발생 (1개의 메인 쿼리 + N개의 서브쿼리)
해결 방법: 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>
JOIN의 장점:
- 한 번의 쿼리로 모든 데이터 조회
- 데이터베이스가 최적화된 실행 계획 생성 가능
- 성능: 1,000배 이상 향상 (체감상)
2. WHERE 조건의 형변환: 컬럼을 변환하지 말고 조건을 변환하라
문제 상황
검색 조건을 처리할 때 자주 실수하는 부분입니다.
<!-- 나쁜 예: 컬럼을 형변환 -->
<select id="searchMembers">
SELECT * FROM members
WHERE CAST(phone AS VARCHAR) = #{searchPhone}
OR CAST(birthDate AS VARCHAR) LIKE #{searchBirth}
</select>
왜 이것이 문제일까요?
- 컬럼을 형변환하면 데이터베이스가 인덱스를 사용할 수 없습니다
- 풀 테이블 스캔(Full Table Scan)이 발생합니다
- 데이터가 1,000만 건이면 매번 1,000만 건을 모두 검사해야 합니다
해결 방법: 검색 조건을 형변환
<!-- 좋은 예: 검색 조건(입력값)을 형변환 -->
<select id="searchMembers">
SELECT * FROM members
WHERE phone = CAST(#{searchPhone} AS NUMBER)
OR birthDate LIKE CONCAT(#{searchBirth}, '%')
</select>
이점:
- 컬럼은 그대로 두고 입력값만 변환
- 데이터베이스가 인덱스를 활용할 수 있음
- 같은 데이터도 10배 이상 빠르게 검색
기억하세요: “컬럼을 건드리지 말고, 조건을 맞춰라”
3. LIKE 검색의 와일드카드 위치: 인덱스를 살리는 방법
문제 상황
검색 기능을 만들 때 사용자가 어디서 검색할지 모르니까 양쪽에 와일드카드를 붙입니다.
<!-- 나쁜 예: 양쪽에 와일드카드 -->
<select id="searchProducts">
SELECT * FROM products
WHERE name LIKE '%' || #{keyword} || '%'
</select>
인덱스와 와일드카드의 관계
데이터베이스 인덱스는 책의 목차처럼 정렬된 상태입니다. 검색할 때 왼쪽부터 읽어나갑니다.
인덱스 구조 (이름 컬럼 예시):
'김준호' → [인덱스 위치 1]
'김태규' → [인덱스 위치 2]
'박재훈' → [인덱스 위치 3]
'이순신' → [인덱스 위치 4]
- ✅
name LIKE '김%'→ “김”으로 시작 → 인덱스 활용 가능 (인덱스 1-2번 확인) - ❌
name LIKE '%김%'→ 어디든 포함 → 인덱스 활용 불가 (모든 데이터 확인) - ❌
name LIKE '%김'→ “김”으로 끝남 → 인덱스 활용 불가
해결 방법 3가지
방법 1: 앞글자 검색으로 제한 (가장 빠름)
<select id="searchProducts">
SELECT * FROM products
WHERE name LIKE #{keyword} || '%'
</select>
방법 2: Elasticsearch 같은 검색 엔진 도입 (복잡한 검색 필요 시)
// Elasticsearch를 사용하면 전문 검색 가능
SearchResponse response = client.search(request);
방법 3: 전문 검색(Full Text Search) 사용 (MySQL 예시)
<select id="searchProducts">
SELECT * FROM products
WHERE MATCH(name) AGAINST(#{keyword} IN BOOLEAN MODE)
</select>
성능 비교 (100만 건 데이터 기준)
LIKE '김%': 약 0.1초LIKE '%김%': 약 10초 이상 (100배 느림)
4. 배치 처리: 개별 INSERT의 성능 함정
문제 상황
대량의 데이터를 저장할 때 루프를 돌며 한 건씩 INSERT합니다.
// 나쁜 예: 1,000건을 1,000번 쿼리
for (Order order : orders) {
orderMapper.insert(order); // 쿼리 1회
}
// 총 시간: 10초 ~ 1분 이상
문제점:
- 데이터베이스와 1,000번 왕복
- 네트워크 오버헤드가 엄청남
- 트랜잭션 로그 처리로 인한 성능 저하
해결 방법: 배치 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>
// Java 코드: 한 번에 처리
List<Order> orders = new ArrayList<>();
// ... 1,000건 추가
orderMapper.insertBatch(orders); // 쿼리 1회
// 총 시간: 1초 미만
성능 비교
- 개별 INSERT (1,000건): 약 30초
- 배치 INSERT (1,000건): 약 1초
- 성능 향상: 30배
추가: 배치 처리 시 에러 처리 전략
대량 데이터 처리 시 부분 실패를 고려해야 합니다:
전략 1: All or Nothing (전부 성공 또는 전부 실패)
// 금융 거래, 재고 갱신처럼 정확성이 중요한 경우
@Transactional
public void saveOrdersBatch(List<Order> orders) {
orderMapper.insertBatch(orders); // 하나라도 실패하면 전부 롤백
}
전략 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);
}
선택 기준:
- 금융/재고: All or Nothing (데이터 정합성 필수)
- 회원/상품: 부분 성공 허용 (실패 건은 따로 재처리)
5. 동적 쿼리의 OR 조건: 같은 필드라면 IN을 사용하라
문제 상황
마이바티스의 동적 쿼리에서 같은 필드에 대해 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>
OR과 인덱스의 관계
데이터베이스가 OR 조건을 처리할 때의 실행 과정:
OR 조건의 실행 과정 (비효율적)
status = 'PENDING' OR
status = 'PROCESSING' OR
status = 'WAITING'"] --> B["조건 1: status = 'PENDING'
인덱스 스캔
→ 50,000개 행 검색"] A --> C["조건 2: status = 'PROCESSING'
인덱스 스캔
→ 75,000개 행 검색"] A --> D["조건 3: status = 'WAITING'
인덱스 스캔
→ 100,000개 행 검색"] B --> E["UNION 연산
3개의 결과 합치기"] C --> E D --> E E --> F["중복 제거
최종: 225,000개 행"] style B fill:#4a0000,stroke:#ff7f7f,color:#ff9999 style C fill:#4a0000,stroke:#ff7f7f,color:#ff9999 style D fill:#4a0000,stroke:#ff7f7f,color:#ff9999 style E fill:#662d2d,stroke:#ff9999,color:#ffb3b3 style F fill:#662d2d,stroke:#ff9999,color:#ffb3b3
IN 조건의 실행 과정 (효율적)
status IN('PENDING',
'PROCESSING', 'WAITING')"] --> B["하나의 조건으로 평가
인덱스 스캔
→ 225,000개 행 검색"] B --> C["결과 반환
최종: 225,000개 행"] style B fill:#004a00,stroke:#7fff7f,color:#99ff99 style C fill:#266626,stroke:#99ff99,color:#b3ffb3
문제: OR은 각 조건을 별도로 평가 후 UNION 처리로 인한 오버헤드 발생
해결 방법: IN 절 사용
<!-- 좋은 예: 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>
성능 비교 (100만 건 데이터 기준)
- OR 조건: 약 2초
- IN 절: 약 0.2초
- 성능 향상: 10배
다른 필드 간의 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>
이 경우는 OR을 피할 수 없으므로:
- Full Text Search 사용 고려
- Elasticsearch 같은 검색 엔진 도입 검토
보너스: 페이징은 필수, LIMIT 없는 조회는 금지
마지막으로 당연하지만 중요한 것입니다.
<!-- 나쁜 예: 전체 데이터 조회 -->
<select id="getAllMembers">
SELECT * FROM members
</select>
100만 건의 회원 데이터를 모두 메모리에 로드하면:
- Out of Memory 에러 발생 가능
- 네트워크 전송 시간 증가
<!-- 좋은 예: 페이징 처리 -->
<select id="getMembersWithPaging">
SELECT * FROM members
LIMIT #{offset}, #{pageSize}
</select>
관리자 페이지라도 반드시 페이징을 적용하세요.
실무 적용 체크리스트
마이바티스 쿼리 작성 시 이 체크리스트를 확인하세요:
- ✅ SELECT 절에 서브쿼리가 있는가? → JOIN으로 변경 검토
- ✅ WHERE 조건에서 컬럼을 형변환하고 있는가? → 입력값 형변환으로 변경
- ✅ LIKE 검색에 양쪽 와일드카드를 붙였는가? → 앞글자 검색으로 변경 또는 검색 엔진 도입
- ✅ 대량 데이터 INSERT를 개별로 처리하는가? → 배치 처리로 변경
- ✅ 같은 필드에 OR 조건이 여러 개 있는가? → IN 절로 변경
- ✅ LIMIT 없이 전체 데이터를 조회하는가? → 페이징 추가
결론: 작은 습관이 모인다
지금은 데이터가 적어서 성능 문제가 보이지 않을 수 있습니다. 하지만 6개월 후, 1년 후 데이터가 100배로 늘어나면? 지금 쓴 쿼리가 10배, 100배 느려집니다.
신입 때부터 이런 최적화 관습을 들이면:
- 나중에 리팩토링 비용이 줄어듭니다
- 성능 문제로 인한 장애를 미리 방지합니다
당신이 오늘 받은 시니어의 조언처럼, 이 글을 읽는 신입 개발자들도 같은 실수를 피하길 바랍니다.
당신의 경험을 공유해주세요
마이바티스를 사용하면서 “이거 실수했는데 큰 성능 문제였다” 같은 경험이 있으신가요? 댓글로 공유해주시면 다른 개발자들에게 큰 도움이 될 거예요!
자신만의 철학을 만들어가는 중입니다.
댓글남기기