MongoDB Index 설계 전략
by Reid
Index는 왜 중요한가
인터넷에는 셀 수 없이 많은 정보들이 있습니다. 2020년이면 인터넷의 정보량이 40제타바이트에 이를 것이라고 합니다. 1 제타바이트는 1021, 그러니까 1,000,000,000,000,000,000,000 byte이니 상상도 할 수 없을 정도의 양입니다. 하지만, 우리가 원하는 정보를 찾을 때는 어떻습니까? 검색어 몇 번 입력하면 꽤 높은 확률로 필요한 정보를 얻을 수 있습니다. 무엇이 이를 가능하게 하는 걸까요?
인터넷이 없던 시절로 잠깐 돌아가 봅시다. 우리가 얻는 정보의 원천은 집 책장에 가지런히 꽂혀 있던 ‘총천연색컬러대백과사전’이었습니다. 궁금한 것이 있을 때면 책장에서 주제에 맞는 책 한 권을 꺼내서 맨 뒷 장을 펼칩니다. 그곳에는 어김없이 ㄱㄴㄷ순의 단어들이 나열되어 있는 ‘색인’이 있습니다. ㄱㄴㄷ을 따라 쭉 내려가다 보면 원하는 단어와 함께 자세한 내용이 담긴 페이지를 찾게 됩니다. 인터넷 검색보다는 한참 느리지만, 수백장의 백과 사전에서 원하는 정보를 찾아내는 것 치고는 꽤 빠른 검색 방법이었습니다.
우리말 ‘색인’은 영어로 ‘index’입니다. 데이터베이스의 인덱스는 그저 원하는 정보를 빨리 찾을 수 있도록 돕는 색인의 역할을 합니다. 백과 사전에서 ㄱㄴㄷ을 따라 검색을 했듯이 인덱스도 순서가 있는 무언가로 정렬되어 있습니다. 작은 숫자에서 큰 숫자로, 혹은 알파벳 순서대로, 정방향으로, 역방향으로 말입니다. 왜 정렬을 해둔 걸까요? 우리가 정렬된 색인으로 ‘선생님’을 찾을 때 ‘ㅅ’, ‘ㅓ’, ‘ㄴ’을 순서대로 쉽게 찾을 수 있는 것 처럼 데이터베이스도 정렬된 인덱스 덕분에 최적의 알고리즘을 이용해 검색을 할 수 있습니다.
그렇다면 인덱스가 없는 데이터베이스를 상상해 볼까요? 정렬되지 않은 채 마구잡이로 작성된, 색인마저 없는 백과사전은 어떻습니까. 우리가 할 수 있는 일은 원하는 단어를 찾을 때까지 첫 장부터 한 단어씩 찾는 것 입니다. 만약 맨 마지막 페이지에 찾고자 하는 단어가 있다면 얼마나 오랫동안 찾고 있어야 할까요?
MongoDB의 인덱스를 잘 작성하는 법을 이야기하기 전에 꼭 기억해야 할 것이 있습니다. 그것은 바로 항상 상상하라는 것입니다. 내가 가진 수많은 데이터를 효과적으로 검색하려면 어떤 키들을 어떤 순서로 정렬해두어야 할지를 언제나 고민해야 합니다. 마치 백과사전의 색인을 스스로 작성하는 것 처럼 말입니다.
기본 전략
앞선 이야기를 이어가봅시다. 영어 단어장을 만들고 있습니다. 나중에 찾고자 하는 영단어를 쉽게 검색하기 위해 색인을 만들어야 합니다. 고민 끝에 ‘동사’와 ‘명사’로 나누어 색인을 만들기로 했습니다. 총 1,000자의 단어 중 동사가 300자, 명사가 700자였습니다. ‘teacher’를 검색하기 위해 700자의 명사를 처음부터 하나씩 찾기 시작합니다.
무엇이 문제일까요? 색인이 너무 큰 범위로 만들어졌습니다. 이를 데이터베이스 용어로 ‘Selectivity’가 떨어진다고 이야기합니다. 효과적인 인덱스 작성 전략을 위해 반드시 고려해야 하는 것이 바로 이 ‘Selectivity’를 높이는 것입니다. 보다 정확하게 검색 할 수 있도록 좁은 범위를 갖는 색인을 만들어야 합니다.
‘읽기’와 ‘쓰기’중 어떤 작업을 주로 하는가도 잘 파악해야 합니다. 내가 만든 단어장은 아직 미완성이어서 계속 새로운 단어를 추가하고 있습니다. 이미 색인을 만들어두었기 때문에 매번 알파벳 순서로 추가 할 위치를 찾고 그 사이에 새로운 단어를 추가하는 작업을 해야 합니다. 따라서 쓰기 작업이 읽기 작업에 비해 많은 데이터베이스는 인덱스를 복잡하게 설정하면 오히려 나쁜 성능을 내는 경우가 있습니다.
마지막으로 사용 할 수 있는 메모리 크기를 고려해야 합니다. 인덱스는 실제 데이터와 별개의 메모리 공간에 저장을 하게 됩니다. 따라서 인덱스를 많이 만들다 보면 그만큼 많은 메모리를 사용하게 됩니다. 데이터베이스가 정상적으로 동작하기 위해서는 그 외에도 작업 셋working set이라는 데이터 구조도 메모리를 점유하게 됩니다. 따라서 메모리가 부족하여 문제가 발생하지 않도록 항상 주의를 기울여야 합니다.
이번 섹션의 내용을 정리해보겠습니다.
- 가급적 촘촘하게 인덱스를 작성해서 selectivity를 높입니다.
- 쓰기 작업이 많은 데이터셋은 인덱스를 복잡하게 설계하지 않습니다.
- 메모리를 충분히 확보하고 항상 관찰해야 합니다.
그보다 중요한 건 테스트입니다
기본 전략을 숙지하면 보다 나은 방식으로 작성 할 수 있지만, 항상 원하는대로 동작하는 것은 아닙니다. 이후에 설명하겠지만, 인덱스에는 여러 종류가 있고 각각의 상황에 적합한 것들이 있습니다. MongoDB는 쿼리를 수행 할 때 어떤 방식으로 검색을 할 지 스스로 계획을 세우고, 옳은 방식으로 인덱스를 활용합니다. 그러므로 적합하지 않은 인덱스가 지정되어 있을 때는 스스로 그것을 사용하지 않기도 합니다. 이와 같은 불필요한 인덱스는 제거를 해주어야 합니다.
이를 위해 MongoDB는 현재 수행 중인 쿼리가 어떤 계획을 세우는지, 어떤 인덱스를 사용하는지, 얼마나 오랜 시간 검색을 수행하는지 등을 보여주는 방법들을 제공합니다. 좋은 인덱스 전략을 세우는 가장 중요한 요소는 반복적인 테스트입니다. 데이터베이스를 조회하는 여러 쿼리들이 어떤 인덱스를 사용 할 때 가장 좋은 효과를 내는지는 테스트를 통해서 가장 정확하게 알아낼 수 있습니다.
인덱스 검사를 위해 주로 사용하는 메소드에는 hint()
와 explain()
이 있습니다. 이런 메소드를 이용해 최적의 인덱스 전략을 세우는 방법은 뒤에 다시 설명하겠습니다.
Single-Key Index
쿼리에서 단 하나의 키key만을 이용한다면 단일 키 인덱스를 사용해야 합니다.
db.products.createIndex({ category: 1 })
카테고리 필드를 키로 인덱스를 만듭니다. 여기서 1
은 오름차순, -1
은 내림차순을 의미합니다. 자, 여기서 상상을 해봅시다. 많은 제품 정보가 들어있는 컬렉션 옆에 배열로 만들어진 또 다른 데이터 구조가 있다고 가정해봅시다. 이 데이터 구조는 카테고리가 작은 숫자부터 큰 숫자로 일렬로 나열되어 있습니다. 그러므로 백과사전의 색인처럼 빠르게 원하는 카테고리를 찾아낼 수 있습니다. 정렬되어 있기 때문에 심지어 범위로 검색하는 것도 수월합니다.
일렬로 나열되어 있기 때문에 찾는 순서는 중요하지 않습니다. 왼쪽에서 오른쪽으로 읽든, 오른쪽에서 왼쪽으로 읽든 어차피 동일하기 때문입니다. 실제로 단일 인덱스에서 오름차순으로 정의 된 인덱스의 컬렉션을 내림차순으로 검색해도 동일한 성능을 냅니다.
Compound Index
다음에 소개 할 인덱스는 복합compound 인덱스입니다. 검색에 여러 키가 사용된다면 이 인덱스 타입으로 정의해야 합니다.
db.students.createIndex({ userid: 1, score: -1 })
컴파운드 인덱스가 여러 키를 지정 할 수 있다고 해서 각각 다른 쿼리에 각각의 키로 검색 할 수 있다고 생각하면 안됩니다. 컴파운드 인덱스를 제대로 이해하기 위해서는 이 부분을 주의 깊게 살피고 이해해야 합니다. 위의 쿼리에서 학생 컬렉션의 인덱스로 userid
와 score
가 별개의 데이터 구조를 가지고 있는 것이 아닙니다. 마치 단일 인덱스처럼 일렬로 나열 된 userid
배열의 각 아이템에 score
를 담고 있는 것으로 생각 할 수 있습니다.
이 그림에서처럼 두 인덱스가 함께 저장되어 있으며, 첫번째 userid
는 원하는대로 오름차순의 순서대로 예쁘게 정렬되어 있지만 score
는 그렇지 않습니다. 하지만 자세히 들여다보면 동일한 userid
들끼리는 그 안에서 score
가 내림차순으로 다시 정렬되어 있는 것을 볼 수 있습니다.
상상을 해봅시다. 키로 score
를 지정해서 검색을 하면 좋은 효과를 볼 수 있을까요? 그림에서처럼 score
는 동일한 userid
안에서만 정렬이 되어 있으므로 단독으로 검색을 할 때에는 효율이 좋을 수가 없습니다. 그런 경우에는 score
를 위한 인덱스를 따로 만들어주는 것이 성능에 이점이 있습니다.
컴파운드 인덱스의 핵심이 여기 있습니다. 컴파운드 인덱스로 지정한 각각의 필드는 그 순서대로 쿼리문에 나타나야 합니다. userid
만을 이용해 검색하는 것은 옳은 방법입니다. 그리고, userid
와 함께 score
를 조합해서 검색하는 것도 옳은 방법입니다. 하지만 score
만 단독으로 검색을 하는 것을 옳지 않은 방법입니다.
정렬도 인덱스 순서가 중요합니다
쿼리를 할 때의 순서와 마찬가지로 정렬을 할 때에도 순서는 중요합니다. 만약 { a: 1, b: 1}
의 형태로 정의된 인덱스가 있다면 검색을 할 때에도 그 순서가 지켜져야 합니다.
db.collection.createIndex({ a: 1, b: 1 })
// 성능이 좋습니다
db.collection.find().sort({ a: 1, b: 1 })
// 성능이 좋지 않습니다
db.collection.find().sort({ b: 1, a: 1 })
한편 오름차순인 1과 내림차순인 -1의 정렬 순서도 세심하게 설정해야 합니다.
db.collection.createIndex({ a: 1, b: -1 })
// 성능이 좋습니다
db.collection.find().sort({ a: 1, b: -1 })
db.collection.find().sort({ a: -1, b: 1 })
// 성능이 좋지 않습니다
db.collection.find().sort({ a: 1, b: 1 })
db.collection.find().sort({ a: -1, b: -1 })
위의 차이도 상상을 하며 이해하면 좋습니다. 단일 인덱스가 오름차순, 내림차순의 구분 없이 좋은 인덱스 효과를 낼 수 있었던 것은 일렬로 나열된 데이터 구조 덕분이었습니다. 마찬가지로 컴파운드 인덱스 역시 구성하는 키들의 방향은 서로 다르더라도 데이터 구조는 일렬로 나열되어 있습니다. 따라서 각 키의 방향 조합만 제대로 유지해주면 단 한번의 스캔으로 검색이 가능하기 때문에 인덱스의 효과를 누릴 수가 있습니다.
인덱스 Prefix
MongoDB의 인덱스 관련 매뉴얼을 읽다 보면 자주 나오는 용어 중에 ‘Index Prefix’라는 말이 있습니다. 영어와 한글의 차이로 인해 바로 와닿지 않는 용어들이 종종 있는데 바로 이런 용어야말로 그렇습니다. 어떻게 번역해야 이 개념을 잘 설명 할 수 있을까요? 선행 인덱스? 앞쪽 인덱스? 일단 코드 예제로 살펴봅시다.
db.collection.createIndex({ x: 1, y: 1, z: 1 })
{ x: 1 } // OK
{ x: 1, y: 1 } // OK
{ x: 1, y: 1, z: 1 } // OK
정의 된 인덱스의 앞 쪽부터 포함하는 부분집합의 인덱스들을 ‘인덱스 Prefix’라고 일컫습니다. 다시 한번 상상의 시간이 왔습니다. 그림으로 살펴 본 컴파운드 인덱스의 데이터 구조를 아직 기억하시죠? 첫 번째 인덱스는 단일 인덱스처럼 일렬로 나열되어 있습니다. 두 번째 인덱스는 동일한 첫 번째 인덱스들 안에서 일렬로 나열되어 있습니다. 세 번째 인덱스는 동일한 두 번째 인덱스들 안에서 일렬로 나열되어 있습니다. 그려지시나요? 첫 번째 인덱스부터 순서대로 그룹 지어진 전체 인덱스의 부분 집합은 이런 구조로 인해 인덱스의 성능을 최대로 발휘 할 수 있게 됩니다. 바로 이런 인덱스들을 ‘인덱스 Prefix’라고 합니다.
예제 쿼리 | 인덱스 Prefix |
---|---|
db.data.find().sort({ a: 1 }) | { a: 1 } |
db.data.find().sort({ a: -1 }) | { a: 1 } |
db.data.find().sort({ a: 1, b: 1 }) | { a: 1, b: 1 } |
db.data.find().sort({ a: -1, b: -1 }) | { a: 1, b: 1 } |
db.data.find().sort({ a: 1, b: 1, c: 1 }) | { a: 1, b: 1, c: 1 } |
db.data.find({ a: { $gt: 4 }}).sort({ a: 1, b: 1 }) | { a: 1, b: 1 } |
이렇게 쿼리의 정렬 조건에는 인덱스 Prefix를 사용해야 합니다.
정렬 조건이 prefix가 아니라면?
그렇다면 prefix가 아닌 정렬 조건이 사용 될 수 있을까요? 가능합니다만, 한 가지 조건이 충족되어야 합니다. 정렬 조건이 prefix가 아닌 경우 검색 조건이 Equality 상태여야 합니다. 또 애매한 단어가 나왔지만, 이것 역시 이해하기 어렵지 않습니다.
쿼리의 검색 조건과 정렬 조건을 하나의 인덱스 Prefix처럼 일렬로 나열해봅시다. 이 상황에서 검색 조건의 키들은 특정 값과 =
상태여야 합니다. 동일 비교가 아닌 다른 비교 상태면 이 조건을 만족하지 못합니다.
아래는 인덱스를 올바르게 사용하는 예제들입니다.
예제 쿼리 | 인덱스 Prefix |
---|---|
db.data.find({ a: 5 }).sort({ b: 1, c: 1 }) | { a: 1 , b: 1, c: 1 } |
db.data.find({ b: 3, a: 4 }).sort({ c: 1 }) | { a: 1, b: 1, c: 1 } |
db.data.find({ a: 5, b: { $lt: 3}}).sort({ b: 1 } | { a: 1, b: 1 } |
반면, 다음의 쿼리들은 검색 조건이 검색 조건 인덱스의 선행 키도 아니고, equality 상태 역시 만족하지 못하기 때문에 인덱스가 효과적으로 적용되지 않습니다.
예제 쿼리 |
---|
db.data.find({ a: { $gt: 2 }}).sort({ c: 1 }) |
db.data.find({ c: 5 }).sort({ c: 1 }) |
인덱스 교차intersection
마지막으로 소개해 드릴 인덱스는 MongoDB 2.6부터 제공하는 인덱스 교차입니다. 이 용어 는 인덱스의 종류가 아니라 인덱스의 작동 방식을 지칭합니다. 지금까지 우리가 알아본 ‘단일 인덱스’와 ‘컴파운드 인덱스’를 하나의 컬렉션 내에서 별개로 지정하더라도 쿼리가 구동 될 때에는 내부에서 교집합처럼 동작하여 성능을 높입니다.
{ qty: 1 }
{ item: 1 }
단일 인덱스가 별개로 두 개를 정의했지만,
db.orders.find({ item: 'abc123', qty: { $gt: 15 }})
위의 쿼리는 인덱스 교차가 적용되어 인덱스가 활용됩니다. 다만, 인덱스 교차는 명시적으로 선언해서 사용하는 것이 아니기 때문에 반드시 정상 동작하는지를 explain()
메소드를 통해 확인해야 합니다. 만약 인덱스 교차가 동작하고 있다면 결과 문서에 AND_SORTED
나 AND_HASH
스테이지가 발견 될 것입니다.
그럼 컴파운드 인덱스와 인덱스 교차, 둘 중 무엇을 써야 할까요? 각각의 방식에는 조건과 한계가 존재합니다.
컴파운드 인덱스는 지금까지 살펴보았듯이 정렬을 할 때 선언하는 키의 순서와 각 키의 정렬 방향이 중요합니다. 또한, 정렬 순서는 인덱스 Prefix 규칙을 따라야 합니다.
반면 인덱스 교차는 그런 문제에서 자유롭습니다. 하지만, 쿼리의 검색 조건에 사용한 인덱스와 별개로 선언된 인덱스를 정렬 조건으로 사용 할 수 없습니다. 그리고 대체로 컴파운드 인덱스에 비해 성능이 느립니다.
다음과 같이 단일 인덱스들과 컴파운드 인덱스가 따로 정의되어 있습니다.
{ qty: 1 }
{ status: 1, ord_date: -1 }
{ status: 1 }
{ ord_date: -1 }
이 상황에서 아래의 쿼리는 인덱스 교차가 불가능합니다.
db.orders.find({ qty: { $gt: 10 }}).sort({ status: 1 })
그 이유는 검색 조건의 qty
는 정렬 조건의 status
와 별개의 인덱스로 작성되었기 때문입니다. 하지만 아래의 쿼리는 컴파운드 인덱스와 교차되어 정상 동작합니다.
db.orders.find({ qty: { $gt: 10 }, status: "A" }).sort({ ord_date: -1 })
또 다른 예제를 살펴봅시다.
db.orders.createIndex({ status: 1, ord_date: -1 })
위와 같이 컴파운드 인덱스가 정의되어 있을 때, 다음 쿼리는 인덱스의 효과를 누릴 수가 있습니다.
db.orders.find({ status: { $in: ["A", "P" ] }})
db.orders.find(
{
ord_date: { $gt: new Date("2014-02-01") },
status: { $in:[ "P", "A" ] }
}
)
하지만 아래의 쿼리는 선행 인덱스가 정의되어야 한다는 규칙에 위배되기 때문에 인덱스가 동작하지 않습니다.
db.orders.find({ ord_date: { $gt: new Date("2014-02-01") }})
db.orders.find({}).sort({ ord_date: 1 })
그러나 만약 인덱스가 다음과 같이 정의되어 있다면, 인덱스 교차가 발동되어 모든 쿼리가 인덱스의 효과를 얻어 성능이 향상됩니다.
{ status: 1 }
{ ord_date: -1 }
인덱스 성능 향상시키기
앞서 최고의 인덱스 전략은 반복 테스트라고 말씀을 드렸습니다. 이론적으로 타당하더라도 실제로 포함하고 있는 데이터들의 성격으로 인해, 인덱스는 바르게 사용되기도 하고 문제를 일으키기도 합니다. 자신의 설계가 맞는지 여부를 지속적으로 점검하고 개선해야 합니다.
$indexStats
인덱스의 사용 통계를 살펴보기 위해서 다음과 같은 쿼리를 던질 수 있습니다.
db.orders.createIndex({ item: 1, quantity: 1 })
db.orders.createIndex({ type: 1, item: 1 })
db.orders.find({ type: "apparel"})
db.orders.find({ item: "abc" }).sort({ quantity: 1 })
db.orders.aggregate([{ $indexStats: {} }])
이에 대한 결과 문서는 다음과 같습니다.
{
"name" : "item_1_quantity_1",
"key" : {
"item" : 1,
"quantity" : 1
},
"host" : "examplehost.local:27017",
"accesses" : {
"ops" : NumberLong(1),
"since" : ISODate("2015-10-02T14:31:53.685Z")
}
}
{
"name" : "_id_",
"key" : {
"_id" : 1
},
"host" : "examplehost.local:27017",
"accesses" : {
"ops" : NumberLong(0),
"since" : ISODate("2015-10-02T14:31:32.479Z")
}
}
{
"name" : "type_1_item_1",
"key" : {
"type" : 1,
"item" : 1
},
"host" : "examplehost.local:27017",
"accesses" : {
"ops" : NumberLong(1),
"since" : ISODate("2015-10-02T14:31:58.321Z")
}
}
_id_
키의 ops
는 0이므로 전혀 사용되지 않았습니다. 하지만, item_1_quantity_1
키와 type_1_item_1
키의 컴파운드 인덱스는 1번씩 사용된 것을 볼 수 있습니다.
explain()
이번에는 특정 쿼리가 어떻게 수행되는지를 알아봅니다. explain()
메소드는 단어의 뜻 그대로 쿼리의 수행 내역을 설명해줍니다. 이 때 어떤 모드로 수행 할 것인지를 인자로 입력 할 수 있습니다.
executionStats 모드
- 인덱스 사용 여부
- 스캔한 문서들의 수
- 쿼리의 수행 시간
allPlansExecution 모드
- 쿼리 계획을 선택하는데 필요한 부분적인 실행 통계
- 쿼리 계획을 선택하게 된 이유
메소드의 결과는 다음과 같은 형태로 제공됩니다.
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.mycollection",
"indexFilterSet" : false,
"parsedQuery" : {
},
"winningPlan" : {
"stage" : "COLLSCAN",
"direction" : "forward"
},
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 2,
"executionTimeMillis" : 4,
"totalKeysExamined" : 0,
"totalDocsExamined" : 2,
"executionStages" : {
"stage" : "COLLSCAN",
"nReturned" : 2,
"executionTimeMillisEstimate" : 0,
"works" : 4,
"advanced" : 2,
"needTime" : 1,
"needYield" : 0,
"saveState" : 0,
"restoreState" : 0,
"isEOF" : 1,
"invalidates" : 0,
"direction" : "forward",
"docsExamined" : 2
}
},
"serverInfo" : {
"host" : "Reidui-MacBookPro.local",
"port" : 27017,
"version" : "4.0.4",
"gitVersion" : "f288a3bdf201007f3693c58e140056adf8b04839"
},
"ok" : 1
}
explain()
메소드가 반환하는 문서의 내용은 꽤 복잡합니다. 확실하게 이해하기 위해서는 MongoDB의 Query Optimization / Explain Results 매뉴얼을 읽어 보는 것이 좋습니다.
hint()
작성한 쿼리의 인덱스를 테스트 하니 크게 효과가 없어 보인다면 어떻게 해야 할까요? 다른 필드에 인덱스를 걸어 보거나 혹은 아예 인덱스를 제거해서 테스트 해보고 싶을지도 모릅니다. 이럴 때 사용할 수 있는 메소드가 hint()
입니다.
다음은 find
쿼리에 zipcode
필드를 키로 하여 인덱스를 걸고, executionStats
모드의 결과를 받아봅니다.
db.people.find(
{ name: "John Doe", zipcode: { $gt: "63000" } }
).hint({ zipcode: 1 }).explain("executionStats")
만약 인덱스가 없는 상태에서의 테스트가 필요하다면 다음과 같이 $natural
옵션을 넣어서 테스트 할 수 있습니다.
db.people.find(
{ name: "John Doe", zipcode: { $gt: "63000" } }
).hint({ $natural: 1 })
마치며
지금까지 MongoDB의 인덱스 전략을 알아보았습니다. 정리하자면, 기본적인 인덱스의 종류와 동작 방식을 이해하는게 우선입니다. 하지만 그 후에는 상상력과 테스트의 싸움입니다. 인덱스의 데이터 구조를 떠올리며 머릿속으로 시뮬레이션을 해보고, 테스트 메소드를 실행하여 예측이 맞았는지를 확인합니다. 프로덕션 서비스를 시작하기 전에는 더미 데이터를 잔뜩 넣고, 실제와 최대한 비슷한 환경으로 만들어서 테스트를 해보는 것도 꼭 필요합니다.
잘 작성된 인덱스의 진가는 데이터베이스의 자료가 많아질수록 크게 발휘됩니다. 이 글이 여러분의 데이터베이스가 최고의 성능을 내는데 도움이 되었으면 좋겠습니다.
Subscribe via RSS