https://www.baeldung.com/ 여기서 다음 글을 찾아서 좋은 내용이라고 생각해 한번 글을 작성해보려고 한다.
https://foojay.io/today/sql-best-practices-every-java-engineer-must-know/ 여기서는 Java Engineer라면 모두 알아야 하는 SQL 모범사례에 대해서 얘기하고 있는데 대부분의 내용들은 좋지만 SubQuery에 대한 부분은 사람들이 인정하지 못하는지 댓글로 다양한 얘기들을 하고 있는 모습을 볼 수 있다.
또한 이러한 내용은 RealMysql 2권에서도 1장에 쿼리최적화 부분과 상당히 일치하는 부분이 많은데 이 블로그에서 요약으로 정리해 보도록 하겠다.
주로 이 글에서는 인덱스를 활용할때 어떻게 해야 하는지에서 다루고 있다.
Tip1. 인덱스를 활용해라
인덱스를 사용하면 데이터베이스가 데이터를 빠르게 찾아 데이터 레코드에 빠르게 액세스가 가능하다.
또한 인덱스를 사용하는 부분은 WHERE, JOIN, ORDER BY 절에서 사용되는 칼럼에 인덱스를 생성하는 것이 좋다.
그리고 쿼리에 필요한 모든 열을 포함하기 위해선, 커버링 인덱스를 사용하라고 지칭한다.
SELECT * FROM users WHERE email = 'ali@gmail.com';
위처럼 email 칼럼을 사용해서 모든 데이터를 가져오는 방식은 좋지 않다. 따라서 WHERE절의 조건으로 사용되는 email 칼럼에 인덱스를 설정하고 인덱스를 활용해서 쿼리를 작성하자.
CREATE INDEX idx_users_email ON users (email);
SELECT name, email FROM users WHERE email = 'ali@gmail.com';
Tip2. 레버리지 함수 기반 인덱스를 활용해라
아마 주로 검색하는 부분에서 대소문자를 구분하지 않는 방법이나, 날짜/시간에 대한 조작이 포함된 쿼리를 작성해 본 적이 있을 것이다. 이때는 다음과 같이 작성하면 안 된다.
-- no function-based index applied.
SELECT * FROM employees WHERE UPPER(last_name) = 'SMITH';
이는 RealMysql에도 나와있는데 인덱스로 employees 칼럼을 설정했다고 하자. 그렇다면 위 방법의 쿼리실행계획은 어떻게 될까?
위 쿼리는 테이블 풀스캔 방식으로 동작하고 인덱스를 전혀 활용하지 못한다. 그 이유는 바로 인덱스는 employees 칼럼을 저장하는데 이렇게 저장된 employees 인덱스에는 소문자, 대문자 등이 섞여있기 때문이다. 따라서 애초에 변형된 인덱스를 저장하거나, 처음 DB에 저장할 때부터 규칙을 준수해서 설정해야 한다. 하지만 규칙을 준수하지 못했다면 다음과 같이 활용할 수 있다.
CREATE INDEX idx_upper_last_name ON employees (UPPER(last_name));
SELECT * FROM employees WHERE UPPER(last_name) = 'SMITH';
다음과 같이 함수기반 인덱스를 설정하여 WHERE이나 다른 조건절에 똑같은 방식의 함수기반을 넣어 조건을 찾으면 된다.
Tip3. SELECT * 를 사용하지 말자
SELECT * 를 사용하면 모든 칼럼의 정보를 가져오게 된다. 이는 필요 없는 데이터까지도 불러올 수 있는 부분이므로 피하는 것이 좋다. 데이터의 크기에 따라서도 비용이 발생하는데 이런 부분을 줄이라고 말하는 것 같다.
SELECT * FROM users;
다음 대신에
SELECT name, email FROM users;
필요한 부분만 선택해서 가져오는 방식을 적용해 보자.
TIP4. 적절하게 JOIN을 사용해라
부적절한 Join은 성능문제가 발생할 가능성이 높기 때문에 쿼리에 올바른 유형의 JOIN을 선택하라는 것이다.
여기서 말하고 있는 JOIN은 INNER와 LEFT 조인의 특성을 잘 파악하라는 것인데 다음 쿼리를 보자.
SELECT u.name, o.order_date
FROM users u, orders o
WHERE u.id = o.user_id;
아마 이 쿼리나 아래 쿼리 모두 동일한 결과를 반환할 것이다. 그러나 옵티마이저에서 어떻게 실행계획을 할지는 직접 돌려봐야 할 것 같은데 아래 쿼리를 사용하여 INNER JOIN을 활용하고 어떤 방식을 사용할지 모르는 상황보다는 명시적으로 JOIN을 작성해 주는 것이 더 쿼리 튜닝을 할 때 효과적으로 튜닝이 가능하다.
SELECT u.name, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id;
TIP5. WHERE 절로 데이터 필터링을 진행하라.
가능한 한 빨리 필터링을 진행하여 데이터의 양을 줄이는 방식을 사용하라는 것인데, 전체 데이터를 가져와 데이터를 필터링하는 것보다, 필터링된 적은 데이터를 가져오는 것이 좀 더 효과적이라는 것이다.
예를 들어 활성화된 사용자를 찾고 싶다고 가정해 보자. 다음방식으로 모든 user를 가져와 일단 active인 사용자를 걸러낸다.
SELECT name, email FROM users;
하지만 이렇게 되면 필요 없는 데이터도 불러올 수 있기 때문에 다음과 같이 WHERE 조건절을 추가하자.
SELECT name, email FROM users WHERE active = true;
이러면 데이터의 양도 적어지고 필터링하여 데이터를 가져올 수 있으며, active에 인덱스를 설정했다면 더욱 효과적으로 데이터를 가져올 수 있을 것이다.
TIP6. LIMIT를 사용하여 행을 제한해라
모든 행이 필요하지 않은 요소들도 존재할 것이다. 예를 들면 최신 데이터 10건을 가져온다던지 하는 비즈니스 로직이 존재할 텐데 그럴 경우 LIMIT를 사용하게 되면 인덱스를 활용하지 않아도 모든 데이터를 읽어오지 않는다. LIMIT는 데이터를 하나씩 값을 추가하면서 LIMIT의 값에 다다랐을 때 쿼리를 그대로 종료하기 때문에 효율적으로 사용한다면 큰 성능을 끌어올릴 수 있는 방식 중에 하나이다.
SELECT name, email FROM users WHERE active = true;
따라서 다음방식 대신 아래 방식을 사용하도록 하자.
SELECT name, email FROM users WHERE active = true LIMIT 10;
TIP7. IN 대신 EXISTS를 사용해라.
블로그에서는 하위 쿼리를 사용하여 EXISTS 행의 존재여부를 파악한다는데 이게 무슨 소린지 잘 모르겠으니 직접 찾아보겠다.
IN과 EXISTS의 차이점
IN은 하위 쿼리의 결과를 비교하여 일치하는 값이 있는지 확인한다. 작동은 하위 쿼리가 먼저 실행되어 결과를 메모리에 적재하고, 이 값들과 비교하는 방식을 택한다. 때문에 대규모 데이터에서는 하위쿼리의 결과가 많아질 수밖에 없는데 이는 성능저하로 이어진다.
EXISTS는 하위쿼리의 결과를 존재여부로 확인한다. 따라서 하위 쿼리가 한 번이라도 일치하는 결과를 찾으면 True를 반환하고 바로 쿼리를 종료하게 되면서 대규모 데이터일시 좀 더 효과적으로 작동한다.
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE order_date > '2024-01-01');
다음과 같이 IN을 사용해서 order_date가 1월 1일 이상인 것들을 모두 찾게 된다.
그 후 users 테이블의 id과 비교하여, orders의 user_id가 일치하는 것이 있는지 모두 확인하게 된다.
SELECT name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.order_date > '2024-01-01');
다음 쿼리는 users 테이블의 각행에 대해 orders 테이블에 일치하는 user_id가 있는지 확인하고 하나라도 있으면 바로 True를 반환 후 다음동작으로 넘어간다. 즉 좀 더 빠른 효율적인 쿼리가 만들어지게 된다.
TIP8. WHERE 절에 함수 사용을 피해라.
위 2번과 비슷한 내용이지만 2번은 어쩔 수 없는 경우 함수기반의 인덱스 설정을 하라는 것이며, 이는 애초에 WHERE 절에 함수사용을 하지 말라는 것이다. 이유는 마찬가지로 WHERE 절에서 함수를 사용하게 되면 인덱스 칼럼과 차이가 발생해 인덱스를 효율적으로 사용하지 못한다는 것이다. 따라서 WHERE절의 조건으로 사용된 칼럼에 인덱스를 설정했다면 WHERE 절에서 인덱스 된 열에 함수를 사용하면 안 된다.
SELECT name, email FROM users WHERE DATE_PART('year', created_at) = 2023;
위 상황에서 created_at의 칼럼에 인덱스를 설정했다고 가정해 보자. 위 경우에는 cretate_at에서 연도만 가져와 2023인 연도를 출력하는데 이미 인덱스에서는 예를 들어 기존의 create_at인 "2023-02-02" 등으로 저장이 돼있을 것이다. 따라서 데이터 타입이 맞지 않기에 인덱스를 활용하지 못하며 이는 부적절한 인덱스 활용이 된다. 따라서 다음과 같이 쿼리를 바꿔 보자.
SELECT name, email FROM users WHERE created_at >= '2023-01-01' AND created_at < '2024-01-01';
이렇게 되면 마찬가지로 2023년도의 모든 데이터를 갖고 오지만 index와 조건절을 비교하게 되면서 인덱스 레인지 스캔을 활용하게 되면서 인덱스를 사용하여 데이터를 찾을 수 있다.
TIP9. 서브 쿼리 대신 JOIN을 사용해라.
여기서 말하길 JOIN은 서브 쿼리보다 효율적인 경우가 많다고 하며, 특히 대규모 데이터 세트일 경우 더욱 효율적이라고 한다.
하지만 아래 댓글에서 사람들이 subquery가 더 좋을 때도 있어요! 라면서 얘기를 하는 모습을 볼 수 있는데
이처럼 다양한 조건들이 존재할 때 가장 좋은 방법은 EXPLAIN 키워드로 쿼리 실행계획을 파악하여 쿼리를 적절히 조율하는 편이 좋아 보인다.
다시 돌아와서 피해야 할 상황을 보자.
SELECT name, (
-- Subquery to get order date for each user
SELECT order_date
FROM orders
WHERE user_id = users.id
) AS order_date
FROM users;
다음처럼 쿼리에 서브쿼리 대신에 아래와 같이 JOIN을 사용한다면 좀 더 효과적으로 데이터를 가져올 수 있다.
SELECT u.name, o.order_date FROM users u JOIN orders o ON u.id = o.user_id;
위 경우를 보니 나도 JOIN이 더 좋아 보이긴 하는데 어떻게 동작하는지 살펴보자.
서브 쿼리를 사용한 경우 쿼리 내에서 별도의 쿼리를 실행하여 다시 메인 쿼리로 넘기는 방식이다.
따라서 첫 번째 경우 order_date를 가져오기 위해서 user마다 order 테이블에 접근하여 데이터를 가져와야 한다. 따라서 users 테이블의 각행이 많고 orders 테이블에서 user_id에 맞는 order_date를 찾아야 하기 때문에 더 많은 추가 쿼리가 발생하고 데이터가 많아지면 많아질수록 성능이 저하될 수 있다.
두 번째 쿼리 JOIN 같은 경우에는 두 테이블을 결합하여 한번에 데이터를 갖고 온다. 따라서 "대부분"의 경우에는 JOIN이 좀 더 효과적인 경우가 많다.
TIP10. GROUP BY , ORDER BY 절에 인덱스를 사용하자.
GROUP BY와 ORDER BY절은 리소스가 많이 사용되는 동작중에 하나다. 따라서 성능을 개선하기 위해서 불필요한 경우가 아니라면 인덱스를 활용하는 것이 좋다.
SELECT user_id, COUNT(*), MAX(order_date) FROM orders GROUP BY user_id, order_date ORDER BY order_date;
따라서 다음보다는 아래 쿼리가 좀더 효율적으로 사용할 수 있다.
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY user_id;
TIP11. 적절한 데이터 유형을 사용해라.
각 칼럼에 맞는 올바른 데이터 유형을 선택해야 하는데 이는 성능과, 저장효율성에 상당한 영향을 준다.
따라서 꼭 필요한 경우가 아니라면 TEXT나 BLOB는 사용하지 말라고 말한다.
-- Using TEXT for name and email which may be inefficient
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT,
created_at TIMESTAMP
);
테이블 설계 시 위처럼 필요 없는 부분에 TEXT 타입을 지정할 필요가 없으며 아래와 같이 사용하자.
-- Using more appropriate data types for better performance and storage efficiency
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR 100,
email VARCHAR 100,
created_at TIMESTAMP
);
TIP12. 쿼리 실행계획을 분석해라.
아마 최고의 도움이 되는 부분인 것 같은데 이는 자신의 서비스에서 쿼리 최적화에 상당히 도움이 되는 부분이라고 생각한다.
모든 개발자가 옵티마이저가 어떻게 동작하는지는 정확히 알기 어렵기 때문에 자체적으로 쿼리가 어떤 방식으로 동작하는지 살펴봐야 한다. 따라서 EXPLAIN 같은 키워드를 사용하여 쿼리 실행 계획을 분석해 쿼리를 효율적으로 튜닝해 보도록 하자.
SELECT name, email FROM users WHERE active = true;
위방식을 그대로 적용해서 앞에 EXPLAIN을 붙이면 쿼리의 실행계획이 나오고 이는 인덱스를 활용 중인지, 인덱스 스캔의 방식, 인덱스의 key byte값 등이 나오고 이를 통해서 개발자가 효율적으로 쿼리를 실행 중인지 파악이 가능하다.
EXPLAIN SELECT name, email FROM users WHERE active = true;
TIP13. Connection Pooling을 사용해라.
이건 쿼리보다는 Java 애플리케이션에 관한 내용인데 대부분 Spring Boot를 사용하면 데이터베이스와 연결 시 커넥션 풀을 사용할 것이다. 따라서 Spring Boot에서 제공하는 커넥션 풀 기능을 사용하여 데이터 베이스의 성능이나 기능에 따라 풀 크기를 서비스에 맞게 조정하는 것이 좋다.
커넥션 풀을 사용하지 않고 다음과 같은 방식으로 커넥션을 맺는다고 해보자.
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydatabase", "user", "password");
// Use connection here
conn.close();
그렇다면 애플리케이션에서는 user, password를 설정하고 데이터베이스와 커넥션을 맺어줘야 하는데 이는 생성비용이 추가로 드는 과정이다. 이러한 경우 상당한 리소스를 소모하게 되고 효율적으로 데이터베이스와 연결하지 못한다. 따라서 다음과 같이 커넥션 풀로 커넥션을 가져와서 사용하는 방법을 사용한다.
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydatabase");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(10);
HikariDataSource dataSource = new HikariDataSource(config);
Spring Boot의 기본 커넥션 풀은 10개며 사용자의 서비스에 맞게 늘릴 수 있다.
TIP14. Batch 처리를 진행해라.
배치 처리를 진행해서 Multiple insert, update, delete를 수행해라. 말 그대로 데이터들을 하나로 묶어서 한 번에 쿼리를 수행하는 것이다.
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
for (User user : userList) {
stmt.executeUpdate("INSERT INTO users (name, email) VALUES ('" + user.getName() + "', '" + user.getEmail() + "')");
}
stmt.close();
conn.close();
위에서 하나씩 쿼리를 날리는 것 대신에 아래와 같이 한 번에 쿼리를 집어넣는 것이다.
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");
for (User user : userList) {
pstmt.setString(1,
user.getName());
pstmt.setString(2,
user.getEmail());
pstmt.addBatch();
}
pstmt.executeBatch();
pstmt.close();
conn.close();
JPA상에 예시를 들면 saveAll 같은 방식이 있을 수 있겠다.
TIP15. JOIN을 최적화해라.
조인을 적절하게 최적화하면 쿼리 성능에 상당한 영향을 끼칠 수 있으며, 특히 대규모 세트의 경우 그런 경우가 많다.
따라서 조인 조건에 사용된 칼럼이 인덱스 되었는지 확인하고, 여러 테이블을 결합할 때는 가장 작은 테이블부터 시작해라.
가장 작은 테이블부터 시작하게 되면 최대한 데이터를 간추려서 작업을 진행하기 때문에 더욱 효율적으로 사용할 수 있다.
SELECT u.name, o.order_date FROM orders o JOIN users u ON u.id = o.user_id WHERE u.active = true;
다음과 같은 테이블이 있다고 가정했을 때 user는 많은 orders를 가질 수 있다. 따라서 orders 테이블은 user의 테이블보다 더 클 것이라 가정한다. 따라서 위와 같은 상황은 많은 레코드를 가진 orders 테이블에서 user를 join 해서 가져오기 때문에 효율적으로 처리하지 못한다. 즉 아래와 같은 쿼리로 변경하자.
SELECT u.name, o.order_date FROM users u JOIN orders o ON u.id = o.user_id WHERE u.active = true;
TIP16. 서브 쿼리를 최적화해라.
하위 쿼리는 위에서 말하다시피 종종 JOIN이나 더 나은 효율적인 쿼리로 대체할 수 있다.
즉 조인을 사용하거나 복잡한 쿼리라면 공통 테이블 표현식(CTE)을 사용하여 가독성과 성능을 향상해라.
SELECT name, email FROM users WHERE id
IN SELECT user_id FROM orders WHERE order_date > '2023-01-01';
즉 위와 같은 쿼리에서
WITH RecentOrders AS
SELECT user_id FROM orders WHERE order_date > '2023-01-01'
SELECT u.name, u.email FROM users u JOIN RecentOrders ro ON u.id = ro.user_id;
다음과 같은 쿼리로 변경하자. 첫 번째 쿼리는 orders 테이블에서 order_date가 2023년도 이상인 user_id 값을 검색하고 이 값을 메인 쿼리에서 users.id와 비교하게 된다.
두 번째 쿼리는 CTE를 사용하는데 CTE는 일시적으로 결과의 집합을 정의하고 메인에서 재사용하는 방식이다. 이런 방식으로 복잡한 쿼리를 나눠서 생각할 수 있으며, 데이터 베이스에서 한 번만 계산하도록 하여 성능을 개선할 수 있다.
RecentOrders 가상 테이블을 생성해 먼저 처음 쿼리의 값을 저장하고 그 이후는 계산하지 않고 그 값을 토대로 사용한다.
이후 JOIN을 사용하여 users 테이블과 결합한다.
따라서 가독성화 효율성 측면에서 더 쿼리를 효율적으로 사용할 수 있다.
TIP17. 집계함수 Aggregation query를 최적화해라.
집계함수는 여러 행을 그룹화해서 하나의 결과로 반환한다. 예시로는 COUNT, SUM 같은 함수가 있다. 이때 GROUP BY와 엮어서 많이 사용을 하는데 그룹에 대한 집계를 주로 사용한다.
여기서 말하는 바는 GROUP BY 절에 포함된 칼럼이 인덱스가 없을 때 성능이 저하될 수 있다. 따라서 GROUP BY절의 조건에도 인덱스를 사용하자.
또한 요약 테이블을 미리 만들어두면 성능이 향상될 수 있는데, 매번 쿼리를 실행하는 것 대신 이미 요약된 데이터를 빠르게 조회하는 방식을 사용하자는 것이다.
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id, order_date;
위 쿼리는 user_id와 order_date에 대해 동시에 그룹화를 진행한다. 이런 방식은 더 많은 그룹을 만들게 되고 더 많은 리소스를 사용한다. 예를 들어 같은 사용자의 주문이라도 order_date가 다르면 별도의 그룹으로 처리되어 더 많은 데이터가 생겨날 수 있다.
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id;
이 쿼리는 user_id만으로 그룹을 지정하는데 user_id가 인덱스로 설정되어 있다고 가정하겠다. 그러면 user_id를 기반으로 인덱스를 활용할 수 있으며 마찬가지로 가져오는 부분인 user_id와 COUNT(*) 까지도 똑같이 처리할 수 있다.
즉 불필요한 그룹화를 피하라는 블로그 작성자의 예시인 것 같다.
TIP18. 요약 칼럼을 사용해라.
집계함수를 사용하는 대신에 미리 칼럼을 설정해, 추가적인 연산 없이 쿼리를 빠르게 수행할 수 있다.
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
이 쿼리는 매번 orders 테이블에서 사용자별로 주문 총액을 계산한다. 이 방식은 데이터가 많을 경우 쿼리 실행 시마다 많은 계산이 필요하므로 성능이 저하될 수 있는 요소가 된다.
ALTER TABLE users ADD total_order_amount DECIMAL(10, 2);
UPDATE users u
SET total_order_amount = (
SELECT SUM(amount)
FROM orders o
WHERE o.user_id = u.id
);
위 쿼리는 users 테이블에 추가 칼럼을 설정했는데, 각 사용자의 주문 금액을 미리 계산해서 이 칼럼에 저장해 둔다.
그렇다면 이제 필요할 때마다 orders 테이블을 전체조회하여 SUM함수를 실행할 필요 없이, users 테이블의 요약 칼럼만 조회해서 추가 주문이 생겼다면 update로 구성할 수 있다.
이런 방식을 사용한다면 users에 연관되어 있는 요소들도 곧바로 알아볼 수 있다는 장점도 있어 복잡성도 사라지며, 성능 또한 높아진다.
TIP19. Materialized Views를 사용해라.
여기서 말하는 물리적 뷰란 위에서 계속 얘기한 대로 복잡한 쿼리의 결과를 미리 계산한 것을 데이터베이스에 캐싱해 두는 개념이다.
일반적인 View와는 다른데, 물리적 뷰는 쿼리의 결과를 실제 테이블처럼 저장하여 읽기 전용작업에 사용된다.
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
위 쿼리는 orders 테이블에서 매번 사용자의 주문수(orders_count)와 주문 총액(total_amount)을 계산한다.
대규모 데이터일수록 이러한 연산은 지속적으로 쓰이지만 계속해서 수행되면서 성능상 저하가 될 요소가 많다.
이때는 MATERIALIZED VIEW를 생성할 수 있는데 다음과 같이 사용한다.
CREATE MATERIALIZED VIEW user_order_summary AS
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
사용자별 주문 정보를 미리 계산해 저장해 둔다. 따라서 user_order_summary라는 물리적 뷰는 하나의 테이블처럼 사용할 수 있으며, 위처럼 계속 SELECT 대신 user_order_summary에 접근하여 사용할 수 있다. 일반적인 VIEW는 쿼리를 실행할 때마다 계산되지만 MATERIALIZED VIEW는 쿼리의 결과를 저장하는 것이다. 따라서 갱신이 필요한데 이는 REFRESH 명령어를 통해 다음과 같이 갱신할 수 있다.
REFRESH MATERIALIZED VIEW user_order_summary;
마지막으로
각 복잡한 쿼리가 있다면 계속적으로 쿼리 리뷰를 진행하고 리팩토링 하라고 말하고 있다.
복잡한 쿼리는 분해해서 사용해 보고, 좀 더 나은 방법으로 쿼리를 작성할 수 없을지 꾸준히 생각하고 확인해 보는 것이 좋은 것 같다.
'DB' 카테고리의 다른 글
[DB] Redis Cluster Docker Compose로 구축하기 (4) | 2024.10.22 |
---|---|
[DB] Redis Cluster (5) | 2024.10.19 |
[DataBase] 트랜잭션에 대해서 얼마나 알고있는가? (1) | 2024.10.11 |