no image
[DB 성능 최적화] 기본 SQL 튜닝
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.많은 데이터를 조회하는 단건 쿼리데이터를 조회할 때 한 번에 너무 많은 데이터를 조회하는 건 아닌지 체크LIMIT, WHERE문 등을 활용해서 한 번에 조회하는 데이터의 수를 줄이는 방법을 고려 테이블 생성DROP TABLE IF EXISTS users; # 기존 테이블 삭제CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT); 100만건의 랜덤 데이터 삽입-- 높은 재귀(반복) 횟수를 허용하도록 설정-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다...
2024.08.23
no image
[인프런 알고리즘] Chapter 6, 8번 문제(이분검색)
이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.문제 설명 코드import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.util.Arrays;import java.util.StringTokenizer;public class sec06_08 { public static int solution(int[] arr, int M) { Arrays.sort(arr); return Arrays.binarySearch(arr, M) + 1; } public static void main(Stri..
2024.08.22
no image
[DB 성능 최적화] 실행 계획(Explain)
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.실행 계획(Explain) 개념옵티마이저가 SQL문을 어떤 방식으로 어떻게 처리할 지를 계획한 걸 의미한다.이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 하는 게 목표다. 실행 계획(Explain)은 데이터베이스가 SQL 쿼리를 실행할 때 어떤 경로로 데이터에 접근하고 처리하는지를 보여주는 일종의 “설계도”라고 할 수 있다.즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공한다. 쉽게 설명하자면 데이터베이스에 어떤 SQL 쿼리를 보내면, 데이터베이스는 그 쿼리를 실행할 때 최..
2024.08.22
no image
[인프런 알고리즘] Chapter 6, 7번 문제(좌표 정렬)
이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.문제 설명 코드import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.util.ArrayList;import java.util.Collections;import java.util.StringTokenizer;public class sec06_07 { public static class Point{ private int x; private int y; public Point(int x, int y) { ..
2024.08.21
no image
[DB 성능 최적화] 인덱스(Index)
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.MySQL 아키텍쳐MySQL의 아키텍처를 간단하게 표현하자면 위와 같다.클라이언트가 DB에 SQL 요청을 보낸다. MySQL 엔진에서 옵티마이저가 SQL문을 분석한 뒤 빠르고 효율적으로 데이터를 가져올 수 있는 계획을 세운다. 어떤 순서로 테이블에 접근할 지, 인덱스를 사용할 지, 어떤 인덱스를 사용할 지 등을 결정한다.(옵티마이저가 세운 계획은 완벽하지 않다. 따라서 SQL 튜닝이 필요하다.)옵티마이저가 세운 계획을 바탕으로 스토리지 엔진에서 데이터를 가져온다. (DB 성능에 문제가 생기는 대부분의 원인은 스토리지 엔진으로부터 데이터를 가져올 때 발생한다. 데이터를 찾기가 어..
2024.08.21
no image
[인프런 알고리즘] Chapter 6, 6번 문제(장난꾸러기)
이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.문제 설명 코드import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.util.ArrayList;import java.util.Arrays;import java.util.StringTokenizer;public class sec06_06 { public static ArrayList solution(int[] arr) { int[] copy = arr.clone(); ArrayList answer = new ArrayList(); ..
2024.08.20
no image
[Query DSL] Spring Data JPA와 Query DSL 통합(+ Query DSL 페이징 최적화)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.Query DSL을 Spring Data JPA에서 사용하려면 사용자 정의 리포지토리를 만들어야 한다. MemberTeamDto@Datapublic class MemberTeamDto { private Long memberId; private String username; private int age; private Long teamId; private String teamName; @QueryProjection public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) ..
2024.08.20
no image
[Query DSL] 중급 문법
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.테스트 기본 코드@Entity@Getter @Setter@NoArgsConstructor(access = AccessLevel.PROTECTED)@ToString(of = {"id", "username", "age"})public class Member { @Id @GeneratedValue @Column(name = "member_id") private Long id; private String username; private int age; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id") privat..
2024.08.19

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


많은 데이터를 조회하는 단건 쿼리

  • 데이터를 조회할 때 한 번에 너무 많은 데이터를 조회하는 건 아닌지 체크
  • LIMIT, WHERE문 등을 활용해서 한 번에 조회하는 데이터의 수를 줄이는 방법을 고려

 

테이블 생성

DROP TABLE IF EXISTS users; # 기존 테이블 삭제

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')),   -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000) AS age    -- 1부터 1000 사이의 난수로 나이 생성
FROM cte;

 

데이터 조회

데이터를 만개정도 조회하면 대략 200ms 정도 걸린다.

SELECT * FROM users LIMIT 10000;

 

하지만 데이터를 10개 조회하면 대략 20ms 정도 걸린다.

SELECT * FROM users LIMIT 10;

 

실제 페이스북, 인스타그램의 서비스를 보더라도 한 번에 모든 게시글의 데이터를 불러오지 않는다.

스크롤을 내리면서 필요한 데이터를 그때그때 로딩하는 방식이다. 다른 커뮤니티 서비스의 게시판을 보면 페이지네이션을 적용시켜서 일부 데이터만 조회하려고 한다.

그 이유가 조회하는 데이터의 개수가 성능에 많은 영향을 끼치기 때문이다.

직관적으로 생각해보면 100만개의 데이터에서 1개의 데이터를 찾는 것보다 10,000개의 데이터를 찾는 게 오래 걸릴 수 밖에 없다.

 

WHERE 문 튜닝

  • WHERE문의 부등호(>, <, ≤, ≥, =), IN, BETWEEN, LIKE와 같은 곳에서 사용되는 컬럼은 인덱스를 사용했을 때 성능이 향상될 가능성이 높다. 
  • 데이터 액세스(rows)를 크게 줄일 수 있는 컬럼은 중복 정도가 낮은 컬럼이다. 따라서 중복 정도가 낮은 컬럼을 골라서 인덱스를 생성
  • "단일 컬럼에 설정하는 일반 인덱스"를 설정했을 때와 "멀티 컬럼 인덱스"를 설정했을 때의 성능 차이가 별로 나지 않는다면, 멀티 컬럼 인덱스를 사용하지 말고 일반 인덱스를 활용하자.

 

조건이 한개

 

최근 3일 이내에 가입한 유저 조회

 

테이블 생성

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- 잘 생성됐는 지 확인
SELECT COUNT(*) FROM users;
SELECT * FROM users LIMIT 10;

 

성능 측정

 

 

 

SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

대략 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

type이 ALL이고 rows가 997,632인 걸 보니, 전체 데이터를 처음부터 끝까지 다 탐색해서 데이터를 찾아내는 풀 테이블 스캔을 했다.

풀 테이블 스캔은 성능상으로 비효율적이다. 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

성능 개선을 위한 인덱스 추가

created_at이 만약 정렬이 되어 있다면 최근 3일 이내에 가입한 유저를 훨씬 빠르게 조회할 수 있을 것이다.

그래서 created_at을 기준으로 정렬을 시키게끔 도와주는 인덱스를 추가한다.

CREATE INDEX idx_created_at ON users (created_at);

 

다시 실행 계획을 조회하면 인덱스 레인지 스캔을 한 것을 확인할 수 있다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

조건이 여러개

 

Sales 부서이면서 최근 3일 이내에 가입한 유저 조회

 

테이블 생성

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- 잘 생성됐는 지 확인
SELECT COUNT(*) FROM users;
SELECT * FROM users LIMIT 10;

 

성능 측정

SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)

약 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

# 실행 계획
EXPLAIN SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

# 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

type이 ALL인걸 보니 풀 테이블 스캔을 했다. 비효율적이다. rows가 996,810인 걸 보니 전체 데이터를 대부분 액세스 했다는 걸 추측할 수 있다. 

 

-> Filter: ((users.department = 'Sales') and (users.created_at >= <cache>((now() - interval 3 day))))  
(cost=93877 rows=33224) (actual time=12.6..205 rows=114 loops=1)
    -> Table scan on users  (cost=93877 rows=996810) 
    (actual time=0.0994..152 rows=1e+6 loops=1)

EXPLAIN ANLYZE는 아래 화살표부터 위로 읽어나가야 한다.

  1. 풀 테이블 스캔을 했다.
    → 이 때, 액세스한 데이터의 개수는 1e+6(= 10의 6제곱 = 1,000,000)개 이다.
  2. 액세스한 1,000,000개의 데이터 중 department = ‘Sales’와 created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)을 만족하는 데이터를 필터링해온다.
    → 조건을 만족한 데이터의 개수는 rows는 114개이다.

 

성능 개선을 위한 인덱스 추가

인덱스를 추가하는 방법이 3가지가 있다.

  1. created_at 컬럼을 기준으로 인덱스 생성
  2. department 컬럼을 기준으로 인덱스 생성
  3. department, created_at 둘 다 인덱스 생성

결론부터 말하면 created_at 컬럼을 기준으로 인덱스를 생성해야 한다.

이유는 아래와 같다.

  1. department 컬럼을 기준으로 인덱스를 잡는 것보다 created_at 컬럼을 기준으로 인덱스를 잡는 것이 훨씬 적은 데이터를 가져온다.
    -department 컬럼: 고정된 값(예: ‘Sales’, ‘Engineering’)이 반복적으로 등장한다. 즉, 특정 값을 검색할 때 많은 행들이 중복되기 때문에, 인덱스를 사용해도 선택되는 데이터의 양이 많다. 따라서 department에 인덱스를 설정해도, 쿼리에서 너무 많은 데이터를 가져오게 되어 인덱스의 이점이 줄어든다.
    -created_at 컬럼: 시간 순서대로 연속적인 값이 기록된다. 범위 검색(예: 최근 3일, 1주일)을 하면 선택되는 데이터의 양이 훨씬 적다. 그러므로 created_at에 인덱스를 설정하면, 필요한 범위 내에서만 데이터를 가져오므로 성능이 크게 향상된다.
  2. 인덱스는 최소한으로 설정하는 것이 좋다.
    둘 다 인덱스를 설정하면 created_at 컬럼을 인덱스로 하나만 설정한 것과 동일하게 작동한다. 따라서 쓰기 성능을 저하시키지 않기 위해서 필요없는 department 컬럼은 인덱스로 설정할 필요가 없다.

 

또한 멀티 컬럼 인덱스를 고려할 수 있다.

하지만 이 경우에 멀티 컬럼 인덱스와 created_at 인덱스만 걸었을 때와 크게 성능 차이가 없다.

이런 경우에는 굳이 멀티 컬럼 인덱스를 사용하지 않고 단일 컬럼 인덱스만 적용시키는 것이 낫다.

 

인덱스가 작동하지 않는 경우

  • 넓은 범위의 데이터를 조회하는 경우, MySQL은 인덱스를 사용해서 조회하는 것보다 풀 테이블 스캔이 효과적이라고 판단한다. 
  • 인덱스 컬럼을 가공(함수 적용, 산술 연산, 문자역 조작 등)하면, MySQL은 해당 인덱스를 사용하지 못하는 경우가 많다. 따라서 인덱스를 적극 활용하기 위해서는 인덱스 컬럼 자체를 최대한 가공하지 않아야 한다. 



조회 범위가 너무 넓은 경우

DROP TABLE IF EXISTS users; # 기존 테이블 삭제

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

 

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')),   -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000) AS age    -- 1부터 1000 사이의 난수로 나이 생성
FROM cte;

 

인덱스 설정

CREATE INDEX idx_name ON users (name);

 

실행 계획 조회

EXPLAIN SELECT * FROM users 
ORDER BY name DESC;

분명 인덱스를 걸었음에도 왜 풀 테이블 스캔으로 데이터를 조회된다. 

그 이유는 옵티마이저가 넓은 범위의 데이터를 조회할 때는 인덱스를 활용하는 것이 비효율적이라고 판단한다. 인덱스를 활용하지 않고 풀 테이블 스캔으로 데이터를 찾을 때 훨씬 효율적이라고 판단한다. 

즉, 굳이 인덱스를 거쳤다가 각 원래 테이블의 데이터를 일일이 하나씩 찾아내는 것보다, 바로 원래 테이블에 접근해서 모든 데이터를 통째로 가져와서 정렬하는 게 효율적이라고 판단한 것이다. 실제 성능상으로도 풀 테이블 스캔을 통해 데이터를 가져오는 게 효율적이다.

 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

 

인덱스 컬럼을 가공한 경우

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- users 테이블에 더미 데이터 삽입
INSERT INTO users (name, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 급여 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

인덱스 설정

CREATE INDEX idx_name ON users (name);
CREATE INDEX idx_salary ON users (salary);

 

실행 계획 조회

# User000000으로 시작하는 이름을 가진 유저 조회
EXPLAIN SELECT * FROM users
WHERE SUBSTRING(name, 1, 10) = 'User000000';

# 2달치 급여(salary)가 1000 이하인 유저 조회
SELECT * FROM users
WHERE salary * 2 < 1000
ORDER BY salary;

위의 실행 계획들을 실행시켜보면 인덱스를 활용하지 않고 풀 테이블 스캔으로 탐색하는 걸 확인할 수 있다. 인덱스를 활용하지 않기 때문에 비효율적으로 데이터를 조회한다. 

 

SQL문을 작성할 인덱스 컬럼을 가공(함수 적용, 산술 연산, 문자역 조작 등)하면, MySQL은 해당 인덱스를 활용하지 못하는 경우가 많다. 따라서 인덱스를 적극 활용하기 위해서는 인덱스 컬럼 자체를 최대한 가공하지 않아야 한다. 

 

인덱스 컬럼을 가공하지 않은 SQL 문

# User000000으로 시작하는 이름을 가진 유저 조회
EXPLAIN SELECT * FROM users
WHERE name LIKE 'User000000%';

# 2달치 급여(salary)가 1000 이하인 유저 조회
EXPLAIN SELECT * FROM users
WHERE salary < 1000 / 2
ORDER BY salary;

인덱스 컬럼을 가공하지 않아야 인덱스를 제대로 활용할 수 있게 된다.

 

ORDER BY문 튜닝

  • ORDER BY는 시간이 오래걸리는 작업이므로 최대한 피해주는 것이 좋다. 인덱스를 사용하면 미리 정렬을 해둔 상태이기 때문에, ORDER BY를 사용해서 정렬해야 하는 번거로운 작업을 피할 수 있다.
  • LIMIT 없이 큰 범위의 데이터를 조회해오는 경우 옵티마이저가 인덱스를 활용하지 않고 풀 테이블 스캔을 해버릴 수도 있다. 따라서 성능 효율을 위해 LIMIT을 통해 작은 데이터의 범위를 조회해오도록 항상 신경쓰자. 

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

성능 측정

SELECT * FROM users
ORDER BY salary
LIMIT 100;

약 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

# 실행 계획
EXPLAIN SELECT * FROM users
ORDER BY salary
LIMIT 100;

# 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
ORDER BY salary
LIMIT 100;
  • type이 ALL이다. 풀 테이블 스캔을 했음을 뜻한다.
  • ORDER BY는 시간이 오래걸리는 작업이므로 최대한 피해주는 것이 좋다. 왜냐하면 정렬이라는 작업 자체가 다른 작업에 비해서 부담스러운 작업이며 성능에 안 좋은 영향을 끼치는 요소 중 하나이기 때문이다.

그런데 만약 인덱스를 사용하면 미리 정렬을 해둔 상태이기 때문에, ORDER BY를 사용하여 정렬해야 하는 번거로운 작업을 피할 수 있다.

 

성능 개선을 위한 인덱스 추가

CREATE INDEX idx_salary ON users (salary);

 

실행 계획 조회

EXPLAIN SELECT * FROM users
ORDER BY salary
LIMIT 100;

  • 풀 테이블 스캔(type: ALL)이 아닌 인덱스 풀 스캔(type: index)을 활용해서 빠르게 데이터를 정렬해서 조회해왔다.
  • LIMIT 없이 큰 범위의 데이터를 조회해오는 경우 옵티마이저가 인덱스를 활용하지 않고 테이블 풀 스캔을 해버릴 수도 있다. 따라서 성능 효율을 위해 LIMIT을 통해 작은 데이터의 범위를 조회해오도록 항상 신경써야 한다.

 

실행 계획 세부 내용 조회

EXPLAIN ANALYZE SELECT * FROM users
ORDER BY salary
LIMIT 100;
-> Limit: 100 row(s)  
(cost=0.0918 rows=100) (actual time=0.732..0.759 rows=100 loops=1)
    -> Index scan on users using idx_salary
    (cost=0.0918 rows=100) (actual time=0.731..0.752 rows=100 loops=1)
  1. 인덱스 스캔으로 100개의 데이터에 대해서만 액세스 했다.
    (정렬 작업을 따로 하지 않았다. 왜냐하면 인덱스라서 이미 정렬이 되어 있기 때문이다.)
  2. Limit에 의해 100개의 데이터만 조회했다.

 

 

WHERE 문에 인덱스 VS ORDER BY 문에 인덱스

  • ORDER BY의 특징상 모든 데이터를 바탕으로 정렬을 해야 하기 때문에, 인덱스 풀 스캔 또는 테이블 풀 스캔을 활용할 수 밖에 없다. 
    이 때문에 ORDER BY문보다 WHERE문에 있는 컬럼에 인덱스를 걸었을 때 성능이 향상되는 경우가 많다. 
    (항상 그런 것은 아니니까 성능 측정과 실행 계획을 살펴야한다.)

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

데이터 조회 성능 확인

SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

약 200ms 정도의 시간이 소요

 

실행 계획 조회

EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

type이 ALL인걸 보니 풀 테이블 스캔을 했다. 비효율적이다. 

 

성능 개선을 위한 인덱스 추가

SQL문만 봤을 때는 created_at, department, salary 컬럼에 인덱스를 걸 수 있는 선택지가 있다는 걸 알 수 있다.

어떤 컬럼에 거는 게 효율적인지는 하나씩 걸어보고 SQL문의 성능을 측정해서 판단해도 된다.

 

우선 created_at와 department 컬럼 중에서 인덱스를 걸었을 때 효율적인 컬럼은 created_at이라는 걸 예상할 수 있다.

왜냐하면 위의 SQL 문 조건을 봤을 때 department = ‘Sales’의 조건은 데이터 액세스 수가 많을 수 밖에 없다.

하지만 created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)의 조건은 데이터 액세스 수가 적다.

 

성능을 향상시키는 데 중요한 요소 중 하나는 데이터 액세스 수를 줄이는 것이다.

따라서 department보다 created_at에 인덱스를 생성하는 게 더 좋을 것으로 예상할 수 있다.

 

그렇다면 WHERE 문에 created_at 인덱스를 거는 것과 ORDER BY 문에 있는 salary에 인덱스를 거는 것 둘 중에 어느것이 더 효율적인지 고려해봐야 한다.

결론부터 말하면 WHERE 절에 있는 created_at에 인덱스를 거는 것이 ORDER BY절에 있는 salary에 인덱스를 거는 것 보다 더 효율적이다.

 

salary에 인덱스

CREATE INDEX idx_salary ON users (salary);
-- 성능 측정
SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획
EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

인덱스를 걸기 전보다 약 5배정도 느려진 것을 확인할 수 있다.

 

위의 실행 계획에서 type이 index이면서 salary 인덱스를 사용한 걸로 봐서 인덱스 풀 스캔을 했음을 알 수 있다.

 

-> Limit: 100 row(s) 
 (cost=9.09 rows=3.33) (actual time=23.8..1009 rows=100 loops=1)
    -> Filter: ((users.created_at >= <cache>((now() - interval 3 day))) and (users.department = 'Sales'))  
    (cost=9.09 rows=3.33) (actual time=23.8..1009 rows=100 loops=1)
        -> Index scan on users using idx_salary  
        (cost=9.09 rows=100) (actual time=1.03..943 rows=974891 loops=1)
  1. idx_salary 인덱스를 활용해 인덱스 풀 스캔을 했다. 이 인덱스를 활용했기 때문에 정렬 과정이 따로 필요없다. 
  2. 그런 뒤에 WHERE 조건을 만족시키는 데이터 100개를 필터링한다. 이 때, 인덱스에는 created_at, department 정보가 없기 때문에 실제 테이블에 접근해서 조건을 만족하는 지 확인해야한다. 풀 테이블 스캔과 거의 다를 바가 없다.
  3. 조건을 만족시키는 데이터 100개를 찾는 순간 더 이상 탐색을 하지 않는다. (LIMIT 100)

 

created_at에 인덱스

ALTER TABLE users DROP INDEX idx_salary; -- 기존 인덱스 삭제
CREATE INDEX idx_created_at ON users (created_at);
-- 성능 측정
SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획
EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

인덱스를 걸지 않은 200ms보다 약 6~7배 정도 빨라졌음을 알 수 있다. 

 

type이 range인걸 보니 인덱스 레인지 스캔을 활용했음을 알 수 있다. 

 

-> Limit: 100 row(s)  
(cost=504 rows=100) (actual time=8.94..8.96 rows=100 loops=1)
    -> Sort: users.salary, limit input to 100 row(s) per chunk  
    (cost=504 rows=1120) (actual time=8.94..8.95 rows=100 loops=1)
        -> Filter: (users.department = 'Sales')  
        (cost=504 rows=1120) (actual time=0.269..8.84 rows=104 loops=1)
            -> Index range scan on users using idx_created_at over ('2024-06-30 01:21:20' <= created_at), 
            with index condition: (users.created_at >= <cache>((now() - interval 3 day)))  
            (cost=504 rows=1120) (actual time=0.0537..8.54 rows=1120 loops=1)
  1. idx_created_at 인덱스를 활용해 인덱스 레인지 스캔을 했다. 이 때, users.created_at >= ((now() - interval 3 day))을 만족하는 데이터에만 액세스했다. (rows=1120)
  2. 1번 과정에서 액세스한 데이터 중에서 users.department = ‘Sales’을 만족하는 데이터를 필터링했다. (rows=104)
  3. 2번 과정에서 필터링한 104개의 데이터를 users.salary를 기준으로 정렬시켰다.
  4. LIMIT으로 인해 100개의 데이터만 가져왔다.

 

HAVING문 튜닝

  • HAVING문 대신에 WHERE문을 쓸 수 있는지 체크

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT,
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 100) AS age, -- 1부터 100 사이의 난수로 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

인덱스 생성

CREATE INDEX idx_age ON users (age);

 

조히 성능 확인

SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;

약 800ms 정도의 시간이 소요

 

실행 계획 조회

EXPLAIN SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;

type이 index인걸로 봐서 인덱스 풀 스캔을 하고 있다. 

 

세부 실행 계획 조회

EXPLAIN ANALYZE SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;
-> Filter: ((users.age >= 20) and (users.age < 30))  (cost=200263 rows=101) (actual time=208..882 rows=10 loops=1)
    -> Group aggregate: max(users.salary)  (cost=200263 rows=101) (actual time=38.4..882 rows=100 loops=1)
        -> Index scan on users using idx_age  (cost=100624 rows=996389) (actual time=1.53..850 rows=1e+6 loops=1)

 

 

성능 개선

SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;

HAVING 문 대신에 WHERE 문에 조건을 넣으니 약 150ms 정도 시간이 걸린다.

 

실행 계획 조회

EXPLAIN SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;

 

EXPLAIN ANALYZE SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;
-> Group aggregate: max(users.salary)  (cost=111397 rows=101) (actual time=75.7..198 rows=10 loops=1)
    -> Index range scan on users using idx_age over (20 <= age < 30), 
    with index condition: ((users.age >= 20) and (users.age < 30))  (cost=91143 rows=202540) (actual time=0.582..193 rows=99990 loops=1)

HAVING 대신에 WHERE문을 사용함으로써 GROUP BY를 처리하기 전에 데이터를 필터링했다.

그런 뒤에 필터링 된 데이터를 기반으로 GROUP BY를 진행했다. 

'데이터베이스 > MySQL' 카테고리의 다른 글

[DB 성능 최적화] 실전 SQL 튜닝  (0) 2024.08.24
[DB 성능 최적화] 실행 계획(Explain)  (0) 2024.08.22
[DB 성능 최적화] 인덱스(Index)  (0) 2024.08.21
[MySQL] 인덱스(Index)  (1) 2023.12.06
[MySQL] 뷰(View)  (1) 2023.12.05

이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.


문제 설명

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.StringTokenizer;

public class sec06_08 {
    public static int solution(int[] arr, int M) {
        Arrays.sort(arr);
        return Arrays.binarySearch(arr, M) + 1;
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        int N = Integer.parseInt(st.nextToken());
        int M = Integer.parseInt(st.nextToken());
        st = new StringTokenizer(br.readLine());
        int[] arr = new int[N];
        for (int i = 0; i < N; ++i) arr[i] = Integer.parseInt(st.nextToken());
        System.out.println(solution(arr, M));
    }
}

 

이진탐색 구현

public static int solution(int[] arr, int M) {
    Arrays.sort(arr);
    int lPtr = 0; int rPtr = arr.length - 1;
    while(lPtr <= rPtr)
    {
        int mid = (lPtr + rPtr) / 2;
        if(arr[mid] == M) return mid + 1;
        else
        {
            if(arr[mid] > M) rPtr = mid - 1;
            else lPtr = mid + 1;
        }
    }
    return -1;
}

 

설명

  • 이진 탐색을 수행하기 위해서는 배열이 반드시 정렬되어 있어야 한다.
  • 이진 탐색은 정렬된 배열에서 중앙값을 기준으로 탐색 범위를 줄여나가는 방식이기 때문에, 정렬되지 않은 배열에서는 제대로 작동하지 않는다.
  • Arrays.binarySearch(arr, M)는 arr 배열에서 값 M을 찾는 이진 탐색 메서드이다. 반환 값은 M의 인덱스이다.
    만약 M을 찾지 못한다면 음수를 반환한다.

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


실행 계획(Explain) 개념

옵티마이저가 SQL문을 어떤 방식으로 어떻게 처리할 지를 계획한 걸 의미한다.

이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 하는 게 목표다.

 

실행 계획(Explain)은 데이터베이스가 SQL 쿼리를 실행할 때 어떤 경로로 데이터에 접근하고 처리하는지를 보여주는 일종의 “설계도”라고 할 수 있다.

즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공한다.

 

쉽게 설명하자면

 

데이터베이스에 어떤 SQL 쿼리를 보내면, 데이터베이스는 그 쿼리를 실행할 때 최적의 경로를 선택해야 한다. 이 경로는 여러 가지 요소에 따라 달라진다. 예를 들어, 테이블이 크다면 인덱스를 사용할지, 아니면 테이블 전체를 스캔할지 등을 결정해야 한다.

실행 계획(Explain)을 사용하면 데이터베이스가 쿼리를 처리하는 과정을 미리 알 수 있고, 이를 통해 성능을 최적화할 수 있다.

예를 들어, 실행 계획을 보면 쿼리가 테이블 전체를 스캔하고 있는지, 아니면 인덱스를 잘 활용하고 있는지를 확인할 수 있다.

 

실행 계획 조회하기

EXPLAIN SELECT * FROM users
WHERE age = 23;

 

  • id : 실행 순서
  • table : 조회한 테이블 명
  • type : 테이블의 데이터를 어떤 방식으로 조회하는지
  • possible keys : 사용할 수 있는 인덱스 목록을 출력 ⭐️
  • key : 데이터 조회할 때 실제로 사용한 인덱스 값 ⭐️
  • ref : 테이블 조인 상황에서 어떤 값을 기준으로 데이터를 조회했는지
  • rows : SQL문 수행을 위해 접근하는 데이터의 모든 행의 수 (= 데이터 액세스 수) ⭐️⭐️⭐️
    → 이 값을 줄이는 게 SQL 튜닝의 핵심
  • filtered : 필터 조건에 따라 어느 정도의 비율로 데이터를 제거했는 지 의미
    → filtered의 값이 30이라면 100개의 데이터를 불러온 뒤 30개의 데이터만 실제로 응답하는데 사용했음을 의미
    → filtered 비율이 낮을 수록 쓸데없는 데이터를 많이 불러온 것.
  • Extra : 부가적인 정보를 제공 ⭐️
    → ex. Using where, Using index
rows, filtered의 값은 정확한 수치가 아닌 추정값이기 때문에 오차가 있을 수 있다.

 

실행 계획에 대한 자세한 정보 조회

EXPLAIN ANALYZE SELECT * FROM users
WHERE age = 23;

  • Table scan on users : users 테이블을 풀 스캔했다.
    -rows : 접근한 데이터의 행의 수
    -actual time=0.0437..0.0502
      ->0.0437 (앞에 있는 숫자) : 첫 번째 데이터에 접근하기까지의 시간
      ->0.0502 (뒤에 있는 숫자) : 마지막 데이터까지 접근한 시간
  • Filter: (users.age = 23) : 필터링을 통해 데이터를 추출했다. (필터링을 할 때의 조건은 users.age = 23)

 

위 작업을 한 번에 이어서 해석해보면 다음과 같다.

  • users 테이블의 모든 데이터(7개)에 접근했다.
  • 그리고 그 데이터 중 age = 23의 조건을 만족하는 데이터만 필터링해서 조회해왔다. 

 

실행 계획에서 Type 의미 분석

실행 계획(EXPLAIN)을 조회했을 때 나오는 결과값 중 하나인 type은 성능 최적화에 있어서 이 값의 의미를 파악하는 게 굉장히 중요하다. 

 

ALL(Full Table Scan)

풀 테이블 스캔(Full Table Scan)이란 인덱스를 활용하지 않고 테이블을 처음부터 끝까지 전부 다 뒤져서 데이터를 찾는 방식이다.

처음부터 끝까지 전부 다 뒤져서 필요한 데이터를 찾는 방식이다보니 비효율적이다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
INSERT INTO users (name, age) VALUES 
('Alice', 30),
('Bob', 23),
('Charlie', 35);
EXPLAIN SELECT * FROM users WHERE age = 23; # type : ALL

users 테이블의 데이터는 age를 기준으로 정렬되어 있지 않고 id를 기준으로 정렬되어 있다.

그래서 age = 23의 값을 가진 데이터를 찾으려면 테이블의 처음부터 끝까지 다 뒤져봐야 한다.

그래서 실행 계획의 type이 ALL로 나온 것이다. 

 

Index(Full Index Scan)

풀 인덱스 스캔(Full Index Scan)이란 인덱스 테이블을 처음부터 끝까지 다 뒤져서 데이터를 찾는 방식이다.

인덱스의 테이블은 실제 테이블보다 크기가 작기 때문에, 풀 테이블 스캔(Full Table Scan)보다 효율적이다.

하지만 인덱스 테이블 전체를 읽어야 하기 때문에 아주 효율적이라고 볼 수는 없다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
CREATE INDEX idx_name ON users (name);
EXPLAIN SELECT * FROM users 
ORDER BY name 
LIMIT 10;

type이 index인걸로 봐서 풀 인덱스 스캔을 한것을 확인할 수 있다. 즉, 인덱스를 처음부터 끝까지 다 읽어서 필요한 데이터를 뽑아냈다.

 

Const

조회하고자 하는 1건의 데이터를 헤매지 않고 단번에 찾아올 수 있을 때 const가 출력된다. 그러다보니 고유 인덱스 또는 기본 키를 사용해서 1건의 데이터만 조회한 경우에 const가 출력된다.

이 방식은 아주 효율적인 방식이다.

 

  • 인덱스가 없다면 특정 값을 일일이 다 뒤져야 한다. 그래서 1건의 데이터를 바로 찾을 수 없다.
  • 인덱스가 있는데 고유하지 않다면(NOT UNIQUE) 원하는 1건의 데이터를 찾았다고 하더라도, 나머지 데이터에 같은 값이 있을 지도 모르므로 다른 데이터들도 체크해봐야 한다.
  • 고유하다면(UNIQUE) 1건의 데이터를 찾는 순간, 나머지 데이터는 아예 볼 필요가 없어진다. 왜냐하면 찾고자 하는 데이터가 유일한 데이터이기 때문이다. 
    → UNIQUE 제약조건이 있는 컬럼과 기본 키는 전부 UNIQUE한 특성을 가지고 있다.
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    account VARCHAR(100) UNIQUE
);
실행 계획 조회하기
EXPLAIN SELECT * FROM users WHERE id = 3;
EXPLAIN SELECT * FROM users WHERE account = 'user3@example.com';

UNIQUE 속성을 가진 컬럼은 인덱스가 자동으로 생성된다. 

 

Range(Index Range Scan)

인덱스 레인지 스캔(Index Range Scan)은 인덱스를 활용해 범위 형태의 데이터를 조회한 경우를 의미한다.

범위 형태란 BETWEEN, 부등호(<, >, ≤, ≥), IN, LIKE를 활용한 데이터 조회를 뜻한다.

이 방식은 인덱스를 활용하기 때문에 효율적인 방식이다.

하지만 인덱스를 사용하더라도 데이터를 조회하는 범위가 클 경우 성능 저하의 원인이 되기도 한다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    age INT
);
CREATE INDEX idx_age ON users(age);
EXPLAIN SELECT * FROM users
WHERE age BETWEEN 10 and 20;

EXPLAIN SELECT * FROM users
WHERE age IN (10, 20, 30);

EXPLAIN SELECT * FROM users
WHERE age < 20;

 

Ref

고유하지 않은 인덱스(비고유 인덱스)를 사용한 경우 (= UNIQUE가 아닌 컬럼의 인덱스를 사용한 경우) type에 ref가 출력된다. 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100)
);
CREATE INDEX idx_name ON users(name);
EXPLAIN SELECT * FROM users WHERE name = '박재성';

 

이외에 eq_ref, index_merge, ref_or_null 등 다양한 타입들이 존재한다.

'데이터베이스 > MySQL' 카테고리의 다른 글

[DB 성능 최적화] 실전 SQL 튜닝  (0) 2024.08.24
[DB 성능 최적화] 기본 SQL 튜닝  (0) 2024.08.23
[DB 성능 최적화] 인덱스(Index)  (0) 2024.08.21
[MySQL] 인덱스(Index)  (1) 2023.12.06
[MySQL] 뷰(View)  (1) 2023.12.05

이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.


문제 설명

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.StringTokenizer;

public class sec06_07 {

    public static class Point{
        private int x;
        private int y;

        public Point(int x, int y)
        {
            this.x = x;
            this.y = y;
        }
    }

    public static ArrayList<Point> solution(ArrayList<Point> list) {
        Collections.sort(list, (a , b) ->{
            if (a.x == b.x) return a.y - b.y;
            else return a.x - b.x;
        });
        return list;
    }



    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());
        ArrayList<Point> list = new ArrayList<>();
        for(int i = 0; i < N; ++i)
        {
            StringTokenizer st = new StringTokenizer(br.readLine());
            Point newPoint = new Point(Integer.parseInt(st.nextToken()), Integer.parseInt(st.nextToken()));
            list.add(newPoint);
        }
        for (Point point : solution(list)) System.out.println(point.x + " " + point.y);
    }
}

 

설명

  • 두 좌표 객체 a와 b의 x 값이 다를 경우, a.x - b.x를 반환하여 x 값이 작은 순서대로 정렬한다.
    즉, a.x < b.x이면 a가 b보다 앞에 오도록 한다.
  • 만약 x 값이 같다면, y 값을 비교하여 y 값이 작은 순서대로 정렬한다.
    이는 a.y - b.y를 통해 이루어진다. 예를 들어, a.y < b.y이면 a가 b보다 앞에 오도록 한다.

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


MySQL 아키텍쳐

MySQL의 아키텍처를 간단하게 표현하자면 위와 같다.

  1. 클라이언트가 DB에 SQL 요청을 보낸다. 
  2. MySQL 엔진에서 옵티마이저가 SQL문을 분석한 뒤 빠르고 효율적으로 데이터를 가져올 수 있는 계획을 세운다. 어떤 순서로 테이블에 접근할 지, 인덱스를 사용할 지, 어떤 인덱스를 사용할 지 등을 결정한다.
    (옵티마이저가 세운 계획은 완벽하지 않다. 따라서 SQL 튜닝이 필요하다.)
  3. 옵티마이저가 세운 계획을 바탕으로 스토리지 엔진에서 데이터를 가져온다. 
    (DB 성능에 문제가 생기는 대부분의 원인은 스토리지 엔진으로부터 데이터를 가져올 때 발생한다. 데이터를 찾기가 어려워서 오래 걸리거나, 가져올 데이터가 너무 많아서 오래 걸린다. SQL 튜닝의 핵심은 스토리지 엔진으로부터 되도록이면 데이터를 찾기 쉽게 바꾸고, 적은 데이터를 가져오도록 바꾸는 것을 말한다.)
  4. MySQL 엔진에서 정렬, 필터링 등의 마지막 처리를 한 뒤에 클라이언트에게 SQL 결과를 응답한다.

 

MySQL 튜닝의 핵심

-스토리지 엔진에서 데이터를 찾기 쉽게 바꾸기
-스토리지 엔진으로부터 가져오는 데이터의 양 줄이기

이 두 가지를 해결할 수 있는 방법은 여러가지 방법이 많지만 가장 많이 활용되는 방법이 인덱스 활용이다.

 

인덱스(Index)의 개념

데이터베이스에서 인덱스는 책의 목차와 비슷한 역할을 한다고 보면 된다.

인덱스를 사용하면 특정 데이터를 더 빠르게 찾을 수 있게 된다.

데이터베이스에서 많은 양의 데이터가 저장되어 있을 때, 인덱스가 없다면 데이터베이스는 처음부터 끝까지 하나하나 데이터를 확인하면서 원하는 데이터를 찾아야 한다.

하지만 인덱스를 사용하면 훨씬 적은 데이터를 검색해서 원하는 결과를 빠르게 찾을 수 있다.

인덱스란 "데이터를 빨리 찾기 위해 특정 컬럼을 기준으로 미리 정렬해놓은 표"라고 할 수 있다.

 

인덱스를 직접 생성하게 되면 우리 눈에는 안 보이지만 왼쪽과 같은 표가 시스템 내부적으로 생성된다.

나이를 기준으로 정렬해놓은 표를 가지고 있기 때문에, 나이를 기준으로 데이터를 조회할 때 훨씬 빠르게 찾을 수 있었던 것이다. 

 

인덱스의 개념

  • 인덱스는 데이터베이스 테이블의 특정 열(컬럼)에 대한 추가적인 데이터 구조이다.
  • 이 구조는 해당 열의 값들을 빠르게 조회할 수 있도록 정렬되어 있으며, 해당 값이 저장된 레코드가 테이블의 어느 위치에 있는지 정보를 담고 있다.
  • 즉, 인덱스는 데이터를 조회할 때 사용하며, 특정 조건에 맞는 데이터를 빠르게 찾기 위해 사용된다.

 

 

인덱스의 동작

  • 인덱스 생성: 특정 열에 대해 인덱스를 생성하면, 데이터베이스는 그 열의 값을 기반으로 빠르게 검색할 수 있는 자료 구조(예: B-tree)를 만든다.
  • 데이터 조회 시 인덱스 사용: 데이터를 조회할 때 해당 열에 인덱스가 있다면, 데이터베이스는 인덱스를 먼저 확인해서 그 열에 해당하는 값이 저장된 정확한 위치를 빠르게 찾아간다.
  • 인덱스 없는 조회: 인덱스가 없다면 데이터베이스는 테이블 전체를 처음부터 끝까지 스캔하며 원하는 데이터를 찾아야 한다. 이를 “Full Table Scan”이라고 한다.

 

인덱스의 장점

  • 검색 성능 향상: 데이터를 빠르게 검색할 수 있다. 특히, 큰 테이블에서 특정 조건을 만족하는 데이터를 찾을 때 매우 유용하다.
  • 정렬 및 그룹화 속도 향상: ORDER BY, GROUP BY와 같은 작업을 수행할 때도 인덱스를 활용하면 빠르게 처리할 수 있다.

 

인덱스의 단점

  • 추가적인 저장 공간 필요: 인덱스 자체가 별도의 데이터 구조이기 때문에, 이를 저장할 추가 공간이 필요하다.
  • 데이터 수정 시 성능 저하: 데이터를 INSERT, UPDATE, DELETE할 때, 인덱스도 함께 수정해야 하므로 성능이 떨어질 수 있다. 따라서 너무 많은 인덱스를 사용하면 오히려 성능이 저하될 수 있다.
  • 인덱스는 주로 데이터 검색 시 유리하지만, 자주 변경되는 데이터를 다룰 때는 오히려 성능에 악영향을 미칠 수 있다.

 

언제 인덱스를 사용해야 하는가?

  • 많은 조회가 이루어지는 열: 데이터 조회가 자주 발생하는 열에 인덱스를 사용하는 것이 좋다. 예를 들어, SELECT 쿼리에서 자주 조건으로 사용하는 열(WHERE 절에 자주 등장하는 열).
  • 유일한 값이 많은 열: 인덱스는 중복 값이 많을수록 성능이 떨어질 수 있다. 따라서 유일한 값이 많을수록 인덱스 효과가 크다. 예를 들어, 고유한 ID를 저장하는 primary key 열.
  • 정렬과 그룹화에 자주 사용되는 열: ORDER BY나 GROUP BY 절에서 자주 사용되는 열에도 인덱스를 사용할 수 있다.

 

인덱스 설정 방법

인덱스 생성

# 인덱스 생성
# CREATE INDEX 인덱스명 ON 테이블명 (컬럼명);
CREATE INDEX idx_age ON users(age);

 

인덱스 조회

# SHOW INDEX FROM 테이블명;
SHOW INDEX FROM users;

 

 

인덱스의 종류와 자동 생성

인덱스의 종류

MySQL에서 사용되는 인덱스의 종류는 크게 클러스터형 인덱스와 보조 인덱스로 나뉜다.

이 두 개를 비유하면 클러스터형 인덱스는 영어 사전과 같은 책이다.

영어사전은 단어들이 알파벳 순서로 정렬되어 있다. 이 정렬된 상태 덕분에 우리는 특정 단어를 빠르게 찾을 수 있다. 예를 들어, “apple”을 찾으려면 사전의 앞부분에서부터 찾고, “zebra”는 사전의 뒷부분을 바로 열어 찾으면 된다.

이 사전의 알파벳 순서로 정렬된 방식이 클러스터 인덱스와 비슷하다.

 

보조 인덱스는 책의 목차와 비슷한 개념이다.

목차는 본문의 내용과는 별도로 위치 정보(페이지 번호)를 제공하는데, 목차에서 내용을 찾으면 바로 해당 페이지로 이동할 수 있다. 목차 자체는 본문과 별도로 관리된다.

 

클러스터 인덱스 (Clustered Index)

  • 클러스터 인덱스는 테이블의 데이터 자체가 정렬되는 방식이다.
  • 즉, 테이블의 행(row)들이 인덱스 기준으로 실제 데이터 파일에 정렬되어 저장된다.
  • 하나의 테이블에 하나의 클러스터 인덱스만 존재할 수 있다, 왜냐하면 테이블 자체가 해당 인덱스를 기준으로 정렬되어 저장되기 때문이다.
  • 클러스터 인덱스가 설정된 열(column)에 따라 데이터가 물리적으로 정렬된다. 이로 인해 해당 열로 데이터를 검색할 때 매우 빠르게 찾을 수 있다.
  • Primary Key는 일반적으로 클러스터 인덱스로 사용된다. 예를 들어, 학생 테이블에서 학생 ID가 Primary Key라면, 테이블은 학생 ID 순서대로 정렬되어 저장된다.

 

보조 인덱스 (Non-clustered Index)

  • 보조 인덱스는 테이블 데이터 자체와는 별도로 만들어진 인덱스 구조이다.
  • 보조 인덱스는 테이블의 데이터와는 물리적으로 분리되어 있으며, 실제 데이터의 위치를 가리키는 포인터 역할을 한다.
  • 하나의 테이블에 여러 개의 보조 인덱스를 생성할 수 있다. 각 보조 인덱스는 특정 열에 대해 데이터 조회 속도를 높여준다.
  • 보조 인덱스를 통해 빠르게 검색한 후, 그 인덱스가 가리키는 위치로 가서 실제 데이터를 가져오는 방식이다.
  • Unique  제약 조건이 걸린 컬럼은 일반적으로 보조 인덱스로 사용된다.

 

클러스터 인덱스와 보조 인덱스 비교

클러스터형 인덱스 보조 인덱스
테이블의 데이터 자체가 인덱스 기준으로 정렬됨 인덱스는 데이터와 분리되어 별도로 존재함
한 테이블에 하나만 존재 가능 여러 개의 보조 인덱스를 생성할 수 있음
데이터가 실제로 인덱스 순서대로 저장(정렬)됨 인덱스는 실제 데이터를 가리키는 포인터를 제공
주로 Primary Key에 사용 자주 조회되는 특정 컬럼에 사용

 

 

자동으로 생성되는 인덱스

-테이블 생성 시에 제약 조건 Primary Key 또는 Unique를 사용하면 자동으로 인덱스가 생성된다.
-Primary Key 컬럼에는 클러스터형 인덱스가 생성되고, Unique 컬럼에는 보조 인덱스가 생성된다.

 

 

인덱스를 많이 사용하면 무조건 좋은가?

인덱스의 자료구조는 B-Tree이다.

B-Tree는 인덱스를 구현하는 데 매우 효율적인 자료구조지만, 인덱스를 너무 많이 사용하면 다음과 같은 문제점이 발생할 수 있다

  • 데이터 수정 시 성능 저하: 인덱스가 많을수록 데이터 수정 시 더 많은 B-Tree가 업데이트되어 성능이 떨어짐.
  • 추가적인 저장 공간 필요: 인덱스가 많을수록 더 많은 B-Tree 구조를 저장해야 하므로 디스크 공간이 많이 사용됨.
  • 인덱스 선택 문제: 너무 많은 인덱스가 있으면 최적의 인덱스를 선택하는 데 시간이 소요될 수 있음.
  • Full Table Scan 발생 가능성: 너무 많은 인덱스가 있으면 오히려 인덱스를 사용하지 않고 테이블 전체를 스캔하는 경우도 발생할 수 있음.

 

인덱스는 최소한으로 사용하는 것이 중요하다.

인덱스를 많이 사용할수록 조회 속도는 빨라지나, 쓰기(삽입, 수정, 삭제) 속도는 느려지기 때문이다.

 

 

멀티 컬럼 인덱스(Multiple - Column Index)

멀티 컬럼 인덱스 개념

멀티 컬럼 인덱스는 여러 개의 컬럼(열)을 하나의 인덱스로 묶어서 관리하는 인덱스이다.

이를 통해 여러 컬럼에 걸친 데이터 조회를 최적화할 수 있다. 즉, 하나의 인덱스가 여러 컬럼을 포함함으로써, 특정 쿼리가 여러 컬럼을 동시에 검색할 때 성능을 높이는 역할을 한다.

 

멀티 컬럼 인덱스 생성

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    이름 VARCHAR(100),
    부서 VARCHAR(100),
    나이 INT
);
CREATE INDEX idx_부서_이름 ON users (부서, 이름);

 

멀티 컬럼 인덱스란, 2개 이상의 컬럼을 묶어서 설정하는 인덱스를 뜻한다. 즉, 데이터를 빨리 찾기 위해 2개 이상의 컬럼을 기준으로 미리 정렬해놓은 표이다. 

 

아래와 같은 users 테이블이 있다고 가정하자.

이 때, 부서와 이름의 컬럼을 활용해 멀티 컬럼 인덱스를 만들면 아래와 같은 표가 시스템 내부에 생성된다. 부서를 기준으로 먼저 오름차순으로 정렬한 뒤, 같은 부서의 값을 가진 데이터들 사이에서 이름을 기준으로 오름차순 정렬을 한다.

 

위의 예시에서는 컬럼 2개를 가지고 인덱스를 생성했지만, 2개 이상의 컬럼을 가지고 인덱스를 생성할 수도 있다.

 

멀티 컬럼 인덱스 주의점

일반 인덱스처럼 활용 가능

부서를 기준으로 먼저 정렬이 되어 있고, 그 다음 같은 부서 내에서 이름을 기준으로 정렬되어 있다.

이런 구조로 되어 있기 때문에 부서 컬럼만 놓고 봤을 때는 부서 인덱스와 동일한 정렬 상태를 갖고 있다. 따라서 위의 멀티 컬럼 인덱스의 구조를 활용하면 부서의 인덱스를 활용하듯이 쓸 수도 있다. 

 

SELECT * FROM users
WHERE 부서 = '운영';

위 SQL문을 봤을 때 부서 컬럼으로 인덱스를 생성할 경우 성능이 향상되리라 짐작할 수 있다. 하지만 부서, 이름 순으로 구성된 멀티 컬럼 인덱스를 이미 만들어 뒀기 때문에, 부서 컬럼의 인덱스를 따로 또 만들 필요는 없다.

 

일반 인덱스처럼 활용하지 못하는 경우도 존재

위에서 부서, 이름 순으로 멀티 컬럼 인덱스를 만들어뒀기 때문에, 부서 컬럼의 인덱스를 만든 것과 같은 역할도 같이 수행한다고 했다.

하지만 이 멀티 컬럼 인덱스로는 이름 컬럼의 인덱스처럼 활용할 수는 없다. 

왜인지 아래 인덱스 표를 다시 한 번 확인해보자.

정렬을 자세히 잘 살펴보면 이름 기준으로 정렬이 되어 있지는 않다.

왜냐면 같은 부서를 가진 데이터끼리만 정렬을 시켰기 때문이다. 실제로 아래 SQL문을 실행시킬 때 인덱스를 활용하지 못한다.

SELECT * FROM users
WHERE 이름 = '이재현';

 

따라서 멀티 컬럼 인덱스에서 일반 인덱스처럼 활용할 수 있는 건 처음에 배치된 컬럼들뿐이다.

 

대분류 → 중분류 → 소분류 컬럼순으로 구성

멀티 컬럼 인덱스를 만들 때는 순서에 주의해야 한다.

왜냐하면 순서를 어떻게 정해서 인덱스를 만드느냐에 따라서 성능 차이가 나기 때문이다. 

먼저 직관적으로 이해해보자. 10층짜리 회사에서 박미나를 찾아야 한다고 가정하자. 

일반적으로 부서를 먼저 찾은 뒤에 박미나를 찾는게 훨씬 빠를 것이다. 이를 일반화해서 표현하자면 "대분류를 먼저 탐색한 뒤, 소분류를 탐색하는 게 빠르다."라고 할 수 있다. 컴퓨터도 이 특성이 동일하게 적용된다. 

멀티 컬럼 인덱스에서도 배치한 컬럼의 순서대로 데이터를 탐색한다. (부서, 이름)의 순서대로 멀티 컬럼 인덱스를 구성했다면 먼저 일치하는 부서를 찾은 뒤, 일치하는 부서에서 이름을 찾는 식으로 처리한다. 

따라서 멀티 컬럼 인덱스를 구성할 때는 데이터 중복도가 높은 컬럼이 앞쪽으로 오는 게 좋은 경우가 많다. 데이터 중복도가 높은 컬럼일수록 큼지막하게 분류가 되어 있다는 뜻이기도 하다. 
(항상 그런 건 아니니 실행 계획과 SQL문 실행 속도를 측정해서 판단하도록 하자.)

- 멀티 컬럼 인덱스 컬럼의 순서는 매우 중요하다.
- 멀티 컬럼 인덱스에서 처음에 배치된 컬럼들은 일반 인덱스처럼 활용할 수 있다.
- 멀티 컬럼 인덱스를 구성할 때 데이터 중복도가 높은 컬럼이 앞쪽으로 오는 게 좋다.

 

 

커버링 인덱스(Covering Index)

SQL문을 실행시킬 때 필요한 모든 컬럼을 갖고 있는 인덱스를 커버링 인덱스(Covering Index)라고 한다. 

 

위와 같은 users 테이블이 있고, name 인덱스가 있다고 가정하자. 그리고 아래 2개의 SQL문을 실행해야 한다고 가정하자. 

SELECT id, created_at FROM users;
SELECT id, name FROM users;

1번째 SQL문을 보면 id, created_at라는 컬럼만 조회한다고 하더라도 실제 테이블의 데이터에 접근해야 한다.

하지만 2번째 SQL문에서는 id, name 컬럼은 실제 테이블에 접근하지 않고 인덱스에만 접근해서 알아낼 수 있는 정보들이다. 따라서 실제 테이블에 접근하지 않고 데이터를 조회할 수 있다. 실제 테이블에 접근하는 것 자체가 인덱스에 접근하는 것보다 속도가 느리다.

이 상황에서 SQL문을 실행시킬 때 필요한 모든 컬럼을 갖고 있는 인덱스를 보고 커버링 인덱스(Covering Index)라고 표현한다.

이 알고리즘 문제는 인프런의 자바(Java) 알고리즘 문제풀이 입문: 코딩테스트 대비 (김태원)의 문제입니다.


문제 설명

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.StringTokenizer;

public class sec06_06 {
    public static ArrayList<Integer> solution(int[] arr)
    {
        int[] copy = arr.clone();
        ArrayList<Integer> answer = new ArrayList<>();

        Arrays.sort(copy);
        for (int i = 0; i < arr.length; ++i) if(arr[i] != copy[i]) answer.add(i + 1);
        return answer;
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());
        int[] arr = new int[N];
        StringTokenizer st = new StringTokenizer(br.readLine());
        for (int i = 0; i < N; ++i) arr[i] = Integer.parseInt(st.nextToken());
        for (int i : solution(arr)) System.out.print(i + " ");
    }
}

 

설명

  • 얕은 복사 후 정렬을 수행하면 원본 배열은 정렬되지 않고 복사된 배열만 정렬된다.
  • 원본 배열과 복사된 배열을 준비하고, 복사된 배열을 정렬한다.
  • 각각의 인덱스를 돌면서 값이 다른 부분을 출력한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


Query DSL을 Spring Data JPA에서 사용하려면 사용자 정의 리포지토리를 만들어야 한다.

 

MemberTeamDto

@Data
public class MemberTeamDto {
    private Long memberId;
    private String username;
    private int age;
    private Long teamId;
    private String teamName;

    @QueryProjection
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
        this.memberId = memberId;
        this.username = username;
        this.age = age;
        this.teamId = teamId;
        this.teamName = teamName;
    }
}

 

MemberSearchCondition

@Data
public class MemberSearchCondition {
    //회원명, 팀명, 나이(ageGoe, ageLoe)

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

 

 

사용자 정의 리포지토리 생성

사용자 정의 리포지토리 사용법

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현 클래스 작성
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

사용자 정의 인터페이스 구현 클래스

규칙: 사용자 정의 인터페이스 이름(리포지토리 인터페이스 이름도 가능) + Impl
스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

여기서는 사용자 정의 인터페이스 구현 클래스의 이름을 위의 그림과 다르게 MemberRepositoryCustomImpl로 작성하였다

 

 

사용자 정의 인터페이스 인터페이스 작성

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}

 

사용자 정의 인터페이스 구현 클래스 작성

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    //회원명, 팀명, 나이(ageGoe, ageLoe)
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.isEmpty(username) ? null : member.username.eq(username);
    }
    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.isEmpty(teamName) ? null : team.name.eq(teamName);
    }
    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe == null ? null : member.age.goe(ageGoe);
    }
    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe == null ? null : member.age.loe(ageLoe);
    }
}
  • search 메서드
    -queryFactory를 통해 Member와 Team 엔티티를 leftJoin 하여 조회한다.
    -조회하는 필드는 Member의 id, username, age와 Team의 id, name이다.
    -조회된 데이터를 MemberTeamDto에 매핑하여 반환한다.
    -쿼리에 사용되는 조건들은 where 절에서 각 메서드를 호출하여 동적으로 생성된다. 각 조건 메서드는 null일 경우 해당 조건을 무시-하도록 설계되었다.

  • 조건 메서드
    -각 조건 메서드는 BooleanExpression을 반환하며, null을 반환할 경우 해당 조건은 무시된다.
    -usernameEq: username이 비어 있지 않으면 해당 값을 기준으로 검색한다.
    -teamNameEq: teamName이 비어 있지 않으면 해당 팀 이름을 기준으로 검색한다.
    -ageGoe: ageGoe가 null이 아니면 해당 나이 이상의 조건을 추가한다.
    -ageLoe: ageLoe가 null이 아니면 해당 나이 이하의 조건을 추가한다.

  • 동적 쿼리: usernameEq, teamNameEq, ageGoe, ageLoe 메서드들은 입력 값이 없는 경우(null 또는 빈 값) 조건을 무시하도록 구현되어 있어 동적 쿼리를 구성할 수 있다.
  • BooleanExpression: Querydsl에서 사용하는 조건식 타입으로, 각 조건을 결합하여 where 절에 사용할 수 있다. null을 반환할 경우 해당 조건은 무시되므로, 동적으로 쿼리의 조건을 설정할 수 있다.
  • leftJoin: Member와 Team을 조인할 때 leftJoin을 사용하여 Member가 속한 Team이 없는 경우에도 결과에 포함될 수 있도록 한다.

 

 

스프링 데이터 리포지토리 상속

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    List<Member> findByUsername(String username);
}

 

 

Query DSL 페이징 연동

전체 카운트를 한 번에 조회하는 단순한 방법

public interface MemberRepositoryCustom {
    //...

    //전체 카운트를 한 번에 조회하는 단순한 방법
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);

    //데이터 내용과 전체 카운트를 별도로 조회하는 방법
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);

    //CountQuery 최적화
    Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable);
}

 

MemberRepositoryImpl

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    //...

    /**
     * 단순한 페이징, fetchResults() 사용
     */
    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<MemberTeamDto> content = results.getResults();
        long total = results.getTotal();
        return new PageImpl<>(content, pageable, total);
    }
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.

따라서 이 글의 Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원) 섹션의 SearchPageSimple() 리펙토링 방식을 사용하는 것을 권장한다.

이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 작성한 예시이다.

사용자가 입력한 조건에 맞춰 조회 결과를 페이징하여 반환하는 기능을 제공한다.

주요 메서드는 searchPageSimple으로, 주어진 조건과 Pageable 객체를 기반으로 조회 결과를 처리한 후 Page 객체로 반환한다.

 

  • QueryResults<MemberTeamDto> results
    QueryFactory를 통해 QMember와 QTeam을 기준으로 조회를 수행한다.
    select 절에서 Member와 Team의 필드를 가져와 MemberTeamDto에 매핑한다.
    where 절에서는 동적으로 쿼리 조건을 적용하여 데이터를 필터링한다.
    offset과 limit를 pageable 객체에서 가져와 페이징 처리를 한다.
    fetchResults() 메서드는 결과 목록과 총 개수를 동시에 가져온다.

  • 페이징 처리
    results.getResults()를 통해 조회된 MemberTeamDto 리스트를 가져온다.
    results.getTotal()은 조건에 맞는 전체 데이터의 개수를 반환한다.
    new PageImpl<>(content, pageable, total)을 통해 페이징된 결과를 반환한다. PageImpl은 Spring Data에서 제공하는 페이징 결과 클래스이다.

  • Pageable 객체
    Pageable은 페이징과 정렬 정보를 포함한 객체로, 클라이언트로부터 전달받은 페이지 번호, 페이지 크기, 정렬 등의 정보를 담고 있다.
    offset은 조회를 시작할 데이터의 위치(즉, 몇 번째 데이터부터 조회할지를 의미)이며, limit는 한 페이지당 조회할 데이터의 개수를 설정한다.

 

  • offset(pageable.getOffset())은 조회 시작 위치를 설정
  • limit(pageable.getPageSize())은 한 페이지에 포함될 데이터 개수를 설정

  • fetchResults()로 쿼리 실행 후, 조회된 리스트와 총 개수를 가져온다.
  • PageImpl을 사용하여 최종 결과를 페이징 형식으로 반환한다.

 

데이터 내용과 전체 카운트를 별도로 조회하는 방법

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

   //...

    /**
     * 복잡한 페이징
     * 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리
     */
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = queryFactory
                .select(QMember.member)
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetchCount();

        return new PageImpl<>(content, pageable, total);
    }
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.

따라서 이 글의 CountQuery 최적화 섹션의 searchPageOptimizationCount 메서드를 방식을 이용하는 것을 권장한다.

이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 구현한 예시이다.

searchPageComplex 메서드는 동적 조건을 기반으로 데이터를 조회하고, 페이징 처리된 결과를 반환한다.

주요 특징은 fetch()와 fetchCount()를 사용해 데이터 조회와 총 개수 계산을 별도로 처리하여 성능을 최적화하려는 접근이다.

 

  • 데이터 조회 부분
    List<MemberTeamDto> content: 실제 데이터를 조회하는 쿼리이다. QueryFactory를 통해 Member와 Team을 조인하여 필요한 데이터를 MemberTeamDto로 매핑한 후 페이징 처리(offset, limit)를 적용해 조회한다.
    fetch()는 조건에 맞는 데이터를 조회해 리스트로 반환한다.

  • 총 데이터 개수 조회 부분
    long total: 총 데이터 개수를 조회하는 쿼리이다. 데이터의 페이징 처리와 별개로, 전체 결과의 총 개수를 계산하기 위해 fetchCount()를 사용한다.
    이 쿼리는 content 쿼리와 동일한 조건을 사용하지만, 실제 데이터를 가져오는 대신 개수만 계산한다.

  • 페이징 결과 반환
    new PageImpl<>(content, pageable, total)을 통해 조회된 데이터를 페이징 형식으로 반환한다. PageImpl 클래스는 스프링 데이터에서 제공하는 Page 인터페이스의 구현체로, 페이징된 데이터 목록과 관련 정보를 담고 있다.

 

  • offset(pageable.getOffset())과 limit(pageable.getPageSize())를 사용하여 페이징 처리한다. offset은 조회를 시작할 위치를, limit은 한 페이지당 조회할 데이터 개수를 설정한다.

  • fetchCount()는 전체 데이터의 개수를 반환하며, 페이징 처리에 사용된다. 이 부분은 데이터의 개수만 계산하기 때문에 select(QMember.member)로 데이터를 조회하지 않고 개수를 세는 최적화된 쿼리다.

  • PageImpl<>(content, pageable, total)을 사용하여 페이징된 데이터를 담은 Page 객체를 반환한다.

 

 

CountQuery 최적화

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

	//...

    @Override
    public Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(QMember.member.count())
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }
}

이 코드는 Querydsl을 사용하여 페이징을 최적화한 쿼리를 작성하는 예시이다.

이 메서드는 MemberSearchCondition을 기반으로 검색 조건을 적용하고, Pageable 객체를 사용하여 페이지네이션을 수행한다. 또한, 페이징 처리에 있어서 성능을 최적화하기 위해 count 쿼리를 분리하여 처리한다.

 

쿼리 실행 과정

  • content 쿼리는 실제로 조회할 데이터 목록을 가져오는 쿼리이다. 이때, Pageable 객체의 offset과 limit을 사용하여 페이징을 처리한다.
  • countQuery는 전체 데이터의 개수를 계산하는 쿼리이다. 이 쿼리는 페이징에 필요한 총 개수를 구하기 위해 사용된다.

 

성능 최적화

  • 페이징 성능을 최적화하기 위해 PageableExecutionUtils.getPage()를 사용한다. 이는 content 쿼리가 실행된 결과와, 필요할 때만 countQuery를 실행하여 전체 페이지 수를 계산하도록 한다. 따라서 불필요한 count 쿼리 실행을 줄여 성능을 높인다.

 

쿼리 조건 설정

  • usernameEq, teamNameEq, ageGoe, ageLoe 같은 메서드들은 조건이 있을 때만 쿼리에 추가하는 방식으로, 동적으로 쿼리 조건을 설정할 수 있다.

 

코드 설명

  • queryFactory: QueryDSL에서 제공하는 쿼리 생성기.
  • QMember.member: QueryDSL로 생성된 QType 클래스.
  • QTeam.team: 마찬가지로 Team 엔티티의 QType.
  • PageableExecutionUtils.getPage(): 페이징 처리를 위해 사용하는 유틸리티 메서드로, 페이징 결과 리스트와 전체 개수를 넘겨준다.

 

 

 

Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원)

Querydsl의 fetchCount() , fetchResult() 는 개발자가 작성한 select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행한다.

그런데 이 기능은 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도이다.

따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다.

Querydsl은 향후 fetchCount() , fetchResult() 를 지원하지 않기로 결정했다.

 

따라서 count 쿼리가 필요하면 다음과 같이 별도로 작성해야한다.

@Test
 public void count() {
     Long totalCount = queryFactory
             //.select(Wildcard.count) //select count(*)
             .select(member.count()) //select count(member.id)
             .from(member)
             .fetchOne();
     System.out.println("totalCount = " + totalCount);
 }

count(*) 을 사용하고 싶으면 예제의 주석처럼 Wildcard.count 를 사용하면 된다.

member.count() 를 사용하면 count(member.id) 로 처리된다. 

응답 결과는 숫자 하나이므로 fetchOne() 을 사용한다.

 

fetchResults()는 QueryDSL에서 한번에 결과 리스트와 전체 카운트를 가져오는 방식이지만, 성능상의 이슈로 인해 deprecated되었다.

이를 대신하여, 결과 목록을 가져오는 쿼리와 전체 카운트를 가져오는 쿼리를 분리하여 리팩토링할 수 있다.

아래는 fetchResults()를 사용하지 않고 동일한 기능을 수행하도록 리팩토링한 코드이다.

 

SearchPageSimple() 리펙토링

@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
    // content 쿼리: 결과 리스트를 가져오는 쿼리
    List<MemberTeamDto> content = queryFactory
            .select(new QMemberTeamDto(
                    QMember.member.id,
                    QMember.member.username,
                    QMember.member.age,
                    QTeam.team.id,
                    QTeam.team.name))
            .from(QMember.member)
            .leftJoin(QMember.member.team, QTeam.team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    // count 쿼리: 전체 카운트를 가져오는 쿼리
    long total = queryFactory
            .select(QMember.member.count())
            .from(QMember.member)
            .leftJoin(QMember.member.team, QTeam.team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .fetchOne();

    return new PageImpl<>(content, pageable, total);
}
  • content는 fetch()를 사용하여 결과 리스트만 가져온다. fetch()는 리스트만 반환하며, fetchResults()처럼 총 개수나 기타 정보는 반환하지 않는다.
  • total은 fetchOne()을 사용하여 전체 개수를 가져온다. count() 쿼리만 실행하여 효율적으로 전체 데이터를 구한다.
  • 최종적으로 PageImpl을 사용하여 결과 리스트(content), 페이지 정보(pageable), 전체 개수(total)를 반환한다.

이렇게 쿼리를 분리하면, 각각의 목적에 맞는 쿼리를 효율적으로 실행할 수 있어 성능 최적화에 도움이 된다.

 

 

컨트롤러 개발

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageSimple(condition, pageable);
    }

    @GetMapping("/v3/members")
    public Page<MemberTeamDto> searchMemberV3(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }

    @GetMapping("/v4/members")
    public Page<MemberTeamDto> searchMemberV4(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageOptimizationCount(condition, pageable);
    }
}
  • /v1/members (검색 조건만으로 검색)
    검색 조건인 MemberSearchCondition을 기반으로 데이터를 필터링하여 List<MemberTeamDto>를 반환한다.
    페이징 처리 없이 모든 결과를 반환하는 방식이다.

  • /v2/members (간단한 페이징 처리)
    MemberSearchCondition과 Pageable을 받아 간단한 페이징 처리를 한다.
    memberRepository.searchPageSimple()을 호출하며, 페이징된 데이터를 Page<MemberTeamDto>로 반환한다.
    searchPageSimple()은 데이터 조회와 개수 조회를 동시에 처리하는 간단한 페이징 로직이다.

  • /v3/members (복잡한 페이징 처리)
    MemberSearchCondition과 Pageable을 받아 복잡한 페이징 처리를 한다.
    memberRepository.searchPageComplex()는 데이터 조회와 카운트 쿼리를 분리하여 각각 처리하며, 페이징 성능을 개선한 방식이다.

  • /v4/members (최적화된 카운트 쿼리 적용)
    MemberSearchCondition과 Pageable을 기반으로 데이터를 조회하지만, 카운트 쿼리를 최적화하여 필요할 때만 실행하는 방식이다.
    memberRepository.searchPageOptimizationCount()는 PageableExecutionUtils.getPage()를 사용해 불필요한 카운트 쿼리를 방지하는 방식으로 최적화되어 있다.

 

예시 요청

http://localhost:8080/v4/members?ageGoe=30&ageLoe=44&page=0&size=5

이 요청은 v4 엔드포인트에 대한 호출로, 다음 조건을 포함한다

  • 나이 조건: ageGoe=30, ageLoe=44 — 나이가 30 이상 44 이하인 멤버를 조회한다.
  • 페이징 조건: page=0, size=5 — 0번째 페이지부터 5개씩 조회한다.

이 호출은 searchPageOptimizationCount() 메서드를 사용해 페이징을 처리하며, 최적화된 카운트 쿼리를 적용하여 성능을 높인다.

반환 결과

Page<MemberTeamDto> 형태로 JSON 응답이 반환된다. 여기에는 다음 정보가 포함된다:

  • content: 해당 페이지에 해당하는 MemberTeamDto 리스트.
  • totalElements: 전체 결과의 총 개수.
  • totalPages: 전체 페이지 수.
  • pageable: 현재 페이징 상태.

 

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


테스트 기본 코드

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this(username, 0);
    }

    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {
    }

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }
}

 

@SpringBootTest
@Transactional
public class QuerydslBasicTest {
    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void before() {
        queryFactory = new JPAQueryFactory(em);

        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);
        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);
        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
    }
    //...
}

 

 

프로젝션과 결과 반환

프로젝션이란 SELECT 문의 대상 지정을 뜻한다.

 

프로젝션 대상이 하나

  • 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있음
  • 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회
@Test
public void simpleProjection(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }

    List<Member> result2 = queryFactory
            .select(member)
            .from(member)
            .fetch();

    for (Member s : result2) {
        System.out.println("s = " + s);
    }
}

 

튜플 조회

프로젝션 대상이 둘 이상일 때 사용

@Test
public void tupleProjection(){
    List<Tuple> result = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();
    for (Tuple tuple : result) {
        String username = tuple.get(member.username);
        Integer age = tuple.get(member.age);
        System.out.println("username=" + username);
        System.out.println("age=" + age);
    }
}

 

 

DTO 조회

setter 접근

@Test
public void findDtoBySetter(){
    List<MemberDto> result = queryFactory
            .select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

 

field 접근

@Test
public void findDtoByField(){
    List<MemberDto> result = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

DTO가 필드의 접근 제한자가 Private여도 상관 없이 필드에 주입이 된다.

 

 

constructor 접근

@Test
public void findDtoByConstructor(){
    List<MemberDto> result = queryFactory
            .select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

 

별칭이 다를 때

프로퍼티나, 필드 접근 생성 방식에서 이름이 다를 때 해결 방안

@Data
public class UserDto {
    private String name;
    private int age;
}

Member 엔티티는 userName이고 UserDto는 name이다.

 

  • ExpressionUtils.as(source,alias) : 필드나, 서브 쿼리에 별칭 적용 
  • username.as("memberName") : 필드에 별칭 적용
@Test
public void findUserDto(){
    QMember memberSub = new QMember("memberSub");
    List<UserDto> fetch = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    ExpressionUtils.as(JPAExpressions
                                    .select(memberSub.age.max())
                                    .from(memberSub), "age")))
            .from(member)
            .fetch();

    for (UserDto userDto : fetch) {
        System.out.println("userDto = " + userDto);
    }
}

 

@QueryProjection

DTO의 생성자에 @QueryProjection 애노테이션을 붙힌다.

@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {
    }

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

  • Gradle의 compileJava를 실행한다.
  • QMemberDto 생성 확인

 

@Test
public void findDtoByQueryProjection(){
    List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

 

컴파일 시점에 오류를 잡아준다.

하지만 DTO가 com.querydsl.core.annotations.QueryProjection 에 의존하게된다.

그렇기 때문에 QueryDSL을 사용하지 않으면 이 DTO를 사용하는 모든 계층에 대해 유지보수가 힘들어진다.

 

동적 쿼리

BooleanBuilder

보통 쿼리를 작성할 때, 여러 조건이 동적으로 추가될 수 있을 때가 있다.

예를 들어, 사용자가 입력한 값에 따라 조건이 달라질 때, BooleanBuilder를 사용하여 조건을 점진적으로 추가할 수 있다.

@Test
public void DynamicQuery_BooleanBuilder() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    List<Member> result = searchMember1(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember1(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();
    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }
    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }
    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}

 

Where 다중 파라미터 사용

@Test
public void DynamicQuery_WhereParam() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    List<Member> result = searchMember2(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameCond), ageEq(ageCond))
            .fetch();
}

private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}
  • where 조건에 null 값은 무시된다.
  • 메서드를 다른 쿼리에서도 재활용 할 수 있다.
  • 쿼리 자체의 가독성이 높아진다.

 

조합 가능

@Test
public void DynamicQuery_AllEq() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    List<Member> result = searchMember2(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember3(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(usernameCond, ageCond))
            .fetch();
}

private BooleanExpression allEq(String usernameCond, Integer ageCond) {
    return usernameEq(usernameCond).and(ageEq(ageCond));
}

private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

이전의 BooleanBuilder 방식과는 조금 다르게, 각 조건을 따로 메서드로 분리하여 동적 쿼리를 작성한 후, 이를 결합하는 방식으로 구성되어 있다.

searchMember3 메서드는 주어진 usernameCond와 ageCond 조건에 따라 동적 쿼리를 실행하는 메서드이다.

where 절에서 allEq() 메서드를 호출하여 usernameCond와 ageCond에 따른 조건을 추가한다.

null 체크는 주의해서 처리해야 한다.

 

수정, 삭제 벌크 연산

JPQL 배치와 마찬가지로, 영속성 컨텍스트에 있는 엔티티를 무시하고 실행되기 때문에 배치 쿼리를 실행하 고 나면 영속성 컨텍스트를 초기화 하는 것이 안전하다.

@Test
public void bulkUpdate(){
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkAdd(){
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(-1))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkMultiply(){
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkDelete(){
    long count = queryFactory
            .delete(member)
            .where(member.age.gt(20))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

 

SQL function 호출하기

SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

 

member -> M으로 변경하는 replace 함수 사용

@Test
public void sqlFunction(){
    String result = queryFactory
            .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"))
            .from(member)
            .fetchFirst();

    System.out.println("result = " + result);

    List<String> result2 = queryFactory
            .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"))
            .from(member)
            .fetch();
    for (String s : result2) {
        System.out.println("s = " + s);
    }
}

 

소문자로 변경해서 비교

@Test
public void sqlFunction2(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})",
                    member.username)))
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

 

lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 다음과 같이 처리해도 결과는 같다.

.where(member.username.eq(member.username.lower()))